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
14 changes: 13 additions & 1 deletion .github/extensions/external-plugin-review-board/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
failed actions leave it open so you can correct or retry them.
- **Quick actions** post `/approve` (with an optional note) or `/reject <reason>` straight to the issue
after a confirm click. The reject reason is prefilled from the AI's suggested comment.
- **Comment** (footer, next to Approve and Reject) posts the shared comment box as a free-form
comment, for example a question for the submitter, without changing the board state. The comment
must be non-empty and needs a second *Confirm comment* click. Comments starting with `/` are
rejected so they can't trigger the command router; use the dedicated actions for commands. After
posting, the panel stays open on the *Comments* tab and reloads it from GitHub.
- **Request fixes** (footer) posts `/request-fixes <reason>` from the comment box after a confirm
click. The command router moves the issue to `requires-submitter-fixes` and lists the requested
fixes for the submitter. A reason is required, and the button is disabled once the issue already
awaits fixes.
- **Column actions** approve, reject, or re-review every eligible item in a column, including
cards hidden by search. Confirmation previews each staged comment; rejection requires a reason
for every item. Drawer edits remain staged when switching cards in the current browser session.
Expand Down Expand Up @@ -74,6 +83,9 @@ Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` fro
to check both canvases' session-scoped persistence without accessing GitHub.
Run `node --test eng/board-bulk-actions.test.mjs` to check column confirmation, staged feedback,
queue exclusions, and partial failures using in-process fakes.
Run `node --test eng/board-preview.test.mjs` to check preview dismissal and in-flight actions.
Run `node --test eng/board-preview.test.mjs` to check preview dismissal, in-flight actions, and the
free-form comment confirm/post flow.
Run `node --test .github/extensions/external-plugin-review-board/lib/github.test.mjs` to check board
feed filtering and free-form comment validation.
Run `node --test eng/external-plugin-review-board-state.test.mjs eng/external-plugin-review-board-ui.test.mjs`
to check intake-blocked reasons, eligibility transitions, visible status, and unavailable controls.
79 changes: 78 additions & 1 deletion .github/extensions/external-plugin-review-board/extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BoardStore, isReadyForApproval, reviewBlockedReason, RECOMMENDATIONS }
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";
import { assertNoPendingFixesRequest, confirmRequestFixes } from "./lib/request-fixes.mjs";

const CANVAS_ID = "external-plugin-review-board";
const REPO = "github/awesome-copilot";
Expand Down Expand Up @@ -46,10 +47,28 @@ async function resolvePendingDecisions(numbers = store.pendingDecisionNumbers())
return resolved;
}

async function resolvePendingFixesRequests(numbers = store.pendingFixesRequestNumbers()) {
const resolved = [];
for (const number of await numbers) {
try {
const status = await gh.getIssueStatus(store.state.repo, number);
const request = await store.resolvePendingFixesRequest(number, status);
if (request) {
detailCache.delete(number);
resolved.push(number);
}
} catch (error) {
session?.log(`Could not resolve pending fixes request for #${number}: ${error.message}`, { level: "warning" });
}
}
return resolved;
}

