From d23bb027f953dcc2de126d07420ee9fcd7216517 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sat, 17 Oct 2026 13:00:33 -0400 Subject: [PATCH] feat: issue-form submissions and npm run new scaffolder The "Add an entry" issue form collects fields; issues.yml runs scripts/submit-entry.js, which validates and probes the entry, comments the result and opens a PR from submission/ (updated on every issue edit). Contributors never write YAML or touch the README, the two most common rejection causes. npm run new writes a correctly formatted entry locally. Bot PRs dispatch the validate check because GITHUB_TOKEN pushes do not trigger workflows. Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/add-entry.yml | 134 ++++++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 40 +++----- .github/workflows/issues.yml | 71 ++++++++++++++ scripts/new-entry.js | 72 ++++++++++++++ scripts/submit-entry.js | 84 ++++++++++++++++ scripts/utils/git-pr.js | 76 +++++++++++++++ scripts/utils/submission.js | 139 +++++++++++++++++++++++++++ 7 files changed, 587 insertions(+), 29 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/add-entry.yml create mode 100644 .github/workflows/issues.yml create mode 100644 scripts/new-entry.js create mode 100644 scripts/submit-entry.js create mode 100644 scripts/utils/git-pr.js create mode 100644 scripts/utils/submission.js diff --git a/.github/ISSUE_TEMPLATE/add-entry.yml b/.github/ISSUE_TEMPLATE/add-entry.yml new file mode 100644 index 0000000..21f0079 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/add-entry.yml @@ -0,0 +1,134 @@ +name: Add an entry +description: Submit a plugin, theme, agent, project or resource. A bot validates it and opens the pull request for you. +title: "Add: " +labels: ["submission"] +body: + - type: markdown + attributes: + value: | + Fill in the fields below; you never have to write YAML or touch the README. A bot checks the entry within a minute, comments here, and opens a pull request when it passes. Edit this issue to fix anything it reports. + + **Plugins must be built on OpenCode V2**: depend on `@opencode/plugin` and export `Plugin.define({ id, setup(ctx) })`. The check reads your package.json (or npm package) to verify this. See the [catalog policy](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md). + - type: input + id: name + attributes: + label: Name + description: Display name (max 80 characters). + placeholder: Quota Board + validations: + required: true + - type: input + id: repo + attributes: + label: Repository URL + description: The repository root, e.g. https://github.com/owner/repo. For a package inside a monorepo, use the next field for its folder. + placeholder: https://github.com/owner/repo + validations: + required: true + - type: input + id: path + attributes: + label: Folder inside the repository + description: Only for monorepos, e.g. packages/my-plugin. + - type: input + id: npm + attributes: + label: npm package + description: The package users install, if any (e.g. opencode-my-plugin). Strongly recommended for plugins. + - type: dropdown + id: category + attributes: + label: Category + options: + - plugins + - themes + - agents + - projects + - resources + validations: + required: true + - type: dropdown + id: kind + attributes: + label: Kind + description: "plugins → plugin; themes → theme; agents → agent or skill; projects → client, tool, mcp-server, skill, agent or fork; resources → guide, config, mcp-server, skill or tool." + options: + - plugin + - theme + - agent + - skill + - mcp-server + - client + - tool + - config + - guide + - fork + validations: + required: true + - type: dropdown + id: topics + attributes: + label: Topics + description: Pick 1 to 3. The first one decides where it appears in the README. + multiple: true + options: + - memory + - context + - orchestration + - workflow + - providers + - cost + - notifications + - sessions + - code-quality + - security + - git + - remote + - web + - ui + - voice + - media + - docs + - environment + - observability + - integrations + - code-intel + - prompting + - domain + validations: + required: true + - type: input + id: tagline + attributes: + label: Tagline + description: One short line, max 120 characters. + placeholder: Provider quotas and reset countdowns in the OpenCode sidebar + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: What it does and why it's useful (max 1500 characters). + validations: + required: true + - type: input + id: homepage + attributes: + label: Documentation URL + description: Only if the docs live somewhere other than the repository. + - type: textarea + id: installation + attributes: + label: Installation + description: Optional short install snippet (markdown). + render: markdown + - type: checkboxes + id: policy + attributes: + label: Checklist + options: + - label: The repository is public and maintained by me or the project I'm representing, or I'm a user recommending it. + required: true + - label: It is not a cryptocurrency, token, or trading project (these are not accepted). + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 671dbd5..31b2962 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,17 @@ -## Submission Type + -## Details +## What does this add or change? -**Name:** -**Repository:** -**Tagline:** + -## Checklist +## Plugins only: OpenCode V2 -- [ ] Relevant to OpenCode -- [ ] Repository is public and accessible -- [ ] Actively maintained -- [ ] Not a duplicate -- [ ] YAML file in correct folder (`data/{category}/`) -- [ ] Filename is kebab-case (e.g., `my-plugin.yaml`) +- [ ] The plugin depends on `@opencode/plugin` (not the legacy `@opencode-ai/plugin` 1.x) and exports `Plugin.define({ id, setup(ctx) })`. -## YAML Format - -Create a file in `data/{category}/your-entry.yaml`: - -```yaml -name: Your Entry Name -repo: https://github.com/owner/repo -tagline: Short punchy summary (shown in collapsed view) -description: Longer description explaining what it does. -``` - -See [contributing.md](contributing.md) for full instructions. +New V1 plugins are not accepted. The `validate` check verifies this automatically and explains anything it can't confirm. See [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md). diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..64c0777 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,71 @@ +# Issue automation: +# - "Add an entry" form submissions (label: submission) become PRs on submission/. +# - Ticked candidates in Scout issues (label: scout) become PRs on scout/. +# +# Security: issue text is untrusted. It is only read by the Node scripts as data (via the API); +# nothing from the issue is interpolated into shell commands. +name: Issue automation + +on: + issues: + types: [opened, edited, reopened, labeled] + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + +concurrency: + group: issue-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + submission: + if: contains(github.event.issue.labels.*.name, 'submission') && github.event.issue.state == 'open' + 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: Build entry and open PR + env: + GITHUB_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: node scripts/submit-entry.js --issue "$ISSUE_NUMBER" + + scout-accept: + if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'scout') + 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: Open PRs for ticked candidates + env: + GITHUB_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + SENDER: ${{ github.event.sender.login }} + run: node scripts/scout.js accept --issue "$ISSUE_NUMBER" --sender "$SENDER" diff --git a/scripts/new-entry.js b/scripts/new-entry.js new file mode 100644 index 0000000..53d5b6f --- /dev/null +++ b/scripts/new-entry.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Scaffold a catalog entry with valid formatting. + * + * Usage: + * npm run new -- --category plugins --repo https://github.com/me/opencode-thing \ + * --tagline "Does the thing" --topics memory,context [--name "Thing"] [--description "..."] \ + * [--npm opencode-thing] [--path packages/thing] [--kind plugin] [--homepage URL] + * + * Name and description default to the GitHub repo's name and description. The V2 check runs for + * plugins and fills in `compat`. Writes data//.yaml and prints any problems. + */ + +const fs = require('fs'); +const path = require('path'); +const { CATEGORIES, KINDS, TOPICS, parseRepoUrl } = require('./utils/catalog'); +const { getRepos } = require('./utils/github'); +const { buildEntry } = require('./utils/submission'); + +const ROOT = path.join(__dirname, '..'); + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const key = argv[i].slice(2); + const next = argv[i + 1]; + args[key] = next && !next.startsWith('--') ? (i++, next) : true; + } + } + return args; +} + +function usage(message) { + console.error(`${message}\n +Required: --category <${CATEGORIES.join('|')}> --repo --tagline --topics +Optional: --name --description --npm --path --kind <${KINDS.join('|')}> --homepage --installation --force +Topics (1-3, comma-separated): ${TOPICS.join(', ')}`); + process.exit(1); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.category || !args.repo || !args.tagline || !args.topics) usage('Missing required options.'); + + const parsed = parseRepoUrl(args.repo); + if ((!args.name || !args.description) && parsed && parsed.host === 'github.com') { + const info = (await getRepos([{ owner: parsed.owner, name: parsed.name }])).get(`${parsed.owner}/${parsed.name}`); + if (info) { + args.name = args.name || parsed.name; + args.description = args.description || info.description || undefined; + } + } + + const result = await buildEntry(args); + for (const note of result.notes) console.log(`ℹ ${note}`); + if (!result.file) usage(result.problems.join('\n')); + + const target = path.join(ROOT, result.file); + if (!result.ok && !args.force) { + console.error(`✗ Not written:\n${result.problems.map(p => ` - ${p}`).join('\n')}\n(pass --force to write it anyway and fix by hand)`); + process.exit(1); + } + fs.writeFileSync(target, result.text); + console.log(`✓ Wrote ${result.file}${result.ok ? '' : ' (with problems; run npm run validate)'}`); + console.log(result.text); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/submit-entry.js b/scripts/submit-entry.js new file mode 100644 index 0000000..4c2aadc --- /dev/null +++ b/scripts/submit-entry.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Turn an "Add an entry" issue into a catalog pull request. + * Usage: node scripts/submit-entry.js --issue [--dry-run] + * + * Reads the issue form as data (never executes it), validates and probes the entry, comments the + * result on the issue, and on success opens or updates the PR from branch submission/. + */ + +const { rest } = require('./utils/github'); +const { openPullRequest } = require('./utils/git-pr'); +const { REPO, addLabels, setTriageLabels, upsertComment } = require('./utils/repo'); +const { buildEntry, parseIssueForm } = require('./utils/submission'); + +const MARKER = ''; + +async function main() { + const argv = process.argv.slice(2); + const number = Number(argv[argv.indexOf('--issue') + 1]); + const dryRun = argv.includes('--dry-run'); + if (!number) throw new Error('usage: submit-entry.js --issue '); + + const { data: issue } = await rest(`/repos/${REPO}/issues/${number}`); + if (!issue || issue.pull_request) throw new Error(`#${number} is not an issue`); + if (issue.state !== 'open') return console.log(`#${number} is closed; nothing to do`); + + const result = await buildEntry(parseIssueForm(issue.body)); + const current = issue.labels.map(l => l.name); + + let pr = null; + if (result.ok) { + pr = await openPullRequest({ + branch: `submission/${number}`, + files: { [result.file]: result.text }, + title: `docs: add ${result.data.name.replace(/[\r\n]+/g, ' ').slice(0, 80)} to ${result.category}`, + body: [ + `Submitted by @${issue.user.login} in #${number}.`, + '', + ...result.notes.map(n => `- ${n}`), + '', + `Closes #${number}` + ].join('\n'), + labels: ['submission', `category: ${result.category}`], + dryRun + }); + } + + const body = result.ok + ? [ + '### ✅ Entry looks good', + '', + pr ? `Opened ${pr.created ? '' : 'and updated '}#${pr.number} with \`${result.file}\`. Edit this issue to change the entry; the PR updates automatically.` : `\`${result.file}\` is ready${dryRun ? ' (dry run)' : '; it already matches the open PR'}.`, + '', + ...result.notes.map(n => `- ${n}`), + '', + '
Generated YAML', + '', + '```yaml', + result.text.trim(), + '```', + '
' + ].join('\n') + : [ + '### ❌ This entry needs changes', + '', + ...result.problems.map(p => `- ${p}`), + ...(result.notes.length ? ['', ...result.notes.map(n => `- ${n}`)] : []), + '', + 'Edit this issue to fix the fields above; the checks run again automatically.' + ].join('\n'); + + console.log(body); + if (dryRun) return; + + const triage = result.ok ? ['ready'] : result.problems.some(p => p.includes('V1 plugin API')) ? ['needs-v2'] : ['needs-metadata']; + await setTriageLabels(number, triage, current); + if (result.category && !current.includes(`category: ${result.category}`)) await addLabels(number, [`category: ${result.category}`]); + await upsertComment(number, MARKER, body); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/utils/git-pr.js b/scripts/utils/git-pr.js new file mode 100644 index 0000000..ed29caf --- /dev/null +++ b/scripts/utils/git-pr.js @@ -0,0 +1,76 @@ +/** + * Open or update a bot pull request from file changes (used by submissions, scout, health). + * + * Commits made with GITHUB_TOKEN do not trigger workflows, so after pushing, the "validate" workflow + * is dispatched on the branch; its check run attaches to the head commit and satisfies the required + * status check on the PR. + */ + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { rest } = require('./github'); +const { REPO, addLabels } = require('./repo'); + +const ROOT = path.join(__dirname, '../..'); +const BOT = ['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com']; + +const git = (...args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +/** + * @param {object} options + * @param {string} options.branch - e.g. "submission/123" + * @param {Object} options.files - repo-relative path -> new content (null deletes) + * @param {string} options.title + * @param {string} options.body + * @param {string[]} [options.labels] + * @param {string} [options.base="main"] + * @param {boolean} [options.dryRun] + * @returns {Promise<{number: number, url: string, created: boolean}|null>} null when there is nothing to change + */ +async function openPullRequest({ branch, files, title, body, labels = [], base = 'main', dryRun = false }) { + if (!/^[a-z0-9][a-z0-9/_.-]*$/i.test(branch)) throw new Error(`unsafe branch name: ${branch}`); + const original = git('rev-parse', '--abbrev-ref', 'HEAD'); + git('fetch', '--quiet', 'origin', base); + git('checkout', '--quiet', '-B', branch, `origin/${base}`); + try { + for (const [file, content] of Object.entries(files)) { + const target = path.join(ROOT, file); + if (!target.startsWith(path.join(ROOT, 'data') + path.sep)) throw new Error(`refusing to write outside data/: ${file}`); + if (content === null) { + if (fs.existsSync(target)) git('rm', '--quiet', '--', file); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + git('add', '--', file); + } + } + if (!git('diff', '--cached', '--name-only')) return null; + if (dryRun) { + console.log(`[dry run] would push ${branch}: ${git('diff', '--cached', '--stat')}`); + return null; + } + git(...BOT, 'commit', '--quiet', '-m', title); + git('push', '--quiet', '--force', 'origin', `HEAD:refs/heads/${branch}`); + } finally { + git('checkout', '--quiet', '--force', original === 'HEAD' ? `origin/${base}` : original); + } + + const owner = REPO.split('/')[0]; + const { data: existing } = await rest(`/repos/${REPO}/pulls?state=open&head=${owner}:${encodeURIComponent(branch)}`); + let pr; + let created = false; + if (Array.isArray(existing) && existing.length) { + ({ data: pr } = await rest(`/repos/${REPO}/pulls/${existing[0].number}`, { method: 'PATCH', body: { title, body } })); + } else { + const res = await rest(`/repos/${REPO}/pulls`, { method: 'POST', body: { title, body, head: branch, base } }); + if (res.status >= 300) throw new Error(`create PR for ${branch}: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 300)}`); + pr = res.data; + created = true; + } + if (labels.length) await addLabels(pr.number, labels); + await rest(`/repos/${REPO}/actions/workflows/validate-pr.yml/dispatches`, { method: 'POST', body: { ref: branch } }); + return { number: pr.number, url: pr.html_url, created }; +} + +module.exports = { openPullRequest }; diff --git a/scripts/utils/submission.js b/scripts/utils/submission.js new file mode 100644 index 0000000..924db8d --- /dev/null +++ b/scripts/utils/submission.js @@ -0,0 +1,139 @@ +/** + * Build catalog entries from structured input (issue form, scout candidates, CLI scaffolder). + */ + +const fs = require('fs'); +const path = require('path'); +const { CATEGORIES, DATA_DIR, canonicalKey, checkCatalog, loadCatalog, parseRepoUrl } = require('./catalog'); +const { probeCompat } = require('./compat'); +const { dumpEntry } = require('./format'); +const { slugify } = require('./yaml'); + +const ISSUE_FORM_FIELDS = { + 'Name': 'name', + 'Repository URL': 'repo', + 'Folder inside the repository': 'path', + 'npm package': 'npm', + 'Category': 'category', + 'Kind': 'kind', + 'Topics': 'topics', + 'Tagline': 'tagline', + 'Description': 'description', + 'Documentation URL': 'homepage', + 'Installation': 'installation' +}; + +const EMPTY = /^(_no response_|none|n\/a|-)?$/i; + +/** + * Parse a GitHub issue form body ("### Label\n\nvalue" sections). + * @param {string} body + * @returns {object} raw fields keyed by ISSUE_FORM_FIELDS values + */ +function parseIssueForm(body) { + const fields = {}; + const sections = String(body || '').split(/^###\s+/m).slice(1); + for (const section of sections) { + const [label, ...rest] = section.split('\n'); + const key = ISSUE_FORM_FIELDS[label.trim()]; + if (!key) continue; + let value = rest.join('\n').trim(); + if (EMPTY.test(value)) continue; + if (key === 'installation') value = value.replace(/^```\w*\n?|\n?```$/g, '').trim(); + fields[key] = value; + } + return fields; +} + +/** + * Normalize raw input into entry data. + * @param {object} input - { name, repo, path, npm, category, kind, topics, tagline, description, homepage, installation } + * @returns {{category: string, data: object}} + */ +function normalizeInput(input) { + const category = String(input.category || '').trim().toLowerCase(); + const topics = Array.isArray(input.topics) + ? input.topics + : String(input.topics || '').split(/[,\n]/).map(t => t.trim().toLowerCase()).filter(Boolean); + let repo = String(input.repo || '').trim().replace(/\.git$/, '').replace(/\/+$/, ''); + let repoPath = input.path ? String(input.path).trim().replace(/^\/+|\/+$/g, '') : undefined; + // Accept a pasted /tree// URL by splitting it into repo + path + const tree = repo.match(/^(https:\/\/github\.com\/[^/]+\/[^/]+)\/tree\/[^/]+\/(.+)$/); + if (tree) { + repo = tree[1]; + repoPath = repoPath || tree[2]; + } + const data = { + name: input.name && String(input.name).trim(), + repo, + path: repoPath, + npm: input.npm ? String(input.npm).trim().replace(/^https:\/\/www\.npmjs\.com\/package\//, '') : undefined, + homepage: input.homepage ? String(input.homepage).trim() : undefined, + tagline: input.tagline && String(input.tagline).trim(), + description: input.description && String(input.description).trim(), + kind: input.kind ? String(input.kind).trim().toLowerCase() : undefined, + topics, + compat: input.compat, + installation: input.installation ? String(input.installation).trim() : undefined + }; + return { category, data }; +} + +/** + * Pick a free filename: .yaml (or the monorepo folder name), then -.yaml. + * @returns {string} path relative to the repository root + */ +function chooseFile(category, data, taken = new Set()) { + const parsed = parseRepoUrl(data.repo) || { owner: 'entry', name: slugify(data.name || 'entry') }; + const base = slugify(data.path ? path.basename(data.path) : parsed.name) || slugify(data.name || 'entry'); + const candidates = [base, slugify(`${parsed.owner}-${base}`), slugify(`${base}-${data.name || ''}`)]; + for (const candidate of candidates) { + const file = path.join('data', category, `${candidate}.yaml`); + if (!taken.has(file) && !fs.existsSync(path.join(DATA_DIR, '..', file))) return file; + } + for (let i = 2; ; i++) { + const file = path.join('data', category, `${base}-${i}.yaml`); + if (!taken.has(file) && !fs.existsSync(path.join(DATA_DIR, '..', file))) return file; + } +} + +/** + * Build, verify and serialize an entry. + * @param {object} input - raw fields + * @param {object} [options] - { probe: boolean } + * @returns {Promise<{ok: boolean, problems: string[], notes: string[], category: string, file?: string, text?: string, data: object}>} + */ +async function buildEntry(input, { probe = true } = {}) { + const { category, data } = normalizeInput(input); + const problems = []; + const notes = []; + + if (!CATEGORIES.includes(category)) { + problems.push(`category must be one of: ${CATEGORIES.join(', ')}`); + return { ok: false, problems, notes, category, data }; + } + if (category === 'plugins') data.kind = data.kind || 'plugin'; + if (category === 'themes') data.kind = data.kind || 'theme'; + + if (probe && data.repo && (category === 'plugins' || data.npm)) { + const result = (await probeCompat([{ key: 'entry', repo: data.repo, path: data.path, npm: data.npm, plugin: category === 'plugins' }])).get('entry'); + data.compat = result.compat === 'unknown' && category !== 'plugins' ? 'n/a' : result.compat; + notes.push(`V2 check: ${result.compat}${result.evidence.length ? ` (${result.evidence.join('; ')})` : ''}`); + if (category === 'plugins' && result.compat === 'v1') { + problems.push('this plugin targets the legacy V1 plugin API. New plugins must depend on @opencode/plugin and export Plugin.define({ id, setup(ctx) }). Edit this issue after migrating and the check runs again.'); + } + } else if (category !== 'plugins') { + data.compat = data.compat || 'n/a'; + } + + const { entries } = loadCatalog(); + const file = chooseFile(category, data); + const entry = { category, id: path.basename(file, '.yaml'), file, data }; + const duplicate = entries.find(e => e.data.repo && canonicalKey(e.data) === canonicalKey(data)); + if (duplicate) problems.push(`already in the catalog as ${duplicate.file}`); + for (const problem of checkCatalog([entry])) problems.push(problem.message); + + return { ok: problems.length === 0, problems, notes, category, file, text: dumpEntry(data), data }; +} + +module.exports = { ISSUE_FORM_FIELDS, buildEntry, chooseFile, normalizeInput, parseIssueForm };