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
|
|
@ -387,6 +387,7 @@ fn socket_path() -> PathBuf {
|
|||
(name = "jobq", description = "the swarm-level job graph"),
|
||||
(name = "agents", description = "creating agent identities at swarm level"),
|
||||
(name = "webhook", description = "swarm-wide forge webhook receipt"),
|
||||
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -474,6 +475,13 @@ struct AppState {
|
|||
/// `Arc<str>` rationale as `webhook_secret`: never mutated, so a
|
||||
/// clone per request is just a refcount bump.
|
||||
swarm_name: Option<Arc<str>>,
|
||||
/// The forge client itself, for the read-only repo/issue-report
|
||||
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
|
||||
/// — distinct from `config_prs`, which holds a *cache* built off this
|
||||
/// same client rather than the client. `None` under the same
|
||||
/// "no forge configured on this host" shape every other
|
||||
/// forge-backed field here uses.
|
||||
forge: Option<Arc<forge::Client>>,
|
||||
}
|
||||
|
||||
/// Env var the controller's NixOS module sets from
|
||||
|
|
@ -1072,6 +1080,107 @@ async fn get_config_prs(
|
|||
Ok(Json(cache.snapshot()))
|
||||
}
|
||||
|
||||
/// 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)"). Not scoped to
|
||||
/// [`forge::CONFIG_ORG`] the way [`get_config_prs`] is: mara's own framing
|
||||
/// for this issue-report page was "repos cannot be fixed for a hyperhive
|
||||
/// feature", so this deliberately covers the whole forge instance, not
|
||||
/// one org.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/repos",
|
||||
responses(
|
||||
(status = 200, description = "repos with at least one open issue, as owner/name", body = Vec<String>),
|
||||
(status = 503, description = "no forge is configured on this host", body = String),
|
||||
),
|
||||
tag = "repos"
|
||||
)]
|
||||
async fn get_repos(State(state): State<AppState>) -> Result<Json<Vec<String>>, StatusUnavailable> {
|
||||
let Some(client) = state.forge.as_ref() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no forge is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
match client.list_repos_with_open_issues().await {
|
||||
Ok(repos) => Ok(Json(repos)),
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "listing repos failed");
|
||||
Err(StatusUnavailable(detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every open issue across every repo with at least one — the default,
|
||||
/// no-repo-filter view (mara: "by default, i want it to not filter by
|
||||
/// repo"). See [`get_issue_report`] for the single-repo counterpart (used
|
||||
/// once the dropdown's repo filter is set) and
|
||||
/// [`forge::Client::issue_report_all`] for how the fan-out works.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/issue-report",
|
||||
responses(
|
||||
(status = 200, description = "every open issue across every repo with one, blocked/dependent-count pre-resolved", body = Vec<forge::IssueReportRow>),
|
||||
(status = 503, description = "no forge is configured on this host, or the report could not be built", body = String),
|
||||
),
|
||||
tag = "repos"
|
||||
)]
|
||||
async fn get_issue_report_all(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<forge::IssueReportRow>>, StatusUnavailable> {
|
||||
let Some(client) = state.forge.as_ref() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no forge is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
match client.issue_report_all().await {
|
||||
Ok(rows) => Ok(Json(rows)),
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "building the all-repos issue report failed");
|
||||
Err(StatusUnavailable(detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every open issue in `{org}/{repo}` specifically — the filtered view once
|
||||
/// swarm-ui's repo dropdown (populated by [`get_repos`]) has a selection.
|
||||
/// See [`forge::Client::issue_report`] for how `blocked` /
|
||||
/// `depended_on_by_count` are computed and why this has to be a
|
||||
/// server-side pass rather than something swarm-ui resolves itself per row.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/repos/{org}/{repo}/issue-report",
|
||||
params(
|
||||
("org" = String, Path, description = "repo owner (user or org)"),
|
||||
("repo" = String, Path, description = "repo name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "every open issue in this repo, blocked/dependent-count pre-resolved", body = Vec<forge::IssueReportRow>),
|
||||
(status = 503, description = "no forge is configured on this host, or the report could not be built", body = String),
|
||||
),
|
||||
tag = "repos"
|
||||
)]
|
||||
async fn get_issue_report(
|
||||
State(state): State<AppState>,
|
||||
Path((org, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<forge::IssueReportRow>>, StatusUnavailable> {
|
||||
let Some(client) = state.forge.as_ref() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no forge is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
match client.issue_report(&org, &repo).await {
|
||||
Ok(rows) => Ok(Json(rows)),
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, %org, %repo, "building issue report failed");
|
||||
Err(StatusUnavailable(detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The swarm's own public base URL, as the forge must address it.
|
||||
///
|
||||
/// Set by `swarm-controller.nix` **only when this host actually serves the
|
||||
|
|
@ -1188,6 +1297,20 @@ async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Clones `forge_client` for [`AppState::forge`] before handing the
|
||||
/// original off to [`register_swarm_webhooks`], which takes it by value —
|
||||
/// split out of `main` purely to keep that function under clippy's
|
||||
/// line-count lint (same rationale as [`connect_status_reader`]'s own doc
|
||||
/// comment), no behavior change.
|
||||
fn keep_forge_for_state(
|
||||
forge_client: Option<Arc<forge::Client>>,
|
||||
webhook_secret: Option<Arc<str>>,
|
||||
) -> Option<Arc<forge::Client>> {
|
||||
let state_forge = forge_client.clone();
|
||||
register_swarm_webhooks(forge_client, webhook_secret);
|
||||
state_forge
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
|
|
@ -1300,8 +1423,7 @@ async fn main() -> Result<()> {
|
|||
};
|
||||
|
||||
let config_prs = forge_client.clone().map(config_pr::spawn);
|
||||
|
||||
register_swarm_webhooks(forge_client, webhook_secret.clone());
|
||||
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
|
||||
|
||||
let state = AppState {
|
||||
hives: Arc::new(load_hives()),
|
||||
|
|
@ -1312,6 +1434,7 @@ async fn main() -> Result<()> {
|
|||
config_prs,
|
||||
swarm_name: load_swarm_name().map(Arc::from),
|
||||
auth,
|
||||
forge: state_forge,
|
||||
};
|
||||
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
|
|
@ -1326,6 +1449,9 @@ async fn main() -> Result<()> {
|
|||
.routes(routes!(get_config_prs))
|
||||
.routes(routes!(create_agent))
|
||||
.routes(routes!(get_agents))
|
||||
.routes(routes!(get_repos))
|
||||
.routes(routes!(get_issue_report_all))
|
||||
.routes(routes!(get_issue_report))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.split_for_parts();
|
||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||
|
|
@ -1437,6 +1563,7 @@ mod tests {
|
|||
// job and never consults one. The roster read is the verb that
|
||||
// needs it, and it has its own test below.
|
||||
auth: None,
|
||||
forge: None,
|
||||
};
|
||||
(state, sched)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue