diff --git a/docs/cli.md b/docs/cli.md index 9c0ce83..14f2f8d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,8 +12,9 @@ vulp [--json] # non-interactive (scripts/AI) ## Contract - **`--json`** (global flag): machine-readable JSON on stdout. Available on: - `list`, `status`, `scan`, `stats`, `activity`, `todo list`, `pr list`, - `issue list`, `project list`, `accounts`, `refresh`, `repo license`. + `list`, `status`, `scan`, `brief`, `stats`, `activity`, `todo list`, + `pr list`, `issue list`, `project list`, `accounts`, `refresh`, + `repo license`. - **No prompts, ever.** Missing input = error + non-zero exit. - **stdout** = results only. **stderr** = progress and errors. - **Exit codes:** `0` ok · `1` execution error · `2` bad usage · @@ -44,6 +45,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 status # branch, dirty, ahead/behind, last commit... vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period @@ -132,7 +134,13 @@ review and commit the file yourself. Defaults come from config: ## Recipes for AI agents ```bash -# What needs attention right now? +# What needs attention right now? (state + todos + cached PRs/issues in one shot) +vulp brief --json + +# Everything about one repo before working on it +vulp brief client-panel --json + +# Only the dirty repos vulp list --dirty --json # Commit and push everything in one project diff --git a/src/cli.rs b/src/cli.rs index 3a1a5d8..8e06aa0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -111,6 +111,11 @@ pub enum Commands { #[arg(long, default_value = "")] description: String, }, + /// One-shot picture: repo state + commits + todos + PRs/issues (from cache) + Brief { + /// Repo name (omit for the whole workspace) + repo: Option, + }, /// Workspace-wide commit statistics Stats { /// 1d | 7d | 30d | 90d | 1y | all diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index 3d230ec..a4da9ac 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -34,6 +34,7 @@ pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Re workspace::sync(cfg, guard, repos, all, project, &action, message, allow_secrets), Commands::Release { repo, tag, notes } => remote::release(cfg, guard, &repo, &tag, ¬es).await, + Commands::Brief { repo } => workspace::brief(cfg, json, repo), Commands::Stats { period } => workspace::stats(cfg, json, &period), Commands::Activity { limit, kind } => workspace::activity(json, limit, kind), diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 43440a9..61092d6 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -179,6 +179,120 @@ pub fn sync( Ok(()) } +/// `vulp brief` — one-shot workspace or per-repo picture for scripts and AI +/// agents: local git state plus todos and the cached PR/issue lists (run +/// `vulp refresh` first when freshness matters). Read-only. +pub fn brief(cfg: &AppConfig, json: bool, repo: Option) -> Result<()> { + use crate::todos::TodoStatus; + + let cache = crate::cache::load(); + + if let Some(name) = repo { + let r = find_repo(cfg, &name); + let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) + .unwrap_or_else(|| r.name.clone()); + let matches_repo = |candidate: &str| { + candidate.eq_ignore_ascii_case(&r.name) || candidate.eq_ignore_ascii_case(&basename) + }; + + let commits: Vec<(String, String)> = crate::status::repo_recent_commits(&r.path, 10); + let todos: Vec = crate::todos::load().into_iter() + .filter(|t| t.status == TodoStatus::Open || t.status == TodoStatus::InProgress) + .filter(|t| t.repo.as_deref().map(matches_repo).unwrap_or(false)) + .collect(); + let prs: Vec = cache.all_prs().into_iter() + .filter(|p| matches_repo(&p.repo)).cloned().collect(); + let issues: Vec = cache.all_issues().into_iter() + .filter(|i| matches_repo(&i.repo)).cloned().collect(); + + if json { + #[derive(serde::Serialize)] + struct Commit { age: String, message: String } + #[derive(serde::Serialize)] + struct Out { + repo: crate::status::RepoSummary, + recent_commits: Vec, + todos: Vec, + open_prs: Vec, + open_issues: Vec, + } + out_json(&Out { + repo: r, + recent_commits: commits.into_iter().map(|(age, message)| Commit { age, message }).collect(), + todos, open_prs: prs, open_issues: issues, + }); + } else { + println!("{} · {} · {}{}{}", r.name, r.branch, + if r.is_dirty { "dirty " } else { "clean " }, + if r.ahead > 0 { format!("↑{} ", r.ahead) } else { String::new() }, + if r.behind > 0 { format!("↓{} ", r.behind) } else { String::new() }); + println!("\nrecent commits:"); + for (age, msg) in &commits { println!(" {:<9} {}", age, msg); } + if !todos.is_empty() { + println!("\ntodos:"); + for t in &todos { println!(" #{:<4} [{}] {}", t.id, t.status.label(), t.title); } + } + if !prs.is_empty() { + println!("\nopen PRs (cached):"); + for p in &prs { println!(" #{:<5} {} ({} → {})", p.number, p.title, p.head_branch, p.base_branch); } + } + if !issues.is_empty() { + println!("\nopen issues (cached):"); + for i in &issues { println!(" #{:<5} {}", i.number, i.title); } + } + } + return Ok(()); + } + + // ── workspace-wide brief ────────────────────────────────────────────── + let repos = scan_repos(cfg); + let dirty = repos.iter().filter(|r| r.is_dirty).count(); + let behind = repos.iter().filter(|r| r.behind > 0).count(); + let ahead = repos.iter().filter(|r| r.ahead > 0).count(); + let unlicensed = repos.iter().filter(|r| !r.has_license).count(); + let needs_attention: Vec<&crate::status::RepoSummary> = repos.iter() + .filter(|r| r.is_dirty || r.ahead > 0 || r.behind > 0) + .collect(); + let todos: Vec = crate::todos::load().into_iter() + .filter(|t| t.status == TodoStatus::Open || t.status == TodoStatus::InProgress) + .collect(); + let prs: Vec<&crate::providers::PrDetail> = cache.all_prs(); + let issues: Vec<&crate::providers::IssueDetail> = cache.all_issues(); + + if json { + #[derive(serde::Serialize)] + struct Summary { total: usize, dirty: usize, ahead: usize, behind: usize, missing_license: usize } + #[derive(serde::Serialize)] + struct Out<'a> { + summary: Summary, + needs_attention: Vec<&'a crate::status::RepoSummary>, + todos: &'a [crate::todos::Todo], + open_prs: &'a [&'a crate::providers::PrDetail], + open_issues: &'a [&'a crate::providers::IssueDetail], + } + out_json(&Out { + summary: Summary { total: repos.len(), dirty, ahead, behind, missing_license: unlicensed }, + needs_attention, todos: &todos, open_prs: &prs, open_issues: &issues, + }); + } else { + println!("{} repos · {} dirty · {} ahead · {} behind · {} without license", + repos.len(), dirty, ahead, behind, unlicensed); + if !needs_attention.is_empty() { + println!("\nneeds attention:"); + for r in &needs_attention { + let mut flags = String::new(); + if r.is_dirty { flags.push('⚠'); } + if r.ahead > 0 { flags.push('↑'); } + if r.behind > 0 { flags.push('↓'); } + println!(" {:<28} {:<12} {}", r.name, r.branch, flags); + } + } + println!("\ntodos: {} open/in-progress · PRs (cached): {} · issues (cached): {}", + todos.len(), prs.len(), issues.len()); + } + Ok(()) +} + pub fn stats(cfg: &AppConfig, json: bool, period: &str) -> Result<()> { use crate::detail::StatsPeriod; let p = match period {