fix(external-plugins): preserve cards during intake

Include submissions awaiting review or submitter fixes in the review board feed so refreshes preserve their existing state. Keep fast-track and approval gated on a ready intake state, and add regression coverage for card persistence across pending transitions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 821f4796-505e-4fb8-96cf-0b5bbf80a636
This commit is contained in:
Aaron Powell
2026-10-13 13:01:19 +11:00
co-authored by Copilot App
parent a48dab5961
commit 666a521cd5
9 changed files with 131 additions and 32 deletions
@@ -1,7 +1,7 @@
# External Plugin Review Board
A maintainer-only Copilot canvas extension for triaging `external-plugin` submissions that are
`ready-for-review` in github/awesome-copilot.
A maintainer-only Copilot canvas extension for triaging active `external-plugin` submissions in
github/awesome-copilot.
Open it by asking Copilot to "open the plugin review board" (canvas id `external-plugin-review-board`).
@@ -16,8 +16,10 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
- **Re-review** (in the details panel) takes optional guidance on what to focus on, then has the agent
spin up a separate sub-session to re-assess that one submission. The sub-session reports its result
back and it's recorded on the card along with your guidance.
- **Refresh** fetches open `external-plugin` + `ready-for-review` issues, adds new ones, and removes any
that are closed or no longer ready for review.
- **Refresh** fetches open `external-plugin` issues in `ready-for-review`, `awaiting-review`, or
`requires-submitter-fixes` states. Pending intake cards stay on the board so their AI reviews,
manual buckets, queued re-reviews, and fast-track dismissal state are preserved. Approval and
first-party fast-track are unavailable until intake is ready.
- **First-party fast-track**: cards labelled `first party` (repository owned by a Microsoft/GitHub
organization) skip AI review and go straight to *Accept* with a suggested `/approve`. Only submissions
that passed automated intake reach the board, so hard validation failures are still held back by intake.
@@ -6,7 +6,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
import { BoardStore, RECOMMENDATIONS } from "./lib/state.mjs";
import { BoardStore, isReadyForApproval, RECOMMENDATIONS } from "./lib/state.mjs";
import * as gh from "./lib/github.mjs";
import { buildReviewPrompt, buildRereviewPrompt, REVIEW_FIELDS_SCHEMA } from "./lib/review-prompt.mjs";
import { startBoardServer, expectedError } from "./lib/server.mjs";
@@ -49,7 +49,7 @@ async function refresh() {
refreshInFlight ??= (async () => {
try {
await resolvePendingDecisions();
const issues = await gh.listReadyIssues(store.state.repo);
const issues = await gh.listBoardIssues(store.state.repo);
detailCache.clear();
return await store.syncIssues(issues);
} finally {
@@ -134,10 +134,7 @@ async function postDecision({ number, kind, comment }) {
const status = await gh.getIssueStatus(store.state.repo, number);
if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`);
if (
kind === "approve" &&
(!status.labels.includes("ready-for-review") || status.labels.includes("awaiting-review"))
) {
if (kind === "approve" && !isReadyForApproval(status.labels)) {
throw new Error(`#${number} is pending intake or is no longer ready-for-review, so /approve would be ignored.`);
}
@@ -241,7 +238,7 @@ session = await joinSession({
},
{
name: "refresh",
description: "Fetch open ready-for-review external plugin issues; adds new ones and removes closed ones.",
description: "Fetch open external plugin submissions in active intake states; adds new ones and removes inactive ones.",
handler: wrap(() => refresh()),
},
{
@@ -29,7 +29,22 @@ function runGh(args, { input } = {}) {
});
}
export async function listReadyIssues(repo) {
export function mapBoardIssues(issues) {
const boardStates = new Set(["ready-for-review", "awaiting-review", "requires-submitter-fixes"]);
return issues
.filter((issue) => (issue.labels ?? []).some((label) => boardStates.has(label.name)))
.map((issue) => ({
number: issue.number,
title: issue.title,
author: issue.author?.login ?? "unknown",
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
url: issue.url,
labels: (issue.labels ?? []).map((label) => label.name),
}));
}
export async function listBoardIssues(repo) {
const out = await runGh([
"issue",
"list",
@@ -37,8 +52,6 @@ export async function listReadyIssues(repo) {
repo,
"--label",
"external-plugin",
"--label",
"ready-for-review",
"--state",
"open",
"--limit",
@@ -46,15 +59,7 @@ export async function listReadyIssues(repo) {
"--json",
"number,title,author,createdAt,updatedAt,labels,url",
]);
return JSON.parse(out).map((issue) => ({
number: issue.number,
title: issue.title,
author: issue.author?.login ?? "unknown",
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
url: issue.url,
labels: (issue.labels ?? []).map((label) => label.name),
}));
return mapBoardIssues(JSON.parse(out));
}
const FULL_JSON = ["-H", "Accept: application/vnd.github.full+json"];
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mapBoardIssues } from "./github.mjs";
test("board feed includes submissions in each active intake state", () => {
const issues = ["ready-for-review", "awaiting-review", "requires-submitter-fixes", "rejected"].map(
(state, index) => ({
number: index + 1,
title: `Submission ${index + 1}`,
author: { login: "contributor" },
labels: [{ name: "external-plugin" }, { name: state }],
url: `https://github.com/github/awesome-copilot/issues/${index + 1}`,
}),
);
assert.deepEqual(
mapBoardIssues(issues).map((issue) => issue.number),
[1, 2, 3],
);
});
@@ -16,6 +16,11 @@ export const COLUMNS = [
const HISTORY_LIMIT = 200;
export const FIRST_PARTY_LABEL = "first party";
const PENDING_INTAKE_LABELS = ["awaiting-review", "requires-submitter-fixes"];
export function isReadyForApproval(labels) {
return labels.includes("ready-for-review") && !labels.some((label) => PENDING_INTAKE_LABELS.includes(label));
}
// Validated submissions from first-party organizations skip AI triage and land in Accept. Only
// cards without an AI review, queued review, or manual bucket are fast-tracked so maintainer
@@ -24,7 +29,7 @@ function applyFirstPartyFastTrack(item) {
if (
!item.labels?.includes(FIRST_PARTY_LABEL) ||
!item.labels?.includes("ready-for-review") ||
item.labels?.includes("awaiting-review") ||
item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label)) ||
item.fastTrackDismissed ||
item.decision ||
item.review ||
@@ -49,7 +54,7 @@ function clearFirstPartyFastTrack(item) {
if (
(item.labels?.includes(FIRST_PARTY_LABEL) &&
item.labels?.includes("ready-for-review") &&
!item.labels?.includes("awaiting-review")) ||
!item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label))) ||
!item.review?.fastTracked ||
item.reviewStatus === "queued"
) {