mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 10:45:57 -10:00
feat(ci): required validate check with V2 probe, and pull_request_target triage
validate-pr.yml now runs on every PR (README-only PRs previously got no feedback), drops tj-actions/changed-files, runs unit tests, scripts/check-pr.js and a README/registry generation dry run, and supports workflow_dispatch so bot branches get the check. New plugin entries that are V1-only are blocked; declared compat must match what is detected. triage.yml labels PRs (ready, needs-v2, needs-metadata, needs-human, category) and keeps one results comment, using base-branch code only and reading PR files as data. The README workflow uses full history and Node 24. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bb2827524b
commit
ef6357d8d1
12 files changed
+1148
-23
No files matched your search
@@ -6,6 +6,8 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'data/**'
|
||||
- 'templates/**'
|
||||
- 'scripts/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -21,15 +23,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
# Full history: "Recently added" is derived from when each entry file was first committed
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -46,6 +51,7 @@ jobs:
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
# Both scripts validate the whole catalog and exit non-zero without writing on any error
|
||||
node scripts/generate-readme.js
|
||||
node scripts/export-json.js --pretty --output dist/registry.json
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Labels PRs (needs-v2 / needs-metadata / needs-human / ready, plus category) and keeps one comment
|
||||
# with the check results up to date.
|
||||
#
|
||||
# Security: pull_request_target runs with a write token, so this workflow checks out the BASE branch
|
||||
# only. PR files are fetched through the API and parsed as data; PR code is never executed here.
|
||||
name: Triage PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: triage-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Label and comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: node scripts/triage.js --pr "$PR_NUMBER"
|
||||
@@ -1,39 +1,49 @@
|
||||
name: Validate PR YAML Files
|
||||
# The required "validate" status check. Runs on every PR (including README-only and non-data PRs, so
|
||||
# hand edits to generated files get feedback) and on workflow_dispatch, which bot workflows use to
|
||||
# attach the check to branches they push with GITHUB_TOKEN.
|
||||
name: Validate PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'data/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Get changed YAML files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v44
|
||||
with:
|
||||
files: |
|
||||
data/**/*.yaml
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate YAML files
|
||||
- name: Check catalog changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
run: node scripts/check-pr.js --base "$BASE_REF"
|
||||
|
||||
- name: README and registry still generate
|
||||
run: |
|
||||
if [[ -n "${{ steps.changed-files.outputs.all_changed_files }}" ]]; then
|
||||
node scripts/validate.js ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
else
|
||||
echo "No YAML files changed, skipping validation"
|
||||
fi
|
||||
node scripts/generate-readme.js
|
||||
node scripts/export-json.js --output "$RUNNER_TEMP/registry.json"
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The required "validate" check for pull requests.
|
||||
* Usage: node scripts/check-pr.js --base <git-ref> [--no-probe]
|
||||
*
|
||||
* Compares HEAD against the merge base with <git-ref>, analyzes the changed files, writes a report to
|
||||
* $GITHUB_STEP_SUMMARY (when set) and exits 1 on any blocking problem.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { loadCatalog } = require('./utils/catalog');
|
||||
const { DATA_FILE_RE, analyzePullRequest, formatReport } = require('./utils/pr-checks');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
|
||||
function parseArgs(argv) {
|
||||
const baseIndex = argv.indexOf('--base');
|
||||
return {
|
||||
base: baseIndex !== -1 ? argv[baseIndex + 1] : 'origin/main',
|
||||
probe: !argv.includes('--no-probe')
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS = { A: 'added', M: 'modified', D: 'removed', R: 'renamed', C: 'added', T: 'modified' };
|
||||
|
||||
function changedFiles(mergeBase) {
|
||||
const changes = [];
|
||||
for (const line of git('diff', '--name-status', '-M', `${mergeBase}...HEAD`).split('\n').filter(Boolean)) {
|
||||
const [code, first, second] = line.split('\t');
|
||||
const status = STATUS[code[0]] || 'modified';
|
||||
if (status === 'renamed') {
|
||||
changes.push({ path: first, status: 'removed' });
|
||||
changes.push({ path: second, status: 'added' });
|
||||
} else {
|
||||
changes.push({ path: first, status });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { base, probe } = parseArgs(process.argv.slice(2));
|
||||
const mergeBase = git('merge-base', base, 'HEAD').trim();
|
||||
const changes = changedFiles(mergeBase);
|
||||
|
||||
for (const change of changes) {
|
||||
if (!DATA_FILE_RE.test(change.path) || change.status === 'removed') continue;
|
||||
change.content = fs.readFileSync(path.join(ROOT, change.path), 'utf8');
|
||||
if (change.status === 'modified') {
|
||||
try { change.baseContent = git('show', `${mergeBase}:${change.path}`); } catch { /* new in base range */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Base catalog = current checkout minus/plus this PR's data changes is what analyzePullRequest rebuilds,
|
||||
// so start from the full head catalog with the changed files removed.
|
||||
const changedPaths = new Set(changes.map(c => c.path));
|
||||
const { entries, errors: loadErrors } = loadCatalog();
|
||||
const baseEntries = entries.filter(e => !changedPaths.has(e.file));
|
||||
|
||||
const result = await analyzePullRequest({ changes, baseEntries, probe });
|
||||
// Unparseable files anywhere in the catalog break README generation, so they always block
|
||||
for (const err of loadErrors) {
|
||||
if (!result.errors.some(e => e.file === err.file)) result.errors.push({ ...err, kind: 'metadata' });
|
||||
}
|
||||
|
||||
const report = formatReport(result);
|
||||
console.log(report);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report + '\n');
|
||||
for (const e of result.errors) console.log(`::error file=${e.file}::${e.message.replace(/\n/g, ' ')}`);
|
||||
for (const w of result.warnings) console.log(`::warning file=${w.file}::${w.message.replace(/\n/g, ' ')}`);
|
||||
|
||||
process.exit(result.errors.length ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Label and comment on a pull request based on the catalog checks.
|
||||
* Usage: node scripts/triage.js --pr <number> [--dry-run]
|
||||
*
|
||||
* Runs from the pull_request_target workflow with the BASE branch checked out: PR files are read
|
||||
* through the API as data and never executed, so this is safe for fork PRs and gives feedback
|
||||
* even before a maintainer approves the PR's own workflow runs.
|
||||
*/
|
||||
|
||||
const { loadCatalog } = require('./utils/catalog');
|
||||
const { analyzePullRequest, formatReport } = require('./utils/pr-checks');
|
||||
const { REPO, pullChanges, setTriageLabels, addLabels, upsertComment } = require('./utils/repo');
|
||||
const { rest } = require('./utils/github');
|
||||
|
||||
const MARKER = '<!-- awesome-opencode:triage -->';
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const number = Number(argv[argv.indexOf('--pr') + 1]);
|
||||
const dryRun = argv.includes('--dry-run');
|
||||
if (!number) throw new Error('usage: triage.js --pr <number>');
|
||||
|
||||
const { data: pull } = await rest(`/repos/${REPO}/pulls/${number}`);
|
||||
if (!pull || !pull.head) throw new Error(`PR #${number} not found`);
|
||||
|
||||
const changes = await pullChanges(pull);
|
||||
const { entries } = loadCatalog();
|
||||
const result = await analyzePullRequest({ changes, baseEntries: entries });
|
||||
const report = formatReport(result);
|
||||
const current = pull.labels.map(l => l.name);
|
||||
|
||||
console.log(report);
|
||||
console.log('labels:', result.labels.join(', ') || '(none)');
|
||||
if (dryRun) return;
|
||||
|
||||
await setTriageLabels(number, result.labels, current);
|
||||
await addLabels(number, result.categories.map(c => `category: ${c}`).filter(l => !current.includes(l)));
|
||||
if (result.dataChanged || result.errors.length) {
|
||||
await upsertComment(number, MARKER, report);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* OpenCode V1/V2 compatibility probe.
|
||||
*
|
||||
* V2 marker: any dependency on an @opencode/* package (@opencode/plugin, client, sdk, theme, core),
|
||||
* including npm aliases like "plugin-v2": "npm:@opencode/[email protected]". The V2 API also shipped as
|
||||
* pre-releases under the old name before the rename (@opencode-ai/[email protected]*, @next, @beta,
|
||||
* and @opencode-ai/client, which only exists in the V2 era); those count as V2 too. Packages that
|
||||
* depend on both generations are dual-support and count as V2.
|
||||
* V1 marker: @opencode-ai/plugin or @opencode-ai/sdk at a 1.x (or unpinned) version, with no V2 marker.
|
||||
*
|
||||
* Sources, in order: the entry's npm package (latest version), then package.json files in the repo
|
||||
* (at `path`, the root, and workspace packages under packages/ or plugins/), then source text for
|
||||
* gists. Anything else is "unknown" and needs a human look.
|
||||
*/
|
||||
|
||||
const zlib = require('zlib');
|
||||
const { getFiles, getNpmLatest, getToken, rest } = require('./github');
|
||||
const { parseRepoUrl } = require('./catalog');
|
||||
|
||||
const SOURCE_FILE_RE = /\.(m?[jt]sx?|cjs|cts)$/;
|
||||
const SKIP_DIR_RE = /(^|\/)(node_modules|\.git|vendor|fixtures?|__tests__|test|tests|examples?)\//;
|
||||
const MAX_TARBALL_BYTES = 30 * 1024 * 1024;
|
||||
// Legacy V1 plugin shape: an exported async function receiving { client, project, directory, $, ... }
|
||||
// and returning hooks such as event, chat.params or tool.execute.before
|
||||
const V1_HOOK_RE = /["']?(chat\.params|chat\.message|tool\.execute\.(before|after)|permission\.ask|shell\.env)["']?\s*:\s*async|\bevent\s*:\s*async\s*\(\s*\{\s*event\b/;
|
||||
// V2 plugin contract without the helper: an object with an id and a setup(ctx) entry point
|
||||
// (Plugin.define is an identity function, so dependency-free V2 plugins export this directly)
|
||||
const V2_OBJECT_RE = /\{[^{}]*\bid\s*:\s*[\w"'`][^{}]*\b(async\s+)?setup\s*[:,(}]/;
|
||||
const V1_EXPORT_RE = /export\s+(const|default)\s+(\w+\s*(:\s*Plugin\s*)?=\s*)?async\s*(function\s*\w*\s*)?\(\s*\{[^}]*\b(client|project|directory|worktree|\$)\b/;
|
||||
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
const V1_PACKAGES = ['@opencode-ai/plugin', '@opencode-ai/sdk'];
|
||||
const V2_ERA_PACKAGES = ['@opencode-ai/client'];
|
||||
const V2_PRERELEASE_SPEC = /^(?:[\^~]|>=?\s*)?(?:0\.0\.0-(?:beta|next|tui-v2)|beta$|next$)/;
|
||||
const WORKSPACE_DIRS = ['packages', 'plugins'];
|
||||
const MAX_WORKSPACE_PACKAGES = 25;
|
||||
|
||||
/**
|
||||
* Resolve each dependency (key or npm: alias target) to the real package name and version spec.
|
||||
* @param {object} manifest - package.json contents
|
||||
* @returns {{name: string, spec: string}[]}
|
||||
*/
|
||||
function dependencies(manifest) {
|
||||
const deps = [];
|
||||
for (const field of DEP_FIELDS) {
|
||||
for (const [key, raw] of Object.entries((manifest && manifest[field]) || {})) {
|
||||
const spec = typeof raw === 'string' ? raw : '';
|
||||
const alias = spec.match(/^npm:((?:@[^/@]+\/)?[^@]+)(?:@(.*))?$/);
|
||||
deps.push(alias ? { name: alias[1], spec: alias[2] || '' } : { name: key, spec });
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers found in one manifest.
|
||||
* @param {object} manifest
|
||||
* @returns {{v2: string[], v1: string[]}}
|
||||
*/
|
||||
function manifestMarkers(manifest) {
|
||||
const v2 = new Set();
|
||||
const v1 = new Set();
|
||||
for (const { name, spec } of dependencies(manifest)) {
|
||||
if (name.startsWith('@opencode/')) v2.add(name);
|
||||
else if (V2_ERA_PACKAGES.includes(name)) v2.add(`${name} (V2 pre-release)`);
|
||||
else if (V1_PACKAGES.includes(name) && V2_PRERELEASE_SPEC.test(spec.trim())) v2.add(`${name}@${spec} (V2 pre-release)`);
|
||||
else if (V1_PACKAGES.includes(name)) v1.add(spec ? `${name}@${spec}` : name);
|
||||
}
|
||||
return { v2: [...v2], v1: [...v1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers found in source text (import/require specifiers).
|
||||
* @param {string} text
|
||||
* @returns {{v2: string[], v1: string[], define: boolean}}
|
||||
*/
|
||||
function sourceMarkers(text) {
|
||||
const specs = [...String(text).matchAll(/(?:from\s+|require\(\s*|import\(\s*)["'](@opencode(?:-ai)?\/[a-z0-9-]+)/g)].map(m => m[1]);
|
||||
return {
|
||||
v2: [...new Set(specs.filter(s => s.startsWith('@opencode/')))],
|
||||
v1: [...new Set(specs.filter(s => V1_PACKAGES.includes(s)))],
|
||||
define: /Plugin\.define\s*\(/.test(text),
|
||||
v2Signature: V2_OBJECT_RE.test(text),
|
||||
v1Signature: V1_HOOK_RE.test(text) || V1_EXPORT_RE.test(text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn collected markers into a verdict.
|
||||
* @param {{source: string, v2: string[], v1: string[]}[]} findings
|
||||
* @returns {{compat: 'v2'|'v1'|'unknown', evidence: string[]}}
|
||||
*/
|
||||
function verdict(findings) {
|
||||
const evidence = findings
|
||||
.filter(f => f.v2.length || f.v1.length)
|
||||
.map(f => `${f.source}: ${[...f.v2, ...f.v1].join(', ')}`);
|
||||
const hasV2 = findings.some(f => f.v2.length);
|
||||
const hasV1 = findings.some(f => f.v1.length);
|
||||
if (hasV2) return { compat: 'v2', evidence: hasV1 ? [...evidence, 'dual V1/V2 support'] : evidence };
|
||||
if (hasV1) return { compat: 'v1', evidence };
|
||||
return { compat: 'unknown', evidence: findings.length ? [`no @opencode/* or @opencode-ai/* dependency in ${findings.map(f => f.source).join(', ')}`] : ['no package.json or npm package found'] };
|
||||
}
|
||||
|
||||
function safeJson(text) {
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate regular files in a tar archive buffer.
|
||||
* @param {Buffer} tar
|
||||
* @returns {Generator<{name: string, data: Buffer}>}
|
||||
*/
|
||||
function* tarFiles(tar) {
|
||||
let offset = 0;
|
||||
let longName = null;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every(b => b === 0)) break;
|
||||
const field = (start, len) => header.subarray(start, start + len).toString('utf8').replace(/\0.*$/s, '');
|
||||
const size = parseInt(field(124, 12).trim() || '0', 8);
|
||||
const type = String.fromCharCode(header[156] || 48);
|
||||
const prefix = field(345, 155);
|
||||
let name = longName || (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
|
||||
const data = tar.subarray(offset + 512, offset + 512 + size);
|
||||
longName = null;
|
||||
if (type === 'L') longName = data.toString('utf8').replace(/\0.*$/s, '');
|
||||
else if (type === 'x') {
|
||||
const pathRecord = data.toString('utf8').match(/\d+ path=([^\n]+)\n/);
|
||||
if (pathRecord) longName = pathRecord[1];
|
||||
} else if (type === '0' || type === '\0') {
|
||||
name = name.replace(/^[^/]+\//, ''); // strip the "owner-repo-sha/" top folder
|
||||
yield { name, data };
|
||||
}
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a GitHub repo's source for V1/V2 markers by downloading its tarball.
|
||||
* @param {{owner: string, name: string}} repo
|
||||
* @param {string} [subPath] - restrict to a monorepo folder
|
||||
* @returns {Promise<{source: string, v2: string[], v1: string[]}|null>}
|
||||
*/
|
||||
async function scanSource(repo, subPath) {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/tarball`, {
|
||||
headers: { 'User-Agent': 'awesome-opencode-bot', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
||||
redirect: 'follow'
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return null;
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
if (gz.length > MAX_TARBALL_BYTES) return null;
|
||||
let tar;
|
||||
try { tar = zlib.gunzipSync(gz); } catch { return null; }
|
||||
|
||||
const v2 = new Set();
|
||||
const v1 = new Set();
|
||||
let define = false;
|
||||
let v1Signature = false;
|
||||
let v2Signature = false;
|
||||
let scanned = 0;
|
||||
const prefix = subPath ? `${subPath.replace(/\/+$/, '')}/` : '';
|
||||
for (const file of tarFiles(tar)) {
|
||||
if (!file.name.startsWith(prefix) || !SOURCE_FILE_RE.test(file.name) || SKIP_DIR_RE.test(file.name) || file.data.length > 2 * 1024 * 1024) continue;
|
||||
scanned++;
|
||||
const markers = sourceMarkers(file.data.toString('utf8'));
|
||||
markers.v2.forEach(m => v2.add(m));
|
||||
markers.v1.forEach(m => v1.add(m));
|
||||
define = define || markers.define;
|
||||
v1Signature = v1Signature || markers.v1Signature;
|
||||
v2Signature = v2Signature || markers.v2Signature;
|
||||
}
|
||||
// Plugin.define / an { id, setup } export is the V2 entry point; a bare V1 import without it is a V1 plugin
|
||||
if (define && v2.size === 0) v2.add('Plugin.define');
|
||||
if (!define && v2Signature && v2.size === 0) v2.add('V2 plugin object (id + setup)');
|
||||
define = define || v2Signature;
|
||||
if (define && v1.size) return { source: 'source', v2: [...v2, ...[...v1].map(m => `${m} (with Plugin.define)`)], v1: [] };
|
||||
return { source: `source (${scanned} files)`, v2: [...v2], v1: [...v1], scanned, define, v1Signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe many entries at once. Network calls are batched.
|
||||
* @param {{key: string, repo: string, path?: string, npm?: string, plugin?: boolean}[]} targets
|
||||
* plugin: true lets a legacy V1 hook signature in source (without Plugin.define) count as V1
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.deep=true] - for still-unknown GitHub repos, download the tarball and scan source
|
||||
* @returns {Promise<Map<string, {compat: string, evidence: string[]}>>}
|
||||
*/
|
||||
async function probeCompat(targets, { deep = true } = {}) {
|
||||
const findings = new Map(targets.map(t => [t.key, []]));
|
||||
|
||||
// 1. npm packages
|
||||
await Promise.all(targets.filter(t => t.npm).map(async t => {
|
||||
try {
|
||||
const doc = await getNpmLatest(t.npm);
|
||||
if (doc) findings.get(t.key).push({ source: `npm ${t.npm}@${doc.version}`, ...manifestMarkers(doc) });
|
||||
} catch { /* registry hiccup: fall through to repo sources */ }
|
||||
}));
|
||||
|
||||
// 2. package.json files in GitHub repos
|
||||
const github = targets
|
||||
.map(t => ({ t, parsed: parseRepoUrl(t.repo) }))
|
||||
.filter(({ parsed }) => parsed && parsed.host === 'github.com');
|
||||
|
||||
const firstRound = [];
|
||||
for (const { t, parsed } of github) {
|
||||
const base = t.path ? `${t.path.replace(/\/+$/, '')}/` : '';
|
||||
firstRound.push({ key: t.key, owner: parsed.owner, name: parsed.name, path: `${base}package.json` });
|
||||
if (!t.path) {
|
||||
for (const dir of WORKSPACE_DIRS) firstRound.push({ key: t.key, owner: parsed.owner, name: parsed.name, path: dir, tree: true });
|
||||
}
|
||||
}
|
||||
const files = await getFiles(firstRound);
|
||||
|
||||
const secondRound = [];
|
||||
for (const req of firstRound) {
|
||||
const obj = files.get(`${req.owner}/${req.name}:${req.path}`);
|
||||
if (!obj) continue;
|
||||
if (req.tree) {
|
||||
(obj.entries || []).filter(e => e.type === 'tree').slice(0, MAX_WORKSPACE_PACKAGES).forEach(e => {
|
||||
secondRound.push({ key: req.key, owner: req.owner, name: req.name, path: `${req.path}/${e.name}/package.json` });
|
||||
});
|
||||
} else if (obj.text) {
|
||||
const manifest = safeJson(obj.text);
|
||||
if (manifest) findings.get(req.key).push({ source: req.path, ...manifestMarkers(manifest) });
|
||||
}
|
||||
}
|
||||
const workspaceFiles = await getFiles(secondRound);
|
||||
for (const req of secondRound) {
|
||||
const obj = workspaceFiles.get(`${req.owner}/${req.name}:${req.path}`);
|
||||
const manifest = obj && obj.text && safeJson(obj.text);
|
||||
if (manifest) findings.get(req.key).push({ source: req.path, ...manifestMarkers(manifest) });
|
||||
}
|
||||
|
||||
// 3. Gists: read the source files directly
|
||||
for (const t of targets) {
|
||||
const gist = String(t.repo).match(/^https:\/\/gist\.github\.com\/[^/]+\/([0-9a-f]+)/);
|
||||
if (!gist) continue;
|
||||
const { status, data } = await rest(`/gists/${gist[1]}`);
|
||||
if (status !== 200 || !data || !data.files) continue;
|
||||
for (const [name, file] of Object.entries(data.files)) {
|
||||
const markers = name === 'package.json' ? manifestMarkers(safeJson(file.content) || {}) : sourceMarkers(file.content || '');
|
||||
findings.get(t.key).push({ source: `gist ${name}`, v2: markers.v2, v1: markers.v1 });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Deep scan of source for anything not yet confirmed V2 (a V1 types dependency can sit next to a
|
||||
// dependency-free V2 setup export, so V1 verdicts are re-checked too)
|
||||
if (deep) {
|
||||
for (const { t, parsed } of github) {
|
||||
if (verdict(findings.get(t.key)).compat === 'v2') continue;
|
||||
const scanned = await scanSource(parsed, t.path);
|
||||
if (!scanned) continue;
|
||||
if (t.plugin && !scanned.v2.length && !scanned.v1.length && scanned.v1Signature && verdict(findings.get(t.key)).compat === 'unknown') {
|
||||
scanned.v1 = ['legacy V1 plugin function/hooks without Plugin.define'];
|
||||
}
|
||||
findings.get(t.key).push(scanned);
|
||||
}
|
||||
}
|
||||
|
||||
const results = new Map();
|
||||
for (const t of targets) results.set(t.key, verdict(findings.get(t.key)));
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { dependencies, manifestMarkers, probeCompat, scanSource, sourceMarkers, tarFiles, verdict };
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Minimal GitHub and npm API helpers (Node 20+ global fetch, no extra dependencies).
|
||||
*
|
||||
* Auth: GITHUB_TOKEN or GH_TOKEN, falling back to `gh auth token` for local runs.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
let cachedToken;
|
||||
|
||||
function getToken() {
|
||||
if (cachedToken !== undefined) return cachedToken;
|
||||
cachedToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
||||
if (!cachedToken) {
|
||||
try {
|
||||
cachedToken = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
cachedToken = '';
|
||||
}
|
||||
}
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function headers(extra = {}) {
|
||||
const token = getToken();
|
||||
return {
|
||||
'User-Agent': 'awesome-opencode-bot',
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, options = {}, attempts = 5) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
if ([429, 502, 503, 504].includes(res.status) && attempt < attempts) {
|
||||
const retryAfter = Number(res.headers.get('retry-after'));
|
||||
await new Promise(r => setTimeout(r, (retryAfter > 0 ? retryAfter : 2 ** attempt) * 1000));
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (attempt >= attempts) throw err;
|
||||
await new Promise(r => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a GraphQL query. Partial data is returned even when some aliases error (e.g. repo not found).
|
||||
* @param {string} query
|
||||
* @param {object} [variables]
|
||||
* @returns {Promise<object>} data
|
||||
*/
|
||||
async function graphql(query, variables = {}) {
|
||||
const res = await fetchWithRetry('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok && !body.data) {
|
||||
throw new Error(`GitHub GraphQL ${res.status}: ${body.message || JSON.stringify(body.errors || body)}`);
|
||||
}
|
||||
const fatal = (body.errors || []).filter(e => e.type !== 'NOT_FOUND' && e.type !== 'FORBIDDEN');
|
||||
if (!body.data && fatal.length) {
|
||||
throw new Error(`GitHub GraphQL error: ${fatal.map(e => e.message).join('; ')}`);
|
||||
}
|
||||
return body.data || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub REST request.
|
||||
* @param {string} route - e.g. "/repos/o/r" or a full URL
|
||||
* @param {object} [options] - { method, body }
|
||||
* @returns {Promise<{status: number, data: any}>}
|
||||
*/
|
||||
async function rest(route, { method = 'GET', body } = {}) {
|
||||
const url = route.startsWith('http') ? route : `https://api.github.com${route}`;
|
||||
const res = await fetchWithRetry(url, {
|
||||
method,
|
||||
headers: headers(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = text;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
const gqlString = s => JSON.stringify(String(s));
|
||||
|
||||
/**
|
||||
* Fetch repository metadata for many repos in batched GraphQL queries.
|
||||
* @param {{owner: string, name: string}[]} repos
|
||||
* @returns {Promise<Map<string, object|null>>} keyed by "owner/name" (as given); null when not found
|
||||
*/
|
||||
async function getRepos(repos, { batchSize = 60 } = {}) {
|
||||
const result = new Map();
|
||||
const unique = [...new Map(repos.map(r => [`${r.owner}/${r.name}`, r])).values()];
|
||||
for (let i = 0; i < unique.length; i += batchSize) {
|
||||
const chunk = unique.slice(i, i + batchSize);
|
||||
const fields = chunk.map((r, j) => `r${j}: repository(owner: ${gqlString(r.owner)}, name: ${gqlString(r.name)}) {
|
||||
nameWithOwner url isArchived isFork isPrivate pushedAt stargazerCount description
|
||||
licenseInfo { spdxId } defaultBranchRef { name }
|
||||
}`).join('\n');
|
||||
const data = await graphql(`query { ${fields} }`);
|
||||
chunk.forEach((r, j) => result.set(`${r.owner}/${r.name}`, data[`r${j}`] || null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text files from repos at HEAD in batched GraphQL queries.
|
||||
* @param {{owner: string, name: string, path: string}[]} requests
|
||||
* @returns {Promise<Map<string, {text?: string, entries?: {name: string, type: string}[]}|null>>}
|
||||
* keyed by "owner/name:path"; tree paths return entries
|
||||
*/
|
||||
async function getFiles(requests, { batchSize = 60 } = {}) {
|
||||
const result = new Map();
|
||||
for (let i = 0; i < requests.length; i += batchSize) {
|
||||
const chunk = requests.slice(i, i + batchSize);
|
||||
const fields = chunk.map((r, j) => `f${j}: repository(owner: ${gqlString(r.owner)}, name: ${gqlString(r.name)}) {
|
||||
object(expression: ${gqlString(`HEAD:${r.path}`)}) {
|
||||
... on Blob { text isTruncated }
|
||||
... on Tree { entries { name type } }
|
||||
}
|
||||
}`).join('\n');
|
||||
const data = await graphql(`query { ${fields} }`);
|
||||
chunk.forEach((r, j) => {
|
||||
const obj = data[`f${j}`] && data[`f${j}`].object;
|
||||
result.set(`${r.owner}/${r.name}:${r.path}`, obj || null);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest published version document of an npm package.
|
||||
* @param {string} pkg
|
||||
* @returns {Promise<object|null>} null when the package does not exist
|
||||
*/
|
||||
async function getNpmLatest(pkg) {
|
||||
const res = await fetchWithRetry(`https://registry.npmjs.org/${pkg.replace('/', '%2F')}/latest`, {
|
||||
headers: { 'User-Agent': 'awesome-opencode-bot', Accept: 'application/json' }
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`npm registry ${res.status} for ${pkg}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly downloads for an npm package (0 when unknown).
|
||||
* @param {string} pkg
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function getNpmWeeklyDownloads(pkg) {
|
||||
const res = await fetchWithRetry(`https://api.npmjs.org/downloads/point/last-week/${pkg}`, {
|
||||
headers: { 'User-Agent': 'awesome-opencode-bot' }
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return body.downloads || 0;
|
||||
}
|
||||
|
||||
module.exports = { getFiles, getNpmLatest, getNpmWeeklyDownloads, getRepos, getToken, graphql, rest };
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Pull request analysis shared by:
|
||||
* - scripts/check-pr.js (the required "validate" check; runs the PR's own code)
|
||||
* - scripts/triage.js (pull_request_target; trusted base code, PR files read via the API)
|
||||
* - scripts/reprobe.js (nightly re-check of PRs held for V2 migration)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
const { CATEGORIES, canonicalKey, checkCatalog } = require('./catalog');
|
||||
const { probeCompat } = require('./compat');
|
||||
|
||||
// Generated files a PR must never edit by hand
|
||||
const GENERATED_FILES = ['README.md', 'dist/registry.json'];
|
||||
const DATA_FILE_RE = new RegExp(`^data/(${CATEGORIES.join('|')})/[^/]+\\.yaml$`);
|
||||
|
||||
const LABELS = {
|
||||
needsV2: 'needs-v2',
|
||||
needsMetadata: 'needs-metadata',
|
||||
needsHuman: 'needs-human',
|
||||
ready: 'ready'
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a PR's changes.
|
||||
* @param {object} input
|
||||
* @param {{path: string, status: string, content?: string, baseContent?: string}[]} input.changes
|
||||
* status is one of added | modified | removed | renamed; content is the new file text for data files
|
||||
* @param {object[]} input.baseEntries - catalog entries on the base branch (from loadCatalog)
|
||||
* @param {boolean} [input.probe=true] - run the network V2 probe for plugin entries
|
||||
* @returns {Promise<{errors: object[], warnings: object[], entries: object[], labels: string[], categories: string[], dataChanged: boolean}>}
|
||||
*/
|
||||
async function analyzePullRequest({ changes, baseEntries, probe = true }) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const change of changes) {
|
||||
if (GENERATED_FILES.includes(change.path) && change.status !== 'removed') {
|
||||
errors.push({
|
||||
file: change.path,
|
||||
kind: 'metadata',
|
||||
message: `${change.path} is generated from data/ and must not be edited by hand. Add or edit data/<category>/<name>.yaml instead (see contributing.md, or use the "Add an entry" issue form).`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay the PR's data changes onto the base catalog
|
||||
const head = new Map(baseEntries.map(e => [e.file, e]));
|
||||
const changed = [];
|
||||
for (const change of changes.filter(c => DATA_FILE_RE.test(c.path))) {
|
||||
head.delete(change.path);
|
||||
if (change.status === 'removed') continue;
|
||||
const [, category, fileName] = change.path.split('/');
|
||||
let data;
|
||||
try {
|
||||
data = yaml.load(change.content || '');
|
||||
} catch (err) {
|
||||
errors.push({ file: change.path, kind: 'metadata', message: `invalid YAML: ${err.reason || err.message}. Quote values that contain ": " (e.g. tagline: "A: B").` });
|
||||
continue;
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
errors.push({ file: change.path, kind: 'metadata', message: 'file is empty or not a YAML mapping' });
|
||||
continue;
|
||||
}
|
||||
const entry = { category, id: fileName.replace(/\.yaml$/, ''), file: change.path, data, status: change.status };
|
||||
head.set(change.path, entry);
|
||||
changed.push(entry);
|
||||
|
||||
if (change.status === 'modified' && change.baseContent) {
|
||||
try {
|
||||
const before = yaml.load(change.baseContent);
|
||||
if (before && before.repo && data.repo && canonicalKey(before) !== canonicalKey(data)) {
|
||||
warnings.push({ file: change.path, kind: 'human', message: `changes this entry's target from ${before.repo} to ${data.repo}. If this is a different project, add a new file instead of reusing an existing filename.` });
|
||||
}
|
||||
} catch { /* base file was already broken */ }
|
||||
}
|
||||
}
|
||||
|
||||
// YAML files outside data/<category>/
|
||||
for (const change of changes) {
|
||||
if (change.path.startsWith('data/') && change.path.endsWith('.yaml') && !DATA_FILE_RE.test(change.path) && !change.path.startsWith('data/examples/') && change.status !== 'removed') {
|
||||
errors.push({ file: change.path, kind: 'metadata', message: `entries must live in data/<category>/<name>.yaml where category is one of: ${CATEGORIES.join(', ')}` });
|
||||
}
|
||||
if (change.path.startsWith('data/') && !change.path.endsWith('.yaml') && !change.path.endsWith('.json') && !change.path.endsWith('.md') && change.status !== 'removed') {
|
||||
errors.push({ file: change.path, kind: 'metadata', message: 'entry files must use the .yaml extension' });
|
||||
}
|
||||
}
|
||||
|
||||
// Schema and cross-entry rules, reported for the files this PR touches
|
||||
const touched = new Set(changed.map(e => e.file));
|
||||
for (const problem of checkCatalog([...head.values()])) {
|
||||
if (touched.has(problem.file) || [...touched].some(f => problem.message.includes(f))) {
|
||||
errors.push({ ...problem, kind: 'metadata' });
|
||||
}
|
||||
}
|
||||
|
||||
// V2 compatibility for plugin entries
|
||||
const plugins = changed.filter(e => e.category === 'plugins' && e.data.repo);
|
||||
if (probe && plugins.length) {
|
||||
const results = await probeCompat(plugins.map(e => ({ key: e.file, repo: e.data.repo, path: e.data.path, npm: e.data.npm, plugin: true })));
|
||||
for (const entry of plugins) {
|
||||
const { compat, evidence } = results.get(entry.file);
|
||||
entry.probe = { compat, evidence };
|
||||
const detail = evidence.length ? ` (${evidence.join('; ')})` : '';
|
||||
if (compat === 'v1' && entry.status === 'added') {
|
||||
errors.push({ file: entry.file, kind: 'v2', message: `targets the legacy V1 plugin API${detail}. New plugins must be built on OpenCode V2: depend on @opencode/plugin and export Plugin.define({ id, setup(ctx) }). This PR is re-checked nightly and unblocks itself once the repo is migrated.` });
|
||||
} else if (compat !== 'unknown' && entry.data.compat && entry.data.compat !== compat) {
|
||||
errors.push({ file: entry.file, kind: compat === 'v1' ? 'v2' : 'metadata', message: `declares compat: ${entry.data.compat} but the package was detected as ${compat}${detail}. Set compat: ${compat}.` });
|
||||
} else if (compat === 'unknown') {
|
||||
warnings.push({ file: entry.file, kind: 'human', message: `could not verify V2 compatibility automatically${detail}. Set \`npm\` (package name) or \`path\` (monorepo folder) so the check can find package.json; otherwise a maintainer will verify by hand.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categories = [...new Set(changes.filter(c => DATA_FILE_RE.test(c.path)).map(c => c.path.split('/')[1]))];
|
||||
const labels = [];
|
||||
if (errors.some(e => e.kind === 'v2')) labels.push(LABELS.needsV2);
|
||||
if (errors.some(e => e.kind === 'metadata')) labels.push(LABELS.needsMetadata);
|
||||
if (warnings.length) labels.push(LABELS.needsHuman);
|
||||
if (!errors.length && !warnings.length && changed.length) labels.push(LABELS.ready);
|
||||
|
||||
return { errors, warnings, entries: changed, labels, categories, dataChanged: categories.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown report for a job summary or PR comment.
|
||||
* @param {object} result - analyzePullRequest() result
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatReport(result) {
|
||||
const lines = [];
|
||||
if (!result.dataChanged && !result.errors.length) {
|
||||
return '✅ No catalog entries changed.';
|
||||
}
|
||||
lines.push(result.errors.length ? '### ❌ Catalog checks failed' : result.warnings.length ? '### ⚠️ Catalog checks passed with notes' : '### ✅ Catalog checks passed');
|
||||
const section = (title, items) => {
|
||||
if (!items.length) return;
|
||||
lines.push('', `**${title}**`, '');
|
||||
for (const item of items) lines.push(`- \`${item.file}\`: ${item.message}`);
|
||||
};
|
||||
section('Must fix', result.errors);
|
||||
section('Needs a maintainer', result.warnings);
|
||||
const verified = result.entries.filter(e => e.probe);
|
||||
if (verified.length) {
|
||||
lines.push('', '**V2 compatibility**', '', '| Entry | Detected | Evidence |', '| :--- | :--- | :--- |');
|
||||
for (const e of verified) lines.push(`| \`${path.basename(e.file)}\` | ${e.probe.compat} | ${e.probe.evidence.join('; ').replace(/\|/g, '\\|') || '—'} |`);
|
||||
}
|
||||
lines.push('', '<sub>Entry format: [contributing.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md) · Catalog policy: [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md)</sub>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { DATA_FILE_RE, GENERATED_FILES, LABELS, analyzePullRequest, formatReport };
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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'],
|
||||
'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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the latest "validate" workflow run for a commit, or dispatch one on a same-repo branch.
|
||||
*/
|
||||
async function revalidate(pull) {
|
||||
const runs = await check(rest(`/repos/${REPO}/actions/workflows/validate-pr.yml/runs?head_sha=${pull.head.sha}&per_page=5`), 'list runs');
|
||||
const run = runs.workflow_runs && runs.workflow_runs[0];
|
||||
if (run && run.status === 'completed') {
|
||||
return rest(`/repos/${REPO}/actions/runs/${run.id}/rerun`, { method: 'POST' });
|
||||
}
|
||||
if (!run && pull.head.repo && pull.head.repo.full_name === REPO) {
|
||||
return rest(`/repos/${REPO}/actions/workflows/validate-pr.yml/dispatches`, { method: 'POST', body: { ref: pull.head.ref } });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BOT_LABELS, REPO, TRIAGE_LABELS,
|
||||
addLabels, comment, ensureLabels, fileText, listComments, listOpenPulls, pullChanges, revalidate, setTriageLabels, upsertComment
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const yaml = require('js-yaml');
|
||||
const { canonicalKey, checkCatalog, checkEntry, loadCatalog } = require('../scripts/utils/catalog');
|
||||
const { dumpEntry } = require('../scripts/utils/format');
|
||||
const { escapeHtml, generateEntryHtml } = require('../scripts/utils/template');
|
||||
|
||||
const base = {
|
||||
name: 'Thing',
|
||||
repo: 'https://github.com/someone/thing',
|
||||
tagline: 'Does the thing',
|
||||
description: 'Does the thing well.',
|
||||
kind: 'plugin',
|
||||
topics: ['workflow'],
|
||||
compat: 'v2'
|
||||
};
|
||||
const entry = (data, category = 'plugins', file = 'data/plugins/thing.yaml') => ({ category, id: 'thing', file, data: { ...base, ...data } });
|
||||
|
||||
test('the committed catalog is valid', () => {
|
||||
const { entries, errors } = loadCatalog();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(checkCatalog(entries), []);
|
||||
assert.ok(entries.length > 400);
|
||||
});
|
||||
|
||||
test('repo must be a repository root', () => {
|
||||
for (const repo of [
|
||||
'https://github.com/a/b/tree/main/packages/x',
|
||||
'https://github.com/a/b/blob/main/x.ts',
|
||||
'https://www.npmjs.com/package/x',
|
||||
'https://example.com'
|
||||
]) {
|
||||
assert.match(checkEntry(entry({ repo })).join(), /repository root/, repo);
|
||||
}
|
||||
for (const repo of ['https://github.com/a/b', 'https://codeberg.org/a/b', 'https://gist.github.com/a/0123abc', 'https://github.com/a/b/discussions/19']) {
|
||||
assert.deepEqual(checkEntry(entry({ repo })), [], repo);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown fields, bad enums, placeholders and mojibake are rejected', () => {
|
||||
assert.match(checkEntry(entry({ install: 'x' })).join(), /unknown field "install"/);
|
||||
assert.match(checkEntry(entry({ topics: ['nope'] })).join(), /allowed values/);
|
||||
assert.match(checkEntry(entry({ repo: 'https://github.com/YOUR_USERNAME/thing' })).join(), /placeholder/);
|
||||
assert.match(checkEntry(entry({ tagline: 'Fast 鈥? really' })).join(), /mis-encoded/);
|
||||
assert.match(checkEntry(entry({ tagline: 'x'.repeat(121) })).join(), /120/);
|
||||
});
|
||||
|
||||
test('kind must fit the category and plugins need compat', () => {
|
||||
assert.match(checkEntry(entry({ kind: 'client' })).join(), /does not belong in data\/plugins/);
|
||||
assert.match(checkEntry(entry({ compat: undefined })).join(), /must declare compat/);
|
||||
assert.deepEqual(checkEntry(entry({ kind: 'client', compat: undefined }, 'projects', 'data/projects/thing.yaml')), []);
|
||||
});
|
||||
|
||||
test('duplicates are keyed on repo and path', () => {
|
||||
const a = entry({}, 'plugins', 'data/plugins/a.yaml');
|
||||
const b = entry({ repo: 'https://github.com/SomeOne/Thing.git' }, 'plugins', 'data/plugins/b.yaml');
|
||||
assert.match(checkCatalog([a, b]).map(p => p.message).join(), /duplicate of data\/plugins\/a.yaml/);
|
||||
const c = entry({ path: 'packages/other' }, 'plugins', 'data/plugins/c.yaml');
|
||||
assert.deepEqual(checkCatalog([a, c]), []);
|
||||
assert.equal(canonicalKey({ repo: 'https://github.com/A/B/', path: 'x/' }), 'github.com/a/b#x');
|
||||
});
|
||||
|
||||
test('dumpEntry quotes values that would break YAML and round-trips', () => {
|
||||
const data = { ...base, tagline: 'Plain: no jargon', description: 'Line one\nLine two: with colon' };
|
||||
const text = dumpEntry(data);
|
||||
assert.deepEqual(yaml.load(text), data);
|
||||
assert.ok(text.indexOf('name:') < text.indexOf('repo:') && text.indexOf('kind:') < text.indexOf('compat:'));
|
||||
});
|
||||
|
||||
test('README entries escape contributor text and show compat badges', () => {
|
||||
assert.equal(escapeHtml('<b>"x" & y</b>'), '<b>"x" & y</b>');
|
||||
const html = generateEntryHtml({ ...base, description: 'run opencode <prompt>', compat: 'v1', npm: 'thing', path: 'pkg/x' }, 'plugins');
|
||||
assert.match(html, /<prompt>/);
|
||||
assert.match(html, /<code>V1 only<\/code>/);
|
||||
assert.match(html, /tree\/HEAD\/pkg\/x/);
|
||||
assert.match(html, /npmjs\.com\/package\/thing/);
|
||||
assert.doesNotMatch(generateEntryHtml({ ...base, compat: 'n/a' }, 'projects'), /<code>/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const zlib = require('zlib');
|
||||
const { manifestMarkers, sourceMarkers, tarFiles, verdict } = require('../scripts/utils/compat');
|
||||
|
||||
const detect = manifest => verdict([{ source: 'package.json', ...manifestMarkers(manifest) }]).compat;
|
||||
|
||||
test('V2 markers', () => {
|
||||
assert.equal(detect({ dependencies: { '@opencode/plugin': '^2.0.0' } }), 'v2');
|
||||
assert.equal(detect({ peerDependencies: { '@opencode/theme': '2.0.14' } }), 'v2');
|
||||
assert.equal(detect({ devDependencies: { 'plugin-v2': 'npm:@opencode/[email protected]', '@opencode-ai/plugin': '1.18.25' } }), 'v2');
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/plugin': '0.0.0-beta-18027' } }), 'v2');
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/plugin': 'next' } }), 'v2');
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/client': '0.0.0-next-17444' } }), 'v2');
|
||||
});
|
||||
|
||||
test('V1 markers', () => {
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/plugin': '^1.18.0' } }), 'v1');
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/plugin': '*' } }), 'v1');
|
||||
assert.equal(detect({ dependencies: { '@opencode-ai/sdk': '>=1.0.0' } }), 'v1');
|
||||
});
|
||||
|
||||
test('no markers is unknown', () => {
|
||||
assert.equal(detect({ dependencies: { zod: '^3' } }), 'unknown');
|
||||
assert.equal(verdict([]).compat, 'unknown');
|
||||
});
|
||||
|
||||
test('source markers', () => {
|
||||
const v2 = sourceMarkers('import { Plugin } from "@opencode/plugin"\nexport default Plugin.define({ id: "x", async setup(ctx) {} })');
|
||||
assert.deepEqual(v2.v2, ['@opencode/plugin']);
|
||||
assert.equal(v2.define, true);
|
||||
assert.equal(sourceMarkers('export const P = async ({ project, client, $, directory }) => ({})').v1Signature, true);
|
||||
assert.equal(sourceMarkers('export const X = async () => ({\n "chat.params": async ({ model }, out) => {} })').v1Signature, true);
|
||||
assert.equal(sourceMarkers('export const MAX = 1;').v1Signature, false);
|
||||
assert.equal(sourceMarkers('const plugin = { id: PLAIN_ID, server: PlainPlugin, setup: setupPlain };').v2Signature, true);
|
||||
assert.equal(sourceMarkers('export default { id: "x", async setup(ctx) { ctx.session.hook("prompt") } }').v2Signature, true);
|
||||
assert.equal(sourceMarkers('export default { id: "obvious-grid", server: async (input) => ({ event }) }').v2Signature, false);
|
||||
});
|
||||
|
||||
test('tarFiles reads regular files and strips the top folder', () => {
|
||||
const header = (name, size, type = '0') => {
|
||||
const h = Buffer.alloc(512);
|
||||
h.write(name, 0);
|
||||
h.write(size.toString(8).padStart(11, '0') + '\0', 124);
|
||||
h.write(type, 156);
|
||||
return h;
|
||||
};
|
||||
const body = Buffer.from('export default 1');
|
||||
const padded = Buffer.concat([body, Buffer.alloc(512 - body.length)]);
|
||||
const tar = Buffer.concat([header('owner-repo-sha/', 0, '5'), header('owner-repo-sha/src/index.ts', body.length), padded, Buffer.alloc(1024)]);
|
||||
const files = [...tarFiles(zlib.gunzipSync(zlib.gzipSync(tar)))];
|
||||
assert.deepEqual(files.map(f => f.name), ['src/index.ts']);
|
||||
assert.equal(files[0].data.toString(), 'export default 1');
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { analyzePullRequest } = require('../scripts/utils/pr-checks');
|
||||
const { normalizeInput, parseIssueForm } = require('../scripts/utils/submission');
|
||||
|
||||
const good = [
|
||||
'name: Thing',
|
||||
'repo: https://github.com/someone/brand-new-thing',
|
||||
'tagline: Does the thing',
|
||||
'description: Does the thing well.',
|
||||
'kind: plugin',
|
||||
'topics:',
|
||||
' - workflow',
|
||||
'compat: v2',
|
||||
''
|
||||
].join('\n');
|
||||
const existing = { category: 'plugins', id: 'old', file: 'data/plugins/old.yaml', data: { name: 'Old', repo: 'https://github.com/someone/old', tagline: 't', description: 'd', kind: 'plugin', topics: ['ui'], compat: 'v2' } };
|
||||
|
||||
test('a valid new entry is ready', async () => {
|
||||
const r = await analyzePullRequest({ changes: [{ path: 'data/plugins/thing.yaml', status: 'added', content: good }], baseEntries: [existing], probe: false });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.deepEqual(r.labels, ['ready']);
|
||||
assert.deepEqual(r.categories, ['plugins']);
|
||||
});
|
||||
|
||||
test('editing the generated README is rejected', async () => {
|
||||
const r = await analyzePullRequest({ changes: [{ path: 'README.md', status: 'modified' }], baseEntries: [existing], probe: false });
|
||||
assert.match(r.errors[0].message, /generated/);
|
||||
assert.deepEqual(r.labels, ['needs-metadata']);
|
||||
});
|
||||
|
||||
test('broken YAML and duplicates are reported against the PR file', async () => {
|
||||
const broken = await analyzePullRequest({ changes: [{ path: 'data/plugins/x.yaml', status: 'added', content: 'tagline: A: B\nname: x' }], baseEntries: [], probe: false });
|
||||
assert.match(broken.errors[0].message, /invalid YAML/);
|
||||
const dup = await analyzePullRequest({
|
||||
changes: [{ path: 'data/plugins/thing.yaml', status: 'added', content: good.replace('brand-new-thing', 'old') }],
|
||||
baseEntries: [existing],
|
||||
probe: false
|
||||
});
|
||||
assert.ok(dup.errors.some(e => /duplicate/.test(e.message)));
|
||||
});
|
||||
|
||||
test('reusing a filename for another repo needs a human', async () => {
|
||||
const r = await analyzePullRequest({
|
||||
changes: [{ path: 'data/plugins/old.yaml', status: 'modified', content: good, baseContent: 'repo: https://github.com/someone/old\n' }],
|
||||
baseEntries: [existing],
|
||||
probe: false
|
||||
});
|
||||
assert.ok(r.warnings.some(w => /changes this entry's target/.test(w.message)));
|
||||
assert.ok(r.labels.includes('needs-human'));
|
||||
});
|
||||
|
||||
test('issue form parsing and normalization', () => {
|
||||
const body = '### Name\n\nThing\n\n### Repository URL\n\nhttps://github.com/a/b/tree/main/packages/x\n\n### npm package\n\n_No response_\n\n### Category\n\nplugins\n\n### Topics\n\nmemory, context\n\n### Installation\n\n```markdown\nnpm i thing\n```';
|
||||
const fields = parseIssueForm(body);
|
||||
assert.equal(fields.name, 'Thing');
|
||||
assert.equal(fields.npm, undefined);
|
||||
assert.equal(fields.installation, 'npm i thing');
|
||||
const { category, data } = normalizeInput(fields);
|
||||
assert.equal(category, 'plugins');
|
||||
assert.equal(data.repo, 'https://github.com/a/b');
|
||||
assert.equal(data.path, 'packages/x');
|
||||
assert.deepEqual(data.topics, ['memory', 'context']);
|
||||
});
|
||||
Reference in new issue
Block a user