From b8a6fb39ec0d409c09aadcd862e3f360abffc10e Mon Sep 17 00:00:00 2001 From: Gio Fernandez Date: Thu, 11 Dec 2025 16:24:25 -0500 Subject: [PATCH 1/3] Update troubleshooting steps for webhook connectivity (#58811) Co-authored-by: Vanessa --- .../troubleshooting-webhooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/webhooks/testing-and-troubleshooting-webhooks/troubleshooting-webhooks.md b/content/webhooks/testing-and-troubleshooting-webhooks/troubleshooting-webhooks.md index 21ca00b20f5a..db26d222ec92 100644 --- a/content/webhooks/testing-and-troubleshooting-webhooks/troubleshooting-webhooks.md +++ b/content/webhooks/testing-and-troubleshooting-webhooks/troubleshooting-webhooks.md @@ -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 From 4fd544a3f502e9020e321878b8a293111b4f41e7 Mon Sep 17 00:00:00 2001 From: Vanessa Date: Fri, 12 Dec 2025 08:00:27 +1000 Subject: [PATCH 2/3] =?UTF-8?q?GitHub=20Copilot=20App=20Modernization=20Ja?= =?UTF-8?q?va/.NET=20=E2=80=93=20GA=20Joint=20Announcement=20[GA]=20(#5884?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/copilot/tutorials/upgrade-projects.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/content/copilot/tutorials/upgrade-projects.md b/content/copilot/tutorials/upgrade-projects.md index b9bec70d4ac2..da233e40e05c 100644 --- a/content/copilot/tutorials/upgrade-projects.md +++ b/content/copilot/tutorials/upgrade-projects.md @@ -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. @@ -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 From be8157c74c8c0248204ced60d99b482a323a250a Mon Sep 17 00:00:00 2001 From: Sarah Schneider Date: Thu, 11 Dec 2025 17:37:13 -0500 Subject: [PATCH 3/3] Support Copilot Spaces in ai-tools (#58845) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/ai-tools/lib/auth-utils.ts | 25 ++ src/ai-tools/lib/call-models-api.ts | 10 +- src/ai-tools/lib/file-utils.ts | 161 +++++++++++ src/ai-tools/lib/prompt-utils.ts | 112 ++++++++ src/ai-tools/lib/spaces-utils.ts | 109 ++++++++ src/ai-tools/scripts/ai-tools.ts | 415 +++++++++++----------------- 6 files changed, 582 insertions(+), 250 deletions(-) create mode 100644 src/ai-tools/lib/auth-utils.ts create mode 100644 src/ai-tools/lib/file-utils.ts create mode 100644 src/ai-tools/lib/prompt-utils.ts create mode 100644 src/ai-tools/lib/spaces-utils.ts diff --git a/src/ai-tools/lib/auth-utils.ts b/src/ai-tools/lib/auth-utils.ts new file mode 100644 index 000000000000..9639c6fc35b0 --- /dev/null +++ b/src/ai-tools/lib/auth-utils.ts @@ -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) + } +} diff --git a/src/ai-tools/lib/call-models-api.ts b/src/ai-tools/lib/call-models-api.ts index e08638e3c31b..70e2a90b6f58 100644 --- a/src/ai-tools/lib/call-models-api.ts +++ b/src/ai-tools/lib/call-models-api.ts @@ -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 @@ -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) { @@ -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) } diff --git a/src/ai-tools/lib/file-utils.ts b/src/ai-tools/lib/file-utils.ts new file mode 100644 index 000000000000..c1cb00d05cb5 --- /dev/null +++ b/src/ai-tools/lib/file-utils.ts @@ -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 = 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}`) + } +} diff --git a/src/ai-tools/lib/prompt-utils.ts b/src/ai-tools/lib/prompt-utils.ts new file mode 100644 index 000000000000..ef7b726b7b55 --- /dev/null +++ b/src/ai-tools/lib/prompt-utils.ts @@ -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 { + 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( + //g, + writeMode ? '' : '', + ) + msg.content = msg.content.replace( + //g, + writeMode ? '' : '', + ) + msg.content = msg.content.replace( + //g, + writeMode ? '' : '', + ) + + // Remove sections marked for removal + msg.content = msg.content.replace(/[\s\S]*?/g, '') + } + + return callModelsApi(prompt, verbose) +} diff --git a/src/ai-tools/lib/spaces-utils.ts b/src/ai-tools/lib/spaces-utils.ts new file mode 100644 index 000000000000..86c28f0477d1 --- /dev/null +++ b/src/ai-tools/lib/spaces-utils.ts @@ -0,0 +1,109 @@ +/** + * Copilot Space API response types + */ +export interface SpaceResource { + id: number + resource_type: string + copilot_chat_attachment_id: string | null + metadata: { + name: string + text: string + } +} + +export interface SpaceData { + id: number + number: number + name: string + description: string + general_instructions: string + resources_attributes: SpaceResource[] + html_url: string + created_at: string + updated_at: string +} + +/** + * Parse a Copilot Space URL to extract org and space ID + */ +export function parseSpaceUrl(url: string): { org: string; id: string } { + // Expected format: https://api.github.com/orgs/{org}/copilot-spaces/{id} + const match = url.match(/\/orgs\/([^/]+)\/copilot-spaces\/(\d+)/) + + if (!match) { + throw new Error( + `Invalid Copilot Space URL format. Expected: https://api.github.com/orgs/{org}/copilot-spaces/{id}`, + ) + } + + return { + org: match[1], + id: match[2], + } +} + +/** + * Fetch a Copilot Space from the GitHub API + */ +export async function fetchCopilotSpace(spaceUrl: string): Promise { + const { org, id } = parseSpaceUrl(spaceUrl) + const apiUrl = `https://api.github.com/orgs/${org}/copilot-spaces/${id}` + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Copilot Space not found: ${apiUrl}`) + } else if (response.status === 401 || response.status === 403) { + throw new Error( + `Authentication failed. Check your GitHub token has access to Copilot Spaces.`, + ) + } else { + throw new Error(`Failed to fetch Copilot Space: ${response.status} ${response.statusText}`) + } + } + + return (await response.json()) as SpaceData +} + +/** + * Convert a Copilot Space to a markdown prompt file + */ +export function convertSpaceToPrompt(space: SpaceData): string { + const timestamp = new Date().toISOString() + const lines: string[] = [] + + // Header with metadata + lines.push(``) + lines.push(``) + lines.push(``) + lines.push('') + + // General instructions + if (space.general_instructions) { + lines.push(space.general_instructions.trim()) + lines.push('') + } + + // Add each resource as a context section + if (space.resources_attributes && space.resources_attributes.length > 0) { + for (const resource of space.resources_attributes) { + if (resource.resource_type === 'free_text' && resource.metadata) { + lines.push('---') + lines.push('') + lines.push(`# Context: ${resource.metadata.name}`) + lines.push('') + lines.push(resource.metadata.text.trim()) + lines.push('') + } + } + } + + return lines.join('\n') +} diff --git a/src/ai-tools/scripts/ai-tools.ts b/src/ai-tools/scripts/ai-tools.ts index fde5245421cd..8ac78ed62c33 100644 --- a/src/ai-tools/scripts/ai-tools.ts +++ b/src/ai-tools/scripts/ai-tools.ts @@ -1,123 +1,36 @@ -import { fileURLToPath } from 'url' import { Command } from 'commander' import fs from 'fs' -import yaml from 'js-yaml' import path from 'path' import ora from 'ora' -import { execSync } from 'child_process' -import { callModelsApi } from '@/ai-tools/lib/call-models-api' +import { execFileSync } from 'child_process' import dotenv from 'dotenv' -import readFrontmatter from '@/frame/lib/read-frontmatter' -import { schema } from '@/frame/lib/frontmatter' +import { findMarkdownFiles, mergeFrontmatterProperties } from '@/ai-tools/lib/file-utils' +import { + getPromptsDir, + getAvailableEditorTypes, + getRefinementDescriptions, + callEditor, +} from '@/ai-tools/lib/prompt-utils' +import { fetchCopilotSpace, convertSpaceToPrompt } from '@/ai-tools/lib/spaces-utils' +import { ensureGitHubToken } from '@/ai-tools/lib/auth-utils' dotenv.config({ quiet: true }) -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const promptDir = path.join(__dirname, '../prompts') -const promptTemplatePath = path.join(promptDir, 'prompt-template.yml') - -if (!process.env.GITHUB_TOKEN) { - // Try to find a token via the CLI before throwing an error - const token = execSync('gh auth token').toString() - if (token.startsWith('gh')) { - process.env.GITHUB_TOKEN = token - } else { - 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) - } -} +const promptDir = getPromptsDir() -// Dynamically discover available editor types from prompt files -const getAvailableEditorTypes = (): 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') - } +// Ensure GitHub token is available +ensureGitHubToken() - return editorTypes -} - -const editorTypes = getAvailableEditorTypes() - -// Enhanced recursive markdown file finder with symlink, depth, and root path checks -const findMarkdownFiles = ( - dir: string, - rootDir: string, - depth: number = 0, - maxDepth: number = 20, - visited: Set = 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 -} - -const refinementDescriptions = (): string => { - return editorTypes.join(', ') -} +const editorTypes = getAvailableEditorTypes(promptDir) interface CliOptions { verbose?: boolean prompt?: string[] refine?: string[] - files: string[] + files?: string[] write?: boolean + exportSpace?: string + space?: string + output?: string } const program = new Command() @@ -130,41 +43,115 @@ program '-w, --write', 'Write changes back to the original files (default: output to console only)', ) - .option('-p, --prompt ', `Specify one or more prompt type: ${refinementDescriptions()}`) + .option( + '-p, --prompt ', + `Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`, + ) .option( '-r, --refine ', - `(Deprecated: use --prompt) Specify one or more prompt type: ${refinementDescriptions()}`, + `(Deprecated: use --prompt) Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`, + ) + .option( + '--export-space ', + 'Export a Copilot Space to a prompt file (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})', ) - .requiredOption( - '-f, --files ', - 'One or more content file paths in the content directory', + .option( + '--space ', + 'Use a Copilot Space as prompt source (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})', + ) + .option( + '--output ', + 'Output filename for exported Space prompt (use with --export-space)', ) + .option('-f, --files ', 'One or more content file paths in the content directory') .action((options: CliOptions) => { ;(async () => { - const spinner = ora('Starting AI review...').start() + // Handle export-space workflow (standalone, doesn't process files) + if (options.exportSpace) { + if (!options.output) { + console.error('Error: --export-space requires --output option') + process.exit(1) + } - const files = options.files - // Handle both --prompt and --refine options for backwards compatibility - const prompts = options.prompt || options.refine + const spinner = ora('Fetching Copilot Space...').start() + try { + const space = await fetchCopilotSpace(options.exportSpace) + spinner.text = `Converting Space "${space.name}" to prompt format...` + + const promptContent = convertSpaceToPrompt(space) + const outputPath = path.join(promptDir, options.output) - if (!prompts || prompts.length === 0) { - spinner.fail('No prompt type specified. Use --prompt or --refine with one or more types.') - process.exitCode = 1 - return + fs.writeFileSync(outputPath, promptContent, 'utf8') + spinner.succeed(`Exported Space to: ${outputPath}`) + console.log(`\nSpace: ${space.name}`) + console.log(`Resources: ${space.resources_attributes?.length || 0} items`) + console.log(`\nYou can now use it with: --prompt ${path.basename(options.output, '.md')}`) + return + } catch (error) { + spinner.fail(`Failed to export Space: ${(error as Error).message}`) + process.exit(1) + } + } + + // Validate mutually exclusive options + if (options.space && options.prompt) { + console.error('Error: Cannot use both --space and --prompt options') + process.exit(1) } - // Validate that all requested editor types exist - const availableEditors = editorTypes - for (const editor of prompts) { - if (!availableEditors.includes(editor)) { - spinner.fail( - `Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`, - ) + // Files are required for processing workflows + if (!options.files || options.files.length === 0) { + console.error('Error: --files option is required (unless using --export-space)') + process.exit(1) + } + + const spinner = ora('Starting AI review...').start() + + const files = options.files + let prompts: string[] = [] + let promptContent: string | undefined + + // Handle Space workflow (in-memory) + if (options.space) { + try { + spinner.text = 'Fetching Copilot Space...' + const space = await fetchCopilotSpace(options.space) + promptContent = convertSpaceToPrompt(space) + prompts = [space.name] // Use space name for display + + if (options.verbose) { + console.log(`Using Space: ${space.name} (ID: ${space.number})`) + console.log(`Resources: ${space.resources_attributes?.length || 0} items`) + } + } catch (error) { + spinner.fail(`Failed to fetch Space: ${(error as Error).message}`) + process.exit(1) + } + } else { + // Handle local prompt workflow + prompts = options.prompt || options.refine || [] + + if (prompts.length === 0) { + spinner.fail('No prompt type specified. Use --prompt, --refine, or --space.') process.exitCode = 1 return } } + // Validate local prompt types exist (skip for Space workflow) + if (!options.space) { + const availableEditors = editorTypes + for (const editor of prompts) { + if (!availableEditors.includes(editor)) { + spinner.fail( + `Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`, + ) + process.exitCode = 1 + return + } + } + } + if (options.verbose) { console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`) } @@ -208,12 +195,20 @@ program const relativePath = path.relative(process.cwd(), fileToProcess) spinner.text = `Processing: ${relativePath}` try { + // Resolve Liquid references before processing + if (options.verbose) { + console.log(`Resolving Liquid references in: ${relativePath}`) + } + runResolveLiquid('resolve', [fileToProcess], options.verbose || false) + const content = fs.readFileSync(fileToProcess, 'utf8') const answer = await callEditor( editorType, content, + promptDir, options.write || false, options.verbose || false, + promptContent, // Pass Space prompt content if using --space ) spinner.stop() @@ -235,10 +230,26 @@ program } console.log(answer) } + + // Always restore Liquid references after processing (even in non-write mode) + if (options.verbose) { + console.log(`Restoring Liquid references in: ${relativePath}`) + } + runResolveLiquid('restore', [fileToProcess], options.verbose || false) } catch (err) { const error = err as Error spinner.fail(`Error processing ${relativePath}: ${error.message}`) process.exitCode = 1 + + // Still try to restore Liquid references on error + try { + runResolveLiquid('restore', [fileToProcess], false) + } catch (restoreError) { + // Log restore failures in verbose mode for debugging + if (options.verbose) { + console.error(`Warning: Failed to restore Liquid references: ${restoreError}`) + } + } } finally { spinner.stop() } @@ -263,134 +274,46 @@ program program.parse(process.argv) -// Handle graceful shutdown -process.on('SIGINT', () => { - console.log('\n\nšŸ›‘ Process interrupted by user') - process.exit(0) -}) - -process.on('SIGTERM', () => { - console.log('\n\nšŸ›‘ Process terminated') - process.exit(0) -}) - -interface PromptMessage { - content: string - role: string -} - -interface PromptData { - messages: PromptMessage[] - model?: string - temperature?: number - max_tokens?: number -} - -// Function to merge new frontmatter properties into existing file while preserving formatting -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(', ')}`, - ) +/** + * Run resolve-liquid command on specified file paths + */ +function runResolveLiquid( + command: 'resolve' | 'restore', + filePaths: string[], + verbose: boolean = false, +): void { + const args = [command, '--paths', ...filePaths] + if (command === 'resolve') { + args.push('--recursive') } - - if (!parsed.content) { - throw new Error('Failed to parse content from file') + if (verbose) { + args.push('--verbose') } 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() - - interface FrontmatterProperties { - intro?: string - [key: string]: unknown - } - 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 - }), + // Run resolve-liquid via tsx + const resolveLiquidPath = path.join( + process.cwd(), + 'src/content-render/scripts/resolve-liquid.ts', ) - - // Merge new properties with existing frontmatter - const mergedData: FrontmatterProperties = { ...parsed.data, ...sanitizedProperties } - - // Manually ensure intro is wrapped in single quotes in the final output - let result = readFrontmatter.stringify(parsed.content, mergedData) - - // Post-process to ensure intro field has single quotes - if (newProperties.intro) { - const introValue = newProperties.intro.toString() - // Replace any quote style on intro with single quotes - result = result.replace( - /^intro:\s*(['"`]?)([^'"`\n\r]+)\1?\s*$/m, - `intro: '${introValue.replace(/'/g, "''")}'`, // Escape single quotes by doubling them - ) - } - return result + execFileSync('npx', ['tsx', resolveLiquidPath, ...args], { + stdio: verbose ? 'inherit' : 'pipe', + }) } 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}`) + if (verbose) { + console.error(`Error running resolve-liquid ${command}:`, error) + } + // Don't fail the entire process if resolve-liquid fails } } -async function callEditor( - editorType: string, - content: string, - writeMode: boolean, - verbose = false, -): Promise { - const markdownPromptPath = path.join(promptDir, `${String(editorType)}.md`) - - if (!fs.existsSync(markdownPromptPath)) { - throw new Error(`Prompt file not found: ${markdownPromptPath}`) - } - - const markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8') - - 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( - //g, - writeMode ? '' : '', - ) - msg.content = msg.content.replace( - //g, - writeMode ? '' : '', - ) - msg.content = msg.content.replace( - //g, - writeMode ? '' : '', - ) - - // Remove sections marked for removal - msg.content = msg.content.replace(/[\s\S]*?/g, '') - } +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('\n\nšŸ›‘ Process interrupted by user') + process.exit(0) +}) - return callModelsApi(prompt, verbose) -} +process.on('SIGTERM', () => { + console.log('\n\nšŸ›‘ Process terminated') + process.exit(0) +})