diff --git a/docs/cli.md b/docs/cli.md index 14f2f8d..40fbf46 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,7 +46,7 @@ vulp [--json] # non-interactive (scripts/AI) ``` vulp scan # repo count + dirty/behind/ahead summary 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 # branch, dirty, ahead/behind, last commit... 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] @@ -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", "last_commit_ts": 1780000000, "last_tag": null, "visibility": null, "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 ``` @@ -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 issue list [--repo R] [--json] 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 accounts [--json] ``` diff --git a/src/cache.rs b/src/cache.rs index 52b69eb..da25595 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -17,7 +17,10 @@ pub struct RepoMeta { pub open_issues: Option, pub open_prs: Option, pub visibility: Option, - pub ci_ok: Option, + /// "success" | "failure" | "running" (`CiStatus::as_str`), None = unknown. + pub ci_status: Option, + /// Open Dependabot alerts; None = endpoint unavailable for this repo. + pub dependabot_open: Option, pub fetched_at: i64, } diff --git a/src/cli.rs b/src/cli.rs index 8e06aa0..d591d4f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -47,6 +47,8 @@ pub enum Commands { #[arg(long)] lang: Option, /// Filter by vulp project name #[arg(long)] project: Option, + /// Only repos with open Dependabot alerts (cached — run refresh first) + #[arg(long)] alerts: bool, }, /// Detailed status of one repo Status { repo: String }, diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index a4da9ac..1c19d53 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -26,8 +26,8 @@ pub(crate) struct Guard { /// `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<()> { match cmd { - Commands::List { dirty, behind, ahead, lang, project } => - workspace::list(cfg, json, dirty, behind, ahead, lang, project), + Commands::List { dirty, behind, ahead, lang, project, alerts } => + workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts), Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Sync { repos, all, project, action, message, allow_secrets } => @@ -69,12 +69,27 @@ pub(crate) fn out_json(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 { - match crate::status::scan_workspace(&cfg.workspace_root, &[]) { + let mut repos = match crate::status::scan_workspace(&cfg.workspace_root, &[]) { Ok(r) => r, 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; diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index f7ca175..16aff91 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -317,22 +317,37 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { // Org-owned repos are reachable through the personal token — refresh // 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| { let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) .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(); - 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>> = + std::collections::HashMap::new(); + { + let owners: std::collections::HashSet = + 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(); async move { eprintln!("refreshing {slug}..."); 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(); - (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; @@ -340,15 +355,21 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { let mut fetched_meta = 0usize; let mut fetched_prs = 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 { + 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 { - 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(), }); fetched_meta += 1; } diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 61092d6..2a3699c 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -9,13 +9,14 @@ use anyhow::Result; pub fn list( cfg: &AppConfig, json: bool, dirty: bool, behind: bool, ahead: bool, - lang: Option, project: Option, + lang: Option, project: Option, alerts: bool, ) -> Result<()> { let mut repos = scan_repos(cfg); if dirty { repos.retain(|r| r.is_dirty); } if behind { repos.retain(|r| r.behind > 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 { let l = l.to_lowercase(); repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false)); @@ -44,11 +45,19 @@ pub fn list( if r.is_dirty { flags.push('⚠'); } if r.behind > 0 { flags.push('↓'); } if r.ahead > 0 { flags.push('↑'); } - println!( - "{:3} alerts {}", + r.name, r.branch, r.dependabot_open.unwrap_or(0), + r.lang.as_deref().unwrap_or("-"), + ); + } else { + println!( + "{: 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)> = 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)> = 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 = 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>> = + std::collections::HashMap::new(); + { + let owners: std::collections::HashSet = + 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 = - 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::>().join(" "); let _ = tx.send(RefreshMsg::Progress("Counting merged/closed...".into())); diff --git a/src/hub/state.rs b/src/hub/state.rs index 865e443..b75af50 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -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); diff --git a/src/hub/tabs_tables.rs b/src/hub/tabs_tables.rs index 66ea046..3ac2d85 100644 --- a/src/hub/tabs_tables.rs +++ b/src/hub/tabs_tables.rs @@ -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 = 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::()).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); diff --git a/src/providers/github.rs b/src/providers/github.rs index 39869c7..76589ba 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -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 { + 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>> { + 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> { + 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 { let resp = self.client.get(self.url("user")).send().await?; let v = Self::check(resp).await.context("whoami")?; diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 3c630d6..35cef55 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -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>> { + 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> { + Ok(None) + } + /// Verify the token works, return the authenticated username. async fn whoami(&self) -> Result; diff --git a/src/status/mod.rs b/src/status/mod.rs index ce4af10..668d6e7 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -38,6 +38,11 @@ pub struct RepoSummary { /// Stash entries, counted from `.git/logs/refs/stash` (cheap file read, /// no subprocess — scan-path safe). 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, + /// Open Dependabot alerts (from the meta cache / API refresh). + pub dependabot_open: Option, } /// One commit's display fields for a recent-commits list. @@ -393,6 +398,7 @@ pub fn repo_summary(path: &Path) -> Result { last_tag, visibility: None, owner, has_license, lang, bookmarked: false, stars: None, open_issues: None, open_prs: None, stash_count, + ci_status: None, dependabot_open: None, }) }