Skip to content

Commit be8157c

Browse files
Support Copilot Spaces in ai-tools (#58845)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 4fd544a commit be8157c

File tree

6 files changed

+582
-250
lines changed

6 files changed

+582
-250
lines changed

src/ai-tools/lib/auth-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { execSync } from 'child_process'
2+
3+
/**
4+
* Ensure GitHub token is available, exiting process if not found
5+
*/
6+
export function ensureGitHubToken(): void {
7+
if (!process.env.GITHUB_TOKEN) {
8+
try {
9+
const token = execSync('gh auth token', { encoding: 'utf8' }).trim()
10+
if (token) {
11+
process.env.GITHUB_TOKEN = token
12+
return
13+
}
14+
} catch {
15+
// gh CLI not available or not authenticated
16+
}
17+
18+
console.warn(`🔑 A token is needed to run this script. Please do one of the following and try again:
19+
20+
1. Add a GITHUB_TOKEN to a local .env file.
21+
2. Install https://cli.github.com and authenticate via 'gh auth login'.
22+
`)
23+
process.exit(1)
24+
}
25+
}

src/ai-tools/lib/call-models-api.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
2+
const API_TIMEOUT_MS = 180000 // 3 minutes
3+
const DEFAULT_MODEL = 'openai/gpt-4o'
24

35
interface ChatMessage {
46
role: string
@@ -42,16 +44,16 @@ export async function callModelsApi(
4244

4345
// Set default model if none specified
4446
if (!promptWithContent.model) {
45-
promptWithContent.model = 'openai/gpt-4o'
47+
promptWithContent.model = DEFAULT_MODEL
4648
if (verbose) {
47-
console.log('⚠️ No model specified, using default: openai/gpt-4o')
49+
console.log(`⚠️ No model specified, using default: ${DEFAULT_MODEL}`)
4850
}
4951
}
5052

5153
try {
5254
// Create an AbortController for timeout handling
5355
const controller = new AbortController()
54-
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
56+
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
5557

5658
const startTime = Date.now()
5759
if (verbose) {
@@ -123,7 +125,7 @@ export async function callModelsApi(
123125
} catch (error) {
124126
if (error instanceof Error) {
125127
if (error.name === 'AbortError') {
126-
throw new Error('API call timed out after 3 minutes')
128+
throw new Error(`API call timed out after ${API_TIMEOUT_MS / 1000} seconds`)
127129
}
128130
console.error('Error calling GitHub Models REST API:', error.message)
129131
}

src/ai-tools/lib/file-utils.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import yaml from 'js-yaml'
4+
import readFrontmatter from '@/frame/lib/read-frontmatter'
5+
import { schema } from '@/frame/lib/frontmatter'
6+
7+
const MAX_DIRECTORY_DEPTH = 20
8+
9+
/**
10+
* Enhanced recursive markdown file finder with symlink, depth, and root path checks
11+
*/
12+
export function findMarkdownFiles(
13+
dir: string,
14+
rootDir: string,
15+
depth: number = 0,
16+
maxDepth: number = MAX_DIRECTORY_DEPTH,
17+
visited: Set<string> = new Set(),
18+
): string[] {
19+
const markdownFiles: string[] = []
20+
let realDir: string
21+
try {
22+
realDir = fs.realpathSync(dir)
23+
} catch {
24+
// If we can't resolve real path, skip this directory
25+
return []
26+
}
27+
// Prevent escaping root directory
28+
if (!realDir.startsWith(rootDir)) {
29+
return []
30+
}
31+
// Prevent symlink loops
32+
if (visited.has(realDir)) {
33+
return []
34+
}
35+
visited.add(realDir)
36+
// Prevent excessive depth
37+
if (depth > maxDepth) {
38+
return []
39+
}
40+
let entries: fs.Dirent[]
41+
try {
42+
entries = fs.readdirSync(realDir, { withFileTypes: true })
43+
} catch {
44+
// If we can't read directory, skip
45+
return []
46+
}
47+
for (const entry of entries) {
48+
const fullPath = path.join(realDir, entry.name)
49+
let realFullPath: string
50+
try {
51+
realFullPath = fs.realpathSync(fullPath)
52+
} catch {
53+
continue
54+
}
55+
// Prevent escaping root directory for files
56+
if (!realFullPath.startsWith(rootDir)) {
57+
continue
58+
}
59+
if (entry.isDirectory()) {
60+
markdownFiles.push(...findMarkdownFiles(realFullPath, rootDir, depth + 1, maxDepth, visited))
61+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
62+
markdownFiles.push(realFullPath)
63+
}
64+
}
65+
return markdownFiles
66+
}
67+
68+
interface FrontmatterProperties {
69+
intro?: string
70+
[key: string]: unknown
71+
}
72+
73+
/**
74+
* Function to merge new frontmatter properties into existing file while preserving formatting.
75+
* Uses surgical replacement to only modify the specific field(s) being updated,
76+
* preserving all original YAML formatting for unchanged fields.
77+
*/
78+
export function mergeFrontmatterProperties(filePath: string, newPropertiesYaml: string): string {
79+
const content = fs.readFileSync(filePath, 'utf8')
80+
const parsed = readFrontmatter(content)
81+
82+
if (parsed.errors && parsed.errors.length > 0) {
83+
throw new Error(
84+
`Failed to parse frontmatter: ${parsed.errors.map((e) => e.message).join(', ')}`,
85+
)
86+
}
87+
88+
if (!parsed.content) {
89+
throw new Error('Failed to parse content from file')
90+
}
91+
92+
try {
93+
// Clean up the AI response - remove markdown code blocks if present
94+
let cleanedYaml = newPropertiesYaml.trim()
95+
cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '')
96+
cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '')
97+
cleanedYaml = cleanedYaml.trim()
98+
99+
const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties
100+
101+
// Security: Validate against prototype pollution using the official frontmatter schema
102+
const allowedKeys = Object.keys(schema.properties)
103+
104+
const sanitizedProperties = Object.fromEntries(
105+
Object.entries(newProperties).filter(([key]) => {
106+
if (allowedKeys.includes(key)) {
107+
return true
108+
}
109+
console.warn(`Filtered out potentially unsafe frontmatter key: ${key}`)
110+
return false
111+
}),
112+
)
113+
114+
// Split content into lines for surgical replacement
115+
const lines = content.split('\n')
116+
let inFrontmatter = false
117+
let frontmatterEndIndex = -1
118+
119+
// Find frontmatter boundaries
120+
for (let i = 0; i < lines.length; i++) {
121+
if (lines[i].trim() === '---') {
122+
if (!inFrontmatter) {
123+
inFrontmatter = true
124+
} else {
125+
frontmatterEndIndex = i
126+
break
127+
}
128+
}
129+
}
130+
131+
// Replace each field value while preserving everything else
132+
for (const [key, value] of Object.entries(sanitizedProperties)) {
133+
const formattedValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value
134+
135+
// Find the line with this field
136+
for (let i = 1; i < frontmatterEndIndex; i++) {
137+
const line = lines[i]
138+
if (line.startsWith(`${key}:`)) {
139+
// Simple replacement: keep the field name and spacing, replace the value
140+
const colonIndex = line.indexOf(':')
141+
const leadingSpace = line.substring(colonIndex + 1, colonIndex + 2) // Usually a space
142+
lines[i] = `${key}:${leadingSpace}${formattedValue}`
143+
144+
// Remove any continuation lines (multi-line values)
145+
const j = i + 1
146+
while (j < frontmatterEndIndex && lines[j].startsWith(' ')) {
147+
lines.splice(j, 1)
148+
frontmatterEndIndex--
149+
}
150+
break
151+
}
152+
}
153+
}
154+
155+
return lines.join('\n')
156+
} catch (error) {
157+
console.error('Failed to parse AI response as YAML:')
158+
console.error('Raw AI response:', JSON.stringify(newPropertiesYaml))
159+
throw new Error(`Failed to parse new frontmatter properties: ${error}`)
160+
}
161+
}

src/ai-tools/lib/prompt-utils.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { fileURLToPath } from 'url'
2+
import fs from 'fs'
3+
import yaml from 'js-yaml'
4+
import path from 'path'
5+
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
6+
7+
export interface PromptMessage {
8+
content: string
9+
role: string
10+
}
11+
12+
export interface PromptData {
13+
messages: PromptMessage[]
14+
model?: string
15+
temperature?: number
16+
max_tokens?: number
17+
}
18+
19+
/**
20+
* Get the prompts directory path
21+
*/
22+
export function getPromptsDir(): string {
23+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
24+
return path.join(__dirname, '../prompts')
25+
}
26+
27+
/**
28+
* Dynamically discover available editor types from prompt files
29+
*/
30+
export function getAvailableEditorTypes(promptDir: string): string[] {
31+
const editorTypes: string[] = []
32+
33+
try {
34+
const promptFiles = fs.readdirSync(promptDir)
35+
for (const file of promptFiles) {
36+
if (file.endsWith('.md')) {
37+
const editorName = path.basename(file, '.md')
38+
editorTypes.push(editorName)
39+
}
40+
}
41+
} catch {
42+
console.warn('Could not read prompts directory, using empty editor types')
43+
}
44+
45+
return editorTypes
46+
}
47+
48+
/**
49+
* Get formatted description of available refinement types
50+
*/
51+
export function getRefinementDescriptions(editorTypes: string[]): string {
52+
return editorTypes.join(', ')
53+
}
54+
55+
/**
56+
* Call an editor with the given content and options
57+
*/
58+
export async function callEditor(
59+
editorType: string,
60+
content: string,
61+
promptDir: string,
62+
writeMode: boolean,
63+
verbose = false,
64+
promptContent?: string, // Optional: use this instead of reading from file
65+
): Promise<string> {
66+
let markdownPrompt: string
67+
68+
if (promptContent) {
69+
// Use provided prompt content (e.g., from Copilot Space)
70+
markdownPrompt = promptContent
71+
} else {
72+
// Read from file
73+
const markdownPromptPath = path.join(promptDir, `${editorType}.md`)
74+
75+
if (!fs.existsSync(markdownPromptPath)) {
76+
throw new Error(`Prompt file not found: ${markdownPromptPath}`)
77+
}
78+
79+
markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8')
80+
}
81+
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
82+
83+
const prompt = yaml.load(fs.readFileSync(promptTemplatePath, 'utf8')) as PromptData
84+
85+
// Validate the prompt template has required properties
86+
if (!prompt.messages || !Array.isArray(prompt.messages)) {
87+
throw new Error('Invalid prompt template: missing or invalid messages array')
88+
}
89+
90+
for (const msg of prompt.messages) {
91+
msg.content = msg.content.replace('{{markdownPrompt}}', markdownPrompt)
92+
msg.content = msg.content.replace('{{input}}', content)
93+
// Replace writeMode template variable with simple string replacement
94+
msg.content = msg.content.replace(
95+
/<!-- IF_WRITE_MODE -->/g,
96+
writeMode ? '' : '<!-- REMOVE_START -->',
97+
)
98+
msg.content = msg.content.replace(
99+
/<!-- ELSE_WRITE_MODE -->/g,
100+
writeMode ? '<!-- REMOVE_START -->' : '',
101+
)
102+
msg.content = msg.content.replace(
103+
/<!-- END_WRITE_MODE -->/g,
104+
writeMode ? '' : '<!-- REMOVE_END -->',
105+
)
106+
107+
// Remove sections marked for removal
108+
msg.content = msg.content.replace(/<!-- REMOVE_START -->[\s\S]*?<!-- REMOVE_END -->/g, '')
109+
}
110+
111+
return callModelsApi(prompt, verbose)
112+
}

0 commit comments

Comments
 (0)