Sprint B.8: vulp brief [repo] --json — one-shot state for agents

- workspace brief: summary counts (incl. missing_license), needs-attention
  repos, open/in-progress todos, cached PR/issue lists
- repo brief: full RepoSummary + 10 recent commits + repo todos + cached
  PRs/issues; name matches display label or basename
- read-only, cache-sourced (vulp refresh for freshness); human + --json
This commit is contained in:
dualfroz committed 2026-07-06 15:50:52 +02:00
1 parent c0b282335d
commit 1e74de5e2e
4 files changed
+131 -3

No files matched your search

+11 -3
View File
@@ -12,8 +12,9 @@ vulp <command> [--json] # non-interactive (scripts/AI)
## Contract ## Contract
- **`--json`** (global flag): machine-readable JSON on stdout. Available on: - **`--json`** (global flag): machine-readable JSON on stdout. Available on:
`list`, `status`, `scan`, `stats`, `activity`, `todo list`, `pr list`, `list`, `status`, `scan`, `brief`, `stats`, `activity`, `todo list`,
`issue list`, `project list`, `accounts`, `refresh`, `repo license`. `pr list`, `issue list`, `project list`, `accounts`, `refresh`,
`repo license`.
- **No prompts, ever.** Missing input = error + non-zero exit. - **No prompts, ever.** Missing input = error + non-zero exit.
- **stdout** = results only. **stderr** = progress and errors. - **stdout** = results only. **stderr** = progress and errors.
- **Exit codes:** `0` ok · `1` execution error · `2` bad usage · - **Exit codes:** `0` ok · `1` execution error · `2` bad usage ·
@@ -44,6 +45,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 list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME]
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
@@ -132,7 +134,13 @@ review and commit the file yourself. Defaults come from config:
## Recipes for AI agents ## Recipes for AI agents
```bash ```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 vulp list --dirty --json
# Commit and push everything in one project # Commit and push everything in one project
+5
View File
@@ -111,6 +111,11 @@ pub enum Commands {
#[arg(long, default_value = "")] description: String, #[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<String>,
},
/// Workspace-wide commit statistics /// Workspace-wide commit statistics
Stats { Stats {
/// 1d | 7d | 30d | 90d | 1y | all /// 1d | 7d | 30d | 90d | 1y | all
+1
View File
@@ -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), workspace::sync(cfg, guard, repos, all, project, &action, message, allow_secrets),
Commands::Release { repo, tag, notes } => Commands::Release { repo, tag, notes } =>
remote::release(cfg, guard, &repo, &tag, &notes).await, remote::release(cfg, guard, &repo, &tag, &notes).await,
Commands::Brief { repo } => workspace::brief(cfg, json, repo),
Commands::Stats { period } => workspace::stats(cfg, json, &period), Commands::Stats { period } => workspace::stats(cfg, json, &period),
Commands::Activity { limit, kind } => workspace::activity(json, limit, kind), Commands::Activity { limit, kind } => workspace::activity(json, limit, kind),
+114
View File
@@ -179,6 +179,120 @@ pub fn sync(
Ok(()) 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<String>) -> 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::Todo> = 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<crate::providers::PrDetail> = cache.all_prs().into_iter()
.filter(|p| matches_repo(&p.repo)).cloned().collect();
let issues: Vec<crate::providers::IssueDetail> = 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<Commit>,
todos: Vec<crate::todos::Todo>,
open_prs: Vec<crate::providers::PrDetail>,
open_issues: Vec<crate::providers::IssueDetail>,
}
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::Todo> = 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<()> { pub fn stats(cfg: &AppConfig, json: bool, period: &str) -> Result<()> {
use crate::detail::StatsPeriod; use crate::detail::StatsPeriod;
let p = match period { let p = match period {