swarm-controller: add a forge issue-report data source for swarm-ui
This commit is contained in:
parent
7172176b4c
commit
f1b729e6b5
2 changed files with 330 additions and 5 deletions
|
|
@ -20,13 +20,15 @@ use forgejo_api::structs::{
|
|||
AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation,
|
||||
ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption,
|
||||
CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption,
|
||||
CreateUserOption, RepoGetContentsQuery, RepoListPullRequestsQuery,
|
||||
RepoListPullRequestsQueryState,
|
||||
CreateUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
||||
RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
||||
RepoSearchQuery, StateType,
|
||||
};
|
||||
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
||||
use futures_util::{StreamExt as _, TryStreamExt as _};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::webhook::DeliveryKind;
|
||||
|
|
@ -41,6 +43,31 @@ pub struct ConfigPrStatus {
|
|||
pub html_url: Option<String>,
|
||||
}
|
||||
|
||||
/// One row of [`Client::issue_report`] — an open issue plus the two facts
|
||||
/// derived from its dependency graph that swarm-ui's issue-report page
|
||||
/// sorts/filters on. See that function's doc comment for how both are
|
||||
/// computed.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
|
||||
pub struct IssueReportRow {
|
||||
/// `owner/name` — present on every row (not just the cross-repo
|
||||
/// aggregate) so a caller can use the same row type for either
|
||||
/// endpoint without a second shape to branch on.
|
||||
pub repo: String,
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignee: Option<String>,
|
||||
pub html_url: Option<String>,
|
||||
/// True if any of this issue's own dependencies is still open — same
|
||||
/// semantics `hive-forge issue dependency list` already uses (a closed
|
||||
/// blocker doesn't count).
|
||||
pub blocked: bool,
|
||||
/// How many OTHER issues in this same report list this issue as a
|
||||
/// dependency. The reverse of `blocked`, for ranking "fix this one to
|
||||
/// unblock the most other work" — mara's own framing for the field.
|
||||
pub depended_on_by_count: u32,
|
||||
}
|
||||
|
||||
/// The `operators` team, whitelisted for the merge gate on every repo
|
||||
/// this client protects — provisioned by `hive-c0re::forge::repos`
|
||||
/// already (`ensure_operators_team`), not re-provisioned here. If that
|
||||
|
|
@ -549,6 +576,177 @@ impl Client {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Repos with at least one open issue, as `owner/name` full names —
|
||||
/// the data source for swarm-ui's repo-filter dropdown (mara: "repo
|
||||
/// filter should be dropdown (list only repos with open issues)").
|
||||
/// Filtered on `Repository::open_issues_count` from the search result
|
||||
/// itself rather than a follow-up `issue_list_issues` call per repo —
|
||||
/// forgejo's own repo summary already carries that count, so this
|
||||
/// stays one request regardless of how many repos exist.
|
||||
///
|
||||
/// One search page (forgejo's own default page size) — this binding's
|
||||
/// `RepoSearchQuery` has no `page`/`limit` field to page through, unlike
|
||||
/// the `(Headers, Vec<T>)`-shaped list endpoints elsewhere in this file
|
||||
/// that `.all()` fully drains. Fine for admin-facing tooling at today's
|
||||
/// repo count; revisit if an instance's repo count ever outgrows one
|
||||
/// page.
|
||||
pub async fn list_repos_with_open_issues(&self) -> Result<Vec<String>> {
|
||||
let results = self
|
||||
.api
|
||||
.repo_search(RepoSearchQuery::default())
|
||||
.await
|
||||
.context("search repos")?;
|
||||
Ok(results
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|r| r.open_issues_count.unwrap_or(0) > 0)
|
||||
.filter_map(|r| r.full_name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every open issue in `owner/repo`, each with its dependency-derived
|
||||
/// `blocked` / `depended_on_by_count` pre-resolved server-side — the
|
||||
/// whole job behind `GET /api/repos/{org}/{repo}/issue-report`. Doing
|
||||
/// this resolution client-side (one dependency lookup per issue, on
|
||||
/// every page load) doesn't scale, which is the reason this endpoint
|
||||
/// exists at all (iris's own framing on the design thread that led here).
|
||||
///
|
||||
/// Forgejo has no bulk or reverse dependency query — the same
|
||||
/// limitation `hive-forge issue dependency` hits — so this is still one
|
||||
/// `issue_list_issue_dependencies` call per open issue, just run with
|
||||
/// bounded concurrency (`CONCURRENCY`) rather than one-at-a-time or all
|
||||
/// at once, so a repo with hundreds of open issues doesn't serialize on
|
||||
/// round-trips or fire them all in one burst.
|
||||
///
|
||||
/// `depended_on_by_count` is a reverse index built off that SAME
|
||||
/// resolve pass, not a second query: every open issue's own dependency
|
||||
/// list is already in hand to compute `blocked`, so counting how often
|
||||
/// each open issue's number appears across all of them is pure
|
||||
/// in-memory aggregation. A dependency on a closed issue, or one
|
||||
/// outside this open-issue set, can never accrue a count here — it
|
||||
/// isn't a row in the report either, so "how many rows depend on this
|
||||
/// row" would have nothing to point at.
|
||||
pub async fn issue_report(&self, owner: &str, repo: &str) -> Result<Vec<IssueReportRow>> {
|
||||
const CONCURRENCY: usize = 8;
|
||||
|
||||
let issues = self
|
||||
.api
|
||||
.issue_list_issues(
|
||||
owner,
|
||||
repo,
|
||||
IssueListIssuesQuery {
|
||||
state: Some(IssueListIssuesQueryState::Open),
|
||||
r#type: Some(IssueListIssuesQueryType::Issues),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.all()
|
||||
.await
|
||||
.with_context(|| format!("list open issues in {owner}/{repo}"))?;
|
||||
|
||||
// Numbers collected into an owned `Vec` first, rather than handing
|
||||
// `stream::iter` a lazy `filter_map` over borrowed `issues`
|
||||
// directly — the latter shape trips a "closure implementation of
|
||||
// `FnOnce` is not general enough" HRTB inference error where this
|
||||
// function is used as an axum handler (the `routes!` macro checks
|
||||
// `Handler` for arbitrary request lifetimes), even though the
|
||||
// closure itself is unremarkable.
|
||||
let numbers: Vec<i64> = issues.iter().filter_map(|i| i.number).collect();
|
||||
let deps: HashMap<i64, Vec<forgejo_api::structs::Issue>> =
|
||||
futures_util::stream::iter(numbers)
|
||||
.map(|number| async move {
|
||||
let blockers = self
|
||||
.api
|
||||
.issue_list_issue_dependencies(owner, repo, number)
|
||||
.await
|
||||
.with_context(|| format!("list dependencies of {owner}/{repo}#{number}"))?;
|
||||
Ok::<_, anyhow::Error>((number, blockers))
|
||||
})
|
||||
.buffer_unordered(CONCURRENCY)
|
||||
.try_collect()
|
||||
.await?;
|
||||
|
||||
let open_numbers: HashSet<i64> = issues.iter().filter_map(|i| i.number).collect();
|
||||
let mut dependent_counts: HashMap<i64, u32> = HashMap::new();
|
||||
for blockers in deps.values() {
|
||||
for blocker in blockers {
|
||||
if let Some(n) = blocker.number
|
||||
&& open_numbers.contains(&n)
|
||||
{
|
||||
*dependent_counts.entry(n).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let repo_full_name = format!("{owner}/{repo}");
|
||||
Ok(issues
|
||||
.into_iter()
|
||||
.filter_map(|issue| {
|
||||
let number = issue.number?;
|
||||
let blocked = deps
|
||||
.get(&number)
|
||||
.is_some_and(|b| b.iter().any(|d| d.state == Some(StateType::Open)));
|
||||
Some(IssueReportRow {
|
||||
repo: repo_full_name.clone(),
|
||||
number,
|
||||
title: issue.title.unwrap_or_default(),
|
||||
labels: issue
|
||||
.labels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|l| l.name)
|
||||
.collect(),
|
||||
assignee: issue.assignee.and_then(|a| a.login),
|
||||
html_url: issue.html_url.map(|u| u.to_string()),
|
||||
blocked,
|
||||
depended_on_by_count: dependent_counts.get(&number).copied().unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// [`Self::issue_report`], fanned out over every repo
|
||||
/// [`Self::list_repos_with_open_issues`] finds — the default,
|
||||
/// no-repo-filter view (mara: "by default, i want it to not filter by
|
||||
/// repo"). Each repo's report is resolved independently and
|
||||
/// concurrently (same `CONCURRENCY` bound as the per-issue dependency
|
||||
/// resolution inside `issue_report`, since this is the same
|
||||
/// "N independent forge round-trips" shape one level up), then
|
||||
/// flattened into one row set — `IssueReportRow::repo` is what lets a
|
||||
/// caller tell which repo a given row came from.
|
||||
///
|
||||
/// One repo's report failing does not fail the whole scan — logged and
|
||||
/// skipped, same best-effort shape [`Self::list_open_config_prs`] uses:
|
||||
/// a flaky repo should not blank out every other repo's rows.
|
||||
pub async fn issue_report_all(&self) -> Result<Vec<IssueReportRow>> {
|
||||
const CONCURRENCY: usize = 4;
|
||||
|
||||
let repos = self.list_repos_with_open_issues().await?;
|
||||
let rows: Vec<Vec<IssueReportRow>> = futures_util::stream::iter(repos)
|
||||
.map(|full_name| async move {
|
||||
let Some((owner, repo)) = full_name.split_once('/') else {
|
||||
tracing::warn!(%full_name, "issue_report_all: repo full_name has no '/'");
|
||||
return Vec::new();
|
||||
};
|
||||
match self.issue_report(owner, repo).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
%full_name,
|
||||
"issue_report_all: one repo's report failed, skipping it"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(CONCURRENCY)
|
||||
.collect()
|
||||
.await;
|
||||
Ok(rows.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
/// Register the swarm-wide hooks against this controller, so a real
|
||||
/// forge event reaches [`crate::webhook`] instead of the endpoint only
|
||||
/// being reachable by hand.
|
||||
|
|
|
|||
Loading…
Reference in a new issue