mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 02:35:58 -10:00
feat: one-click /apply-ai-fix for AI review suggested entries (#590)
- renderComment() adds maintainer hint for fix+valid YAML - new apply-ai-fix.yml workflow (issue_comment trigger, permission-gated) - new scripts/apply-ai-fix.js (extract, validate, commit to PR branch) - docs/policy.md documents command - tests: 16 new, 46 total pass Co-authored-by: John Smith <[email protected]>
This commit is contained in:
1 parent
6d3eab37e1
commit
86e93031fb
5 files changed
+537
No files matched your search
@@ -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 `<!-- ai-review -->`), 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 }}"
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <n> --sender <login> [--comment-id <id>] [--dry-run]
|
||||
*
|
||||
* Flow: fetch the PR (must be open) -> check the sender has write/admin via the
|
||||
* collaborators permission API -> find the latest `<!-- ai-review -->` comment ->
|
||||
* require its reviewed SHA to match the PR head (fail closed on stale) -> extract the
|
||||
* first ```yaml block inside its <details> -> 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 = /<!-- sha=([0-9a-f]{7,40}) -->/;
|
||||
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 <details> 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 = /<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/<category>/...), 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 <n>');
|
||||
if (!sender || typeof sender !== 'string') throw new Error('apply-ai-fix needs --sender <login>');
|
||||
|
||||
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
|
||||
};
|
||||
@@ -203,6 +203,9 @@ function renderComment(verdict, item, { model } = {}) {
|
||||
'</details>',
|
||||
''
|
||||
);
|
||||
if (verdict.verdict === 'fix' && check.ok) {
|
||||
lines.push('To apply this entry automatically, comment `/apply-ai-fix` on this PR (maintainers only).', '');
|
||||
}
|
||||
}
|
||||
lines.push(`<sub>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)</sub>`);
|
||||
return lines.join('\n');
|
||||
|
||||
@@ -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<!-- sha=${sha} -->\n### AI review: fix\n\n**Suggested placement:** category \`projects\` · kind \`tool\`\n\n` +
|
||||
`<details><summary>Suggested entry (passes the catalog schema)</summary>\n\n\`\`\`yaml\n${yamlText}\`\`\`\n</details>\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/);
|
||||
});
|
||||
Reference in new issue
Block a user