Skip to content

Commit 784b71a

Browse files
sarahsCopilot
andauthored
[Hack week] AI-powered editors (#55906)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 603978b commit 784b71a

File tree

4 files changed

+239
-0
lines changed

4 files changed

+239
-0
lines changed

src/ai-editors/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# AI-powered editors
2+
3+
A CLI tool for using AI to edit documentation according to defined prompts.
4+
5+
This tool runs an AI review of content files based on an (extensible) set of prompt-driven guidelines. The default is versioning. In the future we might add: scannability, readability, style, technical accuracy.
6+
7+
This script calls the [Models API](https://docs.github.com/en/rest/models/inference?apiVersion=2022-11-28#run-an-inference-request). It requires a personal access token with Models scopes in your `.env` file.
8+
9+
## Usage
10+
11+
```sh
12+
tsx src/ai-editors/scripts/ai-edit.js --editor <type> --response <type> --files <file1.md>
13+
```
14+
15+
* `--files, -f`: One or more content file paths to process (required).
16+
* `--response, -r`: Specify the AI response format. Options: `rewrite` (default), `list`, `json`.
17+
* `--editor, -e`: Specify one or more editor types (default: `versioning`).
18+
19+
**Example:**
20+
21+
```sh
22+
tsx src/ai-editors/scripts/ai-edit.js --files content/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo.md --editor versioning --response list
23+
```
24+
25+
## Requirements
26+
27+
* A valid `GITHUB_TOKEN` with Models scopes in your local `.env` file.
28+
29+
## Future development ideas
30+
31+
* Add prompts to support all available editors.
32+
* Test prompts in Models UI and add evals to prevent regressions.
33+
* Enable running in CI.
34+
* Explore the new `llm` plugin for GitHub Models (see https://github.com/github/copilot-productivity/discussions/5937).
35+
* Add MCP for more comprehensive context.
36+
* Integrate with Copilot Edit mode in VS Code.
37+
* Add unit tests.

src/ai-editors/lib/call-models-api.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
2+
3+
export async function callModelsApi(promptWithContent) {
4+
let aiResponse
5+
try {
6+
const response = await fetch(modelsCompletionsEndpoint, {
7+
method: 'post',
8+
body: JSON.stringify(promptWithContent),
9+
headers: {
10+
'Content-Type': 'application/json',
11+
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
12+
'X-GitHub-Api-Version': '2022-11-28',
13+
Accept: 'Accept: application/vnd.github+json',
14+
},
15+
})
16+
const data = await response.json()
17+
aiResponse = data.choices[0]
18+
} catch (error) {
19+
console.error('Error calling GitHub Models REST API')
20+
throw error
21+
}
22+
23+
return aiResponse.message.content
24+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
messages:
2+
- role: system
3+
content: >-
4+
Your task is to remove the conditional markup from content files that
5+
looks like {% ifversion fpt or ghec %}Foo{% endif %}. You need to first try
6+
to write the content without any versioning at all, so it still makes sense
7+
to customers without causing confusion. If you need to explain versioning
8+
differences, do so using prose. Here are the prose guidelines to follow:
9+
* For versioning at the article level:
10+
- When the feature is only available in certain products, use the "Who can
11+
use this feature?" box to convey the content of this article applies only
12+
to XYZ products.
13+
- When an article only exists before the functionality is in older versions
14+
of GHES (and not dotcom and newer versions of GHES), just remove that article.
15+
(This is akin to declining to document a feature.)
16+
* For versioning at the heading level:
17+
- Use prose similar to the "Who can use this feature?" to convey that the
18+
content of this section applies only to XYZ products.
19+
* For versioning the paragraph or sentence level:
20+
- Use one of the following content strategies:
21+
- If you're briefly introducing a feature and then linking to an article,
22+
there's no need to specify versioning. Let folks learn availability when
23+
they follow the link, via the "Who can use this feature?" box.
24+
- When necessary, start sentences with "With GitHub Enterprise Cloud...",
25+
"On GitHub.com", "With GitHub Enterprise Server 3.15+..." etc.
26+
- End list items with "(GitHub Enterprise Cloud only)", "(GitHub.com only)", etc.
27+
- role: user
28+
content: >-
29+
Review this content according to the new prose versioning guidelines. {{responseTypeInstruction}}
30+
{{input}}
31+
model: openai/gpt-4.1-mini

src/ai-editors/scripts/ai-edit.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env node
2+
3+
import { fileURLToPath } from 'url'
4+
import { Command } from 'commander'
5+
import fs from 'fs'
6+
import yaml from 'js-yaml'
7+
import path from 'path'
8+
import ora from 'ora'
9+
import github from '#src/workflows/github.ts'
10+
import { callModelsApi } from '#src/ai-editors/lib/call-models-api.js'
11+
12+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
13+
const promptDir = path.join(__dirname, '../prompts')
14+
15+
if (!process.env.GITHUB_TOKEN) {
16+
throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.')
17+
}
18+
19+
const responseTypes = {
20+
rewrite: 'Edit the versioning only. Return the edited content.',
21+
list: `Do NOT rewrite the content. Report your edits in numbered list format.`,
22+
json: `Do NOT rewrite the content. Report your edits as a JSON list, with the format { lineNumber, currentText, suggestion }.`,
23+
}
24+
25+
const validResponseTypes = Object.keys(responseTypes)
26+
27+
const editorTypes = {
28+
versioning: {
29+
promptFile: 'versioning-editor.prompt.yml',
30+
description: 'Review against simplifying versioning guidelines.',
31+
},
32+
// TODO
33+
// scannability: {
34+
// promptFile: 'scannability-editor.prompt.yml',
35+
// description: 'Review against scannability guidelines.',
36+
// },
37+
// readability: {
38+
// promptFile: 'readability-editor.prompt.yml',
39+
// description:
40+
// 'Review against readability criteria like Gunning Fog index, Hemingway, word count, sentence length, etc.',
41+
// },
42+
// technical: {
43+
// promptFile: 'technical-editor.prompt.yml',
44+
// description: 'Review against provided product information for technical accuracy.',
45+
// },
46+
// styleguide: {
47+
// promptFile: 'styleguide-editor.prompt.yml',
48+
// description: 'Review against the GitHub Docs style guide.',
49+
// },
50+
// contentModels: {
51+
// promptFile: 'content-models-editor.prompt.yml',
52+
// description: 'Review against the GitHub Docs content models.',
53+
// },
54+
// Add more here...
55+
}
56+
57+
const editorDescriptions = () => {
58+
let str = '\n\n'
59+
Object.entries(editorTypes).forEach(([ed, edObj]) => {
60+
str += `\t${ed}\n\t\t\t${edObj.description}\n\n`
61+
})
62+
return str
63+
}
64+
65+
const program = new Command()
66+
67+
program
68+
.name('ai-edit')
69+
.description('Edit content files using AI')
70+
.option('-v, --verbose', 'Enable verbose output')
71+
.option(
72+
'-e, --editor <type...>',
73+
`Specify one or more editor type: ${editorDescriptions().trimEnd()}\n`,
74+
)
75+
.option(
76+
'-r, --response <type>',
77+
`Specify response type: ${validResponseTypes.join(', ')} (default: rewrite)`,
78+
)
79+
.requiredOption(
80+
'-f, --files <files...>',
81+
'One or more content file paths in the content directory',
82+
)
83+
.action((options) => {
84+
;(async () => {
85+
const spinner = ora('Starting AI review...').start()
86+
87+
const files = options.files
88+
const editors = options.editor || ['versioning']
89+
const response = options.response || 'rewrite'
90+
91+
let responseTypeInstruction
92+
if (validResponseTypes.includes(response)) {
93+
responseTypeInstruction = responseTypes[response]
94+
} else {
95+
console.error(
96+
`Invalid response type: ${response}. Must be one of: ${validResponseTypes.join(', ')}`,
97+
)
98+
process.exit(1)
99+
}
100+
101+
for (const file of files) {
102+
const filePath = path.resolve(process.cwd(), file)
103+
spinner.text = `Checking file: ${file}`
104+
105+
if (!fs.existsSync(filePath)) {
106+
spinner.fail(`File not found: ${filePath}`)
107+
process.exitCode = 1
108+
continue
109+
}
110+
111+
try {
112+
spinner.text = `Reading file: ${file}`
113+
const content = fs.readFileSync(filePath, 'utf8')
114+
115+
for (const editorType of editors) {
116+
spinner.text = `Running the AI-powered ${editorType} editor...`
117+
const answer = await callEditor(editorType, responseTypeInstruction, content)
118+
119+
if (response === 'rewrite') {
120+
fs.writeFileSync(file, answer, 'utf-8')
121+
spinner.succeed(`Processed file: ${file}`)
122+
console.log(`To see changes, run "git diff" on the file.`)
123+
} else {
124+
spinner.succeed(`Processed file: ${file}`)
125+
console.log(answer)
126+
}
127+
}
128+
} catch (err) {
129+
spinner.fail(`Error processing file ${file}: ${err.message}`)
130+
process.exitCode = 1
131+
}
132+
}
133+
})()
134+
})
135+
136+
program.parse(process.argv)
137+
138+
async function callEditor(editorType, responseTypeInstruction, content) {
139+
const promptName = editorTypes[editorType].promptFile
140+
const promptPath = path.join(promptDir, promptName)
141+
const prompt = yaml.load(fs.readFileSync(promptPath, 'utf8'))
142+
prompt.messages.forEach((msg) => {
143+
msg.content = msg.content.replace('{{responseTypeInstruction}}', responseTypeInstruction)
144+
msg.content = msg.content.replace('{{input}}', content)
145+
})
146+
return callModelsApi(prompt)
147+
}

0 commit comments

Comments
 (0)