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
+238
-32
No files matched your search
+11
-3
@@ -46,7 +46,7 @@ vulp <command> [--json] # non-interactive (scripts/AI)
|
|||||||
```
|
```
|
||||||
vulp scan # repo count + dirty/behind/ahead summary
|
vulp scan # repo count + dirty/behind/ahead summary
|
||||||
vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues
|
vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues
|
||||||
vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME]
|
vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts]
|
||||||
vulp status <repo> # branch, dirty, ahead/behind, last commit...
|
vulp status <repo> # branch, dirty, ahead/behind, last commit...
|
||||||
vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period
|
vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period
|
||||||
vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license]
|
vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license]
|
||||||
@@ -58,9 +58,17 @@ vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|com
|
|||||||
"is_dirty": true, "last_commit_msg": "...", "last_commit_age": "2h ago",
|
"is_dirty": true, "last_commit_msg": "...", "last_commit_age": "2h ago",
|
||||||
"last_commit_ts": 1780000000, "last_tag": null, "visibility": null,
|
"last_commit_ts": 1780000000, "last_tag": null, "visibility": null,
|
||||||
"owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%",
|
"owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%",
|
||||||
"bookmarked": false, "stars": null, "open_issues": null, "open_prs": null }]
|
"bookmarked": false, "stars": null, "open_issues": null, "open_prs": null,
|
||||||
|
"stash_count": 0, "ci_status": "success", "dependabot_open": 0 }]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`stars`/`open_issues`/`open_prs`/`ci_status`/`dependabot_open` come from the
|
||||||
|
on-disk cache (run `vulp refresh` first). `ci_status` is
|
||||||
|
`"success" | "failure" | "running" | null`; `dependabot_open: null` means the
|
||||||
|
alert endpoint isn't available for that repo's owner (personal account,
|
||||||
|
missing token scope, or feature disabled) as opposed to `0` = "checked, none
|
||||||
|
open". `--alerts` filters to repos with `dependabot_open > 0`.
|
||||||
|
|
||||||
## Git operations
|
## Git operations
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -103,7 +111,7 @@ vulp pr list [--repo R] [--json] # cached — run `vulp refresh` for fres
|
|||||||
vulp pr create --repo R --title T --head BRANCH [--base main]
|
vulp pr create --repo R --title T --head BRANCH [--base main]
|
||||||
vulp issue list [--repo R] [--json]
|
vulp issue list [--repo R] [--json]
|
||||||
vulp issue create --repo R --title T [--body "..."]
|
vulp issue create --repo R --title T [--body "..."]
|
||||||
vulp refresh # re-fetch meta + PRs + issues into cache
|
vulp refresh # re-fetch meta + PRs/issues + CI status + Dependabot alerts
|
||||||
vulp init NAME [--private] [--description "..."] # local init + create remote + push
|
vulp init NAME [--private] [--description "..."] # local init + create remote + push
|
||||||
vulp accounts [--json]
|
vulp accounts [--json]
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-1
@@ -17,7 +17,10 @@ pub struct RepoMeta {
|
|||||||
pub open_issues: Option<u32>,
|
pub open_issues: Option<u32>,
|
||||||
pub open_prs: Option<u32>,
|
pub open_prs: Option<u32>,
|
||||||
pub visibility: Option<String>,
|
pub visibility: Option<String>,
|
||||||
pub ci_ok: Option<bool>,
|
/// "success" | "failure" | "running" (`CiStatus::as_str`), None = unknown.
|
||||||
|
pub ci_status: Option<String>,
|
||||||
|
/// Open Dependabot alerts; None = endpoint unavailable for this repo.
|
||||||
|
pub dependabot_open: Option<u32>,
|
||||||
pub fetched_at: i64,
|
pub fetched_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ pub enum Commands {
|
|||||||
#[arg(long)] lang: Option<String>,
|
#[arg(long)] lang: Option<String>,
|
||||||
/// Filter by vulp project name
|
/// Filter by vulp project name
|
||||||
#[arg(long)] project: Option<String>,
|
#[arg(long)] project: Option<String>,
|
||||||
|
/// Only repos with open Dependabot alerts (cached — run refresh first)
|
||||||
|
#[arg(long)] alerts: bool,
|
||||||
},
|
},
|
||||||
/// Detailed status of one repo
|
/// Detailed status of one repo
|
||||||
Status { repo: String },
|
Status { repo: String },
|
||||||
|
|||||||
+19
-4
@@ -26,8 +26,8 @@ pub(crate) struct Guard {
|
|||||||
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
||||||
pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
|
pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Commands::List { dirty, behind, ahead, lang, project } =>
|
Commands::List { dirty, behind, ahead, lang, project, alerts } =>
|
||||||
workspace::list(cfg, json, dirty, behind, ahead, lang, project),
|
workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts),
|
||||||
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
||||||
Commands::Scan => workspace::scan_cmd(cfg, json),
|
Commands::Scan => workspace::scan_cmd(cfg, json),
|
||||||
Commands::Sync { repos, all, project, action, message, allow_secrets } =>
|
Commands::Sync { repos, all, project, action, message, allow_secrets } =>
|
||||||
@@ -69,13 +69,28 @@ pub(crate) fn out_json<T: serde::Serialize>(v: &T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scans the workspace synchronously (labels already applied by the scan).
|
/// Scans the workspace synchronously (labels already applied by the scan)
|
||||||
|
/// and enriches each repo with cached API metadata (stars, PR/issue counts,
|
||||||
|
/// CI status, Dependabot alerts) — same view the TUI shows on startup.
|
||||||
pub(crate) fn scan_repos(cfg: &AppConfig) -> Vec<crate::status::RepoSummary> {
|
pub(crate) fn scan_repos(cfg: &AppConfig) -> Vec<crate::status::RepoSummary> {
|
||||||
match crate::status::scan_workspace(&cfg.workspace_root, &[]) {
|
let mut repos = match crate::status::scan_workspace(&cfg.workspace_root, &[]) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => die(1, &format!("workspace scan failed: {e:#}")),
|
Err(e) => die(1, &format!("workspace scan failed: {e:#}")),
|
||||||
|
};
|
||||||
|
let cache = crate::cache::load();
|
||||||
|
for r in &mut repos {
|
||||||
|
if let Some(entry) = 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.ci_status = m.ci_status.clone();
|
||||||
|
r.dependabot_open = m.dependabot_open;
|
||||||
|
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
repos
|
||||||
|
}
|
||||||
|
|
||||||
/// Finds one repo by (case-insensitive) display name or directory basename;
|
/// Finds one repo by (case-insensitive) display name or directory basename;
|
||||||
/// exits 4 when nothing matches.
|
/// exits 4 when nothing matches.
|
||||||
|
|||||||
+27
-6
@@ -317,22 +317,37 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
|
|||||||
|
|
||||||
// Org-owned repos are reachable through the personal token — refresh
|
// Org-owned repos are reachable through the personal token — refresh
|
||||||
// everything with an owner, addressed by full "owner/basename" slug.
|
// everything with an owner, addressed by full "owner/basename" slug.
|
||||||
let targets: Vec<(String, String)> = repos.iter()
|
let targets: Vec<(String, String, String)> = repos.iter()
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string())
|
let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| r.name.clone());
|
.unwrap_or_else(|| r.name.clone());
|
||||||
r.owner.as_ref().map(|o| (r.name.clone(), format!("{}/{}", o, basename)))
|
r.owner.as_ref().map(|o| (r.name.clone(), format!("{}/{}", o, basename), r.branch.clone()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug)| {
|
// Dependabot alert counts: one org-level request per distinct owner
|
||||||
|
// (personal owners 404 → None → unknown).
|
||||||
|
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(|r| r.owner.clone()).collect();
|
||||||
|
for owner in owners {
|
||||||
|
eprintln!("dependabot alerts: {owner}...");
|
||||||
|
let counts = provider.org_dependabot_counts(&owner).await.ok().flatten();
|
||||||
|
dependabot.insert(owner.to_lowercase(), counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug, branch)| {
|
||||||
let provider = provider.clone();
|
let provider = provider.clone();
|
||||||
async move {
|
async move {
|
||||||
eprintln!("refreshing {slug}...");
|
eprintln!("refreshing {slug}...");
|
||||||
let meta = provider.repo_meta(&slug).await.ok();
|
let meta = provider.repo_meta(&slug).await.ok();
|
||||||
let p = provider.list_pull_requests(&slug).await.ok();
|
let p = provider.list_pull_requests(&slug).await.ok();
|
||||||
let i = provider.list_issues(&slug).await.ok();
|
let i = provider.list_issues(&slug).await.ok();
|
||||||
(name, meta, p, i)
|
let ci = provider.latest_ci_status(&slug, &branch).await.ok().flatten();
|
||||||
|
(name, slug, meta, p, i, ci)
|
||||||
}
|
}
|
||||||
})).buffer_unordered(6).collect().await;
|
})).buffer_unordered(6).collect().await;
|
||||||
|
|
||||||
@@ -340,14 +355,20 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
|
|||||||
let mut fetched_meta = 0usize;
|
let mut fetched_meta = 0usize;
|
||||||
let mut fetched_prs = 0usize;
|
let mut fetched_prs = 0usize;
|
||||||
let mut fetched_iss = 0usize;
|
let mut fetched_iss = 0usize;
|
||||||
for (name, meta, p, i) in results {
|
for (name, slug, meta, p, i, ci) in results {
|
||||||
if let Some(meta) = meta {
|
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)));
|
||||||
cache.set_meta(&name, crate::cache::RepoMeta {
|
cache.set_meta(&name, crate::cache::RepoMeta {
|
||||||
stars: Some(meta.stars),
|
stars: Some(meta.stars),
|
||||||
open_issues: Some(meta.open_issues),
|
open_issues: Some(meta.open_issues),
|
||||||
open_prs: Some(meta.open_prs),
|
open_prs: Some(meta.open_prs),
|
||||||
visibility: None,
|
visibility: None,
|
||||||
ci_ok: None,
|
ci_status: ci.map(|c| c.as_str().to_string()),
|
||||||
|
dependabot_open: dep,
|
||||||
fetched_at: chrono::Utc::now().timestamp(),
|
fetched_at: chrono::Utc::now().timestamp(),
|
||||||
});
|
});
|
||||||
fetched_meta += 1;
|
fetched_meta += 1;
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ use anyhow::Result;
|
|||||||
pub fn list(
|
pub fn list(
|
||||||
cfg: &AppConfig, json: bool,
|
cfg: &AppConfig, json: bool,
|
||||||
dirty: bool, behind: bool, ahead: bool,
|
dirty: bool, behind: bool, ahead: bool,
|
||||||
lang: Option<String>, project: Option<String>,
|
lang: Option<String>, project: Option<String>, alerts: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut repos = scan_repos(cfg);
|
let mut repos = scan_repos(cfg);
|
||||||
|
|
||||||
if dirty { repos.retain(|r| r.is_dirty); }
|
if dirty { repos.retain(|r| r.is_dirty); }
|
||||||
if behind { repos.retain(|r| r.behind > 0); }
|
if behind { repos.retain(|r| r.behind > 0); }
|
||||||
if ahead { repos.retain(|r| r.ahead > 0); }
|
if ahead { repos.retain(|r| r.ahead > 0); }
|
||||||
|
if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 0); }
|
||||||
if let Some(l) = lang {
|
if let Some(l) = lang {
|
||||||
let l = l.to_lowercase();
|
let l = l.to_lowercase();
|
||||||
repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false));
|
repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false));
|
||||||
@@ -44,12 +45,20 @@ pub fn list(
|
|||||||
if r.is_dirty { flags.push('⚠'); }
|
if r.is_dirty { flags.push('⚠'); }
|
||||||
if r.behind > 0 { flags.push('↓'); }
|
if r.behind > 0 { flags.push('↓'); }
|
||||||
if r.ahead > 0 { flags.push('↑'); }
|
if r.ahead > 0 { flags.push('↑'); }
|
||||||
|
if alerts {
|
||||||
|
println!(
|
||||||
|
"{:<name_w$} {:<12} {:>3} alerts {}",
|
||||||
|
r.name, r.branch, r.dependabot_open.unwrap_or(0),
|
||||||
|
r.lang.as_deref().unwrap_or("-"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{:<name_w$} {:<12} {:<3} {}",
|
"{:<name_w$} {:<12} {:<3} {}",
|
||||||
r.name, r.branch, flags,
|
r.name, r.branch, flags,
|
||||||
r.lang.as_deref().unwrap_or("-"),
|
r.lang.as_deref().unwrap_or("-"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ pub fn poll_refresh(state: &mut HubState) {
|
|||||||
r.stars = meta.stars;
|
r.stars = meta.stars;
|
||||||
r.open_issues = meta.open_issues;
|
r.open_issues = meta.open_issues;
|
||||||
r.open_prs = meta.open_prs;
|
r.open_prs = meta.open_prs;
|
||||||
|
r.ci_status = meta.ci_status.clone();
|
||||||
|
r.dependabot_open = meta.dependabot_open;
|
||||||
}
|
}
|
||||||
cache.set_meta(&name, meta);
|
cache.set_meta(&name, meta);
|
||||||
fetched_meta += 1;
|
fetched_meta += 1;
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ pub async fn run_overview_tui(
|
|||||||
r.stars = m.stars;
|
r.stars = m.stars;
|
||||||
r.open_issues = m.open_issues;
|
r.open_issues = m.open_issues;
|
||||||
r.open_prs = m.open_prs;
|
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(); }
|
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-12
@@ -680,14 +680,15 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
|||||||
Ok(a) if !a.is_empty() => a,
|
Ok(a) if !a.is_empty() => a,
|
||||||
_ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; }
|
_ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; }
|
||||||
};
|
};
|
||||||
// (display name = cache key, owner/basename slug for the API). Org-owned
|
// (display name = cache key, owner/basename slug for the API, branch for
|
||||||
// repos are reachable through the personal token, so no owner filtering.
|
// the CI-run lookup). Org-owned repos are reachable through the personal
|
||||||
let repos: Vec<(String, Option<String>, String)> = state.repos.iter()
|
// token, so no owner filtering.
|
||||||
|
let repos: Vec<(String, Option<String>, String, String)> = state.repos.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let basename = r.path.file_name()
|
let basename = r.path.file_name()
|
||||||
.map(|b| b.to_string_lossy().to_string())
|
.map(|b| b.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| r.name.clone());
|
.unwrap_or_else(|| r.name.clone());
|
||||||
(r.name.clone(), r.owner.clone(), basename)
|
(r.name.clone(), r.owner.clone(), basename, r.branch.clone())
|
||||||
})
|
})
|
||||||
.collect();
|
.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 provider: std::sync::Arc<dyn crate::providers::Provider> = std::sync::Arc::from(provider);
|
||||||
|
|
||||||
let targets: Vec<(String, String)> = repos.iter()
|
let targets: Vec<(String, String, String)> = repos.iter()
|
||||||
.filter_map(|(name, owner, basename)| {
|
.filter_map(|(name, owner, basename, branch)| {
|
||||||
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename)))
|
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename), branch.clone()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let total = targets.len();
|
let total = targets.len();
|
||||||
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
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
|
// 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
|
// requests took 40-80 s; this brings it to ~10 s without tripping
|
||||||
// GitHub's secondary rate limits.
|
// 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 provider = provider.clone();
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
let done = done.clone();
|
let done = done.clone();
|
||||||
@@ -731,23 +746,30 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
|||||||
let meta = provider.repo_meta(&slug).await.ok();
|
let meta = provider.repo_meta(&slug).await.ok();
|
||||||
let p = provider.list_pull_requests(&slug).await.ok();
|
let p = provider.list_pull_requests(&slug).await.ok();
|
||||||
let i = provider.list_issues(&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 n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||||
let _ = tx.send(RefreshMsg::Progress(format!("Refreshed {}/{}: {}", n, total, name)));
|
let _ = tx.send(RefreshMsg::Progress(format!("Refreshed {}/{}: {}", n, total, name)));
|
||||||
(name, meta, p, i)
|
(name, slug, meta, p, i, ci)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.buffer_unordered(6)
|
.buffer_unordered(6)
|
||||||
.collect()
|
.collect()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
for (name, meta, p, i) in results {
|
for (name, slug, meta, p, i, ci) in results {
|
||||||
if let Some(meta) = meta {
|
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 {
|
metas.push((name.clone(), crate::cache::RepoMeta {
|
||||||
stars: Some(meta.stars),
|
stars: Some(meta.stars),
|
||||||
open_issues: Some(meta.open_issues),
|
open_issues: Some(meta.open_issues),
|
||||||
open_prs: Some(meta.open_prs),
|
open_prs: Some(meta.open_prs),
|
||||||
visibility: None,
|
visibility: None,
|
||||||
ci_ok: None,
|
ci_status: ci.map(|c| c.as_str().to_string()),
|
||||||
|
dependabot_open: dep,
|
||||||
fetched_at: chrono::Utc::now().timestamp(),
|
fetched_at: chrono::Utc::now().timestamp(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -759,7 +781,7 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
|||||||
// metric, scoped to every owner we know.
|
// metric, scoped to every owner we know.
|
||||||
{
|
{
|
||||||
let mut owners: std::collections::HashSet<String> =
|
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());
|
owners.insert(account.username.clone());
|
||||||
let scope = owners.iter().map(|o| format!("user:{}", o)).collect::<Vec<_>>().join(" ");
|
let scope = owners.iter().map(|o| format!("user:{}", o)).collect::<Vec<_>>().join(" ");
|
||||||
let _ = tx.send(RefreshMsg::Progress("Counting merged/closed...".into()));
|
let _ = tx.send(RefreshMsg::Progress("Counting merged/closed...".into()));
|
||||||
|
|||||||
+6
-3
@@ -312,14 +312,17 @@ impl HubState {
|
|||||||
match result {
|
match result {
|
||||||
Ok(mut repo) => {
|
Ok(mut repo) => {
|
||||||
repo.bookmarked = self.bookmarks.contains(&repo.path);
|
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| {
|
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 {
|
if let Some((stars, issues, prs, vis, ci, dep)) = cache_vals {
|
||||||
repo.stars = stars;
|
repo.stars = stars;
|
||||||
repo.open_issues = issues;
|
repo.open_issues = issues;
|
||||||
repo.open_prs = prs;
|
repo.open_prs = prs;
|
||||||
|
repo.ci_status = ci;
|
||||||
|
repo.dependabot_open = dep;
|
||||||
if repo.visibility.is_none() { repo.visibility = vis; }
|
if repo.visibility.is_none() { repo.visibility = vis; }
|
||||||
}
|
}
|
||||||
self.repos.push(repo);
|
self.repos.push(repo);
|
||||||
|
|||||||
+20
-1
@@ -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 cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||||
|
|
||||||
let header = Row::new(vec![
|
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));
|
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
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
|
Color::Yellow
|
||||||
};
|
};
|
||||||
let lic_str = if r.has_license { "✓" } else { "—" };
|
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_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 stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
||||||
let is_pinned = state.pins.contains(&r.path);
|
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(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(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(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)),
|
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
||||||
]).style(row_sty))
|
]).style(row_sty))
|
||||||
})
|
})
|
||||||
@@ -86,6 +103,8 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
Constraint::Length(10),
|
Constraint::Length(10),
|
||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Length(6),
|
||||||
Constraint::Length(8),
|
Constraint::Length(8),
|
||||||
];
|
];
|
||||||
f.render_widget(Table::new(rows, widths).header(header), area);
|
f.render_widget(Table::new(rows, widths).header(header), area);
|
||||||
|
|||||||
+64
-1
@@ -2,7 +2,7 @@
|
|||||||
//! custom `base_url`). Talks to the REST API directly over `reqwest` rather
|
//! 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.
|
//! 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 anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::{header, Client, Response};
|
use reqwest::{header, Client, Response};
|
||||||
@@ -14,6 +14,17 @@ pub struct GitHubProvider {
|
|||||||
base_url: String,
|
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 {
|
impl GitHubProvider {
|
||||||
/// Builds a client authenticated with `token`, scoped to `owner`'s repos
|
/// Builds a client authenticated with `token`, scoped to `owner`'s repos
|
||||||
/// by default. `base_url` lets this point at a GitHub Enterprise
|
/// by default. `base_url` lets this point at a GitHub Enterprise
|
||||||
@@ -263,6 +274,58 @@ impl Provider for GitHubProvider {
|
|||||||
Ok(())
|
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> {
|
async fn whoami(&self) -> Result<String> {
|
||||||
let resp = self.client.get(self.url("user")).send().await?;
|
let resp = self.client.get(self.url("user")).send().await?;
|
||||||
let v = Self::check(resp).await.context("whoami")?;
|
let v = Self::check(resp).await.context("whoami")?;
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ pub struct IssueDetail {
|
|||||||
pub comments: u32,
|
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
|
/// A hosting backend's API surface. Methods that not every provider
|
||||||
/// implements (`list_orgs`, `list_repos`, optional repo settings) have a
|
/// implements (`list_orgs`, `list_repos`, optional repo settings) have a
|
||||||
/// default that either returns an empty result or bails with a clear
|
/// 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")
|
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.
|
/// Verify the token works, return the authenticated username.
|
||||||
async fn whoami(&self) -> Result<String>;
|
async fn whoami(&self) -> Result<String>;
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ pub struct RepoSummary {
|
|||||||
/// Stash entries, counted from `.git/logs/refs/stash` (cheap file read,
|
/// Stash entries, counted from `.git/logs/refs/stash` (cheap file read,
|
||||||
/// no subprocess — scan-path safe).
|
/// no subprocess — scan-path safe).
|
||||||
pub stash_count: usize,
|
pub stash_count: usize,
|
||||||
|
/// Latest CI run on the current branch: "success"|"failure"|"running"
|
||||||
|
/// (from the meta cache / API refresh; None = unknown).
|
||||||
|
pub ci_status: Option<String>,
|
||||||
|
/// Open Dependabot alerts (from the meta cache / API refresh).
|
||||||
|
pub dependabot_open: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One commit's display fields for a recent-commits list.
|
/// One commit's display fields for a recent-commits list.
|
||||||
@@ -393,6 +398,7 @@ pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
|
|||||||
last_tag, visibility: None, owner, has_license, lang, bookmarked: false,
|
last_tag, visibility: None, owner, has_license, lang, bookmarked: false,
|
||||||
stars: None, open_issues: None, open_prs: None,
|
stars: None, open_issues: None, open_prs: None,
|
||||||
stash_count,
|
stash_count,
|
||||||
|
ci_status: None, dependabot_open: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user