Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/external-plugin-approval-command.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
115 changes: 11 additions & 104 deletions .github/workflows/external-plugin-intake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
};

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 path = require('path');
const { pathToFileURL } = require('url');

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;
Expand All @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions .github/workflows/external-plugin-rerun-intake-command.yml
Original file line number Diff line number Diff line change
@@ -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'
});
}
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading