import { get, post, put } from "./api.js"; import { ago, errorText, h, replace, showAlert } from "./dom.js"; import { markdown } from "./markdown.js"; import { onEach, pickReview, state } from "./state.js"; const severities = ["critical", "high", "medium", "low", "info"]; export function isReviewEvent(ev) { return ev.kind.startsWith("review."); } function findingsOf(r) { const res = (r.result ?? {}); return Array.isArray(res["findings"]) ? res["findings"] : []; } export function findingCount(r) { return r.state === "done" ? findingsOf(r).filter((_, i) => !r.finding_states[String(i)]).length : 0; } export function unignoredFindings(r) { return r.state === "done" ? findingsOf(r).filter((_, i) => r.finding_states[String(i)] !== "ignored") : []; } export function latestReview(events) { const all = events.filter(isReviewEvent).map((ev) => ev.payload); return all.reduce((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined); } export function reviewCounts(r) { return severityCounts(findingsOf(r)); } export function severityCounts(findings) { const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length]).filter(([, n]) => n > 0); return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings"; } function stateText(r) { switch (r.state) { case "running": return `reviewing on ${r.vendor} (${r.model})…`; case "done": return reviewCounts(r); case "failed": return "failed"; case "skipped": return "skipped: no changes"; } } export function reviewCard(r, taskId) { return h("article", { class: `review ${r.state}` }, h("header", {}, h("span", { class: "review-snapshot" }, `snapshot #${r.snapshot_n}`), h("span", { class: `review-state ${r.state}` }, stateText(r)), h("time", { dateTime: r.created_at, title: r.created_at }, ago(r.created_at)), r.state === "running" ? stopButton(r) : null), r.state === "failed" ? h("p", { class: "error-text" }, r.error) : null, r.state === "done" ? reviewResult(r, taskId) : null); } function stopButton(r) { const b = h("button", { type: "button", class: "danger review-stop", title: "End the reviewer and record this review as failed" }, "Stop"); b.onclick = async () => { b.disabled = true; try { await post("/api/reviews/{reviewId}/stop", { path: { reviewId: r.id } }); } catch (err) { b.disabled = false; void showAlert(errorText(err)); } }; return b; } function reviewResult(r, taskId) { const res = (r.result ?? {}); const findings = findingsOf(r); const md = { taskId }; return h("div", { class: "review-result" }, markdown(String(res["summary"] ?? ""), "md review-summary", md), reviewImages(res["images"]), findings.length > 0 ? h("ol", { class: "findings" }, ...findings.map((f, i) => { const severity = String(f["severity"] ?? ""); const path = typeof f["path"] === "string" ? f["path"] : ""; const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0; const end = typeof f["end_line"] === "number" && f["end_line"] > line ? f["end_line"] : line; const where = path ? `${path}${line ? `:${line}${end > line ? `–${end}` : ""}` : ""}` : ""; return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? findingActions(taskId, r, i, f, where) : null), taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null, markdown(String(f["detail"] ?? ""), "md finding-detail", md), reviewImages(f["images"])); })) : null); } function reviewImages(v) { if (!Array.isArray(v) || v.length === 0) { return null; } return h("div", { class: "review-images" }, ...v.map((img) => { const src = `/api/blobs/${encodeURIComponent(String(img["blob"] ?? ""))}`; const caption = typeof img["caption"] === "string" ? img["caption"] : ""; return h("figure", {}, h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: caption || "screenshot", loading: "lazy" })), caption ? h("figcaption", {}, caption) : null); })); } const snapChanges = new Map(); const snapPatches = new Map(); function snapshotPatch(taskId, n, path) { const key = `${taskId}/${n}/${path}`; const cached = snapPatches.get(key); if (cached) { return cached; } const fetched = (async () => { const ck = `${taskId}/${n}`; let pending = snapChanges.get(ck); if (!pending) { pending = get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { snapshot: n } }); snapChanges.set(ck, pending); } const changes = await pending.catch((err) => { snapChanges.delete(ck); throw err; }); const f = changes.files.find((c) => c.path === path); if (!f || f.binary) { return undefined; } const d = await get("/api/tasks/{taskId}/diff", { path: { taskId }, query: { base: changes.base, tree: changes.tree, path: f.path, old_path: f.old_path } }); return d.patch; })(); snapPatches.set(key, fetched); fetched.catch(() => snapPatches.delete(key)); return fetched; } function linesAround(patch, line, end) { const lines = []; let n = 0; let inHunk = false; for (const l of patch.split("\n")) { const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l); if (m) { n = Number(m[1]); inHunk = true; continue; } if (!inHunk) { continue; } if (l.startsWith("+")) { lines.push({ kind: "add", text: l, at: n++ }); } else if (l.startsWith("-")) { lines.push({ kind: "del", text: l, at: n }); } else if (l.startsWith(" ")) { lines.push({ kind: "ctx", text: l, at: n++ }); } } return lines.filter((d) => d.at >= line - 3 && d.at <= end + 3); } function findingDiff(taskId, n, path, line, end) { const box = h("div", { class: "finding-diff" }); snapshotPatch(taskId, n, path).then((patch) => { const lines = patch === undefined ? [] : linesAround(patch, line, end); if (lines.length === 0) { box.remove(); return; } const rows = []; lines.forEach((d, i) => { const prev = lines[i - 1]; if (prev && d.kind !== "del" && prev.kind !== "del" && d.at > prev.at + 1) { rows.push(h("span", { class: "line hunk" }, "⋯")); } const mark = d.kind !== "del" && d.at >= line && d.at <= end; rows.push(h("span", { class: `line ${d.kind}${mark ? " mark" : ""}` }, h("span", { class: "lineno" }, d.kind === "del" ? "" : String(d.at)), d.text || " ")); }); replace(box, h("pre", { class: "diff" }, ...rows)); }, (err) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`))); return box; } function findingActions(taskId, r, index, f, where) { const box = h("span", { class: "finding-actions" }); const done = (s) => replace(box, h("span", { class: "finding-done" }, s === "fixed" ? "fix sent" : "ignored")); const current = r.finding_states[String(index)]; if (current) { done(current); return box; } const mark = (s) => put("/api/reviews/{reviewId}/findings/{index}", { path: { reviewId: r.id, index }, body: { state: s } }); const fix = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix"); const ignore = h("button", { type: "button", class: "finding-ignore", title: "Mark this finding done without fixing it; Merge won't warn about it" }, "ignore"); fix.onclick = async () => { const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`; fix.disabled = true; ignore.disabled = true; try { const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } }); const live = sessions.findLast((s) => s.state !== "completed"); if (live) { await post("/api/sessions/{sessionId}/messages", { path: { sessionId: live.id }, body: { text } }); } else { await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } }); } } catch (err) { fix.disabled = false; ignore.disabled = false; void showAlert(errorText(err)); return; } done("fixed"); await mark("fixed").catch((err) => showAlert(`fix sent, but not recorded: ${errorText(err)}`)); }; ignore.onclick = async () => { fix.disabled = true; ignore.disabled = true; try { await mark("ignored"); done("ignored"); } catch (err) { fix.disabled = false; ignore.disabled = false; void showAlert(errorText(err)); } }; replace(box, fix, ignore); return box; } export function mountReviews(root) { const head = h("header", { class: "reviews-header" }); const body = h("div", { class: "review-list" }); let reviews; let error = ""; let target = 0; let disposed = false; const load = async () => { const taskId = state.selectedTaskId; if (!taskId) { render(); return; } const started = ++target; try { const fresh = await get("/api/tasks/{taskId}/reviews", { path: { taskId } }); if (started === target) { reviews = fresh; error = ""; } } catch (err) { if (started === target) { reviews = undefined; error = errorText(err); } } if (!disposed && started === target) { render(); } }; const render = () => { const taskId = state.selectedTaskId; replace(head); head.hidden = true; if (!taskId) { replace(body, h("div", { class: "empty" }, "No task selected.")); return; } if (error) { replace(body, h("div", { class: "empty error-text" }, error)); return; } if (!reviews) { replace(body, h("div", { class: "empty" }, "Loading…")); return; } if (reviews.length === 0) { replace(body, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor.")); return; } const n = state.reviewed ?? reviews.at(-1).snapshot_n; const shown = reviews.findLast((r) => r.snapshot_n === n); const ns = [...new Set([n, ...reviews.map((r) => r.snapshot_n)])].sort((a, b) => b - a); const picker = h("select", { title: "Snapshot whose review to show" }, ...ns.map((s) => h("option", { value: String(s) }, `snapshot #${s}`))); picker.value = String(n); picker.onchange = () => pickReview(Number(picker.value)); const step = (to, label) => { const b = h("button", { type: "button", class: "snap-step", disabled: !ns.includes(to) }, label); b.onclick = () => pickReview(to); return b; }; const next = ns.filter((s) => s > n).at(-1); const prev = ns.find((s) => s < n); head.hidden = false; replace(head, picker, step(prev ?? n - 1, `← #${prev ?? n - 1}`), step(next ?? n + 1, `#${next ?? n + 1} →`)); replace(body, shown ? reviewCard(shown, taskId) : h("div", { class: "empty" }, `No review of snapshot #${n} yet.`)); }; const offs = [ onEach(["selection"], () => { target++; reviews = undefined; error = ""; render(); void load(); }), onEach(["transcript"], (fresh) => { if (fresh.some(isReviewEvent)) { void load(); } }), onEach(["review"], render), ]; replace(root, h("div", { class: "reviews-view" }, head, body)); render(); void load(); return () => { disposed = true; for (const off of offs) { off(); } }; } //# sourceMappingURL=reviews.js.map