diff --git a/.github/workflows/external-plugin-approval-command.yml b/.github/workflows/external-plugin-approval-command.yml index 8a20b75d..21f088f0 100644 --- a/.github/workflows/external-plugin-approval-command.yml +++ b/.github/workflows/external-plugin-approval-command.yml @@ -118,7 +118,7 @@ jobs: '', '### Required fixes', '', - ...(errors.length > 0 ? errors.map((error) => `- ${error}`) : ['- Re-run intake validation by updating the issue details.']) + ...(errors.length > 0 ? errors.map((error) => `- ${error}`) : ['- Edit the issue details and let intake rerun automatically, or comment `/rerun-intake` to trigger it again on demand.']) ].join('\n'); const { data: comments } = await github.rest.issues.listComments({ @@ -493,7 +493,7 @@ jobs: '', reason, '', - 'If you address the feedback, open a new external plugin submission issue with the updated details.' + 'If you address the feedback, edit this issue with the updated details and have the issue author or a maintainer comment `/rerun-intake` to re-run automated intake.' ].join('\n'); const { data: comments } = await github.rest.issues.listComments({ diff --git a/.github/workflows/external-plugin-intake.yml b/.github/workflows/external-plugin-intake.yml index ce8d47d5..90f80b3f 100644 --- a/.github/workflows/external-plugin-intake.yml +++ b/.github/workflows/external-plugin-intake.yml @@ -4,6 +4,10 @@ on: issues: types: [opened, edited, reopened] +concurrency: + group: external-plugin-intake-${{ github.event.issue.number }} + cancel-in-progress: true + permissions: contents: read issues: write @@ -36,81 +40,10 @@ jobs: 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' - } - }; + const path = require('path'); + const { pathToFileURL } = require('url'); - 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 intakeState = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake-state.mjs')).href); const result = JSON.parse(process.env.RESULT_JSON); const issueNumber = context.issue.number; @@ -128,40 +61,14 @@ jobs: 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({ + await intakeState.applyExternalPluginIntakeEvaluation({ + github, owner: context.repo.owner, repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100 + issueNumber, + evaluation: result }); - 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, diff --git a/.github/workflows/external-plugin-rerun-intake-command.yml b/.github/workflows/external-plugin-rerun-intake-command.yml new file mode 100644 index 00000000..f077c53f --- /dev/null +++ b/.github/workflows/external-plugin-rerun-intake-command.yml @@ -0,0 +1,124 @@ +name: External Plugin Rerun Intake Commands + +on: + issue_comment: + types: [created] + +concurrency: + group: external-plugin-intake-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + handle-command: + runs-on: ubuntu-latest + if: >- + !github.event.issue.pull_request && + startsWith(github.event.comment.body, '/rerun-intake') + steps: + - name: Checkout staged branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: staged + + - name: Re-run external plugin intake + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const path = require('path'); + const { pathToFileURL } = require('url'); + + const intake = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake.mjs')).href); + const intakeState = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'external-plugin-intake-state.mjs')).href); + + const commentAuthor = context.payload.comment.user?.login; + if (!commentAuthor || context.payload.comment.user?.type === 'Bot' || commentAuthor === 'github-actions[bot]') { + core.info('Ignoring /rerun-intake from a bot or unknown actor.'); + return; + } + + if (!intake.parseRerunIntakeCommand(context.payload.comment.body)) { + core.info('No supported /rerun-intake command was found.'); + return; + } + + const { data: currentIssue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const labelNames = new Set((currentIssue.labels || []).map((label) => label.name)); + const isExternalPluginIssue = + labelNames.has('external-plugin') || + String(currentIssue.body || '').includes(intake.ISSUE_FORM_MARKER); + if (!isExternalPluginIssue) { + core.info('Ignoring /rerun-intake because the issue is not an external plugin submission.'); + return; + } + + if (labelNames.has('approved') || labelNames.has('re-review-due') || labelNames.has('re-review-follow-up')) { + core.info('Ignoring /rerun-intake because the issue is already approved or in the six-month re-review flow.'); + return; + } + + const issueAuthor = currentIssue.user?.login; + const isIssueAuthor = Boolean(issueAuthor && commentAuthor === issueAuthor); + + let hasWriteAccess = false; + if (!isIssueAuthor) { + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commentAuthor + }); + hasWriteAccess = ['admin', 'write', 'maintain'].includes(permission.data.permission); + } + + if (!isIssueAuthor && !hasWriteAccess) { + core.info(`Ignoring /rerun-intake because ${commentAuthor} is neither the issue author nor a maintainer.`); + return; + } + + const canRerunFromCurrentState = currentIssue.state === 'open' || labelNames.has('rejected'); + if (!canRerunFromCurrentState) { + core.info('Ignoring /rerun-intake because the issue is closed outside the intake/rejection flow.'); + return; + } + + const evaluation = await intake.evaluateExternalPluginIssue({ + issue: currentIssue, + token: process.env.GITHUB_TOKEN + }); + + await intakeState.applyExternalPluginIntakeEvaluation({ + github, + owner: context.repo.owner, + repo: context.repo.repo, + issueNumber: context.issue.number, + evaluation + }); + + if (evaluation.valid && currentIssue.state === 'closed' && labelNames.has('rejected')) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'open' + }); + return; + } + + if (!evaluation.valid && currentIssue.state === 'open') { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed' + }); + } diff --git a/AGENTS.md b/AGENTS.md index 9132de5a..28f3a33c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,10 +167,11 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: 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` +6. After issue edits, the issue author or a maintainer can comment `/rerun-intake` to re-run automated intake without opening a new submission issue +7. Maintainers make the decision with `/approve` or `/reject ` issue comments; approved issues are closed and used as the six-month re-review anchor +8. Approval automation creates or updates the PR against `staged`, updates `plugins/external.json`, and regenerates marketplace outputs +9. 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 +10. 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 80e03c97..d4c5b37f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -230,9 +230,10 @@ The public-submission policy builds on those rules and also requires `license` p 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. +4. **Requesting another intake pass**: after updating the issue body, the issue author or a maintainer can comment `/rerun-intake` to re-run automated intake on demand. Open issues still re-trigger intake automatically on edit, but closed rejected issues need `/rerun-intake`. +5. **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. +6. **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. +7. **Rejection path**: on `/reject `, automation removes `ready-for-review`, adds `rejected`, closes the issue, and records the reason in an issue comment. After addressing the feedback, update the same issue and use `/rerun-intake` to re-queue intake. ##### Maintainer review responsibilities diff --git a/eng/external-plugin-intake-state.mjs b/eng/external-plugin-intake-state.mjs new file mode 100644 index 00000000..053915da --- /dev/null +++ b/eng/external-plugin-intake-state.mjs @@ -0,0 +1,163 @@ +export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({ + "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", + }, +}); + +const EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS = Object.freeze([ + "external-plugin", + "awaiting-review", + "ready-for-review", + "rejected", +]); + +async function ensureLabel({ github, owner, repo, name, config }) { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name, + color: config.color, + description: config.description, + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } +} + +async function removeLabel({ github, owner, repo, issueNumber, name }) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } +} + +export async function syncExternalPluginIntakeLabels({ github, owner, repo, issueNumber, desiredLabels }) { + await Promise.all( + Object.entries(EXTERNAL_PLUGIN_INTAKE_LABELS).map(([name, config]) => + ensureLabel({ github, owner, repo, name, config }) + ) + ); + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + const currentManagedLabels = currentLabels + .map((label) => label.name) + .filter((name) => EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS.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, + repo, + issue_number: issueNumber, + labels: labelsToAdd, + }); + } + + for (const name of labelsToRemove) { + await removeLabel({ github, owner, repo, issueNumber, name }); + } +} + +export async function upsertExternalPluginIntakeComment({ + github, + owner, + repo, + issueNumber, + marker, + body, +}) { + const { data: comments } = await github.rest.issues.listComments({ + owner, + 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, + repo, + comment_id: existingComment.id, + body, + }); + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); +} + +export async function applyExternalPluginIntakeEvaluation({ + github, + owner, + repo, + issueNumber, + evaluation, +}) { + const desiredLabels = evaluation.valid + ? new Set(["external-plugin", "ready-for-review"]) + : new Set(["external-plugin", "rejected"]); + + await syncExternalPluginIntakeLabels({ + github, + owner, + repo, + issueNumber, + desiredLabels, + }); + + await upsertExternalPluginIntakeComment({ + github, + owner, + repo, + issueNumber, + marker: evaluation.commentMarker, + body: evaluation.commentBody, + }); + + return { desiredLabels }; +} diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index cd22fa4d..7bf187a6 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -6,7 +6,13 @@ import { fileURLToPath } from "url"; import { ROOT_FOLDER } from "./constants.mjs"; import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs"; -const ISSUE_FORM_MARKER = ""; +export const ISSUE_FORM_MARKER = ""; +export const EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER = ""; +export const RERUN_INTAKE_COMMAND = "/rerun-intake"; +const RERUN_INTAKE_COMMAND_PATTERN = new RegExp( + `^\\s*${RERUN_INTAKE_COMMAND.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, + "m", +); const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const REQUIRED_CHECKLIST_ITEMS = [ @@ -261,6 +267,10 @@ export function parseExternalPluginIssueBody(body) { }; } +export function parseRerunIntakeCommand(body) { + return RERUN_INTAKE_COMMAND_PATTERN.test(String(body ?? "")); +} + export async function evaluateExternalPluginIssue({ issue, token } = {}) { const issueBody = issue?.body ?? ""; const parsed = parseExternalPluginIssueBody(issueBody); @@ -294,7 +304,7 @@ export async function evaluateExternalPluginIssue({ issue, token } = {}) { const dedupedErrors = [...new Set(errors)]; const dedupedWarnings = [...new Set(warnings)]; const valid = dedupedErrors.length === 0; - const marker = ""; + const marker = EXTERNAL_PLUGIN_INTAKE_COMMENT_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 @@ -333,7 +343,7 @@ export async function evaluateExternalPluginIssue({ issue, token } = {}) { "## ❌ 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.", + `Edit the issue form to address the fixes below, then have the issue author or a maintainer comment \`${RERUN_INTAKE_COMMAND}\` to re-run intake for this closed submission.`, "", "### Required fixes", "",