Files
awesome-copilot/.github/extensions/external-plugin-review-board/public/app.js
T

659 lines
24 KiB
JavaScript

const token = document.querySelector('meta[name="board-token"]').content;
const RECOMMENDATION_LABELS = {
"straight-reject": "Straight reject",
"probably-reject": "Probably reject",
"needs-review": "Needs review",
accept: "Accept",
};
const ui = {
board: document.getElementById("board"),
meta: document.getElementById("meta"),
search: document.getElementById("search"),
refresh: document.getElementById("refresh"),
review: document.getElementById("review"),
drawer: document.getElementById("drawer"),
drawerKicker: document.getElementById("drawer-kicker"),
drawerTitle: document.getElementById("drawer-title"),
drawerMeta: document.getElementById("drawer-meta"),
drawerClose: document.getElementById("drawer-close"),
drawerMove: document.getElementById("drawer-move"),
drawerRereview: document.getElementById("drawer-rereview"),
rereviewPanel: document.getElementById("rereview-panel"),
rereviewGuidance: document.getElementById("rereview-guidance"),
rereviewCancel: document.getElementById("rereview-cancel"),
rereviewStart: document.getElementById("rereview-start"),
drawerLink: document.getElementById("drawer-link"),
drawerBody: document.getElementById("drawer-body"),
drawerFooter: document.getElementById("drawer-footer"),
tabs: [...document.querySelectorAll(".tab")],
comment: document.getElementById("decision-comment"),
preview: document.getElementById("decision-preview"),
approve: document.getElementById("decision-approve"),
reject: document.getElementById("decision-reject"),
toasts: document.getElementById("toasts"),
};
let board = null;
let filter = "";
let selected = null;
let activeTab = "review";
const details = new Map();
const pendingConfirm = { kind: null, timer: null };
// ---------- helpers ----------
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value == null || value === false) continue;
if (key === "class") el.className = value;
else if (key === "dataset") Object.assign(el.dataset, value);
else if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
else el.setAttribute(key, value === true ? "" : value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return el;
}
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json", "X-Board-Token": token },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? `Request failed (${res.status})`);
return data;
}
function toast(message, { error = false, link } = {}) {
const el = h("div", { class: `toast${error ? " error" : ""}`, role: error ? "alert" : "status" }, message);
if (link) el.append(" ", h("a", { href: link, target: "_blank", rel: "noopener noreferrer" }, "View ↗"));
ui.toasts.append(el);
setTimeout(() => el.remove(), error ? 8000 : 5000);
}
function relativeTime(iso) {
if (!iso) return "never";
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
const units = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
for (const [unit, size] of units) {
if (Math.abs(seconds) >= size) {
return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-Math.round(seconds / size), unit);
}
}
return "just now";
}
function cleanTitle(title) {
return String(title ?? "")
.replace(/^\[External Plugin\]\s*:?\s*/i, "")
.trim();
}
function itemName(item) {
return item.review?.pluginName || cleanTitle(item.title) || `#${item.number}`;
}
function signalPills(item) {
const pills = [];
for (const label of item.labels ?? []) {
const match = label.match(/^needs-review:(HIGH|MEDIUM|LOW)$/i);
if (match) {
const level = match[1].toUpperCase();
pills.push(h("span", { class: `pill pill-${level.toLowerCase()}`, title: "AGT contributor risk" }, `AGT ${level}`));
} else if (label === "external-plugin-canvas") {
pills.push(h("span", { class: "pill" }, "canvas"));
}
}
if (item.decision) {
pills.push(h("span", { class: `pill pill-${item.decision.kind}` }, item.decision.kind === "approve" ? "Approved" : "Rejected"));
} else if (item.pendingDecision) {
pills.push(h("span", { class: "pill pill-queued" }, `${item.pendingDecision.kind === "approve" ? "Approve" : "Reject"} pending`));
} else if (item.reviewStatus === "queued") {
pills.push(h("span", { class: "pill pill-queued" }, "Queued for AI"));
}
if (item.manualColumn && item.review?.recommendation) {
pills.push(h("span", { class: "pill pill-ai", title: "AI recommendation (you moved this card)" }, `AI: ${RECOMMENDATION_LABELS[item.review.recommendation]}`));
}
return pills;
}
function matchesFilter(item) {
if (!filter) return true;
const haystack = [item.number, item.title, item.author, item.review?.pluginName, item.review?.repository]
.join(" ")
.toLowerCase();
return haystack.includes(filter);
}
function findItem(number) {
return board?.items.find((item) => item.number === number) ?? null;
}
function pendingReviewCount() {
return board
? board.items.filter((item) => !item.decision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued").length
: 0;
}
// ---------- board ----------
function renderBoard() {
if (!board) return;
const open = board.items.filter((item) => !item.decision).length;
ui.meta.textContent = `${board.repo} · ${open} open · refreshed ${relativeTime(board.lastRefreshedAt)}`;
const pending = pendingReviewCount();
ui.review.textContent = pending ? `Perform review (${pending})` : "Perform review";
ui.review.disabled = pending === 0;
const scrollPositions = new Map(
[...ui.board.querySelectorAll(".column")].map((col) => [col.dataset.column, col.querySelector(".column-cards").scrollTop]),
);
const columns = board.columns.map((column) => {
const items = board.items.filter((item) => item.column === column.id && matchesFilter(item));
const cards = h(
"div",
{ class: "column-cards" },
items.length ? items.map(renderCard) : h("p", { class: "empty" }, filter ? "No matches" : "Empty"),
);
const col = h(
"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)),
cards,
);
if (column.droppable) wireDropTarget(col, column.id);
return col;
});
ui.board.replaceChildren(...columns);
for (const col of ui.board.querySelectorAll(".column")) {
col.querySelector(".column-cards").scrollTop = scrollPositions.get(col.dataset.column) ?? 0;
}
}
function renderCard(item) {
const draggable = !item.decision;
const card = h(
"article",
{
class: `card${selected === item.number ? " selected" : ""}`,
tabindex: "0",
draggable: draggable ? "true" : "false",
dataset: { number: item.number },
"aria-label": `#${item.number} ${itemName(item)}`,
onclick: () => openDrawer(item.number),
onkeydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer(item.number);
}
},
},
h("div", { class: "card-top" }, h("span", { class: "card-number" }, `#${item.number}`), h("span", { class: "card-byline" }, `@${item.author} · ${relativeTime(item.createdAt)}`)),
h("p", { class: "card-title" }, itemName(item)),
item.review?.rationale ? h("p", { class: "card-rationale" }, item.review.rationale) : null,
);
const pills = signalPills(item);
if (pills.length) card.append(h("div", { class: "pills" }, pills));
if (draggable) {
card.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", String(item.number));
event.dataTransfer.effectAllowed = "move";
card.classList.add("dragging");
});
card.addEventListener("dragend", () => card.classList.remove("dragging"));
}
return card;
}
function wireDropTarget(col, columnId) {
let depth = 0;
col.addEventListener("dragenter", (event) => {
event.preventDefault();
depth += 1;
col.classList.add("drop-target");
});
col.addEventListener("dragover", (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
});
col.addEventListener("dragleave", () => {
depth -= 1;
if (depth <= 0) {
depth = 0;
col.classList.remove("drop-target");
}
});
col.addEventListener("drop", (event) => {
event.preventDefault();
depth = 0;
col.classList.remove("drop-target");
const number = Number(event.dataTransfer.getData("text/plain"));
const item = findItem(number);
if (item && item.column !== columnId) moveItem(number, columnId);
});
}
async function moveItem(number, column) {
const hadReview = column === "unreviewed" && Boolean(findItem(number)?.review);
try {
await api("POST", "/api/move", { number, column });
if (hadReview) toast(`Cleared the AI review for #${number}; it will be included in the next review.`);
} catch (error) {
toast(error.message, { error: true });
renderBoard();
}
}
// ---------- drawer ----------
function openDrawer(number) {
selected = number;
resetConfirm();
hideRereview();
const item = findItem(number);
ui.comment.value = defaultComment(item);
ui.drawer.classList.add("open");
ui.drawer.setAttribute("aria-hidden", "false");
renderDrawer();
renderBoard();
ui.drawerClose.focus({ preventScroll: true });
if (activeTab !== "review") loadDetail(number);
}
function closeDrawer() {
const previous = selected;
selected = null;
resetConfirm();
hideRereview();
ui.drawer.classList.remove("open");
ui.drawer.setAttribute("aria-hidden", "true");
renderBoard();
ui.board.querySelector(`.card[data-number="${previous}"]`)?.focus({ preventScroll: true });
}
function defaultComment(item) {
const suggested = item?.review?.suggestedComment ?? "";
return suggested.replace(/^\s*\/(approve|reject)\b\s*/i, "").trim();
}
function renderDrawer() {
const item = findItem(selected);
if (!item) {
if (selected != null) closeDrawer();
return;
}
ui.drawerKicker.textContent = `#${item.number} · @${item.author}`;
ui.drawerTitle.textContent = itemName(item);
ui.drawerMeta.textContent = `Submitted ${relativeTime(item.createdAt)} · ${item.labels.join(", ")}`;
ui.drawerLink.href = item.url;
const options = board.columns
.filter((column) => column.droppable || column.id === item.column)
.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 = Boolean(item.decision) || item.reviewStatus === "queued";
if (ui.drawerRereview.disabled) hideRereview();
for (const tab of ui.tabs) {
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeTab));
if (tab.dataset.tab === "comments") {
const count = details.get(item.number)?.comments.length;
tab.textContent = count != null ? `Comments (${count})` : "Comments";
}
}
ui.drawerFooter.hidden = Boolean(item.decision);
updatePreview();
renderTab(item);
}
function renderTab(item) {
if (activeTab === "review") {
ui.drawerBody.replaceChildren(renderReview(item));
return;
}
const detail = details.get(item.number);
if (!detail) {
ui.drawerBody.replaceChildren(h("p", { class: "notice" }, "Loading issue from GitHub…"));
return;
}
if (detail.error) {
ui.drawerBody.replaceChildren(h("p", { class: "notice" }, `Couldn't load issue: ${detail.error}`));
return;
}
if (activeTab === "issue") {
ui.drawerBody.replaceChildren(markdownBlock(detail.bodyHtml));
} else {
ui.drawerBody.replaceChildren(
...(detail.comments.length ? detail.comments.map(renderComment) : [h("p", { class: "notice" }, "No comments yet.")]),
);
}
}
function renderReview(item) {
const wrapper = h("div");
if (item.decision) {
const label = item.decision.kind === "approve" ? "Approved" : "Rejected";
const suffix = item.decision.external ? " on GitHub" : "";
const parts = [`${label}${suffix} ${relativeTime(item.decision.at)}`];
if (item.decision.body) parts.push(": ", h("code", {}, item.decision.body));
if (item.decision.commentUrl) {
parts.push(" ", h("a", { href: item.decision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗"));
}
wrapper.append(h("p", { class: "notice" }, parts));
}
if (item.pendingDecision && !item.decision) {
wrapper.append(
h(
"p",
{ class: "notice" },
`${item.pendingDecision.kind === "approve" ? "Approve" : "Reject"} command posted ${relativeTime(item.pendingDecision.at)}; waiting for GitHub Actions to close and label the issue. You can retry if the issue stays ready for review.`,
" ",
item.pendingDecision.commentUrl
? h("a", { href: item.pendingDecision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗")
: null,
),
);
}
const review = item.review;
if (item.reviewStatus === "queued" && review) {
wrapper.append(h("p", { class: "notice" }, `Re-review in progress (started ${relativeTime(item.queuedAt)}). The result below will be replaced when it finishes.`));
}
if (item.reviewStatus === "queued" && item.rereviewGuidance) {
wrapper.append(h("blockquote", { class: "guidance" }, h("strong", {}, "Your guidance: "), item.rereviewGuidance));
}
if (!review) {
wrapper.append(
h(
"p",
{ class: "notice" },
item.reviewStatus === "queued"
? `Queued for AI review ${relativeTime(item.queuedAt)}. Results appear here when the agent records them.`
: "No AI review yet. Use “Perform review” or “Re-review” to have the agent assess it.",
),
);
return wrapper;
}
const rows = [
["Recommendation", RECOMMENDATION_LABELS[review.recommendation] + (item.manualColumn ? ` (you moved it to ${RECOMMENDATION_LABELS[item.manualColumn]})` : "")],
["Plugin", review.pluginName],
["Repository", review.repository ? h("a", { href: `https://github.com/${review.repository}`, target: "_blank", rel: "noopener noreferrer" }, review.repository) : null],
["Repo facts", review.repoFacts],
["Signals", review.signals],
["Contents", review.contents],
["Commercial", review.commercial],
["Rationale", review.rationale],
["Check next", review.checkNext],
["Suggested", review.suggestedComment ? h("code", {}, review.suggestedComment) : null],
["Re-review focus", review.rereviewGuidance],
["Reviewed", relativeTime(review.reviewedAt)],
].filter(([, value]) => value);
wrapper.append(h("dl", { class: "review-grid" }, rows.flatMap(([label, value]) => [h("dt", {}, label), h("dd", {}, value)])));
return wrapper;
}
function markdownBlock(html) {
const block = h("div", { class: "markdown" });
block.innerHTML = html || "<p><em>No description.</em></p>";
for (const link of block.querySelectorAll("a[href]")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
return block;
}
function renderComment(comment) {
const isBot = comment.authorType === "Bot" || /\[bot\]$/.test(comment.author ?? "") || comment.author === "github-actions";
const body = markdownBlock(comment.bodyHtml);
const el = h(
"article",
{ class: `comment${isBot ? " collapsed" : ""}` },
h(
"div",
{ class: "comment-header" },
h("strong", {}, comment.author ?? "unknown"),
comment.association && comment.association !== "NONE" ? h("span", { class: "pill" }, comment.association.toLowerCase()) : null,
isBot ? h("span", { class: "pill" }, "bot") : null,
h("a", { href: comment.url, target: "_blank", rel: "noopener noreferrer" }, relativeTime(comment.createdAt)),
),
body,
);
if (isBot) {
const toggle = h("button", { type: "button", class: "btn btn-small comment-toggle" }, "Expand");
toggle.addEventListener("click", () => {
const collapsed = el.classList.toggle("collapsed");
toggle.textContent = collapsed ? "Expand" : "Collapse";
});
el.querySelector(".comment-header").append(toggle);
}
return el;
}
async function loadDetail(number, { force = false } = {}) {
if (!force && details.has(number) && !details.get(number).error) return;
details.delete(number);
if (selected === number) renderDrawer();
try {
const data = await api(force ? "POST" : "GET", `/api/issue/${number}`);
details.set(number, data);
} catch (error) {
details.set(number, { error: error.message });
}
if (selected === number) renderDrawer();
}
// ---------- decisions ----------
function commentBody(kind) {
const text = ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "");
if (kind === "approve") return text ? `/approve\n\n${text}` : "/approve";
return text ? `/reject ${text}` : "/reject <reason required>";
}
function updatePreview() {
const kind = pendingConfirm.kind;
ui.preview.textContent = kind
? `Will post:\n${commentBody(kind)}`
: `Reject posts: ${commentBody("reject")}\nApprove posts: ${commentBody("approve").replace(/\n+/g, " ⏎ ")}`;
ui.reject.disabled = !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "");
ui.reject.title = ui.reject.disabled ? "Reject requires a reason." : "";
}
function resetConfirm() {
clearTimeout(pendingConfirm.timer);
pendingConfirm.kind = null;
ui.approve.textContent = "Approve";
ui.reject.textContent = "Reject";
ui.approve.classList.remove("confirming");
ui.reject.classList.remove("confirming");
ui.approve.disabled = false;
ui.reject.disabled = false;
updatePreview();
}
async function decide(kind) {
const item = findItem(selected);
if (!item) return;
if (kind === "reject" && !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "")) {
toast("Reject decisions require a reason.", { error: true });
updatePreview();
return;
}
if (pendingConfirm.kind !== kind) {
resetConfirm();
pendingConfirm.kind = kind;
const button = kind === "approve" ? ui.approve : ui.reject;
button.textContent = kind === "approve" ? "Confirm approve" : "Confirm reject";
button.classList.add("confirming");
updatePreview();
pendingConfirm.timer = setTimeout(resetConfirm, 6000);
return;
}
clearTimeout(pendingConfirm.timer);
ui.approve.disabled = true;
ui.reject.disabled = true;
try {
const result = await api("POST", "/api/decision", { number: item.number, kind, comment: ui.comment.value });
toast(
result.pending
? `Posted ${result.body.split("\n")[0]} on #${item.number}; waiting for workflow confirmation.`
: `Posted ${result.body.split("\n")[0]} on #${item.number}`,
{ link: result.commentUrl },
);
} catch (error) {
toast(error.message, { error: true });
} finally {
resetConfirm();
}
}
// ---------- toolbar ----------
async function withBusy(button, label, fn) {
const original = button.textContent;
button.disabled = true;
button.textContent = label;
try {
return await fn();
} finally {
button.textContent = original;
button.disabled = false;
renderBoard();
}
}
ui.refresh.addEventListener("click", () =>
withBusy(ui.refresh, "Refreshing…", async () => {
try {
const result = await api("POST", "/api/refresh");
details.clear();
const parts = [`${result.total} open`];
if (result.added.length) parts.push(`${result.added.length} new`);
if (result.removed.length) parts.push(`${result.removed.length} removed`);
toast(`Refreshed: ${parts.join(", ")}`);
if (selected != null && activeTab !== "review") loadDetail(selected, { force: true });
} catch (error) {
toast(error.message, { error: true });
}
}),
);
ui.review.addEventListener("click", () =>
withBusy(ui.review, "Starting…", async () => {
try {
const result = await api("POST", "/api/review", {});
toast(result.message);
} catch (error) {
toast(error.message, { error: true });
}
}),
);
ui.search.addEventListener("input", () => {
filter = ui.search.value.trim().toLowerCase();
renderBoard();
});
ui.drawerClose.addEventListener("click", closeDrawer);
ui.drawerMove.addEventListener("change", () => moveItem(selected, ui.drawerMove.value));
function hideRereview() {
ui.rereviewPanel.hidden = true;
ui.rereviewGuidance.value = "";
ui.drawerRereview.setAttribute("aria-expanded", "false");
}
ui.drawerRereview.setAttribute("aria-controls", "rereview-panel");
ui.drawerRereview.addEventListener("click", () => {
if (!ui.rereviewPanel.hidden) {
hideRereview();
return;
}
ui.rereviewPanel.hidden = false;
ui.drawerRereview.setAttribute("aria-expanded", "true");
ui.rereviewGuidance.focus();
});
ui.rereviewCancel.addEventListener("click", () => {
hideRereview();
ui.drawerRereview.focus();
});
ui.rereviewGuidance.addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
ui.rereviewPanel.requestSubmit();
} else if (event.key === "Escape") {
event.stopPropagation();
hideRereview();
ui.drawerRereview.focus();
}
});
ui.rereviewPanel.addEventListener("submit", async (event) => {
event.preventDefault();
const number = selected;
if (number == null) return;
ui.rereviewStart.disabled = true;
try {
const result = await api("POST", "/api/rereview", { number, guidance: ui.rereviewGuidance.value });
toast(result.message);
hideRereview();
} catch (error) {
toast(error.message, { error: true });
} finally {
ui.rereviewStart.disabled = false;
}
});
for (const tab of ui.tabs) {
tab.addEventListener("click", () => {
activeTab = tab.dataset.tab;
renderDrawer();
if (activeTab !== "review" && selected != null) loadDetail(selected);
});
}
ui.comment.addEventListener("input", () => {
if (pendingConfirm.kind) resetConfirm();
else updatePreview();
});
ui.approve.addEventListener("click", () => decide("approve"));
ui.reject.addEventListener("click", () => decide("reject"));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && selected != null) {
if (pendingConfirm.kind) resetConfirm();
else closeDrawer();
}
});
// ---------- live updates ----------
function connect() {
const events = new EventSource(`/events?token=${encodeURIComponent(token)}`);
events.addEventListener("board", (event) => {
board = JSON.parse(event.data);
renderBoard();
if (selected != null) renderDrawer();
});
events.addEventListener("error", () => {
ui.meta.textContent = "Reconnecting…";
});
}
connect();
setInterval(() => board && renderBoard(), 60000);