Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: load a repo's rules into the review try-out

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.
This commit is contained in:
iris 2026-10-12 08:18:19 +02:00 • committed by mara
commit cf4c4cde87
4 changed files with 127 additions and 0 deletions

View file

@ -20,6 +20,14 @@
.ui-tryout-target > * {
flex: 1 1 14em;
}
.ui-tryout-target > button {
flex: none;
align-self: flex-end;
}
.ui-tryout-loaded {
margin: 0;
color: var(--muted);
}
.ui-tryout-classification {
display: flex;
flex-direction: column;

View file

@ -6,6 +6,9 @@
// `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
@ -14,22 +17,27 @@ 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
@ -61,6 +69,14 @@ export function ReviewTryOutPage() {
// 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(() => {
@ -151,6 +167,35 @@ export function ReviewTryOutPage() {
}
}
// 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)));
}
@ -180,7 +225,28 @@ export function ReviewTryOutPage() {
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}
@ -211,6 +277,18 @@ export function ReviewTryOutPage() {
{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 ? (

View file

@ -23,6 +23,17 @@ export interface ClassificationBody {
threshold: number;
}
/** The rules route's answer (`GET /api/review-classifier/rules?repo=…`): the
* classifications on the repo's default branch, and where they were read. */
export interface RulesAnswer {
/** The file read: the `.yml` or the `.json` one. */
path: string;
branch: string;
/** The default branch's head commit the file was read at. */
commit: string;
classifications: ClassificationBody[];
}
export type Verdict = "match" | "no-match" | "not-applicable";
export interface TryOutRow {
@ -74,6 +85,28 @@ export function thresholdValue(text: string): number | null {
return n <= 100 ? n : null;
}
/** A loaded rule as a form row. */
export function draftFromRule(
rule: ClassificationBody,
key: number,
): ClassificationDraft {
return {
key,
name: rule.name,
description: rule.description,
polarity: rule.polarity,
threshold: String(rule.threshold),
};
}
/** Whether any row holds a name or description the user typed, which loading
* the repo's rules would replace. The untouched starting row does not. */
export function hasTypedText(drafts: ClassificationDraft[]): boolean {
return drafts.some(
(d) => d.name.trim() !== "" || d.description.trim() !== "",
);
}
/** Why a classification is not ready to send, or null when it is. */
export function draftProblem(d: ClassificationDraft): string | null {
if (d.name.trim() === "") return "give each classification a name";