diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index ecad4e883..728c1fd28 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -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 ` 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 ` 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. @@ -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. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 760803319..2e3d09247 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -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"; @@ -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); @@ -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(); } @@ -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.`); @@ -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 { @@ -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, })), }; } @@ -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. diff --git a/.github/extensions/external-plugin-review-board/lib/github.mjs b/.github/extensions/external-plugin-review-board/lib/github.mjs index bcca92e95..86a375f69 100644 --- a/.github/extensions/external-plugin-review-board/lib/github.mjs +++ b/.github/extensions/external-plugin-review-board/lib/github.mjs @@ -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 `/`. +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 ` 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(); diff --git a/.github/extensions/external-plugin-review-board/lib/github.test.mjs b/.github/extensions/external-plugin-review-board/lib/github.test.mjs index 5ac6dd2c2..94be74d29 100644 --- a/.github/extensions/external-plugin-review-board/lib/github.test.mjs +++ b/.github/extensions/external-plugin-review-board/lib/github.test.mjs @@ -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( diff --git a/.github/extensions/external-plugin-review-board/lib/request-fixes.mjs b/.github/extensions/external-plugin-review-board/lib/request-fixes.mjs new file mode 100644 index 000000000..ae02ca8f0 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/lib/request-fixes.mjs @@ -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; +} diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index 6d261265c..66bf59bce 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -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, @@ -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; diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js index 8764ad49a..e8d4e9c67 100644 --- a/.github/extensions/external-plugin-review-board/public/app.js +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -32,6 +32,8 @@ const ui = { tabs: [...document.querySelectorAll(".tab")], comment: document.getElementById("decision-comment"), preview: document.getElementById("decision-preview"), + commentPost: document.getElementById("decision-comment-post"), + requestFixes: document.getElementById("decision-request-fixes"), approve: document.getElementById("decision-approve"), reject: document.getElementById("decision-reject"), toasts: document.getElementById("toasts"), @@ -45,6 +47,7 @@ const details = new Map(); const pendingConfirm = { kind: null, timer: null }; const stagedComments = new Map(); let bulkBusy = false; +let decisionBusy = false; // ---------- helpers ---------- @@ -439,8 +442,6 @@ function renderDrawer() { ui.drawerFooter.hidden = Boolean(item.decision); updatePreview(); - ui.approve.disabled = bulkBusy || !item.reviewEligible || Boolean(item.pendingDecision); - ui.reject.disabled ||= bulkBusy || Boolean(item.pendingDecision); renderTab(item); } @@ -584,73 +585,173 @@ async function loadDetail(number, { force = false } = {}) { // ---------- decisions ---------- function commentBody(kind) { - const text = ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, ""); + if (kind === "comment") return ui.comment.value.trim(); + const text = reasonText(kind); if (kind === "approve") return text ? `/approve\n\n${text}` : "/approve"; + if (kind === "request-fixes") return text ? `/request-fixes ${text}` : "/request-fixes "; return text ? `/reject ${text}` : "/reject "; } +function reasonText(kind) { + return ui.comment.value.trim() + .replace(new RegExp(`^/${kind}(?![\\w-])\\s*`, "i"), "") + .trim(); +} + +// Free-form comments are posted as written, so they must not look like a slash command to the +// external plugin command router. +function freeformCommentError(text) { + if (!text) return "Write a comment before posting."; + if (text.startsWith("/")) { + return "Comments starting with “/” could trigger issue automation. Use Request fixes, Reject, or Approve instead, or reword the comment."; + } + return null; +} + function updatePreview() { const kind = pendingConfirm.kind; ui.preview.textContent = kind ? `Will post:\n${commentBody(kind)}` - : `Reject posts: ${commentBody("reject")}\nApprove posts: ${commentBody("approve").replace(/\n+/g, " ⏎ ")}`; - ui.reject.disabled = !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, ""); - ui.reject.title = ui.reject.disabled ? "Reject requires a reason." : ""; + : [ + `Request fixes posts: ${commentBody("request-fixes")}`, + `Reject posts: ${commentBody("reject")}`, + `Approve posts: ${commentBody("approve")}`, + ].map((line) => line.replace(/\n+/g, " ⏎ ")).join("\n"); + const item = findItem(selected); + const busy = bulkBusy || decisionBusy; + const pendingDecision = Boolean(item?.pendingDecision); + const pendingFixesRequest = Boolean(item?.pendingFixesRequest); + const hasReason = Boolean(reasonText("reject")); + ui.reject.disabled = busy || pendingDecision || pendingFixesRequest || !hasReason; + ui.reject.title = hasReason ? "" : "Reject requires a reason."; + const hasFixes = Boolean(reasonText("request-fixes")); + const awaitingFixes = item?.labels?.includes("requires-submitter-fixes"); + ui.requestFixes.disabled = busy || pendingDecision || pendingFixesRequest || awaitingFixes || !hasFixes; + ui.requestFixes.title = awaitingFixes + ? "Already waiting on submitter fixes." + : pendingFixesRequest + ? "A fixes request is awaiting workflow confirmation." + : hasFixes ? "" : "Request fixes requires a description of what to change."; + ui.approve.disabled = busy || pendingDecision || pendingFixesRequest || !item?.reviewEligible; + const commentProblem = freeformCommentError(ui.comment.value.trim()); + ui.commentPost.disabled = busy || Boolean(commentProblem); + ui.commentPost.title = commentProblem ?? ""; } function resetConfirm() { clearTimeout(pendingConfirm.timer); pendingConfirm.kind = null; - ui.approve.textContent = "Approve"; - ui.reject.textContent = "Reject"; - ui.approve.classList.remove("confirming"); - ui.reject.classList.remove("confirming"); - ui.approve.disabled = false; - ui.reject.disabled = false; + for (const [button, label] of [ + [ui.commentPost, "Comment"], + [ui.requestFixes, "Request fixes"], + [ui.reject, "Reject"], + [ui.approve, "Approve"], + ]) { + button.textContent = label; + button.classList.remove("confirming"); + button.disabled = false; + } updatePreview(); const item = findItem(selected); if (bulkBusy || item?.pendingDecision) { ui.approve.disabled = true; ui.reject.disabled = true; + ui.requestFixes.disabled = true; } + if (bulkBusy) ui.commentPost.disabled = true; if (!item?.reviewEligible) ui.approve.disabled = true; + if (item?.labels?.includes("requires-submitter-fixes")) ui.requestFixes.disabled = true; } async function decide(kind) { const item = findItem(selected); - if (!item || bulkBusy || item.pendingDecision) return; - if (kind === "reject" && !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "")) { - toast("Reject decisions require a reason.", { error: true }); + if (!item || bulkBusy || decisionBusy || (kind !== "comment" && item.pendingDecision)) return; + const problem = kind === "comment" + ? freeformCommentError(ui.comment.value.trim()) + : kind === "reject" && !reasonText("reject") + ? "Reject decisions require a reason." + : kind === "request-fixes" && !reasonText("request-fixes") + ? "Request fixes needs a description of what the submitter must change." + : null; + if (problem) { + toast(problem, { error: true }); updatePreview(); return; } + const buttons = { comment: ui.commentPost, "request-fixes": ui.requestFixes, reject: ui.reject, approve: ui.approve }; if (pendingConfirm.kind !== kind) { resetConfirm(); pendingConfirm.kind = kind; - const button = kind === "approve" ? ui.approve : ui.reject; - button.textContent = kind === "approve" ? "Confirm approve" : "Confirm reject"; - button.classList.add("confirming"); + buttons[kind].textContent = { + comment: "Confirm comment", + "request-fixes": "Confirm request fixes", + reject: "Confirm reject", + approve: "Confirm approve", + }[kind]; + buttons[kind].classList.add("confirming"); updatePreview(); pendingConfirm.timer = setTimeout(resetConfirm, 6000); return; } clearTimeout(pendingConfirm.timer); - ui.approve.disabled = true; - ui.reject.disabled = true; + decisionBusy = true; + updatePreview(); + const number = item.number; + const submittedDraft = ui.comment.value; try { - const result = await api("POST", "/api/decision", { number: item.number, kind, comment: ui.comment.value }); - toast( - result.pending - ? `Posted ${result.body.split("\n")[0]} on #${item.number}; waiting for workflow confirmation.` - : `Posted ${result.body.split("\n")[0]} on #${item.number}`, - { link: result.commentUrl }, - ); - if (selected === item.number) closeDrawer(); + if (kind === "comment") { + const result = await api("POST", "/api/comment", { number, body: submittedDraft.trim() }); + toast(`Posted comment on #${number}`, { link: result.commentUrl }); + clearSubmittedDraft(number, submittedDraft, ""); + if (selected === number) { + activeTab = "comments"; + renderDrawer(); + } + await loadDetail(number, { force: true }); + } else if (kind === "request-fixes") { + const result = await api("POST", "/api/request-fixes", { number, comment: submittedDraft }); + toast( + result.pending + ? `Posted /request-fixes on #${number}; waiting for the workflow to update labels.` + : `Requested fixes on #${number}`, + { link: result.commentUrl }, + ); + clearSubmittedDraft(number, submittedDraft); + if (selected === number) closeDrawer(); + } else { + const result = await api("POST", "/api/decision", { number, kind, comment: ui.comment.value }); + toast( + result.pending + ? `Posted ${result.body.split("\n")[0]} on #${number}; waiting for workflow confirmation.` + : `Posted ${result.body.split("\n")[0]} on #${number}`, + { link: result.commentUrl }, + ); + if (selected === number) closeDrawer(); + } } catch (error) { toast(error.message, { error: true }); } finally { - if (selected === item.number) resetConfirm(); + decisionBusy = false; + if (selected === number) resetConfirm(); + else updatePreview(); + } +} + +function clearSubmittedDraft(number, submittedDraft, replacement) { + const item = board.items.find((candidate) => candidate.number === number); + const currentDraft = selected === number + ? ui.comment.value + : stagedComments.has(number) + ? stagedComments.get(number) + : defaultComment(item); + if (currentDraft !== submittedDraft) return false; + + if (replacement === undefined) stagedComments.delete(number); + else stagedComments.set(number, replacement); + if (selected === number && ui.comment.value === submittedDraft) { + ui.comment.value = replacement ?? defaultComment(item); } + return true; } // ---------- toolbar ---------- @@ -767,6 +868,8 @@ ui.comment.addEventListener("input", () => { }); ui.approve.addEventListener("click", () => decide("approve")); ui.reject.addEventListener("click", () => decide("reject")); +ui.requestFixes.addEventListener("click", () => decide("request-fixes")); +ui.commentPost.addEventListener("click", () => decide("comment")); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && selected != null) { diff --git a/.github/extensions/external-plugin-review-board/public/index.html b/.github/extensions/external-plugin-review-board/public/index.html index 6528f9b03..f1603a1ef 100644 --- a/.github/extensions/external-plugin-review-board/public/index.html +++ b/.github/extensions/external-plugin-review-board/public/index.html @@ -65,16 +65,19 @@