Skip to content

Commit b85902a

Browse files
authored
Merge pull request #5041 from github/aaronpowell-external-plugin-ownership
Label external plugin ownership and fast-track first-party reviews
2 parents bb035c7 + 4796642 commit b85902a

21 files changed

Lines changed: 916 additions & 50 deletions

‎.github/extensions/external-plugin-review-board/README.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# External Plugin Review Board
22

3-
A maintainer-only Copilot canvas extension for triaging `external-plugin` submissions that are
4-
`ready-for-review` in github/awesome-copilot.
3+
A maintainer-only Copilot canvas extension for triaging active `external-plugin` submissions in
4+
github/awesome-copilot.
55

66
Open it by asking Copilot to "open the plugin review board" (canvas id `external-plugin-review-board`).
77

@@ -16,8 +16,15 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
1616
- **Re-review** (in the details panel) takes optional guidance on what to focus on, then has the agent
1717
spin up a separate sub-session to re-assess that one submission. The sub-session reports its result
1818
back and it's recorded on the card along with your guidance.
19-
- **Refresh** fetches open `external-plugin` + `ready-for-review` issues, adds new ones, and removes any
20-
that are closed or no longer ready for review.
19+
- **Refresh** fetches open `external-plugin` issues in `ready-for-review`, `awaiting-review`, or
20+
`requires-submitter-fixes` states. Pending intake cards stay on the board so their AI reviews,
21+
manual buckets, queued re-reviews, and fast-track dismissal state are preserved. Approval and
22+
first-party fast-track are unavailable until intake is ready.
23+
- **First-party fast-track**: cards labelled `first party` (repository owned by a Microsoft/GitHub
24+
organization) skip AI review and go straight to *Accept* with a suggested `/approve`. Only submissions
25+
that passed automated intake can use fast-track or be approved; pending submissions remain visible on the board.
26+
Fast-track never overrides an existing review or a bucket you chose; drag a card to *Unreviewed* to opt it
27+
out and have it reviewed normally. Cards show *First party* / *Third party* pills from the intake labels.
2128
- **Details panel** shows the AI review, the rendered issue body, and all comments.
2229
- **Quick actions** post `/approve` (with an optional note) or `/reject <reason>` straight to the issue
2330
after a confirm click. The reject reason is prefilled from the AI's suggested comment.

