Sprint B.9+B.10: Dependabot alerts + CI status in table, cache and CLI

- Provider::org_dependabot_counts: one org-level request per distinct owner
  (cursor pagination via Link header — page= returns 400 on this endpoint);
  403/404 → None (unknown) vs Some(0) = checked, none open
- Provider::latest_ci_status: latest Actions run on the repo's branch →
  success/failure/running; CiStatus enum with stable string form
- cache RepoMeta: ci_status + dependabot_open replace unused ci_ok;
  RepoSummary carries both, applied at startup/scan/refresh (TUI + CLI)
- Repos table: CI column (✓/✗/●) + ALERT column (count, red when >0,
  '—' = unknown); CLI: list --alerts filter, scan_repos now cache-enriched
- verified against live API: 49 repos, 44 with alert counts, CI statuses
  match reality; table rendering checked via pyte
This commit is contained in:
dualfroz committed 2026-07-06 16:02:49 +02:00
1 parent 1e74de5e2e
commit d99660420f
14 files changed
+262 -56

No files matched your search

+64 -1
View File
@@ -2,7 +2,7 @@
//! custom `base_url`). Talks to the REST API directly over `reqwest` rather
//! than the `gh` CLI, so it works the same whether or not `gh` is installed.
use super::{IssueDetail, PrDetail, PrInfo, Provider, RepoAffiliation, RepoInfo, RepoMeta};
use super::{CiStatus, IssueDetail, PrDetail, PrInfo, Provider, RepoAffiliation, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
@@ -14,6 +14,17 @@ pub struct GitHubProvider {
base_url: String,
}
/// Extracts the `rel="next"` URL from an RFC-5988 `Link` header, if present.
fn link_next_url(link: &str) -> Option<String> {
link.split(',')
.find(|part| part.contains("rel=\"next\""))
.and_then(|part| {
let start = part.find('<')? + 1;
let end = part.find('>')?;
Some(part[start..end].to_string())
})
}
impl GitHubProvider {
/// Builds a client authenticated with `token`, scoped to `owner`'s repos
/// by default. `base_url` lets this point at a GitHub Enterprise
@@ -263,6 +274,58 @@ impl Provider for GitHubProvider {
Ok(())
}
async fn org_dependabot_counts(
&self, org: &str,
) -> Result<Option<std::collections::HashMap<String, u32>>> {
let mut counts = std::collections::HashMap::new();
// One request covers up to 100 open alerts org-wide. The endpoint is
// cursor-paginated (`page=` returns 400), so past that we follow the
// Link rel="next" header — capped so a pathological org can't stall
// the refresh.
let mut next = Some(format!(
"{}?state=open&per_page=100",
self.url(&format!("orgs/{}/dependabot/alerts", org)),
));
for _ in 0..10 {
let Some(url) = next.take() else { break };
let resp = self.client.get(&url).send().await?;
if !resp.status().is_success() {
// 403 (scope/feature) / 404 (not an org) — unavailable, not an error.
return Ok(None);
}
next = resp.headers().get(header::LINK)
.and_then(|l| l.to_str().ok())
.and_then(link_next_url);
let v: Value = resp.json().await.unwrap_or(Value::Null);
let Some(alerts) = v.as_array() else { return Ok(None) };
for alert in alerts {
if let Some(full) = alert["repository"]["full_name"].as_str() {
*counts.entry(full.to_lowercase()).or_insert(0) += 1;
}
}
}
Ok(Some(counts))
}
async fn latest_ci_status(&self, repo: &str, branch: &str) -> Result<Option<CiStatus>> {
let resp = self.client
.get(self.url(&format!("repos/{}/actions/runs", self.slug(repo))))
.query(&[("branch", branch), ("per_page", "1")])
.send().await?;
if !resp.status().is_success() { return Ok(None); }
let v: Value = resp.json().await.unwrap_or(Value::Null);
let Some(run) = v["workflow_runs"].as_array().and_then(|a| a.first()) else {
return Ok(None);
};
let status = run["status"].as_str().unwrap_or("");
let conclusion = run["conclusion"].as_str();
Ok(Some(match (status, conclusion) {
("completed", Some("success")) => CiStatus::Success,
("completed", _) => CiStatus::Failure,
_ => CiStatus::Running,
}))
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
+31
View File
@@ -86,6 +86,21 @@ pub struct IssueDetail {
pub comments: u32,
}
/// Status of a repo's most recent CI run on its default branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CiStatus { Success, Failure, Running }
impl CiStatus {
/// Stable string form used in the on-disk cache and `--json` output.
pub fn as_str(&self) -> &'static str {
match self {
Self::Success => "success",
Self::Failure => "failure",
Self::Running => "running",
}
}
}
/// A hosting backend's API surface. Methods that not every provider
/// implements (`list_orgs`, `list_repos`, optional repo settings) have a
/// default that either returns an empty result or bails with a clear
@@ -177,6 +192,22 @@ pub trait Provider: Send + Sync {
anyhow::bail!("count_search not supported by this provider")
}
/// Open Dependabot-style alert counts for every repo in `org`, keyed by
/// lowercase "owner/name" slug — one org-level request instead of one
/// per repo. `Ok(None)` = endpoint unavailable (missing scope, feature
/// disabled, not an org, or provider has no equivalent).
async fn org_dependabot_counts(
&self, _org: &str,
) -> Result<Option<std::collections::HashMap<String, u32>>> {
Ok(None)
}
/// Status of the most recent CI run on `branch`. `Ok(None)` = no runs /
/// CI not available / provider has no equivalent.
async fn latest_ci_status(&self, _repo: &str, _branch: &str) -> Result<Option<CiStatus>> {
Ok(None)
}
/// Verify the token works, return the authenticated username.
async fn whoami(&self) -> Result<String>;