diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md index 7beffa4a..e6be7dcb 100644 --- a/docs/swarm/review-classifier.md +++ b/docs/swarm/review-classifier.md @@ -314,6 +314,17 @@ ones. The route answers: - **503** when this host has no forge token yet, or the forge failed or didn't answer within two minutes +The page's repo and PR fields offer values to pick from, and stay free text, +since a run also works on a merged or closed PR. Two routes feed them: + +- `GET /api/pulls/repos` lists the repos with at least one open PR, as + `owner/name`. +- `GET /api/pulls?repo=owner/name` lists the repo's open PRs, highest number + first, each with `number`, `title` and `head_sha`. It answers **400** for a + `repo` that's missing or not `owner/name`, **404** for a repo the forge + doesn't know, and **503** when this host has no forge token yet, or the + forge failed or didn't answer within two minutes. + Every error is problem+json with the reason in `detail`. When it's ready, **open a PR** has the service account push a branch that diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index a4f50251..e6c3ce37 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -55,6 +55,12 @@ 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. +The repo field offers the repos that have an open PR, and once you've picked +or typed a repo, the PR field offers its open PRs by number and title. Both +fields stay free text: type a repo or a PR the lists lack, such as a merged +one. If a list can't load, the page shows the reason and the field still takes +what you type. + **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. diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx index 017d21db..99cc9423 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx @@ -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([]); + const [repoChoicesError, setRepoChoicesError] = + useState(null); + const [pulls, setPulls] = useState([]); + const [pullsError, setPullsError] = useState(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 }))} /> ({ + value: String(p.number), + label: p.title, + }))} />