Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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: false
Comment thread
aaronpowell marked this conversation as resolved.
Outdated

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 &&
contains(github.event.comment.body, '/rerun-intake')
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
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