mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-14 07:44:43 +00:00
chore: merge origin/main into external plugin ownership
Preserve intake readiness checks alongside pending-decision safeguards. Gate newly merged bulk approval and re-review actions on intake readiness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 821f4796-505e-4fb8-96cf-0b5bbf80a636
This commit is contained in:
@@ -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 <reason>` 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.
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -195,6 +195,7 @@ export class BoardStore {
|
||||
(item) =>
|
||||
isReadyForApproval(item.labels ?? []) &&
|
||||
!item.decision &&
|
||||
!item.pendingDecision &&
|
||||
item.reviewStatus !== "queued",
|
||||
);
|
||||
if (numbers?.length) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user