diff --git a/.github/ISSUE_TEMPLATE/external-plugin.yml b/.github/ISSUE_TEMPLATE/external-plugin.yml new file mode 100644 index 000000000..3a0587d5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/external-plugin.yml @@ -0,0 +1,127 @@ +name: External plugin submission +description: Submit a public GitHub-hosted external plugin for marketplace review. +title: "[External Plugin]: " +labels: + - external-plugin + - awaiting-review +body: + - type: markdown + attributes: + value: | + + Thanks for submitting a public external plugin. + + Before you continue: + - Public submissions are **GitHub-only** in v1. + - The plugin must live in a **public GitHub repository**. + - Use an **immutable ref** for review: a release tag or full 40-character commit SHA. + - Do **not** open a PR that edits `plugins/external.json` directly. + - type: input + id: plugin-name + attributes: + label: Plugin name + description: Lowercase letters, numbers, and hyphens only. + placeholder: my-plugin + validations: + required: true + - type: textarea + id: short-description + attributes: + label: Short description + description: One or two sentences describing the plugin. + placeholder: Helps developers... + validations: + required: true + - type: input + id: github-repository + attributes: + label: GitHub repository + description: Public GitHub repository in owner/repo format. + placeholder: owner/repo + validations: + required: true + - type: input + id: plugin-path + attributes: + label: Plugin path inside the repository + description: Optional if the plugin lives at the repository root. + placeholder: .github/plugins/my-plugin + validations: + required: false + - type: input + id: immutable-ref + attributes: + label: Immutable ref to review + description: Release tag or full 40-character commit SHA. + placeholder: refs/tags/v1.2.3 or 0123456789abcdef0123456789abcdef01234567 + validations: + required: true + - type: input + id: version + attributes: + label: Version + placeholder: 1.0.0 + validations: + required: true + - type: input + id: license + attributes: + label: License identifier + description: SPDX identifier or other license string. + placeholder: MIT + validations: + required: true + - type: input + id: author-name + attributes: + label: Author name + placeholder: Example Maintainers + validations: + required: true + - type: input + id: author-url + attributes: + label: Author URL + description: Optional HTTPS URL. + placeholder: https://example.com + validations: + required: false + - type: input + id: homepage-url + attributes: + label: Homepage URL + description: Optional HTTPS URL if different from the repository URL. + placeholder: https://example.com/plugin + validations: + required: false + - type: textarea + id: keywords + attributes: + label: Keywords + description: Comma-separated or newline-separated lowercase tags. + placeholder: | + automation + github + copilot + validations: + required: true + - type: textarea + id: additional-notes + attributes: + label: Additional notes for reviewers + description: Optional context that helps maintainers review the plugin. + validations: + required: false + - type: checkboxes + id: submission-checklist + attributes: + label: Submission checklist + options: + - label: The plugin lives in a public GitHub repository. + required: true + - label: The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch. + required: true + - label: This submission follows this repository's contribution, security, and responsible AI policies. + required: true + - label: This plugin is not already listed in the Awesome Copilot marketplace. + required: true diff --git a/.github/workflows/external-plugin-approval-command.yml b/.github/workflows/external-plugin-approval-command.yml new file mode 100644 index 000000000..8a20b75d9 --- /dev/null +++ b/.github/workflows/external-plugin-approval-command.yml @@ -0,0 +1,534 @@ +name: External Plugin Approval Commands + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + handle-command: + runs-on: ubuntu-latest + if: >- + !github.event.issue.pull_request && + (contains(github.event.comment.body, '/approve') || contains(github.event.comment.body, '/reject')) + steps: + - name: Checkout staged branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: staged + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + + - name: Parse decision command + id: parse + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const path = require('path'); + const { pathToFileURL } = require('url'); + + const approval = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-approval.mjs')).href); + const intake = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake.mjs')).href); + const parsedCommand = approval.parseDecisionCommand(context.payload.comment.body); + + core.setOutput('should-run', 'false'); + if (!parsedCommand) { + core.info('No supported external plugin approval command was found.'); + return; + } + + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login + }); + + const hasWriteAccess = ['admin', 'write', 'maintain'].includes(permission.data.permission); + if (!hasWriteAccess) { + core.info(`Ignoring ${parsedCommand.command} because ${context.payload.comment.user.login} does not have write access.`); + return; + } + + const currentIssue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const labelNames = new Set((currentIssue.data.labels || []).map((label) => label.name)); + if (!labelNames.has('external-plugin')) { + core.info('Ignoring command because the issue is not an external plugin submission.'); + return; + } + + const evaluation = await intake.evaluateExternalPluginIssue({ + issue: currentIssue.data, + token: process.env.GITHUB_TOKEN + }); + + const fallbackName = evaluation.plugin?.name ?? `issue-${context.issue.number}`; + const canApprove = labelNames.has('ready-for-review') || labelNames.has('approved'); + const canReject = !labelNames.has('approved'); + + if (parsedCommand.command === 'approve' && !canApprove) { + core.info('Ignoring /approve because the issue is not ready for review.'); + return; + } + + if (parsedCommand.command === 'reject' && !canReject) { + core.info('Ignoring /reject because the issue is already approved.'); + return; + } + + core.setOutput('should-run', 'true'); + core.setOutput('command', parsedCommand.command); + core.setOutput('reason', parsedCommand.reason ?? ''); + core.setOutput('validation-valid', evaluation.valid ? 'true' : 'false'); + core.setOutput('validation-errors', JSON.stringify(evaluation.errors)); + core.setOutput('plugin-name', fallbackName); + core.setOutput('plugin-slug', approval.slugifyPluginName(fallbackName)); + core.setOutput('source-repo', evaluation.plugin?.source?.repo ?? ''); + + - name: Comment blocked approval + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid != 'true' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + VALIDATION_ERRORS: ${{ steps.parse.outputs.validation-errors }} + PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }} + with: + script: | + const marker = ''; + const errors = JSON.parse(process.env.VALIDATION_ERRORS || '[]'); + const body = [ + marker, + '## āš ļø External plugin approval blocked', + '', + `The current issue form for **${process.env.PLUGIN_NAME}** no longer passes automated intake validation, so \`/approve\` was not applied.`, + '', + '### Required fixes', + '', + ...(errors.length > 0 ? errors.map((error) => `- ${error}`) : ['- Re-run intake validation by updating the issue details.']) + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100 + }); + + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + - name: Install dependencies + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true' + run: npm ci + + - name: Update external plugin catalog and PR + id: approval_pr + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + result=$(node ./eng/external-plugin-approval.mjs approve "$GITHUB_EVENT_PATH" --file ./plugins/external.json) + { + echo 'result<> "$GITHUB_OUTPUT" + + plugin_name=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.plugin.name);" "$result") + action=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.action);" "$result") + source_repo=$(node -e "const data = JSON.parse(process.argv[1]); process.stdout.write(data.plugin.source.repo);" "$result") + plugin_slug='${{ steps.parse.outputs.plugin-slug }}' + issue_number='${{ github.event.issue.number }}' + branch="automation/external-plugin-approve-${issue_number}-${plugin_slug}" + + if [ "$action" = "inserted" ]; then + title_action="Add" + summary_action="add" + else + title_action="Update" + summary_action="update" + fi + + npm run build + bash eng/fix-line-endings.sh + + pr_url="" + pr_number="" + if git diff --quiet; then + pr_number=$(gh pr list --head "$branch" --base staged --json number --jq '.[0].number') + if [ -n "$pr_number" ]; then + pr_url=$(gh pr view "$pr_number" --json url --jq '.url') + fi + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "plugin-name=$plugin_name" >> "$GITHUB_OUTPUT" + echo "action=$action" >> "$GITHUB_OUTPUT" + echo "source-repo=$source_repo" >> "$GITHUB_OUTPUT" + echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$branch" + git add -A + git commit -m "${title_action} external plugin ${plugin_name}" + git push --force-with-lease origin "$branch" + + pr_number=$(gh pr list --head "$branch" --base staged --json number --jq '.[0].number') + pr_body=$(cat <> "$GITHUB_OUTPUT" + echo "plugin-name=$plugin_name" >> "$GITHUB_OUTPUT" + echo "action=$action" >> "$GITHUB_OUTPUT" + echo "source-repo=$source_repo" >> "$GITHUB_OUTPUT" + echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" + + - name: Finalize approval + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'approve' && steps.parse.outputs.validation-valid == 'true' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + CHANGED: ${{ steps.approval_pr.outputs.changed }} + ACTION: ${{ steps.approval_pr.outputs.action }} + PLUGIN_NAME: ${{ steps.approval_pr.outputs.plugin-name }} + SOURCE_REPO: ${{ steps.approval_pr.outputs.source-repo }} + PR_URL: ${{ steps.approval_pr.outputs.pr-url }} + PR_NUMBER: ${{ steps.approval_pr.outputs.pr-number }} + with: + script: | + const managedLabels = { + 'external-plugin': { + color: 'FEF2C0', + description: 'Public external plugin submission' + }, + 'awaiting-review': { + color: 'FBCA04', + description: 'Submission is waiting for automated intake validation' + }, + 'ready-for-review': { + color: '0E8A16', + description: 'Submission passed intake validation and is ready for maintainer review' + }, + 'approved': { + color: '1D76DB', + description: 'Submission was approved by a maintainer' + }, + 'rejected': { + color: 'B60205', + description: 'Submission was rejected or failed intake validation' + } + }; + + async function ensureLabel(name, config) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color: config.color, + description: config.description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + async function removeLabel(issueNumber, name) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + async function syncIssueLabels(issueNumber, desiredLabels) { + await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))); + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const currentManagedLabels = currentLabels + .map((label) => label.name) + .filter((name) => Object.prototype.hasOwnProperty.call(managedLabels, name)); + + const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name)); + const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name)); + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: labelsToAdd + }); + } + + for (const name of labelsToRemove) { + await removeLabel(issueNumber, name); + } + } + + const issueNumber = context.issue.number; + const prNumber = Number(process.env.PR_NUMBER || 0); + const marker = ''; + const action = process.env.ACTION === 'updated' ? 'updated' : 'added'; + const prUrl = process.env.PR_URL; + const body = [ + marker, + '## āœ… External plugin approved', + '', + `A maintainer approved **${process.env.PLUGIN_NAME}**, and the submission issue has been closed.`, + '', + `- **Catalog action:** ${action}`, + `- **Source repository:** \`${process.env.SOURCE_REPO}\``, + prUrl + ? `- **PR against \`staged\`:** ${prUrl}` + : '- **PR against `staged`:** No new PR was needed because the approved listing is already present.' + ].join('\n'); + + await syncIssueLabels(issueNumber, new Set(['external-plugin', 'approved'])); + + if (prNumber > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['external-plugin', 'awaiting-review'] + }); + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body + }); + } + + if (context.payload.issue.state !== 'closed') { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed' + }); + } + + - name: Finalize rejection + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'reject' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + REASON: ${{ steps.parse.outputs.reason }} + PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }} + with: + script: | + const managedLabels = { + 'external-plugin': { + color: 'FEF2C0', + description: 'Public external plugin submission' + }, + 'awaiting-review': { + color: 'FBCA04', + description: 'Submission is waiting for automated intake validation' + }, + 'ready-for-review': { + color: '0E8A16', + description: 'Submission passed intake validation and is ready for maintainer review' + }, + 'approved': { + color: '1D76DB', + description: 'Submission was approved by a maintainer' + }, + 'rejected': { + color: 'B60205', + description: 'Submission was rejected or failed intake validation' + } + }; + + async function ensureLabel(name, config) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color: config.color, + description: config.description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + async function removeLabel(name) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['external-plugin', 'rejected'] + }); + + await removeLabel('awaiting-review'); + await removeLabel('ready-for-review'); + await removeLabel('approved'); + + const marker = ''; + const reason = process.env.REASON || 'No additional reason was provided.'; + const body = [ + marker, + '## āŒ External plugin rejected', + '', + `A maintainer rejected **${process.env.PLUGIN_NAME}**, and the submission issue has been closed.`, + '', + '### Reason', + '', + reason, + '', + 'If you address the feedback, open a new external plugin submission issue with the updated details.' + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100 + }); + + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + if (context.payload.issue.state !== 'closed') { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed' + }); + } diff --git a/.github/workflows/external-plugin-intake.yml b/.github/workflows/external-plugin-intake.yml new file mode 100644 index 000000000..ce8d47d59 --- /dev/null +++ b/.github/workflows/external-plugin-intake.yml @@ -0,0 +1,172 @@ +name: External Plugin Intake + +on: + issues: + types: [opened, edited, reopened] + +permissions: + contents: read + issues: write + +jobs: + validate-submission: + runs-on: ubuntu-latest + if: >- + contains(github.event.issue.labels.*.name, 'external-plugin') || + contains(github.event.issue.body, '') + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Evaluate submission + id: evaluation + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + result=$(node ./eng/external-plugin-intake.mjs "$GITHUB_EVENT_PATH") + { + echo 'result<> "$GITHUB_OUTPUT" + + - name: Sync labels and comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + RESULT_JSON: ${{ steps.evaluation.outputs.result }} + with: + script: | + const managedLabels = { + 'external-plugin': { + color: 'FEF2C0', + description: 'Public external plugin submission' + }, + 'awaiting-review': { + color: 'FBCA04', + description: 'Submission is waiting for automated intake validation' + }, + 'ready-for-review': { + color: '0E8A16', + description: 'Submission passed intake validation and is ready for maintainer review' + }, + 'approved': { + color: '1D76DB', + description: 'Submission was approved by a maintainer' + }, + 'rejected': { + color: 'B60205', + description: 'Submission was rejected or failed intake validation' + } + }; + + async function ensureLabel(name, config) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color: config.color, + description: config.description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + async function syncManagedLabels(issueNumber, desiredLabels) { + await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))); + + const managedForSync = ['external-plugin', 'awaiting-review', 'ready-for-review', 'rejected']; + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const currentManagedLabels = currentLabels + .map((label) => label.name) + .filter((name) => managedForSync.includes(name)); + + const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name)); + const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name)); + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: labelsToAdd + }); + } + + for (const name of labelsToRemove) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }); + } + } + + const result = JSON.parse(process.env.RESULT_JSON); + const issueNumber = context.issue.number; + const issueState = context.payload.issue.state; + const action = context.payload.action; + const existingLabelNames = (context.payload.issue.labels || []).map((label) => label.name); + + if (existingLabelNames.includes('approved')) { + core.info('Issue is already approved; skipping intake synchronization.'); + return; + } + + if (issueState === 'closed' && action !== 'reopened') { + core.info('Issue is closed; waiting for reopen before rerunning intake synchronization.'); + return; + } + + const desiredLabels = result.valid + ? new Set(['external-plugin', 'ready-for-review']) + : new Set(['external-plugin', 'rejected']); + + await syncManagedLabels(issueNumber, desiredLabels); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(result.commentMarker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: result.commentBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: result.commentBody + }); + } + + if (!result.valid && issueState === 'open') { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed' + }); + } diff --git a/.github/workflows/external-plugin-rereview-command.yml b/.github/workflows/external-plugin-rereview-command.yml new file mode 100644 index 000000000..74200f483 --- /dev/null +++ b/.github/workflows/external-plugin-rereview-command.yml @@ -0,0 +1,333 @@ +name: External Plugin Re-review Commands + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + handle-command: + runs-on: ubuntu-latest + if: >- + !github.event.issue.pull_request && + contains(github.event.comment.body, '/re-review-') + steps: + - name: Checkout staged branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: staged + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + + - name: Parse re-review command + id: parse + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const path = require('path'); + const { pathToFileURL } = require('url'); + + const rereview = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-rereview.mjs')).href); + const validation = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-validation.mjs')).href); + const command = rereview.parseRereviewCommand(context.payload.comment.body); + + core.setOutput('should-run', 'false'); + if (!command) { + core.info('No supported re-review command was found.'); + return; + } + + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login + }); + + const hasWriteAccess = ['admin', 'write', 'maintain'].includes(permission.data.permission); + if (!hasWriteAccess) { + core.info(`Ignoring ${command} because ${context.payload.comment.user.login} does not have write access.`); + return; + } + + const labelNames = new Set((context.payload.issue.labels || []).map((label) => label.name)); + if (!labelNames.has('external-plugin') || !labelNames.has('approved')) { + core.info('Ignoring command because the issue is not an approved external plugin submission.'); + return; + } + + const inRereviewQueue = + labelNames.has('re-review-due') || + labelNames.has('re-review-follow-up'); + if (!inRereviewQueue) { + core.info(`Ignoring ${command} because the issue is not currently in the six-month re-review queue.`); + return; + } + + const { plugins, errors } = validation.readExternalPlugins({ policy: 'marketplace' }); + if (errors.length > 0) { + core.setFailed(errors.join('\n')); + return; + } + + const currentIssue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const match = rereview.matchExternalPluginForIssue(currentIssue.data, plugins); + const plugin = match.plugin; + const fallbackName = match.submission.pluginName ?? `issue-${context.issue.number}`; + + core.setOutput('should-run', 'true'); + core.setOutput('command', command); + core.setOutput('has-plugin', plugin ? 'true' : 'false'); + core.setOutput('plugin-name', plugin?.name ?? fallbackName); + core.setOutput('plugin-slug', rereview.slugifyPluginName(plugin?.name ?? fallbackName)); + core.setOutput('source-repo', plugin?.source?.repo ?? match.submission.sourceRepo ?? ''); + + - name: Renew six-month review window + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'keep' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }} + HAS_PLUGIN: ${{ steps.parse.outputs.has-plugin }} + with: + script: | + const pluginName = process.env.PLUGIN_NAME; + const hasPlugin = process.env.HAS_PLUGIN === 'true'; + + async function removeLabel(name) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + if (!hasPlugin) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Could not find a current \`plugins/external.json\` entry for **${pluginName}**, so the six-month re-review window was not reset. Review the listing manually before retrying.` + }); + return; + } + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'open' + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed' + }); + + await removeLabel('re-review-due'); + await removeLabel('re-review-follow-up'); + await removeLabel('removed'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Renewed **${pluginName}** for another six months by reopening and reclosing this approved submission issue.` + }); + + - name: Mark follow-up needed + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'needs-changes' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }} + with: + script: | + const managedLabels = { + 're-review-due': { + color: 'FBCA04', + description: 'Approved external plugin is due for six-month re-review' + }, + 're-review-follow-up': { + color: 'D4C5F9', + description: 'Six-month re-review needs maintainer follow-up before a final decision' + } + }; + + async function ensureLabel(name, config) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color: config.color, + description: config.description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['re-review-due', 're-review-follow-up'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Marked **${process.env.PLUGIN_NAME}** as needing follow-up. The plugin will stay in the six-month re-review queue until a maintainer comments \`/re-review-keep\` or \`/re-review-remove\`.` + }); + + - name: Install dependencies + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove' && steps.parse.outputs.has-plugin == 'true' + run: npm ci + + - name: Remove plugin and create PR + id: remove_pr + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove' && steps.parse.outputs.has-plugin == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + plugin_name='${{ steps.parse.outputs.plugin-name }}' + plugin_slug='${{ steps.parse.outputs.plugin-slug }}' + source_repo='${{ steps.parse.outputs.source-repo }}' + issue_number='${{ github.event.issue.number }}' + branch="automation/external-plugin-rereview-remove-${issue_number}-${plugin_slug}" + + node ./eng/external-plugin-rereview.mjs remove --plugin-name "$plugin_name" --source-repo "$source_repo" --file ./plugins/external.json + npm run build + bash eng/fix-line-endings.sh + + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$branch" + git add -A + git commit -m "Remove external plugin ${plugin_name} after six-month re-review" + git push --force-with-lease origin "$branch" + + pr_url=$(gh pr list --head "$branch" --base staged --json url --jq '.[0].url') + if [ -z "$pr_url" ]; then + pr_body=$(printf '%s\n' \ + '## Summary' \ + '' \ + "- remove \`${plugin_name}\` from \`plugins/external.json\`" \ + '- regenerate marketplace outputs after the six-month re-review decision' \ + "- closes #${issue_number} review follow-up for this listing") + pr_url=$(gh pr create \ + --base staged \ + --head "$branch" \ + --title "[external-plugin] Remove ${plugin_name} after re-review" \ + --body "$pr_body") + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" + + - name: Finalize removal + if: steps.parse.outputs.should-run == 'true' && steps.parse.outputs.command == 'remove' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + CHANGED: ${{ steps.remove_pr.outputs.changed }} + PR_URL: ${{ steps.remove_pr.outputs.pr-url }} + PLUGIN_NAME: ${{ steps.parse.outputs.plugin-name }} + HAS_PLUGIN: ${{ steps.parse.outputs.has-plugin }} + with: + script: | + async function ensureLabel(name, color, description) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color, + description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + async function removeLabel(name) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + const changed = process.env.CHANGED === 'true'; + const prUrl = process.env.PR_URL; + const pluginName = process.env.PLUGIN_NAME; + const hasPlugin = process.env.HAS_PLUGIN === 'true'; + + let body; + if (!hasPlugin || !changed) { + await ensureLabel('removed', 'B60205', 'External plugin was removed from the marketplace after re-review'); + await removeLabel('approved'); + await removeLabel('re-review-due'); + await removeLabel('re-review-follow-up'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['removed'] + }); + body = `Marked **${pluginName}** as removed. No new PR was needed because the listing is already absent from \`plugins/external.json\`.`; + } else { + await ensureLabel('re-review-follow-up', 'D4C5F9', 'Six-month re-review needs maintainer follow-up before a final decision'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['re-review-due', 're-review-follow-up'] + }); + body = `Opened the removal PR for **${pluginName}**: ${prUrl}. The issue remains approved and due for re-review until that removal lands in \`staged\`.`; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); diff --git a/.github/workflows/external-plugin-rereview.yml b/.github/workflows/external-plugin-rereview.yml new file mode 100644 index 000000000..ceaff7bc6 --- /dev/null +++ b/.github/workflows/external-plugin-rereview.yml @@ -0,0 +1,271 @@ +name: External Plugin Re-review + +on: + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + sync-rereview: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Sync six-month re-review queue + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const path = require('path'); + const { pathToFileURL } = require('url'); + + const rereview = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-rereview.mjs')).href); + const validation = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-validation.mjs')).href); + + const managedLabels = { + [rereview.REREVIEW_LABELS.due]: { + color: 'FBCA04', + description: 'Approved external plugin is due for six-month re-review' + }, + [rereview.REREVIEW_LABELS.followUp]: { + color: 'D4C5F9', + description: 'Six-month re-review needs maintainer follow-up before a final decision' + }, + [rereview.REREVIEW_LABELS.removed]: { + color: 'B60205', + description: 'External plugin was removed from the marketplace after re-review' + } + }; + + async function ensureLabel(name, config) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color: config.color, + description: config.description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + async function removeLabel(issueNumber, label) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + async function addLabel(issueNumber, label) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [label] + }); + } + + function formatDate(dateValue) { + return new Date(dateValue).toISOString().slice(0, 10); + } + + function daysPastThreshold(closedAt, threshold) { + const diff = Date.parse(threshold.toISOString()) - Date.parse(closedAt); + return Math.max(0, Math.floor(Math.abs(diff) / (1000 * 60 * 60 * 24))); + } + + await Promise.all(Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config))); + + const { plugins, errors } = validation.readExternalPlugins({ policy: 'marketplace' }); + if (errors.length > 0) { + core.setFailed(errors.join('\n')); + return; + } + + const threshold = new Date(); + threshold.setUTCDate(threshold.getUTCDate() - 183); + + const approvedIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + labels: 'external-plugin,approved', + per_page: 100 + }); + + const issueRecords = approvedIssues + .filter((issue) => !issue.pull_request && issue.closed_at) + .map((issue) => { + const match = rereview.matchExternalPluginForIssue(issue, plugins); + return { + issue, + match + }; + }); + + const dueRecords = issueRecords.filter(({ issue, match }) => { + if (!match.plugin) { + return false; + } + + return Date.parse(issue.closed_at) <= threshold.getTime(); + }); + + const unmatchedDueRecords = issueRecords.filter(({ issue, match }) => { + if (match.plugin) { + return false; + } + + return Date.parse(issue.closed_at) <= threshold.getTime(); + }); + + const dueIssueNumbers = new Set([ + ...dueRecords.map((record) => record.issue.number), + ...unmatchedDueRecords.map((record) => record.issue.number) + ]); + + for (const { issue, match } of issueRecords) { + const labelNames = new Set((issue.labels || []).map((label) => label.name)); + const shouldHaveDueLabel = dueIssueNumbers.has(issue.number); + + if (shouldHaveDueLabel && !labelNames.has(rereview.REREVIEW_LABELS.due)) { + await addLabel(issue.number, rereview.REREVIEW_LABELS.due); + } + + if (!shouldHaveDueLabel && labelNames.has(rereview.REREVIEW_LABELS.due)) { + await removeLabel(issue.number, rereview.REREVIEW_LABELS.due); + } + + if (shouldHaveDueLabel && match.plugin && labelNames.has(rereview.REREVIEW_LABELS.removed)) { + await removeLabel(issue.number, rereview.REREVIEW_LABELS.removed); + } + } + + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + const existingTrackerIssues = openIssues + .filter((issue) => !issue.pull_request && issue.body?.includes(rereview.REREVIEW_REPORT_MARKER)) + .sort((left, right) => left.number - right.number); + + if (dueRecords.length === 0 && unmatchedDueRecords.length === 0) { + for (const tracker of existingTrackerIssues) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: tracker.number, + state: 'closed' + }); + } + + core.info('No external plugins are currently due for six-month re-review.'); + return; + } + + const dueRows = dueRecords + .sort((left, right) => Date.parse(left.issue.closed_at) - Date.parse(right.issue.closed_at)) + .map(({ issue, match }) => { + const labelNames = new Set((issue.labels || []).map((label) => label.name)); + const status = labelNames.has(rereview.REREVIEW_LABELS.followUp) ? 'Needs follow-up' : 'Awaiting decision'; + const repo = match.plugin.source?.repo ?? match.submission.sourceRepo ?? '_unknown_'; + return `| ${match.plugin.name} | ${match.plugin.version} | \`${repo}\` | #${issue.number} | ${formatDate(issue.closed_at)} | ${daysPastThreshold(issue.closed_at, threshold)} | ${status} |`; + }); + + const unmatchedRows = unmatchedDueRecords + .sort((left, right) => Date.parse(left.issue.closed_at) - Date.parse(right.issue.closed_at)) + .map(({ issue, match }) => { + const pluginName = match.submission.pluginName ?? '_unknown_'; + const repo = match.submission.sourceRepo ? `\`${match.submission.sourceRepo}\`` : '_unknown_'; + return `| #${issue.number} | ${pluginName} | ${repo} | ${formatDate(issue.closed_at)} |`; + }); + + const body = [ + rereview.REREVIEW_REPORT_MARKER, + '## šŸ” External plugin six-month re-review queue', + '', + 'The following approved external plugin submissions have reached the six-month re-review threshold.', + 'Review the linked plugin, then comment on the **original approved submission issue** with one of:', + '', + `- \`${rereview.REREVIEW_COMMANDS.keep}\` — renew the plugin for another six months`, + `- \`${rereview.REREVIEW_COMMANDS.needsChanges}\` — keep the plugin in the due queue while follow-up work happens`, + `- \`${rereview.REREVIEW_COMMANDS.remove}\` — open or update a PR against \`staged\` that removes the plugin from the marketplace`, + '', + `- **Threshold date used by this run:** ${formatDate(threshold.toISOString())}`, + '', + '### Plugins due now', + '', + dueRows.length > 0 + ? [ + '| Plugin | Version | Source repo | Submission issue | Closed at | Days past threshold | Status |', + '|---|---|---|---:|---|---:|---|', + ...dueRows + ].join('\n') + : '_No currently listed plugins are due right now._', + unmatchedRows.length > 0 + ? [ + '', + '### Approved issues without a current marketplace match', + '', + 'These closed approved issues are older than six months, but no matching entry was found in `plugins/external.json`. Review them manually if the listing was renamed or removed outside the re-review flow.', + '', + '| Submission issue | Parsed plugin name | Parsed repo | Closed at |', + '|---:|---|---|---|', + ...unmatchedRows + ].join('\n') + : '', + ].filter(Boolean).join('\n'); + + if (existingTrackerIssues.length > 0) { + const [primary, ...duplicates] = existingTrackerIssues; + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: primary.number, + title: 'šŸ” External Plugin Six-Month Review', + body, + labels: [rereview.REREVIEW_LABELS.due] + }); + + for (const duplicate of duplicates) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: duplicate.number, + state: 'closed' + }); + } + + core.info(`Updated re-review tracker issue #${primary.number}.`); + return; + } + + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'šŸ” External Plugin Six-Month Review', + body, + labels: [rereview.REREVIEW_LABELS.due] + }); + + core.info(`Created re-review tracker issue #${created.data.number}.`); diff --git a/AGENTS.md b/AGENTS.md index acb17e3b4..9132de5af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,10 +162,15 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: **For External Plugins:** -1. Edit `plugins/external.json` and add an entry with `name`, `source`, `description`, and `version` -2. The `source` field should be an object specifying a GitHub repo, git URL, npm package, or pip package (see [CONTRIBUTING.md](CONTRIBUTING.md#adding-external-plugins)) -3. Run `npm run build` to regenerate marketplace.json -4. Verify the external plugin appears in `.github/plugin/marketplace.json` +1. Do not open a direct PR that edits `plugins/external.json` for a public third-party plugin submission +2. Public external plugin submissions use the external plugin issue workflow documented in [CONTRIBUTING.md](CONTRIBUTING.md#adding-external-plugins) +3. In v1, only GitHub-hosted plugins are accepted for public submission, using a public repo plus an immutable `ref` +4. The shared validator in `eng/external-plugin-validation.mjs` is the canonical source of truth for external plugin data rules; reuse it instead of duplicating checks in scripts or workflows +5. Submission issues move through `external-plugin` + `awaiting-review` -> `ready-for-review` -> `approved` or `rejected` +6. Maintainers make the decision with `/approve` or `/reject ` issue comments; approved issues are closed and used as the six-month re-review anchor +7. Approval automation creates or updates the PR against `staged`, updates `plugins/external.json`, and regenerates marketplace outputs +8. Nightly re-review automation finds closed `external-plugin` + `approved` issues that are at least six months old, applies `re-review-due`, and opens or updates a tracking issue for maintainers +9. Maintainers complete re-review on the original approved submission issue with `/re-review-keep`, `/re-review-needs-changes`, or `/re-review-remove`; keep resets the issue `closed_at`, and remove opens a PR against `staged` ### Testing Instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de6cf1bc3..0edc60d6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ To maintain a safe, responsible, and high-signal collection, we will **not accep - **Promote Harmful Content**: Guidance that could lead to the creation of harmful, discriminatory, or inappropriate content - **Circumvent Platform Policies**: Attempts to work around GitHub, Microsoft, or other platform terms of service - **Duplicate Existing Model Strengths Without Meaningful Uplift**: Submissions that mainly tell Copilot to do work frontier models already handle well (for example, generic TypeScript, HTML, or other broadly-supported coding tasks) without addressing a clear gap, specialized workflow, or domain-specific constraint. These contributions are often lower value for users and can introduce weaker or conflicting guidance than the model's default behavior. -- **Plugins from remote sources**: While the plugin design allows us to support plugins from other GitHub repos, or other Git endpoints, we are not accepting contributions that simply add plugins from external sources. Plugins from remote sources represent a security risk as we are unable to verify their content for the policies we enforce on this repository. This policy does not apply to repositories that are managed by Microsoft or GitHub. +- **Unreviewed remote-source plugins**: Do not open a pull request that directly adds a third-party plugin to `plugins/external.json`. Public external plugins must use the review workflow documented below. In v1, that workflow only accepts plugins hosted in public GitHub repositories; non-GitHub sources such as generic git URLs are not accepted for public submissions. ## Quality Guidelines @@ -189,33 +189,106 @@ plugins/my-plugin-id/ #### Adding External Plugins -External plugins are plugins hosted outside this repository (e.g., in a GitHub repo, npm package, or git URL). They are listed in `plugins/external.json` and merged into the generated `marketplace.json` during build. +External plugins are plugins hosted outside this repository and listed in `plugins/external.json`. Public contributors should **not** open a PR that edits `plugins/external.json` directly. Instead, submit external plugins through the public review workflow below. -To add an external plugin, append an entry to `plugins/external.json` following the [Claude Code plugin marketplace spec](https://code.claude.com/docs/en/plugin-marketplaces#plugin-entries). Each entry requires `name`, `source`, `description`, and `version`: +> [!IMPORTANT] +> Public external plugin submissions are GitHub-only in v1. The submitted plugin must live in a public GitHub repository and use `source.source: "github"`. + +##### Submission fields + +The external plugin issue form will collect these fields: + +- Plugin name +- Short description +- GitHub repository in `owner/repo` format +- Plugin path inside the repository (optional when the plugin is at the repository root) +- Immutable ref to review (`ref`), using a release tag or full commit SHA rather than a branch +- Plugin version +- License identifier +- Author name +- Author URL (optional) +- Homepage URL (optional) +- Keywords/tags +- Additional notes for reviewers (optional) +- Confirmation checkboxes that the repository is public, the ref is immutable, the submission follows this repository's policies, and the plugin is not a duplicate listing + +The repository's canonical validation rules live in `eng/external-plugin-validation.mjs`. Build scripts reuse the `marketplace` policy from that module, and the issue intake automation uses the stricter `publicSubmission` policy so the JSON contract and workflow checks stay aligned. + +For entries committed to `plugins/external.json`, the current marketplace validation requires: + +- `name`, `description`, and `version` +- `author.name` +- `repository` as an HTTPS GitHub URL +- `keywords` as lowercase hyphenated tags +- `source.source: "github"` plus `source.repo` in `owner/repo` format +- optional `source.path` values to stay relative to the repository root + +The public-submission policy builds on those rules and also requires `license` plus an immutable `source.ref`. + +##### Review workflow + +1. **Open an issue** using the external plugin issue form. Automation applies the `external-plugin` and `awaiting-review` labels. +2. **Automated intake validation** checks that the required fields are present and correctly formatted for a GitHub-hosted plugin. Invalid submissions are closed with a comment explaining what must be fixed before resubmitting. +3. **Ready for maintainer review**: if the issue passes intake validation, automation removes `awaiting-review` and adds `ready-for-review`. +4. **Maintainer decision**: a maintainer with write access performs the manual review, then comments `/approve` or `/reject ` on the issue. Commands from non-maintainers are ignored. +5. **Approval path**: on `/approve`, automation removes `ready-for-review`, adds `approved`, closes the issue, and opens or updates a PR against `staged` that updates `plugins/external.json` and generated marketplace outputs. +6. **Rejection path**: on `/reject `, automation removes `ready-for-review`, adds `rejected`, closes the issue, and records the reason in an issue comment. Submitters can open a new issue after addressing the feedback. + +##### Maintainer review responsibilities + +Maintainers are responsible for confirming that the submission: + +- Clearly fits the Awesome Copilot collection and adds value beyond existing listings +- Uses a public GitHub repository and an immutable ref that can be reviewed reliably +- Includes the required metadata for `plugins/external.json` (`name`, `description`, `version`, `author.name`, `repository`, `keywords`, and `source`), plus any supplied homepage/license fields +- Does not obviously duplicate an existing marketplace entry +- Continues to meet this repository's content, security, and responsible AI policies + +##### Review cadence and label semantics + +- `external-plugin`: applied to every public external plugin submission and retained on approved issues so scheduled review automation can find them later +- `awaiting-review`: initial intake state before automation finishes validating the issue +- `ready-for-review`: the issue passed automated intake checks and is waiting on a maintainer decision +- `approved`: the issue was approved, closed, and can be used as the source of truth for six-month re-review +- `rejected`: the issue was rejected and closed without being added to the marketplace +- `re-review-due`: the approved issue reached the six-month review threshold and is waiting on a maintainer re-review decision +- `re-review-follow-up`: a maintainer reviewed the plugin and requested more follow-up before renewing or removing it +- `removed`: the plugin was removed from `plugins/external.json` after re-review and should no longer be considered active + +The six-month re-review window starts when an approved submission issue is **closed**. A nightly workflow looks for closed issues labeled `external-plugin` and `approved` whose `closed_at` is at least six months old, applies `re-review-due`, and opens or updates a maintainer-facing tracking issue that links every plugin currently due. + +Maintainers complete the re-review on the **original approved submission issue** with one of these issue-comment commands: + +- `/re-review-keep` — renew the listing for another six months by reopening and reclosing the approved issue, which resets the `closed_at` review anchor and removes the due labels +- `/re-review-needs-changes` — keep the listing in the due queue while adding `re-review-follow-up` so maintainers can track extra investigation or remediation work +- `/re-review-remove` — open or update a PR against `staged` that removes the plugin from `plugins/external.json` and regenerates marketplace outputs; the issue stays in the due queue until that removal lands + +Approved submissions are converted into `plugins/external.json` entries following the [Claude Code plugin marketplace spec](https://code.claude.com/docs/en/plugin-marketplaces#plugin-entries). A typical GitHub-hosted entry looks like this: ```json [ { "name": "my-external-plugin", + "description": "Description of the external plugin", + "version": "1.0.0", + "author": { + "name": "Plugin Author", + "url": "https://github.com/plugin-author" + }, + "homepage": "https://github.com/owner/plugin-repo", + "keywords": ["category", "workflow"], + "license": "MIT", + "repository": "https://github.com/owner/plugin-repo", "source": { "source": "github", - "repo": "owner/plugin-repo" - }, - "description": "Description of the external plugin", - "version": "1.0.0" + "repo": "owner/plugin-repo", + "path": ".github/plugins/my-external-plugin", + "ref": "v1.0.0" + } } ] ``` -Supported source types: - -- **GitHub**: `{ "source": "github", "repo": "owner/repo", "ref": "v1.0.0" }` -- **Git URL**: `{ "source": "url", "url": "https://gitlab.com/team/plugin.git" }` -- **npm**: `{ "source": "npm", "package": "@scope/package", "version": "1.0.0" }` -- **pip**: `{ "source": "pip", "package": "package-name", "version": "1.0.0" }` - -After editing `plugins/external.json`, run `npm run build` to regenerate `marketplace.json`. - ### Adding Hooks Hooks enable automated workflows triggered by specific events during GitHub Copilot coding agent sessions, such as session start, session end, user prompts, and tool usage. diff --git a/eng/external-plugin-approval.mjs b/eng/external-plugin-approval.mjs new file mode 100644 index 000000000..f9ea49158 --- /dev/null +++ b/eng/external-plugin-approval.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { ROOT_FOLDER } from "./constants.mjs"; +import { + EXTERNAL_PLUGINS_FILE, + readExternalPlugins, + validateExternalPlugins, +} from "./external-plugin-validation.mjs"; +import { evaluateExternalPluginIssue } from "./external-plugin-intake.mjs"; + +export const DECISION_COMMANDS = Object.freeze({ + approve: "/approve", + reject: "/reject", +}); + +function normalizeValue(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function normalizeRepositoryUrl(value) { + const normalized = normalizeValue(value); + if (!normalized) { + return undefined; + } + + return normalized + .replace(/^https:\/\/github\.com\//, "") + .replace(/\.git$/i, "") + .replace(/^\/+|\/+$/g, ""); +} + +function normalizePathValue(value) { + return String(value ?? "") + .trim() + .replace(/^\/+|\/+$/g, "") + .toLowerCase(); +} + +export function parseDecisionCommand(body) { + const match = String(body ?? "").match(/(?:^|\n)\s*\/(approve|reject)(?=\s|$)([\s\S]*)$/i); + if (!match) { + return undefined; + } + + const command = match[1].toLowerCase(); + const reason = match[2]?.trim() || undefined; + + return { + command, + reason: command === "reject" ? reason : undefined, + }; +} + +export function slugifyPluginName(value) { + const slug = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug || "external-plugin"; +} + +function readLocalPluginNames() { + const pluginsDir = path.join(ROOT_FOLDER, "plugins"); + if (!fs.existsSync(pluginsDir)) { + return []; + } + + return fs.readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +function pluginsMatch(left, right) { + const leftName = normalizeValue(left?.name); + const rightName = normalizeValue(right?.name); + const leftRepo = normalizeValue(left?.source?.repo); + const rightRepo = normalizeValue(right?.source?.repo); + const leftPath = normalizePathValue(left?.source?.path); + const rightPath = normalizePathValue(right?.source?.path); + const leftRepository = normalizeRepositoryUrl(left?.repository); + const rightRepository = normalizeRepositoryUrl(right?.repository); + + if (leftName && rightName && leftName === rightName) { + return true; + } + + const repoMatches = leftRepo && rightRepo && leftRepo === rightRepo; + const repositoryMatches = leftRepository && rightRepository && leftRepository === rightRepository; + const pathKnown = Boolean(leftPath || rightPath); + const pathMatches = leftPath === rightPath; + + if ((repoMatches || repositoryMatches) && pathKnown && pathMatches) { + return true; + } + + return false; +} + +export function upsertExternalPlugin(plugin, { filePath = EXTERNAL_PLUGINS_FILE } = {}) { + const { plugins, errors } = readExternalPlugins({ + filePath, + localPluginNames: readLocalPluginNames(), + policy: "marketplace", + }); + + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const updatedPlugins = [...plugins]; + const existingIndex = updatedPlugins.findIndex((existingPlugin) => pluginsMatch(existingPlugin, plugin)); + const action = existingIndex === -1 ? "inserted" : "updated"; + + if (existingIndex === -1) { + updatedPlugins.push(plugin); + } else { + updatedPlugins[existingIndex] = plugin; + } + + updatedPlugins.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); + + const { errors: validationErrors } = validateExternalPlugins(updatedPlugins, { + localPluginNames: readLocalPluginNames(), + policy: "marketplace", + }); + + if (validationErrors.length > 0) { + throw new Error(validationErrors.join("\n")); + } + + const changed = JSON.stringify(updatedPlugins) !== JSON.stringify(plugins); + if (changed) { + fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`); + } + + return { + action, + changed, + plugin, + }; +} + +function readCliArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (!key.startsWith("--")) { + continue; + } + + args[key.slice(2)] = argv[index + 1]; + index += 1; + } + + return args; +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isCli) { + const [command, eventPath] = process.argv.slice(2); + + if (command !== "approve" || !eventPath) { + console.error("Usage: node ./eng/external-plugin-approval.mjs approve [--file ]"); + process.exit(1); + } + + const args = readCliArgs(process.argv.slice(4)); + const event = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const evaluation = await evaluateExternalPluginIssue({ + issue: event.issue, + token: process.env.GITHUB_TOKEN, + }); + + if (!evaluation.valid) { + console.error(evaluation.errors.join("\n")); + process.exit(1); + } + + const result = upsertExternalPlugin(evaluation.plugin, { filePath: args.file }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs new file mode 100644 index 000000000..cd22fa4d4 --- /dev/null +++ b/eng/external-plugin-intake.mjs @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { ROOT_FOLDER } from "./constants.mjs"; +import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs"; + +const ISSUE_FORM_MARKER = ""; +const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); + +const REQUIRED_CHECKLIST_ITEMS = [ + "The plugin lives in a public GitHub repository.", + "The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.", + "This submission follows this repository's contribution, security, and responsible AI policies.", + "This plugin is not already listed in the Awesome Copilot marketplace.", +]; + +const FIELD_TITLES = Object.freeze({ + pluginName: "Plugin name", + shortDescription: "Short description", + githubRepository: "GitHub repository", + pluginPath: "Plugin path inside the repository", + immutableRef: "Immutable ref to review", + version: "Version", + license: "License identifier", + authorName: "Author name", + authorUrl: "Author URL", + homepageUrl: "Homepage URL", + keywords: "Keywords", + additionalNotes: "Additional notes for reviewers", + submissionChecklist: "Submission checklist", +}); + +function normalizeMultilineText(value) { + return String(value ?? "").replace(/\r\n/g, "\n"); +} + +function stripNoResponse(value) { + if (value === undefined) { + return undefined; + } + + const normalized = normalizeMultilineText(value).trim(); + if (!normalized || normalized === "_No response_") { + return undefined; + } + + return normalized; +} + +function parseIssueFormSections(body) { + const normalized = normalizeMultilineText(body); + const sections = new Map(); + const matches = [...normalized.matchAll(/^###\s+(.+)$/gm)]; + + for (let index = 0; index < matches.length; index += 1) { + const heading = matches[index][1].trim(); + const start = matches[index].index + matches[index][0].length; + const end = index + 1 < matches.length ? matches[index + 1].index : normalized.length; + const content = normalized.slice(start, end).trim(); + sections.set(heading, content); + } + + return sections; +} + +function normalizeGitHubRepo(value) { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + const urlMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i); + if (urlMatch) { + return urlMatch[1]; + } + + return trimmed.replace(/^github\.com\//i, "").replace(/\.git$/i, "").replace(/^\/+|\/+$/g, ""); +} + +function parseKeywords(value) { + const normalized = stripNoResponse(value); + if (!normalized) { + return undefined; + } + + const keywords = normalized + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter(Boolean); + + return keywords.length > 0 ? keywords : undefined; +} + +function parseChecklist(value) { + const checked = new Set(); + const normalized = normalizeMultilineText(value); + + for (const match of normalized.matchAll(/^- \[(x|X)\] (.+)$/gm)) { + checked.add(match[2].trim()); + } + + return checked; +} + +function readLocalPluginNames() { + if (!fs.existsSync(PLUGINS_DIR)) { + return []; + } + + return fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +function toSubmissionError(message) { + return message.replace(/^external\.json\[0\]:\s*/, "submission: "); +} + +async function fetchGitHubJson(apiPath, token) { + const response = await fetch(`https://api.github.com${apiPath}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "awesome-copilot-external-plugin-intake", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + if (response.status === 404) { + return { ok: false, status: 404, data: null }; + } + + let data = null; + try { + data = await response.json(); + } catch { + data = null; + } + + return { + ok: response.ok, + status: response.status, + data, + }; +} + +function encodeRepoPath(repo) { + const [owner, name] = String(repo).split("/"); + return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`; +} + +async function validateRemoteRepository(repo, ref, errors, warnings, token) { + const encodedRepo = encodeRepoPath(repo); + const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token); + + if (!repositoryResponse.ok) { + if (repositoryResponse.status === 404) { + errors.push(`submission: GitHub repository "${repo}" was not found`); + } else { + errors.push(`submission: could not inspect GitHub repository "${repo}" (HTTP ${repositoryResponse.status})`); + } + return; + } + + if (repositoryResponse.data?.private) { + errors.push(`submission: GitHub repository "${repo}" must be public`); + } + + if (repositoryResponse.data?.archived) { + warnings.push(`submission: GitHub repository "${repo}" is archived`); + } + + if (!ref) { + return; + } + + if (/^[0-9a-f]{40}$/i.test(ref)) { + const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(ref)}`, token); + if (!commitResponse.ok) { + errors.push(`submission: commit "${ref}" was not found in GitHub repository "${repo}"`); + } + return; + } + + const tagName = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref; + const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token); + + if (tagResponse.ok) { + return; + } + + if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) { + errors.push('submission: commit SHAs in "Immutable ref to review" must use the full 40-character SHA'); + return; + } + + if (!tagResponse.ok) { + errors.push(`submission: tag "${ref}" was not found in GitHub repository "${repo}"`); + } +} + +export function parseExternalPluginIssueBody(body) { + const sections = parseIssueFormSections(body); + const errors = []; + + function requiredField(title) { + const value = stripNoResponse(sections.get(title)); + if (!value) { + errors.push(`submission: "${title}" is required`); + } + return value; + } + + const pluginName = requiredField(FIELD_TITLES.pluginName); + const shortDescription = requiredField(FIELD_TITLES.shortDescription); + const repoInput = normalizeGitHubRepo(requiredField(FIELD_TITLES.githubRepository)); + const immutableRef = requiredField(FIELD_TITLES.immutableRef); + const version = requiredField(FIELD_TITLES.version); + const license = requiredField(FIELD_TITLES.license); + const authorName = requiredField(FIELD_TITLES.authorName); + + const pluginPath = stripNoResponse(sections.get(FIELD_TITLES.pluginPath)); + const authorUrl = stripNoResponse(sections.get(FIELD_TITLES.authorUrl)); + const homepageUrl = stripNoResponse(sections.get(FIELD_TITLES.homepageUrl)); + const keywords = parseKeywords(sections.get(FIELD_TITLES.keywords)); + const additionalNotes = stripNoResponse(sections.get(FIELD_TITLES.additionalNotes)); + const checkedItems = parseChecklist(sections.get(FIELD_TITLES.submissionChecklist)); + + for (const item of REQUIRED_CHECKLIST_ITEMS) { + if (!checkedItems.has(item)) { + errors.push(`submission: checklist item must be checked: "${item}"`); + } + } + + const plugin = { + name: pluginName, + description: shortDescription, + version, + author: { + name: authorName, + ...(authorUrl ? { url: authorUrl } : {}), + }, + repository: repoInput ? `https://github.com/${repoInput}` : undefined, + ...(homepageUrl ? { homepage: homepageUrl } : {}), + ...(license ? { license } : {}), + ...(keywords ? { keywords } : {}), + source: { + source: "github", + repo: repoInput, + ...(pluginPath ? { path: pluginPath } : {}), + ...(immutableRef ? { ref: immutableRef } : {}), + }, + }; + + return { + markerPresent: normalizeMultilineText(body).includes(ISSUE_FORM_MARKER), + errors, + plugin, + additionalNotes, + }; +} + +export async function evaluateExternalPluginIssue({ issue, token } = {}) { + const issueBody = issue?.body ?? ""; + const parsed = parseExternalPluginIssueBody(issueBody); + const errors = [...parsed.errors]; + const warnings = []; + + const localPluginNames = readLocalPluginNames(); + const { plugins: existingExternalPlugins } = readExternalPlugins({ policy: "marketplace" }); + const duplicateNames = [ + ...localPluginNames, + ...existingExternalPlugins.map((plugin) => plugin.name).filter(Boolean), + ]; + + const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" }); + errors.push(...validationResult.errors.map(toSubmissionError)); + warnings.push(...validationResult.warnings.map(toSubmissionError)); + + if (parsed.plugin?.name) { + const matchingName = duplicateNames.find( + (name) => String(name).toLowerCase() === String(parsed.plugin.name).toLowerCase(), + ); + if (matchingName) { + errors.push(`submission: plugin name "${parsed.plugin.name}" conflicts with existing plugin "${matchingName}"`); + } + } + + if (parsed.plugin?.source?.repo && parsed.plugin?.source?.ref) { + await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source.ref, errors, warnings, token); + } + + const dedupedErrors = [...new Set(errors)]; + const dedupedWarnings = [...new Set(warnings)]; + const valid = dedupedErrors.length === 0; + const marker = ""; + const normalizedKeywords = parsed.plugin?.keywords?.length ? parsed.plugin.keywords.join(", ") : "_None provided_"; + const notes = parsed.additionalNotes ?? "_No additional notes provided._"; + const payload = parsed.plugin + ? [ + "```json", + JSON.stringify(parsed.plugin, null, 2), + "```", + ].join("\n") + : "```json\n{}\n```"; + + const commentBody = valid + ? [ + marker, + "## āœ… External plugin intake passed", + "", + `This submission passed automated intake validation and is ready for maintainer review.`, + "", + `- **Plugin:** ${parsed.plugin.name}`, + `- **Repository:** ${parsed.plugin.repository}`, + `- **Ref:** ${parsed.plugin.source.ref}`, + `- **Keywords:** ${normalizedKeywords}`, + "", + "### Canonical external.json payload", + "", + payload, + "", + "### Reviewer notes", + "", + notes, + dedupedWarnings.length > 0 + ? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n") + : "", + ].filter(Boolean).join("\n") + : [ + marker, + "## āŒ External plugin intake failed", + "", + "This submission did not pass automated intake validation, so the issue has been closed.", + "Update the issue form, then reopen the issue to run intake validation again.", + "", + "### Required fixes", + "", + ...dedupedErrors.map((error) => `- ${error}`), + dedupedWarnings.length > 0 + ? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n") + : "", + ].filter(Boolean).join("\n"); + + return { + valid, + markerPresent: parsed.markerPresent, + errors: dedupedErrors, + warnings: dedupedWarnings, + plugin: parsed.plugin, + commentBody, + commentMarker: marker, + }; +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isCli) { + const eventPath = process.argv[2]; + if (!eventPath) { + console.error("Usage: node ./eng/external-plugin-intake.mjs "); + process.exit(1); + } + + const event = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const result = await evaluateExternalPluginIssue({ issue: event.issue, token: process.env.GITHUB_TOKEN }); + process.stdout.write(JSON.stringify(result)); +} diff --git a/eng/external-plugin-rereview.mjs b/eng/external-plugin-rereview.mjs new file mode 100644 index 000000000..c328d5726 --- /dev/null +++ b/eng/external-plugin-rereview.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { EXTERNAL_PLUGINS_FILE, readExternalPlugins } from "./external-plugin-validation.mjs"; +import { parseExternalPluginIssueBody } from "./external-plugin-intake.mjs"; + +export const REREVIEW_REPORT_MARKER = ""; + +export const REREVIEW_LABELS = Object.freeze({ + due: "re-review-due", + followUp: "re-review-follow-up", + removed: "removed", +}); + +export const REREVIEW_COMMANDS = Object.freeze({ + keep: "/re-review-keep", + needsChanges: "/re-review-needs-changes", + remove: "/re-review-remove", +}); + +function normalizeValue(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function normalizeRepositoryUrl(value) { + const normalized = normalizeValue(value); + if (!normalized) { + return undefined; + } + + return normalized + .replace(/^https:\/\/github\.com\//, "") + .replace(/\.git$/i, "") + .replace(/^\/+|\/+$/g, ""); +} + +function normalizePathValue(value) { + return String(value ?? "") + .trim() + .replace(/^\/+|\/+$/g, "") + .toLowerCase(); +} + +function stripIssueTitlePrefix(title) { + return String(title ?? "") + .trim() + .replace(/^\[\s*external plugin\s*\]\s*:\s*/i, "") + .replace(/^(external plugin(?: submission)?|public external plugin)(?:\s*[:-]\s*|\s+)/i, "") + .trim(); +} + +function firstMatch(body, patterns) { + for (const pattern of patterns) { + const match = body.match(pattern); + if (match?.[1]) { + return match[1].trim(); + } + } + + return undefined; +} + +function fallbackSubmissionData(issue) { + const body = String(issue?.body ?? ""); + const title = stripIssueTitlePrefix(issue?.title); + const sourceRepo = firstMatch(body, [ + /https:\/\/github\.com\/([^/\s]+\/[^/\s)]+)/i, + /\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/, + ]); + + return { + pluginName: title || undefined, + sourceRepo: sourceRepo ? normalizeRepositoryUrl(sourceRepo) : undefined, + repository: sourceRepo ? `https://github.com/${normalizeRepositoryUrl(sourceRepo)}` : undefined, + }; +} + +export function extractSubmissionData(issue) { + const parsed = parseExternalPluginIssueBody(issue?.body ?? ""); + const fallback = fallbackSubmissionData(issue); + const plugin = parsed.plugin ?? {}; + + return { + pluginName: plugin.name ?? fallback.pluginName, + sourceRepo: plugin.source?.repo ?? fallback.sourceRepo, + sourcePath: plugin.source?.path, + repository: plugin.repository ?? fallback.repository, + ref: plugin.source?.ref, + }; +} + +function pluginMatchesSubmission(plugin, submission) { + const pluginName = normalizeValue(plugin?.name); + const submissionName = normalizeValue(submission.pluginName); + const pluginRepo = normalizeValue(plugin?.source?.repo); + const submissionRepo = normalizeValue(submission.sourceRepo); + const pluginPath = normalizePathValue(plugin?.source?.path); + const submissionPath = normalizePathValue(submission.sourcePath); + const pluginRepository = normalizeRepositoryUrl(plugin?.repository); + const submissionRepository = normalizeRepositoryUrl(submission.repository); + + const nameMatch = pluginName && submissionName && pluginName === submissionName; + const repoMatch = pluginRepo && submissionRepo && pluginRepo === submissionRepo; + const repositoryMatch = pluginRepository && submissionRepository && pluginRepository === submissionRepository; + const pathProvided = Boolean(submissionPath); + const pathMatch = pluginPath === submissionPath; + + if (nameMatch && pathProvided) { + return pathMatch && (repoMatch || repositoryMatch || !submissionRepo); + } + + if (nameMatch && (repoMatch || repositoryMatch || !submissionRepo)) { + return true; + } + + if ((repoMatch || repositoryMatch) && pathProvided) { + return pathMatch && (!submissionName || nameMatch); + } + + if ((repoMatch || repositoryMatch) && submissionName && nameMatch) { + return true; + } + + return false; +} + +export function matchExternalPluginForIssue(issue, plugins) { + const submission = extractSubmissionData(issue); + const exactMatch = plugins.find((plugin) => pluginMatchesSubmission(plugin, submission)); + if (exactMatch) { + return { + plugin: exactMatch, + submission, + matchReason: "exact", + }; + } + + const byName = submission.pluginName + ? plugins.find((plugin) => normalizeValue(plugin?.name) === normalizeValue(submission.pluginName)) + : undefined; + if (byName) { + return { + plugin: byName, + submission, + matchReason: "name", + }; + } + + const repoMatches = submission.sourceRepo + ? plugins.filter((plugin) => normalizeValue(plugin?.source?.repo) === normalizeValue(submission.sourceRepo)) + : []; + if (repoMatches.length === 1) { + return { + plugin: repoMatches[0], + submission, + matchReason: "repo", + }; + } + + return { + plugin: undefined, + submission, + matchReason: "none", + }; +} + +export function parseRereviewCommand(body) { + const match = String(body ?? "").match(/(?:^|\n)\s*\/re-review-(keep|needs-changes|remove)(?=\s|$)/i); + if (!match) { + return undefined; + } + + switch (match[1].toLowerCase()) { + case "keep": + return "keep"; + case "needs-changes": + return "needs-changes"; + case "remove": + return "remove"; + default: + return undefined; + } +} + +export function slugifyPluginName(value) { + const slug = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug || "external-plugin"; +} + +export function removePluginFromExternalJson({ pluginName, sourceRepo, filePath = EXTERNAL_PLUGINS_FILE } = {}) { + const { plugins, errors } = readExternalPlugins({ filePath, policy: "marketplace" }); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const normalizedPluginName = normalizeValue(pluginName); + const normalizedSourceRepo = normalizeValue(sourceRepo); + const matchIndex = plugins.findIndex((plugin) => { + const nameMatches = normalizedPluginName && normalizeValue(plugin?.name) === normalizedPluginName; + const repoMatches = normalizedSourceRepo && normalizeValue(plugin?.source?.repo) === normalizedSourceRepo; + + if (normalizedPluginName && normalizedSourceRepo) { + return nameMatches && repoMatches; + } + + return Boolean(nameMatches || repoMatches); + }); + + if (matchIndex === -1) { + throw new Error(`Could not find external plugin "${pluginName || sourceRepo}" in ${path.relative(process.cwd(), filePath)}`); + } + + const updatedPlugins = [...plugins]; + const [removedPlugin] = updatedPlugins.splice(matchIndex, 1); + fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`); + + return removedPlugin; +} + +function readCliArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (!key.startsWith("--")) { + continue; + } + + const value = argv[index + 1]; + args[key.slice(2)] = value; + index += 1; + } + + return args; +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isCli) { + const [command] = process.argv.slice(2); + + if (command !== "remove") { + console.error("Usage: node ./eng/external-plugin-rereview.mjs remove --plugin-name [--source-repo ] [--file ]"); + process.exit(1); + } + + const args = readCliArgs(process.argv.slice(3)); + + if (!args["plugin-name"] && !args["source-repo"]) { + console.error("Provide --plugin-name or --source-repo when removing an external plugin."); + process.exit(1); + } + + const removedPlugin = removePluginFromExternalJson({ + pluginName: args["plugin-name"], + sourceRepo: args["source-repo"], + filePath: args.file, + }); + + process.stdout.write(`${JSON.stringify(removedPlugin, null, 2)}\n`); +} diff --git a/eng/external-plugin-validation.mjs b/eng/external-plugin-validation.mjs new file mode 100644 index 000000000..660c05300 --- /dev/null +++ b/eng/external-plugin-validation.mjs @@ -0,0 +1,377 @@ +import fs from "fs"; +import path from "path"; +import { ROOT_FOLDER } from "./constants.mjs"; + +export const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json"); + +export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({ + marketplace: Object.freeze({ + allowedSourceTypes: ["github"], + requireAuthor: true, + requireRepository: true, + requireKeywords: true, + requireLicense: false, + requireImmutableRef: false, + }), + publicSubmission: Object.freeze({ + allowedSourceTypes: ["github"], + requireAuthor: true, + requireRepository: true, + requireKeywords: true, + requireLicense: true, + requireImmutableRef: true, + }), +}); + +function resolvePolicy(policy) { + if (!policy) { + return EXTERNAL_PLUGIN_POLICIES.marketplace; + } + + if (typeof policy === "string") { + const resolved = EXTERNAL_PLUGIN_POLICIES[policy]; + if (!resolved) { + throw new Error(`Unknown external plugin validation policy "${policy}"`); + } + + return resolved; + } + + return { + ...EXTERNAL_PLUGIN_POLICIES.marketplace, + ...policy, + }; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function validatePluginName(name, prefix, errors) { + if (!isNonEmptyString(name)) { + errors.push(`${prefix}: "name" is required and must be a non-empty string`); + return; + } + + if (name.length > 50) { + errors.push(`${prefix}: "name" must be 50 characters or fewer`); + } + + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push(`${prefix}: "name" must contain only lowercase letters, numbers, and hyphens`); + } +} + +function validateDescription(description, prefix, errors) { + if (!isNonEmptyString(description)) { + errors.push(`${prefix}: "description" is required and must be a non-empty string`); + return; + } + + if (description.length > 500) { + errors.push(`${prefix}: "description" must be 500 characters or fewer`); + } +} + +function validateVersion(version, prefix, errors) { + if (!isNonEmptyString(version)) { + errors.push(`${prefix}: "version" is required and must be a non-empty string`); + return; + } + + if (version.length > 100) { + errors.push(`${prefix}: "version" must be 100 characters or fewer`); + } +} + +function validateKeywords(keywords, prefix, errors, warnings, required) { + if (keywords === undefined) { + if (required) { + errors.push(`${prefix}: "keywords" is required and must be an array of lowercase tags`); + } + return; + } + + if (!Array.isArray(keywords)) { + errors.push(`${prefix}: "keywords" must be an array`); + return; + } + + if (keywords.length > 10) { + errors.push(`${prefix}: "keywords" must contain no more than 10 entries`); + } + + for (let i = 0; i < keywords.length; i++) { + const keyword = keywords[i]; + if (!isNonEmptyString(keyword)) { + errors.push(`${prefix}: "keywords[${i}]" must be a non-empty string`); + continue; + } + + if (!/^[a-z0-9-]+$/.test(keyword)) { + errors.push(`${prefix}: "keywords[${i}]" must contain only lowercase letters, numbers, and hyphens`); + } + + if (keyword.length > 30) { + errors.push(`${prefix}: "keywords[${i}]" must be 30 characters or fewer`); + } + } + + if (keywords.length === 0) { + if (required) { + errors.push(`${prefix}: "keywords" must contain at least one entry`); + } else { + warnings.push(`${prefix}: "keywords" is empty; at least one keyword is recommended for discovery`); + } + } +} + +function validateHttpsUrl(value, fieldName, prefix, errors, options = {}) { + if (!isNonEmptyString(value)) { + errors.push(`${prefix}: "${fieldName}" must be a non-empty string`); + return; + } + + let parsed; + try { + parsed = new URL(value); + } catch { + errors.push(`${prefix}: "${fieldName}" must be a valid URL`); + return; + } + + if (parsed.protocol !== "https:") { + errors.push(`${prefix}: "${fieldName}" must use https`); + } + + if (options.githubOnly && parsed.hostname !== "github.com") { + errors.push(`${prefix}: "${fieldName}" must point to https://github.com/...`); + } +} + +function validateAuthor(author, prefix, errors, required) { + if (author === undefined) { + if (required) { + errors.push(`${prefix}: "author" is required`); + } + return; + } + + if (!author || typeof author !== "object" || Array.isArray(author)) { + errors.push(`${prefix}: "author" must be an object`); + return; + } + + if (!isNonEmptyString(author.name)) { + errors.push(`${prefix}: "author.name" is required and must be a non-empty string`); + } + + if (author.url !== undefined) { + validateHttpsUrl(author.url, "author.url", prefix, errors); + } +} + +function validateLicense(license, prefix, errors, required) { + if (license === undefined) { + if (required) { + errors.push(`${prefix}: "license" is required`); + } + return; + } + + if (!isNonEmptyString(license)) { + errors.push(`${prefix}: "license" must be a non-empty string`); + } +} + +function validateRepository(repository, prefix, errors, required) { + if (repository === undefined) { + if (required) { + errors.push(`${prefix}: "repository" is required`); + } + return; + } + + validateHttpsUrl(repository, "repository", prefix, errors, { githubOnly: true }); +} + +function validateHomepage(homepage, prefix, errors) { + if (homepage === undefined) { + return; + } + + validateHttpsUrl(homepage, "homepage", prefix, errors); +} + +function validateRelativePath(pathValue, prefix, errors) { + if (!isNonEmptyString(pathValue)) { + errors.push(`${prefix}: "source.path" must be a non-empty string when provided`); + return; + } + + const normalized = path.posix.normalize(pathValue); + const segments = pathValue.split("/"); + + if (pathValue.startsWith("/") || pathValue.startsWith("../") || normalized !== pathValue || segments.includes("..")) { + errors.push(`${prefix}: "source.path" must be a safe relative path inside the repository`); + } + + if (pathValue.includes("\\")) { + errors.push(`${prefix}: "source.path" must use forward slashes`); + } +} + +function validateImmutableRef(ref, prefix, errors) { + if (!isNonEmptyString(ref)) { + errors.push(`${prefix}: "source.ref" must be a non-empty string when provided`); + return; + } + + if (ref.startsWith("refs/heads/")) { + errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch ref`); + return; + } + + if (["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) { + errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch name`); + } + + if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) { + errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`); + } +} + +function validateGitHubSource(source, prefix, errors, requireImmutableRef) { + if (!source || typeof source !== "object" || Array.isArray(source)) { + errors.push(`${prefix}: "source" must be an object`); + return; + } + + if (source.source !== "github") { + errors.push(`${prefix}: "source.source" must be "github"`); + } + + if (!isNonEmptyString(source.repo)) { + errors.push(`${prefix}: "source.repo" is required and must be a non-empty string`); + } else if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) { + errors.push(`${prefix}: "source.repo" must be in "owner/repo" format`); + } + + if (source.path !== undefined) { + validateRelativePath(source.path, prefix, errors); + } + + if (source.ref !== undefined) { + validateImmutableRef(source.ref, prefix, errors); + } else if (requireImmutableRef) { + errors.push(`${prefix}: "source.ref" is required for public external plugin submissions`); + } +} + +export function validateExternalPlugin(plugin, index, options = {}) { + const policy = resolvePolicy(options.policy ?? options); + const errors = []; + const warnings = []; + const prefix = `external.json[${index}]`; + + if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) { + return { + errors: [`${prefix}: entry must be an object`], + warnings, + }; + } + + validatePluginName(plugin.name, prefix, errors); + validateDescription(plugin.description, prefix, errors); + validateVersion(plugin.version, prefix, errors); + validateAuthor(plugin.author, prefix, errors, policy.requireAuthor); + validateRepository(plugin.repository, prefix, errors, policy.requireRepository); + validateHomepage(plugin.homepage, prefix, errors); + validateLicense(plugin.license, prefix, errors, policy.requireLicense); + validateKeywords(plugin.keywords ?? plugin.tags, prefix, errors, warnings, policy.requireKeywords); + + if (plugin.tags !== undefined && plugin.keywords === undefined) { + warnings.push(`${prefix}: prefer "keywords" over legacy "tags"`); + } + + if (!plugin.source) { + errors.push(`${prefix}: "source" is required`); + } else if (typeof plugin.source === "string") { + errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`); + } else if (!policy.allowedSourceTypes.includes(plugin.source.source)) { + errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`); + } else if (plugin.source.source === "github") { + validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableRef); + } + + return { errors, warnings }; +} + +export function validateExternalPlugins(plugins, options = {}) { + const policy = resolvePolicy(options.policy ?? options); + const errors = []; + const warnings = []; + const localNames = new Map( + (options.localPluginNames ?? []).map((name) => [String(name).toLowerCase(), String(name)]) + ); + const seenExternalNames = new Map(); + + if (!Array.isArray(plugins)) { + return { + errors: ["external.json must contain an array"], + warnings, + }; + } + + plugins.forEach((plugin, index) => { + const result = validateExternalPlugin(plugin, index, { policy }); + errors.push(...result.errors); + warnings.push(...result.warnings); + + if (!isNonEmptyString(plugin?.name)) { + return; + } + + const normalizedName = plugin.name.toLowerCase(); + const duplicateIndex = seenExternalNames.get(normalizedName); + if (duplicateIndex !== undefined) { + errors.push(`external.json[${index}]: duplicate plugin name "${plugin.name}" already used by external.json[${duplicateIndex}]`); + } else { + seenExternalNames.set(normalizedName, index); + } + + const localDuplicate = localNames.get(normalizedName); + if (localDuplicate) { + errors.push(`external.json[${index}]: plugin name "${plugin.name}" conflicts with local plugin "${localDuplicate}"`); + } + }); + + return { errors, warnings }; +} + +export function readExternalPlugins(options = {}) { + const filePath = options.filePath ?? EXTERNAL_PLUGINS_FILE; + + if (!fs.existsSync(filePath)) { + return { + plugins: [], + errors: [], + warnings: [], + }; + } + + let plugins; + try { + const content = fs.readFileSync(filePath, "utf8"); + plugins = JSON.parse(content); + } catch (error) { + return { + plugins: [], + errors: [`Error reading ${path.basename(filePath)}: ${error.message}`], + warnings: [], + }; + } + + const { errors, warnings } = validateExternalPlugins(plugins, options); + return { plugins, errors, warnings }; +} diff --git a/eng/generate-marketplace.mjs b/eng/generate-marketplace.mjs index 96cf492f8..b69a9bd69 100755 --- a/eng/generate-marketplace.mjs +++ b/eng/generate-marketplace.mjs @@ -3,84 +3,11 @@ import fs from "fs"; import path from "path"; import { ROOT_FOLDER } from "./constants.mjs"; +import { readExternalPlugins } from "./external-plugin-validation.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); -const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json"); const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json"); -/** - * Validate an external plugin entry has required fields and a non-local source - * @param {object} plugin - External plugin entry - * @param {number} index - Index in the array (for error messages) - * @returns {string[]} - Array of validation error messages - */ -function validateExternalPlugin(plugin, index) { - const errors = []; - const prefix = `external.json[${index}]`; - - if (!plugin.name || typeof plugin.name !== "string") { - errors.push(`${prefix}: "name" is required and must be a string`); - } - if (!plugin.description || typeof plugin.description !== "string") { - errors.push(`${prefix}: "description" is required and must be a string`); - } - if (!plugin.version || typeof plugin.version !== "string") { - errors.push(`${prefix}: "version" is required and must be a string`); - } - - if (!plugin.source) { - errors.push(`${prefix}: "source" is required`); - } else if (typeof plugin.source === "string") { - errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`); - } else if (typeof plugin.source === "object") { - if (!plugin.source.source) { - errors.push(`${prefix}: "source.source" is required (e.g. "github", "url", "npm", "pip")`); - } - } else { - errors.push(`${prefix}: "source" must be an object`); - } - - return errors; -} - -/** - * Read external plugin entries from external.json - * @returns {Array} - Array of external plugin entries (merged as-is) - */ -function readExternalPlugins() { - if (!fs.existsSync(EXTERNAL_PLUGINS_FILE)) { - return []; - } - - try { - const content = fs.readFileSync(EXTERNAL_PLUGINS_FILE, "utf8"); - const plugins = JSON.parse(content); - if (!Array.isArray(plugins)) { - console.warn("Warning: external.json must contain an array"); - return []; - } - - // Validate each entry - let hasErrors = false; - for (let i = 0; i < plugins.length; i++) { - const errors = validateExternalPlugin(plugins[i], i); - if (errors.length > 0) { - errors.forEach(e => console.error(`Error: ${e}`)); - hasErrors = true; - } - } - if (hasErrors) { - console.error("Error: external.json contains invalid entries"); - process.exit(1); - } - - return plugins; - } catch (error) { - console.error(`Error reading external.json: ${error.message}`); - return []; - } -} - /** * Read plugin metadata from plugin.json file * @param {string} pluginDir - Path to plugin directory @@ -142,16 +69,20 @@ function generateMarketplace() { } // Read external plugins and merge as-is - const externalPlugins = readExternalPlugins(); + const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({ + localPluginNames: plugins.map((plugin) => plugin.name), + policy: "marketplace", + }); + externalWarnings.forEach((warning) => console.warn(`Warning: ${warning}`)); + if (externalErrors.length > 0) { + externalErrors.forEach((error) => console.error(`Error: ${error}`)); + console.error("Error: external.json contains invalid entries"); + process.exit(1); + } + if (externalPlugins.length > 0) { console.log(`\nFound ${externalPlugins.length} external plugins`); - - // Warn on duplicate names - const localNames = new Set(plugins.map(p => p.name)); for (const ext of externalPlugins) { - if (localNames.has(ext.name)) { - console.warn(`Warning: external plugin "${ext.name}" has the same name as a local plugin`); - } plugins.push(ext); console.log(`āœ“ Added external plugin: ${ext.name}`); } diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs index 42f64265f..f9af74fa2 100755 --- a/eng/validate-plugins.mjs +++ b/eng/validate-plugins.mjs @@ -3,6 +3,7 @@ import fs from "fs"; import path from "path"; import { ROOT_FOLDER } from "./constants.mjs"; +import { readExternalPlugins } from "./external-plugin-validation.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); @@ -222,8 +223,24 @@ function validatePlugins() { } } + console.log("\nValidating external plugin catalog..."); + const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({ + localPluginNames: pluginDirs, + policy: "marketplace", + }); + + externalWarnings.forEach((warning) => console.warn(`āš ļø ${warning}`)); + + if (externalErrors.length > 0) { + console.error("āŒ external.json:"); + externalErrors.forEach((error) => console.error(` - ${error}`)); + hasErrors = true; + } else { + console.log(`āœ… external.json is valid (${externalPlugins.length} external plugins)`); + } + if (!hasErrors) { - console.log(`\nāœ… All ${pluginDirs.length} plugins are valid`); + console.log(`\nāœ… All ${pluginDirs.length} plugins and the external catalog are valid`); } return !hasErrors;