crates/cli/src/cmd/issue.rs 26.0 KiB 746 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge issue: issues (PLAN M3a). Files are the API; numbers derived.3 //! Verbs: create list view comment close reopen assign label edit.4 //! All writes via core atomic-save; numbers never stored.5 6 use crate::{args, output, CliError};7 use f0rge_core::{Forge, ForgeError, Issue, IssueComment, IssueJson};8 use serde::Serialize;9 use std::collections::BTreeMap;10 use std::path::Path;11 12 pub fn help() -> &'static str {13     "f0rge issue: issues\nusage: f0rge issue <verb> [--json] [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --search\ndocs: spec/schema.md §Issue\ncontract: f0rge --skill\n"14 }15 16 // Per-verb helps: small consts next to parser arms (#36). Each <=250B/8ln.17 const CREATE_H: &str = "f0rge issue create: new issue\nusage: f0rge issue create --title S [--body S|--body-file F --label L --assignee A --milestone M]\ncontract: f0rge --skill\n";18 const LIST_H: &str = "f0rge issue list: filter\nusage: f0rge issue list [--label L] [--assignee A] [--state S] [--milestone M] [--search Q]\ncontract: f0rge --skill\n";19 const VIEW_H: &str = "f0rge issue view: show\nusage: f0rge issue view <id>\ncontract: f0rge --skill\n";20 const COMMENT_H: &str = "f0rge issue comment: add\nusage: f0rge issue comment <id> [--body B|--body-file F] [--author A]\ncontract: f0rge --skill\n";21 const CLOSE_H: &str = "f0rge issue close: shut\nusage: f0rge issue close <id>\ncontract: f0rge --skill\n";22 const REOPEN_H: &str = "f0rge issue reopen: open\nusage: f0rge issue reopen <id>\ncontract: f0rge --skill\n";23 const ASSIGN_H: &str = "f0rge issue assign: owner\nusage: f0rge issue assign <id> --assignee A [--remove]\ncontract: f0rge --skill\n";24 const LABEL_H: &str = "f0rge issue label: tag\nusage: f0rge issue label <id> --label L [--remove]\ncontract: f0rge --skill\n";25 const EDIT_H: &str = "f0rge issue edit: change\nusage: f0rge issue edit <id> [--title T] [--body B] [--milestone M] [--label L] [--assignee A]\ncontract: f0rge --skill\n";26 27 /// Verb-depth help for future dispatch (#36). None when unknown.28 pub fn verb_help(verb: &str) -> Option<&'static str> {29     Some(match verb {30         "create" => CREATE_H,31         "list" => LIST_H,32         "view" => VIEW_H,33         "comment" => COMMENT_H,34         "close" => CLOSE_H,35         "reopen" => REOPEN_H,36         "assign" => ASSIGN_H,37         "label" => LABEL_H,38         "edit" => EDIT_H,39         _ => return None,40     })41 }42 43 /// Append parse hint (#37), one line, when missing.44 fn with_try(e: CliError, verb: &str) -> CliError {45     match e {46         CliError::Usage(m) if !m.contains("(try:") => {47             CliError::Usage(format!("{m} (try: f0rge issue {verb} --help)"))48         }49         other => other,50     }51 }52 53 const VERBS: &[&str] =54     &["create", "list", "view", "comment", "close", "reopen", "assign", "label", "edit"];55 56 fn spec() -> Vec<args::FlagSpec> {57     args::spec_from_lists(58         &["title", "body", "body-file", "label", "assignee", "milestone", "state", "search", "author", "jq"],59         &["json", "remove"],60     )61 }62 63 fn usage(msg: &str) -> CliError {64     CliError::Usage(msg.to_string())65 }66 67 /// One-line safe echo of argv text (SEC-10).68 fn clean(s: &str) -> String {69     s.chars().filter(|c| *c != '\n' && *c != '\r').take(80).collect::<String>().trim().to_string()70 }71 72 // ---- JSON views (fixed field order) ----73 74 #[derive(Debug, Clone, Serialize)]75 struct CommentJson {76     id: String,77     author: Option<String>,78     body: String,79     #[serde(rename = "createdAt")]80     created_at: String,81 }82 83 #[derive(Debug, Clone, Serialize)]84 struct ViewJson {85     number: u64,86     title: String,87     state: String,88     labels: Vec<String>,89     assignees: Vec<String>,90     milestone: Option<String>,91     body: String,92     #[serde(rename = "createdAt")]93     created_at: String,94     comments: Vec<CommentJson>,95 }96 97 // ---- small helpers ----98 99 struct Full {100     id: String,101     issue: Issue,102     body: String,103 }104 105 fn numbers(fg: &Forge) -> Result<BTreeMap<String, u64>, CliError> {106     match fg.scan_numbers() {107         Ok(m) => Ok(m),108         Err(ForgeError::NotFound { .. }) => Ok(BTreeMap::new()),109         Err(e) => Err(CliError::from(e)),110     }111 }112 113 /// Accept derived number ("N" or "#N") or full uuid; garbage -> usage (exit 2).114 fn resolve_id(fg: &Forge, raw: &str) -> Result<String, CliError> {115     let id = clean(raw);116     let id = id.strip_prefix('#').unwrap_or(&id).to_string();117     if !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) {118         let n: u64 = id.parse().map_err(|_| usage("f0rge issue <verb> <id>"))?;119         for (uuid, num) in numbers(fg)? {120             if num == n {121                 return Ok(uuid);122             }123         }124         return Err(CliError::Runtime(format!("issue {id}: not found")));125     }126     if f0rge_core::is_uuid4_lower(&id) {127         return Ok(id);128     }129     Err(usage("f0rge issue <verb> <id>"))130 }131 132 /// Unknown-field warnings + schema note, one line each on stderr.133 fn warn(file: &str, w: &[String], note: &Option<String>) {134     for x in w {135         eprintln!("warning: {file}: {x}");136     }137     output::warn_schema(note);138 }139 140 /// List-hot-path load: core fast extract with strict fallback (same141 /// values/errors as `load_full`; views and mutations stay strict).142 fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {143     let rel = fg.issue_file(uuid);144     let label = f0rge_core::base_name(&rel);145     let bytes = fg.read_capped(&rel).map_err(|e| match e {146         ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),147         _ => CliError::from(e),148     })?;149     let text = String::from_utf8(bytes).map_err(|_| {150         CliError::from(ForgeError::Parse { file: label.clone(), msg: "not utf-8".to_string() })151     })?;152     let p = f0rge_core::parse_issue_card(&text, &label).map_err(CliError::from)?;153     warn(&label, &p.warnings, &p.schema_note);154     Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })155 }156 157 fn load_full(fg: &Forge, uuid: &str) -> Result<Full, CliError> {158     let rel = fg.issue_file(uuid);159     let label = f0rge_core::base_name(&rel);160     let p: f0rge_core::Parsed<Issue> = fg.load_entity(&rel).map_err(|e| match e {161         ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),162         _ => CliError::from(e),163     })?;164     warn(&label, &p.warnings, &p.schema_note);165     Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })166 }167 168 /// All non-archived-tracked issues; stray dirs without issue.md skipped.169 /// M8 perf: full YAML parses + comment counts run on a scoped thread170 /// pool (callers sort by number after); filters, order, output unchanged.171 fn load_all(fg: &Forge) -> Result<Vec<(Full, u64)>, CliError> {172     let dir = fg.root().join("issues");173     let rd = match std::fs::read_dir(&dir) {174         Ok(r) => r,175         Err(_) => return Ok(Vec::new()),176     };177     let mut ids = Vec::new();178     for e in rd.flatten() {179         let ft = match e.file_type() {180             Ok(t) => t,181             Err(_) => continue,182         };183         if ft.is_symlink() || !ft.is_dir() {184             continue;185         }186         let id = e.file_name().to_string_lossy().into_owned();187         if !f0rge_core::is_uuid4_lower(&id) {188             continue;189         }190         ids.push(id);191     }192     if ids.is_empty() {193         return Ok(Vec::new());194     }195     let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);196     let chunk = ids.len().div_ceil(workers).max(1);197     let mut out: Vec<(Full, u64)> = Vec::with_capacity(ids.len());198     std::thread::scope(|s| {199         let hs: Vec<_> = ids200             .chunks(chunk)201             .map(|c| {202                 s.spawn(move || {203                     let mut v: Vec<(Full, u64)> = Vec::with_capacity(c.len());204                     for id in c {205                         match load_card(fg, id) {206                             Ok(f) => {207                                 let n = comment_list(fg, id)?.len() as u64;208                                 v.push((f, n));209                             }210                             Err(CliError::Runtime(_)) => continue, // racing delete / stray dir211                             Err(e) => return Err(e),212                         }213                     }214                     Ok::<_, CliError>(v)215                 })216             })217             .collect();218         for h in hs {219             match h.join() {220                 Ok(Ok(v)) => out.extend(v),221                 Ok(Err(e)) => return Err(e),222                 Err(_) => return Err(CliError::Runtime("issue list: worker error".to_string())),223             }224         }225         Ok::<(), CliError>(())226     })?;227     Ok(out)228 }229 230 /// Comments sorted by (created, id). Never edits existing files.231 fn comment_list(fg: &Forge, uuid: &str) -> Result<Vec<(IssueComment, String)>, CliError> {232     let dir = fg.root().join("issues").join(uuid).join("comments");233     let rd = match std::fs::read_dir(&dir) {234         Ok(r) => r,235         Err(_) => return Ok(Vec::new()),236     };237     let mut out = Vec::new();238     for e in rd.flatten() {239         let ft = match e.file_type() {240             Ok(t) => t,241             Err(_) => continue,242         };243         if ft.is_symlink() || !ft.is_file() {244             continue;245         }246         let name = e.file_name().to_string_lossy().into_owned();247         if !name.ends_with(".md") {248             continue;249         }250         let rel = Path::new("issues").join(uuid).join("comments").join(&name);251         let p: f0rge_core::Parsed<IssueComment> = fg.load_entity(&rel)?;252         warn(&name, &p.warnings, &p.schema_note);253         out.push((p.value, p.body));254     }255     out.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));256     Ok(out)257 }258 259 fn dedup(v: Vec<String>) -> Vec<String> {260     let mut o: Vec<String> = Vec::with_capacity(v.len());261     for s in v {262         if !o.contains(&s) {263             o.push(s);264         }265     }266     o267 }268 269 fn norm_body(b: &str) -> String {270     if b.is_empty() {271         return String::new();272     }273     if b.ends_with('\n') {274         b.to_string()275     } else {276         format!("{b}\n")277     }278 }279 280 fn base_of(p: &str) -> String {281     Path::new(p)282         .file_name()283         .map(|s| s.to_string_lossy().into_owned())284         .unwrap_or_else(|| clean(p))285 }286 287 /// Body from `--body-file <path|->`; `-` = stdin. SEC caps apply (256 KiB doc cap, same as wiki).288 fn read_body_file(src: &str) -> Result<String, CliError> {289     if clean(src).is_empty() {290         return Err(usage("f0rge issue --body-file <path|->"));291     }292     let label = if src == "-" { "stdin".to_string() } else { base_of(src) };293     let bytes: Vec<u8> = if src == "-" {294         use std::io::Read;295         let mut buf = Vec::new();296         std::io::stdin()297             .lock()298             .take(f0rge_core::MAX_FILE_BYTES as u64 + 1)299             .read_to_end(&mut buf)300             .map_err(|_| CliError::Runtime("stdin: io error".to_string()))?;301         buf302     } else {303         std::fs::read(Path::new(src))304             .map_err(|_| CliError::Runtime(format!("{label}: not found")))?305     };306     if bytes.len() > f0rge_core::MAX_FILE_BYTES {307         return Err(CliError::Runtime(format!("{label}: file over 256 KiB cap")));308     }309     String::from_utf8(bytes)310         .map_err(|_| CliError::Runtime(format!("{label}: not utf-8")))311         .map(|s| norm_body(&s))312 }313 314 fn emit_issue(fs: &args::FlagSet, fg: &Forge, f: &Full) -> Result<(), CliError> {315     let n = numbers(fg)?.get(&f.id).copied().unwrap_or(0);316     let c = comment_list(fg, &f.id)?.len() as u64;317     output::emit_json(&IssueJson::new(n, &f.issue, &f.body, c), fs.get("jq"));318     Ok(())319 }320 321 fn one_id(fs: &args::FlagSet, verb: &str) -> Result<String, CliError> {322     if fs.positional.len() != 1 {323         return Err(usage(&format!("f0rge issue {verb} <id>")));324     }325     Ok(fs.positional[0].clone())326 }327 328 // ---- verbs ----329 330 fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {331     if fs.has("state") {332         return Err(usage("f0rge issue create: --state not accepted"));333     }334     if fs.has("search") {335         return Err(usage("f0rge issue create: --search not accepted"));336     }337     if fs.has("author") {338         return Err(usage("f0rge issue create: --author not accepted"));339     }340     if fs.is("remove") {341         return Err(usage("f0rge issue create: --remove not accepted"));342     }343     if !fs.positional.is_empty() {344         return Err(usage("f0rge issue create --title T"));345     }346     let title = fs.get("title").unwrap_or("");347     if clean(title).is_empty() {348         return Err(usage("f0rge issue create --title T"));349     }350     if fs.has("body-file") && fs.get("body").is_some() {351         return Err(usage("f0rge issue create: --body with --body-file not accepted"));352     }353     let body = match fs.get("body-file") {354         Some(src) => read_body_file(src)?,355         None => norm_body(fs.get("body").unwrap_or("")),356     };357     let labels = dedup(358         fs.get_all("label")359             .into_iter()360             .filter(|s| !clean(s).is_empty())361             .map(|s| s.to_string())362             .collect(),363     );364     let assignees = dedup(365         fs.get_all("assignee")366             .into_iter()367             .filter(|s| !clean(s).is_empty())368             .map(|s| s.to_string())369             .collect(),370     );371     let milestone = fs.get("milestone").map(|s| s.to_string());372     let id = uuid::Uuid::new_v4().to_string();373     let issue = Issue {374         id: id.clone(),375         title: title.to_string(),376         state: "open".to_string(),377         labels,378         assignees,379         milestone,380         created: f0rge_core::now_rfc3339(),381         updated: None,382     };383     fg.save_entity(&fg.issue_file(&id), &issue, &body)?;384     let n = numbers(fg)?.get(&id).copied().unwrap_or(0);385     output::emit_json(&IssueJson::new(n, &issue, &body, 0), fs.get("jq"));386     Ok(())387 }388 389 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {390     if !fs.positional.is_empty() {391         return Err(usage("f0rge issue list [--label L] [--assignee A] [--state S] [--milestone M] [--search Q]"));392     }393     if let Some(s) = fs.get("state") {394         if !["open", "closed", "in-progress", "archived"].contains(&s) {395             return Err(usage("f0rge issue list --state open|closed|in-progress|archived"));396         }397     }398     let all = load_all(fg)?;399     let pairs: Vec<(String, String)> =400         all.iter().map(|(f, _)| (f.id.clone(), f.issue.created.clone())).collect();401     let nums = f0rge_core::issue_numbers(&pairs);402     let lab: Vec<&str> = fs.get_all("label");403     let asg: Vec<&str> = fs.get_all("assignee");404     let st = fs.get("state");405     let ms = fs.get("milestone");406     let q = fs.get("search").map(|s| s.to_lowercase());407     let mut rows: Vec<(u64, &Full, u64)> = Vec::new();408     for (f, cc) in &all {409         if !lab.iter().all(|l| f.issue.labels.iter().any(|x| x == l)) {410             continue;411         }412         if !asg.iter().all(|a| f.issue.assignees.iter().any(|x| x == a)) {413             continue;414         }415         if let Some(s) = st {416             if f.issue.state != s {417                 continue;418             }419         } else if f.issue.state == "archived" {420             continue; // indexes skip archived by default421         }422         match (ms, &f.issue.milestone) {423             (Some(m), Some(x)) if x == m => {}424             (Some(_), _) => continue,425             (None, _) => {}426         }427         if let Some(qq) = &q {428             let hay = format!("{}\n{}", f.issue.title, f.body).to_lowercase();429             if !hay.contains(qq) {430                 continue;431             }432         }433         let n = nums.get(&f.id).copied().unwrap_or(0);434         rows.push((n, f, *cc));435     }436     rows.sort_by_key(|r| r.0);437     if fs.is("json") {438         if rows.is_empty() {439             output::emit_empty_array(fs.get("jq"));440             return Ok(());441         }442         let mut buf = String::new();443         for (n, f, c) in &rows {444             buf.push_str(&f0rge_core::to_json_line(&IssueJson::new(*n, &f.issue, &f.body, *c)));445             buf.push('\n');446         }447         output::emit_bulk(&buf, fs.get("jq"));448         return Ok(());449     }450     if rows.is_empty() {451         return Ok(());452     }453     let grid: Vec<Vec<String>> = rows454         .iter()455         .map(|(n, f, _)| {456             let mut r = vec![format!("#{n}"), f.issue.state.clone(), f.issue.title.clone()];457             let mut tag = f.issue.labels.join(",");458             if let Some(m) = &f.issue.milestone {459                 if !tag.is_empty() {460                     tag.push(' ');461                 }462                 tag.push('@');463                 tag.push_str(m);464             }465             if !tag.is_empty() {466                 r.push(tag);467             }468             r469         })470         .collect();471     output::out(&format!("{}\n", output::align(&grid)));472     Ok(())473 }474 475 fn view(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {476     let uuid = resolve_id(fg, &one_id(fs, "view")?)?;477     let f = load_full(fg, &uuid)?;478     let cs = comment_list(fg, &uuid)?;479     let n = numbers(fg)?.get(&uuid).copied().unwrap_or(0);480     if fs.is("json") {481         let vj = ViewJson {482             number: n,483             title: f.issue.title.clone(),484             state: f.issue.state.clone(),485             labels: f.issue.labels.clone(),486             assignees: f.issue.assignees.clone(),487             milestone: f.issue.milestone.clone(),488             body: f.body.clone(),489             created_at: f.issue.created.clone(),490             comments: cs491                 .into_iter()492                 .map(|(c, b)| CommentJson {493                     id: c.id,494                     author: c.author,495                     body: b,496                     created_at: c.created,497                 })498                 .collect(),499         };500         output::emit_json(&vj, fs.get("jq"));501         return Ok(());502     }503     let mut human = format!("#{n} {} [{}]\n", f.issue.title, f.issue.state);504     human.push_str(&f.body);505     if !f.body.ends_with('\n') {506         human.push('\n');507     }508     for (c, b) in &cs {509         human.push_str(&format!("- {}: {}\n", c.author.as_deref().unwrap_or("?"), b.trim_end()));510     }511     output::out(&human);512     Ok(())513 }514 515 fn comment(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {516     let uuid = resolve_id(fg, &one_id(fs, "comment")?)?;517     load_full(fg, &uuid)?; // must exist518     if fs.has("body-file") && fs.get("body").is_some() {519         return Err(usage("f0rge issue comment: --body with --body-file not accepted"));520     }521     let body = match fs.get("body-file") {522         Some(src) => read_body_file(src)?,523         None => norm_body(fs.get("body").unwrap_or("")),524     };525     if clean(&body).is_empty() {526         return Err(usage("f0rge issue comment <id> --body B|--body-file F"));527     }528     let cid = uuid::Uuid::new_v4().to_string();529     let c = IssueComment {530         id: cid.clone(),531         author: fs.get("author").map(|s| s.to_string()),532         created: f0rge_core::now_rfc3339(),533     };534     let rel = Path::new("issues").join(&uuid).join("comments").join(format!("{cid}.md"));535     fg.save_entity(&rel, &c, &body)?;536     output::emit_json(537         &CommentJson { id: cid, author: c.author, body, created_at: c.created },538         fs.get("jq"),539     );540     Ok(())541 }542 543 fn set_state(fs: &args::FlagSet, fg: &Forge, verb: &str, state: &str) -> Result<(), CliError> {544     let uuid = resolve_id(fg, &one_id(fs, verb)?)?;545     let mut f = load_full(fg, &uuid)?;546     f.issue.state = state.to_string();547     f.issue.updated = Some(f0rge_core::now_rfc3339());548     fg.save_entity(&fg.issue_file(&uuid), &f.issue, &f.body)?; // owning file only549     emit_issue(fs, fg, &f)550 }551 552 fn assign(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {553     let uuid = resolve_id(fg, &one_id(fs, "assign")?)?;554     let given: Vec<String> = fs555         .get_all("assignee")556         .into_iter()557         .filter(|s| !clean(s).is_empty())558         .map(|s| s.to_string())559         .collect();560     if given.is_empty() {561         return Err(usage("f0rge issue assign <id> --assignee A"));562     }563     let mut f = load_full(fg, &uuid)?;564     if fs.is("remove") {565         f.issue.assignees.retain(|x| !given.contains(x));566     } else {567         let mut cur = f.issue.assignees.clone();568         for g in given {569             if !cur.contains(&g) {570                 cur.push(g);571             }572         }573         f.issue.assignees = cur;574     }575     f.issue.updated = Some(f0rge_core::now_rfc3339());576     fg.save_entity(&fg.issue_file(&uuid), &f.issue, &f.body)?;577     emit_issue(fs, fg, &f)578 }579 580 fn label(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {581     let uuid = resolve_id(fg, &one_id(fs, "label")?)?;582     let given: Vec<String> = fs583         .get_all("label")584         .into_iter()585         .filter(|s| !clean(s).is_empty())586         .map(|s| s.to_string())587         .collect();588     if given.is_empty() {589         return Err(usage("f0rge issue label <id> --label L"));590     }591     let mut f = load_full(fg, &uuid)?;592     if fs.is("remove") {593         f.issue.labels.retain(|x| !given.contains(x));594     } else {595         let mut cur = f.issue.labels.clone();596         for g in given {597             if !cur.contains(&g) {598                 cur.push(g);599             }600         }601         f.issue.labels = cur;602     }603     f.issue.updated = Some(f0rge_core::now_rfc3339());604     fg.save_entity(&fg.issue_file(&uuid), &f.issue, &f.body)?;605     emit_issue(fs, fg, &f)606 }607 608 fn edit(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {609     let uuid = resolve_id(fg, &one_id(fs, "edit")?)?;610     if fs.has("state") {611         return Err(usage("f0rge issue edit: --state not accepted (use close/reopen)"));612     }613     if fs.has("search") {614         return Err(usage("f0rge issue edit: --search not accepted"));615     }616     if fs.has("author") {617         return Err(usage("f0rge issue edit: --author not accepted"));618     }619     if fs.has("body-file") {620         return Err(usage("f0rge issue edit: --body-file not accepted"));621     }622     if fs.is("remove") {623         return Err(usage("f0rge issue edit: --remove not accepted"));624     }625     const USE: &str = "f0rge issue edit <id> [--title T] [--body B] [--milestone M] [--label L] [--assignee A]";626     let title = fs.get("title");627     let b = fs.get("body");628     let ms = fs.get("milestone");629     // append semantics, same as the label/assign verbs (dedup, no replace).630     let labs: Vec<String> = fs631         .get_all("label")632         .into_iter()633         .filter(|s| !clean(s).is_empty())634         .map(|s| s.to_string())635         .collect();636     let asgs: Vec<String> = fs637         .get_all("assignee")638         .into_iter()639         .filter(|s| !clean(s).is_empty())640         .map(|s| s.to_string())641         .collect();642     if title.is_none() && b.is_none() && ms.is_none() && labs.is_empty() && asgs.is_empty() {643         return Err(usage(USE));644     }645     let mut f = load_full(fg, &uuid)?;646     if let Some(t) = title {647         if clean(t).is_empty() {648             return Err(usage(USE));649         }650         f.issue.title = t.to_string();651     }652     if let Some(b) = b {653         f.body = norm_body(b);654     }655     if let Some(m) = ms {656         if clean(m).is_empty() {657             return Err(usage(USE));658         }659         f.issue.milestone = Some(m.to_string());660     }661     for g in labs {662         if !f.issue.labels.contains(&g) {663             f.issue.labels.push(g);664         }665     }666     for g in asgs {667         if !f.issue.assignees.contains(&g) {668             f.issue.assignees.push(g);669         }670     }671     f.issue.updated = Some(f0rge_core::now_rfc3339());672     fg.save_entity(&fg.issue_file(&uuid), &f.issue, &f.body)?;673     emit_issue(fs, fg, &f)674 }675 676 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {677     if args_in.is_empty() {678         return Err(usage("f0rge issue <verb> [flags] (try: f0rge issue -h)"));679     }680     let verb = clean(&args_in[0]);681     if verb.is_empty() || verb.starts_with('-') || !VERBS.contains(&verb.as_str()) {682         let shown = if verb.is_empty() { "<none>".to_string() } else { verb };683         return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge issue -h)")));684     }685     let fs = args::parse_flags(&args_in[1..], &spec())686         .map_err(|e| with_try(e, &verb))?;687     if fs.is("help") {688         if let Some(h) = verb_help(&verb) {689             output::out(h);690             return Ok(());691         }692     }693     match verb.as_str() {694         "create" => create(&fs, fg).map_err(|e| with_try(e, &verb)),695         "list" => list(&fs, fg).map_err(|e| with_try(e, &verb)),696         "view" => view(&fs, fg).map_err(|e| with_try(e, &verb)),697         "comment" => comment(&fs, fg).map_err(|e| with_try(e, &verb)),698         "close" => set_state(&fs, fg, "close", "closed").map_err(|e| with_try(e, &verb)),699         "reopen" => set_state(&fs, fg, "reopen", "open").map_err(|e| with_try(e, &verb)),700         "assign" => assign(&fs, fg).map_err(|e| with_try(e, &verb)),701         "label" => label(&fs, fg).map_err(|e| with_try(e, &verb)),702         "edit" => edit(&fs, fg).map_err(|e| with_try(e, &verb)),703         _ => Err(usage("f0rge issue <verb> [flags] (try: f0rge issue -h)")),704     }705 }706 707 #[cfg(test)]708 mod verb_help_tests {709     use super::*;710     fn budget(s: &str) {711         assert!(s.lines().count() <= 8, "lines:\n{s}");712         assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());713     }714     #[test]715     fn per_verb_budgets_and_needles() {716         for (v, needle) in [717             ("create", "--title"),718             ("list", "--label"),719             ("view", "<id>"),720             ("comment", "--body"),721             ("close", "close <id>"),722             ("reopen", "reopen <id>"),723             ("assign", "--assignee"),724             ("label", "--label"),725             ("edit", "--title"),726         ] {727             let h = verb_help(v).expect(v);728             budget(h);729             assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");730             assert!(h.ends_with("contract: f0rge --skill\n"), "{v} contract:\n{h}");731         }732         assert!(verb_help("frobnicate").is_none());733         // #43-#45: new flags pinned, --state never advertised for edit.734         for needle in ["--body-file"] {735             assert!(verb_help("create").expect("create").contains(needle));736             assert!(verb_help("comment").expect("comment").contains(needle));737         }738         assert!(verb_help("list").expect("list").contains("--milestone"));739         assert!(verb_help("list").expect("list").contains("--state"));740         let eh = verb_help("edit").expect("edit");741         for needle in ["--milestone", "--label", "--assignee"] {742             assert!(eh.contains(needle), "edit missing `{needle}`:\n{eh}");743         }744         assert!(!eh.contains("--state"), "edit advertises --state:\n{eh}");745     }746 }