fix: quieter retarget warnings and plugin/project split in the scout

check-pr no longer flags repo URL changes that GitHub redirects (renames/transfers) or that only add a monorepo path, so bot rename PRs stay clean. The scout files packages built on @opencode/plugin as plugins and other @opencode/* consumers (SDK/client tools) as projects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John SmithandClaude Opus 5.5 committed 2026-10-17 13:06:06 -04:00
1 parent 91287042b8
commit 7b219dd82a
3 files changed
+33 -14

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
# Weekly discovery of OpenCode V2 plugins on npm that are not in the catalog yet.
# Weekly discovery of OpenCode V2 plugins and SDK tools 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
name: Scout V2 packages
on:
schedule:
+13 -9
View File
@@ -22,7 +22,7 @@ const { getNpmLatest, getNpmWeeklyDownloads, getRepos, rest } = require('./utils
const { openPullRequest } = require('./utils/git-pr');
const { REPO, ensureLabels, listOpenPulls } = require('./utils/repo');
const { buildEntry } = require('./utils/submission');
const { suggestTopics } = require('./utils/suggest');
const { suggestKind, suggestTopics } = require('./utils/suggest');
const V2_RELEASED = '2026-09-02';
const SEARCHES = ['keywords:opencode-plugin', '@opencode/plugin', 'keywords:opencode'];
@@ -144,13 +144,16 @@ async function findCandidates(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(' ');
// Packages built on the plugin API are plugins; other @opencode/* consumers (SDK/client) are projects
const isPlugin = c.markers.v2.some(m => /^@opencode(-ai)?\/plugin\b/.test(m));
const category = isPlugin ? 'plugins' : 'projects';
return {
name: c.pkg.name,
repo: c.source.repo,
path: c.source.path,
npm: c.pkg.name,
category: 'plugins',
kind: 'plugin',
category,
kind: isPlugin ? 'plugin' : suggestKind('projects', text),
topics: suggestTopics(text),
tagline: truncate(c.manifest.description || description || c.pkg.name, 120),
description: description || c.pkg.name
@@ -164,10 +167,11 @@ async function run({ dryRun, limit }) {
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 label = data[id].category === 'plugins' ? 'plugin' : data[id].kind;
return `- [ ] **${c.pkg.name}** (${label}) — ${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.`,
`OpenCode V2 packages on npm that are not in the catalog yet: published since ${V2_RELEASED}, depending on \`@opencode/*\` (plugins on \`@opencode/plugin\`, other tools on the SDK/client), 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).',
'',
@@ -183,7 +187,7 @@ async function run({ dryRun, limit }) {
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'] } });
const res = await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: `Scout: V2 candidates (${week})`, body, labels: ['scout'] } });
console.log(`Opened scout issue #${res.data.number} with ${candidates.length} candidate(s)`);
}
@@ -211,11 +215,11 @@ async function accept({ issueNumber, sender, dryRun }) {
note = ` → not added: ${result.problems.join('; ').slice(0, 200)}`;
} else {
const pr = await openPullRequest({
branch: `scout/${result.file.replace(/^data\/plugins\/|\.yaml$/g, '')}`,
branch: `scout/${result.file.replace(/^data\/[a-z]+\/|\.yaml$/g, '')}`,
files: { [result.file]: result.text },
title: `docs: add ${input.name} to plugins`,
title: `docs: add ${input.name} to ${input.category}`,
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'],
labels: ['scout', `category: ${input.category}`],
dryRun
});
note = pr ? ` → #${pr.number}` : ' → (no changes)';
+18 -3
View File
@@ -7,8 +7,9 @@
const path = require('path');
const yaml = require('js-yaml');
const { CATEGORIES, canonicalKey, checkCatalog } = require('./catalog');
const { CATEGORIES, canonicalKey, checkCatalog, parseRepoUrl } = require('./catalog');
const { probeCompat } = require('./compat');
const { getRepos } = require('./github');
// Generated files a PR must never edit by hand
const GENERATED_FILES = ['README.md', 'dist/registry.json'];
@@ -47,6 +48,7 @@ async function analyzePullRequest({ changes, baseEntries, probe = true }) {
// Overlay the PR's data changes onto the base catalog
const head = new Map(baseEntries.map(e => [e.file, e]));
const changed = [];
const retargeted = [];
for (const change of changes.filter(c => DATA_FILE_RE.test(c.path))) {
head.delete(change.path);
if (change.status === 'removed') continue;
@@ -69,13 +71,26 @@ async function analyzePullRequest({ changes, baseEntries, probe = true }) {
if (change.status === 'modified' && change.baseContent) {
try {
const before = yaml.load(change.baseContent);
if (before && before.repo && data.repo && canonicalKey(before) !== canonicalKey(data)) {
warnings.push({ file: change.path, kind: 'human', message: `changes this entry's target from ${before.repo} to ${data.repo}. If this is a different project, add a new file instead of reusing an existing filename.` });
// Adding or changing `path` within the same repo is not a new target
if (before && before.repo && data.repo && canonicalKey({ repo: before.repo }) !== canonicalKey({ repo: data.repo })) {
retargeted.push({ file: change.path, from: before.repo, to: data.repo });
}
} catch { /* base file was already broken */ }
}
}
// A changed repo URL is fine when GitHub redirects the old name to the new one (rename/transfer)
if (retargeted.length) {
const olds = retargeted.map(r => parseRepoUrl(r.from)).filter(p => p && p.host === 'github.com');
const resolved = probe ? await getRepos(olds.map(p => ({ owner: p.owner, name: p.name }))).catch(() => new Map()) : new Map();
for (const r of retargeted) {
const old = parseRepoUrl(r.from);
const info = old && resolved.get(`${old.owner}/${old.name}`);
if (info && canonicalKey({ repo: `https://github.com/${info.nameWithOwner}` }) === canonicalKey({ repo: r.to })) continue;
warnings.push({ file: r.file, kind: 'human', message: `changes this entry's target from ${r.from} to ${r.to}. If this is a different project, add a new file instead of reusing an existing filename.` });
}
}
// YAML files outside data/<category>/
for (const change of changes) {
if (change.path.startsWith('data/') && change.path.endsWith('.yaml') && !DATA_FILE_RE.test(change.path) && !change.path.startsWith('data/examples/') && change.status !== 'removed') {