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 |