diff --git a/.github/workflows/apply-ai-fix.yml b/.github/workflows/apply-ai-fix.yml new file mode 100644 index 0000000..2b6ccc3 --- /dev/null +++ b/.github/workflows/apply-ai-fix.yml @@ -0,0 +1,56 @@ +# Apply an AI review `fix` verdict in one command. +# +# A maintainer comments `/apply-ai-fix` on a PR and this workflow commits the Suggested +# entry from the latest AI review comment to the PR branch (same-repo PRs only). +# +# Security: +# - The trigger comment body is untrusted and only used as a signal (an `if:` check for +# the command string); nothing from it is interpolated into shell commands. +# - The YAML applied comes only from our own validated bot AI-review comment +# (MARKER ``), never from the trigger comment. +# - The sender's permission is checked via the collaborators permission API (write or +# admin required) before anything is written; fork PRs are rejected with an +# explanatory comment (same-repo only for the MVP); a stale reviewed SHA is rejected. +# - Untrusted strings are escaped with safe() before reaching a comment, and git runs +# via execFile array args (no shell). +name: Apply AI fix + +on: + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: apply-ai-fix-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + apply: + if: github.event.issue.pull_request && contains(github.event.comment.body, '/apply-ai-fix') + permissions: + contents: write + pull-requests: write + issues: write + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Apply AI suggested entry + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/apply-ai-fix.js --pr "${{ github.event.issue.number }}" --comment-id "${{ github.event.comment.id }}" --sender "${{ github.event.comment.user.login }}" diff --git a/docs/policy.md b/docs/policy.md index 0ead3a0..eade5d0 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -63,6 +63,8 @@ An OpenCode V2 agent reviews the judgment calls the deterministic checks leave o A maintainer still merges every PR, including the ones the AI opened, and the `validate` check still applies. Delete the comment or remove the label to discard a verdict; dispatching the workflow with `force` re-reviews. +Maintainers can apply a fix verdict in one click by commenting /apply-ai-fix on the PR. The bot validates the sender, checks the suggestion is fresh and schema-valid, commits it to the PR branch (same-repo PRs only), and validate re-runs. + How it is contained: - It runs only on a schedule or manual dispatch, with base-branch code; PR code is never checked out. diff --git a/scripts/apply-ai-fix.js b/scripts/apply-ai-fix.js new file mode 100644 index 0000000..dfb7795 --- /dev/null +++ b/scripts/apply-ai-fix.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** + * Apply the AI review's suggested entry to a PR branch in one command. + * + * node scripts/apply-ai-fix.js --pr --sender [--comment-id ] [--dry-run] + * + * Flow: fetch the PR (must be open) -> check the sender has write/admin via the + * collaborators permission API -> find the latest `` comment -> + * require its reviewed SHA to match the PR head (fail closed on stale) -> extract the + * first ```yaml block inside its
-> require exactly one changed + * data/**.yaml file in the PR -> schema-validate the suggestion -> commit it to the + * PR's head branch (same-repo PRs only for the MVP) and comment the result. + * + * Security: the trigger comment body is untrusted and only used as a signal (never + * interpolated into shell). The YAML applied comes only from our own validated bot + * AI-review comment (MARKER). All untrusted strings are escaped with safe() before + * reaching a comment. Git runs via execFile with array args (no shell). + */ + +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const yaml = require('js-yaml'); +const { rest } = require('./utils/github'); +const { REPO, comment, listComments, pullChanges } = require('./utils/repo'); +const { MARKER, checkSuggestedYaml, safe } = require('./utils/ai-verdict'); +const { CATEGORIES } = require('./utils/catalog'); + +const execFileAsync = promisify(execFile); +const ROOT = path.join(__dirname, '..'); + +const APPLY_COMMAND = '/apply-ai-fix'; +const SHA_RE = //; +const CATEGORY_RE = /category `([A-Za-z0-9_-]+)`/; +const DATA_FILE_RE = /^data\/[^/]+\/[^/]+\.yaml$/; +const SUCCESS_MESSAGE = 'Apply AI suggested entry (/apply-ai-fix by @%s)'; + +/** True when a comment body is an apply trigger (signal only; body is otherwise untrusted). */ +function isTriggerComment(body) { + return String(body || '').includes(APPLY_COMMAND); +} + +/** Latest comment whose body starts with the bot AI-review marker, or null. */ +function findAiComment(comments) { + const list = Array.isArray(comments) ? comments : []; + for (let i = list.length - 1; i >= 0; i--) { + const body = list[i] && list[i].body; + if (typeof body === 'string' && body.startsWith(MARKER)) return list[i]; + } + return null; +} + +/** Reviewed SHA recorded in an AI review comment body, or null. */ +function extractReviewedSha(body) { + const match = SHA_RE.exec(String(body || '')); + return match ? match[1] : null; +} + +/** + * First ```yaml block inside the comment's
element (else the first ```yaml + * block anywhere, for robustness). Returns the raw YAML text or null. + */ +function extractSuggestedYaml(body) { + const text = String(body || ''); + const details = /
([\s\S]*?)<\/details>/i.exec(text); + const scope = details ? details[1] : text; + const match = /```yaml\s*\n([\s\S]*?)```/.exec(scope); + return match ? match[1].trim() : null; +} + +/** Category hint rendered in the AI review comment (`category `X``), or null. */ +function extractCategoryHint(body) { + const match = CATEGORY_RE.exec(String(body || '')); + return match && CATEGORIES.includes(match[1]) ? match[1] : null; +} + +/** Category implied by a catalog path (data//...), or null. */ +function categoryFromPath(filePath) { + const match = /^data\/([^/]+)\//.exec(String(filePath || '')); + return match && CATEGORIES.includes(match[1]) ? match[1] : null; +} + +/** + * Pick the single catalog file the suggestion applies to. + * @param {string[]} changedPaths - changed file paths in the PR + * @returns {{file: string|null, error: string|null}} + */ +function targetFile(changedPaths) { + const files = (Array.isArray(changedPaths) ? changedPaths : []) + .filter(p => typeof p === 'string' && DATA_FILE_RE.test(p)); + if (files.length === 0) return { file: null, error: 'No catalog file in this PR' }; + if (files.length > 1) return { file: null, error: 'Multiple files; apply manually' }; + return { file: files[0], error: null }; +} + +/** True when the reviewed SHA is still the PR head (prefix match, same as validateVerdict). */ +function isFresh(reviewedSha, headSha) { + return Boolean(reviewedSha && headSha && String(headSha).startsWith(reviewedSha)); +} + +function parseArgs(argv) { + const value = (flag, fallback = null) => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : fallback); + const pr = value('--pr') ? Number(value('--pr')) : NaN; + return { + pr, + sender: value('--sender'), + commentId: value('--comment-id') ? Number(value('--comment-id')) : null, + dryRun: argv.includes('--dry-run') + }; +} + +/** Post a comment; never throws (fail-closed reporting must not crash on the report itself). */ +async function say(number, body, deps) { + try { + await deps.postComment(number, body); + } catch (err) { + console.error(`comment failed: ${err.message}`); + } +} + +async function git(args, { cwd = ROOT } = {}) { + const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + return String(stdout || '').trim(); +} + +/** + * Commit the suggested entry to the PR head branch (same-repo only). + * Uses execFile array args throughout; file content never touches a shell command line. + */ +async function commitToBranch({ file, content, headRef, sender }, deps) { + const abs = path.join(ROOT, file); + const resolved = path.resolve(abs); + if (!resolved.startsWith(path.resolve(ROOT) + path.sep)) throw new Error(`refusing to write outside the repo: ${file}`); + if (!DATA_FILE_RE.test(file)) throw new Error(`refusing to write non-catalog file: ${file}`); + await deps.fetchHead(headRef); + await deps.checkoutHead(headRef); + deps.writeFile(resolved, content.endsWith('\n') ? content : `${content}\n`); + await deps.stageAndCommit(file, `Apply AI suggested entry (/apply-ai-fix by @${sender})`); + await deps.pushHead(headRef); +} + +const defaultDeps = { + getPull: async number => rest(`/repos/${REPO}/pulls/${number}`), + getPermission: async sender => rest(`/repos/${REPO}/collaborators/${encodeURIComponent(sender)}/permission`), + listComments, + listPrFiles: async pull => pullChanges(pull), + postComment: async (number, body) => comment(number, body), + fetchHead: async headRef => { await git(['fetch', 'origin', headRef]); }, + checkoutHead: async headRef => { await git(['checkout', headRef]); }, + writeFile: (abs, content) => fs.writeFileSync(abs, content, 'utf8'), + stageAndCommit: async (file, message) => { + await git(['add', '--', file]); + await git(['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', 'commit', '-m', message, '--', file]); + }, + pushHead: async headRef => { await git(['push', 'origin', `HEAD:${headRef}`]); }, + runValidate: async () => { await execFileAsync('node', ['scripts/validate.js'], { cwd: ROOT, maxBuffer: 8 * 1024 * 1024 }); } +}; + +/** + * Run the apply flow. Returns an exit code (0 = handled, 1 = infrastructure error). + * User-facing rejections post an explanatory comment and return 0 so the workflow + * stays green; only unexpected failures return 1. + */ +async function applyAiFix({ pr, sender, dryRun = false }, deps = defaultDeps) { + if (!Number.isInteger(pr) || pr <= 0) throw new Error('apply-ai-fix needs --pr '); + if (!sender || typeof sender !== 'string') throw new Error('apply-ai-fix needs --sender '); + + const { status: pullStatus, data: pull } = await deps.getPull(pr); + if (pullStatus !== 200 || !pull) { + console.error(`fetch PR #${pr} failed: HTTP ${pullStatus}`); + return 1; + } + if (pull.state !== 'open') { + console.log(`#${pr} is ${pull.state}; nothing to apply`); + return 0; + } + + const permRes = await deps.getPermission(sender); + const permission = permRes && permRes.data && permRes.data.permission; + if (permRes.status !== 200 || (permission !== 'admin' && permission !== 'write')) { + await say(pr, `Only maintainers with write access can use \`${APPLY_COMMAND}\` (sender @${safe(sender)}).`, deps); + console.log(`sender ${sender} has permission ${permission || permRes.status}; rejected`); + return 0; + } + + const comments = await deps.listComments(pr); + const aiComment = findAiComment(comments); + if (!aiComment) { + await say(pr, `No AI review comment (\`${MARKER}\`) found on this PR; nothing to apply.`, deps); + return 0; + } + + const reviewed = extractReviewedSha(aiComment.body); + if (!isFresh(reviewed, pull.head.sha)) { + await say(pr, 'Suggested entry is stale (PR has new commits); waiting for fresh AI review.', deps); + return 0; + } + + const suggested = extractSuggestedYaml(aiComment.body); + if (!suggested) { + await say(pr, 'No suggested entry found in the latest AI review comment; nothing to apply.', deps); + return 0; + } + + const headRepo = (pull.head.repo && pull.head.repo.full_name) || REPO; + if (headRepo !== REPO) { + await say(pr, 'Fork PRs are not auto-updated; please apply manually.', deps); + return 0; + } + + const files = await deps.listPrFiles(pull); + const paths = files.map(f => (typeof f === 'string' ? f : f.path)); + const { file, error } = targetFile(paths); + if (!file) { + await say(pr, `${safe(error)}; please apply manually.`, deps); + return 0; + } + + const category = extractCategoryHint(aiComment.body) || categoryFromPath(file); + const check = checkSuggestedYaml(suggested, category); + if (!check.ok) { + await say(pr, `Suggested entry does not validate, so it was not applied:\n\n- ${safe(check.problems.join('\n- '))}`, deps); + return 0; + } + + let data; + try { + data = yaml.load(suggested); + } catch (err) { + await say(pr, `Suggested entry is not valid YAML, so it was not applied: ${safe(err.message)}`, deps); + return 0; + } + const serialized = yaml.dump(data, { lineWidth: 1000 }).trim() + '\n'; + + if (dryRun) { + console.log(`would apply suggestion to ${file} on ${pull.head.ref} (PR #${pr})`); + return 0; + } + + try { + await commitToBranch({ file, content: serialized, headRef: pull.head.ref, sender }, deps); + } catch (err) { + console.error(`apply failed: ${err.message}`); + await say(pr, `Could not apply the suggested entry (${safe(err.message).slice(0, 200)}); please apply manually.`, deps); + return 1; + } + + try { + await deps.runValidate(); + } catch (err) { + console.error(`validate after apply failed: ${err.message}`); + await say(pr, `Applied the suggested entry to \`${safe(file)}\`, but \`npm run validate\` still reports problems; please review the check output.`, deps); + return 1; + } + + await say(pr, `Applied the AI suggested entry to \`${safe(file)}\` (commit on \`${safe(pull.head.ref)}\`); \`validate\` re-runs on the new commit.`, deps); + console.log(`applied suggestion to ${file} on PR #${pr}`); + return 0; +} + +async function main(argv = process.argv.slice(2), deps = defaultDeps) { + const opts = parseArgs(argv); + return applyAiFix(opts, deps); +} + +if (require.main === module) { + main().catch(err => { + console.error(err); + process.exit(1); + }).then(code => { process.exit(code); }); +} + +module.exports = { + APPLY_COMMAND, + applyAiFix, + categoryFromPath, + extractCategoryHint, + extractReviewedSha, + extractSuggestedYaml, + findAiComment, + isFresh, + isTriggerComment, + parseArgs, + targetFile +}; diff --git a/scripts/utils/ai-verdict.js b/scripts/utils/ai-verdict.js index e820468..27a1b6f 100644 --- a/scripts/utils/ai-verdict.js +++ b/scripts/utils/ai-verdict.js @@ -203,6 +203,9 @@ function renderComment(verdict, item, { model } = {}) { '
', '' ); + if (verdict.verdict === 'fix' && check.ok) { + lines.push('To apply this entry automatically, comment `/apply-ai-fix` on this PR (maintainers only).', ''); + } } lines.push(`Confidence ${verdict.confidence}/100 · reviewed commit \`${item.head_sha.slice(0, 7)}\`${model ? ` · ${safe(model)}` : ''} · advisory only: a maintainer decides. [How this works](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md#ai-review)`); return lines.join('\n'); diff --git a/test/apply-ai-fix.test.js b/test/apply-ai-fix.test.js new file mode 100644 index 0000000..f0dbb10 --- /dev/null +++ b/test/apply-ai-fix.test.js @@ -0,0 +1,190 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { MARKER, renderComment } = require('../scripts/utils/ai-verdict'); +const { + applyAiFix, + extractCategoryHint, + extractReviewedSha, + extractSuggestedYaml, + findAiComment, + isFresh, + isTriggerComment, + parseArgs, + targetFile +} = require('../scripts/apply-ai-fix'); + +const HEAD = 'abcdef0123456789abcdef0123456789abcdef01'; +const prItem = { type: 'pr', number: 42, head_sha: HEAD }; +const GOOD_YAML = 'name: Thing\nrepo: https://github.com/a/thing\ntagline: Does a thing\ndescription: Does a thing well.\nkind: tool\ntopics:\n - sessions\n'; +const fixVerdict = (over = {}) => ({ + type: 'pr', number: 42, head_sha: HEAD, verdict: 'fix', category: 'projects', kind: 'tool', + topics: ['sessions'], reasons: ['Standalone CLI, so it belongs in projects.'], + policy: { opencode_relevant: true, crypto: false, duplicate_of: null }, + suggested_yaml: GOOD_YAML, confidence: 80, ...over +}); +const aiBody = (yamlText = GOOD_YAML, sha = HEAD) => + `${MARKER}\n\n### AI review: fix\n\n**Suggested placement:** category \`projects\` · kind \`tool\`\n\n` + + `
Suggested entry (passes the catalog schema)\n\n\`\`\`yaml\n${yamlText}\`\`\`\n
\n`; + +test('renderComment fix with valid suggestion includes the apply hint', () => { + const body = renderComment(fixVerdict(), prItem); + assert.match(body, /passes the catalog schema/); + assert.match(body, /\/apply-ai-fix/); + assert.match(body, /comment `\/apply-ai-fix` on this PR \(maintainers only\)/); +}); + +test('renderComment omits the hint for approve and for invalid suggestions', () => { + const approve = renderComment({ ...fixVerdict(), verdict: 'approve', suggested_yaml: null }, prItem); + assert.doesNotMatch(approve, /\/apply-ai-fix/); + const bad = renderComment(fixVerdict({ suggested_yaml: 'name: [' }), prItem); + assert.match(bad, /does not validate/); + assert.doesNotMatch(bad, /\/apply-ai-fix/); +}); + +test('isTriggerComment only fires on the exact command string', () => { + assert.equal(isTriggerComment('/apply-ai-fix please'), true); + assert.equal(isTriggerComment('comment body without the command'), false); + assert.equal(isTriggerComment(null), false); +}); + +test('findAiComment picks the latest marker comment and ignores others', () => { + assert.equal(findAiComment([{ body: 'unrelated' }]), null); + assert.equal(findAiComment([]), null); + const comments = [{ body: `${MARKER}\nold` }, { body: 'unrelated' }, { body: `${MARKER}\nnew` }]; + assert.equal(findAiComment(comments).body, `${MARKER}\nnew`); +}); + +test('extractors handle missing sha, yaml and category', () => { + assert.equal(extractReviewedSha(aiBody()), HEAD); + assert.equal(extractReviewedSha('no marker here'), null); + assert.equal(extractSuggestedYaml(aiBody()).includes('name: Thing'), true); + assert.equal(extractSuggestedYaml(`${MARKER}\nno yaml here`), null); + assert.equal(extractCategoryHint(aiBody()), 'projects'); + assert.equal(extractCategoryHint('no hint'), null); + assert.equal(isFresh(HEAD, HEAD), true); + assert.equal(isFresh(HEAD.slice(0, 7), HEAD), true); + assert.equal(isFresh('1234567', HEAD), false); + assert.equal(isFresh(null, HEAD), false); +}); + +test('targetFile requires exactly one catalog file', () => { + assert.deepEqual(targetFile([]), { file: null, error: 'No catalog file in this PR' }); + assert.deepEqual(targetFile(['README.md']), { file: null, error: 'No catalog file in this PR' }); + assert.deepEqual(targetFile(['data/plugins/a.yaml']), { file: 'data/plugins/a.yaml', error: null }); + assert.deepEqual(targetFile(['data/plugins/a.yaml', 'data/themes/b.yaml']), { file: null, error: 'Multiple files; apply manually' }); +}); + +test('parseArgs reads the CLI flags', () => { + assert.deepEqual(parseArgs(['--pr', '42', '--sender', 'octo', '--comment-id', '7']), { pr: 42, sender: 'octo', commentId: 7, dryRun: false }); + assert.equal(parseArgs(['--pr', '42', '--sender', 'octo', '--dry-run']).dryRun, true); +}); + +function stubDeps(over = {}) { + const posted = []; + const gitCalls = []; + const written = {}; + return { + posted, + gitCalls, + written, + deps: { + getPull: async () => ({ + status: 200, + data: { number: 42, state: 'open', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'Heretek-AI/awesome-opencode' } }, base: { sha: 'base' } } + }), + getPermission: async () => ({ status: 200, data: { permission: 'write' } }), + listComments: async () => [{ body: aiBody() }], + listPrFiles: async () => [{ path: 'data/projects/thing.yaml', status: 'added' }], + postComment: async (number, body) => { posted.push({ number, body }); return {}; }, + fetchHead: async ref => { gitCalls.push(['fetch', ref]); }, + checkoutHead: async ref => { gitCalls.push(['checkout', ref]); }, + writeFile: (abs, content) => { written[abs] = content; }, + stageAndCommit: async (file, message) => { gitCalls.push(['commit', file, message]); }, + pushHead: async ref => { gitCalls.push(['push', ref]); }, + runValidate: async () => {}, + ...over + } + }; +} + +test('applyAiFix rejects senders without write access', async () => { + const { posted, gitCalls, deps } = stubDeps({ getPermission: async () => ({ status: 200, data: { permission: 'read' } }) }); + assert.equal(await applyAiFix({ pr: 42, sender: 'outsider' }, deps), 0); + assert.match(posted[0].body, /Only maintainers with write access/); + assert.deepEqual(gitCalls, []); +}); + +test('applyAiFix rejects unknown senders (permission 404)', async () => { + const { posted, deps } = stubDeps({ getPermission: async () => ({ status: 404, data: { message: 'Not Found' } }) }); + assert.equal(await applyAiFix({ pr: 42, sender: 'ghost' }, deps), 0); + assert.match(posted[0].body, /Only maintainers with write access/); +}); + +test('applyAiFix reports a missing AI comment', async () => { + const { posted, deps } = stubDeps({ listComments: async () => [{ body: 'hello' }] }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0); + assert.match(posted[0].body, /No AI review comment/); +}); + +test('applyAiFix rejects stale suggestions', async () => { + const { posted, gitCalls, deps } = stubDeps({ listComments: async () => [{ body: aiBody(GOOD_YAML, '1234567890abcdef1234567890abcdef12345678') }] }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0); + assert.match(posted[0].body, /stale/); + assert.deepEqual(gitCalls, []); +}); + +test('applyAiFix rejects fork PRs without touching git', async () => { + const { posted, gitCalls, deps } = stubDeps({ + getPull: async () => ({ + status: 200, + data: { number: 42, state: 'open', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'someone/awesome-opencode' } }, base: { sha: 'base' } } + }) + }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0); + assert.match(posted[0].body, /Fork PRs are not auto-updated/); + assert.deepEqual(gitCalls, []); +}); + +test('applyAiFix rejects zero and multiple catalog files', async () => { + const zero = stubDeps({ listPrFiles: async () => [{ path: 'README.md', status: 'modified' }] }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, zero.deps), 0); + assert.match(zero.posted[0].body, /No catalog file/); + + const multi = stubDeps({ listPrFiles: async () => [{ path: 'data/plugins/a.yaml' }, { path: 'data/themes/b.yaml' }] }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, multi.deps), 0); + assert.match(multi.posted[0].body, /Multiple files/); +}); + +test('applyAiFix rejects invalid suggestions without committing', async () => { + const { posted, gitCalls, deps } = stubDeps({ listComments: async () => [{ body: aiBody('name: [') }] }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0); + assert.match(posted[0].body, /does not validate/); + assert.deepEqual(gitCalls, []); +}); + +test('applyAiFix does nothing on closed PRs', async () => { + const { posted, deps } = stubDeps({ + getPull: async () => ({ + status: 200, + data: { number: 42, state: 'closed', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'Heretek-AI/awesome-opencode' } }, base: { sha: 'base' } } + }) + }); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0); + assert.deepEqual(posted, []); +}); + +test('applyAiFix dry-run never touches git; full run commits and comments success', async () => { + const dry = stubDeps(); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo', dryRun: true }, dry.deps), 0); + assert.deepEqual(dry.gitCalls, []); + assert.deepEqual(dry.posted, []); + + const full = stubDeps(); + assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, full.deps), 0); + assert.ok(full.gitCalls.length >= 4); + assert.match(full.posted[0].body, /Applied the AI suggested entry/); + const paths = Object.keys(full.written); + assert.equal(paths.length, 1); + assert.match(paths[0], /data.projects.thing\.yaml$/); + assert.match(full.written[paths[0]], /name: Thing/); +});