/** * AI review verdicts: parse `opencode run --format json` output, validate it strictly, and render it. * * The model's reply is untrusted (it read third-party text), so nothing it says is acted on until it * passes validateVerdict(), and every string is escaped before it reaches a comment. */ const Ajv = require('ajv'); const yaml = require('js-yaml'); const { CATEGORIES, KINDS, TOPICS, checkEntry } = require('./catalog'); const { escapeHtml } = require('./template'); const MARKER = ''; const AI_LABELS = { approve: 'ai-approve', fix: 'ai-fix', close: 'ai-close', unsure: 'ai-unsure' }; const ACCEPT_CONFIDENCE = 70; const policy = { type: 'object', additionalProperties: false, required: ['opencode_relevant', 'crypto', 'duplicate_of'], properties: { opencode_relevant: { type: 'boolean' }, crypto: { type: 'boolean' }, duplicate_of: { type: ['string', 'null'], maxLength: 200 } } }; const common = { kind: { anyOf: [{ enum: KINDS }, { type: 'null' }] }, topics: { type: 'array', maxItems: 3, uniqueItems: true, items: { enum: TOPICS } }, reasons: { type: 'array', minItems: 1, maxItems: 5, items: { type: 'string', minLength: 1, maxLength: 300 } }, policy, confidence: { type: 'integer', minimum: 0, maximum: 100 } }; const SCHEMAS = { pr: { type: 'object', additionalProperties: false, required: ['type', 'number', 'head_sha', 'verdict', 'category', 'kind', 'topics', 'reasons', 'policy', 'suggested_yaml', 'confidence'], properties: { type: { const: 'pr' }, number: { type: 'integer' }, head_sha: { type: 'string', pattern: '^[0-9a-f]{7,40}$' }, verdict: { enum: Object.keys(AI_LABELS) }, category: { anyOf: [{ enum: CATEGORIES }, { type: 'null' }] }, suggested_yaml: { type: ['string', 'null'], maxLength: 4000 }, ...common } }, scout: { type: 'object', additionalProperties: false, required: ['type', 'id', 'package', 'verdict', 'kind', 'topics', 'tagline', 'reasons', 'policy', 'confidence'], properties: { type: { const: 'scout' }, id: { type: 'string', pattern: '^c\\d+$' }, package: { type: 'string', maxLength: 214 }, verdict: { enum: ['accept', 'decline', 'unsure'] }, tagline: { type: ['string', 'null'], minLength: 1, maxLength: 120 }, ...common } } }; const ajv = new Ajv({ allErrors: true, strict: false }); const validators = Object.fromEntries(Object.entries(SCHEMAS).map(([type, schema]) => [type, ajv.compile(schema)])); /** Last JSON object in a block of text: a fenced ```json block, else the last balanced {...}. */ function lastJsonObject(text) { const fenced = [...String(text).matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)].map(m => m[1]); for (const block of fenced.reverse()) { try { return JSON.parse(block); } catch { /* try the next one */ } } const s = String(text); for (let end = s.lastIndexOf('}'); end >= 0; end = s.lastIndexOf('}', end - 1)) { let depth = 0; let inString = false; for (let i = end; i >= 0; i--) { const ch = s[i]; if (ch === '"' && s[i - 1] !== '\\') inString = !inString; if (inString) continue; if (ch === '}') depth++; else if (ch === '{' && --depth === 0) { try { return JSON.parse(s.slice(i, end + 1)); } catch { break; } } } } return null; } /** * Pull the verdict out of `opencode run --format json` output (JSON Lines events; the reply is in * `text` events at part.text). Falls back to treating the whole output as plain text. * @returns {{verdict: object|null, text: string, tools: {tool: string, status: string, input: object, error?: string}[], errors: string[]}} */ function extractVerdict(stdout) { const MAX = 400 * 1024; // the verdict is at the end; bound the work on runaway output stdout = String(stdout).slice(-MAX); const texts = []; const tools = []; const errors = []; let events = 0; for (const line of String(stdout).split('\n')) { if (!line.trim().startsWith('{')) continue; let event; try { event = JSON.parse(line); } catch { continue; } // OpenCode events carry a session and a part; a bare verdict object (also with a `type`) is not one if (!event || typeof event.type !== 'string' || !(event.part || event.sessionID)) continue; events++; if (event.type === 'text' && event.part && typeof event.part.text === 'string') texts.push(event.part.text); if (event.type === 'error' && event.error) errors.push(`${event.error.type || 'error'}: ${event.error.message || ''}`.slice(0, 300)); if (event.type === 'tool_use' && event.part) { const state = event.part.state || {}; tools.push({ tool: event.part.tool, status: state.status, input: state.input || {}, error: state.error }); } } const text = events ? texts.join('\n') : String(stdout); // The reply should be the final text part; search it first, then everything const verdict = (texts.length && lastJsonObject(texts[texts.length - 1])) || lastJsonObject(text); return { verdict, text, tools, errors }; } /** * Validate a verdict against the schema and the item it is supposed to describe. * @param {object} verdict * @param {{type: 'pr'|'scout', number?: number, head_sha?: string, id?: string}} item * @returns {string[]} problems; empty when the verdict can be acted on */ function validateVerdict(verdict, item) { if (!verdict || typeof verdict !== 'object' || Array.isArray(verdict)) return ['reply contained no JSON object']; const validate = validators[item.type]; if (!validate) return [`unknown item type ${item.type}`]; const problems = validate(verdict) ? [] : validate.errors.map(e => `${e.instancePath || '/'} ${e.message}`); if (item.type === 'pr') { if (verdict.number !== item.number) problems.push(`number ${verdict.number} does not match PR #${item.number}`); if (typeof verdict.head_sha === 'string' && !item.head_sha.startsWith(verdict.head_sha)) problems.push('head_sha does not match the reviewed commit'); if (verdict.verdict === 'fix' && !verdict.suggested_yaml) problems.push('verdict fix needs suggested_yaml'); } else { if (verdict.id !== item.id) problems.push(`id ${verdict.id} does not match candidate ${item.id}`); if (verdict.verdict === 'accept' && !verdict.tagline) problems.push('verdict accept needs a tagline'); } return problems; } /** Neutralize text for a GitHub comment: escape HTML and break @mentions and #references. */ function safe(text) { return escapeHtml(String(text).replace(/[\r\n]+/g, ' ')) .replace(/@(?=[A-Za-z0-9])/g, '@​') .replace(/#(?=\d)/g, '#​'); } /** * Check a suggested YAML entry with the catalog's own rules. * @returns {{ok: boolean, problems: string[], text: string}} */ function checkSuggestedYaml(text, category) { let data; try { data = yaml.load(text); } catch (err) { return { ok: false, problems: [`invalid YAML: ${err.reason || err.message}`], text }; } if (!data || typeof data !== 'object' || Array.isArray(data)) return { ok: false, problems: ['not a YAML mapping'], text }; const problems = category ? checkEntry({ category, id: 'suggested', file: `data/${category}/suggested.yaml`, data }) : ['no category given']; return { ok: problems.length === 0, problems, text }; } /** * Comment body for a PR verdict, rendered by us from validated fields (never the model's prose as-is). * upsertComment() prepends MARKER; the sha comment records which commit was reviewed. */ function renderComment(verdict, item, { model } = {}) { const head = { approve: '✅ **Recommend merging**', fix: '🛠️ **Recommend merging after the changes below**', close: '🚫 **Recommend closing**', unsure: '❔ **Needs a maintainer**: the AI review could not decide' }[verdict.verdict]; const lines = [ ``, `### AI review: ${head}`, '', ...verdict.reasons.map(r => `- ${safe(r)}`), '' ]; const fields = []; if (verdict.category) fields.push(`category \`${verdict.category}\``); if (verdict.kind) fields.push(`kind \`${verdict.kind}\``); if (verdict.topics.length) fields.push(`topics ${verdict.topics.map(t => `\`${t}\``).join(', ')}`); if (fields.length) lines.push(`**Suggested placement:** ${fields.join(' · ')}`, ''); const flags = []; if (!verdict.policy.opencode_relevant) flags.push('not clearly OpenCode-related'); if (verdict.policy.crypto) flags.push('crypto/token/trading project (zero tolerance)'); if (verdict.policy.duplicate_of) flags.push(`possible duplicate of \`${safe(verdict.policy.duplicate_of).replace(/`/g, '')}\``); if (flags.length) lines.push(`**Policy flags:** ${flags.join('; ')}`, ''); if (verdict.suggested_yaml) { const check = checkSuggestedYaml(verdict.suggested_yaml, verdict.category); lines.push( '
Suggested entry' + (check.ok ? ' (passes the catalog schema)' : ` (does not validate: ${safe(check.problems.join('; ')).slice(0, 300)})`) + '', '', '```yaml', verdict.suggested_yaml.replace(/```/g, "'''").trim(), '```', '
', '' ); } 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'); } /** Short note appended to a scout candidate line. */ function scoutNote(verdict) { // safe() escapes < and >, so the note cannot open or close the line's marker const reason = safe(verdict.reasons[0]).slice(0, 160); return ` — AI: ${verdict.verdict} (${reason})`; } /** True when a scout verdict is confident and clean enough to open a PR automatically. */ function shouldAccept(verdict) { return verdict.verdict === 'accept' && verdict.confidence >= ACCEPT_CONFIDENCE && verdict.policy.opencode_relevant && !verdict.policy.crypto && !verdict.policy.duplicate_of; } /** Head SHA recorded in an existing AI review comment, if any. */ function reviewedSha(comments) { const found = [...comments].reverse().find(c => (c.body || '').startsWith(MARKER)); const match = found && //.exec(found.body); return match ? match[1] : null; } module.exports = { ACCEPT_CONFIDENCE, AI_LABELS, MARKER, checkSuggestedYaml, extractVerdict, lastJsonObject, renderComment, reviewedSha, safe, scoutNote, shouldAccept, validateVerdict };