mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 10:45:57 -10:00
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/<issue> (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 <[email protected]>
This commit is contained in:
1 parent
ef6357d8d1
commit
d23bb027f9
7 files changed
+587
-29
No files matched your search
@@ -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
|
||||
@@ -1,35 +1,17 @@
|
||||
## Submission Type
|
||||
<!--
|
||||
Easiest path: use the "Add an entry" issue form instead of a PR. A bot writes the YAML and opens the PR for you:
|
||||
https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml
|
||||
|
||||
- [ ] Plugin
|
||||
- [ ] Project
|
||||
- [ ] Theme
|
||||
- [ ] Agent
|
||||
- [ ] Resource
|
||||
Editing by hand? Add data/<category>/<name>.yaml (see contributing.md). Do not edit README.md; it is generated.
|
||||
`npm run new -- --category plugins --repo <url> --tagline "..." --topics memory` writes a valid file for you.
|
||||
-->
|
||||
|
||||
## Details
|
||||
## What does this add or change?
|
||||
|
||||
**Name:**
|
||||
**Repository:**
|
||||
**Tagline:**
|
||||
<!-- One or two sentences. -->
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Issue automation:
|
||||
# - "Add an entry" form submissions (label: submission) become PRs on submission/<issue>.
|
||||
# - Ticked candidates in Scout issues (label: scout) become PRs on scout/<name>.
|
||||
#
|
||||
# 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"
|
||||
@@ -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/<category>/<name>.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 <url> --tagline <text> --topics <list>
|
||||
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);
|
||||
});
|
||||
@@ -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 <number> [--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/<issue>.
|
||||
*/
|
||||
|
||||
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 = '<!-- awesome-opencode:submission -->';
|
||||
|
||||
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 <number>');
|
||||
|
||||
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}`),
|
||||
'',
|
||||
'<details><summary>Generated YAML</summary>',
|
||||
'',
|
||||
'```yaml',
|
||||
result.text.trim(),
|
||||
'```',
|
||||
'</details>'
|
||||
].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);
|
||||
});
|
||||
@@ -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<string, string|null>} 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 };
|
||||
@@ -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/<branch>/<folder> 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: <repo-name>.yaml (or the monorepo folder name), then <owner>-<repo>.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 };
|
||||
Reference in new issue
Block a user