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

@ -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

View file

@ -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.

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>
);
}

View file

@ -58,6 +58,32 @@ pub struct ConfigPrStatus {
pub html_url: Option<String>,
}
/// An open PR as the review try-out's PR dropdown lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
pub struct OpenPull {
pub number: u64,
pub title: String,
/// The head commit the PR is at now. Absent only if Forgejo omitted it.
pub head_sha: Option<String>,
}
/// `pulls` as the dropdown's rows, newest (highest number) first. A PR
/// without a usable number cannot be named, so it is left out.
fn open_pulls(pulls: Vec<forgejo_api::structs::PullRequest>) -> Vec<OpenPull> {
let mut rows: Vec<OpenPull> = pulls
.into_iter()
.filter_map(|pr| {
Some(OpenPull {
number: u64::try_from(pr.number?).ok()?,
title: pr.title.unwrap_or_default(),
head_sha: pr.head.and_then(|h| h.sha),
})
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.number));
rows
}
/// A PR as [`Client::pull_for_review`] reads it for the review classifier.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PullForReview {
@ -871,6 +897,35 @@ impl Client {
/// already carries that count, so this stays one request per page
/// regardless of how many repos exist.
pub async fn list_repos_with_open_issues(&self) -> Result<Vec<String>> {
Ok(Self::full_names_where(self.search_repos().await?, |r| {
r.open_issues_count.unwrap_or(0) > 0
}))
}
/// Repos with at least one open pull request, as `owner/name` full
/// names — the data source for the review try-out's repo dropdown. Same
/// one-request-per-page shape as [`Self::list_repos_with_open_issues`],
/// filtered on `Repository::open_pr_counter`.
pub async fn list_repos_with_open_pulls(&self) -> Result<Vec<String>> {
Ok(Self::full_names_where(self.search_repos().await?, |r| {
r.open_pr_counter.unwrap_or(0) > 0
}))
}
/// The `owner/name` of each repo in `repos` that `keep` accepts.
fn full_names_where(
repos: Vec<forgejo_api::structs::Repository>,
keep: impl Fn(&forgejo_api::structs::Repository) -> bool,
) -> Vec<String> {
repos
.into_iter()
.filter(|r| keep(r))
.filter_map(|r| r.full_name)
.collect()
}
/// Every repo the forge hosts, from the search endpoint.
async fn search_repos(&self) -> Result<Vec<forgejo_api::structs::Repository>> {
const PAGE_SIZE: u32 = 50;
// 40 pages at 50/page is 2000 repos — comfortably past any repo
// count this instance has today; see `page_search_results` for why
@ -887,11 +942,29 @@ impl Client {
.data)
})
.await?;
Ok(repos
.into_iter()
.filter(|r| r.open_issues_count.unwrap_or(0) > 0)
.filter_map(|r| r.full_name)
.collect())
Ok(repos)
}
/// The open pull requests of `owner/repo`, newest first — the data
/// source for the review try-out's PR dropdown. A Forgejo 404 stays in
/// the error chain for [`is_repo_not_found`] to find.
pub async fn list_open_pulls(&self, owner: &str, repo: &str) -> Result<Vec<OpenPull>> {
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
sort: None,
milestone: None,
labels: None,
poster: None,
base: None,
head: None,
};
let pulls = self
.api
.repo_list_pull_requests(owner, repo, query)
.all()
.await
.with_context(|| format!("list open pull requests in {owner}/{repo}"))?;
Ok(open_pulls(pulls))
}
/// Every open issue in `owner/repo`, each with its dependency-derived
@ -1650,6 +1723,34 @@ impl Client {
mod tests {
use super::*;
#[test]
fn open_pulls_are_listed_newest_first_and_skip_one_with_no_number() {
// The generated type insists on `requested_reviewers` being present.
let pull = |mut pr: serde_json::Value| {
pr["requested_reviewers"] = serde_json::json!([]);
serde_json::from_value(pr).expect("a pull request")
};
let pulls = vec![
pull(serde_json::json!({"number": 7, "title": "older", "head": {"sha": "aaa"}})),
pull(serde_json::json!({"title": "no number", "head": {"sha": "bbb"}})),
pull(serde_json::json!({"number": 12, "title": "newer", "head": {"sha": "ccc"}})),
pull(serde_json::json!({"number": 9, "head": {}})),
];
let want = |number, title: &str, head_sha: Option<&str>| OpenPull {
number,
title: title.to_owned(),
head_sha: head_sha.map(str::to_owned),
};
assert_eq!(
open_pulls(pulls),
vec![
want(12, "newer", Some("ccc")),
want(9, "", None),
want(7, "older", Some("aaa")),
]
);
}
// Both scenarios live in ONE test, not two, deliberately: `cargo
// test` runs tests in parallel by default, and two tests mutating
// the same process-global env var would race each other. A single

View file

@ -141,7 +141,7 @@ pub async fn get_issue_report(
/// anything else — including a 404 on some *other* call this function
/// doesn't cover, an outage, auth failure, etc. — stays 503, same as before
/// this existed.
fn issue_report_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
pub(crate) fn issue_report_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
match e.downcast_ref::<forgejo_api::ForgejoError>() {
Some(fe) if forge::is_repo_not_found(fe) => axum::http::StatusCode::NOT_FOUND,
_ => axum::http::StatusCode::SERVICE_UNAVAILABLE,

View file

@ -57,6 +57,7 @@ mod issue_report;
mod linked_accounts;
mod matrix_account;
mod otel_http_client;
mod pulls;
mod queue_identity;
mod read_policy;
mod review_classifier;
@ -768,6 +769,7 @@ fn socket_path() -> PathBuf {
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page)"),
(name = "pulls", description = "open pull requests, for the try-out page's dropdowns"),
)
)]
struct ApiDoc;
@ -3333,6 +3335,8 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
.routes(routes!(issue_report::get_issue_report))
.routes(routes!(pulls::get_pull_repos))
.routes(routes!(pulls::get_pulls))
.routes(routes!(review_classifier::post_try_out))
.routes(routes!(review_classifier::get_try_out))
.routes(routes!(review_classifier::get_rules))

View file

@ -0,0 +1,156 @@
//! The data the review try-out's dropdowns list: the repos that have an open
//! pull request, and a repo's open pull requests. Read-only views of the
//! forge, over the controller's own forge token. The try-out page still takes
//! a repo or a PR typed by hand, since the run also works on a PR that is
//! merged or closed.
use axum::Json;
use axum::extract::rejection::QueryRejection;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::Deserialize;
use utoipa::IntoParams;
use crate::forge::{self, OpenPull};
use crate::issue_report::issue_report_error_status;
use crate::review_classifier::split_repo;
use crate::{AppState, error_problem};
/// Whose open pull requests to list.
#[derive(Debug, Deserialize, IntoParams)]
pub(crate) struct PullsQuery {
/// `owner/name`.
repo: String,
}
/// Repos with at least one open pull request, as `owner/name`.
#[utoipa::path(
get,
path = "/api/pulls/repos",
responses(
(status = 200, description = "repos with at least one open pull request, as owner/name", body = Vec<String>),
(status = 503, description = "this host has no forge token yet; or the forge failed or \
did not answer within two minutes (problem+json)", body = String),
),
tag = "pulls"
)]
pub(crate) async fn get_pull_repos(
State(state): State<AppState>,
) -> Result<Json<Vec<String>>, ProblemDetails> {
let Some(client) = state.forge.get() else {
return Err(no_forge());
};
let repos = within_deadline("pull request repos", client.list_repos_with_open_pulls()).await?;
Ok(Json(repos.map_err(|e| unavailable(&e))?))
}
/// A repo's open pull requests, newest first.
#[utoipa::path(
get,
path = "/api/pulls",
params(PullsQuery),
responses(
(status = 200, description = "the repo's open pull requests, highest number first", body = Vec<OpenPull>),
(status = 400, description = "no `repo`, or a repo that is not owner/name with segments \
of `[A-Za-z0-9._-]` other than `.` and `..` (problem+json)", body = String),
(status = 404, description = "the forge knows no such repo (problem+json)", body = String),
(status = 503, description = "this host has no forge token yet; or the forge failed or \
did not answer within two minutes (problem+json)", body = String),
),
tag = "pulls"
)]
pub(crate) async fn get_pulls(
State(state): State<AppState>,
query: Result<Query<PullsQuery>, QueryRejection>,
) -> Result<Json<Vec<OpenPull>>, ProblemDetails> {
let Query(query) = query.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
let (owner, name) =
split_repo(&query.repo).map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?;
let Some(client) = state.forge.get() else {
return Err(no_forge());
};
let pulls = within_deadline("open pull requests", client.list_open_pulls(owner, name))
.await?
.map_err(|e| {
if issue_report_error_status(&e) == StatusCode::NOT_FOUND {
error_problem(StatusCode::NOT_FOUND, &format!("{owner}/{name} not found"))
} else {
unavailable(&e)
}
})?;
Ok(Json(pulls))
}
fn no_forge() -> ProblemDetails {
error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"no forge access on this host yet",
)
}
/// `fut`'s answer, or a 503 if the forge did not answer by the deadline.
async fn within_deadline<T>(
what: &str,
fut: impl std::future::Future<Output = T>,
) -> Result<T, ProblemDetails> {
forge::within_forge_deadline(what, fut)
.await
.ok_or_else(|| {
error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"the forge did not answer within the deadline",
)
})
}
fn unavailable(e: &anyhow::Error) -> ProblemDetails {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "listing pull requests failed");
error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail)
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt as _;
async fn get(uri: &str) -> (StatusCode, serde_json::Value) {
let (state, _) = crate::tests::state_with_roster();
let req = Request::get(uri).body(Body::empty()).expect("a request");
let res = crate::build_app(state)
.oneshot(req)
.await
.expect("infallible");
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.expect("a body");
(status, serde_json::from_slice(&bytes).unwrap_or_default())
}
#[tokio::test]
async fn a_bad_or_missing_repo_is_400_before_the_forge_is_asked() {
for (query, says) in [
("", "repo"),
("?repo=o", "owner/name"),
("?repo=o/..", "owner/name"),
("?repo=a/b/c", "owner/name"),
] {
let (status, problem) = get(&format!("/api/pulls{query}")).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{query}: {problem}");
let detail = problem["detail"].as_str().expect("a problem+json detail");
assert!(detail.contains(says), "{query}: {detail}");
}
}
#[tokio::test]
async fn both_lists_without_forge_access_on_this_host_are_503() {
for uri in ["/api/pulls?repo=o/r", "/api/pulls/repos"] {
let (status, problem) = get(uri).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{uri}: {problem}");
assert_eq!(problem["detail"], "no forge access on this host yet");
}
}
}

View file

@ -551,7 +551,7 @@ impl Run {
}
/// `repo` as its owner and name, or what is wrong with it.
fn split_repo(repo: &str) -> Result<(&str, &str), String> {
pub(crate) fn split_repo(repo: &str) -> Result<(&str, &str), String> {
repo.split_once('/')
.filter(|(o, n)| segment(o) && segment(n))
.ok_or_else(|| format!("repo {repo:?} is not owner/name"))