merge e233a109-49a1-4497-92a0-8ab614376504
Message
merge e233a109-49a1-4497-92a0-8ab614376504
Diff
crates/cli/src/cmd/issue.rs+133 −16
--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -10,19 +10,19 @@ use std::collections::BTreeMap;1010 use std::path::Path;1111 1212 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 --state --search\ndocs: spec/schema.md §Issue\ncontract: f0rge --skill\n"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"1414 }1515 1616 // 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 --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] [--search Q]\ncontract: f0rge --skill\n";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";1919 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 [--author A]\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";2121 const CLOSE_H: &str = "f0rge issue close: shut\nusage: f0rge issue close <id>\ncontract: f0rge --skill\n";2222 const REOPEN_H: &str = "f0rge issue reopen: open\nusage: f0rge issue reopen <id>\ncontract: f0rge --skill\n";2323 const ASSIGN_H: &str = "f0rge issue assign: owner\nusage: f0rge issue assign <id> --assignee A [--remove]\ncontract: f0rge --skill\n";2424 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]\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";2626 2727 /// Verb-depth help for future dispatch (#36). None when unknown.2828 pub fn verb_help(verb: &str) -> Option<&'static str> {@@ -55,7 +55,7 @@ const VERBS: &[&str] =5555 5656 fn spec() -> Vec<args::FlagSpec> {5757 args::spec_from_lists(58- &["title", "body", "label", "assignee", "milestone", "state", "search", "author", "jq"],58+ &["title", "body", "body-file", "label", "assignee", "milestone", "state", "search", "author", "jq"],5959 &["json", "remove"],6060 )6161 }@@ -277,6 +277,40 @@ fn norm_body(b: &str) -> String {277277 }278278 }279279 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+280314 fn emit_issue(fs: &args::FlagSet, fg: &Forge, f: &Full) -> Result<(), CliError> {281315 let n = numbers(fg)?.get(&f.id).copied().unwrap_or(0);282316 let c = comment_list(fg, &f.id)?.len() as u64;@@ -313,7 +347,13 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {313347 if clean(title).is_empty() {314348 return Err(usage("f0rge issue create --title T"));315349 }316- let body = norm_body(fs.get("body").unwrap_or(""));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+ };317357 let labels = dedup(318358 fs.get_all("label")319359 .into_iter()@@ -348,7 +388,7 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {348388 349389 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {350390 if !fs.positional.is_empty() {351- return Err(usage("f0rge issue list [--label L] [--assignee A] [--state S]"));391+ return Err(usage("f0rge issue list [--label L] [--assignee A] [--state S] [--milestone M] [--search Q]"));352392 }353393 if let Some(s) = fs.get("state") {354394 if !["open", "closed", "in-progress", "archived"].contains(&s) {@@ -412,7 +452,21 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {412452 }413453 let grid: Vec<Vec<String>> = rows414454 .iter()415- .map(|(n, f, _)| vec![format!("#{n}"), f.issue.state.clone(), f.issue.title.clone()])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+ })416470 .collect();417471 output::out(&format!("{}\n", output::align(&grid)));418472 Ok(())@@ -461,9 +515,15 @@ fn view(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {461515 fn comment(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {462516 let uuid = resolve_id(fg, &one_id(fs, "comment")?)?;463517 load_full(fg, &uuid)?; // must exist464- let body = fs.get("body").unwrap_or("");465- if clean(body).is_empty() {466- return Err(usage("f0rge issue comment <id> --body B"));518+ 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"));467527 }468528 let cid = uuid::Uuid::new_v4().to_string();469529 let c = IssueComment {@@ -472,7 +532,6 @@ fn comment(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {472532 created: f0rge_core::now_rfc3339(),473533 };474534 let rel = Path::new("issues").join(&uuid).join("comments").join(format!("{cid}.md"));475- let body = norm_body(body);476535 fg.save_entity(&rel, &c, &body)?;477536 output::emit_json(478537 &CommentJson { id: cid, author: c.author, body, created_at: c.created },@@ -548,21 +607,67 @@ fn label(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {548607 549608 fn edit(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {550609 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]";551626 let title = fs.get("title");552627 let b = fs.get("body");553- if title.is_none() && b.is_none() {554- return Err(usage("f0rge issue edit <id> [--title T] [--body B]"));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));555644 }556645 let mut f = load_full(fg, &uuid)?;557646 if let Some(t) = title {558647 if clean(t).is_empty() {559- return Err(usage("f0rge issue edit <id> [--title T] [--body B]"));648+ return Err(usage(USE));560649 }561650 f.issue.title = t.to_string();562651 }563652 if let Some(b) = b {564653 f.body = norm_body(b);565654 }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+ }566671 f.issue.updated = Some(f0rge_core::now_rfc3339());567672 fg.save_entity(&fg.issue_file(&uuid), &f.issue, &f.body)?;568673 emit_issue(fs, fg, &f)@@ -625,5 +730,17 @@ mod verb_help_tests {625730 assert!(h.ends_with("contract: f0rge --skill\n"), "{v} contract:\n{h}");626731 }627732 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}");628745 }629746 }
crates/cli/tests/issue_e2e.rs+180 −0
--- a/crates/cli/tests/issue_e2e.rs+++ b/crates/cli/tests/issue_e2e.rs@@ -30,6 +30,25 @@ fn jv(s: &str) -> serde_json::Value {3030 serde_json::from_str(s.trim()).expect("json line")3131 }3232 33+fn run_in_stdin(dir: &Path, args: &[&str], input: &str) -> (String, String, i32) {34+ use std::io::Write;35+ let mut kid = bin()36+ .current_dir(dir)37+ .args(args)38+ .stdin(std::process::Stdio::piped())39+ .stdout(std::process::Stdio::piped())40+ .stderr(std::process::Stdio::piped())41+ .spawn()42+ .expect("spawn f0rge");43+ kid.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();44+ let out = kid.wait_with_output().expect("wait f0rge");45+ (46+ String::from_utf8_lossy(&out.stdout).into_owned(),47+ String::from_utf8_lossy(&out.stderr).into_owned(),48+ out.status.code().unwrap_or(-1),49+ )50+}51+3352 fn field_order(s: &str, keys: &[&str]) {3453 let mut last = 0;3554 for k in keys {@@ -288,3 +307,164 @@ fn create_rejects_state_and_silent_flags() {288307 assert_eq!(o.lines().count(), 1);289308 let _ = std::fs::remove_dir_all(&dir);290309 }310+311+#[test]312+fn edit_milestone_label_assignee_and_state_rejection() {313+ // #43: edit gains --milestone/--label/--assignee (append, like the314+ // label/assign verbs); --state stays rejected with the try-path hint.315+ let dir = tmp("editflags");316+ run_in(&dir, &["init"]);317+ let (o, _, code) = run_in(318+ &dir,319+ &["issue", "create", "--title", "E", "--body", "b", "--label", "bug",320+ "--assignee", "ann", "--milestone", "v1", "--json"],321+ );322+ assert_eq!(code, 0);323+ let n = jv(&o)["number"].to_string();324+325+ // --milestone regroups, everything else intact326+ let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--milestone", "v2"]);327+ assert_eq!(code, 0);328+ let v = jv(&o);329+ assert_eq!(v["milestone"], "v2");330+ assert_eq!(v["labels"], serde_json::json!(["bug"]));331+ assert_eq!(v["assignees"], serde_json::json!(["ann"]));332+ assert_eq!(v["title"], "E");333+ assert_eq!(v["body"], "b\n");334+335+ // --label/--assignee append with dedup (verb semantics, no replace)336+ let (o, _, code) = run_in(337+ &dir,338+ &["issue", "edit", &n, "--label", "urgent", "--label", "bug", "--assignee", "zed"],339+ );340+ assert_eq!(code, 0);341+ let v = jv(&o);342+ assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));343+ assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));344+ assert_eq!(v["milestone"], "v2");345+346+ // --state rejected: exit 2, one line, try-path hint; nothing changed347+ let (_, err, code) = run_in(&dir, &["issue", "edit", &n, "--state", "closed"]);348+ assert_eq!(code, 2);349+ assert_eq!(err.lines().count(), 1);350+ assert!(err.contains("--state") && err.contains("(try: f0rge issue edit --help)"), "{err:?}");351+ let (o, _, _) = run_in(&dir, &["issue", "view", &n, "--json"]);352+ assert_eq!(jv(&o)["state"], "open");353+354+ let _ = std::fs::remove_dir_all(&dir);355+}356+357+#[test]358+fn body_file_create_and_comment() {359+ // #44: --body-file <path|-> on create + comment; stdin via `-`.360+ let dir = tmp("bodyfile");361+ run_in(&dir, &["init"]);362+ let hostile = "shell-hostile: `back` $var \"dq\" 'sq'\nline2 `$(x)`\n";363+ std::fs::write(dir.join("notes.md"), hostile).unwrap();364+365+ let (o, _, code) =366+ run_in(&dir, &["issue", "create", "--title", "F", "--body-file", "notes.md", "--json"]);367+ assert_eq!(code, 0);368+ let v = jv(&o);369+ assert_eq!(v["body"], hostile);370+ let n = v["number"].to_string();371+372+ // stdin via `-`373+ let (o, _, code) = run_in_stdin(374+ &dir,375+ &["issue", "create", "--title", "S", "--body-file", "-", "--json"],376+ "stdin `body` $x\n",377+ );378+ assert_eq!(code, 0);379+ assert_eq!(jv(&o)["body"], "stdin `body` $x\n");380+381+ // comment from file + stdin382+ let (o, _, code) =383+ run_in(&dir, &["issue", "comment", &n, "--body-file", "notes.md", "--json"]);384+ assert_eq!(code, 0);385+ assert_eq!(jv(&o)["body"], hostile);386+ let (o, _, code) = run_in_stdin(387+ &dir,388+ &["issue", "comment", &n, "--body-file", "-", "--json"],389+ "c2\n",390+ );391+ assert_eq!(code, 0);392+ assert_eq!(jv(&o)["body"], "c2\n");393+394+ // --body with --body-file rejected (exit 2, one line)395+ let (_, err, code) = run_in(396+ &dir,397+ &["issue", "create", "--title", "X", "--body", "b", "--body-file", "notes.md"],398+ );399+ assert_eq!(code, 2);400+ assert_eq!(err.lines().count(), 1);401+402+ // missing file -> runtime exit 1, one line403+ let (_, err, code) =404+ run_in(&dir, &["issue", "create", "--title", "X", "--body-file", "nope.md"]);405+ assert_eq!(code, 1);406+ assert_eq!(err.lines().count(), 1);407+408+ let _ = std::fs::remove_dir_all(&dir);409+}410+411+#[test]412+fn field_preservation_regression() {413+ // #45: an issue carrying milestone+labels+assignee survives label/assign414+ // edits with ALL fields intact (the silent-wipe class stays dead).415+ let dir = tmp("preserve");416+ run_in(&dir, &["init"]);417+ let (o, _, code) = run_in(418+ &dir,419+ &["issue", "create", "--title", "P", "--body", "keep", "--label", "bug",420+ "--assignee", "ann", "--milestone", "v9", "--json"],421+ );422+ assert_eq!(code, 0);423+ let n = jv(&o)["number"].to_string();424+425+ // label verb (append) must not wipe milestone/assignees/body426+ let (o, _, code) = run_in(&dir, &["issue", "label", &n, "--label", "urgent"]);427+ assert_eq!(code, 0);428+ let v = jv(&o);429+ assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));430+ assert_eq!(v["milestone"], "v9");431+ assert_eq!(v["assignees"], serde_json::json!(["ann"]));432+ assert_eq!(v["body"], "keep\n");433+434+ // assign verb (append) must not wipe milestone/labels/body435+ let (o, _, code) = run_in(&dir, &["issue", "assign", &n, "--assignee", "zed"]);436+ assert_eq!(code, 0);437+ let v = jv(&o);438+ assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));439+ assert_eq!(v["milestone"], "v9");440+ assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));441+442+ // edit --title keeps the rest (partial-record-write class stays dead)443+ let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--title", "P2"]);444+ assert_eq!(code, 0);445+ let v = jv(&o);446+ assert_eq!(v["title"], "P2");447+ assert_eq!(v["state"], "open");448+ assert_eq!(v["milestone"], "v9");449+ assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));450+ assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));451+ assert_eq!(v["body"], "keep\n");452+453+ // raw record still carries every field454+ let issues_dir = dir.join(".forge/issues");455+ let mut files = Vec::new();456+ for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {457+ files.push(std::fs::read_to_string(issues_dir.join(e.file_name()).join("issue.md")).unwrap());458+ }459+ assert_eq!(files.len(), 1);460+ for needle in ["P2", "bug", "urgent", "ann", "zed", "v9", "keep"] {461+ assert!(files[0].contains(needle), "record lost `{needle}`:\n{}", files[0]);462+ }463+464+ // human list shows labels (+ milestone when set)465+ let (h, _, code) = run_in(&dir, &["issue", "list"]);466+ assert_eq!(code, 0);467+ assert!(h.contains("bug") && h.contains("urgent") && h.contains("v9"), "list:\n{h}");468+469+ let _ = std::fs::remove_dir_all(&dir);470+}