A button beside the repo field reads the repo's rules from its default branch into the classification rows, names the file and the commit it read, asks before replacing rows the operator wrote, and shows the route's error without touching the rows.
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
// <ReviewTryOutPage> — write a review classification and see how the model
|
|
// answers it on a real PR, before it goes into a repo's config
|
|
// (`docs/swarm/review-classifier.md`, "Try-out"). Pick a repo and a PR, write
|
|
// one or more classifications, run: the page posts them to
|
|
// swarm-controller's `POST /api/review-classifier/try-out`, which answers
|
|
// `202 {id}`, and polls `GET .../try-out/{id}` until the run is `done` or
|
|
// `failed`. A run takes a minute or more, so the page says it is running.
|
|
//
|
|
// **load the repo's rules** fills the rows from the rules on the repo's default
|
|
// branch (`GET /api/review-classifier/rules`), to run as they are or to edit.
|
|
//
|
|
// The inputs stay as typed after a run, so the operator can edit and run
|
|
// again. The page keeps nothing you typed and no result, and posts nothing to
|
|
// the forge; the only thing the browser remembers is the result table's
|
|
// column filters, as for every table in the UI.
|
|
import { useEffect, useState } from "preact/hooks";
|
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|
import { Button } from "../../ui/button/Button.js";
|
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
|
import { Panel } from "../../ui/panel/Panel.js";
|
|
import { TextField } from "../../ui/text-field/TextField.js";
|
|
import { ClassificationEditor } from "./ClassificationEditor.js";
|
|
import { TryOutResult } from "./TryOutResult.js";
|
|
import {
|
|
draftFromRule,
|
|
draftProblem,
|
|
duplicateName,
|
|
hasTypedText,
|
|
prNumber,
|
|
repoProblem,
|
|
toBody,
|
|
type ClassificationDraft,
|
|
type RulesAnswer,
|
|
type TryOutResult as Result,
|
|
} from "./TryOutTypes.js";
|
|
import "./ReviewTryOutPage.css";
|
|
|
|
const TRY_OUT = "/api/review-classifier/try-out";
|
|
const RULES = "/api/review-classifier/rules";
|
|
const POLL_MS = 2000;
|
|
// More classifications than this are not offered a row. It is the try-out
|
|
// endpoint's own cap, which answers a longer list with a 400 that the page
|
|
// shows as it is.
|
|
const MAX_CLASSIFICATIONS = 10;
|
|
|
|
let nextKey = 0;
|
|
function blankDraft(): ClassificationDraft {
|
|
return {
|
|
key: nextKey++,
|
|
name: "",
|
|
description: "",
|
|
polarity: "positive",
|
|
threshold: "80",
|
|
};
|
|
}
|
|
|
|
export function ReviewTryOutPage() {
|
|
const [repo, setRepo] = useState("");
|
|
const [pr, setPr] = useState("");
|
|
const [drafts, setDrafts] = useState<ClassificationDraft[]>(() => [
|
|
blankDraft(),
|
|
]);
|
|
// The id of the run being polled; null when none is in flight.
|
|
const [runId, setRunId] = useState<string | null>(null);
|
|
const [starting, setStarting] = useState(false);
|
|
const [result, setResult] = useState<Result | null>(null);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
// Whole seconds since the run started, so a long wait shows it is alive.
|
|
const [elapsed, setElapsed] = useState(0);
|
|
|
|
const [loadingRules, setLoadingRules] = useState(false);
|
|
const [rulesError, setRulesError] = useState<ProblemDetails | null>(null);
|
|
// Where the rows came from, until the next load replaces it.
|
|
const [loaded, setLoaded] = useState<(RulesAnswer & { repo: string }) | null>(
|
|
null,
|
|
);
|
|
const [confirmLoad, setConfirmLoad] = useState(false);
|
|
|
|
const running = starting || runId !== null;
|
|
|
|
useEffect(() => {
|
|
if (!running) return;
|
|
setElapsed(0);
|
|
const t = setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
return () => clearInterval(t);
|
|
}, [running]);
|
|
|
|
// Polls the run until it leaves `running`. Cancelled by the next run, or by
|
|
// leaving the page.
|
|
useEffect(() => {
|
|
if (runId === null) return;
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const stop = (problem: ProblemDetails) => {
|
|
setError(problem);
|
|
setRunId(null);
|
|
};
|
|
async function poll() {
|
|
try {
|
|
const res = await fetch(
|
|
`${TRY_OUT}/${encodeURIComponent(runId ?? "")}`,
|
|
);
|
|
if (cancelled) return;
|
|
if (res.status === 404) {
|
|
stop({
|
|
detail:
|
|
"the result is gone, because it expired or the controller restarted: run it again",
|
|
});
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const problem = await readApiError(res);
|
|
if (!cancelled) stop(problem);
|
|
return;
|
|
}
|
|
const body = (await res.json()) as Result;
|
|
if (cancelled) return;
|
|
setResult(body);
|
|
if (body.state === "running")
|
|
timer = setTimeout(() => void poll(), POLL_MS);
|
|
else setRunId(null);
|
|
} catch (e: unknown) {
|
|
if (!cancelled) stop({ detail: String(e) });
|
|
}
|
|
}
|
|
void poll();
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
};
|
|
}, [runId]);
|
|
|
|
const problem =
|
|
repoProblem(repo) ??
|
|
(prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ??
|
|
drafts.map(draftProblem).find((p) => p !== null) ??
|
|
duplicateName(drafts) ??
|
|
null;
|
|
|
|
async function run(e: Event) {
|
|
e.preventDefault();
|
|
const number = prNumber(pr);
|
|
if (problem !== null || number === null) return;
|
|
setStarting(true);
|
|
setError(null);
|
|
setResult(null);
|
|
try {
|
|
const res = await fetch(TRY_OUT, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
repo: repo.trim(),
|
|
pr: number,
|
|
classifications: drafts.map(toBody),
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
setError(await readApiError(res));
|
|
return;
|
|
}
|
|
setRunId(((await res.json()) as { id: string }).id);
|
|
} catch (err: unknown) {
|
|
setError({ detail: String(err) });
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
}
|
|
|
|
// Reads the repo's rules from its default branch into the rows. A failed
|
|
// load leaves the rows as they were.
|
|
async function loadRules() {
|
|
const target = repo.trim();
|
|
setConfirmLoad(false);
|
|
setLoadingRules(true);
|
|
setRulesError(null);
|
|
try {
|
|
const res = await fetch(`${RULES}?repo=${encodeURIComponent(target)}`);
|
|
if (!res.ok) {
|
|
setRulesError(await readApiError(res));
|
|
return;
|
|
}
|
|
const answer = (await res.json()) as RulesAnswer;
|
|
setDrafts(answer.classifications.map((c) => draftFromRule(c, nextKey++)));
|
|
setLoaded({ ...answer, repo: target });
|
|
} catch (err: unknown) {
|
|
setRulesError({ detail: String(err) });
|
|
} finally {
|
|
setLoadingRules(false);
|
|
}
|
|
}
|
|
|
|
// Typed rows are the operator's work, so replacing them is asked first.
|
|
function askLoadRules() {
|
|
if (hasTypedText(drafts)) setConfirmLoad(true);
|
|
else void loadRules();
|
|
}
|
|
|
|
function changeDraft(next: ClassificationDraft) {
|
|
setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d)));
|
|
}
|
|
|
|
return (
|
|
<div class="ui-tryout-page">
|
|
<Panel title="review try-out" icon="🧪">
|
|
<p>
|
|
Run a review classification against a real PR and see how the model
|
|
answers it. Nothing is posted to the forge, and nothing is saved:
|
|
change the classification and run it again until it answers the way
|
|
you want.
|
|
</p>
|
|
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
|
|
<div class="ui-tryout-target">
|
|
<TextField
|
|
id="tryout-repo"
|
|
label="repo"
|
|
value={repo}
|
|
placeholder="owner/name"
|
|
onInput={setRepo}
|
|
/>
|
|
<TextField
|
|
id="tryout-pr"
|
|
label="PR number"
|
|
value={pr}
|
|
pattern="[0-9]+"
|
|
onInput={setPr}
|
|
/>
|
|
<Button
|
|
onClick={askLoadRules}
|
|
disabled={loadingRules || running || repoProblem(repo) !== null}
|
|
>
|
|
{loadingRules ? "loading…" : "load the repo's rules"}
|
|
</Button>
|
|
</div>
|
|
{rulesError ? (
|
|
<ApiErrorPanel
|
|
context="the repo's rules could not be loaded"
|
|
problem={rulesError}
|
|
/>
|
|
) : null}
|
|
{loaded ? (
|
|
<p class="ui-tryout-loaded">
|
|
Loaded {loaded.classifications.length} from{" "}
|
|
<code>{loaded.path}</code> on <code>{loaded.branch}</code> of{" "}
|
|
{loaded.repo}, read at commit{" "}
|
|
<code>{loaded.commit.slice(0, 12)}</code>. Edit them below, then
|
|
run.
|
|
</p>
|
|
) : null}
|
|
{drafts.map((d) => (
|
|
<ClassificationEditor
|
|
key={d.key}
|
|
draft={d}
|
|
onChange={changeDraft}
|
|
onRemove={
|
|
drafts.length > 1
|
|
? () => setDrafts((ds) => ds.filter((x) => x.key !== d.key))
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
<div class="ui-tryout-actions">
|
|
<Button
|
|
onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
|
|
disabled={drafts.length >= MAX_CLASSIFICATIONS}
|
|
>
|
|
add a classification
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={running || problem !== null}
|
|
>
|
|
{running ? "running…" : "run"}
|
|
</Button>
|
|
</div>
|
|
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
|
|
</form>
|
|
</Panel>
|
|
<ConfirmDialog
|
|
open={confirmLoad}
|
|
label="replace the classifications"
|
|
confirmLabel="replace them"
|
|
onCancel={() => setConfirmLoad(false)}
|
|
onConfirm={() => void loadRules()}
|
|
>
|
|
<p>
|
|
Loading the rules of {repo.trim()} replaces the classifications you
|
|
have written below.
|
|
</p>
|
|
</ConfirmDialog>
|
|
{running || result || error ? (
|
|
<Panel title="result" icon="📋">
|
|
{running ? (
|
|
<p class="ui-tryout-running" role="status">
|
|
Running — a run takes a minute or more, and up to half an hour for
|
|
a long list ({elapsed}s so far).
|
|
</p>
|
|
) : null}
|
|
{error ? (
|
|
<ApiErrorPanel context="the try-out failed" problem={error} />
|
|
) : null}
|
|
{result?.state === "failed" ? (
|
|
<ApiErrorPanel
|
|
context="the run failed"
|
|
problem={{
|
|
title: "the classifier could not finish",
|
|
detail: result.error ?? "no reason given",
|
|
}}
|
|
/>
|
|
) : null}
|
|
{result?.state === "done" ? <TryOutResult result={result} /> : null}
|
|
</Panel>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|