Files
byre/web/dist/reviews.js
T

290 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 === "failed" ? h("p", { class: "error-text" }, r.error) : null, r.state === "done" ? reviewResult(r, taskId) : null);
}
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 stepChanges = new Map();
const stepPatches = new Map();
function snapshotPatch(taskId, n, path) {
const key = `${taskId}/${n}/${path}`;
const cached = stepPatches.get(key);
if (cached) {
return cached;
}
const fetched = (async () => {
const ck = `${taskId}/${n}`;
let pending = stepChanges.get(ck);
if (!pending) {
pending = get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { snapshot: n, step: true } });
stepChanges.set(ck, pending);
}
const changes = await pending.catch((err) => {
stepChanges.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;
})();
stepPatches.set(key, fetched);
fetched.catch(() => stepPatches.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