diff --git a/.github/workflows/health.yml b/.github/workflows/health.yml index bf3944d..1cd2481 100644 --- a/.github/workflows/health.yml +++ b/.github/workflows/health.yml @@ -1,5 +1,6 @@ # 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). +# renamed repos, mark plugins that migrated to V2, and remove entries dead for 30+ days (or V1-only +# plugins still detected as V1 after the sunset date). Also keeps the V1 sunset tracking issue current. name: Catalog health on: diff --git a/docs/policy.md b/docs/policy.md index 9c14d87..11cff0e 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -30,7 +30,9 @@ Themes, agents, projects and resources are not blocked on V2. Their `compat` is ## Existing V1 plugins: sunset on 2027-01-01 -Plugins already in the catalog that are V1-only carry a `V1 only` badge in the README and on the site. **After 2027-01-01 they are removed** by the nightly health job's removal PR unless they have migrated (the check runs again before removal). Plugins with `unknown` compatibility are listed in the health report for manual verification before that date. +Plugins already in the catalog that are V1-only carry a `V1 only` badge in the README and on the site. The nightly health job re-detects every plugin not marked `v2`; when one is detected as V2 it opens a PR (`bot/health-compat`) marking the entry `compat: v2`. **After 2027-01-01 V1-only plugins are removed** by the health job's removal PR, but only when that night's re-check still detects V1: a V2 or inconclusive result keeps the entry, and inconclusive ones are listed in the health report for a maintainer. Plugins with `unknown` compatibility are listed there too for manual verification. + +The bot keeps the list in the V1 sunset tracking issue (label `v1-sunset`) current, posts one reminder there 31 days before the sunset, and closes the issue once no V1-only plugins remain. ## Health checks and removal @@ -40,7 +42,8 @@ A nightly job checks every entry and maintains the **Catalog health report** iss | :--- | :--- | | Repository renamed or transferred | Bot PR updates the `repo` URL | | Repository archived, deleted (404), or npm package deprecated | Listed in the report; **removal PR after 30 days** if it doesn't recover | -| V1-only plugin after 2027-01-01 | Removal PR | +| Plugin not marked `v2` now detected as V2 | Bot PR sets `compat: v2` | +| V1-only plugin after 2027-01-01, still detected as V1 | Removal PR | | No push for 6–12 months / 12+ months | `quiet` / `stale` badge on the site. **Never removed for inactivity alone**: small, finished tools are fine | Removal PRs are reviewed like any other PR; closing one keeps the entries until the next run proposes them again. Git history is the archive; there is no separate retired list. @@ -61,7 +64,7 @@ A weekly scout searches npm for packages published since V2 shipped that depend | `triage.yml` | PR opened/updated | Labels and a results comment (base-branch code only). `needs-human` is only ever removed by a maintainer | | `generate-readme.yml` | push to main | README.md and dist/registry.json; fails without writing on any invalid entry. The regenerated commit goes through a bot PR (`bot/readme-*`) that merges itself once `validate` passes | | `site.yml` | push to main, daily | Search site on GitHub Pages | -| `health.yml` | daily | Health report issue, rename and removal PRs | +| `health.yml` | daily | Health report and V1 sunset issues; rename, V2-migration and removal PRs | | `reprobe.yml` | daily | Re-checks `needs-v2` PRs | | `scout.yml` | weekly | Scout issue with V2 plugin candidates | | `issues.yml` | issue opened/edited | Issue-form submissions and scout acceptances become PRs | diff --git a/scripts/health-check.js b/scripts/health-check.js index 6b646f4..44352cb 100644 --- a/scripts/health-check.js +++ b/scripts/health-check.js @@ -1,33 +1,42 @@ #!/usr/bin/env node /** - * Catalog health: link rot, archived/deprecated targets, renames, staleness and the V1 sunset. + * Catalog health: link rot, archived/deprecated targets, renames, staleness, V2 migrations 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) + * and the V1 sunset tracking issue (label v1-sunset) * --apply-renames rewrite repo URLs of renamed/transferred repos in the working tree + * --apply-compat mark plugins now detected as V2 with compat: v2 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) + * --open-prs open/update bot PRs for renames (bot/health-renames), V2 migrations + * (bot/health-compat) 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. + * period; renamed repos are updated automatically; inactivity alone never removes an entry. Plugins not + * marked v2 are re-detected every night: a V2 detection opens a PR marking them v2, and after the sunset + * date a V1 plugin is only removed when that night's re-check confirms it is still V1. */ const fs = require('fs'); const path = require('path'); const { V1_SUNSET, loadCatalog, parseRepoUrl } = require('./utils/catalog'); +const { probeCompat } = require('./utils/compat'); 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 { REPO, comment, ensureLabels, listComments } = 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; +const POLICY = 'https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md'; +const REMINDER_DAYS = 31; +const REMINDER_MARKER = ''; function parseArgs(argv) { const value = flag => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : null); @@ -35,6 +44,7 @@ function parseArgs(argv) { out: value('--out'), issue: argv.includes('--issue'), applyRenames: argv.includes('--apply-renames'), + applyCompat: argv.includes('--apply-compat'), applyRemovals: argv.includes('--apply-removals'), openPrs: argv.includes('--open-prs'), prBody: value('--pr-body'), @@ -44,6 +54,7 @@ function parseArgs(argv) { const today = () => new Date().toISOString().slice(0, 10); const daysSince = date => Math.floor((Date.now() - new Date(date).getTime()) / DAY); +const daysUntil = date => Math.ceil((new Date(date).getTime() - Date.now()) / DAY); async function checkUrl(url) { try { @@ -104,6 +115,21 @@ async function collect(entries) { return health; } +/** + * Re-detect the compatibility of every plugin not marked v2 (V1-only and unverified), so migrations are + * picked up and the sunset never removes a plugin that has moved to V2 since it was classified. + * @returns {Promise>} keyed by entry file; empty if the probe failed + */ +async function recheckCompat(entries) { + const plugins = entries.filter(e => e.category === 'plugins' && e.data.compat !== 'v2'); + try { + return await probeCompat(plugins.map(e => ({ key: e.file, repo: e.data.repo, path: e.data.path, npm: e.data.npm, plugin: true }))); + } catch (err) { + console.error(`compat re-check failed: ${String(err.message).replace(/[\r\n]+/g, ' ')}`); + return new Map(); + } +} + 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; @@ -128,17 +154,29 @@ function updateState(previous, entries, health) { return state; } -function removalCandidates(entries, health, state) { +/** + * Entries to delete: dead for GRACE_DAYS+, or V1-only plugins past the sunset that tonight's re-check + * still detects as V1 (a V2 or inconclusive result keeps the entry). + */ +function removalCandidates(entries, state, compat, date = today()) { const out = []; for (const e of entries) { const s = state[e.file]; + const now = compat.get(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` }); + else if (e.category === 'plugins' && e.data.compat === 'v1' && date >= V1_SUNSET && now?.compat === 'v1') { + out.push({ entry: e, reason: `V1-only plugin past the ${V1_SUNSET} sunset (${now.evidence.join('; ')})` }); + } } return out; } -function report(entries, health, state) { +/** Plugins not marked v2 that tonight's re-check detects as V2. */ +function migratedPlugins(entries, compat) { + return entries.filter(e => e.category === 'plugins' && e.data.compat !== 'v2' && compat.get(e.file)?.compat === 'v2'); +} + +function report(entries, health, state, compat) { 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]); @@ -146,6 +184,8 @@ function report(entries, health, state) { 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 migrated = migratedPlugins(entries, compat); + const unconfirmed = v1.filter(e => !['v1', 'v2'].includes(compat.get(e.file)?.compat)); const stale = entries.filter(e => health.get(e.file).status === 'stale'); const npmMissing = entries.filter(e => health.get(e.file).npmMissing); @@ -157,7 +197,9 @@ function report(entries, health, state) { `| Entries | ${entries.length} |`, `| Archived, missing or deprecated (removed after ${GRACE_DAYS} days) | ${dead.length} |`, `| Renamed or transferred (fixed by bot PR) | ${renamed.length} |`, + `| Plugins now detected as V2 (marked by bot PR) | ${migrated.length} |`, `| V1-only plugins (removed after ${V1_SUNSET}) | ${v1.length} |`, + `| V1-only plugins tonight's re-check could not confirm (never removed automatically) | ${unconfirmed.length} |`, `| Plugins with unverified compatibility | ${unknown.length} |`, `| No push for a year (badge only, not removed) | ${stale.length} |`, '', @@ -167,6 +209,9 @@ function report(entries, health, state) { '### Renamed or transferred', rows(renamed, e => `- ${link(e)} → ${health.get(e.file).renamedTo}`), '', + '### Migrated to V2', + rows(migrated, e => `- ${link(e)}: ${e.data.compat} → v2 (${compat.get(e.file).evidence.join('; ')})`), + '', '### 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.', '', @@ -174,7 +219,7 @@ function report(entries, health, state) { '', `
V1-only plugins (${v1.length}), removed after ${V1_SUNSET} unless migrated`, '', - rows(v1, e => `- ${link(e)}`), + rows(v1, e => `- ${link(e)}${unconfirmed.includes(e) ? ': re-check inconclusive, verify by hand before the sunset' : ''}`), '', '
', '', @@ -194,12 +239,73 @@ function report(entries, health, state) { ].join('\n'); } +function sunsetIssueBody(entries, migrated) { + const plugins = entries.filter(e => e.category === 'plugins').sort((a, b) => a.data.name.localeCompare(b.data.name)); + const v1 = plugins.filter(e => e.data.compat === 'v1' && !migrated.includes(e)); + const unknown = plugins.filter(e => e.data.compat === 'unknown' && !migrated.includes(e)); + const pending = plugins.filter(e => migrated.includes(e)); + const row = e => `| [\`${e.data.name.replace(/[|`]/g, '')}\`](${e.data.repo}) | \`${e.file}\` |`; + const table = (summary, list) => list.length ? [ + `
${summary}`, '', '| Plugin | Entry |', '| :--- | :--- |', ...list.map(row), '', '
', '' + ] : []; + return [ + `The catalog only accepts new plugins built for **OpenCode V2**, and existing V1-only plugins carry a \`V1 only\` badge. **On ${V1_SUNSET}, V1-only plugins are removed** from the catalog unless they have migrated ([policy](${POLICY})).`, + '', + '**If you maintain one of these plugins:**', + '1. Depend on `@opencode/plugin` (instead of `@opencode-ai/plugin` 1.x).', + '2. Export `Plugin.define({ id: "your-plugin", async setup(ctx) { … } })`, or a plain `{ id, setup }` object. Keeping the V1 export alongside it for dual support is fine.', + '3. Publish. The nightly health check re-detects compatibility and opens a PR marking your entry `compat: v2`; you can also open that PR yourself and the `validate` check will confirm it.', + '', + 'Detection reads your npm package, `package.json` files and source ([`scripts/utils/compat.js`](https://github.com/Heretek-AI/awesome-opencode/blob/main/scripts/utils/compat.js)). If we got yours wrong, comment here with the file that shows V2 support and we will fix the entry.', + '', + `_List updated ${today()} by the nightly health check._`, + '', + ...table(`V1-only plugins (${v1.length})`, v1), + ...table(`Detected as V2, catalog update pending (${pending.length}): merged with the next bot/health-compat PR`, pending), + ...table(`Could not be verified automatically (${unknown.length}): maintainers will check these by hand`, unknown) + ].join('\n').trimEnd(); +} + +/** + * Keep the V1 sunset tracking issue's lists current, post one reminder REMINDER_DAYS before the sunset, + * and close the issue once no V1-only plugins remain after it. + */ +async function updateSunsetIssue(entries, migrated) { + const { data } = await rest(`/repos/${REPO}/issues?state=open&labels=v1-sunset&per_page=10`); + const issue = Array.isArray(data) ? data.find(i => !i.pull_request) : null; + if (!issue) return; + const body = sunsetIssueBody(entries, migrated); + if (body !== issue.body) await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { body } }); + + const remaining = entries.filter(e => e.category === 'plugins' && e.data.compat === 'v1' && !migrated.includes(e)).length; + const days = daysUntil(V1_SUNSET); + if (days > 0 && days <= REMINDER_DAYS && remaining) { + const comments = await listComments(issue.number); + if (!comments.some(c => (c.body || '').startsWith(REMINDER_MARKER))) { + await comment(issue.number, [ + REMINDER_MARKER, + `Reminder: V1-only plugins are removed from the catalog on ${V1_SUNSET}, ${days} day(s) from now. ${remaining} plugin(s) in the list above still use the V1 API.`, + '', + 'If yours has migrated, nothing else is needed: the nightly check detects it and opens a PR marking the entry `compat: v2`. If the check got it wrong, reply here with the file that shows V2 support.' + ].join('\n')); + console.log(`Posted the sunset reminder on #${issue.number}`); + } + } + if (days <= 0 && !remaining) { + await comment(issue.number, 'No V1-only plugins remain in the catalog. Closing.'); + await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { state: 'closed', state_reason: 'completed' } }); + } + console.log(`Updated the V1 sunset issue #${issue.number}`); +} + 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 compat = await recheckCompat(entries); + const migrated = migratedPlugins(entries, compat); const issue = await findIssue(); const state = updateState(readState(issue), entries, health); @@ -213,25 +319,37 @@ async function main() { console.log(`Wrote ${opts.out}`); } - const body = report(entries, health, state); + const body = report(entries, health, state, compat); 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`); + await updateSunsetIssue(entries, migrated); } else if (opts.dryRun) { console.log(body); } + // A file that is both renamed and migrated gets the same content in both PRs, so they never conflict + const updated = e => dumpEntry({ + ...e.data, + ...(health.get(e.file).renamedTo ? { repo: health.get(e.file).renamedTo } : {}), + ...(migrated.includes(e) ? { compat: 'v2' } : {}) + }); 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.files[e.file] = updated(e); renames.lines.push(`- \`${e.file}\`: ${e.data.repo} → ${to}`); } + const migrations = { files: {}, lines: [] }; + for (const e of migrated) { + migrations.files[e.file] = updated(e); + migrations.lines.push(`- \`${e.file}\` ([${e.data.name}](${e.data.repo})): ${e.data.compat} → v2 (${compat.get(e.file).evidence.join('; ')})`); + } const removals = { files: {}, lines: [] }; - for (const { entry, reason } of removalCandidates(entries, health, state)) { + for (const { entry, reason } of removalCandidates(entries, state, compat)) { removals.files[entry.file] = null; removals.lines.push(`- \`${entry.file}\` ([${entry.data.name}](${entry.data.repo})): ${reason}`); } @@ -242,6 +360,11 @@ async function main() { summary.push(...renames.lines); console.log(`Applied ${renames.lines.length} rename(s)`); } + if (opts.applyCompat) { + for (const [file, text] of Object.entries(migrations.files)) fs.writeFileSync(path.join(ROOT, file), text); + summary.push(...migrations.lines); + console.log(`Applied ${migrations.lines.length} V2 migration(s)`); + } if (opts.applyRemovals) { for (const file of Object.keys(removals.files)) fs.unlinkSync(path.join(ROOT, file)); summary.push(...removals.lines); @@ -261,6 +384,17 @@ async function main() { }); if (pr) console.log(`Renames: #${pr.number}`); } + if (migrations.lines.length) { + const pr = await openPullRequest({ + branch: 'bot/health-compat', + files: migrations.files, + title: 'chore: mark plugins that migrated to OpenCode V2', + body: ['The nightly health check now detects OpenCode V2 support in these V1-only or unverified plugins, so they no longer face the V1 sunset.', '', ...migrations.lines].join('\n'), + labels: ['health'], + dryRun: opts.dryRun + }); + if (pr) console.log(`V2 migrations: #${Number(pr.number)}`); + } if (removals.lines.length) { const pr = await openPullRequest({ branch: 'bot/health-removals', @@ -277,9 +411,14 @@ async function main() { const counts = {}; for (const h of health.values()) counts[h.status] = (counts[h.status] || 0) + 1; console.log('status counts:', JSON.stringify(counts)); + console.log(`compat re-check: ${compat.size} plugin(s), ${migrated.length} now V2`); } -main().catch(err => { - console.error(err); - process.exit(1); -}); +if (require.main === module) { + main().catch(err => { + console.error(err); + process.exit(1); + }); +} + +module.exports = { migratedPlugins, removalCandidates, sunsetIssueBody }; diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..3ce0848 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,41 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { V1_SUNSET } = require('../scripts/utils/catalog'); +const { migratedPlugins, removalCandidates, sunsetIssueBody } = require('../scripts/health-check'); + +const plugin = (id, compat) => ({ + category: 'plugins', + id, + file: `data/plugins/${id}.yaml`, + data: { name: id, repo: `https://github.com/someone/${id}`, tagline: 't', description: 'd', kind: 'plugin', topics: ['workflow'], compat } +}); +const recheck = results => new Map(Object.entries(results).map(([id, compat]) => [`data/plugins/${id}.yaml`, { compat, evidence: [`probe: ${compat}`] }])); + +test('after the sunset, only V1 plugins the re-check still detects as V1 are removed', () => { + const entries = ['still-v1', 'migrated', 'inconclusive', 'unprobed'].map(id => plugin(id, 'v1')); + const compat = recheck({ 'still-v1': 'v1', migrated: 'v2', inconclusive: 'unknown' }); + const removed = removalCandidates(entries, {}, compat, V1_SUNSET).map(r => r.entry.id); + assert.deepEqual(removed, ['still-v1']); + assert.deepEqual(removalCandidates(entries, {}, compat, '2026-12-31'), []); +}); + +test('dead entries are removed after the grace period regardless of compat', () => { + const entries = [plugin('gone', 'v2')]; + const removed = removalCandidates(entries, { 'data/plugins/gone.yaml': { since: '2020-01-01', reason: 'archived' } }, new Map()); + assert.equal(removed.length, 1); +}); + +test('plugins not marked v2 that now probe as V2 are migrations', () => { + const entries = [plugin('a', 'v1'), plugin('b', 'unknown'), plugin('c', 'v1'), plugin('d', 'v2')]; + const compat = recheck({ a: 'v2', b: 'v2', c: 'unknown' }); + assert.deepEqual(migratedPlugins(entries, compat).map(e => e.id), ['a', 'b']); +}); + +test('the sunset issue lists remaining V1 plugins and pending migrations separately', () => { + const entries = [plugin('stuck', 'v1'), plugin('moved', 'v1'), plugin('odd', 'unknown')]; + const body = sunsetIssueBody(entries, [entries[1]]); + assert.match(body, /V1-only plugins \(1\)[\s\S]*stuck\.yaml/); + assert.match(body, /catalog update pending \(1\)[\s\S]*moved\.yaml/); + assert.match(body, /Could not be verified automatically \(1\)[\s\S]*odd\.yaml/); + assert.doesNotMatch(body.split('catalog update pending')[0], /moved\.yaml/); +});