Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions content/copilot/tutorials/upgrade-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ category:
- Author and optimize with Copilot
---

> [!NOTE]
> "GitHub Copilot app modernization – upgrade for Java" and "GitHub Copilot app modernization – Upgrade for .NET" are currently in {% data variables.release-phases.public_preview %} and subject to change.
## Introduction

{% data variables.product.prodname_copilot %} can help streamline the process of modernizing and upgrading your Java and .NET applications. {% data variables.product.prodname_copilot_short %} will analyze the project, generate a plan, automatically fix issues it encounters when carrying out the plan, and produce a summary.
Expand All @@ -32,7 +29,7 @@ You can upgrade a Git-based Maven or Gradle Java project using {% data variables
* For Maven-based projects, access to the public Maven Central repository.
* Installed versions of both the source and target JDKs.

For the next steps, see [Quickstart: upgrade a Java project with GitHub Copilot App Modernization - upgrade for Java (preview)](https://learn.microsoft.com/en-gb/java/upgrade/quickstart-upgrade) on Microsoft Learn.
For the next steps, see [Quickstart: upgrade a Java project with GitHub Copilot App Modernization - upgrade for Java](https://learn.microsoft.com/en-gb/java/upgrade/quickstart-upgrade) on Microsoft Learn.

## Upgrading .NET projects

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The `failed to connect to host` error occurs when {% data variables.product.comp

To check whether a host name resolves to an IP address, you can use `nslookup`. For example, if your payload URL is `https://octodex.github.com/webhooks`, you can run `nslookup octodex.github.com`. If the host name could not be resolved to an IP address, the nslookup command will indicate that the server can't find the host name.

You should make sure that your server allows connections from {% data variables.product.company_short %}'s IP addresses. You can use the `GET /meta` endpoint to find the current list of {% data variables.product.company_short %}'s IP addresses. See [AUTOTITLE](/rest/meta/meta#get-github-meta-information). Ensure connectivity is allowed from the IP addresses listed in the `hooks` section. {% data variables.product.company_short %} occasionally makes changes to its IP addresses, so you should update your IP allow list periodically.
You should make sure that your server allows connections from {% data variables.product.company_short %}'s IP addresses. You can use the `GET /meta` endpoint to find the current list of {% data variables.product.company_short %}'s IP addresses. For more information, see [AUTOTITLE](/rest/meta/meta#get-github-meta-information). {% data variables.product.company_short %} occasionally makes changes to its IP addresses, so you should update your IP allow list periodically.

## Failed to connect to network

Expand Down
25 changes: 25 additions & 0 deletions src/ai-tools/lib/auth-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { execSync } from 'child_process'

/**
* Ensure GitHub token is available, exiting process if not found
*/
export function ensureGitHubToken(): void {
if (!process.env.GITHUB_TOKEN) {
try {
const token = execSync('gh auth token', { encoding: 'utf8' }).trim()
if (token) {
process.env.GITHUB_TOKEN = token
return
}
} catch {
// gh CLI not available or not authenticated
}

console.warn(`🔑 A token is needed to run this script. Please do one of the following and try again:

1. Add a GITHUB_TOKEN to a local .env file.
2. Install https://cli.github.com and authenticate via 'gh auth login'.
`)
process.exit(1)
}
}
10 changes: 6 additions & 4 deletions src/ai-tools/lib/call-models-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
const API_TIMEOUT_MS = 180000 // 3 minutes
const DEFAULT_MODEL = 'openai/gpt-4o'

interface ChatMessage {
role: string
Expand Down Expand Up @@ -42,16 +44,16 @@ export async function callModelsApi(

// Set default model if none specified
if (!promptWithContent.model) {
promptWithContent.model = 'openai/gpt-4o'
promptWithContent.model = DEFAULT_MODEL
if (verbose) {
console.log('⚠️ No model specified, using default: openai/gpt-4o')
console.log(`⚠️ No model specified, using default: ${DEFAULT_MODEL}`)
}
}

try {
// Create an AbortController for timeout handling
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS)

const startTime = Date.now()
if (verbose) {
Expand Down Expand Up @@ -123,7 +125,7 @@ export async function callModelsApi(
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error('API call timed out after 3 minutes')
throw new Error(`API call timed out after ${API_TIMEOUT_MS / 1000} seconds`)
}
console.error('Error calling GitHub Models REST API:', error.message)
}
Expand Down
161 changes: 161 additions & 0 deletions src/ai-tools/lib/file-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import fs from 'fs'
import path from 'path'
import yaml from 'js-yaml'
import readFrontmatter from '@/frame/lib/read-frontmatter'
import { schema } from '@/frame/lib/frontmatter'

const MAX_DIRECTORY_DEPTH = 20

/**
* Enhanced recursive markdown file finder with symlink, depth, and root path checks
*/
export function findMarkdownFiles(
dir: string,
rootDir: string,
depth: number = 0,
maxDepth: number = MAX_DIRECTORY_DEPTH,
visited: Set<string> = new Set(),
): string[] {
const markdownFiles: string[] = []
let realDir: string
try {
realDir = fs.realpathSync(dir)
} catch {
// If we can't resolve real path, skip this directory
return []
}
// Prevent escaping root directory
if (!realDir.startsWith(rootDir)) {
return []
}
// Prevent symlink loops
if (visited.has(realDir)) {
return []
}
visited.add(realDir)
// Prevent excessive depth
if (depth > maxDepth) {
return []
}
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(realDir, { withFileTypes: true })
} catch {
// If we can't read directory, skip
return []
}
for (const entry of entries) {
const fullPath = path.join(realDir, entry.name)
let realFullPath: string
try {
realFullPath = fs.realpathSync(fullPath)
} catch {
continue
}
// Prevent escaping root directory for files
if (!realFullPath.startsWith(rootDir)) {
continue
}
if (entry.isDirectory()) {
markdownFiles.push(...findMarkdownFiles(realFullPath, rootDir, depth + 1, maxDepth, visited))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
markdownFiles.push(realFullPath)
}
}
return markdownFiles
}

interface FrontmatterProperties {
intro?: string
[key: string]: unknown
}

/**
* Function to merge new frontmatter properties into existing file while preserving formatting.
* Uses surgical replacement to only modify the specific field(s) being updated,
* preserving all original YAML formatting for unchanged fields.
*/
export function mergeFrontmatterProperties(filePath: string, newPropertiesYaml: string): string {
const content = fs.readFileSync(filePath, 'utf8')
const parsed = readFrontmatter(content)

if (parsed.errors && parsed.errors.length > 0) {
throw new Error(
`Failed to parse frontmatter: ${parsed.errors.map((e) => e.message).join(', ')}`,
)
}

if (!parsed.content) {
throw new Error('Failed to parse content from file')
}

try {
// Clean up the AI response - remove markdown code blocks if present
let cleanedYaml = newPropertiesYaml.trim()
cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '')
cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '')
cleanedYaml = cleanedYaml.trim()

const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties

// Security: Validate against prototype pollution using the official frontmatter schema
const allowedKeys = Object.keys(schema.properties)

const sanitizedProperties = Object.fromEntries(
Object.entries(newProperties).filter(([key]) => {
if (allowedKeys.includes(key)) {
return true
}
console.warn(`Filtered out potentially unsafe frontmatter key: ${key}`)
return false
}),
)

// Split content into lines for surgical replacement
const lines = content.split('\n')
let inFrontmatter = false
let frontmatterEndIndex = -1

// Find frontmatter boundaries
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '---') {
if (!inFrontmatter) {
inFrontmatter = true
} else {
frontmatterEndIndex = i
break
}
}
}

// Replace each field value while preserving everything else
for (const [key, value] of Object.entries(sanitizedProperties)) {
const formattedValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value

// Find the line with this field
for (let i = 1; i < frontmatterEndIndex; i++) {
const line = lines[i]
if (line.startsWith(`${key}:`)) {
// Simple replacement: keep the field name and spacing, replace the value
const colonIndex = line.indexOf(':')
const leadingSpace = line.substring(colonIndex + 1, colonIndex + 2) // Usually a space
lines[i] = `${key}:${leadingSpace}${formattedValue}`

// Remove any continuation lines (multi-line values)
const j = i + 1
while (j < frontmatterEndIndex && lines[j].startsWith(' ')) {
lines.splice(j, 1)
frontmatterEndIndex--
}
break
}
}
}

return lines.join('\n')
} catch (error) {
console.error('Failed to parse AI response as YAML:')
console.error('Raw AI response:', JSON.stringify(newPropertiesYaml))
throw new Error(`Failed to parse new frontmatter properties: ${error}`)
}
}
112 changes: 112 additions & 0 deletions src/ai-tools/lib/prompt-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { fileURLToPath } from 'url'
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'
import { callModelsApi } from '@/ai-tools/lib/call-models-api'

export interface PromptMessage {
content: string
role: string
}

export interface PromptData {
messages: PromptMessage[]
model?: string
temperature?: number
max_tokens?: number
}

/**
* Get the prompts directory path
*/
export function getPromptsDir(): string {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
return path.join(__dirname, '../prompts')
}

/**
* Dynamically discover available editor types from prompt files
*/
export function getAvailableEditorTypes(promptDir: string): string[] {
const editorTypes: string[] = []

try {
const promptFiles = fs.readdirSync(promptDir)
for (const file of promptFiles) {
if (file.endsWith('.md')) {
const editorName = path.basename(file, '.md')
editorTypes.push(editorName)
}
}
} catch {
console.warn('Could not read prompts directory, using empty editor types')
}

return editorTypes
}

/**
* Get formatted description of available refinement types
*/
export function getRefinementDescriptions(editorTypes: string[]): string {
return editorTypes.join(', ')
}

/**
* Call an editor with the given content and options
*/
export async function callEditor(
editorType: string,
content: string,
promptDir: string,
writeMode: boolean,
verbose = false,
promptContent?: string, // Optional: use this instead of reading from file
): Promise<string> {
let markdownPrompt: string

if (promptContent) {
// Use provided prompt content (e.g., from Copilot Space)
markdownPrompt = promptContent
} else {
// Read from file
const markdownPromptPath = path.join(promptDir, `${editorType}.md`)

if (!fs.existsSync(markdownPromptPath)) {
throw new Error(`Prompt file not found: ${markdownPromptPath}`)
}

markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8')
}
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')

const prompt = yaml.load(fs.readFileSync(promptTemplatePath, 'utf8')) as PromptData

// Validate the prompt template has required properties
if (!prompt.messages || !Array.isArray(prompt.messages)) {
throw new Error('Invalid prompt template: missing or invalid messages array')
}

for (const msg of prompt.messages) {
msg.content = msg.content.replace('{{markdownPrompt}}', markdownPrompt)
msg.content = msg.content.replace('{{input}}', content)
// Replace writeMode template variable with simple string replacement
msg.content = msg.content.replace(
/<!-- IF_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_START -->',
)
msg.content = msg.content.replace(
/<!-- ELSE_WRITE_MODE -->/g,
writeMode ? '<!-- REMOVE_START -->' : '',
)
msg.content = msg.content.replace(
/<!-- END_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_END -->',
)

// Remove sections marked for removal
msg.content = msg.content.replace(/<!-- REMOVE_START -->[\s\S]*?<!-- REMOVE_END -->/g, '')
}

return callModelsApi(prompt, verbose)
}
Loading
Loading