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
|
|
@ -314,6 +314,17 @@ ones. The route answers:
|
||||||
- **503** when this host has no forge token yet, or the forge failed or
|
- **503** when this host has no forge token yet, or the forge failed or
|
||||||
didn't answer within two minutes
|
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`.
|
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
|
When it's ready, **open a PR** has the service account push a branch that
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
**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
|
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.
|
and edit. It reads the file each time, so a rule merged a minute ago shows up.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
// `202 {id}`, and polls `GET .../try-out/{id}` until the run is `done` or
|
// `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.
|
// `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
|
// **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.
|
// branch (`GET /api/review-classifier/rules`), to run as they are or to edit.
|
||||||
//
|
//
|
||||||
|
|
@ -31,6 +35,7 @@ import {
|
||||||
repoProblem,
|
repoProblem,
|
||||||
toBody,
|
toBody,
|
||||||
type ClassificationDraft,
|
type ClassificationDraft,
|
||||||
|
type OpenPull,
|
||||||
type RulesAnswer,
|
type RulesAnswer,
|
||||||
type TryOutResult as Result,
|
type TryOutResult as Result,
|
||||||
} from "./TryOutTypes.js";
|
} from "./TryOutTypes.js";
|
||||||
|
|
@ -38,6 +43,11 @@ import "./ReviewTryOutPage.css";
|
||||||
|
|
||||||
const TRY_OUT = "/api/review-classifier/try-out";
|
const TRY_OUT = "/api/review-classifier/try-out";
|
||||||
const RULES = "/api/review-classifier/rules";
|
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;
|
const POLL_MS = 2000;
|
||||||
// More classifications than this are not offered a row. It is the try-out
|
// 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
|
// 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);
|
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;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
setElapsed(0);
|
setElapsed(0);
|
||||||
|
|
@ -217,6 +287,7 @@ export function ReviewTryOutPage() {
|
||||||
value={repo}
|
value={repo}
|
||||||
placeholder="owner/name"
|
placeholder="owner/name"
|
||||||
onInput={setRepo}
|
onInput={setRepo}
|
||||||
|
suggestions={repoChoices.map((value) => ({ value }))}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
id="tryout-pr"
|
id="tryout-pr"
|
||||||
|
|
@ -224,6 +295,10 @@ export function ReviewTryOutPage() {
|
||||||
value={pr}
|
value={pr}
|
||||||
pattern="[0-9]+"
|
pattern="[0-9]+"
|
||||||
onInput={setPr}
|
onInput={setPr}
|
||||||
|
suggestions={pulls.map((p) => ({
|
||||||
|
value: String(p.number),
|
||||||
|
label: p.title,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={askLoadRules}
|
onClick={askLoadRules}
|
||||||
|
|
@ -232,6 +307,18 @@ export function ReviewTryOutPage() {
|
||||||
{loadingRules ? "loading…" : "load the repo's rules"}
|
{loadingRules ? "loading…" : "load the repo's rules"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{rulesError ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
context="the repo's rules could not be loaded"
|
context="the repo's rules could not be loaded"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ export interface RulesAnswer {
|
||||||
classifications: ClassificationBody[];
|
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 type Verdict = "match" | "no-match" | "not-applicable";
|
||||||
|
|
||||||
export interface TryOutRow {
|
export interface TryOutRow {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@
|
||||||
// build ahead of a caller" rule the rest of `ui/` follows.
|
// build ahead of a caller" rule the rest of `ui/` follows.
|
||||||
import { FormField } from "../form-field/FormField.js";
|
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({
|
export function TextField({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
|
@ -15,6 +22,7 @@ export function TextField({
|
||||||
title,
|
title,
|
||||||
required,
|
required,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
suggestions,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -25,7 +33,11 @@ export function TextField({
|
||||||
title?: string;
|
title?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
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 (
|
return (
|
||||||
<FormField label={label} htmlFor={id}>
|
<FormField label={label} htmlFor={id}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -37,8 +49,16 @@ export function TextField({
|
||||||
title={title}
|
title={title}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
list={suggestions ? listId : undefined}
|
||||||
onInput={(e) => onInput((e.target as HTMLInputElement).value)}
|
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>
|
</FormField>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,32 @@ pub struct ConfigPrStatus {
|
||||||
pub html_url: Option<String>,
|
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.
|
/// A PR as [`Client::pull_for_review`] reads it for the review classifier.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PullForReview {
|
pub struct PullForReview {
|
||||||
|
|
@ -871,6 +897,35 @@ impl Client {
|
||||||
/// already carries that count, so this stays one request per page
|
/// already carries that count, so this stays one request per page
|
||||||
/// regardless of how many repos exist.
|
/// regardless of how many repos exist.
|
||||||
pub async fn list_repos_with_open_issues(&self) -> Result<Vec<String>> {
|
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;
|
const PAGE_SIZE: u32 = 50;
|
||||||
// 40 pages at 50/page is 2000 repos — comfortably past any repo
|
// 40 pages at 50/page is 2000 repos — comfortably past any repo
|
||||||
// count this instance has today; see `page_search_results` for why
|
// count this instance has today; see `page_search_results` for why
|
||||||
|
|
@ -887,11 +942,29 @@ impl Client {
|
||||||
.data)
|
.data)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(repos
|
Ok(repos)
|
||||||
.into_iter()
|
}
|
||||||
.filter(|r| r.open_issues_count.unwrap_or(0) > 0)
|
|
||||||
.filter_map(|r| r.full_name)
|
/// The open pull requests of `owner/repo`, newest first — the data
|
||||||
.collect())
|
/// 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
|
/// Every open issue in `owner/repo`, each with its dependency-derived
|
||||||
|
|
@ -1650,6 +1723,34 @@ impl Client {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
// Both scenarios live in ONE test, not two, deliberately: `cargo
|
||||||
// test` runs tests in parallel by default, and two tests mutating
|
// test` runs tests in parallel by default, and two tests mutating
|
||||||
// the same process-global env var would race each other. A single
|
// the same process-global env var would race each other. A single
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ pub async fn get_issue_report(
|
||||||
/// anything else — including a 404 on some *other* call this function
|
/// anything else — including a 404 on some *other* call this function
|
||||||
/// doesn't cover, an outage, auth failure, etc. — stays 503, same as before
|
/// doesn't cover, an outage, auth failure, etc. — stays 503, same as before
|
||||||
/// this existed.
|
/// 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>() {
|
match e.downcast_ref::<forgejo_api::ForgejoError>() {
|
||||||
Some(fe) if forge::is_repo_not_found(fe) => axum::http::StatusCode::NOT_FOUND,
|
Some(fe) if forge::is_repo_not_found(fe) => axum::http::StatusCode::NOT_FOUND,
|
||||||
_ => axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
_ => axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ mod issue_report;
|
||||||
mod linked_accounts;
|
mod linked_accounts;
|
||||||
mod matrix_account;
|
mod matrix_account;
|
||||||
mod otel_http_client;
|
mod otel_http_client;
|
||||||
|
mod pulls;
|
||||||
mod queue_identity;
|
mod queue_identity;
|
||||||
mod read_policy;
|
mod read_policy;
|
||||||
mod review_classifier;
|
mod review_classifier;
|
||||||
|
|
@ -768,6 +769,7 @@ fn socket_path() -> PathBuf {
|
||||||
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
||||||
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
|
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
|
||||||
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page)"),
|
(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;
|
struct ApiDoc;
|
||||||
|
|
@ -3333,6 +3335,8 @@ fn build_app(state: AppState) -> axum::Router {
|
||||||
.routes(routes!(issue_report::get_repos))
|
.routes(routes!(issue_report::get_repos))
|
||||||
.routes(routes!(issue_report::get_issue_report_all))
|
.routes(routes!(issue_report::get_issue_report_all))
|
||||||
.routes(routes!(issue_report::get_issue_report))
|
.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::post_try_out))
|
||||||
.routes(routes!(review_classifier::get_try_out))
|
.routes(routes!(review_classifier::get_try_out))
|
||||||
.routes(routes!(review_classifier::get_rules))
|
.routes(routes!(review_classifier::get_rules))
|
||||||
|
|
|
||||||
156
swarm-controller/src/pulls.rs
Normal file
156
swarm-controller/src/pulls.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -551,7 +551,7 @@ impl Run {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `repo` as its owner and name, or what is wrong with it.
|
/// `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('/')
|
repo.split_once('/')
|
||||||
.filter(|(o, n)| segment(o) && segment(n))
|
.filter(|(o, n)| segment(o) && segment(n))
|
||||||
.ok_or_else(|| format!("repo {repo:?} is not owner/name"))
|
.ok_or_else(|| format!("repo {repo:?} is not owner/name"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue