mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 10:45:57 -10:00
* 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]>
180 lines
7.9 KiB
JavaScript
180 lines
7.9 KiB
JavaScript
/**
|
|
* Helpers for acting on this repository's issues and pull requests via the GitHub REST API.
|
|
*/
|
|
|
|
const { rest } = require('./github');
|
|
|
|
const REPO = process.env.GITHUB_REPOSITORY || 'Heretek-AI/awesome-opencode';
|
|
|
|
// Labels the bots manage: name -> [color, description]
|
|
const BOT_LABELS = {
|
|
'needs-v2': ['d93f0b', 'Plugin targets the legacy V1 API; re-checked nightly'],
|
|
'needs-metadata': ['fbca04', 'Entry fails schema or catalog rules'],
|
|
'needs-human': ['c5def5', 'Automated checks could not decide; maintainer review needed'],
|
|
ready: ['0e8a16', 'All automated checks pass'],
|
|
submission: ['1d76db', 'Entry submitted through the issue form'],
|
|
scout: ['5319e7', 'Weekly discovery of V2 plugin candidates'],
|
|
health: ['b60205', 'Catalog link-rot and maintenance tracking'],
|
|
'v1-sunset': ['e99695', 'V1-only plugin scheduled for removal at the sunset date'],
|
|
'ai-approve': ['0e8a16', 'AI review recommends merging (advisory)'],
|
|
'ai-fix': ['fbca04', 'AI review recommends merging after changes (advisory)'],
|
|
'ai-close': ['b60205', 'AI review recommends closing (advisory)'],
|
|
'ai-unsure': ['c5def5', 'AI review could not decide'],
|
|
'category: plugins': ['bfdadc', 'Touches data/plugins'],
|
|
'category: themes': ['bfdadc', 'Touches data/themes'],
|
|
'category: agents': ['bfdadc', 'Touches data/agents'],
|
|
'category: projects': ['bfdadc', 'Touches data/projects'],
|
|
'category: resources': ['bfdadc', 'Touches data/resources']
|
|
};
|
|
|
|
const TRIAGE_LABELS = ['needs-v2', 'needs-metadata', 'needs-human', 'ready'];
|
|
|
|
async function check(promise, what) {
|
|
const res = await promise;
|
|
if (res.status >= 300) throw new Error(`${what} failed: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 300)}`);
|
|
return res.data;
|
|
}
|
|
|
|
/**
|
|
* Create any missing bot labels.
|
|
*/
|
|
async function ensureLabels(names = Object.keys(BOT_LABELS)) {
|
|
for (const name of names) {
|
|
const [color, description] = BOT_LABELS[name] || ['ededed', ''];
|
|
const res = await rest(`/repos/${REPO}/labels`, { method: 'POST', body: { name, color, description } });
|
|
if (res.status !== 201 && res.status !== 422) throw new Error(`create label ${name}: HTTP ${res.status}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make the triage labels on an issue/PR exactly `wanted` (other labels untouched).
|
|
*/
|
|
async function setTriageLabels(number, wanted, current) {
|
|
const have = new Set(current);
|
|
const add = wanted.filter(l => !have.has(l));
|
|
const remove = TRIAGE_LABELS.filter(l => have.has(l) && !wanted.includes(l));
|
|
if (add.length) await ensureLabels(add);
|
|
if (add.length) await check(rest(`/repos/${REPO}/issues/${number}/labels`, { method: 'POST', body: { labels: add } }), 'add labels');
|
|
for (const label of remove) {
|
|
await rest(`/repos/${REPO}/issues/${number}/labels/${encodeURIComponent(label)}`, { method: 'DELETE' });
|
|
}
|
|
return { add, remove };
|
|
}
|
|
|
|
async function addLabels(number, labels) {
|
|
if (!labels.length) return;
|
|
await ensureLabels(labels.filter(l => BOT_LABELS[l]));
|
|
await check(rest(`/repos/${REPO}/issues/${number}/labels`, { method: 'POST', body: { labels } }), 'add labels');
|
|
}
|
|
|
|
async function listComments(number) {
|
|
const comments = [];
|
|
for (let page = 1; ; page++) {
|
|
const batch = await check(rest(`/repos/${REPO}/issues/${number}/comments?per_page=100&page=${page}`), 'list comments');
|
|
comments.push(...batch);
|
|
if (batch.length < 100) return comments;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create or update the single bot comment identified by `marker`.
|
|
*/
|
|
async function upsertComment(number, marker, body) {
|
|
const full = `${marker}\n${body}`;
|
|
const existing = (await listComments(number)).find(c => c.body && c.body.startsWith(marker));
|
|
if (existing) {
|
|
if (existing.body === full) return existing;
|
|
return check(rest(`/repos/${REPO}/issues/comments/${existing.id}`, { method: 'PATCH', body: { body: full } }), 'update comment');
|
|
}
|
|
return check(rest(`/repos/${REPO}/issues/${number}/comments`, { method: 'POST', body: { body: full } }), 'create comment');
|
|
}
|
|
|
|
async function comment(number, body) {
|
|
return check(rest(`/repos/${REPO}/issues/${number}/comments`, { method: 'POST', body: { body } }), 'create comment');
|
|
}
|
|
|
|
/**
|
|
* All open pull requests (with head repo/ref/sha and labels).
|
|
*/
|
|
async function listOpenPulls() {
|
|
const pulls = [];
|
|
for (let page = 1; ; page++) {
|
|
const batch = await check(rest(`/repos/${REPO}/pulls?state=open&per_page=100&page=${page}`), 'list pulls');
|
|
pulls.push(...batch);
|
|
if (batch.length < 100) return pulls;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Files changed in a PR, with the new text of data files read at the PR head.
|
|
* @returns {Promise<{path: string, status: string, content?: string, baseContent?: string}[]>}
|
|
*/
|
|
async function pullChanges(pull, { wantContent = p => p.startsWith('data/') && p.endsWith('.yaml') } = {}) {
|
|
const files = [];
|
|
for (let page = 1; ; page++) {
|
|
const batch = await check(rest(`/repos/${REPO}/pulls/${pull.number}/files?per_page=100&page=${page}`), 'list PR files');
|
|
files.push(...batch);
|
|
if (batch.length < 100) break;
|
|
}
|
|
const headRepo = (pull.head.repo && pull.head.repo.full_name) || REPO;
|
|
const changes = [];
|
|
for (const f of files) {
|
|
if (f.status === 'renamed') {
|
|
changes.push({ path: f.previous_filename, status: 'removed' });
|
|
changes.push({ path: f.filename, status: 'added' });
|
|
} else {
|
|
changes.push({ path: f.filename, status: f.status === 'changed' ? 'modified' : f.status });
|
|
}
|
|
}
|
|
for (const change of changes) {
|
|
if (change.status === 'removed' || !wantContent(change.path)) continue;
|
|
change.content = await fileText(headRepo, change.path, pull.head.sha);
|
|
if (change.status === 'modified') change.baseContent = await fileText(REPO, change.path, pull.base.sha);
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
async function fileText(repo, filePath, ref) {
|
|
const res = await rest(`/repos/${repo}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}?ref=${ref}`);
|
|
if (res.status !== 200 || !res.data || typeof res.data.content !== 'string') return undefined;
|
|
return Buffer.from(res.data.content, 'base64').toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Approve the pull_request "validate" run(s) waiting on a commit. GitHub holds pull_request runs for PRs
|
|
* opened or updated by github-actions[bot] as action_required, and only that run satisfies the required
|
|
* check on main (a workflow_dispatch run on the same commit does not).
|
|
* @param {string} sha - PR head commit
|
|
* @param {object} [options] - { waitSeconds: how long to wait for the run to appear }
|
|
* @returns {Promise<number>} number of runs approved
|
|
*/
|
|
async function approvePendingRuns(sha, { waitSeconds = 60 } = {}) {
|
|
for (let waited = 0; ; waited += 5) {
|
|
const runs = await check(rest(`/repos/${REPO}/actions/workflows/validate-pr.yml/runs?head_sha=${sha}&event=pull_request&per_page=10`), 'list runs');
|
|
const pending = (runs.workflow_runs || []).filter(r => r.conclusion === 'action_required');
|
|
for (const run of pending) await rest(`/repos/${REPO}/actions/runs/${run.id}/approve`, { method: 'POST' });
|
|
if (pending.length || (runs.workflow_runs || []).length || waited >= waitSeconds) return pending.length;
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the "validate" check running again for a PR's head commit: approve a held run, otherwise re-run
|
|
* the latest pull_request run.
|
|
*/
|
|
async function revalidate(pull) {
|
|
if (await approvePendingRuns(pull.head.sha, { waitSeconds: 30 })) return true;
|
|
const runs = await check(rest(`/repos/${REPO}/actions/workflows/validate-pr.yml/runs?head_sha=${pull.head.sha}&event=pull_request&per_page=5`), 'list runs');
|
|
const run = runs.workflow_runs && runs.workflow_runs[0];
|
|
if (run && run.status === 'completed') {
|
|
await rest(`/repos/${REPO}/actions/runs/${run.id}/rerun`, { method: 'POST' });
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
module.exports = {
|
|
BOT_LABELS, REPO, TRIAGE_LABELS,
|
|
addLabels, approvePendingRuns, comment, ensureLabels, fileText, listComments, listOpenPulls, pullChanges, revalidate, setTriageLabels, upsertComment
|
|
};
|