OXYGENHUB

oh: OxygenHub from the command line 5c6e92fd8d71

A new crate, crates/oh, on hub-api. In an Oxygen checkout it uses the
remote's hub, repository, token and CA, as oxy does; elsewhere --repo
with a hub saved by oh auth login (owner-only config) or OH_HUB. Commands:
auth login/status, repo view, issue list/view/create/close/reopen/comment,
proposal list/view/create/checks, run list/view/start/watch/log/rerun,
release list/create/upload/trust, and oh api for any call. Colour and
glyphs on a terminal, --plain, and --json with the API's own JSON. The
CI fmt job covers the new crate. docs/oh.md describes it.

closes #43
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 10:52 UTC
Signature
✔ signed by Andy McCall
Hash
5c6e92fd8d71f940f4769a1bc71d9461eb4f7f3b3cf118374a62f3b53eab4d09
Depends on
25091e5c3bba 380996db84e5 93be9ce4653e d23464c0ac60
Depended on by
1062a0f8e985 fb8f35d025ab 40a43921ef42 f2d2776b2a14 0bc95135954d 64e5964e9f13 306aec1839aa e4f1f52f2929 8297a33b9a4c 47932fa8d6f3 1c52d940b4a0 b7e1be264c0a 75491d35c258 041a945f55f8 04f9c984439e 27322cd2755a c26267b3aba6 fac6853c4e46 3d086bc3d69b 8306eca4e4a3 1ec29691b67a 4dfa4599b3f7 a9088323c390 84f09ad45851 c4bffee5b0d4 5caa65fd51e7 d7e2c2754807 d061ac87618c 9bbca80f777a c2efc0b4674d a3ba7cc10673 234921ea0834 09ffa7b600a9 73c01b3448ee de4467cb1eca b7d2b0b30b4a 439ecd8708a1 6a306cd03fc1 69e4f27c5633 456f4197ade3 ba0a55bb5b3c ab2d8b40f9f4 a549ddd5941f 166f6e9bf4f1 9c04c779f032 86a69b53aff0 2a8b621676b4 bd91edeb44f8 8aa5ef384ef1 dc59d1b56c3d 7ebb7052bc7f 11a2e09d6a69 32d97df55ab7 e8fa53694f68 71e65fcf95ab 501d8b268dd4 59338826539c 40dd3f86379a 67040b158ae2 0650d94ede82 b1e92a6233ee 8aafd2023dec 17a3a84b00d1 62ceb2eec18c a6c241e8eba0 5ea4872f23e8 b5229b56f49b 56a786aa42f1 745d225de344 0a56c5313bc0 2291f8cc9c96 34f40eb4b205 739a763a558c ced6d3afc5cf 1b770a1fecf0 d305b3b0afdc 2bbe88a85860 df71c7892d27 bd630386001e cefb0ea24511 0b20f1ec4007 d91780ee20de e9bfea8e26db 1f1eb06a9009
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.1.0 v0.2.0 v0.3.0 v0.3.1 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
12 atoms, 10 tree operations, 10 files

Browse the tree at this change

added crates/oh/Cargo.toml +27 −0
1 +
[package]
2 +
name = "oh"
3 +
description = "OxygenHub from the command line: issues, proposals, Flow runs and releases"
4 +
version.workspace = true
5 +
edition.workspace = true
6 +
license.workspace = true
7 +
authors.workspace = true
8 +
9 +
[[bin]]
10 +
name = "oh"
11 +
path = "src/main.rs"
12 +
13 +
[dependencies]
14 +
hub-api = { path = "../hub-api" }
15 +
oxy-core.workspace = true
16 +
clap.workspace = true
17 +
serde.workspace = true
18 +
serde_json.workspace = true
19 +
toml.workspace = true
20 +
ureq = "2"
21 +
anyhow.workspace = true
22 +
23 +
[dev-dependencies]
24 +
oxygenhub = { path = "../hub" }
25 +
tokio.workspace = true
26 +
tempfile.workspace = true
27 +
axum.workspace = true
added crates/oh/src/client.rs +79 −0
1 +
//! The hub's JSON API over HTTPS, with the checkout's CA when the hub's
2 +
//! certificate is self-signed.
3 +
4 +
use crate::target::Target;
5 +
use anyhow::{bail, Context, Result};
6 +
use serde::de::DeserializeOwned;
7 +
use serde::Serialize;
8 +
use std::time::Duration;
9 +
10 +
pub struct Client {
11 +
    agent: ureq::Agent,
12 +
    base: String,
13 +
    token: Option<String>,
14 +
}
15 +
16 +
impl Client {
17 +
    pub fn new(t: &Target) -> Result<Client> {
18 +
        let tls = oxy_core::protocol::tls_client_config(t.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
19 +
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
20 +
        Ok(Client { agent, base: format!("{}/api/v1", t.hub), token: t.token.clone() })
21 +
    }
22 +
23 +
    fn req(&self, method: &str, path: &str) -> ureq::Request {
24 +
        let r = self.agent.request(method, &format!("{}{path}", self.base)).set("Accept", "application/json");
25 +
        match &self.token {
26 +
            Some(t) => r.set("Authorization", &format!("Bearer {t}")),
27 +
            None => r,
28 +
        }
29 +
    }
30 +
31 +
    fn finish(res: Result<ureq::Response, ureq::Error>, what: &str) -> Result<String> {
32 +
        match res {
33 +
            Ok(r) => Ok(r.into_string()?),
34 +
            Err(ureq::Error::Status(code, r)) => {
35 +
                let body = r.into_string().unwrap_or_default();
36 +
                // Problem documents (RFC 7807) carry the reason in `detail`.
37 +
                let detail = serde_json::from_str::<serde_json::Value>(&body)
38 +
                    .ok()
39 +
                    .and_then(|v| v["detail"].as_str().map(String::from))
40 +
                    .unwrap_or(body);
41 +
                let hint = match code {
42 +
                    401 => " (sign in: `oh auth login`, or push with a token first)",
43 +
                    403 => " (your token lacks the scope or the role for this)",
44 +
                    _ => "",
45 +
                };
46 +
                bail!("{what}: HTTP {code}: {}{hint}", detail.trim())
47 +
            }
48 +
            Err(e) => Err(e).context(what.to_string()),
49 +
        }
50 +
    }
51 +
52 +
    /// GET, as raw JSON text.
53 +
    pub fn get_text(&self, path: &str) -> Result<String> {
54 +
        Self::finish(self.req("GET", path).call(), &format!("GET {path}"))
55 +
    }
56 +
57 +
    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
58 +
        let text = self.get_text(path)?;
59 +
        serde_json::from_str(&text).with_context(|| format!("GET {path}: unexpected answer"))
60 +
    }
61 +
62 +
    pub fn send_text(&self, method: &str, path: &str, body: &impl Serialize) -> Result<String> {
63 +
        let json = serde_json::to_string(body)?;
64 +
        Self::finish(self.req(method, path).set("Content-Type", "application/json").send_string(&json), &format!("{method} {path}"))
65 +
    }
66 +
67 +
    pub fn send<T: DeserializeOwned>(&self, method: &str, path: &str, body: &impl Serialize) -> Result<T> {
68 +
        let text = self.send_text(method, path, body)?;
69 +
        serde_json::from_str(&text).with_context(|| format!("{method} {path}: unexpected answer"))
70 +
    }
71 +
72 +
    /// POST raw bytes (a release asset).
73 +
    pub fn upload(&self, path: &str, bytes: &[u8]) -> Result<String> {
74 +
        Self::finish(
75 +
            self.req("POST", path).set("Content-Type", "application/octet-stream").timeout(Duration::from_secs(1800)).send_bytes(bytes),
76 +
            &format!("POST {path}"),
77 +
        )
78 +
    }
79 +
}
added crates/oh/src/main.rs +717 −0
1 +
//! `oh`: OxygenHub from the command line, as `gh` is for GitHub. It talks
2 +
//! to the hub's JSON API (hub spec §12) as the checkout's remote does:
3 +
//! same hub, same token, same CA.
4 +
5 +
mod client;
6 +
mod target;
7 +
mod ui;
8 +
9 +
use anyhow::{bail, Context, Result};
10 +
use clap::{Parser, Subcommand};
11 +
use client::Client;
12 +
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release};
13 +
use std::time::Duration;
14 +
use target::{Choice, Config, Host, Target};
15 +
16 +
#[derive(Parser)]
17 +
#[command(name = "oh", version, about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")]
18 +
struct Cli {
19 +
    /// The repository, when not inside an Oxygen checkout
20 +
    #[arg(long, short = 'R', global = true, value_name = "OWNER/NAME")]
21 +
    repo: Option<String>,
22 +
    /// The hub, with --repo or outside a checkout (default: OH_HUB or the one `oh auth login` saved)
23 +
    #[arg(long, global = true, value_name = "URL")]
24 +
    hub: Option<String>,
25 +
    /// Which of the checkout's remotes to use (default: origin, or the only one)
26 +
    #[arg(long, global = true)]
27 +
    remote: Option<String>,
28 +
    /// Machine-readable output: the API's JSON, unchanged
29 +
    #[arg(long, global = true)]
30 +
    json: bool,
31 +
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
32 +
    #[arg(long, global = true)]
33 +
    plain: bool,
34 +
    #[command(subcommand)]
35 +
    cmd: Cmd,
36 +
}
37 +
38 +
#[derive(Subcommand)]
39 +
enum Cmd {
40 +
    /// Sign in to a hub, or show who you are signed in as
41 +
    Auth {
42 +
        #[command(subcommand)]
43 +
        cmd: AuthCmd,
44 +
    },
45 +
    /// The repository
46 +
    Repo {
47 +
        #[command(subcommand)]
48 +
        cmd: RepoCmd,
49 +
    },
50 +
    /// Issues
51 +
    Issue {
52 +
        #[command(subcommand)]
53 +
        cmd: IssueCmd,
54 +
    },
55 +
    /// Proposals (pull requests)
56 +
    Proposal {
57 +
        #[command(subcommand)]
58 +
        cmd: ProposalCmd,
59 +
    },
60 +
    /// OxygenFlow runs
61 +
    Run {
62 +
        #[command(subcommand)]
63 +
        cmd: RunCmd,
64 +
    },
65 +
    /// Releases
66 +
    Release {
67 +
        #[command(subcommand)]
68 +
        cmd: ReleaseCmd,
69 +
    },
70 +
    /// Call the API directly: `oh api /repos/OWNER/NAME/issues`
71 +
    Api {
72 +
        /// A path under /api/v1
73 +
        path: String,
74 +
        /// The method
75 +
        #[arg(long, short = 'X', default_value = "GET")]
76 +
        method: String,
77 +
        /// A JSON body
78 +
        #[arg(long, short = 'd')]
79 +
        data: Option<String>,
80 +
    },
81 +
}
82 +
83 +
#[derive(Subcommand)]
84 +
enum AuthCmd {
85 +
    /// Save a token for a hub (read from stdin when not given) and make it the default
86 +
    Login {
87 +
        /// The hub, e.g. https://earth.example:7343
88 +
        #[arg(long = "with-hub", value_name = "URL")]
89 +
        url: String,
90 +
        /// An API token from the hub's Settings → Tokens
91 +
        #[arg(long)]
92 +
        token: Option<String>,
93 +
        /// The hub's CA certificate, when self-signed
94 +
        #[arg(long)]
95 +
        ca: Option<std::path::PathBuf>,
96 +
    },
97 +
    /// Who the token in use belongs to
98 +
    Status,
99 +
}
100 +
101 +
#[derive(Subcommand)]
102 +
enum RepoCmd {
103 +
    /// Name, description, visibility and default bookmark
104 +
    View,
105 +
}
106 +
107 +
#[derive(Subcommand)]
108 +
enum IssueCmd {
109 +
    /// Open issues (or --state closed|all)
110 +
    List {
111 +
        #[arg(long, default_value = "open")]
112 +
        state: String,
113 +
    },
114 +
    /// One issue with its comments
115 +
    View { number: i64 },
116 +
    /// Open an issue
117 +
    Create {
118 +
        #[arg(long, short)]
119 +
        title: String,
120 +
        #[arg(long, short)]
121 +
        body: Option<String>,
122 +
        /// Read the body from a file ("-" for stdin)
123 +
        #[arg(long, short = 'F')]
124 +
        body_file: Option<String>,
125 +
        /// A label (repeatable)
126 +
        #[arg(long, short)]
127 +
        label: Vec<String>,
128 +
    },
129 +
    /// Close an issue
130 +
    Close {
131 +
        number: i64,
132 +
        /// `completed` or `not_planned`
133 +
        #[arg(long, default_value = "completed")]
134 +
        reason: String,
135 +
    },
136 +
    /// Reopen an issue
137 +
    Reopen { number: i64 },
138 +
    /// Comment on an issue
139 +
    Comment {
140 +
        number: i64,
141 +
        #[arg(long, short)]
142 +
        body: String,
143 +
    },
144 +
}
145 +
146 +
#[derive(Subcommand)]
147 +
enum ProposalCmd {
148 +
    /// Open proposals
149 +
    List,
150 +
    /// One proposal: its changes, approvals and whether it can land
151 +
    View { number: i64 },
152 +
    /// Propose a bookmark's changes for another bookmark
153 +
    Create {
154 +
        #[arg(long, short)]
155 +
        title: String,
156 +
        #[arg(long, short)]
157 +
        body: Option<String>,
158 +
        /// The bookmark with the changes (or OWNER/NAME:BOOKMARK of a fork)
159 +
        #[arg(long)]
160 +
        from: String,
161 +
        /// The bookmark to land them on (default: the repository's default bookmark)
162 +
        #[arg(long)]
163 +
        into: Option<String>,
164 +
        #[arg(long)]
165 +
        draft: bool,
166 +
    },
167 +
    /// The checks on a proposal's projected state
168 +
    Checks { number: i64 },
169 +
}
170 +
171 +
#[derive(Subcommand)]
172 +
enum RunCmd {
173 +
    /// Recent runs, newest first
174 +
    List {
175 +
        #[arg(long, default_value_t = 20)]
176 +
        limit: i64,
177 +
    },
178 +
    /// A run and its jobs
179 +
    View { number: i64 },
180 +
    /// Run the pipeline without a push: a bookmark's current state (default: the default bookmark), or one change's
181 +
    Start {
182 +
        #[arg(long)]
183 +
        bookmark: Option<String>,
184 +
        /// A change's full hash
185 +
        #[arg(long, conflicts_with = "bookmark")]
186 +
        change: Option<String>,
187 +
        /// Wait for the run and exit 1 unless it passes
188 +
        #[arg(long)]
189 +
        watch: bool,
190 +
    },
191 +
    /// Follow a run until it finishes; exit 1 unless it passes
192 +
    Watch { number: i64 },
193 +
    /// A job's log
194 +
    Log { number: i64, job: String },
195 +
    /// Run a finished run's state again
196 +
    Rerun { number: i64 },
197 +
}
198 +
199 +
#[derive(Subcommand)]
200 +
enum ReleaseCmd {
201 +
    /// Releases, newest first
202 +
    List,
203 +
    /// Create a release on an existing tag
204 +
    Create {
205 +
        tag: String,
206 +
        #[arg(long, short)]
207 +
        title: Option<String>,
208 +
        /// Notes in Markdown (default: generated from the changes since the previous release)
209 +
        #[arg(long, short)]
210 +
        notes: Option<String>,
211 +
        #[arg(long)]
212 +
        prerelease: bool,
213 +
    },
214 +
    /// Attach files to a release
215 +
    Upload { tag: String, files: Vec<std::path::PathBuf> },
216 +
    /// The release's trust report: signatures, builds, receipts and audit
217 +
    Trust { tag: String },
218 +
}
219 +
220 +
fn main() {
221 +
    let cli = Cli::parse();
222 +
    ui::init(cli.plain);
223 +
    match run(cli) {
224 +
        Ok(code) => std::process::exit(code),
225 +
        Err(e) => {
226 +
            eprintln!("{} {e:#}", ui::bad("error:"));
227 +
            std::process::exit(2);
228 +
        }
229 +
    }
230 +
}
231 +
232 +
fn enc(s: &str) -> String {
233 +
    let mut out = String::new();
234 +
    for b in s.bytes() {
235 +
        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
236 +
            out.push(b as char);
237 +
        } else {
238 +
            out.push_str(&format!("%{b:02X}"));
239 +
        }
240 +
    }
241 +
    out
242 +
}
243 +
244 +
fn run(cli: Cli) -> Result<i32> {
245 +
    let cfg = Config::load()?;
246 +
    if let Cmd::Auth { cmd: AuthCmd::Login { url, token, ca } } = &cli.cmd {
247 +
        return login(cfg, url, token.clone(), ca.clone());
248 +
    }
249 +
    let here = std::env::current_dir()?;
250 +
    let t = target::resolve(&Choice { repo: cli.repo.as_deref(), hub: cli.hub.as_deref(), remote: cli.remote.as_deref() }, &here, &cfg)?;
251 +
    let c = Client::new(&t)?;
252 +
    let json = cli.json;
253 +
    match cli.cmd {
254 +
        Cmd::Auth { cmd: AuthCmd::Status } => {
255 +
            let me: serde_json::Value = c.get("/user")?;
256 +
            if json {
257 +
                println!("{me}");
258 +
            } else {
259 +
                println!("{} signed in to {} as {}", ui::ok("✔"), t.hub, ui::strong(me["username"].as_str().unwrap_or("?")));
260 +
            }
261 +
            Ok(0)
262 +
        }
263 +
        Cmd::Auth { .. } => unreachable!(),
264 +
        Cmd::Api { path, method, data } => {
265 +
            let path = if path.starts_with('/') { path } else { format!("/{path}") };
266 +
            let text = match data {
267 +
                Some(d) => c.send_text(
268 +
                    &method.to_uppercase(),
269 +
                    &path,
270 +
                    &serde_json::from_str::<serde_json::Value>(&d).context("--data is not JSON")?,
271 +
                )?,
272 +
                None => c.get_text(&path)?,
273 +
            };
274 +
            println!("{text}");
275 +
            Ok(0)
276 +
        }
277 +
        Cmd::Repo { cmd: RepoCmd::View } => {
278 +
            let path = t.repo_path()?;
279 +
            if json {
280 +
                println!("{}", c.get_text(&path)?);
281 +
                return Ok(0);
282 +
            }
283 +
            let r: serde_json::Value = c.get(&path)?;
284 +
            println!("{} {}", ui::strong(&format!("{}/{}", t.owner, t.name)), ui::dim(r["visibility"].as_str().unwrap_or("")));
285 +
            if let Some(d) = r["description"].as_str().filter(|d| !d.is_empty()) {
286 +
                println!("{d}");
287 +
            }
288 +
            println!("{}", ui::kv("default bookmark", r["default_bookmark"].as_str().unwrap_or("")));
289 +
            println!("{}", ui::kv("clone", &format!("oxy clone {}/{}/{}", t.hub, t.owner, t.name)));
290 +
            Ok(0)
291 +
        }
292 +
        Cmd::Issue { cmd } => issue(&c, &t, cmd, json),
293 +
        Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json),
294 +
        Cmd::Run { cmd } => flow(&c, &t, cmd, json),
295 +
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
296 +
    }
297 +
}
298 +
299 +
fn login(mut cfg: Config, url: &str, token: Option<String>, ca: Option<std::path::PathBuf>) -> Result<i32> {
300 +
    let hub = url.trim_end_matches('/').to_string();
301 +
    let token = match token {
302 +
        Some(t) => t,
303 +
        None => {
304 +
            eprintln!("Paste a token from {hub}/settings/tokens, then press Enter:");
305 +
            let mut line = String::new();
306 +
            std::io::stdin().read_line(&mut line)?;
307 +
            line.trim().to_string()
308 +
        }
309 +
    };
310 +
    if token.is_empty() {
311 +
        bail!("no token given");
312 +
    }
313 +
    let t = Target { hub: hub.clone(), token: Some(token.clone()), ca: ca.clone(), owner: String::new(), name: String::new() };
314 +
    let me: serde_json::Value = Client::new(&t)?.get("/user").context("checking the token")?;
315 +
    cfg.hosts.insert(hub.clone(), Host { token, ca });
316 +
    cfg.default_hub = Some(hub.clone());
317 +
    cfg.save()?;
318 +
    println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(me["username"].as_str().unwrap_or("?")));
319 +
    Ok(0)
320 +
}
321 +
322 +
fn body_of(body: Option<String>, file: Option<String>) -> Result<String> {
323 +
    match (body, file) {
324 +
        (Some(b), _) => Ok(b),
325 +
        (None, Some(f)) if f == "-" => {
326 +
            let mut s = String::new();
327 +
            std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
328 +
            Ok(s)
329 +
        }
330 +
        (None, Some(f)) => std::fs::read_to_string(&f).with_context(|| format!("read {f}")),
331 +
        (None, None) => Ok(String::new()),
332 +
    }
333 +
}
334 +
335 +
fn issue_line(i: &Issue) -> String {
336 +
    let glyph = if i.state == "open" { ui::ok("●") } else { ui::dim("○") };
337 +
    let labels: Vec<String> = i.labels.iter().map(|l| format!("[{}]", l.name)).collect();
338 +
    format!(
339 +
        "{glyph} {} {}{}{}",
340 +
        ui::dim(&format!("#{}", i.number)),
341 +
        i.title,
342 +
        if labels.is_empty() { String::new() } else { format!(" {}", ui::dim(&labels.join(" "))) },
343 +
        ui::dim(&format!(" · {} · {}", i.author, ui::ago(i.updated_ms)))
344 +
    )
345 +
}
346 +
347 +
fn issue(c: &Client, t: &Target, cmd: IssueCmd, json: bool) -> Result<i32> {
348 +
    let base = t.repo_path()?;
349 +
    match cmd {
350 +
        IssueCmd::List { state } => {
351 +
            let path = format!("{base}/issues?state={}", enc(&state));
352 +
            if json {
353 +
                println!("{}", c.get_text(&path)?);
354 +
                return Ok(0);
355 +
            }
356 +
            let list: Vec<Issue> = c.get(&path)?;
357 +
            if list.is_empty() {
358 +
                println!("{}", ui::dim(&format!("No {state} issues.")));
359 +
            }
360 +
            for i in &list {
361 +
                println!("{}", issue_line(i));
362 +
            }
363 +
        }
364 +
        IssueCmd::View { number } => {
365 +
            let path = format!("{base}/issues/{number}");
366 +
            if json {
367 +
                println!("{}", c.get_text(&path)?);
368 +
                return Ok(0);
369 +
            }
370 +
            let i: Issue = c.get(&path)?;
371 +
            println!("{}", issue_line(&i));
372 +
            if !i.body.trim().is_empty() {
373 +
                println!("\n{}\n", i.body.trim_end());
374 +
            }
375 +
            let comments: Vec<hub_api::Comment> = c.get(&format!("{path}/comments"))?;
376 +
            for cm in comments {
377 +
                println!("{} {}\n{}\n", ui::strong(&cm.author), ui::dim(&ui::ago(cm.created_ms)), cm.body.trim_end());
378 +
            }
379 +
            println!("{}", ui::dim(&i.url));
380 +
        }
381 +
        IssueCmd::Create { title, body, body_file, label } => {
382 +
            let new = NewIssue { title, body: body_of(body, body_file)?, labels: label, assignees: vec![], milestone: None };
383 +
            let i: Issue = c.send("POST", &format!("{base}/issues"), &new)?;
384 +
            if json {
385 +
                println!("{}", serde_json::to_string(&i)?);
386 +
            } else {
387 +
                println!("{} opened #{} {}", ui::ok("✔"), i.number, ui::dim(&i.url));
388 +
            }
389 +
        }
390 +
        IssueCmd::Close { number, reason } => {
391 +
            let i: Issue = c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "closed".into(), reason })?;
392 +
            println!("{} closed #{} {}", ui::ok("✔"), i.number, i.title);
393 +
        }
394 +
        IssueCmd::Reopen { number } => {
395 +
            let i: Issue =
396 +
                c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?;
397 +
            println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title);
398 +
        }
399 +
        IssueCmd::Comment { number, body } => {
400 +
            c.send_text("POST", &format!("{base}/issues/{number}/comments"), &NewComment { body, path: None, line: None })?;
401 +
            println!("{} commented on #{number}", ui::ok("✔"));
402 +
        }
403 +
    }
