mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 02:35:58 -10:00
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:
1 parent
d23bb027f9
commit
47298ca6f3
8 files changed
+853
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user