159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
import { get, type ByreEvent, type Schemas } from "./api.js";
|
|||
|
|
import { ago, errorText, h, replace } from "./dom.js";
|
||
|
|
import { markdown } from "./markdown.js";
|
||
|
|
import { onEach, state } from "./state.js";
|
||
|
|
|
||
|
|
type Review = Schemas["Review"];
|
||
|
|
|
||
|
|
const severities = ["critical", "high", "medium", "low", "info"];
|
||
|
|
|
||
|
|
export function isReviewEvent(ev: ByreEvent): boolean {
|
||
|
|
return ev.kind.startsWith("review.");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function reviewCounts(r: Review): string {
|
||
|
|
const res = (r.result ?? {}) as Record<string, unknown>;
|
||
|
|
const findings = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||
|
|
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";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||
|
|
const md = { taskId };
|
||
|
|
return h(
|
||
|
|
"div",
|
||
|
|
{ class: "review-result" },
|
||
|
|
markdown(String(res["summary"] ?? ""), "md review-summary", md),
|
||
|
|
findings.length > 0
|
||
|
|
? h(
|
||
|
|
"ol",
|
||
|
|
{ class: "findings" },
|
||
|
|
...findings.map((f) => {
|
||
|
|
const severity = String(f["severity"] ?? "");
|
||
|
|
const where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
|
||
|
|
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,
|
||
|
|
),
|
||
|
|
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
: null,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function mountReviews(root: HTMLElement): () => void {
|
||
|
|
const list = 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;
|
||
|
|
if (!taskId) {
|
||
|
|
replace(list, h("div", { class: "empty" }, "No task selected."));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (error) {
|
||
|
|
replace(list, h("div", { class: "empty error-text" }, error));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!reviews) {
|
||
|
|
replace(list, h("div", { class: "empty" }, "Loading…"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (reviews.length === 0) {
|
||
|
|
replace(list, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
replace(list, ...[...reviews].reverse().map((r) => reviewCard(r, taskId)));
|
||
|
|
};
|
||
|
|
|
||
|
|
const offs = [
|
||
|
|
onEach(["selection"], () => {
|
||
|
|
target++;
|
||
|
|
reviews = undefined;
|
||
|
|
error = "";
|
||
|
|
render();
|
||
|
|
void load();
|
||
|
|
}),
|
||
|
|
onEach(["transcript"], (fresh) => {
|
||
|
|
if ((fresh as ByreEvent[]).some(isReviewEvent)) {
|
||
|
|
void load();
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
|
||
|
|
replace(root, h("div", { class: "reviews-view" }, list));
|
||
|
|
render();
|
||
|
|
void load();
|
||
|
|
return () => {
|
||
|
|
disposed = true;
|
||
|
|
for (const off of offs) {
|
||
|
|
off();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|