404 +
    Ok(0)
405 +
}
406 +
407 +
fn source_name(s: &hub_api::ProposalSource) -> String {
408 +
    match s {
409 +
        hub_api::ProposalSource::Bookmark { bookmark } => bookmark.clone(),
410 +
        hub_api::ProposalSource::Fork { owner, name, bookmark } => format!("{owner}/{name}:{bookmark}"),
411 +
        hub_api::ProposalSource::Changes { hashes } => format!("{} changes", hashes.len()),
412 +
    }
413 +
}
414 +
415 +
fn proposal_line(p: &Proposal) -> String {
416 +
    let state = serde_json::to_value(p.state).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default();
417 +
    let glyph = match state.as_str() {
418 +
        "open" => ui::ok("●"),
419 +
        "draft" => ui::dim("◌"),
420 +
        "landed" => ui::strong("✔"),
421 +
        _ => ui::dim("○"),
422 +
    };
423 +
    format!(
424 +
        "{glyph} {} {} {}{}",
425 +
        ui::dim(&format!("#{}", p.number)),
426 +
        p.title,
427 +
        ui::dim(&format!("{} → {}", source_name(&p.source), p.target)),
428 +
        ui::dim(&format!(" · {} · {state}", p.author))
429 +
    )
430 +
}
431 +
432 +
fn proposal(c: &Client, t: &Target, cmd: ProposalCmd, json: bool) -> Result<i32> {
433 +
    let base = t.repo_path()?;
434 +
    match cmd {
435 +
        ProposalCmd::List => {
436 +
            let path = format!("{base}/proposals");
437 +
            if json {
438 +
                println!("{}", c.get_text(&path)?);
439 +
                return Ok(0);
440 +
            }
441 +
            let list: Vec<Proposal> = c.get(&path)?;
442 +
            if list.is_empty() {
443 +
                println!("{}", ui::dim("No open proposals."));
444 +
            }
445 +
            for p in &list {
446 +
                println!("{}", proposal_line(p));
447 +
            }
448 +
        }
449 +
        ProposalCmd::View { number } => {
450 +
            let path = format!("{base}/proposals/{number}");
451 +
            if json {
452 +
                println!("{}", c.get_text(&path)?);
453 +
                return Ok(0);
454 +
            }
455 +
            let p: Proposal = c.get(&path)?;
456 +
            println!("{}", proposal_line(&p));
457 +
            if !p.body.trim().is_empty() {
458 +
                println!("\n{}\n", p.body.trim_end());
459 +
            }
460 +
            println!("{}", ui::kv("changes", &p.changes.len().to_string()));
461 +
            for ch in &p.changes {
462 +
                println!("  {} {}", ui::dim(&ch.hash[..12.min(ch.hash.len())]), ch.message.lines().next().unwrap_or(""));
463 +
            }
464 +
            println!("{}", ui::kv("approvals", &p.approvals.iter().map(|a| a.account.clone()).collect::<Vec<_>>().join(", ")));
465 +
            if p.landable {
466 +
                println!("{} can land", ui::ok("✔"));
467 +
            } else {
468 +
                for b in &p.blockers {
469 +
                    println!("{} {b}", ui::bad("✘"));
470 +
                }
471 +
            }
472 +
            if p.conflicted {
473 +
                println!("{} landing it would leave conflicts", ui::bad("!"));
474 +
            }
475 +
            println!("{}", ui::dim(&p.url));
476 +
        }
477 +
        ProposalCmd::Create { title, body, from, into, draft } => {
478 +
            let target = match into {
479 +
                Some(i) => i,
480 +
                None => c.get::<serde_json::Value>(&base)?["default_bookmark"].as_str().unwrap_or("main").to_string(),
481 +
            };
482 +
            let new = NewProposal { title, body: body.unwrap_or_default(), target, source: from, draft };
483 +
            let p: Proposal = c.send("POST", &format!("{base}/proposals"), &new)?;
484 +
            if json {
485 +
                println!("{}", serde_json::to_string(&p)?);
486 +
            } else {
487 +
                println!("{} proposed #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url));
488 +
            }
489 +
        }
490 +
        ProposalCmd::Checks { number } => {
491 +
            let path = format!("{base}/proposals/{number}/checks");
492 +
            if json {
493 +
                println!("{}", c.get_text(&path)?);
494 +
                return Ok(0);
495 +
            }
496 +
            let checks: Vec<hub_api::Check> = c.get(&path)?;
497 +
            if checks.is_empty() {
498 +
                println!("{}", ui::dim("No checks yet."));
499 +
            }
500 +
            for ch in checks {
501 +
                println!("{} {} {}", ui::status(ch.status.as_str()), ch.name, ui::dim(&ch.summary));
502 +
            }
503 +
        }
504 +
    }
505 +
    Ok(0)
506 +
}
507 +
508 +
fn run_line(r: &FlowRun) -> String {
509 +
    let what =
510 +
        if r.bookmark.is_empty() { format!("change {}", r.heads.first().map(|h| &h[..12]).unwrap_or("")) } else { r.bookmark.clone() };
511 +
    format!(
512 +
        "{} {} {} {}",
513 +
        ui::status(&r.status),
514 +
        ui::dim(&format!("#{}", r.number)),
515 +
        what,
516 +
        ui::dim(&format!("· {} by {} · {}", r.trigger, r.actor, ui::ago(r.created_ms)))
517 +
    )
518 +
}
519 +
520 +
fn print_run(r: &FlowRun) {
521 +
    println!("{}", run_line(r));
522 +
    for j in &r.jobs {
523 +
        let dur = match (j.started_ms, j.finished_ms) {
524 +
            (Some(a), Some(b)) => format!(" {:.1} s", (b - a) as f64 / 1000.0),
525 +
            _ => String::new(),
526 +
        };
527 +
        let on = if j.runner.is_empty() { String::new() } else { format!(" on {}", j.runner) };
528 +
        println!("  {} {}{}", ui::status(&j.status), j.name, ui::dim(&format!("{dur}{on}")));
529 +
    }
530 +
    if !r.error.is_empty() {
531 +
        println!("{}", ui::bad(&r.error));
532 +
    }
533 +
    println!("{}", ui::dim(&r.url));
534 +
}
535 +
536 +
fn live(status: &str) -> bool {
537 +
    matches!(status, "queued" | "running")
538 +
}
539 +
540 +
fn watch(c: &Client, base: &str, number: i64, json: bool) -> Result<i32> {
541 +
    let path = format!("{base}/flow/runs/{number}");
542 +
    let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
543 +
    loop {
544 +
        let r: FlowRun = c.get(&path)?;
545 +
        if !json {
546 +
            for j in &r.jobs {
547 +
                if seen.get(&j.name) != Some(&j.status) {
548 +
                    println!("{} {}", ui::status(&j.status), j.name);
549 +
                    seen.insert(j.name.clone(), j.status.clone());
550 +
                }
551 +
            }
552 +
        }
553 +
        if !live(&r.status) {
554 +
            if json {
555 +
                println!("{}", serde_json::to_string(&r)?);
556 +
            } else {
557 +
                println!("{} run #{} {}", ui::status(&r.status), r.number, r.status);
558 +
            }
559 +
            return Ok(if r.status == "passed" { 0 } else { 1 });
560 +
        }
561 +
        std::thread::sleep(Duration::from_secs(3));
562 +
    }
563 +
}
564 +
565 +
fn flow(c: &Client, t: &Target, cmd: RunCmd, json: bool) -> Result<i32> {
566 +
    let base = t.repo_path()?;
567 +
    match cmd {
568 +
        RunCmd::List { limit } => {
569 +
            let path = format!("{base}/flow/runs?limit={limit}");
570 +
            if json {
571 +
                println!("{}", c.get_text(&path)?);
572 +
                return Ok(0);
573 +
            }
574 +
            let runs: Vec<FlowRun> = c.get(&path)?;
575 +
            if runs.is_empty() {
576 +
                println!("{}", ui::dim("No runs yet."));
577 +
            }
578 +
            for r in &runs {
579 +
                println!("{}", run_line(r));
580 +
            }
581 +
        }
582 +
        RunCmd::View { number } => {
583 +
            let path = format!("{base}/flow/runs/{number}");
584 +
            if json {
585 +
                println!("{}", c.get_text(&path)?);
586 +
                return Ok(0);
587 +
            }
588 +
            print_run(&c.get(&path)?);
589 +
        }
590 +
        RunCmd::Start { bookmark, change, watch: follow } => {
591 +
            let body = match (bookmark, change) {
592 +
                (_, Some(ch)) => serde_json::json!({ "change": ch }),
593 +
                (Some(b), None) => serde_json::json!({ "bookmark": b }),
594 +
                (None, None) => serde_json::json!({}),
595 +
            };
596 +
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs"), &body)?;
597 +
            let n = started["number"].as_i64().context("the hub named no run")?;
598 +
            if !json || !follow {
599 +
                if json {
600 +
                    println!("{started}");
601 +
                } else {
602 +
                    println!("{} started run #{n} {}", ui::ok("✔"), ui::dim(started["url"].as_str().unwrap_or("")));
603 +
                }
604 +
            }
605 +
            if follow {
606 +
                return watch(c, &base, n, json);
607 +
            }
608 +
        }
609 +
        RunCmd::Watch { number } => return watch(c, &base, number, json),
610 +
        RunCmd::Log { number, job } => {
611 +
            let mut after = 0;
612 +
            loop {
613 +
                let path = format!("{base}/flow/runs/{number}/jobs/{}/log?after={after}&limit=5000", enc(&job));
614 +
                let lines: Vec<FlowLogLine> = c.get(&path)?;
615 +
                if lines.is_empty() {
616 +
                    break;
617 +
                }
618 +
                for l in &lines {
619 +
                    if json {
620 +
                        println!("{}", serde_json::to_string(l)?);
621 +
                    } else if l.stream == "flow" {
622 +
                        println!("{}", ui::dim(&l.text));
623 +
                    } else {
624 +
                        println!("{}", l.text);
625 +
                    }
626 +
                }
627 +
                after = lines.last().map(|l| l.seq).unwrap_or(after);
628 +
            }
629 +
        }
630 +
        RunCmd::Rerun { number } => {
631 +
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/rerun"), &serde_json::json!({}))?;
632 +
            if json {
633 +
                println!("{started}");
634 +
            } else {
635 +
                println!("{} started run #{} {}", ui::ok("✔"), started["number"], ui::dim(started["url"].as_str().unwrap_or("")));
636 +
            }
637 +
        }
638 +
    }
639 +
    Ok(0)
640 +
}
641 +
642 +
fn release(c: &Client, t: &Target, cmd: ReleaseCmd, json: bool) -> Result<i32> {
643 +
    let base = t.repo_path()?;
644 +
    match cmd {
645 +
        ReleaseCmd::List => {
646 +
            let path = format!("{base}/releases");
647 +
            if json {
648 +
                println!("{}", c.get_text(&path)?);
649 +
                return Ok(0);
650 +
            }
651 +
            let list: Vec<Release> = c.get(&path)?;
652 +
            if list.is_empty() {
653 +
                println!("{}", ui::dim("No releases."));
654 +
            }
655 +
            for r in &list {
656 +
                println!(
657 +
                    "{} {} {}{}",
658 +
                    if r.latest { ui::ok("●") } else { ui::dim("○") },
659 +
                    ui::strong(&r.tag),
660 +
                    r.title,
661 +
                    ui::dim(&format!(
662 +
                        " · {} asset{} · {}",
663 +
                        r.assets.len(),
664 +
                        if r.assets.len() == 1 { "" } else { "s" },
665 +
                        ui::ago(r.created_ms)
666 +
                    ))
667 +
                );
668 +
            }
669 +
        }
670 +
        ReleaseCmd::Create { tag, title, notes, prerelease } => {
671 +
            let new = NewRelease { title: title.unwrap_or_else(|| tag.clone()), tag, notes, prerelease };
672 +
            let r: Release = c.send("POST", &format!("{base}/releases"), &new)?;
673 +
            if json {
674 +
                println!("{}", serde_json::to_string(&r)?);
675 +
            } else {
676 +
                println!("{} released {} {}", ui::ok("✔"), r.tag, ui::dim(&r.url));
677 +
            }
678 +
        }
679 +
        ReleaseCmd::Upload { tag, files } => {
680 +
            if files.is_empty() {
681 +
                bail!("name the files to attach");
682 +
            }
683 +
            for f in files {
684 +
                let name = f.file_name().and_then(|n| n.to_str()).context("a file name")?.to_string();
685 +
                let bytes = std::fs::read(&f).with_context(|| format!("read {}", f.display()))?;
686 +
                c.upload(&format!("{base}/releases/{}/assets?name={}", enc(&tag), enc(&name)), &bytes)?;
687 +
                println!("{} attached {name} to {tag}", ui::ok("✔"));
688 +
            }
689 +
        }
690 +
        ReleaseCmd::Trust { tag } => {
691 +
            let path = format!("{base}/releases/trust/{}", enc(&tag));
692 +
            if json {
693 +
                println!("{}", c.get_text(&path)?);
694 +
                return Ok(0);
695 +
            }
696 +
            let r: serde_json::Value = c.get(&path)?;
697 +
            println!("{} {}", ui::strong(&format!("Trust report for {tag}")), ui::dim(&format!("{} changes", r["changes"])));
698 +
            let mut worst = 0;
699 +
            for ch in r["audit"].as_array().into_iter().flatten() {
700 +
                let v = ch["verdict"].as_str().unwrap_or("");
701 +
                worst = worst.max(match v {
702 +
                    "fail" => 2,
703 +
                    "warn" => 1,
704 +
                    _ => 0,
705 +
                });
706 +
                let g = match v {
707 +
                    "pass" => ui::ok("✔"),
708 +
                    "warn" => ui::warn("!"),
709 +
                    _ => ui::bad("✘"),
710 +
                };
711 +
                println!("{g} {} {}", ch["title"].as_str().unwrap_or(""), ui::dim(ch["detail"].as_str().unwrap_or("")));
712 +
            }
713 +
            return Ok(if worst == 2 { 1 } else { 0 });
714 +
        }
715 +
    }
716 +
    Ok(0)
717 +
}
added crates/oh/src/target.rs +187 −0
1 +
//! Which hub and repository a command talks to, and with which token.
2 +
//!
3 +
//! Inside an Oxygen checkout, `oh` uses the checkout's remote (`origin`, or
4 +
//! the only one, or `--remote NAME`): its URL names the hub and the
5 +
//! repository, and its token and CA are the ones `oxy` pushes with.
6 +
//! Elsewhere, `--repo OWNER/NAME` with the hub from `--hub`, `OH_HUB` or
7 +
//! the one `oh auth login` saved.
8 +
9 +
use anyhow::{anyhow, bail, Context, Result};
10 +
use serde::{Deserialize, Serialize};
11 +
use std::collections::BTreeMap;
12 +
use std::path::{Path, PathBuf};
13 +
14 +
/// `~/.config/oh/config.toml`: tokens and CAs by hub, and the default hub.
15 +
#[derive(Debug, Default, Serialize, Deserialize)]
16 +
pub struct Config {
17 +
    #[serde(default)]
18 +
    pub default_hub: Option<String>,
19 +
    #[serde(default)]
20 +
    pub hosts: BTreeMap<String, Host>,
21 +
}
22 +
23 +
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
24 +
pub struct Host {
25 +
    pub token: String,
26 +
    #[serde(default)]
27 +
    pub ca: Option<PathBuf>,
28 +
}
29 +
30 +
pub fn config_path() -> Result<PathBuf> {
31 +
    if let Some(p) = std::env::var_os("OH_CONFIG") {
32 +
        return Ok(PathBuf::from(p));
33 +
    }
34 +
    let base = match std::env::var_os("XDG_CONFIG_HOME") {
35 +
        Some(d) => PathBuf::from(d),
36 +
        None => PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?).join(".config"),
37 +
    };
38 +
    Ok(base.join("oh").join("config.toml"))
39 +
}
40 +
41 +
impl Config {
42 +
    pub fn load() -> Result<Config> {
43 +
        let p = config_path()?;
44 +
        match std::fs::read_to_string(&p) {
45 +
            Ok(t) => toml::from_str(&t).with_context(|| format!("parse {}", p.display())),
46 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
47 +
            Err(e) => Err(e).with_context(|| format!("read {}", p.display())),
48 +
        }
49 +
    }
50 +
51 +
    /// Written owner-only: it holds tokens.
52 +
    pub fn save(&self) -> Result<()> {
53 +
        use std::io::Write;
54 +
        use std::os::unix::fs::OpenOptionsExt;
55 +
        let p = config_path()?;
56 +
        std::fs::create_dir_all(p.parent().unwrap())?;
57 +
        let tmp = p.with_extension("toml.tmp");
58 +
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
59 +
        f.write_all(toml::to_string_pretty(self)?.as_bytes())?;
60 +
        f.sync_all()?;
61 +
        std::fs::rename(&tmp, &p)?;
62 +
        Ok(())
63 +
    }
64 +
}
65 +
66 +
/// A hub, and a repository on it when the command needs one.
67 +
#[derive(Clone, Debug)]
68 +
pub struct Target {
69 +
    /// `https://host:port`, no trailing slash.
70 +
    pub hub: String,
71 +
    pub token: Option<String>,
72 +
    pub ca: Option<PathBuf>,
73 +
    pub owner: String,
74 +
    pub name: String,
75 +
}
76 +
77 +
impl Target {
78 +
    pub fn repo(&self) -> Result<(&str, &str)> {
79 +
        if self.owner.is_empty() {
80 +
            bail!("no repository: run inside an Oxygen checkout, or give --repo OWNER/NAME");
81 +
        }
82 +
        Ok((&self.owner, &self.name))
83 +
    }
84 +
85 +
    /// `/repos/{owner}/{name}`.
86 +
    pub fn repo_path(&self) -> Result<String> {
87 +
        let (o, n) = self.repo()?;
88 +
        Ok(format!("/repos/{o}/{n}"))
89 +
    }
90 +
}
91 +
92 +
/// Split `https://host:port/owner/name` into the hub and the repository.
93 +
pub fn split_url(url: &str) -> Result<(String, String, String)> {
94 +
    let (scheme, rest) = url.split_once("://").ok_or_else(|| anyhow!("{url} is not a hub URL"))?;
95 +
    let mut parts = rest.trim_end_matches('/').splitn(2, '/');
96 +
    let host = parts.next().unwrap_or("");
97 +
    let path = parts.next().unwrap_or("");
98 +
    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
99 +
    match segs.as_slice() {
100 +
        [o, n] => Ok((format!("{scheme}://{host}"), o.to_string(), n.trim_end_matches(".oxy").to_string())),
101 +
        _ => bail!("{url} does not name a repository as https://hub/OWNER/NAME"),
102 +
    }
103 +
}
104 +
105 +
pub struct Choice<'a> {
106 +
    pub repo: Option<&'a str>,
