oh issue edit, and oh dies quietly on a closed pipe b5229b56f49b
oh issue edit N changes an issue's title and body (--title, --body,
-F), or as whole sets its labels (--label, --no-labels), assignees
(--assignee, --no-assignees) and milestone (--milestone, "" to clear),
through PATCH /issues/{n}; oh issue label still adds and removes.
oh resets SIGPIPE as oxy does, so 'oh issue list | head -1' ends
without 'failed printing to stdout: Broken pipe'.
closes #95, closes #92
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 02:32 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b5229b56f49b7d54b1e11b60c038030a87cdd36357e73a23134a727685c86877- Depends on
- 2291f8cc9c96 5c6e92fd8d71 d23464c0ac60
- Depended on by
- 306aec1839aa 17a3a84b00d1 5ea4872f23e8
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified Cargo.lock +1 −0
| … | 2130 unchanged lines |
||
| 2131 | 2131 | "axum", |
|
| 2132 | 2132 | "clap", |
|
| 2133 | 2133 | "hub-api", |
|
| 2134 | + | "libc", |
|
| 2134 | 2135 | "oxy-core", |
|
| 2135 | 2136 | "oxygenhub", |
|
| 2136 | 2137 | "serde", |
|
| … | 2498 unchanged lines |
modified crates/oh/Cargo.toml +1 −0
| … | 17 unchanged lines |
||
| 18 | 18 | serde_json.workspace = true |
|
| 19 | 19 | toml.workspace = true |
|
| 20 | 20 | ureq = "2" |
|
| 21 | + | libc = "0.2" |
|
| 21 | 22 | anyhow.workspace = true |
|
| 22 | 23 | ||
| 23 | 24 | [dev-dependencies] |
|
| … | 4 unchanged lines |
modified crates/oh/src/main.rs +64 −0
| … | 143 unchanged lines |
||
| 144 | 144 | }, |
|
| 145 | 145 | /// Reopen an issue |
|
| 146 | 146 | Reopen { number: i64 }, |
|
| 147 | + | /// Change an issue's title or body (its author or triage), or its |
|
| 148 | + | /// labels, assignees or milestone as whole sets (triage) |
|
| 149 | + | Edit { |
|
| 150 | + | number: i64, |
|
| 151 | + | #[arg(long, short)] |
|
| 152 | + | title: Option<String>, |
|
| 153 | + | #[arg(long, short)] |
|
| 154 | + | body: Option<String>, |
|
| 155 | + | /// Read the body from a file ("-" for stdin) |
|
| 156 | + | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 157 | + | body_file: Option<String>, |
|
| 158 | + | /// The labels it should have (repeatable; `oh issue label` adds and removes) |
|
| 159 | + | #[arg(long, short)] |
|
| 160 | + | label: Vec<String>, |
|
| 161 | + | /// Clear its labels |
|
| 162 | + | #[arg(long, conflicts_with = "label")] |
|
| 163 | + | no_labels: bool, |
|
| 164 | + | /// The users it should be assigned to (repeatable) |
|
| 165 | + | #[arg(long, short)] |
|
| 166 | + | assignee: Vec<String>, |
|
| 167 | + | /// Take everyone off it |
|
| 168 | + | #[arg(long, conflicts_with = "assignee")] |
|
| 169 | + | no_assignees: bool, |
|
| 170 | + | /// A milestone's title ("" takes it out of its milestone) |
|
| 171 | + | #[arg(long, short)] |
|
| 172 | + | milestone: Option<String>, |
|
| 173 | + | }, |
|
| 147 | 174 | /// Comment on an issue |
|
| 148 | 175 | Comment { |
|
| 149 | 176 | number: i64, |
|
| … | 172 unchanged lines |
||
| 322 | 349 | } |
|
| 323 | 350 | ||
| 324 | 351 | fn main() { |
|
| 352 | + | // `oh issue view 90 | head -1`: die quietly on SIGPIPE, as oxy and |
|
| 353 | + | // other command-line tools do, instead of panicking on the failed |
|
| 354 | + | // write (oxygenhub#92). |
|
| 355 | + | #[cfg(unix)] |
|
| 356 | + | unsafe { |
|
| 357 | + | libc::signal(libc::SIGPIPE, libc::SIG_DFL); |
|
| 358 | + | } |
|
| 325 | 359 | let cli = Cli::parse(); |
|
| 326 | 360 | ui::init(cli.plain); |
|
| 327 | 361 | match run(cli) { |
|
| … | 225 unchanged lines |
||
| 553 | 587 | let i: Issue = |
|
| 554 | 588 | c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?; |
|
| 555 | 589 | println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title); |
|
| 590 | + | } |
|
| 591 | + | IssueCmd::Edit { number, title, body, body_file, label, no_labels, assignee, no_assignees, milestone } => { |
|
| 592 | + | let body = match (&body, &body_file) { |
|
| 593 | + | (None, None) => None, |
|
| 594 | + | _ => Some(body_of(body, body_file)?), |
|
| 595 | + | }; |
|
| 596 | + | let labels = if no_labels { |
|
| 597 | + | Some(Vec::new()) |
|
| 598 | + | } else if label.is_empty() { |
|
| 599 | + | None |
|
| 600 | + | } else { |
|
| 601 | + | Some(label) |
|
| 602 | + | }; |
|
| 603 | + | let assignees = if no_assignees { |
|
| 604 | + | Some(Vec::new()) |
|
| 605 | + | } else if assignee.is_empty() { |
|
| 606 | + | None |
|
| 607 | + | } else { |
|
| 608 | + | Some(assignee) |
|
| 609 | + | }; |
|
| 610 | + | if title.is_none() && body.is_none() && labels.is_none() && assignees.is_none() && milestone.is_none() { |
|
| 611 | + | bail!("say what to change: --title, --body, --body-file, --label, --assignee or --milestone"); |
|
| 612 | + | } |
|
| 613 | + | let edit = hub_api::IssueEdit { title, body, labels, assignees, milestone }; |
|
| 614 | + | let i: Issue = c.send("PATCH", &format!("{base}/issues/{number}"), &edit)?; |
|
| 615 | + | if json { |
|
| 616 | + | println!("{}", serde_json::to_string(&i)?); |
|
| 617 | + | } else { |
|
| 618 | + | println!("{} edited #{} {}", ui::ok("✔"), i.number, ui::dim(&i.url)); |
|
| 619 | + | } |
|
| 556 | 620 | } |
|
| 557 | 621 | IssueCmd::Label(change) => { |
|
| 558 | 622 | let path = format!("{base}/issues/{}", change.number); |
|
| … | 422 unchanged lines |
modified crates/oh/tests/cli.rs +17 −1
| … | 80 unchanged lines |
||
| 81 | 81 | // The site token acts as the first site administrator. |
|
| 82 | 82 | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 83 | 83 | h.app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); |
|
| 84 | - | db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); |
|
| 84 | + | let demo = db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); |
|
| 85 | + | db::issues::upsert_label(&h.app.db, demo.id, "bug", "#d73a4a", "").await.unwrap(); |
|
| 85 | 86 | ||
| 86 | 87 | // A checkout whose origin is the hub: `oh` needs nothing else. |
|
| 87 | 88 | let work = tempfile::tempdir().unwrap(); |
|
| … | 25 unchanged lines |
||
| 113 | 114 | let (_, out, _) = oh(w, &[], &["issue", "list", "--json"]).await; |
|
| 114 | 115 | let list: serde_json::Value = serde_json::from_str(&out).unwrap(); |
|
| 115 | 116 | assert_eq!((list[0]["number"].as_i64(), list[0]["title"].as_str()), (Some(1), Some("Broken thing"))); |
|
| 117 | + | let (code, out, err) = |
|
| 118 | + | oh(w, &[], &["issue", "edit", "1", "--title", "Broken thing, badly", "--label", "bug", "--assignee", "andy"]).await; |
|
| 119 | + | assert_eq!(code, 0, "{err}"); |
|
| 120 | + | assert!(out.contains("edited #1"), "{out}"); |
|
| 121 | + | let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--json"]).await; |
|
| 122 | + | let i: serde_json::Value = serde_json::from_str(&out).unwrap(); |
|
| 123 | + | assert_eq!(i["title"], "Broken thing, badly"); |
|
| 124 | + | assert_eq!(i["labels"][0]["name"], "bug"); |
|
| 125 | + | assert_eq!(i["assignees"][0], "andy"); |
|
| 126 | + | let (code, _, err) = oh(w, &[], &["issue", "edit", "1"]).await; |
|
| 127 | + | assert_eq!(code, 2); |
|
| 128 | + | assert!(err.contains("say what to change"), "{err}"); |
|
| 129 | + | let (_, out, _) = oh(w, &[], &["issue", "edit", "1", "--no-labels", "--no-assignees", "--json"]).await; |
|
| 130 | + | let i: serde_json::Value = serde_json::from_str(&out).unwrap(); |
|
| 131 | + | assert!(i["labels"].as_array().unwrap().is_empty() && i["assignees"].as_array().unwrap().is_empty(), "{out}"); |
|
| 116 | 132 | let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "--body", "Seen it too."]).await; |
|
| 117 | 133 | assert!(out.contains("commented on #1")); |
|
| 118 | 134 | let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--plain"]).await; |
|
| … | 80 unchanged lines |
modified docs/oh.md +2 −0
| … | 37 unchanged lines |
||
| 38 | 38 | | `oh issue list [--state open\|closed\|all]` | Issues | |
|
| 39 | 39 | | `oh issue view N` | An issue with its comments | |
|
| 40 | 40 | | `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue | |
|
| 41 | + | | `oh issue edit N [-t TITLE] [-b BODY \| -F FILE] [-l LABEL]… [-a USER]… [-m MILESTONE]` | Change it; labels and assignees are the whole set (`--no-labels`, `--no-assignees` clear them), `-m ""` clears the milestone | |
|
| 42 | + | | `oh issue label N --add L --remove L` | Put labels on or take them off | |
|
| 41 | 43 | | `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen | |
|
| 42 | 44 | | `oh issue comment N -b BODY` | Comment | |
|
| 43 | 45 | | `oh proposal list [--state open\|draft\|landed\|closed\|all]`, `oh proposal view N` | Proposals, with changes, approvals and blockers; a landed one with who landed it and how | |
|
| … | 19 unchanged lines |