OXYGENHUB

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