107 +
    pub hub: Option<&'a str>,
108 +
    pub remote: Option<&'a str>,
109 +
}
110 +
111 +
/// Resolve what to talk to from the flags, the checkout at `dir`, the
112 +
/// environment and the saved configuration.
113 +
pub fn resolve(choice: &Choice, dir: &Path, cfg: &Config) -> Result<Target> {
114 +
    let env_token = std::env::var("OH_TOKEN").ok().or_else(|| std::env::var("OXY_TOKEN").ok()).filter(|t| !t.is_empty());
115 +
    let env_ca = std::env::var_os("OXY_CA").map(PathBuf::from);
116 +
    let saved = |hub: &str| cfg.hosts.get(hub).cloned();
117 +
    if let Some(r) = choice.repo {
118 +
        let (owner, name) = r.split_once('/').ok_or_else(|| anyhow!("--repo takes OWNER/NAME"))?;
119 +
        let hub = choice
120 +
            .hub
121 +
            .map(String::from)
122 +
            .or_else(|| std::env::var("OH_HUB").ok())
123 +
            .or_else(|| cfg.default_hub.clone())
124 +
            .ok_or_else(|| anyhow!("which hub? give --hub URL, set OH_HUB, or run `oh auth login`"))?;
125 +
        let hub = hub.trim_end_matches('/').to_string();
126 +
        let host = saved(&hub).unwrap_or_default();
127 +
        return Ok(Target {
128 +
            token: env_token.or(Some(host.token).filter(|t| !t.is_empty())),
129 +
            ca: env_ca.or(host.ca),
130 +
            hub,
131 +
            owner: owner.into(),
132 +
            name: name.into(),
133 +
        });
134 +
    }
135 +
    // The checkout's remote.
136 +
    if let Ok(repo) = oxy_core::repo::Repo::open(dir) {
137 +
        let remotes = &repo.config.remotes;
138 +
        let name = match choice.remote {
139 +
            Some(n) => n.to_string(),
140 +
            None if remotes.contains_key("origin") => "origin".into(),
141 +
            None if remotes.len() == 1 => remotes.keys().next().unwrap().clone(),
142 +
            None if remotes.is_empty() => bail!("this checkout has no remote; give --repo OWNER/NAME"),
143 +
            None => bail!(
144 +
                "this checkout has several remotes ({}); choose one with --remote",
145 +
                remotes.keys().cloned().collect::<Vec<_>>().join(", ")
146 +
            ),
147 +
        };
148 +
        let url = remotes.get(&name).ok_or_else(|| anyhow!("no remote named {name}"))?;
149 +
        let (hub, owner, repo_name) = split_url(url)?;
150 +
        let host = saved(&hub).unwrap_or_default();
151 +
        let token = env_token.or_else(|| repo.config.remote_tokens.get(&name).cloned()).or(Some(host.token).filter(|t| !t.is_empty()));
152 +
        let ca = env_ca.or_else(|| repo.config.remote_cas.get(&name).map(PathBuf::from)).or(host.ca);
153 +
        return Ok(Target { hub, token, ca, owner, name: repo_name });
154 +
    }
155 +
    // No repository: commands that need only a hub.
156 +
    let hub = choice
157 +
        .hub
158 +
        .map(String::from)
159 +
        .or_else(|| std::env::var("OH_HUB").ok())
160 +
        .or_else(|| cfg.default_hub.clone())
161 +
        .ok_or_else(|| anyhow!("not in an Oxygen checkout: give --repo OWNER/NAME and --hub URL, or run `oh auth login`"))?;
162 +
    let hub = hub.trim_end_matches('/').to_string();
163 +
    let host = saved(&hub).unwrap_or_default();
164 +
    Ok(Target {
165 +
        token: env_token.or(Some(host.token).filter(|t| !t.is_empty())),
166 +
        ca: env_ca.or(host.ca),
167 +
        hub,
168 +
        owner: String::new(),
169 +
        name: String::new(),
170 +
    })
171 +
}
172 +
173 +
#[cfg(test)]
174 +
mod tests {
175 +
    use super::*;
176 +
177 +
    #[test]
178 +
    fn remote_urls_name_the_hub_and_the_repository() {
179 +
        assert_eq!(
180 +
            split_url("https://earth.example:7343/andy/oxygen").unwrap(),
181 +
            ("https://earth.example:7343".into(), "andy".into(), "oxygen".into())
182 +
        );
183 +
        assert_eq!(split_url("http://127.0.0.1:9/a/b/").unwrap().2, "b");
184 +
        assert!(split_url("https://hub/only-owner").is_err());
185 +
        assert!(split_url("not a url").is_err());
186 +
    }
187 +
}
added crates/oh/src/ui.rs +72 −0
1 +
//! Output styling in the manner of `oxy`: colour and glyphs on a terminal,
2 +
//! plain text with `--plain`, `NO_COLOR` or when piped.
3 +
4 +
use std::io::IsTerminal;
5 +
use std::sync::OnceLock;
6 +
7 +
static PLAIN: OnceLock<bool> = OnceLock::new();
8 +
9 +
pub fn init(plain_flag: bool) {
10 +
    let plain = plain_flag || std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) || !std::io::stdout().is_terminal();
