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

@ -55,6 +55,14 @@ Give it a repo as `owner/name` and a PR number, then write a classification: a
name, a description, a polarity (positive or negative) and a threshold from 0
to 100. **add a classification** adds more, and **run** starts the run.
**load the repo's rules** fills the classifications from the rules file on the
repo's default branch, so you can run the repo's own rules or start from them
and edit. It reads the file each time, so a rule merged a minute ago shows up.
The page names the file and the commit it read, and asks before it replaces
classifications you have written. If the repo has no rules file, the file
doesn't parse, or it holds more than ten rules, the page shows the reason and
leaves your classifications as they were.
A run takes a minute or more, and up to half an hour for ten classifications.
The page says it's running and shows how long it has been, then shows the review's table: for each classification the verdict,
the model's certainty, the threshold, whether the verdict counted and the

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";