From ef6357d8d1fbdfe4635e2b8e8005622ce39b525a Mon Sep 17 00:00:00 2001 From: John Smith Date: Sat, 17 Oct 2026 13:00:22 -0400 Subject: [PATCH] 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 --- .github/workflows/generate-readme.yml | 14 +- .github/workflows/triage.yml | 43 +++++ .github/workflows/validate-pr.yml | 48 +++-- scripts/check-pr.js | 81 ++++++++ scripts/triage.js | 47 +++++ scripts/utils/compat.js | 265 ++++++++++++++++++++++++++ scripts/utils/github.js | 168 ++++++++++++++++ scripts/utils/pr-checks.js | 152 +++++++++++++++ scripts/utils/repo.js | 157 +++++++++++++++ test/catalog.test.js | 78 ++++++++ test/compat.test.js | 54 ++++++ test/pr-checks.test.js | 64 +++++++ 12 files changed, 1148 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/triage.yml create mode 100644 scripts/check-pr.js create mode 100644 scripts/triage.js create mode 100644 scripts/utils/compat.js create mode 100644 scripts/utils/github.js create mode 100644 scripts/utils/pr-checks.js create mode 100644 scripts/utils/repo.js create mode 100644 test/catalog.test.js create mode 100644 test/compat.test.js create mode 100644 test/pr-checks.test.js diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml index cea4d5c..1d4d9a1 100644 --- a/.github/workflows/generate-readme.yml +++ b/.github/workflows/generate-readme.yml @@ -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 diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 0000000..d513a25 --- /dev/null +++ b/.github/workflows/triage.yml @@ -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" diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index e9a10be..967e171 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -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" diff --git a/scripts/check-pr.js b/scripts/check-pr.js new file mode 100644 index 0000000..363b6eb --- /dev/null +++ b/scripts/check-pr.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * The required "validate" check for pull requests. + * Usage: node scripts/check-pr.js --base [--no-probe] + * + * Compares HEAD against the merge base with , 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); +}); diff --git a/scripts/triage.js b/scripts/triage.js new file mode 100644 index 0000000..bd4e75b --- /dev/null +++ b/scripts/triage.js @@ -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 [--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 = ''; + +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 '); + + 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); +}); diff --git a/scripts/utils/compat.js b/scripts/utils/compat.js new file mode 100644 index 0000000..dd12c10 --- /dev/null +++ b/scripts/utils/compat.js @@ -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/plugin@2.0.14". The V2 API also shipped as + * pre-releases under the old name before the rename (@opencode-ai/plugin@0.0.0-beta-*, @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>} + */ +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 }; diff --git a/scripts/utils/github.js b/scripts/utils/github.js new file mode 100644 index 0000000..0181c6a --- /dev/null +++ b/scripts/utils/github.js @@ -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} 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>} 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>} + * 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} 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} + */ +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 }; diff --git a/scripts/utils/pr-checks.js b/scripts/utils/pr-checks.js new file mode 100644 index 0000000..b775e69 --- /dev/null +++ b/scripts/utils/pr-checks.js @@ -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//.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// + 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//.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('', '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)'); + return lines.join('\n'); +} + +module.exports = { DATA_FILE_RE, GENERATED_FILES, LABELS, analyzePullRequest, formatReport }; diff --git a/scripts/utils/repo.js b/scripts/utils/repo.js new file mode 100644 index 0000000..b2c1049 --- /dev/null +++ b/scripts/utils/repo.js @@ -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 +}; diff --git a/test/catalog.test.js b/test/catalog.test.js new file mode 100644 index 0000000..f572314 --- /dev/null +++ b/test/catalog.test.js @@ -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('"x" & y'), '<b>"x" & y</b>'); + const html = generateEntryHtml({ ...base, description: 'run opencode ', compat: 'v1', npm: 'thing', path: 'pkg/x' }, 'plugins'); + assert.match(html, /<prompt>/); + assert.match(html, /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'), //); +}); diff --git a/test/compat.test.js b/test/compat.test.js new file mode 100644 index 0000000..225b7c6 --- /dev/null +++ b/test/compat.test.js @@ -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/plugin@2.0.14', '@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'); +}); diff --git a/test/pr-checks.test.js b/test/pr-checks.test.js new file mode 100644 index 0000000..921b93f --- /dev/null +++ b/test/pr-checks.test.js @@ -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']); +});