‎.github/extensions/external-plugin-review-board/extension.mjs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import path from "node:path";
77
import { fileURLToPath } from "node:url";
88
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
9-
import { BoardStore, RECOMMENDATIONS } from "./lib/state.mjs";
9+
import { BoardStore, isReadyForApproval, RECOMMENDATIONS } from "./lib/state.mjs";
1010
import * as gh from "./lib/github.mjs";
1111
import { buildReviewPrompt, buildRereviewPrompt, REVIEW_FIELDS_SCHEMA } from "./lib/review-prompt.mjs";
1212
import { startBoardServer, expectedError } from "./lib/server.mjs";
@@ -50,7 +50,7 @@ async function refresh() {
5050
refreshInFlight ??= (async () => {
5151
try {
5252
await resolvePendingDecisions();
53-
const issues = await gh.listReadyIssues(store.state.repo);
53+
const issues = await gh.listBoardIssues(store.state.repo);
5454
detailCache.clear();
5555
return await store.syncIssues(issues);
5656
} finally {
@@ -97,7 +97,11 @@ const MAX_GUIDANCE_LENGTH = 4000;
9797
async function startRereview({ number, guidance, instanceId, send }) {
9898
const item = store.getItem(Number(number));
9999
if (item.decision) throw new Error(`#${item.number} was already actioned (${item.decision.kind}).`);
100+
if (item.pendingDecision) throw new Error(`#${item.number} has a posted command awaiting workflow confirmation.`);
100101
if (item.reviewStatus === "queued") throw new Error(`#${item.number} is already queued for review.`);
102+
if (!isReadyForApproval(item.labels ?? [])) {
103+
throw new Error(`#${item.number} is pending intake or is no longer ready-for-review.`);
104+
}
101105
const text = String(guidance ?? "").trim().slice(0, MAX_GUIDANCE_LENGTH);
102106
const snapshots = await store.markQueued([item.number], { rereviewGuidance: text || null });
103107
const prompt = buildRereviewPrompt({
@@ -146,8 +150,8 @@ async function postDecisionCommand({ number, kind, comment }) {
146150

147151
const status = await gh.getIssueStatus(store.state.repo, number);
148152
if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`);
149-
if (kind === "approve" && !status.labels.includes("ready-for-review")) {
150-
throw new Error(`#${number} is no longer labelled ready-for-review, so /approve would be ignored.`);
153+
if (kind === "approve" && !isReadyForApproval(status.labels)) {
154+
throw new Error(`#${number} is pending intake or is no longer ready-for-review, so /approve would be ignored.`);
151155
}
152156

153157
const text = stripCommand(comment, kind);
@@ -250,7 +254,7 @@ session = await joinSession({
250254
},
251255
{
252256
name: "refresh",
253-
description: "Fetch open ready-for-review external plugin issues; adds new ones and removes closed ones.",
257+
description: "Fetch open external plugin submissions in active intake states; adds new ones and removes inactive ones.",
254258
handler: wrap(() => refresh()),
255259
},
256260
{

‎.github/extensions/external-plugin-review-board/lib/github.mjs‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,32 +29,37 @@ function runGh(args, { input } = {}) {
2929
});
3030
}
3131

32-
export async function listReadyIssues(repo) {
32+
export function mapBoardIssues(issues) {
33+
const boardStates = new Set(["ready-for-review", "awaiting-review", "requires-submitter-fixes"]);
34+
return issues
35+
.filter((issue) => (issue.labels ?? []).some((label) => boardStates.has(label.name)))
36+
.map((issue) => ({
37+
number: issue.number,
38+
title: issue.title,
39+
author: issue.author?.login ?? "unknown",
40+
createdAt: issue.createdAt,
41+
updatedAt: issue.updatedAt,
42+
url: issue.url,
43+
labels: (issue.labels ?? []).map((label) => label.name),
44+
}));
45+
}
46+
47+
export async function listBoardIssues(repo) {
3348
const out = await runGh([
3449
"issue",
3550
"list",
3651
"-R",
3752
repo,
3853
"--label",
3954
"external-plugin",
40-
"--label",
41-
"ready-for-review",
4255
"--state",
4356
"open",
4457
"--limit",
4558
"500",
4659
"--json",
4760
"number,title,author,createdAt,updatedAt,labels,url",
4861
]);
49-
return JSON.parse(out).map((issue) => ({
50-
number: issue.number,
51-
title: issue.title,
52-
author: issue.author?.login ?? "unknown",
53-
createdAt: issue.createdAt,
54-
updatedAt: issue.updatedAt,
55-
url: issue.url,
56-
labels: (issue.labels ?? []).map((label) => label.name),
57-
}));
62+
return mapBoardIssues(JSON.parse(out));
5863
}
5964

6065
const FULL_JSON = ["-H", "Accept: application/vnd.github.full+json"];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { mapBoardIssues } from "./github.mjs";
5+
6+
test("board feed includes submissions in each active intake state", () => {
7+
const issues = ["ready-for-review", "awaiting-review", "requires-submitter-fixes", "rejected"].map(
8+
(state, index) => ({
9+
number: index + 1,
10+
title: `Submission ${index + 1}`,
11+
author: { login: "contributor" },
12+
labels: [{ name: "external-plugin" }, { name: state }],
13+
url: `https://github.com/github/awesome-copilot/issues/${index + 1}`,
14+
}),
15+
);
16+
17+
assert.deepEqual(
18+
mapBoardIssues(issues).map((issue) => issue.number),
19+
[1, 2, 3],
20+
);
21+
});

‎.github/extensions/external-plugin-review-board/lib/state.mjs‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,57 @@ export const COLUMNS = [
1515
];
1616

1717
const HISTORY_LIMIT = 200;
18+
export const FIRST_PARTY_LABEL = "first party";
19+
const MANUAL_INTAKE_OVERRIDE_LABEL = "intake-manually-overridden";
20+
const PENDING_INTAKE_LABELS = ["awaiting-review", "requires-submitter-fixes"];
21+
22+
export function isReadyForApproval(labels) {
23+
return labels.includes("ready-for-review") && !labels.some((label) => PENDING_INTAKE_LABELS.includes(label));
24+
}
25+
26+
// Validated submissions from first-party organizations skip AI triage and land in Accept. Only
27+
// cards without an AI review, queued review, or manual bucket are fast-tracked so maintainer
28+
// choices are kept; dragging a card back to Unreviewed opts it out so it gets a normal AI review.
29+
function applyFirstPartyFastTrack(item) {
30+
if (
31+
!item.labels?.includes(FIRST_PARTY_LABEL) ||
32+
!item.labels?.includes("ready-for-review") ||
33+
item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label)) ||
34+
item.labels?.includes(MANUAL_INTAKE_OVERRIDE_LABEL) ||
35+
item.fastTrackDismissed ||
36+
item.decision ||
37+
item.review ||
38+
item.manualColumn ||
39+
item.reviewStatus === "queued"
40+
) {
41+
return false;
42+
}
43+
item.review = {
44+
recommendation: "accept",
45+
fastTracked: true,
46+
rationale:
47+
"First-party submission (repository owned by a Microsoft/GitHub organization) that passed automated intake validation; fast-tracked to approval.",
48+
suggestedComment: "/approve",
49+
reviewedAt: new Date().toISOString(),
50+
};
51+
item.reviewStatus = "reviewed";
52+
return true;
53+
}
54+
55+
function clearFirstPartyFastTrack(item) {
56+
if (
57+
(item.labels?.includes(FIRST_PARTY_LABEL) &&
58+
item.labels?.includes("ready-for-review") &&
59+
!item.labels?.includes(MANUAL_INTAKE_OVERRIDE_LABEL) &&
60+
!item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label))) ||
61+
!item.review?.fastTracked ||
62+
item.reviewStatus === "queued"
63+
) {
64+
return;
65+
}
66+
item.review = null;
67+
if (!item.decision && !item.manualColumn) item.reviewStatus = "unreviewed";
68+
}
1869

1970
function newQueueId() {
2071
return randomBytes(6).toString("base64url");
@@ -92,7 +143,10 @@ export class BoardStore {
92143
repo: this.state.repo,
93144
lastRefreshedAt: this.state.lastRefreshedAt,
94145
columns: COLUMNS,
95-
items,
146+
items: items.map((item) => ({
147+
...item,
148+
reviewEligible: isReadyForApproval(item.labels ?? []),
149+
})),
96150
history: this.state.history.slice(0, 50),
97151
};
98152
}
@@ -101,6 +155,7 @@ export class BoardStore {
101155
const incoming = new Map(issues.map((issue) => [String(issue.number), issue]));
102156
const added = [];
103157
const removed = [];
158+
const fastTracked = [];
104159
for (const [key, issue] of incoming) {
105160
const existing = this.state.items[key];
106161
if (existing) {
@@ -123,6 +178,9 @@ export class BoardStore {
123178
};
124179
added.push(issue.number);
125180
}
181+
const item = this.state.items[key];
182+
if (applyFirstPartyFastTrack(item)) fastTracked.push(issue.number);
183+
else clearFirstPartyFastTrack(item);
126184
}
127185
for (const key of Object.keys(this.state.items)) {
128186
if (!incoming.has(key)) {
@@ -132,12 +190,17 @@ export class BoardStore {
132190
}
133191
this.state.lastRefreshedAt = new Date().toISOString();
134192
await this.commit();
135-
return { added, removed, total: incoming.size };
193+
return { added, removed, fastTracked, total: incoming.size };
136194
}
137195

138196
pendingReview(numbers) {
139-
const all = Object.values(this.state.items).filter((item) =>
140-
!item.decision && !item.pendingDecision && item.reviewStatus !== "queued");
197+
const all = Object.values(this.state.items).filter(
198+
(item) =>
199+
isReadyForApproval(item.labels ?? []) &&
200+
!item.decision &&
201+
!item.pendingDecision &&
202+
item.reviewStatus !== "queued",
203+
);
141204
if (numbers?.length) {
142205
const wanted = new Set(numbers.map(Number));
143206
return all.filter((item) => wanted.has(item.number));
@@ -219,6 +282,7 @@ export class BoardStore {
219282
const item = this.getItem(number);
220283
if (item.decision) throw new Error(`#${number} has already been actioned.`);
221284
if (column === "unreviewed") {
285+
if (item.review?.fastTracked || item.labels?.includes(FIRST_PARTY_LABEL)) item.fastTrackDismissed = true;
222286
item.review = null;
223287
item.reviewStatus = "unreviewed";
224288
item.manualColumn = null;

‎.github/extensions/external-plugin-review-board/public/app.js‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,15 @@ function signalPills(item) {
117117
pills.push(h("span", { class: `pill pill-${level.toLowerCase()}`, title: "AGT contributor risk" }, `AGT ${level}`));
118118
} else if (label === "external-plugin-canvas") {
119119
pills.push(h("span", { class: "pill" }, "canvas"));
120+
} else if (label === "first party") {
121+
pills.push(h("span", { class: "pill pill-first-party", title: "Repository owned by a Microsoft/GitHub organization" }, "First party"));
122+
} else if (label === "third party") {
123+
pills.push(h("span", { class: "pill pill-third-party", title: "Repository owned by a third-party organization" }, "Third party"));
120124
}
121125
}
126+
if (item.review?.fastTracked && !item.decision) {
127+
pills.push(h("span", { class: "pill pill-approve", title: "First-party submission fast-tracked to approval" }, "Fast-tracked"));
128+
}
122129
if (item.decision) {
123130
pills.push(h("span", { class: `pill pill-${item.decision.kind}` }, item.decision.kind === "approve" ? "Approved" : "Rejected"));
124131
} else if (item.pendingDecision) {
@@ -146,7 +153,14 @@ function findItem(number) {
146153

147154
function pendingReviewCount() {
148155
return board
149-
? board.items.filter((item) => !item.decision && !item.pendingDecision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued").length
156+
? board.items.filter(
157+
(item) =>
158+
item.reviewEligible &&
159+
!item.decision &&
160+
!item.pendingDecision &&
161+
item.reviewStatus !== "reviewed" &&
162+
item.reviewStatus !== "queued",
163+
).length
150164
: 0;
151165
}
152166

@@ -188,9 +202,10 @@ function renderBoard() {
188202
}
189203
}
190204

191-
function columnItems(columnId) {
205+
function columnItems(columnId, kind) {
192206
return board.items.filter((item) => item.column === columnId && !item.decision &&
193-
!item.pendingDecision && item.reviewStatus !== "queued");
207+
!item.pendingDecision && item.reviewStatus !== "queued" &&
208+
(kind === "reject" || item.reviewEligible));
194209
}
195210

196211
function stagedComment(item) {
@@ -199,18 +214,20 @@ function stagedComment(item) {
199214
}
200215

201216
function renderColumnActions(column) {
202-
const count = columnItems(column.id).length;
203217
return h("div", { class: "column-actions", role: "group", "aria-label": `${column.title} bulk actions` },
204-
["approve", "reject", "rereview"].map((kind) => h("button", {
205-
type: "button", class: "btn btn-small", disabled: bulkBusy || !count,
206-
title: `Apply to all ${count} eligible items in this column, including hidden search results`,
207-
onclick: () => bulkAction(column.id, kind),
208-
}, `${kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"} all`)));
218+
["approve", "reject", "rereview"].map((kind) => {
219+
const count = columnItems(column.id, kind).length;
220+
return h("button", {
221+
type: "button", class: "btn btn-small", disabled: bulkBusy || !count,
222+
title: `Apply to all ${count} eligible items in this column, including hidden search results`,
223+
onclick: () => bulkAction(column.id, kind),
224+
}, `${kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"} all`);
225+
}));
209226
}
210227

211228
async function bulkAction(columnId, kind) {
212229
if (bulkBusy) return;
213-
const items = columnItems(columnId).map((item) => ({
230+
const items = columnItems(columnId, kind).map((item) => ({
214231
number: item.number, comment: stagedComment(item),
215232
}));
216233
if (!items.length) return;
@@ -239,7 +256,7 @@ async function bulkAction(columnId, kind) {
239256
}
240257
for (const item of items) {
241258
try {
242-
if (!columnItems(columnId).some((current) => current.number === item.number)) {
259+
if (!columnItems(columnId, kind).some((current) => current.number === item.number)) {
243260
throw new Error("Item moved, was queued, or was already actioned; review it again.");
244261
}
245262
await api("POST", "/api/decision", { ...item, kind });
@@ -381,7 +398,8 @@ function renderDrawer() {
381398
.map((column) => h("option", { value: column.id, selected: column.id === item.column }, column.title));
382399
ui.drawerMove.replaceChildren(...options);
383400
ui.drawerMove.disabled = Boolean(item.decision);
384-
ui.drawerRereview.disabled = Boolean(item.decision) || item.reviewStatus === "queued";
401+
ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) ||
402+
Boolean(item.pendingDecision) || item.reviewStatus === "queued";
385403
if (ui.drawerRereview.disabled) hideRereview();
386404

387405
for (const tab of ui.tabs) {
@@ -394,7 +412,7 @@ function renderDrawer() {
394412

395413
ui.drawerFooter.hidden = Boolean(item.decision);
396414
updatePreview();
397-
ui.approve.disabled = bulkBusy || Boolean(item.pendingDecision);
415+
ui.approve.disabled = bulkBusy || !item.reviewEligible || Boolean(item.pendingDecision);
398416
ui.reject.disabled ||= bulkBusy || Boolean(item.pendingDecision);
399417
renderTab(item);
400418
}
@@ -467,7 +485,7 @@ function renderReview(item) {
467485
return wrapper;
468486
}
469487
const rows = [
470-
["Recommendation", RECOMMENDATION_LABELS[review.recommendation] + (item.manualColumn ? ` (you moved it to ${RECOMMENDATION_LABELS[item.manualColumn]})` : "")],
488+
["Recommendation", RECOMMENDATION_LABELS[review.recommendation] + (review.fastTracked ? " (first-party fast-track, no AI review)" : "") + (item.manualColumn ? ` (you moved it to ${RECOMMENDATION_LABELS[item.manualColumn]})` : "")],
471489
["Plugin", review.pluginName],
472490
["Repository", review.repository ? h("a", { href: `https://github.com/${review.repository}`, target: "_blank", rel: "noopener noreferrer" }, review.repository) : null],
473491
["Repo facts", review.repoFacts],
@@ -566,6 +584,7 @@ function resetConfirm() {
566584
ui.approve.disabled = true;
567585
ui.reject.disabled = true;
568586
}
587+
if (!item?.reviewEligible) ui.approve.disabled = true;
569588
}
570589

571590
async function decide(kind) {
@@ -627,6 +646,7 @@ ui.refresh.addEventListener("click", () =>
627646
const parts = [`${result.total} open`];
628647
if (result.added.length) parts.push(`${result.added.length} new`);
629648
if (result.removed.length) parts.push(`${result.removed.length} removed`);
649+
if (result.fastTracked?.length) parts.push(`${result.fastTracked.length} first-party fast-tracked`);
630650
toast(`Refreshed: ${parts.join(", ")}`);
631651
if (selected != null && activeTab !== "review") loadDetail(selected, { force: true });
632652
} catch (error) {

0 commit comments

Comments
 (0)