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:
parent
60ea7fdc04
commit
9a4059e130
10 changed files with 399 additions and 7 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue