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:
1 parent
1e74de5e2e
commit
d99660420f
14 files changed
+262
-56
No files matched your search
+64
-1
@@ -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")?;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user