Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui, swarm-controller: the review try-out's repo and PR fields offer the repos and PRs to pick

Two read-only routes feed them: GET /api/pulls/repos lists the repos with an
open pull request, and GET /api/pulls?repo=owner/name lists a repo's open
pull requests, highest number first. The page offers the lists through the
browser's own suggestion list and keeps both fields free text, since a run
also works on a merged or closed PR.
This commit is contained in:
iris 2026-10-12 19:05:37 +02:00
commit 9a4059e130
10 changed files with 399 additions and 7 deletions

View file

@ -6,6 +6,10 @@
// `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.
//
// The repo and PR fields offer the repos that have an open PR and the open PRs
// of the repo (`GET /api/pulls/repos`, `GET /api/pulls?repo=…`), and stay free
// text: the run also works on a merged or closed PR.
//
// **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.
//
@ -31,6 +35,7 @@ import {
repoProblem,
toBody,
type ClassificationDraft,
type OpenPull,
type RulesAnswer,
type TryOutResult as Result,
} from "./TryOutTypes.js";
@ -38,6 +43,11 @@ import "./ReviewTryOutPage.css";
const TRY_OUT = "/api/review-classifier/try-out";
const RULES = "/api/review-classifier/rules";
const PULL_REPOS = "/api/pulls/repos";
const PULLS = "/api/pulls";
// How long the repo field rests before its PRs are fetched, so typing a repo
// does not ask for the PRs of each prefix of it.
const PICK_DELAY_MS = 300;
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
@ -77,8 +87,68 @@ export function ReviewTryOutPage() {
);
const [confirmLoad, setConfirmLoad] = useState(false);
// What the two fields offer to complete to. The fields stay free text, so a
// repo or a PR the lists lack, a merged one say, can still be typed.
const [repoChoices, setRepoChoices] = useState<string[]>([]);
const [repoChoicesError, setRepoChoicesError] =
useState<ProblemDetails | null>(null);
const [pulls, setPulls] = useState<OpenPull[]>([]);
const [pullsError, setPullsError] = useState<ProblemDetails | null>(null);
const running = starting || runId !== null;
// The repos with an open PR, read once when the page opens.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(PULL_REPOS);
if (cancelled) return;
if (!res.ok) {
setRepoChoicesError(await readApiError(res));
return;
}
setRepoChoices((await res.json()) as string[]);
} catch (e: unknown) {
if (!cancelled) setRepoChoicesError({ detail: String(e) });
}
})();
return () => {
cancelled = true;
};
}, []);
// The open PRs of the repo once it is typed or picked and has stopped
// changing. A repo that is not valid yet has no list.
useEffect(() => {
setPulls([]);
setPullsError(null);
const target = repo.trim();
if (repoProblem(target) !== null) return;
let cancelled = false;
const timer = setTimeout(() => {
void (async () => {
try {
const res = await fetch(
`${PULLS}?repo=${encodeURIComponent(target)}`,
);
if (cancelled) return;
if (!res.ok) {
setPullsError(await readApiError(res));
return;
}
setPulls((await res.json()) as OpenPull[]);
} catch (e: unknown) {
if (!cancelled) setPullsError({ detail: String(e) });
}
})();
}, PICK_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [repo]);
useEffect(() => {
if (!running) return;
setElapsed(0);
@ -217,6 +287,7 @@ export function ReviewTryOutPage() {
value={repo}
placeholder="owner/name"
onInput={setRepo}
suggestions={repoChoices.map((value) => ({ value }))}
/>
<TextField
id="tryout-pr"
@ -224,6 +295,10 @@ export function ReviewTryOutPage() {
value={pr}
pattern="[0-9]+"
onInput={setPr}
suggestions={pulls.map((p) => ({
value: String(p.number),
label: p.title,
}))}
/>
<Button
onClick={askLoadRules}
@ -232,6 +307,18 @@ export function ReviewTryOutPage() {
{loadingRules ? "loading…" : "load the repo's rules"}
</Button>
</div>
{repoChoicesError ? (
<ApiErrorPanel
context="the repos with an open PR could not be listed"
problem={repoChoicesError}
/>
) : null}
{pullsError ? (
<ApiErrorPanel
context="the repo's open PRs could not be listed"
problem={pullsError}
/>
) : null}
{rulesError ? (
<ApiErrorPanel
context="the repo's rules could not be loaded"

View file

@ -34,6 +34,13 @@ export interface RulesAnswer {
classifications: ClassificationBody[];
}
/** An open PR, as `GET /api/pulls?repo=…` lists it. */
export interface OpenPull {
number: number;
title: string;
head_sha?: string;
}
export type Verdict = "match" | "no-match" | "not-applicable";
export interface TryOutRow {

View file

@ -5,6 +5,13 @@
// build ahead of a caller" rule the rest of `ui/` follows.
import { FormField } from "../form-field/FormField.js";
/** One value the field offers to complete to. */
export interface TextFieldSuggestion {
value: string;
/** Shown beside the value in the browser's list. */
label?: string;
}
export function TextField({
id,
label,
@ -15,6 +22,7 @@ export function TextField({
title,
required,
placeholder,
suggestions,
}: {
id: string;
label: string;
@ -25,7 +33,11 @@ export function TextField({
title?: string;
required?: boolean;
placeholder?: string;
/** Values to pick from. The field stays free text: a value that is not in
* the list can still be typed. */
suggestions?: TextFieldSuggestion[];
}) {
const listId = `${id}-suggestions`;
return (
<FormField label={label} htmlFor={id}>
<input
@ -37,8 +49,16 @@ export function TextField({
title={title}
required={required}
placeholder={placeholder}
list={suggestions ? listId : undefined}
onInput={(e) => onInput((e.target as HTMLInputElement).value)}
/>
{suggestions ? (
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.value} value={s.value} label={s.label} />
))}
</datalist>
) : null}
</FormField>
);
}