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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue