feat: nightly health checks, V2 re-probe of held PRs, weekly V2 scout

- health.yml: rolling "Catalog health report" issue; bot PRs that follow renamed repos and remove entries archived/missing/deprecated for 30+ days, and V1-only plugins after the 2027-01-01 sunset. Inactivity alone only changes a badge.
- reprobe.yml: re-checks needs-v2 PRs nightly; unblocks and notifies the original author when the target migrates (filling kind/topics/compat on same-repo branches), closes after 60 days on V1.
- scout.yml: weekly issue ranking npm packages published since V2 that depend on @opencode/* and are not listed yet; ticking a box opens a PR (maintainers only). discovery/declined.txt suppresses candidates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John SmithandClaude Opus 5.5 committed 2026-10-17 13:00:33 -04:00
1 parent d23bb027f9
commit 47298ca6f3
8 files changed
+853

No files matched your search

+41
View File
@@ -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
+51
View File
@@ -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
+36
View File
@@ -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
+3
View File
@@ -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.
+285
View File
@@ -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 <file> 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 <file> 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 = /<!-- health-state (\{.*?\}) -->/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<Map<string, object>>} 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)}`),
'',
`<details><summary>V1-only plugins (${v1.length}), removed after ${V1_SUNSET} unless migrated</summary>`,
'',
rows(v1, e => `- ${link(e)}`),
'',
'</details>',
'',
`<details><summary>npm package not found (${npmMissing.length})</summary>`,
'',
rows(npmMissing, e => `- ${link(e)}: \`${e.data.npm}\``),
'',
'</details>',
'',
`<details><summary>No push for a year (${stale.length})</summary>`,
'',
rows(stale, e => `- ${link(e)}: last push ${String(health.get(e.file).pushedAt).slice(0, 10)}`),
'',
'</details>',
'',
`<!-- health-state ${JSON.stringify(state)} -->`
].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);
});
+130
View File
@@ -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 <number>]
*
* - 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);
});
+247
View File
@@ -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 <n> [--sender <login>] [--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 = /<!-- scout-data (\{.*?\}) -->/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}) <!-- scout:${id} -->`;
});
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._']),
'',
`<!-- scout-data ${JSON.stringify(data)} -->`
].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\] .*<!-- scout:(c\d+) -->$/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(/ <!-- scout:/, `${note} <!-- scout:`));
}
if (!dryRun && body !== issue.body) await rest(`/repos/${REPO}/issues/${issueNumber}`, { method: 'PATCH', body: { body } });
console.log(`Processed ${opened} ticked candidate(s)`);
}
async function main() {
const [command, ...argv] = process.argv.slice(2);
const dryRun = argv.includes('--dry-run');
if (command === 'run') {
const limit = argv.includes('--limit') ? Number(argv[argv.indexOf('--limit') + 1]) : 20;
await run({ dryRun, limit });
} else if (command === 'accept') {
const sender = argv.includes('--sender') ? argv[argv.indexOf('--sender') + 1] : null;
await accept({ issueNumber: Number(argv[argv.indexOf('--issue') + 1]), sender, dryRun });
} else {
throw new Error('usage: scout.js run|accept --issue <n> [--dry-run]');
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Keyword-based suggestions for kind/topics, used when a bot writes an entry
* (scout candidates, backfilling older PRs). Contributors pick topics themselves in the issue form.
*/
const TOPIC_RULES = {
memory: /\bmemor|recall|remember|knowledge graph|journal|long-term/i,
context: /\bcontext\b|compact|compaction|prun|fewer tokens|token (use|usage|sav|reduc)|prompt.?cache|compress/i,
orchestration: /multi-agent|subagent|sub-agent|orchestrat|agent team|parallel agent|background (agent|task)|swarm|delegat/i,
workflow: /workflow|spec-driven|planning|planner|\bplan\b|ralph|\bgoal\b|methodolog|brainstorm/i,
providers: /provider|\bauth\b|oauth|gateway|proxy|routing|router|model discovery|openai-compatible|litellm|subscription|api key/i,
cost: /\bcost|quota|usage|billing|pricing|spend|rate.?limit/i,
notifications: /notif|\bbell\b|alert|ntfy|toast/i,
sessions: /session|history|handoff|transcript|resume/i,
'code-quality': /code review|\breview\b|lint|\btest|verif|quality|refactor/i,
security: /secur|sandbox|secret|\.env\b|pii|redact|privacy|injection|jailbreak|destructive|guardrail|leak/i,
git: /\bgit\b|github|gitlab|pull request|worktree|commit/i,
remote: /remote|mobile|telegram|discord|slack|whatsapp|phone|\bios\b|android/i,
web: /browser|scrap|crawl|web search|search the web|playwright|puppeteer|chromium/i,
ui: /\btui\b|sidebar|dashboard|status ?line|panel|\bgui\b|desktop app/i,
voice: /voice|speech|\bstt\b|\btts\b|dictation|whisper/i,
media: /image|video|pptx|slides|diagram|\bpdf\b|ocr/i,
docs: /documentation|\bdocs\b|research|tutorial|knowledge base|wiki/i,
environment: /docker|container|devcontainer|direnv|shell|tmux|zellij|\bnix\b|\bwsl\b/i,
observability: /telemetry|tracing|observab|monitor|logging|analytics|metrics|opentelemetry/i,
integrations: /jira|linear|notion|issue tracker|payment|stripe|calendar|email|figma|obsidian/i,
'code-intel': /\blsp\b|tree-sitter|code search|semantic search|indexing|code ?map|symbol|language server/i,
prompting: /prompt|persona|personality|system prompt|\bskills?\b|instructions/i
};
const DEFAULT_KIND = { plugins: 'plugin', themes: 'theme', agents: 'agent', projects: 'tool', resources: 'guide' };
/**
* Suggest up to three topics from free text (name + tagline + description).
* @param {string} text
* @returns {string[]}
*/
function suggestTopics(text) {
const topics = Object.entries(TOPIC_RULES).filter(([, re]) => re.test(text)).map(([topic]) => topic);
return topics.length ? topics.slice(0, 3) : ['workflow'];
}
/**
* Suggest a kind for a category, refined by text for projects/resources.
* @param {string} category
* @param {string} text
* @returns {string}
*/
function suggestKind(category, text) {
const t = text.toLowerCase();
if (category === 'agents') return /\bskills?\b/.test(t) && !/subagent/.test(t) ? 'skill' : 'agent';
if (category === 'projects' || category === 'resources') {
if (/\bmcp\b|model context protocol/.test(t) && /server/.test(t)) return 'mcp-server';
if (category === 'projects' && /desktop|\bgui\b|web ui|mobile|\bios\b|android|vs ?code|neovim|telegram|discord|slack|\bapp\b|client/.test(t)) return 'client';
if (category === 'resources' && /config|starter|template|dotfiles/.test(t)) return 'config';
}
return DEFAULT_KIND[category] || 'tool';
}
module.exports = { suggestKind, suggestTopics };