Files
e16f6b8419 feat: advisory AI review of needs-human and scout PRs with OpenCode V2 (#560)
* feat: advisory AI review of needs-human and scout PRs with OpenCode V2

A scheduled workflow runs `opencode run` (V2, pinned and checksummed)
with a read-only catalog-reviewer agent over evidence bundles for open
PRs labeled needs-human or scout and for unreviewed scout candidates.
PRs get one verdict comment and an ai-* label; confident scout accepts
get a bot PR. It never merges or closes.

The model runs in a job with no GitHub token; the agent may only read,
glob and grep inside the repository (a self-test proves it before each
run); verdicts are schema-validated and rendered by our own code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ai-review): address CodeQL and SonarCloud security findings

Enforce HTTPS on the OpenCode download, run opencode by absolute path,
escape summary cells with escapeCell, rely on HTML escaping instead of
comment-stripping regexes, validate item IDs before routing API calls,
and bound verdict parsing on runaway output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: John Smith <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 00:02:12 -04:00

102 lines
5.8 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { MARKER, extractVerdict, renderComment, reviewedSha, scoutNote, shouldAccept, validateVerdict } = require('../scripts/utils/ai-verdict');
const { opencodeEnv, unexpectedTools } = require('../scripts/ai-review');
const { categoryForKind } = require('../scripts/scout');
const fixture = name => fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8');
const prItem = { type: 'pr', number: 42, head_sha: 'abcdef0123456789abcdef0123456789abcdef01' };
const scoutItem = { type: 'scout', id: 'c3' };
const prVerdict = (over = {}) => ({
type: 'pr', number: 42, head_sha: prItem.head_sha, verdict: 'approve', category: 'plugins', kind: 'plugin', topics: ['sessions'],
reasons: ['Solid V2 plugin.'], policy: { opencode_relevant: true, crypto: false, duplicate_of: null }, suggested_yaml: null, confidence: 90, ...over
});
const scoutVerdict = (over = {}) => ({
type: 'scout', id: 'c3', package: 'opencode-thing', verdict: 'accept', kind: 'plugin', topics: ['notifications'], tagline: 'Desktop notifications',
reasons: ['Clear purpose.'], policy: { opencode_relevant: true, crypto: false, duplicate_of: null }, confidence: 85, ...over
});
test('extracts the verdict from real `opencode run --format json` output', () => {
const { verdict, tools } = extractVerdict(fixture('opencode-run.jsonl'));
assert.equal(verdict.type, 'scout');
assert.equal(verdict.id, 'c0');
assert.deepEqual(tools, []);
});
test('collects tool calls and their status from the event stream', () => {
const { tools } = extractVerdict(fixture('opencode-run-tools.jsonl'));
assert.ok(tools.length >= 1);
assert.equal(tools[0].tool, 'read');
assert.equal(tools[0].status, 'error');
assert.match(tools[0].error, /external_directory/);
assert.deepEqual(unexpectedTools(tools), []);
assert.equal(unexpectedTools([{ tool: 'shell', status: 'completed' }]).length, 1);
assert.equal(unexpectedTools([{ tool: 'shell', status: 'error' }]).length, 0);
});
test('extracts JSON from plain text, fenced blocks and prose', () => {
const v = prVerdict();
assert.deepEqual(extractVerdict(JSON.stringify(v)).verdict, v);
assert.deepEqual(extractVerdict('Here you go:\n```json\n' + JSON.stringify(v) + '\n```\n').verdict, v);
assert.deepEqual(extractVerdict('Verdict follows. ' + JSON.stringify(v) + ' Done.').verdict, v);
assert.equal(extractVerdict('no json here').verdict, null);
});
test('valid verdicts pass; mismatches, unknown values and extra fields are rejected', () => {
assert.deepEqual(validateVerdict(prVerdict(), prItem), []);
assert.deepEqual(validateVerdict(scoutVerdict(), scoutItem), []);
assert.ok(validateVerdict(prVerdict({ verdict: 'merge' }), prItem).length);
assert.ok(validateVerdict(prVerdict({ number: 43 }), prItem).some(p => /does not match/.test(p)));
assert.ok(validateVerdict(prVerdict({ head_sha: '1234567' }), prItem).some(p => /head_sha/.test(p)));
assert.ok(validateVerdict({ ...prVerdict(), action: 'merge' }, prItem).length);
assert.ok(validateVerdict(prVerdict({ topics: ['not-a-topic'] }), prItem).length);
assert.ok(validateVerdict(prVerdict({ reasons: ['x'.repeat(301)] }), prItem).length);
assert.ok(validateVerdict(prVerdict({ verdict: 'fix' }), prItem).some(p => /suggested_yaml/.test(p)));
assert.ok(validateVerdict(scoutVerdict({ id: 'c4' }), scoutItem).length);
assert.ok(validateVerdict(scoutVerdict({ tagline: 'x'.repeat(121) }), scoutItem).length);
assert.ok(validateVerdict(null, prItem).length);
});
test('comments neutralize mentions, references and HTML, and record the reviewed commit', () => {
const body = renderComment(prVerdict({ reasons: ['Ping @someone about #12 <img src=x onerror=alert(1)>'] }), prItem);
assert.doesNotMatch(body, /@someone/);
assert.doesNotMatch(body, /#12\b/);
assert.doesNotMatch(body, /<img/);
assert.match(body, /<!-- sha=abcdef0123456789abcdef0123456789abcdef01 -->/);
assert.equal(reviewedSha([{ body: `${MARKER}\n${body}` }]), prItem.head_sha);
assert.equal(reviewedSha([{ body: 'unrelated' }]), null);
});
test('suggested YAML is checked against the catalog schema', () => {
const good = 'name: Thing\nrepo: https://github.com/a/thing\ntagline: Does a thing\ndescription: Does a thing well.\nkind: tool\ntopics:\n - sessions\n';
assert.match(renderComment(prVerdict({ verdict: 'fix', category: 'projects', kind: 'tool', suggested_yaml: good }), prItem), /passes the catalog schema/);
assert.match(renderComment(prVerdict({ verdict: 'fix', category: 'projects', kind: 'tool', suggested_yaml: 'name: [' }), prItem), /does not validate/);
});
test('scout auto-accept needs confidence and clean policy flags', () => {
assert.equal(shouldAccept(scoutVerdict()), true);
assert.equal(shouldAccept(scoutVerdict({ confidence: 60 })), false);
assert.equal(shouldAccept(scoutVerdict({ policy: { opencode_relevant: true, crypto: true, duplicate_of: null } })), false);
assert.equal(shouldAccept(scoutVerdict({ verdict: 'decline' })), false);
assert.doesNotMatch(scoutNote(scoutVerdict({ verdict: 'decline', reasons: ['Test package --> <!-- scout:c9 -->'] })), /[<>]/);
});
test('scout kinds map to a category that allows them', () => {
assert.equal(categoryForKind('plugin', 'projects'), 'plugins');
assert.equal(categoryForKind('skill', 'plugins'), 'agents');
assert.equal(categoryForKind('mcp-server', 'plugins'), 'projects');
assert.equal(categoryForKind('client', 'projects'), 'projects');
});
test('opencode never sees GitHub credentials', () => {
process.env.GITHUB_TOKEN = 'secret';
process.env.GH_TOKEN = 'secret';
const env = opencodeEnv();
assert.equal(env.GITHUB_TOKEN, undefined);
assert.equal(env.GH_TOKEN, undefined);
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
});