360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { get, post, put, type ByreEvent, type Schemas } from "./api.js";
|
||
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
||
import { markdown } from "./markdown.js";
|
||
import { onEach, pickReview, state } from "./state.js";
|
||
|
||
type Review = Schemas["Review"];
|
||
type FindingState = Schemas["FindingState"];
|
||
|
||
const severities = ["critical", "high", "medium", "low", "info"];
|
||
|
||
export function isReviewEvent(ev: ByreEvent): boolean {
|
||
return ev.kind.startsWith("review.");
|
||
}
|
||
|
||
function findingsOf(r: Review): Record<string, unknown>[] {
|
||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||
return Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||
}
|
||
|
||
export function findingCount(r: Review): number {
|
||
return r.state === "done" ? findingsOf(r).filter((_, i) => !r.finding_states[String(i)]).length : 0;
|
||
}
|
||
|
||
export function unignoredFindings(r: Review): Record<string, unknown>[] {
|
||
return r.state === "done" ? findingsOf(r).filter((_, i) => r.finding_states[String(i)] !== "ignored") : [];
|
||
}
|
||
|
||
export function latestReview(events: ByreEvent[]): Review | undefined {
|
||
const all = events.filter(isReviewEvent).map((ev) => ev.payload as unknown as Review);
|
||
return all.reduce<Review | undefined>((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined);
|
||
}
|
||
|
||
export function reviewCounts(r: Review): string {
|
||
return severityCounts(findingsOf(r));
|
||
}
|
||
|
||
export function severityCounts(findings: Record<string, unknown>[]): string {
|
||
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length] as const).filter(([, n]) => n > 0);
|
||
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
|
||
}
|
||
|
||
function stateText(r: Review): string {
|
||
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: Review, taskId: string | undefined): HTMLElement {
|
||
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: Review, taskId: string | undefined): HTMLElement {
|
||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||
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: unknown): HTMLElement | null {
|
||
if (!Array.isArray(v) || v.length === 0) {
|
||
return null;
|
||
}
|
||
return h(
|
||
"div",
|
||
{ class: "review-images" },
|
||
...(v as Record<string, unknown>[]).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<string, Promise<Schemas["TaskChanges"]>>();
|
||
const stepPatches = new Map<string, Promise<string | undefined>>();
|
||
|
||
function snapshotPatch(taskId: string, n: number, path: string): Promise<string | undefined> {
|
||
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: unknown) => {
|
||
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;
|
||
}
|
||
|
||
type DiffLine = { kind: "add" | "del" | "ctx"; text: string; at: number };
|
||
|
||
function linesAround(patch: string, line: number, end: number): DiffLine[] {
|
||
const lines: DiffLine[] = [];
|
||
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: string, n: number, path: string, line: number, end: number): HTMLElement {
|
||
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: HTMLElement[] = [];
|
||
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: unknown) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)),
|
||
);
|
||
return box;
|
||
}
|
||
|
||
function findingActions(taskId: string, r: Review, index: number, f: Record<string, unknown>, where: string): HTMLElement {
|
||
const box = h("span", { class: "finding-actions" });
|
||
const done = (s: FindingState) => 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: FindingState) => 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: unknown) => 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: HTMLElement): () => void {
|
||
const head = h("header", { class: "reviews-header" });
|
||
const body = h("div", { class: "review-list" });
|
||
let reviews: Review[] | undefined;
|
||
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: number, label: string) => {
|
||
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 as ByreEvent[]).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();
|
||
}
|
||
};
|
||
}
|