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 }