diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index 1346e98f9..941fef6a2 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -28,6 +28,11 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external - **Details panel** shows the AI review, the rendered issue body, and all comments. - **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. +- **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. + Queued reviews, pending decisions, and completed actions are excluded. Failures are reported per + item without automatically retrying posted commands. ## State @@ -53,3 +58,5 @@ not imported; boards start fresh. Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` from the repository root 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. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 25866459b..d29de342b 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -23,6 +23,7 @@ const servers = new Map(); const detailCache = new Map(); let refreshInFlight = null; let session; +const decisionsInFlight = new Set(); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -69,7 +70,7 @@ async function getIssue(number, { force = false } = {}) { async function startReview({ numbers, instanceId, send }) { const items = store.pendingReview(numbers); - if (!items.length) return { queued: [], message: "Nothing to review — all items already have an AI review." }; + if (!items.length) return { queued: [], message: "Nothing to review — no eligible unreviewed items remain." }; const queued = items.map((item) => item.number); const snapshots = await store.markQueued(queued); const prompt = buildReviewPrompt({ @@ -96,6 +97,7 @@ const MAX_GUIDANCE_LENGTH = 4000; async function startRereview({ number, guidance, instanceId, send }) { const item = store.getItem(Number(number)); if (item.decision) throw new Error(`#${item.number} was already actioned (${item.decision.kind}).`); + if (item.pendingDecision) throw new Error(`#${item.number} has a posted command awaiting workflow confirmation.`); if (item.reviewStatus === "queued") throw new Error(`#${item.number} is already queued for review.`); if (!isReadyForApproval(item.labels ?? [])) { throw new Error(`#${item.number} is pending intake or is no longer ready-for-review.`); @@ -131,9 +133,20 @@ function stripCommand(text, command) { } async function postDecision({ number, kind, comment }) { + if (decisionsInFlight.has(number)) throw new Error(`#${number} already has a GitHub action in progress.`); + decisionsInFlight.add(number); + try { + return await postDecisionCommand({ number, kind, comment }); + } finally { + decisionsInFlight.delete(number); + } +} + +async function postDecisionCommand({ number, kind, comment }) { if (kind !== "approve" && kind !== "reject") throw new Error(`Unknown decision "${kind}".`); 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.`); const status = await gh.getIssueStatus(store.state.repo, number); if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`); diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index 9d5477f69..fdd419e22 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -195,6 +195,7 @@ export class BoardStore { (item) => isReadyForApproval(item.labels ?? []) && !item.decision && + !item.pendingDecision && item.reviewStatus !== "queued", ); if (numbers?.length) { diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js index 01429d1c5..c40cafa5a 100644 --- a/.github/extensions/external-plugin-review-board/public/app.js +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -41,6 +41,8 @@ let selected = null; let activeTab = "review"; const details = new Map(); const pendingConfirm = { kind: null, timer: null }; +const stagedComments = new Map(); +let bulkBusy = false; // ---------- helpers ---------- @@ -155,6 +157,7 @@ function pendingReviewCount() { (item) => item.reviewEligible && !item.decision && + !item.pendingDecision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued", ).length @@ -186,6 +189,7 @@ function renderBoard() { "section", { class: "column", dataset: { column: column.id }, "aria-label": column.title }, h("div", { class: "column-header" }, h("span", { class: "column-name" }, column.title), h("span", { class: "count" }, items.length)), + column.droppable ? renderColumnActions(column) : null, cards, ); if (column.droppable) wireDropTarget(col, column.id); @@ -198,6 +202,80 @@ function renderBoard() { } } +function columnItems(columnId, kind) { + return board.items.filter((item) => item.column === columnId && !item.decision && + !item.pendingDecision && item.reviewStatus !== "queued" && + (kind === "reject" || item.reviewEligible)); +} + +function stagedComment(item) { + return (stagedComments.get(item.number) ?? defaultComment(item)) + .trim().replace(/^\/(approve|reject)\b\s*/i, "").trim(); +} + +function renderColumnActions(column) { + return h("div", { class: "column-actions", role: "group", "aria-label": `${column.title} bulk actions` }, + ["approve", "reject", "rereview"].map((kind) => { + const count = columnItems(column.id, kind).length; + return h("button", { + type: "button", class: "btn btn-small", disabled: bulkBusy || !count, + title: `Apply to all ${count} eligible items in this column, including hidden search results`, + onclick: () => bulkAction(column.id, kind), + }, `${kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"} all`); + })); +} + +async function bulkAction(columnId, kind) { + if (bulkBusy) return; + const items = columnItems(columnId, kind).map((item) => ({ + number: item.number, comment: stagedComment(item), + })); + if (!items.length) return; + if (kind === "reject") { + const missing = items.filter((item) => !item.comment.trim().replace(/^\/(approve|reject)\b\s*/i, "").trim()); + if (missing.length) { + toast(`Stage a rejection reason for ${missing.map((item) => `#${item.number}`).join(", ")} before rejecting this column.`, { error: true }); + return; + } + } + const preview = items.map((item) => `#${item.number}${kind === "rereview" ? "" : `: ${ + kind === "approve" ? "/approve" : "/reject"} ${item.comment}`}`).join("\n\n"); + const label = kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"; + if (!confirm(`${label} all ${items.length} items in this column (including hidden search results)?\n${ + kind === "rereview" ? "Queues AI reviews; GitHub is not changed." : "Posts each item's staged comment to GitHub."}\n\n${preview}`)) return; + bulkBusy = true; + renderBoard(); + renderDrawer(); + let completed = 0; + const failures = []; + try { + if (kind === "rereview") { + const result = await api("POST", "/api/review", { numbers: items.map((item) => item.number) }); + toast(result.message); + return; + } + for (const item of items) { + try { + if (!columnItems(columnId, kind).some((current) => current.number === item.number)) { + throw new Error("Item moved, was queued, or was already actioned; review it again."); + } + await api("POST", "/api/decision", { ...item, kind }); + completed++; + } catch (error) { + failures.push(`#${item.number}: ${error.message}`); + } + } + toast(`${label}: ${completed}/${items.length} commands posted.${failures.length ? ` Failed: ${failures.join(" ")}` : ""}`, + { error: failures.length > 0 }); + } catch (error) { + toast(error.message, { error: true }); + } finally { + bulkBusy = false; + renderBoard(); + renderDrawer(); + } +} + function renderCard(item) { const draggable = !item.decision; const card = h( @@ -279,7 +357,7 @@ function openDrawer(number) { resetConfirm(); hideRereview(); const item = findItem(number); - ui.comment.value = defaultComment(item); + ui.comment.value = stagedComment(item); ui.drawer.classList.add("open"); ui.drawer.setAttribute("aria-hidden", "false"); renderDrawer(); @@ -320,7 +398,8 @@ function renderDrawer() { .map((column) => h("option", { value: column.id, selected: column.id === item.column }, column.title)); ui.drawerMove.replaceChildren(...options); ui.drawerMove.disabled = Boolean(item.decision); - ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) || item.reviewStatus === "queued"; + ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) || + Boolean(item.pendingDecision) || item.reviewStatus === "queued"; if (ui.drawerRereview.disabled) hideRereview(); for (const tab of ui.tabs) { @@ -333,6 +412,8 @@ function renderDrawer() { ui.drawerFooter.hidden = Boolean(item.decision); updatePreview(); + ui.approve.disabled = bulkBusy || Boolean(item.pendingDecision); + ui.reject.disabled ||= bulkBusy || Boolean(item.pendingDecision); renderTab(item); } @@ -498,11 +579,16 @@ function resetConfirm() { ui.approve.disabled = false; ui.reject.disabled = false; updatePreview(); + const item = findItem(selected); + if (bulkBusy || item?.pendingDecision) { + ui.approve.disabled = true; + ui.reject.disabled = true; + } } async function decide(kind) { const item = findItem(selected); - if (!item) return; + 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 }); updatePreview(); @@ -641,6 +727,7 @@ for (const tab of ui.tabs) { } ui.comment.addEventListener("input", () => { + if (selected != null) stagedComments.set(selected, ui.comment.value); if (pendingConfirm.kind) resetConfirm(); else updatePreview(); }); diff --git a/.github/extensions/external-plugin-review-board/public/styles.css b/.github/extensions/external-plugin-review-board/public/styles.css index f36afadb2..db726fbef 100644 --- a/.github/extensions/external-plugin-review-board/public/styles.css +++ b/.github/extensions/external-plugin-review-board/public/styles.css @@ -228,6 +228,13 @@ h2 { gap: 8px; } +.column-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 12px 8px; +} + .column-name::before { content: ""; width: 8px; diff --git a/.github/extensions/open-pr-board/README.md b/.github/extensions/open-pr-board/README.md index 225d2874e..c2648c8bf 100644 --- a/.github/extensions/open-pr-board/README.md +++ b/.github/extensions/open-pr-board/README.md @@ -21,6 +21,12 @@ authenticated `gh` CLI. PRs with submitted feedback or requested changes return to Unreviewed after new commits; the previous action remains in history. - **Re-review** — ask for a fresh pass on one PR with optional focus guidance. +- **Column actions** — approve + auto-merge, request changes (close in the Close column), or + re-review every eligible PR, including cards hidden by search. Confirmation previews the exact + staged comments; requests for changes and bulk closes require feedback on every PR. + Drawer edits remain staged when switching cards in the current browser session. Queued and + actioned PRs are excluded; individual errors and partial-success warnings are surfaced without + automatic retries. - **Labels** — GitHub labels render on every card in their own colours, blended toward the app theme so they stay readable in both light and dark mode. - **Detail drawer** — AI review, description, conversation, changed files, and checks. @@ -74,7 +80,7 @@ touching GitHub. The agent reads [`review-guidance.md`](./review-guidance.md), which layers PR-specific procedure on top of `.github/skills/code-review/SKILL.md` and `.github/copilot-instructions.md`. AI evidence gathering is read-only: the agent never -edits files or runs write commands against the PR. Only an explicitly confirmed drawer +edits files or runs write commands against the PR. Only an explicitly confirmed drawer or column action writes to GitHub. Review results require the exact queue token from the current review request. @@ -84,5 +90,7 @@ Results arriving after a manual move, reset, or replacement review are rejected. Run `node --test eng/open-pr-board.test.mjs` from the repository root. GitHub writes are tested with injected in-process fakes, never the authenticated CLI. +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 --experimental-vm-modules --test eng/board-state-storage.test.mjs` to check both canvases' session-scoped persistence without accessing GitHub. diff --git a/.github/extensions/open-pr-board/extension.mjs b/.github/extensions/open-pr-board/extension.mjs index b51eb98e5..fd717c7e3 100644 --- a/.github/extensions/open-pr-board/extension.mjs +++ b/.github/extensions/open-pr-board/extension.mjs @@ -113,10 +113,10 @@ function pendingReview(store, numbers) { const snapshot = store.snapshot(); if (Array.isArray(numbers) && numbers.length) { const wanted = new Set(numbers.map(Number)); - return snapshot.items.filter((item) => wanted.has(item.number) && !item.decision); + return snapshot.items.filter((item) => wanted.has(item.number) && !item.decision && item.reviewStatus !== "queued"); } // Default pass: anything never reviewed, plus reviews invalidated by new commits. - return snapshot.items.filter((item) => !item.decision && (!item.review || item.reviewStale)); + return snapshot.items.filter((item) => !item.decision && item.reviewStatus !== "queued" && (!item.review || item.reviewStale)); } async function startReview({ store, numbers, instanceId, send }) { diff --git a/.github/extensions/open-pr-board/lib/github.mjs b/.github/extensions/open-pr-board/lib/github.mjs index 1ed8a533a..d3c404057 100644 --- a/.github/extensions/open-pr-board/lib/github.mjs +++ b/.github/extensions/open-pr-board/lib/github.mjs @@ -187,14 +187,39 @@ const LIST_FIELDS = [ "additions", "deletions", "changedFiles", - "statusCheckRollup", "reviewDecision", "mergeStateStatus", ].join(","); -export async function listOpenPullRequests(repo) { - const out = await runGh(["pr", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", LIST_FIELDS]); - return JSON.parse(out).map(normalizePullRequest); +const CHECK_FETCH_CONCURRENCY = 6; + +async function mapWithConcurrency(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +// statusCheckRollup is fetched per PR because requesting it in the bulk list query +// makes GitHub's GraphQL API time out (HTTP 504) on repos with many open PRs. +export async function listOpenPullRequests(repo, runner = runGh) { + const out = await runner(["pr", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", LIST_FIELDS]); + const pullRequests = JSON.parse(out); + await mapWithConcurrency(pullRequests, CHECK_FETCH_CONCURRENCY, async (pr) => { + try { + const raw = await runner(["pr", "view", String(pr.number), "-R", repo, "--json", "statusCheckRollup"]); + pr.statusCheckRollup = JSON.parse(raw).statusCheckRollup; + } catch { + pr.statusCheckRollup = []; + } + }); + return pullRequests.map(normalizePullRequest); } const FULL_JSON = ["-H", "Accept: application/vnd.github.full+json"]; diff --git a/.github/extensions/open-pr-board/public/app.js b/.github/extensions/open-pr-board/public/app.js index b28e2b910..a3004d39c 100644 --- a/.github/extensions/open-pr-board/public/app.js +++ b/.github/extensions/open-pr-board/public/app.js @@ -42,6 +42,8 @@ let pendingConfirm = null; let detailGeneration = 0; let drawerOriginNumber = null; const decisionsInFlight = new Set(); +const stagedComments = new Map(); +let bulkBusy = false; const el = { title: document.getElementById("board-title"), @@ -315,6 +317,7 @@ function renderBoard() { h("span", { class: "column-name" }, column.label), h("span", { class: "count" }, String(items.length)), ), + column.droppable ? renderColumnActions(column) : null, list, ); }); @@ -339,6 +342,84 @@ function renderBoard() { el.review.disabled = pending === 0; } +function columnItems(columnId) { + return board.items.filter((item) => item.column === columnId && !item.decision && + item.reviewStatus !== "queued" && !decisionsInFlight.has(item.number)); +} + +function stagedComment(item) { + return stagedComments.get(item.number) ?? item.review?.suggestedComment ?? ""; +} + +function renderColumnActions(column) { + const count = columnItems(column.id).length; + const rejectKind = column.id === "close" ? "close" : "request-changes"; + const actions = [["approve", "Approve + auto-merge"], [rejectKind, DECISION_LABELS[rejectKind]], ["rereview", "Re-review"]]; + return h("div", { class: "column-actions", role: "group", "aria-label": `${column.label} bulk actions` }, + actions.map(([kind, label]) => h("button", { + type: "button", class: "btn btn-small", disabled: bulkBusy || !count, + title: `Apply to all ${count} eligible PRs in this column, including hidden search results`, + onclick: () => bulkAction(column.id, kind), + }, `${label} all`))); +} + +async function bulkAction(columnId, kind) { + if (bulkBusy) return; + const items = columnItems(columnId).map((item) => ({ + number: item.number, headRefOid: item.headRefOid, comment: stagedComment(item), + })); + if (!items.length) return; + if (kind === "close" || kind === "request-changes") { + const missing = items.filter((item) => !item.comment.trim()); + if (missing.length) { + toast(`Stage feedback for ${missing.map((item) => `#${item.number}`).join(", ")} before actioning this column.`, { error: true }); + return; + } + } + const label = kind === "rereview" ? "Re-review" : DECISION_LABELS[kind]; + const preview = items.map((item) => `#${item.number}${kind === "rereview" ? "" : `: ${item.comment || "(no comment)"}`}`).join("\n\n"); + if (!confirm(`${label} all ${items.length} PRs in this column (including hidden search results)?\n${ + kind === "rereview" ? "Queues AI reviews; GitHub is not changed." : kind === "approve" ? + "Submits approvals and enables auto-merge with a merge commit." : "Uses each PR's staged comment on GitHub."}\n\n${preview}`)) return; + bulkBusy = true; + renderBoard(); + let completed = 0; + const failures = []; + try { + if (kind === "rereview") { + const result = await api("/api/review", { method: "POST", body: { numbers: items.map((item) => item.number) } }); + toast(result.message); + return; + } + for (const item of items) { + try { + const current = columnItems(columnId).find((current) => current.number === item.number); + if (!current || current.headRefOid !== item.headRefOid) { + throw new Error("PR moved, changed, or was already actioned; review it again."); + } + decisionsInFlight.add(item.number); + const result = await api("/api/decision", { + method: "POST", body: { number: item.number, kind, comment: item.comment }, + }); + completed++; + if (result.warning) failures.push(`#${item.number}: ${result.warning}`); + } catch (error) { + failures.push(`#${item.number}: ${error.message}`); + } finally { + decisionsInFlight.delete(item.number); + } + } + toast(`${label}: ${completed}/${items.length} actions submitted.${failures.length ? ` Attention needed: ${failures.join(" ")}` : ""}`, + { error: failures.length > 0 }); + } catch (error) { + toast(error.message, { error: true }); + } finally { + bulkBusy = false; + renderBoard(); + if (currentItem()) renderDrawerHeader(); + } +} + /* ------------------------------------------------------------------ drawer */ function reviewTab(item) { @@ -541,7 +622,7 @@ function renderDrawerHeader() { el.rereviewToggle.disabled = Boolean(item.decision) || item.reviewStatus === "queued"; for (const button of el.drawer.querySelectorAll("[data-decision]")) { - button.disabled = Boolean(item.decision) || decisionsInFlight.has(item.number); + button.disabled = bulkBusy || Boolean(item.decision) || decisionsInFlight.has(item.number); button.classList.remove("confirming"); button.textContent = DECISION_BUTTONS[button.dataset.decision]; } @@ -580,7 +661,7 @@ function openDrawer(number) { el.drawer.setAttribute("aria-hidden", "false"); el.rereviewPanel.hidden = true; if (changed) { - el.comment.value = item.review?.suggestedComment ?? ""; + el.comment.value = stagedComment(item); detail = null; detailError = null; } @@ -634,7 +715,7 @@ function armConfirm(button, kind) { async function submitDecision(button, kind) { const item = currentItem(); - if (!item || decisionsInFlight.has(item.number)) return; + if (!item || bulkBusy || decisionsInFlight.has(item.number)) return; if (!armConfirm(button, kind)) return; clearTimeout(pendingConfirm.timer); pendingConfirm = null; @@ -784,6 +865,13 @@ for (const button of el.drawer.querySelectorAll("[data-decision]")) { button.addEventListener("click", () => submitDecision(button, button.dataset.decision)); } +el.comment.addEventListener("input", () => { + if (selectedNumber !== null) stagedComments.set(selectedNumber, el.comment.value); + clearTimeout(pendingConfirm?.timer); + pendingConfirm = null; + if (currentItem()) renderDrawerHeader(); +}); + function applySnapshot(snapshot) { board = snapshot; renderBoard(); diff --git a/.github/extensions/open-pr-board/public/styles.css b/.github/extensions/open-pr-board/public/styles.css index 40d7effaa..da3c7686e 100644 --- a/.github/extensions/open-pr-board/public/styles.css +++ b/.github/extensions/open-pr-board/public/styles.css @@ -238,6 +238,13 @@ h2 { gap: 8px; } +.column-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 12px 8px; +} + .column-name::before { content: ""; width: 8px; diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 9840528c1..2ca81c7ce 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -433,8 +433,8 @@ }, { "name": "cache-stats", - "description": "Shows live prompt-cache reuse, expiry, token composition, and AIU cost for the current GitHub Copilot session without making model calls.", - "version": "1.0.1", + "description": "Shows live prompt-cache reuse, expiry, token composition, and AI Credit cost for the current GitHub Copilot session without making model calls.", + "version": "1.0.2", "author": { "name": "Arafat Tehsin", "url": "https://github.com/arafattehsin" @@ -443,6 +443,7 @@ "homepage": "https://github.com/arafattehsin/cache-stats-extension", "license": "MIT", "keywords": [ + "ai-credits", "aiu", "cache", "canvas", @@ -454,8 +455,8 @@ "source": { "source": "github", "repo": "arafattehsin/cache-stats-extension", - "ref": "v1.0.1", - "sha": "678c3468977889d5c50a0a5f33898b8d9638523a" + "ref": "v1.0.2", + "sha": "a6d6e0c2cb3c4309794dcc712cd14906b2b297db" } }, { @@ -997,8 +998,8 @@ }, { "name": "excel-cli", - "description": "Windows-only Excel automation plugin that runs the latest excelcli package through npx.", - "version": "2.2.1", + "description": "Let your coding agent work with real Microsoft Excel workbooks from the terminal: formulas, Power Query, PivotTables, charts, and VBA. Same features as Excel MCP, usually with fewer AI tokens. Requires Windows, desktop Excel 2016 or later, and Node.js 18 or later.", + "version": "2.3.11", "author": { "name": "Stefan Brönner", "url": "https://github.com/sbroenne" @@ -1011,25 +1012,25 @@ "windows", "cli", "automation", - "scripting", - "batch", + "formulas", "power-query", "dax", "pivottable", - "ci-cd" + "charts", + "vba" ], "source": { "source": "github", "repo": "sbroenne/mcp-server-excel-plugins", "path": "plugins/excel-cli", - "ref": "v2.2.1", - "sha": "8941e5d59a068e421ddbddf40107c6f505f5acac" + "ref": "v2.3.11", + "sha": "0057e645ff3620206e440bd313b8b26f0be943c0" } }, { "name": "excel-mcp", - "description": "Windows-only Excel automation plugin that runs the latest ExcelMcp MCP server through npx.", - "version": "2.2.1", + "description": "Let an AI assistant create and update Microsoft Excel workbooks: work with cells, formulas, Power Query, PivotTables, charts, and VBA in the desktop app. Requires Windows, desktop Excel 2016 or later, and Node.js 18 or later.", + "version": "2.3.11", "author": { "name": "Stefan Brönner", "url": "https://github.com/sbroenne" @@ -1041,18 +1042,20 @@ "excel", "windows", "mcp", + "automation", + "formulas", "power-query", "dax", "pivottable", - "vba", - "com-interop" + "charts", + "vba" ], "source": { "source": "github", "repo": "sbroenne/mcp-server-excel-plugins", "path": "plugins/excel-mcp", - "ref": "v2.2.1", - "sha": "8941e5d59a068e421ddbddf40107c6f505f5acac" + "ref": "v2.3.11", + "sha": "0057e645ff3620206e440bd313b8b26f0be943c0" } }, { @@ -1265,7 +1268,7 @@ { "name": "incident-io", "description": "Official incident.io plugins for AI agents: skills for working with [incident.io](https://incident.io/) — incidents, on-call, and investigations — and for authoring the operational content our platform draws on.", - "version": "0.18.0", + "version": "1.20261009.666", "author": { "name": "incident.io", "url": "https://incident.io" @@ -1285,7 +1288,7 @@ "source": "github", "repo": "incident-io/skills", "path": "plugins/incident-io", - "sha": "6d0bbfc9e52256fc39326b9ba6d08f16831b60ae" + "sha": "52f1c60db6d59b2e4e6abd327d5107d3bff0f53d" } }, { @@ -1315,7 +1318,7 @@ { "name": "keep-the-why", "description": "Preserves or recovers the reasoning behind a codebase — architectural decisions, rejected alternatives, workarounds, incident learnings, operational constraints, and historical context the code itself cannot explain.", - "version": "0.20.1", + "version": "0.20.2", "author": { "name": "Oliver Zehentleitner", "url": "https://github.com/oliver-zehentleitner" @@ -1334,7 +1337,8 @@ "source": { "source": "github", "repo": "oliver-zehentleitner/keep-the-why", - "ref": "v0.20.1" + "ref": "v0.20.2", + "sha": "4c14d0d170789d54af00307bdbb5f38a3312d3a6" } }, { @@ -2316,7 +2320,7 @@ { "name": "upgrade-agent", "description": "AI-powered upgrade assistant for upgrading and migrating applications. Helps modernize legacy code and upgrade .NET applications to current frameworks.", - "version": "1.1.652", + "version": "1.1.685", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" @@ -2335,8 +2339,8 @@ "source": "github", "repo": "microsoft/upgrade-agent-plugins", "path": "plugins/upgrade-agent", - "ref": "v1.1.652", - "sha": "f2f23675833495ecee9858cb4ce7b089fe9cf491" + "ref": "v1.1.685", + "sha": "90c7fe3beb38660a266685a417b6ff75300abe9b" } }, { @@ -2460,6 +2464,36 @@ "sha": "7c4e67e320c82600e9fb7d8786ea3a9927039b72" } }, + { + "name": "windbg", + "description": "WinDbg diagnosis workflows for native Windows applications, services, UMDF/user-mode drivers, and kernel-mode drivers. Covers crashes, hangs, heap corruption, memory exhaustion, TTD, bugchecks, Driver Verifier, IRP lifecycle, and kernel deadlocks, with an evidence-first method and report validation.", + "version": "1.0.0", + "author": { + "name": "Microsoft", + "url": "https://github.com/microsoft/WinDbg-Feedback" + }, + "repository": "https://github.com/microsoft/win-dev-skills", + "homepage": "https://github.com/microsoft/WinDbg-Feedback", + "license": "MIT", + "keywords": [ + "windows", + "windbg", + "debugging", + "crash-dump", + "hang", + "memory", + "drivers", + "kernel", + "umdf", + "ttd" + ], + "source": { + "source": "github", + "repo": "microsoft/win-dev-skills", + "path": "plugins/windbg", + "sha": "5ce74fa89c49ca353c6ab03bd5889aa519aa9e5e" + } + }, { "name": "windows-app-storage-inspector-cleanup", "source": "plugins/windows-app-storage-inspector-cleanup", diff --git a/eng/board-bulk-actions.test.mjs b/eng/board-bulk-actions.test.mjs new file mode 100644 index 000000000..195b911da --- /dev/null +++ b/eng/board-bulk-actions.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import { test } from "node:test"; + +async function createUI(boardName, items, { confirm = true, request } = {}) { + const nodes = new Map(); + const node = () => ({ + content: "", value: "", dataset: {}, + listeners: {}, + addEventListener(name, handler) { this.listeners[name] = handler; }, + setAttribute() {}, + querySelectorAll: () => [], + }); + const document = { + querySelector: node, + querySelectorAll: () => [], + getElementById(id) { + if (!nodes.has(id)) nodes.set(id, node()); + return nodes.get(id); + }, + addEventListener() {}, + }; + const calls = [], messages = [], confirmations = []; + const context = vm.createContext({ + document, setTimeout, clearTimeout, + confirm: (text) => { confirmations.push(text); return confirm; }, + }); + const source = await readFile(new URL(`../.github/extensions/${boardName}/public/app.js`, import.meta.url), "utf8"); + const end = boardName === "open-pr-board" ? "const events = new EventSource" : "function connect()"; + vm.runInContext(source.slice(0, source.indexOf(end)), context); + context.items = items; + context.request = async (...args) => { + calls.push(args); + return request ? request(...args) : { message: "Queued", status: "submitted" }; + }; + context.notify = (text, options) => messages.push({ text, options }); + vm.runInContext(` + board = { items, columns: [] }; + renderBoard = renderDrawer = renderDrawerHeader = () => {}; + toast = notify; + api = request; + `, context); + return { context, calls, messages, confirmations, nodes }; +} + +for (const boardName of ["open-pr-board", "external-plugin-review-board"]) { + const isPR = boardName === "open-pr-board"; + const reject = isPR ? "request-changes" : "reject"; + const item = (number, extra = {}) => ({ + number, column: "test", headRefOid: "first", + review: { suggestedComment: isPR ? `Feedback ${number}` : `/reject Feedback ${number}` }, + ...(!isPR ? { reviewEligible: true } : {}), + ...extra, + }); + const payload = (args) => isPR ? args[1].body : args[2]; + + test(`${boardName}: bulk rejection uses each staged comment, including hidden search results`, async () => { + const ui = await createUI(boardName, [item(1), item(2)]); + vm.runInContext('stagedComments.set(1, "Edited feedback"); filterText = filter = "no matches";', ui.context); + await ui.context.bulkAction("test", reject); + assert.deepEqual(ui.calls.map(args => ({ ...payload(args) })), [ + { number: 1, kind: reject, comment: "Edited feedback" }, + { number: 2, kind: reject, comment: "Feedback 2" }, + ]); + assert.match(ui.confirmations[0], /Edited feedback/); + assert.match(ui.confirmations[0], /hidden search results/); + }); + + test(`${boardName}: missing feedback blocks the entire batch before confirmation`, async () => { + const ui = await createUI(boardName, [item(1), item(2, { review: null })]); + await ui.context.bulkAction("test", reject); + assert.equal(ui.calls.length, 0); + assert.equal(ui.confirmations.length, 0); + assert.match(ui.messages[0].text, /#2/); + }); + + test(`${boardName}: cancelling confirmation never calls an API`, async () => { + const ui = await createUI(boardName, [item(1)], { confirm: false }); + await ui.context.bulkAction("test", "approve"); + assert.equal(ui.calls.length, 0); + }); + + test(`${boardName}: rereview sends one batch and excludes queued and actioned items`, async () => { + const items = [item(1), item(2), item(3, { reviewStatus: "queued" }), item(4, { decision: { kind: "approve" } })]; + if (!isPR) items.push(item(5, { pendingDecision: { kind: "reject" } })); + const ui = await createUI(boardName, items); + await ui.context.bulkAction("test", "rereview"); + assert.equal(ui.calls.length, 1); + assert.deepEqual([...payload(ui.calls[0]).numbers], [1, 2]); + assert.match(ui.confirmations[0], /GitHub is not changed/); + }); + + test(`${boardName}: individual failures are reported and successful items are never retried`, async () => { + const ui = await createUI(boardName, [item(1), item(2)], { + request: async (...args) => { + if (payload(args).number === 1) throw new Error("Offline"); + return { warning: isPR ? "Approval posted but auto-merge failed" : null }; + }, + }); + await ui.context.bulkAction("test", "approve"); + assert.equal(ui.calls.length, 2); + assert.match(ui.messages.at(-1).text, /1\/2/); + assert.match(ui.messages.at(-1).text, /#1: Offline/); + if (isPR) assert.match(ui.messages.at(-1).text, /auto-merge failed/); + assert.equal(ui.messages.at(-1).options.error, true); + }); + + test(`${boardName}: an in-flight batch cannot be submitted twice`, async () => { + let release; + const waiting = new Promise(resolve => { release = resolve; }); + const ui = await createUI(boardName, [item(1)], { + request: async () => { await waiting; return {}; }, + }); + const first = ui.context.bulkAction("test", "approve"); + await ui.context.bulkAction("test", "approve"); + assert.equal(ui.calls.length, 1); + release(); + await first; + }); + + test(`${boardName}: draft edits are kept when switching cards`, async () => { + const ui = await createUI(boardName, [item(1)]); + vm.runInContext(isPR ? "selectedNumber = 1" : "selected = 1", ui.context); + ui.nodes.get("decision-comment").value = "My staged reason"; + ui.nodes.get("decision-comment").listeners.input(); + assert.equal(ui.context.stagedComment(ui.context.items[0]), "My staged reason"); + }); +} + +test("external-plugin-review-board: pending review count excludes posted decisions", async () => { + const ui = await createUI("external-plugin-review-board", [ + { number: 1, reviewEligible: true, reviewStatus: "unreviewed" }, + { number: 2, reviewEligible: true, reviewStatus: "unreviewed", pendingDecision: { kind: "approve" } }, + { number: 3, reviewStatus: "reviewed" }, + { number: 4, reviewStatus: "queued" }, + { number: 5, decision: { kind: "reject" } }, + { number: 6, reviewEligible: false, reviewStatus: "unreviewed" }, + ]); + + assert.equal(ui.context.pendingReviewCount(), 1); +}); + +for (const kind of ["approve", "rereview"]) { + test(`external-plugin-review-board: bulk ${kind} excludes pending intake cards`, async () => { + const ui = await createUI("external-plugin-review-board", [ + { number: 1, column: "test", reviewEligible: true }, + { number: 2, column: "test", reviewEligible: false }, + ]); + + await ui.context.bulkAction("test", kind); + + assert.equal(ui.calls.length, 1); + if (kind === "rereview") { + assert.deepEqual([...ui.calls[0][2].numbers], [1]); + } else { + assert.equal(ui.calls[0][2].number, 1); + } + assert.doesNotMatch(ui.confirmations[0], /#2/); + }); +} + +test("PR bulk close requires and posts the staged reason", async () => { + const ui = await createUI("open-pr-board", [{ number: 1, column: "close", review: { suggestedComment: "Duplicate" } }]); + await ui.context.bulkAction("close", "close"); + assert.deepEqual({ ...ui.calls[0][1].body }, { number: 1, kind: "close", comment: "Duplicate" }); +}); + +test("PR changed during a batch is skipped, not approved at a new commit", async () => { + const items = [1, 2].map(number => ({ number, column: "approve", headRefOid: "first" })); + const ui = await createUI("open-pr-board", items, { + request: async () => { items[1].headRefOid = "second"; return {}; }, + }); + await ui.context.bulkAction("approve", "approve"); + assert.equal(ui.calls.length, 1); + assert.match(ui.messages.at(-1).text, /#2: PR moved, changed/); +}); diff --git a/eng/external-plugin-review-board.test.mjs b/eng/external-plugin-review-board.test.mjs new file mode 100644 index 000000000..dde506671 --- /dev/null +++ b/eng/external-plugin-review-board.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; +import { test } from "node:test"; +import { BoardStore } from "../.github/extensions/external-plugin-review-board/lib/state.mjs"; + +test("external-plugin-review-board excludes items with pending decisions from AI review", () => { + const store = new BoardStore("unused", "github/awesome-copilot"); + store.state.items = { + "1": { number: 1, labels: ["ready-for-review"], reviewStatus: "unreviewed" }, + "2": { number: 2, labels: ["ready-for-review"], reviewStatus: "unreviewed", pendingDecision: { kind: "approve" } }, + "3": { number: 3, reviewStatus: "queued" }, + "4": { number: 4, reviewStatus: "unreviewed", decision: { kind: "reject" } }, + }; + + assert.deepEqual(store.pendingReview().map(({ number }) => number), [1]); + assert.deepEqual(store.pendingReview([1, 2, 3, 4]).map(({ number }) => number), [1]); +}); + +test("resetConfirm keeps decision buttons disabled while a decision is pending", async () => { + const source = await readFile( + new URL("../.github/extensions/external-plugin-review-board/public/app.js", import.meta.url), + "utf8", + ); + const start = source.indexOf("function resetConfirm()"); + const end = source.indexOf("async function decide(", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + const button = () => ({ + disabled: false, + textContent: "", + classList: { remove() {} }, + }); + const context = vm.createContext({ + clearTimeout() {}, + pendingConfirm: { timer: null, kind: "approve" }, + ui: { approve: button(), reject: button() }, + bulkBusy: false, + selected: 42, + findItem: () => ({ pendingDecision: { kind: "approve" } }), + updatePreview() {}, + }); + vm.runInContext(source.slice(start, end), context); + + context.resetConfirm(); + + assert.equal(context.ui.approve.disabled, true); + assert.equal(context.ui.reject.disabled, true); +}); diff --git a/eng/open-pr-board.test.mjs b/eng/open-pr-board.test.mjs index b6d5e4f8e..151b3ec50 100644 --- a/eng/open-pr-board.test.mjs +++ b/eng/open-pr-board.test.mjs @@ -6,7 +6,7 @@ import vm from "node:vm"; import { test } from "node:test"; import { BoardStore } from "../.github/extensions/open-pr-board/lib/state.mjs"; import { applyDecisionOnGitHub } from "../.github/extensions/open-pr-board/lib/decisions.mjs"; -import { approveAndEnableAutoMerge } from "../.github/extensions/open-pr-board/lib/github.mjs"; +import { approveAndEnableAutoMerge, listOpenPullRequests } from "../.github/extensions/open-pr-board/lib/github.mjs"; import { REVIEW_FIELDS_SCHEMA } from "../.github/extensions/open-pr-board/lib/review-prompt.mjs"; const pr = (headRefOid = "first") => ({ @@ -59,6 +59,50 @@ test("approval and auto-merge are pinned to the reviewed commit", async () => { assert.deepEqual(calls[2].args, ["pr", "merge", "1", "-R", "example/repo", "--auto", "--merge", "--match-head-commit", headRefOid]); }); +test("open pull requests fetch checks with bounded concurrency, preserve order, and tolerate a failed check request", async () => { + const numbers = [41, 7, 23, 4, 99, 12, 68, 51]; + let active = 0; + let peak = 0; + const requested = []; + const runner = async (args) => { + if (args[1] === "list") { + return JSON.stringify(numbers.map((number) => ({ number, title: `PR ${number}`, labels: [] }))); + } + if (args[1] === "view") { + const number = Number(args[2]); + requested.push(number); + active++; + peak = Math.max(peak, active); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (number === 23) throw new Error("check request failed"); + return JSON.stringify({ + statusCheckRollup: [{ name: `check-${number}`, status: "COMPLETED", conclusion: "SUCCESS" }], + }); + } finally { + active--; + } + } + throw new Error(`Unexpected gh command: ${args.join(" ")}`); + }; + + const pullRequests = await listOpenPullRequests("example/repo", runner); + + assert.ok(peak <= 6, "check requests should not exceed the six-request limit"); + assert.ok(peak > 1, "check requests should run concurrently"); + assert.deepEqual(pullRequests.map(({ number }) => number), numbers, "results should preserve list order"); + assert.deepEqual(requested.sort((a, b) => a - b), [...numbers].sort((a, b) => a - b), "every PR should request checks"); + for (const pullRequest of pullRequests) { + if (pullRequest.number === 23) { + assert.deepEqual(pullRequest.checks, []); + assert.equal(pullRequest.checksState, "none"); + } else { + assert.deepEqual(pullRequest.checks.map(({ name }) => name), [`check-${pullRequest.number}`]); + assert.equal(pullRequest.checksState, "success"); + } + } +}); + test("approval refuses a missing SHA, changed head, or closed pull request without writing", async () => { let calls = 0; const runner = async () => { calls++; throw new Error("must not run"); }; diff --git a/plugins/external.json b/plugins/external.json index 630ed040e..4162dd52e 100644 --- a/plugins/external.json +++ b/plugins/external.json @@ -345,8 +345,8 @@ }, { "name": "cache-stats", - "description": "Shows live prompt-cache reuse, expiry, token composition, and AIU cost for the current GitHub Copilot session without making model calls.", - "version": "1.0.1", + "description": "Shows live prompt-cache reuse, expiry, token composition, and AI Credit cost for the current GitHub Copilot session without making model calls.", + "version": "1.0.2", "author": { "name": "Arafat Tehsin", "url": "https://github.com/arafattehsin" @@ -355,6 +355,7 @@ "homepage": "https://github.com/arafattehsin/cache-stats-extension", "license": "MIT", "keywords": [ + "ai-credits", "aiu", "cache", "canvas", @@ -366,8 +367,8 @@ "source": { "source": "github", "repo": "arafattehsin/cache-stats-extension", - "ref": "v1.0.1", - "sha": "678c3468977889d5c50a0a5f33898b8d9638523a" + "ref": "v1.0.2", + "sha": "a6d6e0c2cb3c4309794dcc712cd14906b2b297db" } }, { @@ -783,8 +784,8 @@ }, { "name": "excel-cli", - "description": "Windows-only Excel automation plugin that runs the latest excelcli package through npx.", - "version": "2.2.1", + "description": "Let your coding agent work with real Microsoft Excel workbooks from the terminal: formulas, Power Query, PivotTables, charts, and VBA. Same features as Excel MCP, usually with fewer AI tokens. Requires Windows, desktop Excel 2016 or later, and Node.js 18 or later.", + "version": "2.3.11", "author": { "name": "Stefan Brönner", "url": "https://github.com/sbroenne" @@ -797,25 +798,25 @@ "windows", "cli", "automation", - "scripting", - "batch", + "formulas", "power-query", "dax", "pivottable", - "ci-cd" + "charts", + "vba" ], "source": { "source": "github", "repo": "sbroenne/mcp-server-excel-plugins", "path": "plugins/excel-cli", - "ref": "v2.2.1", - "sha": "8941e5d59a068e421ddbddf40107c6f505f5acac" + "ref": "v2.3.11", + "sha": "0057e645ff3620206e440bd313b8b26f0be943c0" } }, { "name": "excel-mcp", - "description": "Windows-only Excel automation plugin that runs the latest ExcelMcp MCP server through npx.", - "version": "2.2.1", + "description": "Let an AI assistant create and update Microsoft Excel workbooks: work with cells, formulas, Power Query, PivotTables, charts, and VBA in the desktop app. Requires Windows, desktop Excel 2016 or later, and Node.js 18 or later.", + "version": "2.3.11", "author": { "name": "Stefan Brönner", "url": "https://github.com/sbroenne" @@ -827,18 +828,20 @@ "excel", "windows", "mcp", + "automation", + "formulas", "power-query", "dax", "pivottable", - "vba", - "com-interop" + "charts", + "vba" ], "source": { "source": "github", "repo": "sbroenne/mcp-server-excel-plugins", "path": "plugins/excel-mcp", - "ref": "v2.2.1", - "sha": "8941e5d59a068e421ddbddf40107c6f505f5acac" + "ref": "v2.3.11", + "sha": "0057e645ff3620206e440bd313b8b26f0be943c0" } }, { @@ -979,7 +982,7 @@ { "name": "incident-io", "description": "Official incident.io plugins for AI agents: skills for working with [incident.io](https://incident.io/) — incidents, on-call, and investigations — and for authoring the operational content our platform draws on.", - "version": "0.18.0", + "version": "1.20261009.666", "author": { "name": "incident.io", "url": "https://incident.io" @@ -999,13 +1002,13 @@ "source": "github", "repo": "incident-io/skills", "path": "plugins/incident-io", - "sha": "6d0bbfc9e52256fc39326b9ba6d08f16831b60ae" + "sha": "52f1c60db6d59b2e4e6abd327d5107d3bff0f53d" } }, { "name": "keep-the-why", "description": "Preserves or recovers the reasoning behind a codebase — architectural decisions, rejected alternatives, workarounds, incident learnings, operational constraints, and historical context the code itself cannot explain.", - "version": "0.20.1", + "version": "0.20.2", "author": { "name": "Oliver Zehentleitner", "url": "https://github.com/oliver-zehentleitner" @@ -1024,7 +1027,8 @@ "source": { "source": "github", "repo": "oliver-zehentleitner/keep-the-why", - "ref": "v0.20.1" + "ref": "v0.20.2", + "sha": "4c14d0d170789d54af00307bdbb5f38a3312d3a6" } }, { @@ -1706,7 +1710,7 @@ { "name": "upgrade-agent", "description": "AI-powered upgrade assistant for upgrading and migrating applications. Helps modernize legacy code and upgrade .NET applications to current frameworks.", - "version": "1.1.652", + "version": "1.1.685", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" @@ -1725,8 +1729,8 @@ "source": "github", "repo": "microsoft/upgrade-agent-plugins", "path": "plugins/upgrade-agent", - "ref": "v1.1.652", - "sha": "f2f23675833495ecee9858cb4ce7b089fe9cf491" + "ref": "v1.1.685", + "sha": "90c7fe3beb38660a266685a417b6ff75300abe9b" } }, { @@ -1838,6 +1842,36 @@ "sha": "7c4e67e320c82600e9fb7d8786ea3a9927039b72" } }, + { + "name": "windbg", + "description": "WinDbg diagnosis workflows for native Windows applications, services, UMDF/user-mode drivers, and kernel-mode drivers. Covers crashes, hangs, heap corruption, memory exhaustion, TTD, bugchecks, Driver Verifier, IRP lifecycle, and kernel deadlocks, with an evidence-first method and report validation.", + "version": "1.0.0", + "author": { + "name": "Microsoft", + "url": "https://github.com/microsoft/WinDbg-Feedback" + }, + "repository": "https://github.com/microsoft/win-dev-skills", + "homepage": "https://github.com/microsoft/WinDbg-Feedback", + "license": "MIT", + "keywords": [ + "windows", + "windbg", + "debugging", + "crash-dump", + "hang", + "memory", + "drivers", + "kernel", + "umdf", + "ttd" + ], + "source": { + "source": "github", + "repo": "microsoft/win-dev-skills", + "path": "plugins/windbg", + "sha": "5ce74fa89c49ca353c6ab03bd5889aa519aa9e5e" + } + }, { "name": "winui", "description": "Agents and skills for WinUI 3 app development. Create new WinUI 3 desktop apps, convert from other frameworks (WPF, WinForms, Electron, Tauri, Flutter) to WinUI 3, or add features to existing WinUI 3 applications. Includes MSIX packaging, code signing, UI automation testing, and Windows App SDK guidance.",