diff --git a/.github/workflows/health.yml b/.github/workflows/health.yml new file mode 100644 index 0000000..d3b3787 --- /dev/null +++ b/.github/workflows/health.yml @@ -0,0 +1,41 @@ +# Nightly catalog health: updates the "Catalog health report" issue, and opens bot PRs that follow +# renamed repos and remove entries dead for 30+ days (or V1-only plugins after the sunset date). +name: Catalog health + +on: + schedule: + - cron: '17 5 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + +concurrency: + group: catalog-health + cancel-in-progress: false + +jobs: + health: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check health and open bot PRs + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/health-check.js --issue --open-prs diff --git a/.github/workflows/reprobe.yml b/.github/workflows/reprobe.yml new file mode 100644 index 0000000..12f2956 --- /dev/null +++ b/.github/workflows/reprobe.yml @@ -0,0 +1,51 @@ +# Nightly re-check of PRs labeled needs-v2: unblocks them once the target migrates to the V2 plugin +# API, and closes them after 60 days on V1 (see docs/policy.md). +name: Re-check V2 migrations + +on: + schedule: + - cron: '43 6 * * *' + workflow_dispatch: + inputs: + pr: + description: 'Only this PR number (optional)' + required: false + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + +concurrency: + group: reprobe + cancel-in-progress: false + +jobs: + reprobe: + runs-on: ubuntu-latest + steps: + - name: Checkout + 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: Re-check held PRs + env: + GITHUB_TOKEN: ${{ github.token }} + ONLY_PR: ${{ inputs.pr }} + run: | + if [ -n "${ONLY_PR:-}" ]; then + node scripts/reprobe.js --pr "$ONLY_PR" + else + node scripts/reprobe.js + fi diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml new file mode 100644 index 0000000..45c2c47 --- /dev/null +++ b/.github/workflows/scout.yml @@ -0,0 +1,36 @@ +# Weekly discovery of OpenCode V2 plugins on npm that are not in the catalog yet. +# Opens a "Scout" issue; ticking a candidate's box opens a PR (see issues.yml). +name: Scout V2 plugins + +on: + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + issues: write + +jobs: + scout: + runs-on: ubuntu-latest + steps: + - name: Checkout + 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: Find candidates + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/scout.js run diff --git a/discovery/declined.txt b/discovery/declined.txt new file mode 100644 index 0000000..aa41efb --- /dev/null +++ b/discovery/declined.txt @@ -0,0 +1,3 @@ +# Packages or repos the weekly scout should stop suggesting, one per line. +# Use an npm package name (e.g. opencode-foo) or a repo URL (e.g. https://github.com/owner/repo). +# Add a short reason after a '#' so the next maintainer knows why. diff --git a/scripts/health-check.js b/scripts/health-check.js new file mode 100644 index 0000000..6b646f4 --- /dev/null +++ b/scripts/health-check.js @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Catalog health: link rot, archived/deprecated targets, renames, staleness and the V1 sunset. + * + * Usage: node scripts/health-check.js [options] + * --out write per-entry health JSON (used by the site) + * --issue create/update the rolling "Catalog health report" issue (holds grace-period state) + * --apply-renames rewrite repo URLs of renamed/transferred repos in the working tree + * --apply-removals delete entries dead for GRACE_DAYS+ (and V1 plugins after the sunset date) + * --open-prs open/update bot PRs for renames (bot/health-renames) and removals (bot/health-removals) + * --pr-body write a markdown summary of the applied changes + * --dry-run print the report instead of writing the issue + * + * Policy (docs/policy.md): archived, 404 or npm-deprecated targets are removed after a 30-day grace + * period; renamed repos are updated automatically; inactivity alone never removes an entry. + */ + +const fs = require('fs'); +const path = require('path'); +const { V1_SUNSET, loadCatalog, parseRepoUrl } = require('./utils/catalog'); +const { dumpEntry } = require('./utils/format'); +const { getNpmLatest, getRepos, rest } = require('./utils/github'); +const { openPullRequest } = require('./utils/git-pr'); +const { REPO, ensureLabels } = require('./utils/repo'); + +const ROOT = path.join(__dirname, '..'); +const GRACE_DAYS = 30; +const DAY = 24 * 60 * 60 * 1000; +const ISSUE_TITLE = 'Catalog health report'; +const STATE_RE = //s; + +function parseArgs(argv) { + const value = flag => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : null); + return { + out: value('--out'), + issue: argv.includes('--issue'), + applyRenames: argv.includes('--apply-renames'), + applyRemovals: argv.includes('--apply-removals'), + openPrs: argv.includes('--open-prs'), + prBody: value('--pr-body'), + dryRun: argv.includes('--dry-run') + }; +} + +const today = () => new Date().toISOString().slice(0, 10); +const daysSince = date => Math.floor((Date.now() - new Date(date).getTime()) / DAY); + +async function checkUrl(url) { + try { + const res = await fetch(url, { method: 'GET', redirect: 'follow', headers: { 'User-Agent': 'awesome-opencode-bot' } }); + return res.status; + } catch { + return 0; + } +} + +/** + * Collect health facts for every entry. + * @returns {Promise>} keyed by entry file + */ +async function collect(entries) { + const health = new Map(); + const github = entries.map(e => ({ e, p: parseRepoUrl(e.data.repo) })) + .filter(x => x.p && x.p.host === 'github.com' && !/\/discussions\//.test(x.e.data.repo)); + const repos = await getRepos(github.map(x => ({ owner: x.p.owner, name: x.p.name }))); + + for (const e of entries) { + const h = { status: 'active' }; + const p = parseRepoUrl(e.data.repo); + const gist = /^https:\/\/gist\.github\.com\/[^/]+\/([0-9a-f]+)/.exec(e.data.repo); + if (gist) { + const { status } = await rest(`/gists/${gist[1]}`); + if (status === 404) h.status = 'missing'; + } else if (p && p.host === 'github.com' && !/\/discussions\//.test(e.data.repo)) { + const repo = repos.get(`${p.owner}/${p.name}`); + if (!repo) { + h.status = 'missing'; + } else { + h.stars = repo.stargazerCount; + h.pushedAt = repo.pushedAt; + h.license = repo.licenseInfo ? repo.licenseInfo.spdxId : null; + if (repo.nameWithOwner.toLowerCase() !== `${p.owner}/${p.name}`.toLowerCase()) h.renamedTo = `https://github.com/${repo.nameWithOwner}`; + if (repo.isArchived) h.status = 'archived'; + else if (daysSince(repo.pushedAt) >= 365) h.status = 'stale'; + else if (daysSince(repo.pushedAt) >= 180) h.status = 'quiet'; + } + } else { + const status = await checkUrl(e.data.repo); + if (status === 404 || status === 410) h.status = 'missing'; + } + health.set(e.file, h); + } + + await Promise.all(entries.filter(e => e.data.npm).map(async e => { + const doc = await getNpmLatest(e.data.npm).catch(() => undefined); + const h = health.get(e.file); + if (doc === null) h.npmMissing = true; + else if (doc && doc.deprecated) { + h.deprecated = String(doc.deprecated).slice(0, 200); + if (h.status !== 'missing' && h.status !== 'archived') h.status = 'deprecated'; + } + })); + + return health; +} + +async function findIssue() { + const { data } = await rest(`/repos/${REPO}/issues?state=open&labels=health&per_page=20`); + return Array.isArray(data) ? data.find(i => i.title === ISSUE_TITLE && !i.pull_request) : null; +} + +function readState(issue) { + const match = issue && STATE_RE.exec(issue.body || ''); + try { return match ? JSON.parse(match[1]) : {}; } catch { return {}; } +} + +/** + * Track when each entry was first seen dead; drop entries that recovered. + */ +function updateState(previous, entries, health) { + const state = {}; + for (const e of entries) { + const h = health.get(e.file); + if (['missing', 'archived', 'deprecated'].includes(h.status)) { + state[e.file] = { since: (previous[e.file] && previous[e.file].since) || today(), reason: h.status }; + } + } + return state; +} + +function removalCandidates(entries, health, state) { + const out = []; + for (const e of entries) { + const s = state[e.file]; + if (s && daysSince(s.since) >= GRACE_DAYS) out.push({ entry: e, reason: `${s.reason} since ${s.since}` }); + else if (e.category === 'plugins' && e.data.compat === 'v1' && today() >= V1_SUNSET) out.push({ entry: e, reason: `V1-only plugin past the ${V1_SUNSET} sunset` }); + } + return out; +} + +function report(entries, health, state) { + const link = e => `[${e.data.name}](${e.data.repo}) \`${e.file}\``; + const rows = (list, fmt) => list.length ? list.map(fmt).join('\n') : '_None._'; + const dead = entries.filter(e => state[e.file]); + const renamed = entries.filter(e => health.get(e.file).renamedTo); + const plugins = entries.filter(e => e.category === 'plugins'); + const v1 = plugins.filter(e => e.data.compat === 'v1'); + const unknown = plugins.filter(e => e.data.compat === 'unknown'); + const stale = entries.filter(e => health.get(e.file).status === 'stale'); + const npmMissing = entries.filter(e => health.get(e.file).npmMissing); + + return [ + `Updated ${today()} by the nightly health check. Policy: [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md).`, + '', + `| Check | Count |`, + `| :--- | ---: |`, + `| Entries | ${entries.length} |`, + `| Archived, missing or deprecated (removed after ${GRACE_DAYS} days) | ${dead.length} |`, + `| Renamed or transferred (fixed by bot PR) | ${renamed.length} |`, + `| V1-only plugins (removed after ${V1_SUNSET}) | ${v1.length} |`, + `| Plugins with unverified compatibility | ${unknown.length} |`, + `| No push for a year (badge only, not removed) | ${stale.length} |`, + '', + `### Archived, missing or deprecated`, + rows(dead, e => `- [ ] ${link(e)}: ${state[e.file].reason} since ${state[e.file].since}${health.get(e.file).deprecated ? ` ("${health.get(e.file).deprecated.replace(/[\r\n]+/g, ' ')}")` : ''}, removal PR in ${Math.max(0, GRACE_DAYS - daysSince(state[e.file].since))} day(s)`), + '', + '### Renamed or transferred', + rows(renamed, e => `- ${link(e)} → ${health.get(e.file).renamedTo}`), + '', + '### Plugins with unverified compatibility', + 'The V2 check could not decide. Verify by hand and set `compat`, or add `npm`/`path` so the check can find the package.', + '', + rows(unknown, e => `- [ ] ${link(e)}`), + '', + `
V1-only plugins (${v1.length}), removed after ${V1_SUNSET} unless migrated`, + '', + rows(v1, e => `- ${link(e)}`), + '', + '
', + '', + `
npm package not found (${npmMissing.length})`, + '', + rows(npmMissing, e => `- ${link(e)}: \`${e.data.npm}\``), + '', + '
', + '', + `
No push for a year (${stale.length})`, + '', + rows(stale, e => `- ${link(e)}: last push ${String(health.get(e.file).pushedAt).slice(0, 10)}`), + '', + '
', + '', + `` + ].join('\n'); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const { entries, errors } = loadCatalog(); + if (errors.length) throw new Error(`catalog has unparseable files: ${errors.map(e => e.file).join(', ')}`); + + const health = await collect(entries); + const issue = await findIssue(); + const state = updateState(readState(issue), entries, health); + + if (opts.out) { + const out = { + generated: new Date().toISOString(), + entries: Object.fromEntries(entries.map(e => [`${e.category}/${e.id}`, health.get(e.file)])) + }; + fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true }); + fs.writeFileSync(opts.out, JSON.stringify(out)); + console.log(`Wrote ${opts.out}`); + } + + const body = report(entries, health, state); + if (opts.issue && !opts.dryRun) { + await ensureLabels(['health']); + if (issue) await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { body } }); + else await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: ISSUE_TITLE, body, labels: ['health'] } }); + console.log(`${issue ? 'Updated' : 'Created'} the "${ISSUE_TITLE}" issue`); + } else if (opts.dryRun) { + console.log(body); + } + + const renames = { files: {}, lines: [] }; + for (const e of entries) { + const to = health.get(e.file).renamedTo; + if (!to) continue; + renames.files[e.file] = dumpEntry({ ...e.data, repo: to }); + renames.lines.push(`- \`${e.file}\`: ${e.data.repo} → ${to}`); + } + const removals = { files: {}, lines: [] }; + for (const { entry, reason } of removalCandidates(entries, health, state)) { + removals.files[entry.file] = null; + removals.lines.push(`- \`${entry.file}\` ([${entry.data.name}](${entry.data.repo})): ${reason}`); + } + + const summary = []; + if (opts.applyRenames) { + for (const [file, text] of Object.entries(renames.files)) fs.writeFileSync(path.join(ROOT, file), text); + summary.push(...renames.lines); + console.log(`Applied ${renames.lines.length} rename(s)`); + } + if (opts.applyRemovals) { + for (const file of Object.keys(removals.files)) fs.unlinkSync(path.join(ROOT, file)); + summary.push(...removals.lines); + console.log(`Applied ${removals.lines.length} removal(s)`); + } + if (opts.prBody) fs.writeFileSync(opts.prBody, summary.join('\n') + '\n'); + + if (opts.openPrs) { + if (renames.lines.length) { + const pr = await openPullRequest({ + branch: 'bot/health-renames', + files: renames.files, + title: 'chore: follow renamed or transferred repositories', + body: ['The nightly health check found entries whose GitHub repository was renamed or transferred.', '', ...renames.lines].join('\n'), + labels: ['health'], + dryRun: opts.dryRun + }); + if (pr) console.log(`Renames: #${pr.number}`); + } + if (removals.lines.length) { + const pr = await openPullRequest({ + branch: 'bot/health-removals', + files: removals.files, + title: 'chore: remove dead or sunset catalog entries', + body: [`These entries have been archived, missing or deprecated for ${GRACE_DAYS}+ days, or are V1-only plugins past the ${V1_SUNSET} sunset (see [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md)). Close this PR to keep them; the next run will propose them again unless the target recovers.`, '', ...removals.lines].join('\n'), + labels: ['health'], + dryRun: opts.dryRun + }); + if (pr) console.log(`Removals: #${pr.number}`); + } + } + + const counts = {}; + for (const h of health.values()) counts[h.status] = (counts[h.status] || 0) + 1; + console.log('status counts:', JSON.stringify(counts)); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/reprobe.js b/scripts/reprobe.js new file mode 100644 index 0000000..b2598fb --- /dev/null +++ b/scripts/reprobe.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * Nightly re-check of pull requests held for V2 migration (label: needs-v2). + * Usage: node scripts/reprobe.js [--dry-run] [--pr ] + * + * - Target migrated to V2: relabel, thank the author, fill in missing kind/topics/compat on + * same-repo branches, and re-run the validate check. + * - Still V1 after HOLD_DAYS since needs-v2 was applied: close with a reopen-anytime note. + */ + +const yaml = require('js-yaml'); +const { loadCatalog } = require('./utils/catalog'); +const { probeCompat } = require('./utils/compat'); +const { dumpEntry } = require('./utils/format'); +const { rest } = require('./utils/github'); +const { analyzePullRequest } = require('./utils/pr-checks'); +const { REPO, comment, listOpenPulls, pullChanges, revalidate, setTriageLabels } = require('./utils/repo'); +const { suggestKind, suggestTopics } = require('./utils/suggest'); + +const HOLD_DAYS = 60; +const DAY = 24 * 60 * 60 * 1000; + +async function labeledAt(number, label) { + let last = null; + for (let page = 1; ; page++) { + const { data } = await rest(`/repos/${REPO}/issues/${number}/events?per_page=100&page=${page}`); + if (!Array.isArray(data)) break; + for (const ev of data) if (ev.event === 'labeled' && ev.label && ev.label.name === label) last = ev.created_at; + if (data.length < 100) break; + } + return last ? new Date(last) : null; +} + +/** Original author of an imported upstream PR, else the PR author. */ +function authorOf(pull) { + const upstream = String(pull.body || '').match(/\*\*Original Author\*\*:\s*\[@([A-Za-z0-9-]+)\]/); + return upstream ? upstream[1] : pull.user.login; +} + +async function autofix(pull, change, compat) { + const data = yaml.load(change.content); + const [, category] = change.path.split('/'); + const text = [data.name, data.tagline, data.description].join(' '); + let changed = false; + if (!data.kind) { data.kind = suggestKind(category, text); changed = true; } + if (!data.topics || !data.topics.length) { data.topics = suggestTopics(text); changed = true; } + if (data.compat !== compat) { data.compat = compat; changed = true; } + if (!changed) return false; + const { data: current } = await rest(`/repos/${REPO}/contents/${change.path}?ref=${encodeURIComponent(pull.head.ref)}`); + const res = await rest(`/repos/${REPO}/contents/${change.path}`, { + method: 'PUT', + body: { + message: `chore: fill in kind, topics and compat for ${change.path}`, + content: Buffer.from(dumpEntry(data)).toString('base64'), + sha: current.sha, + branch: pull.head.ref + } + }); + if (res.status >= 300) throw new Error(`autofix ${change.path}: HTTP ${res.status}`); + return true; +} + +async function handle(pull, baseEntries, dryRun) { + const changes = await pullChanges(pull); + const plugins = changes.filter(c => /^data\/plugins\/[^/]+\.yaml$/.test(c.path) && c.status === 'added' && c.content); + const parsed = plugins.map(c => { try { return { c, data: yaml.load(c.content) }; } catch { return null; } }).filter(x => x && x.data && x.data.repo); + if (!parsed.length) return `#${pull.number}: no plugin entries to re-check`; + + const results = await probeCompat(parsed.map(({ c, data }) => ({ key: c.path, repo: data.repo, path: data.path, npm: data.npm, plugin: true }))); + const stillV1 = parsed.filter(({ c }) => results.get(c.path).compat === 'v1'); + + if (stillV1.length === 0) { + const sameRepo = pull.head.repo && pull.head.repo.full_name === REPO; + if (dryRun) return `#${pull.number}: now ${parsed.map(({ c }) => results.get(c.path).compat).join(', ')} -> would unblock`; + let fixed = false; + if (sameRepo) { + for (const { c } of parsed) fixed = (await autofix(pull, c, results.get(c.path).compat)) || fixed; + } + const fresh = fixed ? await pullChanges((await rest(`/repos/${REPO}/pulls/${pull.number}`)).data) : changes; + const analysis = await analyzePullRequest({ changes: fresh, baseEntries }); + await setTriageLabels(pull.number, analysis.labels, pull.labels.map(l => l.name)); + const evidence = parsed.map(({ c }) => `\`${c.path}\`: ${results.get(c.path).compat} (${results.get(c.path).evidence.join('; ')})`).join('\n- '); + await comment(pull.number, [ + `@${authorOf(pull)} the nightly check now detects OpenCode V2 support. Thank you for migrating!`, + '', + `- ${evidence}`, + fixed ? '\nThe entry was updated with `kind`, `topics` and `compat` so it matches the current schema.' : '', + analysis.errors.length ? '\nA few metadata items still need fixing; see the checks on this PR.' : '\nAll automated checks pass; a maintainer will merge this shortly.' + ].join('\n')); + await revalidate((await rest(`/repos/${REPO}/pulls/${pull.number}`)).data); + return `#${pull.number}: unblocked (${analysis.labels.join(', ')})`; + } + + const since = await labeledAt(pull.number, 'needs-v2'); + const days = since ? Math.floor((Date.now() - since.getTime()) / DAY) : 0; + if (days < HOLD_DAYS) return `#${pull.number}: still V1 (${days}/${HOLD_DAYS} days)`; + if (dryRun) return `#${pull.number}: still V1 after ${days} days -> would close`; + await comment(pull.number, [ + `@${authorOf(pull)} closing this for now: the target is still on the legacy V1 plugin API ${days} days after the V2 migration request.`, + '', + 'Reopen this PR (or submit again through the "Add an entry" issue form) once the plugin depends on `@opencode/plugin` and exports `Plugin.define({ id, setup(ctx) })`. See [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md).' + ].join('\n')); + await rest(`/repos/${REPO}/pulls/${pull.number}`, { method: 'PATCH', body: { state: 'closed' } }); + return `#${pull.number}: closed after ${days} days on V1`; +} + +async function main() { + const argv = process.argv.slice(2); + const dryRun = argv.includes('--dry-run'); + const only = argv.includes('--pr') ? Number(argv[argv.indexOf('--pr') + 1]) : null; + + const pulls = (await listOpenPulls()).filter(p => (only ? p.number === only : p.labels.some(l => l.name === 'needs-v2'))); + const { entries } = loadCatalog(); + console.log(`Re-checking ${pulls.length} PR(s) labeled needs-v2${dryRun ? ' (dry run)' : ''}`); + let failures = 0; + for (const pull of pulls) { + try { + console.log(await handle(pull, entries, dryRun)); + } catch (err) { + failures++; + console.error(`#${pull.number}: ${err.message}`); + } + } + if (failures) process.exit(1); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scout.js b/scripts/scout.js new file mode 100644 index 0000000..885196d --- /dev/null +++ b/scripts/scout.js @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * Discover OpenCode V2 plugins that are not in the catalog yet. + * + * Usage: + * node scripts/scout.js run [--dry-run] [--limit 20] weekly: post a ranked candidate issue + * node scripts/scout.js accept --issue [--sender ] [--dry-run] + * open PRs for ticked candidates (sender needs write access) + * + * Sources: npm search (packages published since V2 shipped, depending on @opencode/*). npm gives + * structured dependencies, download counts and repository links; GitHub code search caps at + * 1,000 results per query and only reports approximate counts. + * Gates: public, not archived, not a fork, has a license, V2 dependency. Ranking: weekly downloads, + * then stars. Humans pick; the bot only does the typing. + */ + +const fs = require('fs'); +const path = require('path'); +const { canonicalKey, loadCatalog, parseRepoUrl } = require('./utils/catalog'); +const { manifestMarkers } = require('./utils/compat'); +const { getNpmLatest, getNpmWeeklyDownloads, getRepos, rest } = require('./utils/github'); +const { openPullRequest } = require('./utils/git-pr'); +const { REPO, ensureLabels, listOpenPulls } = require('./utils/repo'); +const { buildEntry } = require('./utils/submission'); +const { suggestTopics } = require('./utils/suggest'); + +const V2_RELEASED = '2026-09-02'; +const SEARCHES = ['keywords:opencode-plugin', '@opencode/plugin', 'keywords:opencode']; +const DECLINED_FILE = path.join(__dirname, '../discovery/declined.txt'); +const DATA_RE = //s; +const ACCEPT_LIMIT = 10; + +async function npmSearch(text) { + const results = []; + for (let from = 0; from < 1000; from += 250) { + const res = await fetch(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=250&from=${from}`, { headers: { 'User-Agent': 'awesome-opencode-bot' } }); + if (!res.ok) break; + const body = await res.json(); + results.push(...body.objects.map(o => o.package)); + if (body.objects.length < 250) break; + } + return results; +} + +async function mapLimit(items, limit, fn) { + const out = new Array(items.length); + let next = 0; + await Promise.all(Array.from({ length: limit }, async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]).catch(() => null); + } + })); + return out; +} + +function readDeclined() { + if (!fs.existsSync(DECLINED_FILE)) return new Set(); + return new Set(fs.readFileSync(DECLINED_FILE, 'utf8').split('\n').map(l => l.replace(/#.*/, '').trim().toLowerCase()).filter(Boolean)); +} + +function repoFromManifest(manifest) { + const raw = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository && manifest.repository.url; + const m = String(raw || '').match(/github\.com[/:]([^/]+)\/([^/#.]+?)(?:\.git)?(?:[#/]|$)/) || String(raw || '').match(/^github:([^/]+)\/(.+)$/) || String(raw || '').match(/^([A-Za-z0-9-]+)\/([A-Za-z0-9_.-]+)$/); + if (!m) return null; + const directory = manifest.repository && manifest.repository.directory; + return { repo: `https://github.com/${m[1]}/${m[2]}`, path: directory ? String(directory).replace(/^\.?\/+|\/+$/g, '') : undefined }; +} + +/** Repo URLs and npm names already targeted by open PRs. */ +async function openPullTargets() { + const targets = new Set(); + for (const pull of await listOpenPulls()) { + const { data: files } = await rest(`/repos/${REPO}/pulls/${pull.number}/files?per_page=100`); + for (const f of Array.isArray(files) ? files : []) { + for (const m of String(f.patch || '').matchAll(/^\+\s*(repo|npm):\s*["']?([^\s"']+)/gm)) { + targets.add(m[1] === 'repo' ? canonicalKey({ repo: m[2] }) : `npm:${m[2].toLowerCase()}`); + } + } + } + return targets; +} + +function truncate(text, max) { + const clean = String(text || '').replace(/\s+/g, ' ').trim(); + if (clean.length <= max) return clean; + return clean.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'; +} + +async function findCandidates(limit) { + const { entries } = loadCatalog(); + const known = new Set(entries.map(e => canonicalKey(e.data))); + entries.forEach(e => e.data.npm && known.add(`npm:${e.data.npm.toLowerCase()}`)); + const declined = readDeclined(); + const pending = await openPullTargets(); + + const seen = new Map(); + for (const query of SEARCHES) { + for (const pkg of await npmSearch(query)) { + if (pkg.date >= V2_RELEASED && !seen.has(pkg.name)) seen.set(pkg.name, pkg); + } + } + const fresh = [...seen.values()].filter(p => !known.has(`npm:${p.name}`) && !pending.has(`npm:${p.name}`) && !declined.has(p.name.toLowerCase())); + console.log(`npm: ${seen.size} packages published since ${V2_RELEASED}, ${fresh.length} not yet listed`); + + const manifests = await mapLimit(fresh, 8, p => getNpmLatest(p.name)); + const v2 = []; + fresh.forEach((pkg, i) => { + const manifest = manifests[i]; + if (!manifest || manifest.deprecated) return; + const markers = manifestMarkers(manifest); + const source = repoFromManifest(manifest); + if (!markers.v2.length || !source) return; + const key = canonicalKey(source); + const repoKey = canonicalKey({ repo: source.repo }); + if (known.has(key) || known.has(repoKey) || pending.has(repoKey) || declined.has(key) || declined.has(repoKey)) return; + v2.push({ pkg, manifest, source, markers }); + }); + console.log(`${v2.length} depend on @opencode/* with a GitHub repo`); + + const repos = await getRepos(v2.map(c => { const p = parseRepoUrl(c.source.repo); return { owner: p.owner, name: p.name }; })); + const gated = v2.filter(c => { + const p = parseRepoUrl(c.source.repo); + const info = repos.get(`${p.owner}/${p.name}`); + c.info = info; + if (!info || info.isArchived || info.isFork || info.isPrivate || !(info.licenseInfo || c.manifest.license)) return false; + // npm repository links often point at a pre-transfer name; dedupe on GitHub's canonical name + c.source.repo = `https://github.com/${info.nameWithOwner}`; + const key = canonicalKey({ repo: c.source.repo }); + return !known.has(key) && !known.has(canonicalKey(c.source)) && !pending.has(key) && !declined.has(key); + }); + // One candidate per repo (monorepos publish several packages; keep the most downloaded) + await mapLimit(gated, 8, async c => { c.downloads = await getNpmWeeklyDownloads(c.pkg.name); }); + const byRepo = new Map(); + for (const c of gated) { + const key = canonicalKey(c.source); + if (!byRepo.has(key) || byRepo.get(key).downloads < c.downloads) byRepo.set(key, c); + } + const ranked = [...byRepo.values()].sort((a, b) => b.downloads - a.downloads || b.info.stargazerCount - a.info.stargazerCount); + console.log(`${gated.length} pass the gates; ${ranked.length} distinct repos`); + return ranked.slice(0, limit); +} + +function candidateInput(c) { + const description = truncate(c.info.description || c.manifest.description || c.pkg.description || '', 600); + const text = [c.pkg.name, description, (c.manifest.keywords || []).join(' ')].join(' '); + return { + name: c.pkg.name, + repo: c.source.repo, + path: c.source.path, + npm: c.pkg.name, + category: 'plugins', + kind: 'plugin', + topics: suggestTopics(text), + tagline: truncate(c.manifest.description || description || c.pkg.name, 120), + description: description || c.pkg.name + }; +} + +async function run({ dryRun, limit }) { + const candidates = await findCandidates(limit); + const week = new Date().toISOString().slice(0, 10); + const data = {}; + const lines = candidates.map((c, i) => { + const id = `c${i + 1}`; + data[id] = candidateInput(c); + return `- [ ] **${c.pkg.name}** — ${truncate(c.manifest.description || '', 140)} · ⭐ ${c.info.stargazerCount} · ⬇ ${c.downloads}/wk · [repo](${c.source.repo}) · [npm](https://www.npmjs.com/package/${c.pkg.name}) `; + }); + const body = [ + `OpenCode V2 plugins on npm that are not in the catalog yet: published since ${V2_RELEASED}, depending on \`@opencode/*\`, public, licensed, not archived or forked. Ranked by weekly downloads, then stars.`, + '', + '**Tick a box to open a pull request for that candidate** (maintainers only; topics are a keyword guess, fix them in the PR). To stop suggesting a package, add it to [`discovery/declined.txt`](https://github.com/Heretek-AI/awesome-opencode/blob/main/discovery/declined.txt).', + '', + ...(lines.length ? lines : ['_No new candidates this week._']), + '', + `` + ].join('\n'); + + if (dryRun) return console.log(body); + + await ensureLabels(['scout']); + const { data: open } = await rest(`/repos/${REPO}/issues?state=open&labels=scout&per_page=20`); + for (const old of Array.isArray(open) ? open : []) { + if (!old.pull_request) await rest(`/repos/${REPO}/issues/${old.number}`, { method: 'PATCH', body: { state: 'closed', state_reason: 'completed' } }); + } + const res = await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: `Scout: V2 plugin candidates (${week})`, body, labels: ['scout'] } }); + console.log(`Opened scout issue #${res.data.number} with ${candidates.length} candidate(s)`); +} + +async function accept({ issueNumber, sender, dryRun }) { + if (sender) { + const { data } = await rest(`/repos/${REPO}/collaborators/${encodeURIComponent(sender)}/permission`); + if (!data || !['admin', 'maintain', 'write'].includes(data.permission)) { + return console.log(`${sender} does not have write access; ignoring`); + } + } + const { data: issue } = await rest(`/repos/${REPO}/issues/${issueNumber}`); + const match = DATA_RE.exec(issue.body || ''); + if (!match) return console.log('no scout data in issue'); + const data = JSON.parse(match[1]); + let body = issue.body; + let opened = 0; + for (const line of body.split('\n')) { + const ticked = line.match(/^- \[x\] .*$/i); + if (!ticked || / → #\d+/.test(line) || opened >= ACCEPT_LIMIT) continue; + const input = data[ticked[1]]; + if (!input) continue; + const result = await buildEntry(input); + let note; + if (!result.ok) { + note = ` → not added: ${result.problems.join('; ').slice(0, 200)}`; + } else { + const pr = await openPullRequest({ + branch: `scout/${result.file.replace(/^data\/plugins\/|\.yaml$/g, '')}`, + files: { [result.file]: result.text }, + title: `docs: add ${input.name} to plugins`, + body: [`Found by the weekly scout in #${issueNumber}.`, '', ...result.notes.map(n => `- ${n}`), '', 'Topics are a keyword guess; adjust them before merging.'].join('\n'), + labels: ['scout', 'category: plugins'], + dryRun + }); + note = pr ? ` → #${pr.number}` : ' → (no changes)'; + opened++; + } + body = body.replace(line, line.replace(/