11 +
    let _ = PLAIN.set(plain);
12 +
}
13 +
14 +
fn plain() -> bool {
15 +
    *PLAIN.get().unwrap_or(&true)
16 +
}
17 +
18 +
fn paint(code: &str, s: &str) -> String {
19 +
    if plain() {
20 +
        s.to_string()
21 +
    } else {
22 +
        format!("\x1b[{code}m{s}\x1b[0m")
23 +
    }
24 +
}
25 +
26 +
pub fn ok(s: &str) -> String {
27 +
    paint("32", s)
28 +
}
29 +
pub fn bad(s: &str) -> String {
30 +
    paint("31", s)
31 +
}
32 +
pub fn warn(s: &str) -> String {
33 +
    paint("35", s)
34 +
}
35 +
pub fn dim(s: &str) -> String {
36 +
    paint("2", s)
37 +
}
38 +
pub fn strong(s: &str) -> String {
39 +
    paint("1", s)
40 +
}
41 +
42 +
pub fn kv(key: &str, value: &str) -> String {
43 +
    format!("{} {value}", dim(&format!("{key:>16}")))
44 +
}
45 +
46 +
/// A run's or job's or check's status, as a glyph.
47 +
pub fn status(s: &str) -> String {
48 +
    match s {
49 +
        "passed" | "success" => ok("✔"),
50 +
        "failed" | "failure" | "error" => bad("✘"),
51 +
        "running" | "pending" => warn("●"),
52 +
        "queued" => dim("○"),
53 +
        "skipped" | "neutral" => dim("–"),
54 +
        _ => dim("?"),
55 +
    }
56 +
}
57 +
58 +
/// "3 minutes ago", as oxy says it.
59 +
pub fn ago(ms: i64) -> String {
60 +
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
61 +
    let secs = (now - ms).max(0) / 1000;
62 +
    let unit = |n: i64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
63 +
    match secs {
64 +
        0..=44 => "just now".into(),
65 +
        45..=89 => "a minute ago".into(),
66 +
        90..=2_699 => unit((secs + 59) / 60, "minute"),
67 +
        2_700..=5_399 => "an hour ago".into(),
68 +
        5_400..=79_199 => unit((secs + 3_599) / 3_600, "hour"),
69 +
        79_200..=129_599 => "yesterday".into(),
70 +
        _ => unit((secs + 86_399) / 86_400, "day"),
71 +
    }
72 +
}
added crates/oh/tests/cli.rs +178 −0
1 +
//! `oh` against a real hub: signing in, finding the repository from a
2 +
//! checkout's remote, issues, Flow runs, releases and their trust report,
3 +
//! and the raw API.
4 +
5 +
use oxy_core::repo::{IdentityScope, Repo};
6 +
use oxygenhub::config::Config;
7 +
use oxygenhub::repos::RepoName;
8 +
use oxygenhub::{db, Shared};
9 +
use std::path::{Path, PathBuf};
10 +
11 +
const ROOTFS: &str = "36fc4e3238044e424219a2b9002bf61bbe1805fec14d52cf37e17fae77c9ff16";
12 +
13 +
fn hermetic() -> PathBuf {
14 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
15 +
    let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
16 +
    std::env::set_var("OXY_HOME", d.path());
17 +
    d.path().to_path_buf()
18 +
}
19 +
20 +
struct Hub {
21 +
    app: Shared,
22 +
    base: String,
23 +
    _dir: tempfile::TempDir,
24 +
}
25 +
26 +
async fn hub() -> Hub {
27 +
    hermetic();
28 +
    let dir = tempfile::tempdir().unwrap();
29 +
    let mut config = Config::load(dir.path()).unwrap();
30 +
    config.auth.push_tokens = vec!["site".into()];
31 +
    config.flow.repos = vec!["andy/*".into()];
32 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
33 +
    let port = listener.local_addr().unwrap().port();
34 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
35 +
    let app = oxygenhub::build(config).await.unwrap();
36 +
    let router = oxygenhub::router(app.clone());
37 +
    tokio::spawn(async move { axum_serve(listener, router).await });
38 +
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
39 +
}
40 +
41 +
async fn axum_serve(listener: tokio::net::TcpListener, router: axum::Router) {
42 +
    axum::serve(listener, router).await.unwrap();
43 +
}
44 +
45 +
/// Run `oh` in `dir` with `env`; (exit code, stdout, stderr).
46 +
async fn oh(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> (i32, String, String) {
47 +
    let (dir, env, args): (PathBuf, Vec<(String, String)>, Vec<String>) = (
48 +
        dir.to_path_buf(),
49 +
        env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
50 +
        args.iter().map(|a| a.to_string()).collect(),
51 +
    );
52 +
    tokio::task::spawn_blocking(move || {
53 +
        let out = std::process::Command::new(env!("CARGO_BIN_EXE_oh"))
54 +
            .args(&args)
55 +
            .current_dir(&dir)
56 +
            .env_remove("OH_TOKEN")
57 +
            .env_remove("OXY_TOKEN")
58 +
            .env_remove("OH_HUB")
59 +
            .envs(env)
60 +
            .output()
61 +
            .unwrap();
62 +
        (
63 +
            out.status.code().unwrap_or(-1),
64 +
            String::from_utf8_lossy(&out.stdout).into_owned(),
65 +
            String::from_utf8_lossy(&out.stderr).into_owned(),
66 +
        )
67 +
    })
68 +
    .await
69 +
    .unwrap()
70 +
}
71 +
72 +
fn write(root: &Path, rel: &str, content: &str) {
73 +
    let p = root.join(rel);
74 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
75 +
    std::fs::write(p, content).unwrap();
76 +
}
77 +
78 +
#[tokio::test(flavor = "multi_thread")]
79 +
async fn oh_drives_issues_runs_and_releases() {
80 +
    let h = hub().await;
81 +
    // The site token acts as the first site administrator.
82 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
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();
85 +
86 +
    // A checkout whose origin is the hub: `oh` needs nothing else.
87 +
    let work = tempfile::tempdir().unwrap();
88 +
    let mut c = Repo::init(work.path()).unwrap();
89 +
    c.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
90 +
    c.config.set("remote.origin.token", "site").unwrap();
91 +
    c.save_config().unwrap();
92 +
    c.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
93 +
    write(
94 +
        work.path(),
95 +
        ".flow/pipeline.kdl",
96 +
        &format!("schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n    sandbox \"alpine\"\n    exec \"true\"\n}}\n"),
97 +
    );
98 +
    c.snapshot("test").unwrap();
99 +
    c.commit("Add a pipeline", &[]).unwrap();
100 +
    let heads = c.bookmark_heads().unwrap();
101 +
    c.tag_create("v1", &heads, "first").unwrap();
102 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
103 +
    let w = work.path();
104 +
105 +
    let (code, out, err) = oh(w, &[], &["repo", "view", "--plain"]).await;
106 +
    assert_eq!(code, 0, "{err}");
107 +
    assert!(out.contains("andy/demo") && out.contains("A demo") && out.contains("oxy clone"), "{out}");
108 +
109 +
    // Issues.
110 +
    let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Broken thing", "--body", "It breaks."]).await;
111 +
    assert_eq!(code, 0, "{err}");
112 +
    assert!(out.contains("opened #1"), "{out}");
113 +
    let (_, out, _) = oh(w, &[], &["issue", "list", "--json"]).await;
114 +
    let list: serde_json::Value = serde_json::from_str(&out).unwrap();
115 +
    assert_eq!((list[0]["number"].as_i64(), list[0]["title"].as_str()), (Some(1), Some("Broken thing")));
116 +
    let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "--body", "Seen it too."]).await;
