merge e233a109-49a1-4497-92a0-8ab614376504

2da8df0gl0w

all commits

Message

merge e233a109-49a1-4497-92a0-8ab614376504

Diff

crates/cli/src/cmd/issue.rs+133 −16
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex fe0de19..2f7217e 100644--- 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
diff --git a/crates/cli/tests/issue_e2e.rs b/crates/cli/tests/issue_e2e.rsindex 1f5f1ff..73551c7 100644--- 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+}