async function refresh() {
refreshInFlight ??= (async () => {
try {
await resolvePendingDecisions();
await resolvePendingFixesRequests();
const issues = await gh.listBoardIssues(store.state.repo);
detailCache.clear();
return await store.syncIssues(issues);
Expand Down Expand Up @@ -141,7 +160,7 @@ async function startRereview({ number, guidance, instanceId, send }) {
function stripCommand(text, command) {
return String(text ?? "")
.trim()
.replace(new RegExp(`^/${command}\\b`, "i"), "")
.replace(new RegExp(`^/${command}(?![\\w-])`, "i"), "")
.trim();
}

Expand All @@ -160,6 +179,7 @@ async function postDecisionCommand({ number, kind, comment }) {
const item = store.getItem(number);
if (item.decision) throw new Error(`#${number} was already actioned (${item.decision.kind}).`);
if (item.pendingDecision) throw new Error(`#${number} already has a posted command awaiting workflow confirmation. Refresh the board.`);
assertNoPendingFixesRequest(item, number);

const status = await gh.getIssueStatus(store.state.repo, number);
if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`);
Expand All @@ -182,6 +202,57 @@ async function postDecisionCommand({ number, kind, comment }) {
return { number, ...pendingDecision, pending: true };
}

async function postFreeformComment({ number, body }) {
const text = gh.normalizeFreeformComment(body);
store.getItem(number);
if (decisionsInFlight.has(number)) throw new Error(`#${number} already has a GitHub action in progress.`);
decisionsInFlight.add(number);
try {
const commentUrl = await gh.postComment(store.state.repo, number, text);
detailCache.delete(number);
return { number, commentUrl };
} finally {
decisionsInFlight.delete(number);
}
}

// Request fixes keeps the issue open, so it bypasses the pending-decision flow. The router swaps the
// intake labels, and the board picks that up with a refresh once the label lands.
async function postRequestFixes({ number, comment }) {
const body = gh.buildRequestFixesCommand(comment);
const item = store.getItem(number);
if (item.decision) throw new Error(`#${number} was already actioned (${item.decision.kind}).`);
if (item.pendingDecision) throw new Error(`#${number} already has a posted command awaiting workflow confirmation. Refresh the board.`);
assertNoPendingFixesRequest(item, number);
if (decisionsInFlight.has(number)) throw new Error(`#${number} already has a GitHub action in progress.`);
decisionsInFlight.add(number);
try {
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 (status.labels.includes("requires-submitter-fixes")) {
throw new Error(`#${number} is already waiting on submitter fixes.`);
}
const commentUrl = await gh.postComment(store.state.repo, number, body);
const pendingFixesRequest = { body, commentUrl, at: new Date().toISOString() };
await store.recordPendingFixesRequest(number, pendingFixesRequest);
detailCache.delete(number);
const confirmed = await confirmRequestFixes({
number,
getIssueStatus: (issueNumber) => gh.getIssueStatus(store.state.repo, issueNumber),
onConfirmed: (latest) => store.resolvePendingFixesRequest(number, latest),
refresh,
sleep,
log: (error) => session?.log(
`Could not confirm fixes request for #${number}: ${error?.stack ?? error}`,
{ level: "warning" },
),
});
return { number, commentUrl, pending: !confirmed };
} finally {
decisionsInFlight.delete(number);
}
}

function boardSummary() {
const snapshot = store.snapshot();
return {
Expand All @@ -205,6 +276,9 @@ function boardSummary() {
pendingDecision: item.pendingDecision
? { kind: item.pendingDecision.kind, at: item.pendingDecision.at }
: null,
pendingFixesRequest: item.pendingFixesRequest
? { at: item.pendingFixesRequest.at, commentUrl: item.pendingFixesRequest.commentUrl }
: null,
})),
};
}
Expand All @@ -221,6 +295,9 @@ function routesFor(instanceId) {
"POST /api/issue/:number": ({ number }) => getIssue(number, { force: true }),
"POST /api/decision": ({ body }) =>
postDecision({ number: Number(body.number), kind: body.kind, comment: body.comment }),
"POST /api/comment": ({ body }) => postFreeformComment({ number: Number(body.number), body: body.body }),
"POST /api/request-fixes": ({ body }) =>
postRequestFixes({ number: Number(body.number), comment: body.comment }),
};
// Board errors are user-facing messages (e.g. "already actioned", gh failures); surface only the
// message text, never the stack, and log the full error for debugging.
Expand Down
32 changes: 32 additions & 0 deletions .github/extensions/external-plugin-review-board/lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,38 @@ export async function getIssueStatus(repo, number) {
return { state: data.state, labels: (data.labels ?? []).map((label) => label.name) };
}

export const MAX_COMMENT_LENGTH = 60000;

// Free-form comments must never be parsed as slash commands by the external plugin command router,
// which matches on the comment body starting with `/<command>`.
export function normalizeFreeformComment(body) {
const text = String(body ?? "").trim();
if (!text) throw new Error("Comment cannot be empty.");
if (text.length > MAX_COMMENT_LENGTH) {
throw new Error(`Comment is too long (${text.length} characters; maximum ${MAX_COMMENT_LENGTH}).`);
}
if (text.startsWith("/")) {
throw new Error(
"Comments starting with \"/\" could trigger issue automation. Use the Approve, Reject, or Request fixes actions instead, or reword the comment.",
);
}
return text;
}

// Builds the `/request-fixes <reason>` command handled by the external plugin command router. The
// router ignores the command without a reason, so one is required here too.
export function buildRequestFixesCommand(reason) {
const text = String(reason ?? "")
.trim()
.replace(/^\/request-fixes(?![\w-])/i, "")
.trim();
if (!text) throw new Error("Request fixes needs a description of what the submitter must change.");
if (text.length > MAX_COMMENT_LENGTH) {
throw new Error(`Comment is too long (${text.length} characters; maximum ${MAX_COMMENT_LENGTH}).`);
}
return `/request-fixes ${text}`;
}

export async function postComment(repo, number, body) {
const out = await runGh(["issue", "comment", String(number), "-R", repo, "--body-file", "-"], { input: body });
return out.trim();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { mapBoardIssues } from "./github.mjs";
import { MAX_COMMENT_LENGTH, buildRequestFixesCommand, mapBoardIssues, normalizeFreeformComment } from "./github.mjs";

test("request fixes builds the router command and requires a reason", () => {
assert.equal(buildRequestFixesCommand(" Pin a commit SHA.\n- Add a LICENSE "), "/request-fixes Pin a commit SHA.\n- Add a LICENSE");
assert.equal(buildRequestFixesCommand("/request-fixes Add a README"), "/request-fixes Add a README");
for (const reason of ["", " ", "/request-fixes", null]) {
assert.throws(() => buildRequestFixesCommand(reason), /needs a description/);
}
});

test("free-form comments are trimmed and posted as written", () => {
assert.equal(normalizeFreeformComment(" Could you add a README?\n/approve is not needed "),
"Could you add a README?\n/approve is not needed");
});

test("free-form comments reject empty bodies", () => {
for (const body of ["", " \n\t", null, undefined]) {
assert.throws(() => normalizeFreeformComment(body), /cannot be empty/);
}
});

test("free-form comments cannot start with a slash command", () => {
for (const body of ["/approve", " /reject nope", "/rerun-intake", "/mark-ready-for-review", "/anything"]) {
assert.throws(() => normalizeFreeformComment(body), /could trigger issue automation/);
}
});

test("free-form comments enforce a maximum length", () => {
assert.equal(normalizeFreeformComment("a".repeat(MAX_COMMENT_LENGTH)).length, MAX_COMMENT_LENGTH);
assert.throws(() => normalizeFreeformComment("a".repeat(MAX_COMMENT_LENGTH + 1)), /too long/);
});

test("board feed includes submissions in each active intake state", () => {
const issues = ["ready-for-review", "awaiting-review", "requires-submitter-fixes", "rejected"].map(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const POLL_DELAYS = [1000, 2000, 4000];

export function assertNoPendingFixesRequest(item, number) {
if (item.pendingFixesRequest) {
throw new Error(`#${number} has a fixes request awaiting workflow confirmation.`);
}
}

export async function confirmRequestFixes({ number, getIssueStatus, onConfirmed, refresh, sleep, log }) {
for (const delay of POLL_DELAYS) {
try {
await sleep(delay);
const status = await getIssueStatus(number);
if (!(status.labels ?? []).includes("requires-submitter-fixes")) continue;
await onConfirmed(status);
await refresh();
return true;
} catch (error) {
try {
log(error);
} catch {
// Confirmation is best-effort after GitHub accepts the comment.
}
return false;
}
}
return false;
}
24 changes: 24 additions & 0 deletions .github/extensions/external-plugin-review-board/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ export class BoardStore {
for (const [key, issue] of incoming) {
const existing = this.state.items[key];
if (existing) {
if (existing.pendingFixesRequest && issue.labels.includes("requires-submitter-fixes")) {
delete existing.pendingFixesRequest;
}
Object.assign(existing, {
title: issue.title,
author: issue.author,
Expand Down Expand Up @@ -320,6 +323,27 @@ export class BoardStore {
await this.commit();
}

async recordPendingFixesRequest(number, pendingFixesRequest) {
const item = this.getItem(number);
item.pendingFixesRequest = pendingFixesRequest;
await this.commit();
}

async resolvePendingFixesRequest(number, status) {
const item = this.state.items[String(number)];
if (!item?.pendingFixesRequest || !(status.labels ?? []).includes("requires-submitter-fixes")) return null;
const request = item.pendingFixesRequest;
delete item.pendingFixesRequest;
await this.commit();
return request;
}

async pendingFixesRequestNumbers() {
return Object.values(this.state.items)
.filter((item) => item.pendingFixesRequest && !item.decision)
.map((item) => item.number);
}

async resolvePendingDecision(number, status) {
const item = this.state.items[String(number)];
if (!item?.pendingDecision || status.state !== "CLOSED") return null;
Expand Down
Loading
Loading