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

+5 -3
View File
@@ -141,9 +141,11 @@ pub fn poll_refresh(state: &mut HubState) {
let mut fetched_meta = 0usize;
for (name, meta) in metas {
if let Some(r) = state.repos.iter_mut().find(|r| r.name == name) {
r.stars = meta.stars;
r.open_issues = meta.open_issues;
r.open_prs = meta.open_prs;
r.stars = meta.stars;
r.open_issues = meta.open_issues;
r.open_prs = meta.open_prs;
r.ci_status = meta.ci_status.clone();
r.dependabot_open = meta.dependabot_open;
}
cache.set_meta(&name, meta);
fetched_meta += 1;
+5 -3
View File
@@ -82,9 +82,11 @@ pub async fn run_overview_tui(
for r in &mut repos {
if let Some(entry) = meta_cache.get(&r.name) {
let m = &entry.meta;
r.stars = m.stars;
r.open_issues = m.open_issues;
r.open_prs = m.open_prs;
r.stars = m.stars;
r.open_issues = m.open_issues;
r.open_prs = m.open_prs;
r.ci_status = m.ci_status.clone();
r.dependabot_open = m.dependabot_open;
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
}
}
+39 -17
View File
@@ -680,14 +680,15 @@ pub fn spawn_api_refresh(state: &mut HubState) {
Ok(a) if !a.is_empty() => a,
_ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; }
};
// (display name = cache key, owner/basename slug for the API). Org-owned
// repos are reachable through the personal token, so no owner filtering.
let repos: Vec<(String, Option<String>, String)> = state.repos.iter()
// (display name = cache key, owner/basename slug for the API, branch for
// the CI-run lookup). Org-owned repos are reachable through the personal
// token, so no owner filtering.
let repos: Vec<(String, Option<String>, String, String)> = state.repos.iter()
.map(|r| {
let basename = r.path.file_name()
.map(|b| b.to_string_lossy().to_string())
.unwrap_or_else(|| r.name.clone());
(r.name.clone(), r.owner.clone(), basename)
(r.name.clone(), r.owner.clone(), basename, r.branch.clone())
})
.collect();
@@ -712,18 +713,32 @@ pub fn spawn_api_refresh(state: &mut HubState) {
};
let provider: std::sync::Arc<dyn crate::providers::Provider> = std::sync::Arc::from(provider);
let targets: Vec<(String, String)> = repos.iter()
.filter_map(|(name, owner, basename)| {
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename)))
let targets: Vec<(String, String, String)> = repos.iter()
.filter_map(|(name, owner, basename, branch)| {
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename), branch.clone()))
})
.collect();
let total = targets.len();
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
// Dependabot alert counts: one org-level request per distinct
// owner (personal owners 404 → None → unknown, shown as "—").
let mut dependabot: std::collections::HashMap<String, Option<std::collections::HashMap<String, u32>>> =
std::collections::HashMap::new();
{
let owners: std::collections::HashSet<String> =
repos.iter().filter_map(|(_, o, _, _)| o.clone()).collect();
for owner in owners {
let _ = tx.send(RefreshMsg::Progress(format!("Dependabot alerts: {}", owner)));
let counts = provider.org_dependabot_counts(&owner).await.ok().flatten();
dependabot.insert(owner.to_lowercase(), counts);
}
}
// Up to 6 repos in flight — sequential fetch of ~50 repos × 4
// requests took 40-80 s; this brings it to ~10 s without tripping
// GitHub's secondary rate limits.
let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug)| {
let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug, branch)| {
let provider = provider.clone();
let tx = tx.clone();
let done = done.clone();
@@ -731,24 +746,31 @@ pub fn spawn_api_refresh(state: &mut HubState) {
let meta = provider.repo_meta(&slug).await.ok();
let p = provider.list_pull_requests(&slug).await.ok();
let i = provider.list_issues(&slug).await.ok();
let ci = provider.latest_ci_status(&slug, &branch).await.ok().flatten();
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let _ = tx.send(RefreshMsg::Progress(format!("Refreshed {}/{}: {}", n, total, name)));
(name, meta, p, i)
(name, slug, meta, p, i, ci)
}
}))
.buffer_unordered(6)
.collect()
.await;
for (name, meta, p, i) in results {
for (name, slug, meta, p, i, ci) in results {
if let Some(meta) = meta {
let slug_lc = slug.to_lowercase();
let dep = dependabot
.get(slug_lc.split('/').next().unwrap_or(""))
.and_then(|per_org| per_org.as_ref()
.map(|m| m.get(&slug_lc).copied().unwrap_or(0)));
metas.push((name.clone(), crate::cache::RepoMeta {
stars: Some(meta.stars),
open_issues: Some(meta.open_issues),
open_prs: Some(meta.open_prs),
visibility: None,
ci_ok: None,
fetched_at: chrono::Utc::now().timestamp(),
stars: Some(meta.stars),
open_issues: Some(meta.open_issues),
open_prs: Some(meta.open_prs),
visibility: None,
ci_status: ci.map(|c| c.as_str().to_string()),
dependabot_open: dep,
fetched_at: chrono::Utc::now().timestamp(),
}));
}
if let Some(p) = p { prs.push((name.clone(), p)); }
@@ -759,7 +781,7 @@ pub fn spawn_api_refresh(state: &mut HubState) {
// metric, scoped to every owner we know.
{
let mut owners: std::collections::HashSet<String> =
repos.iter().filter_map(|(_, o, _)| o.clone()).collect();
repos.iter().filter_map(|(_, o, _, _)| o.clone()).collect();
owners.insert(account.username.clone());
let scope = owners.iter().map(|o| format!("user:{}", o)).collect::<Vec<_>>().join(" ");
let _ = tx.send(RefreshMsg::Progress("Counting merged/closed...".into()));
+9 -6
View File
@@ -312,14 +312,17 @@ impl HubState {
match result {
Ok(mut repo) => {
repo.bookmarked = self.bookmarks.contains(&repo.path);
// Apply any cached API metadata (stars, PRs, issues)
// Apply any cached API metadata (stars, PRs, issues, CI, alerts)
let cache_vals = self.meta_cache.get(&repo.name).map(|e| {
(e.meta.stars, e.meta.open_issues, e.meta.open_prs, e.meta.visibility.clone())
(e.meta.stars, e.meta.open_issues, e.meta.open_prs,
e.meta.visibility.clone(), e.meta.ci_status.clone(), e.meta.dependabot_open)
});
if let Some((stars, issues, prs, vis)) = cache_vals {
repo.stars = stars;
repo.open_issues = issues;
repo.open_prs = prs;
if let Some((stars, issues, prs, vis, ci, dep)) = cache_vals {
repo.stars = stars;
repo.open_issues = issues;
repo.open_prs = prs;
repo.ci_status = ci;
repo.dependabot_open = dep;
if repo.visibility.is_none() { repo.visibility = vis; }
}
self.repos.push(repo);
+20 -1
View File
@@ -20,7 +20,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
let cur_di = state.cur_di().unwrap_or(usize::MAX);
let header = Row::new(vec![
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "STARS",
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "CI", "ALERT", "STARS",
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.display.iter().enumerate()
@@ -51,6 +51,21 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
Color::Yellow
};
let lic_str = if r.has_license { "✓" } else { "—" };
let (ci_str, ci_col) = match r.ci_status.as_deref() {
Some("success") => ("✓", Color::Green),
Some("failure") => ("✗", Color::Red),
Some("running") => ("●", Color::Yellow),
_ => ("—", Color::DarkGray),
};
let dep_str = match r.dependabot_open {
Some(0) => "0".to_string(),
Some(n) => format!("{}", n),
None => "—".into(),
};
let dep_col = match r.dependabot_open {
Some(0) | None => Color::DarkGray,
Some(_) => Color::Red,
};
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
let is_pinned = state.pins.contains(&r.path);
@@ -71,6 +86,8 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
Cell::from(status_str).style(if r.is_dirty || r.behind > 0 { Style::default().fg(Color::Yellow) } else if is_stale { Style::default().fg(Color::Magenta).add_modifier(Modifier::DIM) } else { Style::default().fg(Color::DarkGray) }),
Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
Cell::from(lic_str).style(Style::default().fg(if r.has_license { Color::Green } else { Color::DarkGray })),
Cell::from(ci_str).style(Style::default().fg(ci_col)),
Cell::from(dep_str).style(Style::default().fg(dep_col)),
Cell::from(stars_str).style(Style::default().fg(stars_col)),
]).style(row_sty))
})
@@ -86,6 +103,8 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
Constraint::Length(9),
Constraint::Length(10),
Constraint::Length(9),
Constraint::Length(4),
Constraint::Length(6),
Constraint::Length(8),
];
f.render_widget(Table::new(rows, widths).header(header), area);