crates/cli/src/cmd/pr.rs 22.8 KiB 662 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge pr: pull requests (M3b). Minimal merge: check-only, no engine.3 use crate::args::{parse_flags, spec_from_lists, FlagSet};4 use crate::output;5 use crate::CliError;6 use f0rge_core::{7     is_uuid4_lower, issue_numbers, now_rfc3339, Forge, MergeRecord, Pr, PrJson, PrReview,8 };9 use std::collections::BTreeMap;10 use std::path::{Path, PathBuf};11 use std::process::Command;12 use std::sync::atomic::{AtomicU64, Ordering};13 14 pub fn help() -> &'static str {15     "f0rge pr: pull requests\nusage: f0rge pr <create|diff|review|comment|merge|close|list> [--json]\nverbs: create diff review comment merge close list\ncreate: --base B --head H --issue UUID\ndocs: spec/schema.md §Pr\ncontract: f0rge --skill\n"16 }17 18 // Per-verb helps next to parser arms (#36). Each <=250B/8ln.19 const CREATE_H: &str = "f0rge pr create: open\nusage: f0rge pr create --base B --head H [--issue UUID] [--title T]\ncontract: f0rge --skill\n";20 const DIFF_H: &str = "f0rge pr diff: patch\nusage: f0rge pr diff <id|number>\ncontract: f0rge --skill\n";21 const REVIEW_H: &str = "f0rge pr review: judge\nusage: f0rge pr review <id|number> --body T [--state S]\ncontract: f0rge --skill\n";22 const COMMENT_H: &str = "f0rge pr comment: note\nusage: f0rge pr comment <id|number> --body T\ncontract: f0rge --skill\n";23 const MERGE_H: &str = "f0rge pr merge: land\nusage: f0rge pr merge <id|number>\ncontract: f0rge --skill\n";24 const CLOSE_H: &str = "f0rge pr close: shut\nusage: f0rge pr close <id|number>\ncontract: f0rge --skill\n";25 const LIST_H: &str = "f0rge pr list: all\nusage: f0rge pr list [--state S]\ncontract: f0rge --skill\n";26 27 /// Verb-depth help (#36). None when unknown.28 pub fn verb_help(verb: &str) -> Option<&'static str> {29     Some(match verb {30         "create" => CREATE_H,31         "diff" => DIFF_H,32         "review" => REVIEW_H,33         "comment" => COMMENT_H,34         "merge" => MERGE_H,35         "close" => CLOSE_H,36         "list" => LIST_H,37         _ => return None,38     })39 }40 41 const KNOWN_VERBS: &[&str] = &["create", "diff", "review", "comment", "merge", "close", "list"];42 43 /// First non-flag token as verb guess for pre-verb parse errors (#37).44 fn guess_verb(raw: &[String]) -> Option<String> {45     for a in raw {46         if a == "--" {47             continue;48         }49         if a.starts_with('-') {50             continue;51         }52         // skip values glued to flags is impossible pre-parse; take first bare token53         return Some(a.clone());54     }55     None56 }57 58 /// Append parse hint (#37), one line, when missing.59 fn with_try_verb(e: CliError, verb: &str) -> CliError {60     match e {61         CliError::Usage(m) if !m.contains("(try:") => {62             CliError::Usage(format!("{m} (try: f0rge pr {verb} --help)"))63         }64         other => other,65     }66 }67 68 fn with_try_domain(e: CliError) -> CliError {69     match e {70         CliError::Usage(m) if !m.contains("(try:") => {71             CliError::Usage(format!("{m} (try: f0rge pr -h)"))72         }73         other => other,74     }75 }76 77 static TMP_CTR: AtomicU64 = AtomicU64::new(0);78 79 const VALS: &[&str] = &["title", "body", "base", "head", "issue", "author", "state", "jq"];80 const BOOLS: &[&str] = &["json", "draft"];81 82 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {83     let spec = spec_from_lists(VALS, BOOLS);84     let fs = parse_flags(args, &spec).map_err(|e| {85         match guess_verb(args) {86             Some(v) if KNOWN_VERBS.contains(&v.as_str()) => with_try_verb(e, &v),87             _ => with_try_domain(e),88         }89     })?;90     if fs.is("help") {91         match guess_verb(args) {92             Some(v) => {93                 if let Some(h) = verb_help(&v) {94                     output::out(h);95                     return Ok(());96                 }97             }98             None => {}99         }100     }101     let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));102     let p = &fs.positional;103     if p.is_empty() {104         return Err(CliError::Usage(105             "pr <create|diff|review|comment|merge|close|list> (try: f0rge pr -h)".to_string(),106         ));107     }108     let r: Result<(), CliError> = match p[0].as_str() {109         "create" => create(&fs, args, fg, json, jq.as_deref()),110         "diff" => diff(p, fg),111         "review" | "comment" => review(&fs, p, fg, json, jq.as_deref()),112         "merge" => merge(p, fg, json, jq.as_deref()),113         "close" => close(p, fg, json, jq.as_deref()),114         "list" => list(&fs, fg, json, jq.as_deref()),115         other => {116             return Err(CliError::Usage(format!(117                 "unknown verb: {other} (try: f0rge pr -h)"118             )))119         }120     };121     r.map_err(|e| {122         let v = p[0].clone();123         if KNOWN_VERBS.contains(&v.as_str()) {124             with_try_verb(e, &v)125         } else {126             with_try_domain(e)127         }128     })129 }130 131 fn one_line(s: &str) -> String {132     s.chars()133         .filter(|c| *c != '\n' && *c != '\r')134         .take(160)135         .collect::<String>()136         .trim()137         .to_string()138 }139 140 fn repo_dir(fg: &Forge) -> Result<PathBuf, CliError> {141     fg.root()142         .parent()143         .map(Path::to_path_buf)144         .ok_or_else(|| CliError::Runtime("repo: not found".to_string()))145 }146 147 fn git(cwd: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {148     Command::new("git")149         .args(args)150         .current_dir(cwd)151         .output()152         .map_err(|_| CliError::Runtime("git: not found".to_string()))153 }154 155 fn git_err(what: &str, o: &std::process::Output) -> CliError {156     let s = String::from_utf8_lossy(&o.stderr);157     let first = s.lines().next().unwrap_or("failed").trim();158     CliError::Runtime(one_line(&format!("{what}: {first}")))159 }160 161 struct Rec {162     pr: Pr,163 }164 165 fn load_all(fg: &Forge) -> Result<Vec<Rec>, CliError> {166     let rd = match std::fs::read_dir(fg.root().join("prs")) {167         Ok(r) => r,168         Err(_) => return Ok(Vec::new()),169     };170     let mut out = Vec::new();171     for e in rd.flatten() {172         let ft = match e.file_type() {173             Ok(t) => t,174             Err(_) => continue,175         };176         if ft.is_symlink() || !ft.is_dir() {177             continue;178         }179         let name = e.file_name().to_string_lossy().into_owned();180         let rel = fg.pr_file(&name);181         if !fg.root().join(&rel).is_file() {182             continue;183         }184         let p = fg.load_entity::<Pr>(&rel).map_err(CliError::from)?;185         output::warn_schema(&p.schema_note);186         out.push(Rec { pr: p.value });187     }188     out.sort_by(|a, b| {189         (a.pr.created.as_str(), a.pr.id.as_str())190             .cmp(&(b.pr.created.as_str(), b.pr.id.as_str()))191     });192     Ok(out)193 }194 195 fn numbers(all: &[Rec]) -> BTreeMap<String, u64> {196     let e: Vec<(String, String)> =197         all.iter().map(|r| (r.pr.id.clone(), r.pr.created.clone())).collect();198     issue_numbers(&e)199 }200 201 fn resolve(fg: &Forge, s: &str, all: &[Rec]) -> Result<String, CliError> {202     let s = one_line(s);203     if s.is_empty() {204         return Err(CliError::Usage("pr <id|number>".to_string()));205     }206     if fg.root().join(fg.pr_file(&s)).is_file() {207         return Ok(s);208     }209     if let Ok(n) = s.parse::<u64>() {210         if n >= 1 && (n as usize) <= all.len() {211             return Ok(all[n as usize - 1].pr.id.clone());212         }213     }214     Err(CliError::Runtime(format!("{s}: not found")))215 }216 217 fn scan_issues(raw: &[String]) -> Result<Vec<String>, CliError> {218     let mut out = Vec::new();219     let mut i = 0;220     while i < raw.len() {221         let a = &raw[i];222         let val: Option<String> = if a == "--issue" {223             let v = raw224                 .get(i + 1)225                 .ok_or_else(|| CliError::Usage("--issue: needs value".to_string()))?;226             Some(v.clone())227         } else if let Some(v) = a.strip_prefix("--issue=") {228             if v.is_empty() {229                 return Err(CliError::Usage("--issue: needs value".to_string()));230             }231             Some(v.to_string())232         } else {233             None234         };235         match val {236             Some(v) => {237                 for part in v.split(',') {238                     let t = part.trim();239                     if t.is_empty() {240                         continue;241                     }242                     if !is_uuid4_lower(t) {243                         return Err(CliError::Usage(format!("issue {t}: bad id")));244                     }245                     if !out.contains(&t.to_string()) {246                         out.push(t.to_string());247                     }248                 }249                 i += if a == "--issue" { 2 } else { 1 };250             }251             None => i += 1,252         }253     }254     Ok(out)255 }256 257 fn create(258     fs: &FlagSet,259     raw: &[String],260     fg: &Forge,261     json: bool,262     jq: Option<&str>,263 ) -> Result<(), CliError> {264     let usage = "f0rge pr create --base B --head H";265     let base = fs.get("base").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;266     let head = fs.get("head").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;267     let repo = repo_dir(fg)?;268     let hp = if Path::new(head).is_absolute() {269         PathBuf::from(head)270     } else {271         repo.join(head)272     };273     let patch: Vec<u8> = if hp.is_file() {274         let label = hp.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "patch".to_string());275         let bytes = std::fs::read(&hp).map_err(|_| CliError::Runtime(format!("{label}: io error")))?;276         bytes277     } else {278         let spec = format!("{base}..{head}");279         let o = git(&repo, &["format-patch", &spec, "--stdout"])?;280         if !o.status.success() {281             return Err(git_err("git format-patch", &o));282         }283         o.stdout284     };285     if patch.iter().all(|b| b.is_ascii_whitespace()) {286         return Err(CliError::Runtime("empty patch: nothing in base..head".to_string()));287     }288     let issues = scan_issues(raw)?;289     let title = fs.get("title").filter(|s| !s.is_empty()).unwrap_or(head).to_string();290     let body = fs.get("body").unwrap_or("").to_string();291     let id = uuid::Uuid::new_v4().to_string();292     let created = now_rfc3339();293     let pr = Pr {294         id: id.clone(),295         title,296         state: "open".to_string(),297         base: base.to_string(),298         head: head.to_string(),299         draft: fs.is("draft"),300         issues,301         ci: None,302         created: created.clone(),303         updated: None,304     };305     fg.save_entity(&fg.pr_file(&id), &pr, &body).map_err(CliError::from)?;306     fg.atomic_write(&fg.pr_patch(&id), &patch).map_err(CliError::from)?;307     let all = load_all(fg)?;308     let n = numbers(&all).get(&id).copied().unwrap_or(1);309     if json {310         output::emit_json(&PrJson::new(n, &pr), jq);311     } else {312         output::out(&format!("{id}\n"));313     }314     Ok(())315 }316 317 fn diff(p: &[String], fg: &Forge) -> Result<(), CliError> {318     let s = p.get(1).ok_or_else(|| CliError::Usage("pr diff <id|number>".to_string()))?;319     let all = load_all(fg)?;320     let id = resolve(fg, s, &all)?;321     let bytes = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;322     let mut text = String::from_utf8_lossy(&bytes).into_owned();323     if !text.ends_with('\n') {324         text.push('\n');325     }326     output::out(&text);327     Ok(())328 }329 330 fn review(331     fs: &FlagSet,332     p: &[String],333     fg: &Forge,334     json: bool,335     jq: Option<&str>,336 ) -> Result<(), CliError> {337     let verb = p[0].clone();338     let s = p.get(1).ok_or_else(|| CliError::Usage(format!("pr {verb} <id|number> --body <text>")))?;339     let all = load_all(fg)?;340     let id = resolve(fg, s, &all)?;341     let body = fs.get("body").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(format!("pr {verb} <id|number> --body <text>")))?;342     let state = match fs.get("state") {343         Some(v) => match v {344             "approved" | "changes-requested" | "commented" => Some(v.to_string()),345             _ => return Err(CliError::Usage("review --state <approved|changes-requested|commented>".to_string())),346         },347         None => None,348     };349     let author = fs.get("author").filter(|s| !s.is_empty()).map(str::to_string);350     let rid = uuid::Uuid::new_v4().to_string();351     let created = now_rfc3339();352     let rv = PrReview { id: rid.clone(), author, state, created: created.clone() };353     let rel = fg.pr_reviews(&id).join(format!("{rid}.md"));354     fg.save_entity(&rel, &rv, body).map_err(CliError::from)?;355     if json {356         output::emit_json(&serde_json::json!({"created": created, "id": rid}), jq);357     } else {358         output::out(&format!("{rid}\n"));359     }360     Ok(())361 }362 363 fn conflict_files(stderr: &str) -> Vec<String> {364     let mut out = Vec::new();365     for line in stderr.lines() {366         let l = line.strip_prefix("error: ").unwrap_or(line).trim();367         let f = if let Some(rest) = l.strip_prefix("patch failed: ") {368             rest.split(':').next().unwrap_or("").trim()369         } else if let Some(i) = l.find(": already exists") {370             l[..i].trim()371         } else if let Some(i) = l.find(": does not match index") {372             l[..i].trim()373         } else {374             continue;375         };376         if !f.is_empty() && !out.contains(&f.to_string()) {377             out.push(f.to_string());378         }379         if out.len() >= 8 {380             break;381         }382     }383     out384 }385 386 fn merge_record_name(fg: &Forge, now: &str) -> PathBuf {387     let mut t = now.replace(':', "-");388     let mut n = 0u64;389     loop {390         let rel = fg.merge_file(&t);391         if !fg.root().join(&rel).exists() {392             return rel;393         }394         n += 1;395         t = format!("{}-{n}", now.replace(':', "-"));396     }397 }398 399 /// ONE canonical byte-form for hashing (SEC-08): CRLF->LF so folder400 /// patch.diff bytes and branch `format-patch --stdout` bytes hash equal.401 fn canon_patch(p: &[u8]) -> Vec<u8> {402     let mut out = Vec::with_capacity(p.len());403     let mut i = 0;404     while i < p.len() {405         if p[i] == b'\r' {406             if i + 1 < p.len() && p[i + 1] == b'\n' {407                 out.push(b'\n');408                 i += 2;409             } else {410                 out.push(b'\n');411                 i += 1;412             }413         } else {414             out.push(p[i]);415             i += 1;416         }417     }418     out419 }420 421 /// patch-id via git; fallback hash-object (both deterministic, no new deps).422 fn patch_hash(repo: &Path, patch: &[u8]) -> String {423     let canon = canon_patch(patch);424     let patch = &canon;425     let mut c = Command::new("git");426     c.arg("patch-id").arg("--stable").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());427     if let Ok(mut kid) = c.spawn() {428         use std::io::Write;429         if let Some(mut stdin) = kid.stdin.take() {430             let _ = stdin.write_all(patch);431         }432         if let Ok(o) = kid.wait_with_output() {433             if o.status.success() {434                 let t = String::from_utf8_lossy(&o.stdout);435                 if let Some(h) = t.split_whitespace().next().filter(|h| h.len() >= 8) {436                     return h.to_string();437                 }438             }439         }440     }441     let mut c2 = Command::new("git");442     c2.arg("hash-object").arg("--stdin").current_dir(repo).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());443     if let Ok(mut kid) = c2.spawn() {444         use std::io::Write;445         if let Some(mut stdin) = kid.stdin.take() {446             let _ = stdin.write_all(patch);447         }448         if let Ok(o) = kid.wait_with_output() {449             if o.status.success() {450                 return String::from_utf8_lossy(&o.stdout).trim().to_string();451             }452         }453     }454     format!("len{}", patch.len())455 }456 457 /// SEC-08: successful merge record whose body pins this patch hash?458 fn landed(fg: &Forge, hash: &str) -> Option<MergeRecord> {459     let rd = std::fs::read_dir(fg.root().join("merges")).ok()?;460     let want = format!("patch: {hash}");461     for e in rd.flatten() {462         let ft = e.file_type().ok()?;463         if ft.is_symlink() || !ft.is_file() {464             continue;465         }466         let name = e.file_name().to_string_lossy().into_owned();467         if !name.ends_with(".md") || name.starts_with('.') {468             continue;469         }470         let rel = Path::new("merges").join(&name);471         let p: f0rge_core::Parsed<MergeRecord> = fg.load_entity(&rel).ok()?;472         if p.value.verdict == "pass" && p.value.applied && p.body.lines().any(|l| l.trim() == want) {473             return Some(p.value);474         }475     }476     None477 }478 479 fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {480     let s = p.get(1).ok_or_else(|| CliError::Usage("pr merge <id|number>".to_string()))?;481     let all = load_all(fg)?;482     let id = resolve(fg, s, &all)?;483     let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;484     output::warn_schema(&parsed.schema_note);485     let mut pr = parsed.value;486     let patch = fg.read_capped(&fg.pr_patch(&id)).map_err(CliError::from)?;487     let repo = repo_dir(fg)?;488     let hash = patch_hash(&repo, &patch);489     if let Some(rec) = landed(fg, &hash) {490         if pr.state == "open" {491             pr.state = "merged".to_string();492             pr.ci = Some("pass".to_string());493             pr.updated = Some(now_rfc3339());494             let _ = fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body);495         }496         if json {497             output::emit_json(&rec, jq);498         } else {499             output::out(&format!("already landed: {}\n", rec.pr));500         }501         return Ok(());502     }503     if pr.state != "open" {504         return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));505     }506     let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);507     let pid = std::process::id();508     let tmp = std::env::temp_dir().join(format!("f0rge-pr-{pid}-{n}"));509     let patchf = std::env::temp_dir().join(format!("f0rge-patch-{pid}-{n}.diff"));510     std::fs::write(&patchf, &patch)511         .map_err(|_| CliError::Runtime("tmp: io error".to_string()))?;512     let tmp_s = tmp.to_string_lossy().into_owned();513     let patch_s = patchf.to_string_lossy().into_owned();514     let cleanup = |code: Option<i32>| {515         let _ = code;516         let _ = git(&repo, &["worktree", "remove", "--force", &tmp_s]);517         let _ = std::fs::remove_file(&patchf);518         let _ = std::fs::remove_dir_all(&tmp);519     };520     let o = git(&repo, &["worktree", "add", "--detach", &tmp_s, &pr.base])?;521     if !o.status.success() {522         cleanup(None);523         return Err(git_err("git worktree", &o));524     }525     let o = git(&tmp, &["apply", "--check", &patch_s])?;526     let now = now_rfc3339();527     if o.status.success() {528         cleanup(None);529         pr.state = "merged".to_string();530         pr.ci = Some("pass".to_string());531         pr.updated = Some(now.clone());532         fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;533         let rec = MergeRecord {534             pr: id.clone(),535             applied: true,536             verdict: "pass".to_string(),537             resolved: Vec::new(),538             created: now,539         };540         let rel = merge_record_name(fg, &rec.created);541         fg.save_entity(&rel, &rec, &format!("clean apply\npatch: {hash}\n")).map_err(CliError::from)?;542         let all2 = load_all(fg)?;543         let num = numbers(&all2).get(&id).copied().unwrap_or(1);544         if json {545             output::emit_json(&PrJson::new(num, &pr), jq);546         } else {547             output::out(&format!("merged {id}\n"));548         }549         Ok(())550     } else {551         let files = conflict_files(&String::from_utf8_lossy(&o.stderr));552         let base_body = if files.is_empty() {553             "patch does not apply\n".to_string()554         } else {555             format!("conflict: {}\n", files.join(", "))556         };557         let body = format!("{base_body}patch: {hash}\n");558         let rec = MergeRecord {559             pr: id.clone(),560             applied: false,561             verdict: "fail".to_string(),562             resolved: Vec::new(),563             created: now,564         };565         let rel = merge_record_name(fg, &rec.created);566         let _ = fg.save_entity(&rel, &rec, &body);567         cleanup(None);568         if files.is_empty() {569             return Err(CliError::Runtime("merge conflict: patch does not apply".to_string()));570         }571         return Err(CliError::Runtime(one_line(&format!("merge conflict: {}", files.join(", ")))));572     }573 }574 575 fn close(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {576     let s = p.get(1).ok_or_else(|| CliError::Usage("pr close <id|number>".to_string()))?;577     let all = load_all(fg)?;578     let id = resolve(fg, s, &all)?;579     let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;580     output::warn_schema(&parsed.schema_note);581     let mut pr = parsed.value;582     pr.state = "closed".to_string();583     pr.updated = Some(now_rfc3339());584     fg.save_entity(&fg.pr_file(&id), &pr, &parsed.body).map_err(CliError::from)?;585     let all2 = load_all(fg)?;586     let n = numbers(&all2).get(&id).copied().unwrap_or(1);587     if json {588         output::emit_json(&PrJson::new(n, &pr), jq);589     } else {590         output::out(&format!("closed {id}\n"));591     }592     Ok(())593 }594 595 fn list(fs: &FlagSet, fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), CliError> {596     let filter = fs.get("state").map(str::to_string);597     if let Some(f) = &filter {598         match f.as_str() {599             "open" | "closed" | "merged" | "draft" | "all" => {}600             _ => return Err(CliError::Usage("pr list --state <open|closed|merged|draft|all>".to_string())),601         }602     }603     let all = load_all(fg)?;604     let nums = numbers(&all);605     let mut rows: Vec<String> = Vec::new();606     let mut human: Vec<Vec<String>> = Vec::new();607     for r in &all {608         if let Some(f) = &filter {609             let keep = match f.as_str() {610                 "all" => true,611                 "draft" => r.pr.draft,612                 _ => &r.pr.state == f,613             };614             if !keep {615                 continue;616             }617         }618         let n = nums.get(&r.pr.id).copied().unwrap_or(0);619         if json {620             rows.push(output::json_line(&PrJson::new(n, &r.pr)));621         } else {622             human.push(vec![n.to_string(), r.pr.state.clone(), r.pr.title.clone()]);623         }624     }625     if json {626         if rows.is_empty() {627             output::emit_str("[]", jq);628         } else {629             output::emit_str(&rows.join("\n"), jq);630         }631     } else if !human.is_empty() {632         output::out(&format!("{}\n", output::align(&human)));633     }634     Ok(())635 }636 637 #[cfg(test)]638 mod verb_help_tests {639     use super::*;640     fn budget(s: &str) {641         assert!(s.lines().count() <= 8, "lines:\n{s}");642         assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());643     }644     #[test]645     fn per_verb_budgets_and_needles() {646         for (v, needle) in [647             ("create", "--base"),648             ("create", "--head"),649             ("diff", "diff <id|number>"),650             ("review", "--body"),651             ("comment", "--body"),652             ("merge", "merge <id|number>"),653             ("close", "close <id|number>"),654             ("list", "--state"),655         ] {656             let h = verb_help(v).expect(v);657             budget(h);658             assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");659         }660         assert!(verb_help("frobnicate").is_none());661     }662 }