Adding a new /rerun-intake command for when updates are required (#1786)

* Adding a new /rerun-intake command for when updates are required

Reruns the intake process if feedback is given that will require the submitter to update something about the submittion.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use rerun command constant in parser regex

Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-05-22 10:53:15 +10:00
committed by GitHub
parent a303e17975
commit 2ca49df9d4
7 changed files with 322 additions and 116 deletions
@@ -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({
+11 -104
View File
@@ -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,
@@ -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'
});
}
+5 -4
View File
@@ -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 <reason>` 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 <reason>` 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
+4 -3
View File
@@ -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 <reason>` 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 <reason>`, 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 <reason>` 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 <reason>`, 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
+163
View File
@@ -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 };
}
+13 -3
View File
@@ -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 = "<!-- external-plugin-submission -->";
export const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
export const EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER = "<!-- external-plugin-intake -->";
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 = "<!-- external-plugin-intake -->";
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",
"",