Files
awesome-copilot/.github/extensions/open-pr-board/public/app.js
T
Aaron PowellandCopilot App 4754582823 feat(canvas): add open pull request review board
Add persistent AI-assisted PR triage with labels, checks, review evidence, and confirmed GitHub feedback, close, approval, and auto-merge actions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8045e7e0-dfd7-425a-9131-21ea5ca1553e
2026-10-09 17:01:53 +11:00

719 lines
26 KiB
JavaScript

const TOKEN = document.querySelector('meta[name="board-token"]')?.content ?? "";
const CONFIRM_MS = 6000;
const COLUMN_HINTS = {
unreviewed: "Not reviewed yet",
reviewing: "Waiting on the agent",
close: "Doesn't belong here",
"request-changes": "Fixable, needs work",
"needs-insight": "Needs a maintainer call",
approve: "Ready to merge",
actioned: "GitHub action completed",
};
const DECISION_LABELS = {
approve: "Approve + auto-merge",
"request-changes": "Request changes",
comment: "Submit feedback",
close: "Close PR",
};
const DECISION_BUTTONS = {
approve: "Approve + auto-merge",
"request-changes": "Request changes",
comment: "Submit feedback",
close: "Close PR",
};
const CONFIRM_LABELS = {
approve: "Confirm: approve + merge",
"request-changes": "Confirm: request changes",
comment: "Confirm: submit feedback",
close: "Confirm: close PR",
};
let board = null;
let selectedNumber = null;
let detail = null;
let detailError = null;
let activeTab = "review";
let filterText = "";
let pendingConfirm = null;
const el = {
title: document.getElementById("board-title"),
summary: document.getElementById("board-summary"),
filter: document.getElementById("filter"),
refresh: document.getElementById("refresh-button"),
review: document.getElementById("review-button"),
board: document.getElementById("board"),
drawer: document.getElementById("drawer"),
kicker: document.getElementById("drawer-kicker"),
drawerTitle: document.getElementById("drawer-title"),
drawerMeta: document.getElementById("drawer-meta"),
drawerLabels: document.getElementById("drawer-labels"),
drawerColumn: document.getElementById("drawer-column"),
drawerLink: document.getElementById("drawer-link"),
drawerBody: document.getElementById("drawer-body"),
drawerClose: document.getElementById("drawer-close"),
rereviewToggle: document.getElementById("rereview-toggle"),
rereviewPanel: document.getElementById("rereview-panel"),
rereviewGuidance: document.getElementById("rereview-guidance"),
rereviewStart: document.getElementById("rereview-start"),
rereviewCancel: document.getElementById("rereview-cancel"),
comment: document.getElementById("decision-comment"),
toasts: document.getElementById("toasts"),
};
/* ------------------------------------------------------------------ utils */
function h(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
if (key === "class") node.className = value;
else if (key === "dataset") Object.assign(node.dataset, value);
else if (key === "vars") for (const [name, val] of Object.entries(value)) node.style.setProperty(name, val);
else if (key.startsWith("on") && typeof value === "function")
node.addEventListener(key.slice(2).toLowerCase(), value);
else node.setAttribute(key, value === true ? "" : String(value));
}
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/**
* Renders HTML that GitHub produced and sanitised (`body_html` from the REST API
* with the full+json media type). Never pass user-authored raw strings here.
*/
function markdownBlock(html) {
const node = h("div", { class: "markdown" });
node.innerHTML = html || "<p><em>No content.</em></p>";
for (const anchor of node.querySelectorAll("a")) {
anchor.target = "_blank";
anchor.rel = "noreferrer noopener";
}
return node;
}
function relativeTime(iso) {
if (!iso) return "";
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.round(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString();
}
function toast(message, { error = false, url } = {}) {
const node = h(
"div",
{ class: error ? "toast error" : "toast" },
message,
url ? " " : null,
url ? h("a", { href: url, target: "_blank", rel: "noreferrer noopener" }, "View") : null,
);
el.toasts.append(node);
setTimeout(() => node.remove(), error ? 9000 : 5000);
}
async function api(path, { method = "GET", body } = {}) {
const response = await fetch(path, {
method,
headers: {
"X-Board-Token": TOKEN,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
}
async function run(button, work, successMessage) {
const label = button?.textContent;
if (button) {
button.disabled = true;
button.textContent = "Working…";
}
try {
const result = await work();
if (successMessage) toast(typeof successMessage === "function" ? successMessage(result) : successMessage);
return result;
} catch (error) {
toast(error.message, { error: true });
return null;
} finally {
if (button) {
button.disabled = false;
button.textContent = label;
}
}
}
function currentItem() {
if (selectedNumber === null) return null;
return board?.items.find((item) => item.number === selectedNumber) ?? null;
}
function matchesFilter(item) {
if (!filterText) return true;
const haystack = [
String(item.number),
item.title,
item.author,
...(item.labels ?? []).map((label) => label.name),
]
.join(" ")
.toLowerCase();
return haystack.includes(filterText);
}
/* ------------------------------------------------------------------- pills */
function labelChips(labels, limit) {
const shown = limit ? labels.slice(0, limit) : labels;
const chips = shown.map((label) =>
h(
"span",
{
class: "label-chip",
title: label.description || label.name,
vars: label.color ? { "--label": `#${label.color}` } : null,
},
label.name,
),
);
if (limit && labels.length > limit) {
chips.push(h("span", { class: "label-chip label-chip-more" }, `+${labels.length - limit}`));
}
return chips;
}
function statusPills(item) {
const pills = [];
const add = (text, tone, title) => pills.push(h("span", { class: `pill pill-${tone}`, title: title || text }, text));
if (item.isDraft) add("draft", "info");
if (item.checkCounts?.total) {
if (item.checksState === "failure") add(`${item.checkCounts.failure} failing`, "danger", "Failing checks");
else if (item.checksState === "pending") add(`${item.checkCounts.pending} running`, "caution", "Checks running");
else add("checks pass", "success", "All checks passed");
}
if (item.mergeStateStatus === "DIRTY") add("conflicts", "danger", "Merge conflicts with the base branch");
if (item.reviewDecision === "APPROVED") add("approved", "success", "Approved on GitHub");
if (item.reviewDecision === "CHANGES_REQUESTED") add("changes requested", "warn", "Changes requested on GitHub");
if (item.reviewStatus === "queued") add("review queued", "ai", "Waiting on the agent's review");
else if (item.review) {
add(`AI: ${item.review.recommendation}`, "ai", item.review.rationale || "AI recommendation");
if (item.reviewStale) add("PR changed", "caution", "The PR was updated after this review was recorded");
}
if (item.manualColumn) add("moved", "info", "Manually moved; overrides the AI recommendation");
if (item.decision)
add(
item.decision.status || DECISION_LABELS[item.decision.kind] || item.decision.kind,
"info",
`GitHub action: ${item.decision.status || item.decision.kind}`,
);
return pills;
}
/* ------------------------------------------------------------------- board */
function renderCard(item) {
const card = h(
"article",
{
class: item.number === selectedNumber ? "card selected" : "card",
draggable: !item.decision,
dataset: { number: String(item.number) },
tabindex: "0",
role: "button",
"aria-label": `#${item.number} ${item.title}`,
onclick: () => openDrawer(item.number),
onkeydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer(item.number);
}
},
ondragstart: (event) => {
event.dataTransfer.setData("text/plain", String(item.number));
event.dataTransfer.effectAllowed = "move";
card.classList.add("dragging");
},
ondragend: () => card.classList.remove("dragging"),
},
h(
"div",
{ class: "card-top" },
h("span", { class: "card-number" }, `#${item.number}`),
h("span", { class: "card-byline" }, `@${item.author} · ${relativeTime(item.updatedAt)}`),
),
h("p", { class: "card-title" }, item.title),
item.labels?.length ? h("div", { class: "pills" }, labelChips(item.labels, 6)) : null,
h("div", { class: "pills" }, statusPills(item)),
item.review?.rationale ? h("p", { class: "card-rationale" }, item.review.rationale) : null,
);
return card;
}
function renderBoard() {
if (!board) return;
const scroll = new Map();
for (const list of el.board.querySelectorAll(".column-cards")) scroll.set(list.dataset.column, list.scrollTop);
const columns = board.columns.map((column) => {
const items = board.items.filter((item) => item.column === column.id && matchesFilter(item));
const list = h(
"div",
{
class: "column-cards",
dataset: { column: column.id },
ondragover: (event) => {
if (!column.droppable) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
list.closest(".column")?.classList.add("drop-target");
},
ondragleave: () => list.closest(".column")?.classList.remove("drop-target"),
ondrop: async (event) => {
list.closest(".column")?.classList.remove("drop-target");
if (!column.droppable) return;
event.preventDefault();
const number = Number(event.dataTransfer.getData("text/plain"));
if (!Number.isInteger(number)) return;
try {
await api("/api/move", { method: "POST", body: { number, column: column.id } });
} catch (error) {
toast(error.message, { error: true });
}
},
},
items.length ? items.map(renderCard) : h("p", { class: "empty" }, COLUMN_HINTS[column.id] ?? "Empty"),
);
return h(
"section",
{ class: "column", dataset: { column: column.id } },
h(
"header",
{ class: "column-header" },
h("span", { class: "column-name" }, column.label),
h("span", { class: "count" }, String(items.length)),
),
list,
);
});
el.board.replaceChildren(...columns);
for (const list of el.board.querySelectorAll(".column-cards")) {
const previous = scroll.get(list.dataset.column);
if (previous) list.scrollTop = previous;
}
const open = board.items.filter((item) => !item.decision).length;
const pending = board.items.filter((item) => !item.decision && (!item.review || item.reviewStale)).length;
el.title.textContent = `Open pull requests — ${board.repo}`;
el.summary.textContent = [
`${board.items.length} open`,
`${board.reviewed} reviewed`,
`${pending} pending review`,
`${board.items.length - open} actioned`,
board.updatedAt ? `synced ${relativeTime(board.updatedAt)}` : "never synced",
].join(" · ");
el.review.textContent = pending ? `Review ${pending} pending` : "Review pending";
el.review.disabled = pending === 0;
}
/* ------------------------------------------------------------------ drawer */
function reviewTab(item) {
if (item.reviewStatus === "queued") {
return h("p", { class: "notice" }, "Queued — the agent is reviewing this pull request now.");
}
if (!item.review) {
return h(
"div",
{ class: "notice" },
h("p", { class: "meta" }, "No AI review recorded yet."),
h(
"button",
{
class: "btn btn-small btn-primary",
type: "button",
onclick: (event) =>
run(
event.currentTarget,
() => api("/api/review", { method: "POST", body: { numbers: [item.number] } }),
(result) => result.message ?? "Review requested.",
),
},
"Review this PR",
),
);
}
const review = item.review;
const rows = [
["Recommendation", review.recommendation],
["Confidence", review.confidence],
["Rationale", review.rationale],
["Summary", review.summary],
["Repo fit", review.repoFit],
["Compliance", review.compliance],
["Differentiation", review.differentiation],
["Risks", review.risks],
["Validation", review.validation],
["Check next", review.checkNext],
].filter(([, value]) => value);
return h(
"div",
{},
item.reviewStale
? h("p", { class: "notice" }, "This pull request changed after the review was recorded. Consider a re-review.")
: null,
h("dl", { class: "review-grid" }, rows.map(([term, value]) => [h("dt", {}, term), h("dd", {}, value)])),
review.reviewedAt ? h("p", { class: "meta", style: "margin-top:12px" }, `Reviewed ${relativeTime(review.reviewedAt)}`) : null,
);
}
function conversationTab() {
const entries = [
...(detail.reviews ?? []).map((review) => ({ ...review, kind: `review: ${review.state.toLowerCase()}`, at: review.submittedAt })),
...(detail.comments ?? []).map((comment) => ({ ...comment, kind: "comment", at: comment.createdAt })),
]
.filter((entry) => entry.bodyHtml || entry.kind.startsWith("review"))
.sort((a, b) => new Date(a.at) - new Date(b.at));
if (!entries.length) return h("p", { class: "notice" }, "No comments or reviews yet.");
return h(
"div",
{},
entries.map((entry) => {
const body = markdownBlock(entry.bodyHtml);
const wrapper = h(
"article",
{ class: "comment collapsed" },
h(
"header",
{ class: "comment-header" },
h("strong", {}, `@${entry.author ?? "unknown"}`),
h("span", { class: "meta" }, `${entry.kind} · ${relativeTime(entry.at)}`),
entry.url ? h("a", { href: entry.url, target: "_blank", rel: "noreferrer noopener" }, "link") : null,
h(
"button",
{
class: "btn btn-small comment-toggle",
type: "button",
onclick: (event) => {
const collapsed = wrapper.classList.toggle("collapsed");
event.currentTarget.textContent = collapsed ? "Expand" : "Collapse";
},
},
"Expand",
),
),
body,
);
return wrapper;
}),
);
}
function filesTab() {
if (!detail.files?.length) return h("p", { class: "notice" }, "No changed files reported.");
return h(
"ul",
{ class: "row-list" },
detail.files.map((file) =>
h(
"li",
{ class: "row" },
h("span", { class: "row-name" }, file.filename),
h("span", { class: "meta" }, file.status),
h(
"span",
{ class: "row-stat" },
h("span", { class: "add" }, `+${file.additions}`),
" ",
h("span", { class: "del" }, `-${file.deletions}`),
),
),
),
);
}
function checksTab(item) {
if (!item.checks?.length) return h("p", { class: "notice" }, "No checks reported for this pull request.");
return h(
"ul",
{ class: "row-list" },
item.checks.map((check) =>
h(
"li",
{ class: "row" },
h("span", { class: "state-dot", dataset: { state: check.state }, title: check.state }),
check.url
? h(
"a",
{ class: "row-name", href: check.url, target: "_blank", rel: "noreferrer noopener" },
check.name,
)
: h("span", { class: "row-name" }, check.name),
check.workflow ? h("span", { class: "meta" }, check.workflow) : null,
),
),
);
}
function renderDrawerBody() {
const item = currentItem();
if (!item) return;
for (const tab of el.drawer.querySelectorAll(".tab")) {
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeTab));
}
if (activeTab === "review") {
el.drawerBody.replaceChildren(reviewTab(item));
return;
}
if (activeTab === "checks") {
el.drawerBody.replaceChildren(checksTab(item));
return;
}
if (detailError) {
el.drawerBody.replaceChildren(h("p", { class: "notice" }, detailError));
return;
}
if (!detail || detail.number !== item.number) {
el.drawerBody.replaceChildren(h("p", { class: "notice" }, "Loading…"));
return;
}
if (activeTab === "description") el.drawerBody.replaceChildren(markdownBlock(detail.bodyHtml));
else if (activeTab === "conversation") el.drawerBody.replaceChildren(conversationTab());
else if (activeTab === "files") el.drawerBody.replaceChildren(filesTab());
}
function renderDrawerHeader() {
const item = currentItem();
if (!item) return;
el.kicker.textContent = `#${item.number} · ${item.headRefName} → ${item.baseRefName}`;
el.drawerTitle.textContent = item.title;
el.drawerMeta.textContent = [
`@${item.author}`,
`opened ${relativeTime(item.createdAt)}`,
`+${item.additions}/-${item.deletions} in ${item.changedFiles} files`,
item.decision ? `actioned: ${item.decision.status || DECISION_LABELS[item.decision.kind] || item.decision.kind}` : null,
]
.filter(Boolean)
.join(" · ");
el.drawerLabels.replaceChildren(...labelChips(item.labels ?? []));
el.drawerLink.href = item.url;
const droppable = board.columns.filter((column) => column.droppable);
const options = droppable.map((column) => h("option", { value: column.id }, column.label));
const fixed = board.columns.find((column) => !column.droppable && column.id === item.column);
if (fixed) options.push(h("option", { value: fixed.id, disabled: true }, fixed.label));
el.drawerColumn.replaceChildren(...options);
el.drawerColumn.value = item.column;
el.drawerColumn.disabled = Boolean(item.decision);
el.rereviewToggle.disabled = Boolean(item.decision) || item.reviewStatus === "queued";
for (const button of el.drawer.querySelectorAll("[data-decision]")) {
button.disabled = Boolean(item.decision);
button.classList.remove("confirming");
button.textContent = DECISION_BUTTONS[button.dataset.decision];
}
pendingConfirm = null;
}
async function loadDetail(number, { force = false } = {}) {
detail = null;
detailError = null;
renderDrawerBody();
try {
detail = await api(`/api/pr/${number}`, force ? { method: "POST" } : {});
} catch (error) {
detailError = error.message;
}
if (selectedNumber === number) renderDrawerBody();
}
function openDrawer(number) {
const changed = selectedNumber !== number;
selectedNumber = number;
const item = currentItem();
if (!item) return;
el.drawer.classList.add("open");
el.drawer.setAttribute("aria-hidden", "false");
el.rereviewPanel.hidden = true;
if (changed) {
el.comment.value = item.review?.suggestedComment ?? "";
detail = null;
detailError = null;
}
renderDrawerHeader();
renderDrawerBody();
renderBoard();
if (changed) loadDetail(number);
}
function closeDrawer() {
selectedNumber = null;
detail = null;
detailError = null;
pendingConfirm = null;
el.drawer.classList.remove("open");
el.drawer.setAttribute("aria-hidden", "true");
renderBoard();
}
/* --------------------------------------------------------------- decisions */
function armConfirm(button, kind) {
if (pendingConfirm?.kind === kind) return true;
if (pendingConfirm) {
clearTimeout(pendingConfirm.timer);
pendingConfirm.button.classList.remove("confirming");
pendingConfirm.button.textContent = DECISION_BUTTONS[pendingConfirm.kind];
}
button.classList.add("confirming");
button.textContent = CONFIRM_LABELS[kind];
pendingConfirm = {
kind,
button,
timer: setTimeout(() => {
button.classList.remove("confirming");
button.textContent = DECISION_BUTTONS[kind];
pendingConfirm = null;
}, CONFIRM_MS),
};
return false;
}
async function submitDecision(button, kind) {
const item = currentItem();
if (!item) return;
if (!armConfirm(button, kind)) return;
clearTimeout(pendingConfirm.timer);
pendingConfirm = null;
button.classList.remove("confirming");
const result = await run(button, () =>
api("/api/decision", { method: "POST", body: { number: item.number, kind, comment: el.comment.value } }),
);
button.textContent = DECISION_BUTTONS[kind];
if (result) {
if (result.warning) toast(result.warning, { error: true, url: result.url });
else toast(`#${item.number}: ${result.status}.`, { url: result.url });
if (currentItem()) loadDetail(item.number, { force: true });
}
}
/* ----------------------------------------------------------------- wire-up */
el.filter.addEventListener("input", (event) => {
filterText = event.target.value.trim().toLowerCase();
renderBoard();
});
el.refresh.addEventListener("click", (event) =>
run(event.currentTarget, () => api("/api/refresh", { method: "POST" }), "Board refreshed."),
);
el.review.addEventListener("click", (event) =>
run(event.currentTarget, () => api("/api/review", { method: "POST", body: {} }), (result) => result.message ?? "Review requested."),
);
el.drawerClose.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && el.drawer.classList.contains("open")) closeDrawer();
});
el.drawerColumn.addEventListener("change", async (event) => {
const item = currentItem();
if (!item) return;
const column = event.target.value;
if (column === item.column) return;
try {
await api("/api/move", { method: "POST", body: { number: item.number, column } });
} catch (error) {
toast(error.message, { error: true });
event.target.value = item.column;
}
});
for (const tab of el.drawer.querySelectorAll(".tab")) {
tab.addEventListener("click", () => {
activeTab = tab.dataset.tab;
renderDrawerBody();
if (!detail && !detailError && selectedNumber !== null) loadDetail(selectedNumber);
});
}
el.rereviewToggle.addEventListener("click", () => {
el.rereviewPanel.hidden = !el.rereviewPanel.hidden;
if (!el.rereviewPanel.hidden) el.rereviewGuidance.focus();
});
el.rereviewCancel.addEventListener("click", () => {
el.rereviewPanel.hidden = true;
});
el.rereviewStart.addEventListener("click", async (event) => {
const item = currentItem();
if (!item) return;
const result = await run(
event.currentTarget,
() =>
api("/api/rereview", {
method: "POST",
body: { number: item.number, guidance: el.rereviewGuidance.value },
}),
(value) => value.message ?? "Re-review requested.",
);
if (result) {
el.rereviewGuidance.value = "";
el.rereviewPanel.hidden = true;
}
});
for (const button of el.drawer.querySelectorAll("[data-decision]")) {
button.addEventListener("click", () => submitDecision(button, button.dataset.decision));
}
function applySnapshot(snapshot) {
board = snapshot;
renderBoard();
if (selectedNumber !== null) {
if (!currentItem()) closeDrawer();
else {
renderDrawerHeader();
renderDrawerBody();
}
}
}
const events = new EventSource(`/events?token=${encodeURIComponent(TOKEN)}`);
events.addEventListener("board", (event) => applySnapshot(JSON.parse(event.data)));
events.addEventListener("error", () => {
el.summary.textContent = "Connection lost — reopen the canvas to reconnect.";
});
api("/api/board").then(applySnapshot).catch((error) => toast(error.message, { error: true }));