117 +
    assert!(out.contains("commented on #1"));
118 +
    let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--plain"]).await;
119 +
    assert!(out.contains("It breaks.") && out.contains("Seen it too."), "{out}");
120 +
    let (_, out, _) = oh(w, &[], &["issue", "close", "1"]).await;
121 +
    assert!(out.contains("closed #1"));
122 +
    let (_, out, _) = oh(w, &[], &["issue", "list", "--plain"]).await;
123 +
    assert!(out.contains("No open issues."), "{out}");
124 +
125 +
    // Flow: the push started run 1; start another without a push.
126 +
    let (code, out, err) = oh(w, &[], &["run", "start"]).await;
127 +
    assert_eq!(code, 0, "{err}");
128 +
    assert!(out.contains("started run #2"), "{out}");
129 +
    let (_, out, _) = oh(w, &[], &["run", "list", "--json"]).await;
130 +
    let runs: serde_json::Value = serde_json::from_str(&out).unwrap();
131 +
    assert_eq!(runs.as_array().unwrap().len(), 2);
132 +
    let (_, out, _) = oh(w, &[], &["run", "view", "2", "--plain"]).await;
133 +
    assert!(out.contains("test") && out.contains("#2"), "{out}");
134 +
135 +
    // Releases, an asset, and the trust report.
136 +
    let (code, out, err) = oh(w, &[], &["release", "create", "v1", "--title", "One", "--notes", "First."]).await;
137 +
    assert_eq!(code, 0, "{err}");
138 +
    assert!(out.contains("released v1"), "{out}");
139 +
    let asset = w.join("tool.bin");
140 +
    std::fs::write(&asset, b"bytes").unwrap();
141 +
    let (code, out, err) = oh(w, &[], &["release", "upload", "v1", asset.to_str().unwrap()]).await;
142 +
    assert_eq!(code, 0, "{err}");
143 +
    assert!(out.contains("attached tool.bin to v1"));
144 +
    let (_, out, _) = oh(w, &[], &["release", "list", "--json"]).await;
145 +
    let rel: serde_json::Value = serde_json::from_str(&out).unwrap();
146 +
    assert_eq!(rel[0]["assets"][0]["name"], "tool.bin");
147 +
    let (code, out, _) = oh(w, &[], &["release", "trust", "v1", "--plain"]).await;
148 +
    assert_eq!(code, 0, "warnings only: {out}");
149 +
    assert!(out.contains("Tag v1 is signed") && out.contains("tool.bin: no build found"), "{out}");
150 +
151 +
    // Outside a checkout: --repo and a saved login.
152 +
    let elsewhere = tempfile::tempdir().unwrap();
153 +
    let cfg = elsewhere.path().join("oh.toml");
154 +
    let cfg_s = cfg.to_str().unwrap();
155 +
    let (code, _, err) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list"]).await;
156 +
    assert_ne!(code, 0);
157 +
    assert!(err.contains("--repo"), "{err}");
158 +
    let (code, out, err) =
159 +
        oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["auth", "login", "--with-hub", &h.base, "--token", "site"]).await;
160 +
    assert_eq!(code, 0, "{err}");
161 +
    assert!(out.contains("signed in"), "{out}");
162 +
    #[cfg(unix)]
163 +
    {
164 +
        use std::os::unix::fs::PermissionsExt;
165 +
        assert_eq!(std::fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, 0o600, "tokens are owner-only");
166 +
    }
167 +
    let (code, out, _) =
168 +
        oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list", "--state", "all", "--repo", "andy/demo", "--plain"]).await;
169 +
    assert_eq!(code, 0);
170 +
    assert!(out.contains("Broken thing"), "{out}");
171 +
    let (code, out, _) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["api", "/repos/andy/demo/labels"]).await;
172 +
    assert_eq!(code, 0);
173 +
    assert!(out.trim_start().starts_with('['), "{out}");
174 +
    // A bad token says what to do.
175 +
    let (code, _, err) = oh(w, &[("OXY_TOKEN", "nope")], &["issue", "create", "--title", "x"]).await;
176 +
    assert_ne!(code, 0);
177 +
    assert!(err.contains("HTTP 401") && err.contains("oh auth login"), "{err}");
178 +
}
added docs/oh.md +59 −0
1 +
# oh: OxygenHub from the command line
2 +
3 +
`oh` is to OxygenHub what `gh` is to GitHub. It talks to the hub's JSON API
4 +
(hub spec §12) and prints what it gets back: in colour on a terminal, as
5 +
plain text with `--plain` or `NO_COLOR`, and as the API's own JSON with
6 +
`--json`.
7 +
8 +
## Which hub, which repository, which token
9 +
10 +
Inside an Oxygen checkout, `oh` uses the checkout's remote:
11 +
- **Remote:** `origin`, or the only remote, or `--remote NAME`.
12 +
- **Hub and repository:** from the remote's URL (`https://hub/OWNER/NAME`).
13 +
- **Token and CA:** the ones `oxy` pushes with (`remote.NAME.token`, `remote.NAME.ca`).
14 +
15 +
So in a checkout that pushes to the hub, `oh` needs no setup.
16 +
17 +
Elsewhere, sign in once and name the repository:
18 +
19 +
```
20 +
oh auth login --with-hub https://earth.andymccall.xyz:7343 --ca ~/.config/oxy/earth-ca.pem
21 +
oh issue list --repo andymccall/oxygen
22 +
```
23 +
24 +
`oh auth login` checks the token, saves it with the CA in
25 +
`~/.config/oh/config.toml` (owner-only), and makes that hub the default. It
26 +
reads the token from stdin unless `--token` is given. `OH_TOKEN` (or
27 +
`OXY_TOKEN`), `OH_HUB` and `OXY_CA` override the saved values.
28 +
29 +
## Commands
30 +
31 +
| Command | What it does |
32 +
|---|---|
33 +
| `oh auth login`, `oh auth status` | Save a token; show who you are |
34 +
| `oh repo view` | Name, description, visibility, default bookmark, clone command |
35 +
| `oh issue list [--state open\|closed\|all]` | Issues |
36 +
| `oh issue view N` | An issue with its comments |
37 +
| `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue |
38 +
| `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen |
39 +
| `oh issue comment N -b BODY` | Comment |
40 +
| `oh proposal list`, `oh proposal view N` | Proposals, with changes, approvals and blockers |
41 +
| `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes |
42 +
| `oh proposal checks N` | Checks on the projected state |
43 +
| `oh run list`, `oh run view N` | OxygenFlow runs and their jobs |
44 +
| `oh run start [--bookmark B \| --change HASH] [--watch]` | Run the pipeline without a push |
45 +
| `oh run watch N` | Follow a run; exit 0 if it passes, 1 if not |
46 +
| `oh run log N JOB` | A job's log |
47 +
| `oh run rerun N` | Run a finished run's state again |
48 +
| `oh release list`, `oh release create TAG [-t TITLE] [-n NOTES]` | Releases |
49 +
| `oh release upload TAG FILE…` | Attach assets |
50 +
| `oh release trust TAG` | The release's trust audit; exit 1 on a failure |
51 +
| `oh api PATH [-X METHOD] [-d JSON]` | Any API call, JSON out |
52 +
53 +
Exit codes: 0 on success, 1 when a watched run or an audit fails, 2 on an
54 +
error.
55 +
56 +
## Not yet
57 +
58 +
- Landing a proposal needs an API route, which comes next.
59 +
- Also still to come: reviews, labels and milestones as commands, runner management (oxygenhub#41), and shell completions.
modified .flow/pipeline.kdl +1 −1
…
27 unchanged lines
28 28
    sandbox "rust-small"
29 29
    // Only this workspace's packages: --all would also check the path
30 30
    // dependencies in deps/, which follow their own formatting.
31 -
    exec "cargo" "fmt" "--check" "-p" "oxygenhub" "-p" "hub-api"
31 +
    exec "cargo" "fmt" "--check" "-p" "oxygenhub" "-p" "hub-api" "-p" "oh"
32 32
}
33 33
34 34
job "clippy" {
…
40 unchanged lines
modified Cargo.lock +18 −0
…
2055 unchanged lines
2056 2056
]
2057 2057
2058 2058
[[package]]
2059 +
name = "oh"
2060 +
version = "0.1.0"
2061 +
dependencies = [
2062 +
 "anyhow",
2063 +
 "axum",
2064 +
 "clap",
2065 +
 "hub-api",
2066 +
 "oxy-core",
2067 +
 "oxygenhub",
2068 +
 "serde",
2069 +
 "serde_json",
2070 +
 "tempfile",
2071 +
 "tokio",
2072 +
 "toml",
2073 +
 "ureq",
2074 +
]
2075 +
2076 +
[[package]]
2059 2077
name = "oid-registry"
2060 2078
version = "0.8.1"
2061 2079
source = "registry+https://github.com/rust-lang/crates.io-index"
…
2373 unchanged lines
modified Cargo.toml +1 −1
1 1
[workspace]
2 -
members = ["crates/hub", "crates/hub-api"]
2 +
members = ["crates/hub", "crates/hub-api", "crates/oh"]
3 3
# oxygenflow as a pinned subrepo (oxy submodule), and oxygen as oxygenflow's
4 4
# own pinned subrepo inside it: one oxygen, the one oxygenflow was tested
5 5
# with. They are workspaces of their own.
…
60 unchanged lines