The g1t command line does a forge's daily work now, for people at a terminal and for agents in a shell, not only g1t push. g1t auth login signs in by approving a code in the browser (the API's device sign-in), or takes a token on stdin with --with-token; the token goes in the system keychain (macOS Keychain, Windows Credential Manager, the Secret Service), or in config.json readable by its owner only when there is none, and G1T_TOKEN overrides both. auth status, logout, token and switch handle several accounts on several hosts, g1t.sh and self-hosted ones (--hostname, G1T_HOST; a new host is asked where its API is through /.well-known/g1t.json). auth setup-git makes g1t git's credential helper for the host, and g1t push takes the same signed-in token before docker's.
Commands: repo view, clone, create and list; issue list, view, create, comment, close, reopen and edit; pr list, view, create, checkout, diff, checks, review, merge, ready, comment, close and reopen; run list, view, watch, logs, rerun and cancel; secret list, set and delete for a repository or a workspace, per environment, brokered or not; deploy list and view; logs, a project's log lines or its errors grouped, with --follow; status, which gathers your open pull requests, reviews asked of you, what is assigned to you and failing checks; browse; alias; completion for bash, zsh, fish, PowerShell and elvish; and api, which calls any operation by its name (g1t api list_issues -f state=closed) or any path, with --paginate and --slurp. The repository comes from --repo, G1T_REPO or the git remote, including a pull request's own working copy. Every command takes --json, every field or a comma-separated few, and --jq with a jq expression built in (including @csv and @tsv). JSON is compact with its keys in a stable order when piped; tables are aligned on a terminal and tab-separated without a header when piped; lists default to 20 entries. Nothing is asked unless stdin and stderr are a terminal. Errors are one line on stderr, and exit codes are stable: 1 failed, 2 used wrongly, 4 sign-in needed, 8 checks still pending. For agents, pr checks --watch --fail-fast stops at the first failing check, and run logs --failed prints only the last lines of the steps that failed. Commands never spell out a path. ops_table.rs is generated from the API's OpenAPI document, and a test fails when it falls behind (G1T_WRITE_OPS=1 cargo test -p g1t ops_table regenerates it); mapping.rs lists the operations each command calls, and its tests fail when a command calls an operation the API no longer has, when a command is missing from the list, or when the list and the code disagree. Every operation is reachable through g1t api. Integration tests run the binary against a mock API.
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t" | |
| 3 | − | version = "0.1.0" | |
| 3 | + | version = "0.2.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | 6 | repository.workspace = true | |
| 7 | − | description = "The g1t command line: g1t push sends a local image to g1t.sh in chunks, so a layer can be any size." | |
| 7 | + | description = "The g1t command line: repositories, issues, pull requests, workflow runs, deployments, secrets and logs on g1t, from a terminal or an agent's shell." | |
| 8 | + | ||
| 9 | + | [[bin]] | |
| 10 | + | name = "g1t" | |
| 11 | + | path = "src/main.rs" | |
| 8 | 12 | ||
| 9 | 13 | [dependencies] | |
| 14 | + | anstyle-query = "1.1" | |
| 10 | 15 | base64 = "0.22" | |
| 16 | + | clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } | |
| 17 | + | clap_complete = "4.6" | |
| 11 | 18 | flate2 = "1" | |
| 12 | 19 | hex = "0.4" | |
| 20 | + | jaq-core = "3.1" | |
| 21 | + | jaq-json = "2.0" | |
| 22 | + | jaq-std = "3.0" | |
| 23 | + | keyring = "4.2" | |
| 24 | + | rpassword = "7" | |
| 25 | + | serde = { workspace = true } | |
| 13 | 26 | serde_json = { workspace = true } | |
| 14 | 27 | sha2 = "0.10" | |
| 15 | 28 | tar = { version = "0.4", default-features = false } | |
| 29 | + | terminal_size = "0.4" | |
| 16 | 30 | ureq = { version = "2", default-features = false, features = ["tls"] } | |
| 31 | + |
| 1 | + | //! Hosts and their tokens. | |
| 2 | + | //! | |
| 3 | + | //! A host is a g1t: g1t.sh, or one run by its owner. Each has a site (git | |
| 4 | + | //! remotes, pages) and an API; g1t.sh's are built in, and any other is found | |
| 5 | + | //! from its `/.well-known/g1t.json` when signing in. | |
| 6 | + | //! | |
| 7 | + | //! A token is the first of: | |
| 8 | + | //! | |
| 9 | + | //! 1. `G1T_TOKEN`, for whatever host the command talks to; | |
| 10 | + | //! 2. the system keychain (macOS Keychain, Windows Credential Manager, the | |
| 11 | + | //! Secret Service on Linux), under the service `g1t:<host>`; | |
| 12 | + | //! 3. `config.json`, where a token goes only when there is no keychain | |
| 13 | + | //! (a server, a container) or `--insecure-storage` asked for it. | |
| 14 | + | //! | |
| 15 | + | //! `G1T_KEYRING=off` keeps the keychain out of it entirely. | |
| 16 | + | ||
| 17 | + | use serde_json::Value; | |
| 18 | + | ||
| 19 | + | use crate::config::{Config, DEFAULT_HOST, HostConfig, UserConfig}; | |
| 20 | + | use crate::error::{Error, Result}; | |
| 21 | + | ||
| 22 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 23 | + | pub struct Host { | |
| 24 | + | /// How it is named: `g1t.sh`, `git.example.com`, `localhost:8787`. | |
| 25 | + | pub name: String, | |
| 26 | + | pub site: String, | |
| 27 | + | pub api: String, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl Host { | |
| 31 | + | pub fn hosted() -> Host { | |
| 32 | + | Host { | |
| 33 | + | name: DEFAULT_HOST.to_owned(), | |
| 34 | + | site: "https://g1t.sh".to_owned(), | |
| 35 | + | api: "https://api.g1t.sh".to_owned(), | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// `git.example.com`, `https://git.example.com/` or `http://localhost:8787`: | |
| 41 | + | /// the host's name and its site's address. | |
| 42 | + | pub fn parse_hostname(input: &str) -> Result<(String, String)> { | |
| 43 | + | let input = input.trim().trim_end_matches('/'); | |
| 44 | + | let (scheme, rest) = match input.split_once("://") { | |
| 45 | + | Some((scheme @ ("http" | "https"), rest)) => (scheme, rest), | |
| 46 | + | Some((scheme, _)) => { | |
| 47 | + | return Err(Error::usage(format!( | |
| 48 | + | "`{scheme}://` is not a g1t address: use https://." | |
| 49 | + | ))); | |
| 50 | + | } | |
| 51 | + | None => ("https", input), | |
| 52 | + | }; | |
| 53 | + | let name = rest.split('/').next().unwrap_or(rest).to_lowercase(); | |
| 54 | + | if name.is_empty() || name.contains(char::is_whitespace) { | |
| 55 | + | return Err(Error::usage(format!("`{input}` is not a hostname."))); | |
| 56 | + | } | |
| 57 | + | Ok((name.clone(), format!("{scheme}://{name}"))) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// The host named by `--hostname`/`G1T_HOST`, or the default one. | |
| 61 | + | pub fn resolve(config: &Config, hostname: Option<&str>) -> Result<Host> { | |
| 62 | + | let wanted = match hostname { | |
| 63 | + | Some(h) if !h.trim().is_empty() => h.to_owned(), | |
| 64 | + | _ => match std::env::var("G1T_HOST") | |
| 65 | + | .ok() | |
| 66 | + | .filter(|h| !h.trim().is_empty()) | |
| 67 | + | { | |
| 68 | + | Some(h) => h, | |
| 69 | + | None => config | |
| 70 | + | .default_host | |
| 71 | + | .clone() | |
| 72 | + | .unwrap_or_else(|| DEFAULT_HOST.to_owned()), | |
| 73 | + | }, | |
| 74 | + | }; | |
| 75 | + | let (name, site) = parse_hostname(&wanted)?; | |
| 76 | + | let mut host = match config.host(&name) { | |
| 77 | + | Some(h) => Host { | |
| 78 | + | name, | |
| 79 | + | site: h.site.clone(), | |
| 80 | + | api: h.api.clone(), | |
| 81 | + | }, | |
| 82 | + | None if name == DEFAULT_HOST => Host::hosted(), | |
| 83 | + | // Not signed in to it: guess the API's address the way g1t is set | |
| 84 | + | // up (api.<host>); `auth login` asks the host instead. | |
| 85 | + | None => { | |
| 86 | + | let api = site.replacen("://", "://api.", 1); | |
| 87 | + | Host { name, site, api } | |
| 88 | + | } | |
| 89 | + | }; | |
| 90 | + | if let Some(api) = std::env::var("G1T_API_URL") | |
| 91 | + | .ok() | |
| 92 | + | .filter(|a| !a.trim().is_empty()) | |
| 93 | + | { | |
| 94 | + | host.api = api.trim_end_matches('/').to_owned(); | |
| 95 | + | } | |
| 96 | + | Ok(host) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Asks `site` where its API is. | |
| 100 | + | pub fn discover(name: &str, site: &str) -> Result<Host> { | |
| 101 | + | if name == DEFAULT_HOST { | |
| 102 | + | return Ok(Host::hosted()); | |
| 103 | + | } | |
| 104 | + | let url = format!("{site}/.well-known/g1t.json"); | |
| 105 | + | let text = ureq::get(&url) | |
| 106 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 107 | + | .call() | |
| 108 | + | .map_err(|e| Error::failed(format!("{name} did not say where its API is ({url}): {e}")))? | |
| 109 | + | .into_string() | |
| 110 | + | .map_err(|e| Error::failed(format!("Could not read {url}: {e}")))?; | |
| 111 | + | let found: Value = serde_json::from_str(&text) | |
| 112 | + | .map_err(|e| Error::failed(format!("{url} is not g1t's: {e}")))?; | |
| 113 | + | let api = found["api"] | |
| 114 | + | .as_str() | |
| 115 | + | .ok_or_else(|| Error::failed(format!("{url} names no API.")))?; | |
| 116 | + | let site_said = found["site"].as_str().unwrap_or(site); | |
| 117 | + | Ok(Host { | |
| 118 | + | name: name.to_owned(), | |
| 119 | + | site: site_said.trim_end_matches('/').to_owned(), | |
| 120 | + | api: api.trim_end_matches('/').to_owned(), | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 125 | + | pub enum Source { | |
| 126 | + | Env, | |
| 127 | + | Keyring, | |
| 128 | + | File, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | impl Source { | |
| 132 | + | pub fn label(self) -> &'static str { | |
| 133 | + | match self { | |
| 134 | + | Source::Env => "G1T_TOKEN", | |
| 135 | + | Source::Keyring => "keychain", | |
| 136 | + | Source::File => "config file", | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[derive(Clone, Debug)] | |
| 142 | + | pub struct Token { | |
| 143 | + | pub value: String, | |
| 144 | + | pub source: Source, | |
| 145 | + | /// Whose it is, when the store knows. | |
| 146 | + | pub user: Option<String>, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn keyring_enabled() -> bool { | |
| 150 | + | !matches!( | |
| 151 | + | std::env::var("G1T_KEYRING") | |
| 152 | + | .ok() | |
| 153 | + | .as_deref() | |
| 154 | + | .map(str::to_lowercase) | |
| 155 | + | .as_deref(), | |
| 156 | + | Some("off" | "0" | "false" | "no") | |
| 157 | + | ) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | fn service(host: &str) -> String { | |
| 161 | + | format!("g1t:{host}") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | fn keyring_get(host: &str, user: &str) -> Option<String> { | |
| 165 | + | if !keyring_enabled() { | |
| 166 | + | return None; | |
| 167 | + | } | |
| 168 | + | let entry = keyring::Entry::new(&service(host), user).ok()?; | |
| 169 | + | entry.get_password().ok().filter(|t| !t.is_empty()) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | fn keyring_set(host: &str, user: &str, token: &str) -> std::result::Result<(), String> { | |
| 173 | + | if !keyring_enabled() { | |
| 174 | + | return Err("G1T_KEYRING is off".to_owned()); | |
| 175 | + | } | |
| 176 | + | let entry = keyring::Entry::new(&service(host), user).map_err(|e| e.to_string())?; | |
| 177 | + | entry.set_password(token).map_err(|e| e.to_string())?; | |
| 178 | + | // Some stores accept a write they cannot read back (a locked keyring). | |
| 179 | + | match entry.get_password() { | |
| 180 | + | Ok(back) if back == token => Ok(()), | |
| 181 | + | _ => Err("the keychain did not keep it".to_owned()), | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | fn keyring_delete(host: &str, user: &str) { | |
| 186 | + | if keyring_enabled() | |
| 187 | + | && let Ok(entry) = keyring::Entry::new(&service(host), user) | |
| 188 | + | { | |
| 189 | + | let _ = entry.delete_credential(); | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// The token for `host`, and the account it is for when known. | |
| 194 | + | pub fn token(config: &Config, host: &str, user: Option<&str>) -> Option<Token> { | |
| 195 | + | if user.is_none() | |
| 196 | + | && let Some(value) = std::env::var("G1T_TOKEN") | |
| 197 | + | .ok() | |
| 198 | + | .map(|t| t.trim().to_owned()) | |
| 199 | + | .filter(|t| !t.is_empty()) | |
| 200 | + | { | |
| 201 | + | return Some(Token { | |
| 202 | + | value, | |
| 203 | + | source: Source::Env, | |
| 204 | + | user: None, | |
| 205 | + | }); | |
| 206 | + | } | |
| 207 | + | let entry = config.host(host)?; | |
| 208 | + | let user = user.map(str::to_owned).or_else(|| entry.user.clone())?; | |
| 209 | + | stored_token(host, &user, entry.users.get(&user)) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn stored_token(host: &str, user: &str, entry: Option<&UserConfig>) -> Option<Token> { | |
| 213 | + | if let Some(value) = entry | |
| 214 | + | .and_then(|u| u.token.clone()) | |
| 215 | + | .filter(|t| !t.is_empty()) | |
| 216 | + | { | |
| 217 | + | return Some(Token { | |
| 218 | + | value, | |
| 219 | + | source: Source::File, | |
| 220 | + | user: Some(user.to_owned()), | |
| 221 | + | }); | |
| 222 | + | } | |
| 223 | + | keyring_get(host, user).map(|value| Token { | |
| 224 | + | value, | |
| 225 | + | source: Source::Keyring, | |
| 226 | + | user: Some(user.to_owned()), | |
| 227 | + | }) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Keeps `token` for `user` on `host` and makes that account the active one. | |
| 231 | + | /// Returns where it went. | |
| 232 | + | pub fn store(config: &mut Config, host: &Host, user: &str, token: &str, insecure: bool) -> Source { | |
| 233 | + | let source = if !insecure && keyring_set(&host.name, user, token).is_ok() { | |
| 234 | + | Source::Keyring | |
| 235 | + | } else { | |
| 236 | + | Source::File | |
| 237 | + | }; | |
| 238 | + | let entry = config | |
| 239 | + | .hosts | |
| 240 | + | .entry(host.name.clone()) | |
| 241 | + | .or_insert_with(|| HostConfig { | |
| 242 | + | site: host.site.clone(), | |
| 243 | + | api: host.api.clone(), | |
| 244 | + | ..HostConfig::default() | |
| 245 | + | }); | |
| 246 | + | entry.site = host.site.clone(); | |
| 247 | + | entry.api = host.api.clone(); | |
| 248 | + | entry.user = Some(user.to_owned()); | |
| 249 | + | entry.users.insert( | |
| 250 | + | user.to_owned(), | |
| 251 | + | UserConfig { | |
| 252 | + | token: (source == Source::File).then(|| token.to_owned()), | |
| 253 | + | }, | |
| 254 | + | ); | |
| 255 | + | if config.default_host.is_none() { | |
| 256 | + | config.default_host = Some(host.name.clone()); | |
| 257 | + | } | |
| 258 | + | source | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// Forgets `user` on `host`; the next account there, if any, becomes active. | |
| 262 | + | pub fn forget(config: &mut Config, host: &str, user: &str) { | |
| 263 | + | keyring_delete(host, user); | |
| 264 | + | let Some(entry) = config.hosts.get_mut(host) else { | |
| 265 | + | return; | |
| 266 | + | }; | |
| 267 | + | entry.users.remove(user); | |
| 268 | + | if entry.user.as_deref() == Some(user) { | |
| 269 | + | entry.user = entry.users.keys().next().cloned(); | |
| 270 | + | } | |
| 271 | + | if entry.users.is_empty() { | |
| 272 | + | config.hosts.remove(host); | |
| 273 | + | if config.default_host.as_deref() == Some(host) { | |
| 274 | + | config.default_host = config.hosts.keys().next().cloned(); | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// Every account's token on `host`, for `auth status`. | |
| 280 | + | pub fn accounts(config: &Config, host: &str) -> Vec<(String, Option<Token>, bool)> { | |
| 281 | + | let Some(entry) = config.host(host) else { | |
| 282 | + | return Vec::new(); | |
| 283 | + | }; | |
| 284 | + | entry | |
| 285 | + | .users | |
| 286 | + | .iter() | |
| 287 | + | .map(|(user, u)| { | |
| 288 | + | ( | |
| 289 | + | user.clone(), | |
| 290 | + | stored_token(host, user, Some(u)), | |
| 291 | + | entry.user.as_deref() == Some(user), | |
| 292 | + | ) | |
| 293 | + | }) | |
| 294 | + | .collect() | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// The token `g1t push` signs in to a registry with, when one is stored for | |
| 298 | + | /// its host. | |
| 299 | + | pub fn registry_token(host: &str) -> Option<String> { | |
| 300 | + | let config = Config::load().ok()?; | |
| 301 | + | let (name, _) = parse_hostname(host).ok()?; | |
| 302 | + | let entry = config.host(&name)?; | |
| 303 | + | let user = entry.user.clone()?; | |
| 304 | + | stored_token(&name, &user, entry.users.get(&user)).map(|t| t.value) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[cfg(test)] | |
| 308 | + | mod tests { | |
| 309 | + | use super::*; | |
| 310 | + | ||
| 311 | + | #[test] | |
| 312 | + | fn hostnames_take_any_form() { | |
| 313 | + | assert_eq!( | |
| 314 | + | parse_hostname("g1t.sh").unwrap(), | |
| 315 | + | ("g1t.sh".into(), "https://g1t.sh".into()) | |
| 316 | + | ); | |
| 317 | + | assert_eq!( | |
| 318 | + | parse_hostname("https://Git.Example.com/").unwrap(), | |
| 319 | + | ("git.example.com".into(), "https://git.example.com".into()) | |
| 320 | + | ); | |
| 321 | + | assert_eq!( | |
| 322 | + | parse_hostname("http://localhost:8787").unwrap(), | |
| 323 | + | ("localhost:8787".into(), "http://localhost:8787".into()) | |
| 324 | + | ); | |
| 325 | + | assert!(parse_hostname("ssh://g1t.sh").is_err()); | |
| 326 | + | assert!(parse_hostname("").is_err()); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn file_tokens_are_kept_and_forgotten() { | |
| 331 | + | // SAFETY: tests that touch G1T_KEYRING all set it to off. | |
| 332 | + | unsafe { std::env::set_var("G1T_KEYRING", "off") }; | |
| 333 | + | let mut config = Config::default(); | |
| 334 | + | let host = Host::hosted(); | |
| 335 | + | assert_eq!( | |
| 336 | + | store(&mut config, &host, "ana", "g1t_a", false), | |
| 337 | + | Source::File | |
| 338 | + | ); | |
| 339 | + | store(&mut config, &host, "bo", "g1t_b", true); | |
| 340 | + | assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("bo")); | |
| 341 | + | let t = stored_token( | |
| 342 | + | "g1t.sh", | |
| 343 | + | "ana", | |
| 344 | + | config.host("g1t.sh").unwrap().users.get("ana"), | |
| 345 | + | ) | |
| 346 | + | .unwrap(); | |
| 347 | + | assert_eq!((t.value.as_str(), t.source), ("g1t_a", Source::File)); | |
| 348 | + | forget(&mut config, "g1t.sh", "bo"); | |
| 349 | + | assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("ana")); | |
| 350 | + | forget(&mut config, "g1t.sh", "ana"); | |
| 351 | + | assert!(config.hosts.is_empty()); | |
| 352 | + | assert_eq!(config.default_host, None); | |
| 353 | + | } | |
| 354 | + | } |
| 2 | 2 | ||
| 3 | 3 | pub const MIB: u64 = 1024 * 1024; | |
| 4 | 4 | /// What a chunk is unless `--chunk-size` says otherwise. | |
| 5 | + | #[cfg_attr(not(test), allow(dead_code))] | |
| 5 | 6 | pub const DEFAULT_CHUNK: u64 = 90 * MIB; | |
| 6 | 7 | /// The largest chunk: well under the 100 MB a request to g1t.sh may carry. | |
| 7 | 8 | pub const MAX_CHUNK: u64 = 95 * MIB; |
| 1 | + | //! The command line's shape: every command, flag and argument. | |
| 2 | + | ||
| 3 | + | use clap::{Args, Parser, Subcommand, ValueEnum}; | |
| 4 | + | use std::path::PathBuf; | |
| 5 | + | ||
| 6 | + | pub const ABOUT: &str = "Work with g1t from the command line."; | |
| 7 | + | ||
| 8 | + | pub const AFTER_HELP: &str = "\ | |
| 9 | + | Examples: | |
| 10 | + | g1t auth login | |
| 11 | + | g1t issue list --label bug | |
| 12 | + | g1t pr create --fill | |
| 13 | + | g1t pr checks --watch --fail-fast | |
| 14 | + | g1t run logs --failed | |
| 15 | + | g1t api list_issues --jq '.[].title' | |
| 16 | + | ||
| 17 | + | Environment: | |
| 18 | + | G1T_TOKEN A token to use instead of the stored one | |
| 19 | + | G1T_HOST The host to talk to when no repository names one | |
| 20 | + | G1T_REPO The repository, as OWNER/NAME, instead of the git remote | |
| 21 | + | NO_COLOR Plain output on a terminal too | |
| 22 | + | ||
| 23 | + | Exit codes: 0 done, 1 failed, 2 used wrongly, 4 sign-in needed, 8 checks pending. | |
| 24 | + | Docs: https://docs.g1t.sh/guides/cli/"; | |
| 25 | + | ||
| 26 | + | #[derive(Debug, Parser)] | |
| 27 | + | #[command( | |
| 28 | + | name = "g1t", | |
| 29 | + | version, | |
| 30 | + | about = ABOUT, | |
| 31 | + | after_help = AFTER_HELP, | |
| 32 | + | propagate_version = true, | |
| 33 | + | max_term_width = 100, | |
| 34 | + | arg_required_else_help = true | |
| 35 | + | )] | |
| 36 | + | pub struct Cli { | |
| 37 | + | #[command(flatten)] | |
| 38 | + | pub global: Global, | |
| 39 | + | #[command(subcommand)] | |
| 40 | + | pub command: Command, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[derive(Debug, Args, Clone, Default)] | |
| 44 | + | pub struct Global { | |
| 45 | + | /// The repository, as [HOST/]OWNER/NAME, instead of the current directory's. | |
| 46 | + | #[arg( | |
| 47 | + | short = 'R', | |
| 48 | + | long, | |
| 49 | + | global = true, | |
| 50 | + | value_name = "[HOST/]OWNER/NAME", | |
| 51 | + | env = "G1T_REPO" | |
| 52 | + | )] | |
| 53 | + | pub repo: Option<String>, | |
| 54 | + | /// The g1t host, such as g1t.sh or git.example.com. | |
| 55 | + | #[arg(long, global = true, value_name = "HOST")] | |
| 56 | + | pub hostname: Option<String>, | |
| 57 | + | /// Print JSON: every field, or only the comma-separated FIELDS. | |
| 58 | + | #[arg( | |
| 59 | + | long, | |
| 60 | + | global = true, | |
| 61 | + | value_name = "FIELDS", | |
| 62 | + | num_args = 0..=1, | |
| 63 | + | default_missing_value = "", | |
| 64 | + | help_heading = "Output" | |
| 65 | + | )] | |
| 66 | + | pub json: Option<String>, | |
| 67 | + | /// Run a jq expression over the JSON and print what it gives. | |
| 68 | + | #[arg( | |
| 69 | + | long, | |
| 70 | + | global = true, | |
| 71 | + | value_name = "EXPRESSION", | |
| 72 | + | help_heading = "Output" | |
| 73 | + | )] | |
| 74 | + | pub jq: Option<String>, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[derive(Debug, Subcommand)] | |
| 78 | + | pub enum Command { | |
| 79 | + | /// Sign in, sign out, and set git up to use g1t's credentials. | |
| 80 | + | #[command(subcommand)] | |
| 81 | + | Auth(AuthCommand), | |
| 82 | + | /// Repositories: view, clone, create, list. | |
| 83 | + | #[command(subcommand)] | |
| 84 | + | Repo(RepoCommand), | |
| 85 | + | /// Issues: list, view, create, comment, close, reopen, edit. | |
| 86 | + | #[command(subcommand)] | |
| 87 | + | Issue(IssueCommand), | |
| 88 | + | /// Pull requests: list, view, create, check out, diff, checks, review, merge. | |
| 89 | + | #[command(subcommand)] | |
| 90 | + | Pr(PrCommand), | |
| 91 | + | /// Workflow runs: list, view, watch, logs, rerun, cancel. | |
| 92 | + | #[command(subcommand)] | |
| 93 | + | Run(RunCommand), | |
| 94 | + | /// Secrets for workflows and deployments, a repository's or a workspace's. | |
| 95 | + | #[command(subcommand)] | |
| 96 | + | Secret(SecretCommand), | |
| 97 | + | /// Deployments: list and view. | |
| 98 | + | #[command(subcommand)] | |
| 99 | + | Deploy(DeployCommand), | |
| 100 | + | /// A project's logs and errors. | |
| 101 | + | Logs(LogsArgs), | |
| 102 | + | /// What needs you: your pull requests, reviews asked of you, your issues, failing checks. | |
| 103 | + | Status(StatusArgs), | |
| 104 | + | /// Call any API operation or path, with your token. | |
| 105 | + | Api(ApiArgs), | |
| 106 | + | /// Open the repository, an issue or a pull request in the browser. | |
| 107 | + | Browse(BrowseArgs), | |
| 108 | + | /// Shortcuts for commands you type often. | |
| 109 | + | #[command(subcommand)] | |
| 110 | + | Alias(AliasCommand), | |
| 111 | + | /// Print a shell completion script. | |
| 112 | + | Completion(CompletionArgs), | |
| 113 | + | /// Push a local container image to g1t's registry, in chunks. | |
| 114 | + | Push(PushArgs), | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // --- auth ------------------------------------------------------------- | |
| 118 | + | ||
| 119 | + | #[derive(Debug, Subcommand)] | |
| 120 | + | pub enum AuthCommand { | |
| 121 | + | /// Sign in to a g1t host. | |
| 122 | + | #[command(after_help = "\ | |
| 123 | + | Without --with-token, g1t shows a code and a link; approve it in the browser, signed in. | |
| 124 | + | With --with-token, the token is read from stdin: | |
| 125 | + | g1t auth login --with-token < token.txt")] | |
| 126 | + | Login(LoginArgs), | |
| 127 | + | /// Sign out of an account on a host. | |
| 128 | + | Logout(AccountArgs), | |
| 129 | + | /// Show which accounts are signed in, and whether their tokens work. | |
| 130 | + | Status(AuthStatusArgs), | |
| 131 | + | /// Print the token in use, for another program. | |
| 132 | + | Token(AccountArgs), | |
| 133 | + | /// Make another signed-in account on a host the active one. | |
| 134 | + | Switch(AccountArgs), | |
| 135 | + | /// Make git use g1t's credentials for pushing and fetching over HTTPS. | |
| 136 | + | SetupGit(SetupGitArgs), | |
| 137 | + | /// The git credential helper `setup-git` installs. Git runs it. | |
| 138 | + | #[command(hide = true)] | |
| 139 | + | GitCredential(GitCredentialArgs), | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[derive(Debug, Args)] | |
| 143 | + | pub struct LoginArgs { | |
| 144 | + | /// Read a token from stdin instead of approving in the browser. | |
| 145 | + | #[arg(long)] | |
| 146 | + | pub with_token: bool, | |
| 147 | + | /// Keep the token in the config file, not the system keychain. | |
| 148 | + | #[arg(long)] | |
| 149 | + | pub insecure_storage: bool, | |
| 150 | + | /// Do not open the browser; just print the link. | |
| 151 | + | #[arg(long)] | |
| 152 | + | pub no_browser: bool, | |
| 153 | + | /// Also set git up to use g1t's credentials (see `g1t auth setup-git`). | |
| 154 | + | #[arg(long)] | |
| 155 | + | pub git: bool, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[derive(Debug, Args)] | |
| 159 | + | pub struct AccountArgs { | |
| 160 | + | /// The account, when more than one is signed in on the host. | |
| 161 | + | #[arg(short, long)] | |
| 162 | + | pub user: Option<String>, | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[derive(Debug, Args)] | |
| 166 | + | pub struct AuthStatusArgs { | |
| 167 | + | /// Print each token too. | |
| 168 | + | #[arg(long)] | |
| 169 | + | pub show_token: bool, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[derive(Debug, Args)] | |
| 173 | + | pub struct SetupGitArgs { | |
| 174 | + | /// Print the git config commands instead of running them. | |
| 175 | + | #[arg(long)] | |
| 176 | + | pub dry_run: bool, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[derive(Debug, Args)] | |
| 180 | + | pub struct GitCredentialArgs { | |
| 181 | + | /// get, store or erase, as git says. | |
| 182 | + | pub operation: String, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // --- repo ------------------------------------------------------------- | |
| 186 | + | ||
| 187 | + | #[derive(Debug, Subcommand)] | |
| 188 | + | pub enum RepoCommand { | |
| 189 | + | /// Show a repository. | |
| 190 | + | View(RepoViewArgs), | |
| 191 | + | /// Clone a repository. | |
| 192 | + | Clone(RepoCloneArgs), | |
| 193 | + | /// Make a repository. | |
| 194 | + | Create(RepoCreateArgs), | |
| 195 | + | /// List repositories you can see. | |
| 196 | + | List(RepoListArgs), | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[derive(Debug, Args)] | |
| 200 | + | pub struct RepoViewArgs { | |
| 201 | + | /// OWNER/NAME; the current repository when left out. | |
| 202 | + | pub repository: Option<String>, | |
| 203 | + | /// Open it in the browser. | |
| 204 | + | #[arg(short, long)] | |
| 205 | + | pub web: bool, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[derive(Debug, Args)] | |
| 209 | + | pub struct RepoCloneArgs { | |
| 210 | + | /// OWNER/NAME, or NAME for one of your own. | |
| 211 | + | pub repository: String, | |
| 212 | + | /// Where to clone it. | |
| 213 | + | pub directory: Option<String>, | |
| 214 | + | /// Flags for git clone, after --. | |
| 215 | + | #[arg(last = true)] | |
| 216 | + | pub git_flags: Vec<String>, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[derive(Debug, Args)] | |
| 220 | + | pub struct RepoCreateArgs { | |
| 221 | + | /// NAME, or WORKSPACE/NAME. | |
| 222 | + | pub name: Option<String>, | |
| 223 | + | /// Anyone can see it. Repositories are private unless this is given. | |
| 224 | + | #[arg(long, conflicts_with = "private")] | |
| 225 | + | pub public: bool, | |
| 226 | + | /// Only people with a role on it can see it (the default). | |
| 227 | + | #[arg(long)] | |
| 228 | + | pub private: bool, | |
| 229 | + | #[arg(short, long)] | |
| 230 | + | pub description: Option<String>, | |
| 231 | + | /// Clone it once it is made. | |
| 232 | + | #[arg(short, long)] | |
| 233 | + | pub clone: bool, | |
| 234 | + | /// Add it as a remote of this directory's repository and push it there. | |
| 235 | + | #[arg(long, value_name = "DIR")] | |
| 236 | + | pub source: Option<String>, | |
| 237 | + | /// The remote's name, with --source. | |
| 238 | + | #[arg(long, default_value = "origin")] | |
| 239 | + | pub remote: String, | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[derive(Debug, Args)] | |
| 243 | + | pub struct RepoListArgs { | |
| 244 | + | /// Only this workspace's. | |
| 245 | + | pub workspace: Option<String>, | |
| 246 | + | /// Only those whose name or description holds this. | |
| 247 | + | #[arg(short, long)] | |
| 248 | + | pub query: Option<String>, | |
| 249 | + | #[arg(short = 'L', long, default_value_t = 30)] | |
| 250 | + | pub limit: usize, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // --- issue ------------------------------------------------------------ | |
| 254 | + | ||
| 255 | + | #[derive(Debug, Subcommand)] | |
| 256 | + | pub enum IssueCommand { | |
| 257 | + | /// List issues. | |
| 258 | + | List(IssueListArgs), | |
| 259 | + | /// Show an issue, with its comments. | |
| 260 | + | View(IssueViewArgs), | |
| 261 | + | /// Open an issue. | |
| 262 | + | Create(IssueCreateArgs), | |
| 263 | + | /// Comment on an issue. | |
| 264 | + | Comment(CommentArgs), | |
| 265 | + | /// Close an issue. | |
| 266 | + | Close(IssueCloseArgs), | |
| 267 | + | /// Reopen an issue. | |
| 268 | + | Reopen(ReopenArgs), | |
| 269 | + | /// Change an issue's title, body, labels, assignees or milestone. | |
| 270 | + | Edit(IssueEditArgs), | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 274 | + | pub enum IssueState { | |
| 275 | + | Open, | |
| 276 | + | Closed, | |
| 277 | + | All, | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[derive(Debug, Args)] | |
| 281 | + | pub struct IssueListArgs { | |
| 282 | + | #[arg(short, long, value_enum, default_value_t = IssueState::Open)] | |
| 283 | + | pub state: IssueState, | |
| 284 | + | /// Only issues with this label. | |
| 285 | + | #[arg(short, long)] | |
| 286 | + | pub label: Option<String>, | |
| 287 | + | /// Only issues in the milestone of this number. | |
| 288 | + | #[arg(short, long)] | |
| 289 | + | pub milestone: Option<u64>, | |
| 290 | + | /// Only issues assigned to this person (@me for you). | |
| 291 | + | #[arg(short, long)] | |
| 292 | + | pub assignee: Option<String>, | |
| 293 | + | /// Only issues opened by this person (@me for you). | |
| 294 | + | #[arg(short = 'A', long)] | |
| 295 | + | pub author: Option<String>, | |
| 296 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 297 | + | pub limit: usize, | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[derive(Debug, Args)] | |
| 301 | + | pub struct IssueViewArgs { | |
| 302 | + | /// The issue's number. | |
| 303 | + | pub number: u64, | |
| 304 | + | /// Show its comments too. | |
| 305 | + | #[arg(short, long)] | |
| 306 | + | pub comments: bool, | |
| 307 | + | /// Open it in the browser. | |
| 308 | + | #[arg(short, long)] | |
| 309 | + | pub web: bool, | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[derive(Debug, Args)] | |
| 313 | + | pub struct BodyArgs { | |
| 314 | + | /// The text. Markdown. | |
| 315 | + | #[arg(short, long)] | |
| 316 | + | pub body: Option<String>, | |
| 317 | + | /// Read the text from a file, or - for stdin. | |
| 318 | + | #[arg(short = 'F', long, value_name = "FILE")] | |
| 319 | + | pub body_file: Option<String>, | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[derive(Debug, Args)] | |
| 323 | + | pub struct IssueCreateArgs { | |
| 324 | + | #[arg(short, long)] | |
| 325 | + | pub title: Option<String>, | |
| 326 | + | #[command(flatten)] | |
| 327 | + | pub body: BodyArgs, | |
| 328 | + | /// A label; repeat for more. | |
| 329 | + | #[arg(short, long)] | |
| 330 | + | pub label: Vec<String>, | |
| 331 | + | /// Someone to assign; repeat for more (@me for you). | |
| 332 | + | #[arg(short, long)] | |
| 333 | + | pub assignee: Vec<String>, | |
| 334 | + | /// A milestone's number. | |
| 335 | + | #[arg(short, long)] | |
| 336 | + | pub milestone: Option<u64>, | |
| 337 | + | /// Open the new-issue page in the browser instead. | |
| 338 | + | #[arg(short, long)] | |
| 339 | + | pub web: bool, | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | #[derive(Debug, Args)] | |
| 343 | + | pub struct CommentArgs { | |
| 344 | + | /// The number; for a pull request, the current branch's when left out. | |
| 345 | + | pub number: Option<u64>, | |
| 346 | + | #[command(flatten)] | |
| 347 | + | pub body: BodyArgs, | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 351 | + | pub enum CloseReason { | |
| 352 | + | Completed, | |
| 353 | + | NotPlanned, | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[derive(Debug, Args)] | |
| 357 | + | pub struct IssueCloseArgs { | |
| 358 | + | pub number: u64, | |
| 359 | + | #[arg(short, long, value_enum)] | |
| 360 | + | pub reason: Option<CloseReason>, | |
| 361 | + | /// Leave a comment as it closes. | |
| 362 | + | #[arg(short, long)] | |
| 363 | + | pub comment: Option<String>, | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[derive(Debug, Args)] | |
| 367 | + | pub struct ReopenArgs { | |
| 368 | + | pub number: u64, | |
| 369 | + | /// Leave a comment as it reopens. | |
| 370 | + | #[arg(short, long)] | |
| 371 | + | pub comment: Option<String>, | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[derive(Debug, Args)] | |
| 375 | + | pub struct IssueEditArgs { | |
| 376 | + | pub number: u64, | |
| 377 | + | #[arg(short, long)] | |
| 378 | + | pub title: Option<String>, | |
| 379 | + | #[command(flatten)] | |
| 380 | + | pub body: BodyArgs, | |
| 381 | + | #[arg(long, value_name = "LABEL")] | |
| 382 | + | pub add_label: Vec<String>, | |
| 383 | + | #[arg(long, value_name = "LABEL")] | |
| 384 | + | pub remove_label: Vec<String>, | |
| 385 | + | #[arg(long, value_name = "LOGIN")] | |
| 386 | + | pub add_assignee: Vec<String>, | |
| 387 | + | #[arg(long, value_name = "LOGIN")] | |
| 388 | + | pub remove_assignee: Vec<String>, | |
| 389 | + | /// A milestone's number. | |
| 390 | + | #[arg(short, long)] | |
| 391 | + | pub milestone: Option<u64>, | |
| 392 | + | /// Take it out of its milestone. | |
| 393 | + | #[arg(long, conflicts_with = "milestone")] | |
| 394 | + | pub remove_milestone: bool, | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // --- pr --------------------------------------------------------------- | |
| 398 | + | ||
| 399 | + | #[derive(Debug, Subcommand)] | |
| 400 | + | pub enum PrCommand { | |
| 401 | + | /// List pull requests. | |
| 402 | + | List(PrListArgs), | |
| 403 | + | /// Show a pull request. | |
| 404 | + | View(PrViewArgs), | |
| 405 | + | /// Open a pull request from a branch, or start one for an issue. | |
| 406 | + | Create(PrCreateArgs), | |
| 407 | + | /// Check a pull request's changes out locally. | |
| 408 | + | Checkout(PrCheckoutArgs), | |
| 409 | + | /// Show a pull request's changes as a diff. | |
| 410 | + | Diff(PrDiffArgs), | |
| 411 | + | /// Show a pull request's checks; --watch waits for them. | |
| 412 | + | Checks(PrChecksArgs), | |
| 413 | + | /// Approve a pull request, or ask for changes. | |
| 414 | + | Review(PrReviewArgs), | |
| 415 | + | /// Merge a pull request. | |
| 416 | + | Merge(PrMergeArgs), | |
| 417 | + | /// Mark a draft ready for review, or turn it back into a draft. | |
| 418 | + | Ready(PrReadyArgs), | |
| 419 | + | /// Comment on a pull request. | |
| 420 | + | Comment(CommentArgs), | |
| 421 | + | /// Close a pull request without merging it. | |
| 422 | + | Close(PrSelect), | |
| 423 | + | /// Reopen a closed pull request. | |
| 424 | + | Reopen(PrSelect), | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | #[derive(Debug, Args)] | |
| 428 | + | pub struct PrSelect { | |
| 429 | + | /// The number; the current branch's when left out. | |
| 430 | + | pub number: Option<u64>, | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 434 | + | pub enum PrState { | |
| 435 | + | Open, | |
| 436 | + | Closed, | |
| 437 | + | Merged, | |
| 438 | + | All, | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | #[derive(Debug, Args)] | |
| 442 | + | pub struct PrListArgs { | |
| 443 | + | #[arg(short, long, value_enum, default_value_t = PrState::Open)] | |
| 444 | + | pub state: PrState, | |
| 445 | + | /// Only those merging into this branch. | |
| 446 | + | #[arg(short = 'B', long)] | |
| 447 | + | pub base: Option<String>, | |
| 448 | + | #[arg(short, long)] | |
| 449 | + | pub label: Option<String>, | |
| 450 | + | /// Only those opened by this person (@me for you). | |
| 451 | + | #[arg(short = 'A', long)] | |
| 452 | + | pub author: Option<String>, | |
| 453 | + | /// Only drafts. | |
| 454 | + | #[arg(short, long)] | |
| 455 | + | pub draft: bool, | |
| 456 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 457 | + | pub limit: usize, | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | #[derive(Debug, Args)] | |
| 461 | + | pub struct PrViewArgs { | |
| 462 | + | /// The number; the current branch's when left out. | |
| 463 | + | pub number: Option<u64>, | |
| 464 | + | #[arg(short, long)] | |
| 465 | + | pub comments: bool, | |
| 466 | + | #[arg(short, long)] | |
| 467 | + | pub web: bool, | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | #[derive(Debug, Args)] | |
| 471 | + | pub struct PrCreateArgs { | |
| 472 | + | #[arg(short, long)] | |
| 473 | + | pub title: Option<String>, | |
| 474 | + | #[command(flatten)] | |
| 475 | + | pub body: BodyArgs, | |
| 476 | + | /// The branch to merge into; the default branch when left out. | |
| 477 | + | #[arg(short = 'B', long)] | |
| 478 | + | pub base: Option<String>, | |
| 479 | + | /// The branch with the changes; the current one when left out. | |
| 480 | + | #[arg(short = 'H', long)] | |
| 481 | + | pub head: Option<String>, | |
| 482 | + | /// The issue it is for. Without a branch, starts a draft in a working copy of its own. | |
| 483 | + | #[arg(short, long)] | |
| 484 | + | pub issue: Option<u64>, | |
| 485 | + | /// Take the title and body from the branch's commits. | |
| 486 | + | #[arg(short, long)] | |
| 487 | + | pub fill: bool, | |
| 488 | + | /// Open it as a draft. | |
| 489 | + | #[arg(short, long)] | |
| 490 | + | pub draft: bool, | |
| 491 | + | /// Do not push the branch first. | |
| 492 | + | #[arg(long)] | |
| 493 | + | pub no_push: bool, | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | #[derive(Debug, Args)] | |
| 497 | + | pub struct PrCheckoutArgs { | |
| 498 | + | pub number: u64, | |
| 499 | + | /// The local branch's name; pr-NUMBER when left out. | |
| 500 | + | #[arg(short, long)] | |
| 501 | + | pub branch: Option<String>, | |
| 502 | + | /// Reset the local branch to the pull request's head even if it has other commits. | |
| 503 | + | #[arg(short, long)] | |
| 504 | + | pub force: bool, | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | #[derive(Debug, Args)] | |
| 508 | + | pub struct PrDiffArgs { | |
| 509 | + | pub number: Option<u64>, | |
| 510 | + | /// Only the changed files' names. | |
| 511 | + | #[arg(long)] | |
| 512 | + | pub name_only: bool, | |
| 513 | + | /// Color: auto, always or never. | |
| 514 | + | #[arg(long, default_value = "auto")] | |
| 515 | + | pub color: String, | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | #[derive(Debug, Args)] | |
| 519 | + | pub struct PrChecksArgs { | |
| 520 | + | pub number: Option<u64>, | |
| 521 | + | /// Wait until every check has finished. | |
| 522 | + | #[arg(short, long)] | |
| 523 | + | pub watch: bool, | |
| 524 | + | /// With --watch, stop at the first failing check. | |
| 525 | + | #[arg(long, requires = "watch")] | |
| 526 | + | pub fail_fast: bool, | |
| 527 | + | /// Seconds between looks, with --watch. | |
| 528 | + | #[arg(short, long, default_value_t = 10)] | |
| 529 | + | pub interval: u64, | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | #[derive(Debug, Args)] | |
| 533 | + | #[command(group(clap::ArgGroup::new("verdict").required(true).args(["approve", "request_changes", "comment"])))] | |
| 534 | + | pub struct PrReviewArgs { | |
| 535 | + | pub number: Option<u64>, | |
| 536 | + | #[arg(short, long)] | |
| 537 | + | pub approve: bool, | |
| 538 | + | #[arg(short, long)] | |
| 539 | + | pub request_changes: bool, | |
| 540 | + | /// Comment without approving or asking for changes. | |
| 541 | + | #[arg(short, long)] | |
| 542 | + | pub comment: bool, | |
| 543 | + | #[command(flatten)] | |
| 544 | + | pub body: BodyArgs, | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | #[derive(Debug, Args)] | |
| 548 | + | pub struct PrMergeArgs { | |
| 549 | + | pub number: Option<u64>, | |
| 550 | + | /// Leave the issue it is for open. | |
| 551 | + | #[arg(long)] | |
| 552 | + | pub keep_issue_open: bool, | |
| 553 | + | /// Merge past failing required checks and rules (needs the Admin role). | |
| 554 | + | #[arg(long)] | |
| 555 | + | pub admin: bool, | |
| 556 | + | /// Delete the local and remote branch afterwards. | |
| 557 | + | #[arg(short, long)] | |
| 558 | + | pub delete_branch: bool, | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | #[derive(Debug, Args)] | |
| 562 | + | pub struct PrReadyArgs { | |
| 563 | + | pub number: Option<u64>, | |
| 564 | + | /// What changed and why; it becomes the description. The current one when left out. | |
| 565 | + | #[arg(short, long)] | |
| 566 | + | pub summary: Option<String>, | |
| 567 | + | /// Turn it back into a draft. | |
| 568 | + | #[arg(long)] | |
| 569 | + | pub undo: bool, | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | // --- run -------------------------------------------------------------- | |
| 573 | + | ||
| 574 | + | #[derive(Debug, Subcommand)] | |
| 575 | + | pub enum RunCommand { | |
| 576 | + | /// List workflow runs, newest first. | |
| 577 | + | List(RunListArgs), | |
| 578 | + | /// Show a run's jobs and steps. | |
| 579 | + | View(RunViewArgs), | |
| 580 | + | /// Follow a run until it finishes; exits 1 if it fails. | |
| 581 | + | Watch(RunWatchArgs), | |
| 582 | + | /// Print a run's logs; --failed prints only the failing steps' tails. | |
| 583 | + | Logs(RunLogsArgs), | |
| 584 | + | /// Run a run again: all of it, its failed jobs, or one job. | |
| 585 | + | Rerun(RunRerunArgs), | |
| 586 | + | /// Cancel a run. | |
| 587 | + | Cancel(RunSelect), | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | #[derive(Debug, Args)] | |
| 591 | + | pub struct RunSelect { | |
| 592 | + | /// The run's id (run_…) or number; the current branch's latest when left out. | |
| 593 | + | pub run: Option<String>, | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | #[derive(Debug, Args)] | |
| 597 | + | pub struct RunListArgs { | |
| 598 | + | /// Only this workflow's: its file name or id. | |
| 599 | + | #[arg(short, long)] | |
| 600 | + | pub workflow: Option<String>, | |
| 601 | + | #[arg(short, long)] | |
| 602 | + | pub branch: Option<String>, | |
| 603 | + | /// Only runs for this event: push, pull_request, schedule... | |
| 604 | + | #[arg(short, long)] | |
| 605 | + | pub event: Option<String>, | |
| 606 | + | /// Only runs of this commit. | |
| 607 | + | #[arg(short, long)] | |
| 608 | + | pub commit: Option<String>, | |
| 609 | + | /// Only runs for this pull request. | |
| 610 | + | #[arg(long)] | |
| 611 | + | pub pr: Option<u64>, | |
| 612 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 613 | + | pub limit: usize, | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | #[derive(Debug, Args)] | |
| 617 | + | pub struct RunViewArgs { | |
| 618 | + | pub run: Option<String>, | |
| 619 | + | /// Show this job's steps only. | |
| 620 | + | #[arg(short, long)] | |
| 621 | + | pub job: Option<String>, | |
| 622 | + | /// Print the log too. | |
| 623 | + | #[arg(long)] | |
| 624 | + | pub log: bool, | |
| 625 | + | /// Print the failing steps' logs too. | |
| 626 | + | #[arg(long)] | |
| 627 | + | pub log_failed: bool, | |
| 628 | + | #[arg(short, long)] | |
| 629 | + | pub web: bool, | |
| 630 | + | /// Exit 1 when the run failed. | |
| 631 | + | #[arg(long)] | |
| 632 | + | pub exit_status: bool, | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | #[derive(Debug, Args)] | |
| 636 | + | pub struct RunWatchArgs { | |
| 637 | + | pub run: Option<String>, | |
| 638 | + | /// Seconds between looks. | |
| 639 | + | #[arg(short, long, default_value_t = 3)] | |
| 640 | + | pub interval: u64, | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | #[derive(Debug, Args)] | |
| 644 | + | pub struct RunLogsArgs { | |
| 645 | + | pub run: Option<String>, | |
| 646 | + | /// Only this job's (its id, key or name). | |
| 647 | + | #[arg(short, long)] | |
| 648 | + | pub job: Option<String>, | |
| 649 | + | /// Only the failing steps, and only their last lines. | |
| 650 | + | #[arg(short, long)] | |
| 651 | + | pub failed: bool, | |
| 652 | + | /// How many lines of each failing step to print, with --failed. | |
| 653 | + | #[arg(short = 'n', long, default_value_t = 60)] | |
| 654 | + | pub tail: usize, | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | #[derive(Debug, Args)] | |
| 658 | + | pub struct RunRerunArgs { | |
| 659 | + | pub run: Option<String>, | |
| 660 | + | /// Only the jobs that failed. | |
| 661 | + | #[arg(long)] | |
| 662 | + | pub failed: bool, | |
| 663 | + | /// Only this job (its id). | |
| 664 | + | #[arg(short, long, conflicts_with = "failed")] | |
| 665 | + | pub job: Option<String>, | |
| 666 | + | /// With debug logging. | |
| 667 | + | #[arg(short, long)] | |
| 668 | + | pub debug: bool, | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // --- secret ----------------------------------------------------------- | |
| 672 | + | ||
| 673 | + | #[derive(Debug, Subcommand)] | |
| 674 | + | pub enum SecretCommand { | |
| 675 | + | /// List secrets' names (never their values). | |
| 676 | + | List(SecretScope), | |
| 677 | + | /// Set a secret. The value is read from stdin when not given. | |
| 678 | + | Set(SecretSetArgs), | |
| 679 | + | /// Delete a secret. | |
| 680 | + | Delete(SecretDeleteArgs), | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | #[derive(Debug, Args)] | |
| 684 | + | pub struct SecretScope { | |
| 685 | + | /// A workspace's secrets instead of the repository's. | |
| 686 | + | #[arg(short, long, value_name = "WORKSPACE")] | |
| 687 | + | pub workspace: Option<String>, | |
| 688 | + | /// Only the value for this environment (production, preview...). | |
| 689 | + | #[arg(short, long, value_name = "ENVIRONMENT")] | |
| 690 | + | pub env: Option<String>, | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | #[derive(Debug, Args)] | |
| 694 | + | pub struct SecretSetArgs { | |
| 695 | + | /// The secret's name; uppercased. | |
| 696 | + | pub name: String, | |
| 697 | + | /// The value. Read from stdin when left out, so it stays out of your shell's history. | |
| 698 | + | #[arg(short, long)] | |
| 699 | + | pub body: Option<String>, | |
| 700 | + | #[command(flatten)] | |
| 701 | + | pub scope: SecretScope, | |
| 702 | + | /// Who can read it: workflows, deployments, or both (repeat). | |
| 703 | + | #[arg(long, value_name = "WHO")] | |
| 704 | + | pub available_to: Vec<String>, | |
| 705 | + | /// Broker it: jobs get a stand-in, and g1t fills the value in only on requests to this host. Repeat for more. | |
| 706 | + | #[arg(long, value_name = "HOST")] | |
| 707 | + | pub brokered_host: Vec<String>, | |
| 708 | + | /// A note for whoever reads the list. | |
| 709 | + | #[arg(long)] | |
| 710 | + | pub note: Option<String>, | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | #[derive(Debug, Args)] | |
| 714 | + | pub struct SecretDeleteArgs { | |
| 715 | + | pub name: String, | |
| 716 | + | #[command(flatten)] | |
| 717 | + | pub scope: SecretScope, | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | // --- deploy ----------------------------------------------------------- | |
| 721 | + | ||
| 722 | + | #[derive(Debug, Subcommand)] | |
| 723 | + | pub enum DeployCommand { | |
| 724 | + | /// List deployments, newest first. | |
| 725 | + | List(DeployListArgs), | |
| 726 | + | /// Show a deployment and its statuses. | |
| 727 | + | View(DeployViewArgs), | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | #[derive(Debug, Args)] | |
| 731 | + | pub struct DeployListArgs { | |
| 732 | + | #[arg(short, long)] | |
| 733 | + | pub env: Option<String>, | |
| 734 | + | /// queued, in_progress, success, failure, error or inactive. | |
| 735 | + | #[arg(short, long)] | |
| 736 | + | pub state: Option<String>, | |
| 737 | + | /// api, actions or g1t_page. | |
| 738 | + | #[arg(long)] | |
| 739 | + | pub source: Option<String>, | |
| 740 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 741 | + | pub limit: usize, | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | #[derive(Debug, Args)] | |
| 745 | + | pub struct DeployViewArgs { | |
| 746 | + | pub id: String, | |
| 747 | + | #[arg(short, long)] | |
| 748 | + | pub web: bool, | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | // --- logs ------------------------------------------------------------- | |
| 752 | + | ||
| 753 | + | #[derive(Debug, Args)] | |
| 754 | + | pub struct LogsArgs { | |
| 755 | + | /// The project; the current repository's when left out. | |
| 756 | + | #[arg(short, long)] | |
| 757 | + | pub project: Option<String>, | |
| 758 | + | /// Grouped errors instead of log lines. | |
| 759 | + | #[arg(long)] | |
| 760 | + | pub errors: bool, | |
| 761 | + | /// Lines holding all of these words, or a "quoted phrase". | |
| 762 | + | #[arg(short, long)] | |
| 763 | + | pub query: Option<String>, | |
| 764 | + | /// Levels: trace, debug, info, warn, error, fatal (comma-separated). | |
| 765 | + | #[arg(short, long)] | |
| 766 | + | pub level: Option<String>, | |
| 767 | + | #[arg(short, long)] | |
| 768 | + | pub env: Option<String>, | |
| 769 | + | #[arg(long)] | |
| 770 | + | pub service: Option<String>, | |
| 771 | + | /// How far back: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. | |
| 772 | + | #[arg(short, long)] | |
| 773 | + | pub since: Option<String>, | |
| 774 | + | /// With --errors: open, resolved or ignored. | |
| 775 | + | #[arg(long)] | |
| 776 | + | pub state: Option<String>, | |
| 777 | + | /// Keep printing new lines as they come. | |
| 778 | + | #[arg(short, long, conflicts_with = "errors")] | |
| 779 | + | pub follow: bool, | |
| 780 | + | #[arg(short = 'L', long, default_value_t = 50)] | |
| 781 | + | pub limit: usize, | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | // --- status, api, browse --------------------------------------------- | |
| 785 | + | ||
| 786 | + | #[derive(Debug, Args)] | |
| 787 | + | pub struct StatusArgs {} | |
| 788 | + | ||
| 789 | + | #[derive(Debug, Args)] | |
| 790 | + | #[command(after_help = "\ | |
| 791 | + | ENDPOINT is an operation's name (list_issues) or a path (/repos/{owner}/{name}/issues). | |
| 792 | + | {owner}, {name}, {repo} and {workspace} are filled in from the current repository, | |
| 793 | + | and an operation's other path parameters from -f/-F fields of the same name. | |
| 794 | + | ||
| 795 | + | Examples: | |
| 796 | + | g1t api list_issues -f state=closed | |
| 797 | + | g1t api /user --jq .username | |
| 798 | + | g1t api create_issue -f title='Broken link' -f labels[]=docs | |
| 799 | + | g1t api list_workflow_runs --paginate --jq '.[].conclusion' | |
| 800 | + | g1t api --list issue")] | |
| 801 | + | pub struct ApiArgs { | |
| 802 | + | /// An operation's name, or a path. | |
| 803 | + | pub endpoint: Option<String>, | |
| 804 | + | /// List the operations, or those matching this word. | |
| 805 | + | #[arg(long, value_name = "QUERY", num_args = 0..=1, default_missing_value = "")] | |
| 806 | + | pub list: Option<String>, | |
| 807 | + | /// The HTTP method: GET unless fields are given, then POST. | |
| 808 | + | #[arg(short = 'X', long)] | |
| 809 | + | pub method: Option<String>, | |
| 810 | + | /// A string field, key=value. key[]=value adds to a list. | |
| 811 | + | #[arg(short = 'f', long = "raw-field", value_name = "KEY=VALUE")] | |
| 812 | + | pub raw_field: Vec<String>, | |
| 813 | + | /// A typed field: true, false, null and numbers become JSON; @file reads a file. | |
| 814 | + | #[arg(short = 'F', long = "field", value_name = "KEY=VALUE")] | |
| 815 | + | pub field: Vec<String>, | |
| 816 | + | /// A request header, key:value. | |
| 817 | + | #[arg(short = 'H', long = "header", value_name = "KEY:VALUE")] | |
| 818 | + | pub header: Vec<String>, | |
| 819 | + | /// The request body, from a file or - for stdin. | |
| 820 | + | #[arg(long, value_name = "FILE")] | |
| 821 | + | pub input: Option<String>, | |
| 822 | + | /// Follow next pages until there are none. | |
| 823 | + | #[arg(long)] | |
| 824 | + | pub paginate: bool, | |
| 825 | + | /// With --paginate, put every page's items into one array. | |
| 826 | + | #[arg(long, requires = "paginate")] | |
| 827 | + | pub slurp: bool, | |
| 828 | + | /// Print the status line and headers too. | |
| 829 | + | #[arg(short, long)] | |
| 830 | + | pub include: bool, | |
| 831 | + | /// Print nothing on success. | |
| 832 | + | #[arg(long)] | |
| 833 | + | pub silent: bool, | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | #[derive(Debug, Args)] | |
| 837 | + | pub struct BrowseArgs { | |
| 838 | + | /// An issue or pull request number, a file path (path:line), or a commit. | |
| 839 | + | pub target: Option<String>, | |
| 840 | + | /// The branch to show files on. | |
| 841 | + | #[arg(short, long)] | |
| 842 | + | pub branch: Option<String>, | |
| 843 | + | #[arg(short, long)] | |
| 844 | + | pub settings: bool, | |
| 845 | + | #[arg(long)] | |
| 846 | + | pub issues: bool, | |
| 847 | + | #[arg(long)] | |
| 848 | + | pub pulls: bool, | |
| 849 | + | #[arg(long)] | |
| 850 | + | pub actions: bool, | |
| 851 | + | /// Print the address instead of opening it. | |
| 852 | + | #[arg(short, long)] | |
| 853 | + | pub no_browser: bool, | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | // --- alias, completion, push ----------------------------------------- | |
| 857 | + | ||
| 858 | + | #[derive(Debug, Subcommand)] | |
| 859 | + | pub enum AliasCommand { | |
| 860 | + | /// Make an alias: g1t alias set co 'pr checkout'. Start it with ! to run it through the shell. | |
| 861 | + | Set(AliasSetArgs), | |
| 862 | + | /// List aliases. | |
| 863 | + | List, | |
| 864 | + | /// Delete an alias. | |
| 865 | + | Delete(AliasDeleteArgs), | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | #[derive(Debug, Args)] | |
| 869 | + | pub struct AliasSetArgs { | |
| 870 | + | pub name: String, | |
| 871 | + | /// What it expands to. $1, $2... take the arguments after it. | |
| 872 | + | pub expansion: String, | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | #[derive(Debug, Args)] | |
| 876 | + | pub struct AliasDeleteArgs { | |
| 877 | + | pub name: String, | |
| 878 | + | } | |
| 879 | + | ||
| 880 | + | #[derive(Debug, Args)] | |
| 881 | + | pub struct CompletionArgs { | |
| 882 | + | #[arg(value_enum)] | |
| 883 | + | pub shell: clap_complete::Shell, | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | #[derive(Debug, Args)] | |
| 887 | + | #[command(after_help = "\ | |
| 888 | + | The token is the first of: --token-stdin, G1T_TOKEN, the account `g1t auth login` | |
| 889 | + | signed in for the registry's host, or what `docker login g1t.sh` stored.")] | |
| 890 | + | pub struct PushArgs { | |
| 891 | + | /// The local image, [REGISTRY/]NAME[:TAG]. | |
| 892 | + | pub image: Option<String>, | |
| 893 | + | /// Where to push, when the image's own name is not an address on g1t.sh. The tag defaults to latest. | |
| 894 | + | #[arg(long = "as", value_name = "ADDRESS")] | |
| 895 | + | pub target: Option<String>, | |
| 896 | + | /// How much of a layer each request carries: 5MB to 95MB. | |
| 897 | + | #[arg(long, value_name = "SIZE", default_value = "90MB", value_parser = parse_chunk)] | |
| 898 | + | pub chunk_size: u64, | |
| 899 | + | /// Read the g1t token from stdin. | |
| 900 | + | #[arg(long)] | |
| 901 | + | pub token_stdin: bool, | |
| 902 | + | /// Push a tarball `docker save` wrote, instead of asking docker for the image. | |
| 903 | + | #[arg(long, value_name = "FILE", conflicts_with = "image")] | |
| 904 | + | pub archive: Option<PathBuf>, | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | fn parse_chunk(text: &str) -> Result<u64, String> { | |
| 908 | + | crate::chunk::check_chunk(crate::chunk::parse_size(text)?) | |
| 909 | + | } |
| 1 | + | //! Calls to the g1t API. | |
| 2 | + | ||
| 3 | + | use serde_json::{Map, Value}; | |
| 4 | + | use std::collections::BTreeMap; | |
| 5 | + | use std::io::Read; | |
| 6 | + | use std::time::Duration; | |
| 7 | + | ||
| 8 | + | use crate::auth::Host; | |
| 9 | + | use crate::error::{Error, Result}; | |
| 10 | + | use crate::ops::{self, Operation}; | |
| 11 | + | use crate::repo::Repo; | |
| 12 | + | ||
| 13 | + | pub struct Client { | |
| 14 | + | pub host: Host, | |
| 15 | + | token: Option<String>, | |
| 16 | + | agent: ureq::Agent, | |
| 17 | + | debug: bool, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// What came back from a raw request, for `g1t api`. | |
| 21 | + | pub struct Reply { | |
| 22 | + | pub status: u16, | |
| 23 | + | pub headers: Vec<(String, String)>, | |
| 24 | + | pub text: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl Reply { | |
| 28 | + | pub fn json(&self) -> Option<Value> { | |
| 29 | + | if self.text.trim().is_empty() { | |
| 30 | + | return Some(Value::Null); | |
| 31 | + | } | |
| 32 | + | serde_json::from_str(&self.text).ok() | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | pub fn header(&self, name: &str) -> Option<&str> { | |
| 36 | + | self.headers | |
| 37 | + | .iter() | |
| 38 | + | .find(|(k, _)| k.eq_ignore_ascii_case(name)) | |
| 39 | + | .map(|(_, v)| v.as_str()) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Encodes one segment of a path: a branch's `/` stays inside the segment. | |
| 44 | + | pub fn encode_segment(segment: &str) -> String { | |
| 45 | + | let mut out = String::new(); | |
| 46 | + | for byte in segment.bytes() { | |
| 47 | + | match byte { | |
| 48 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 49 | + | out.push(byte as char) | |
| 50 | + | } | |
| 51 | + | _ => out.push_str(&format!("%{byte:02X}")), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | out | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl Client { | |
| 58 | + | pub fn new(host: Host, token: Option<String>) -> Client { | |
| 59 | + | let agent = ureq::AgentBuilder::new() | |
| 60 | + | .timeout_connect(Duration::from_secs(15)) | |
| 61 | + | .timeout_read(Duration::from_secs(120)) | |
| 62 | + | .user_agent(&format!("g1t-cli/{}", env!("CARGO_PKG_VERSION"))) | |
| 63 | + | .redirects(5) | |
| 64 | + | .build(); | |
| 65 | + | let debug = std::env::var("G1T_DEBUG").is_ok_and(|v| !v.is_empty() && v != "0"); | |
| 66 | + | Client { | |
| 67 | + | host, | |
| 68 | + | token, | |
| 69 | + | agent, | |
| 70 | + | debug, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | pub fn has_token(&self) -> bool { | |
| 75 | + | self.token.is_some() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Starts a call to the operation `id`. | |
| 79 | + | pub fn op(&self, id: &str) -> Call<'_> { | |
| 80 | + | Call { | |
| 81 | + | client: self, | |
| 82 | + | op: ops::get(id), | |
| 83 | + | path: BTreeMap::new(), | |
| 84 | + | query: Vec::new(), | |
| 85 | + | body: None, | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// A request to `path` (under the API), answered with whatever status. | |
| 90 | + | pub fn raw( | |
| 91 | + | &self, | |
| 92 | + | method: &str, | |
| 93 | + | path_or_url: &str, | |
| 94 | + | query: &[(String, String)], | |
| 95 | + | headers: &[(String, String)], | |
| 96 | + | body: Option<Vec<u8>>, | |
| 97 | + | ) -> Result<Reply> { | |
| 98 | + | let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { | |
| 99 | + | path_or_url.to_owned() | |
| 100 | + | } else { | |
| 101 | + | format!("{}/{}", self.host.api, path_or_url.trim_start_matches('/')) | |
| 102 | + | }; | |
| 103 | + | let mut request = self | |
| 104 | + | .agent | |
| 105 | + | .request(method, &url) | |
| 106 | + | .set("accept", "application/json"); | |
| 107 | + | if let Some(token) = &self.token { | |
| 108 | + | request = request.set("authorization", &format!("Bearer {token}")); | |
| 109 | + | } | |
| 110 | + | for (k, v) in query { | |
| 111 | + | request = request.query(k, v); | |
| 112 | + | } | |
| 113 | + | let has_type = headers | |
| 114 | + | .iter() | |
| 115 | + | .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); | |
| 116 | + | for (k, v) in headers { | |
| 117 | + | request = request.set(k, v); | |
| 118 | + | } | |
| 119 | + | if self.debug { | |
| 120 | + | eprintln!("* {method} {}", request.url()); | |
| 121 | + | } | |
| 122 | + | let result = match body { | |
| 123 | + | Some(bytes) => { | |
| 124 | + | if !has_type { | |
| 125 | + | request = request.set("content-type", "application/json"); | |
| 126 | + | } | |
| 127 | + | request.send_bytes(&bytes) | |
| 128 | + | } | |
| 129 | + | None => request.call(), | |
| 130 | + | }; | |
| 131 | + | let response = match result { | |
| 132 | + | Ok(r) => r, | |
| 133 | + | Err(ureq::Error::Status(_, r)) => r, | |
| 134 | + | Err(ureq::Error::Transport(t)) => { | |
| 135 | + | return Err(Error::failed(format!( | |
| 136 | + | "Could not reach {}: {t}", | |
| 137 | + | self.host.api | |
| 138 | + | ))); | |
| 139 | + | } | |
| 140 | + | }; | |
| 141 | + | let status = response.status(); | |
| 142 | + | let headers = response | |
| 143 | + | .headers_names() | |
| 144 | + | .into_iter() | |
| 145 | + | .filter_map(|name| response.header(&name).map(|v| (name.clone(), v.to_owned()))) | |
| 146 | + | .collect(); | |
| 147 | + | let mut text = String::new(); | |
| 148 | + | response | |
| 149 | + | .into_reader() | |
| 150 | + | .take(256 * 1024 * 1024) | |
| 151 | + | .read_to_string(&mut text) | |
| 152 | + | .map_err(|e| { | |
| 153 | + | Error::failed(format!( | |
| 154 | + | "Could not read the answer from {}: {e}", | |
| 155 | + | self.host.api | |
| 156 | + | )) | |
| 157 | + | })?; | |
| 158 | + | if self.debug { | |
| 159 | + | eprintln!("* {status}"); | |
| 160 | + | } | |
| 161 | + | Ok(Reply { | |
| 162 | + | status, | |
| 163 | + | headers, | |
| 164 | + | text, | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// A request whose answer must be a success, as JSON. | |
| 169 | + | pub fn send( | |
| 170 | + | &self, | |
| 171 | + | method: &str, | |
| 172 | + | path: &str, | |
| 173 | + | query: &[(String, String)], | |
| 174 | + | body: Option<&Value>, | |
| 175 | + | ) -> Result<Value> { | |
| 176 | + | let bytes = body.map(|b| serde_json::to_vec(b).expect("json")); | |
| 177 | + | let reply = self.raw(method, path, query, &[], bytes)?; | |
| 178 | + | if !(200..300).contains(&reply.status) { | |
| 179 | + | return Err(self.api_error(&reply)); | |
| 180 | + | } | |
| 181 | + | Ok(reply.json().unwrap_or(Value::String(reply.text))) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// One line saying why the API refused. | |
| 185 | + | pub fn api_error(&self, reply: &Reply) -> Error { | |
| 186 | + | let body = reply.json().unwrap_or(Value::Null); | |
| 187 | + | let message = body["error"]["message"] | |
| 188 | + | .as_str() | |
| 189 | + | .or_else(|| body["message"].as_str()) | |
| 190 | + | .map(str::to_owned) | |
| 191 | + | .unwrap_or_else(|| { | |
| 192 | + | let text = reply.text.trim(); | |
| 193 | + | if text.is_empty() || text.starts_with('<') { | |
| 194 | + | format!("{} answered HTTP {}", self.host.api, reply.status) | |
| 195 | + | } else { | |
| 196 | + | text.lines().next().unwrap_or_default().to_owned() | |
| 197 | + | } | |
| 198 | + | }); | |
| 199 | + | let message = message.trim_end_matches('.').to_owned(); | |
| 200 | + | match reply.status { | |
| 201 | + | 401 if self.token.is_none() => Error::auth(format!( | |
| 202 | + | "{message}. Sign in with `g1t auth login{}`, or set G1T_TOKEN.", | |
| 203 | + | self.hostname_flag() | |
| 204 | + | )), | |
| 205 | + | 401 => Error::auth(format!( | |
| 206 | + | "{message}. The token for {} was refused: sign in again with `g1t auth login{}`.", | |
| 207 | + | self.host.name, | |
| 208 | + | self.hostname_flag() | |
| 209 | + | )), | |
| 210 | + | 403 => match body["error"]["needed_scope"].as_str() { | |
| 211 | + | Some(scope) => Error::failed(format!( | |
| 212 | + | "{message}. The token needs the `{scope}` scope: make one with it at {}/settings/tokens.", | |
| 213 | + | self.host.site | |
| 214 | + | )), | |
| 215 | + | None => Error::failed(format!("{message}.")), | |
| 216 | + | }, | |
| 217 | + | 404 if self.token.is_none() => Error::failed(format!( | |
| 218 | + | "{message}. If it is private, sign in first: `g1t auth login{}`.", | |
| 219 | + | self.hostname_flag() | |
| 220 | + | )), | |
| 221 | + | _ => Error::failed(format!("{message}.")), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | fn hostname_flag(&self) -> String { | |
| 226 | + | if self.host.name == crate::config::DEFAULT_HOST { | |
| 227 | + | String::new() | |
| 228 | + | } else { | |
| 229 | + | format!(" --hostname {}", self.host.name) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// One call to an operation, its path filled in as it is built. | |
| 235 | + | pub struct Call<'a> { | |
| 236 | + | client: &'a Client, | |
| 237 | + | op: &'static Operation, | |
| 238 | + | path: BTreeMap<&'static str, String>, | |
| 239 | + | query: Vec<(String, String)>, | |
| 240 | + | body: Option<Map<String, Value>>, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | impl<'a> Call<'a> { | |
| 244 | + | pub fn arg(mut self, name: &str, value: impl ToString) -> Self { | |
| 245 | + | let param = self | |
| 246 | + | .op | |
| 247 | + | .path_params() | |
| 248 | + | .find(|p| p.name == name) | |
| 249 | + | .unwrap_or_else(|| panic!("{} has no path parameter {name}", self.op.id)); | |
| 250 | + | self.path.insert(param.name, value.to_string()); | |
| 251 | + | self | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// The repository's owner and name. | |
| 255 | + | pub fn repo(self, repo: &Repo) -> Self { | |
| 256 | + | self.arg("owner", &repo.owner).arg("name", &repo.name) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | pub fn query(mut self, name: &str, value: impl ToString) -> Self { | |
| 260 | + | self.query.push((name.to_owned(), value.to_string())); | |
| 261 | + | self | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | pub fn query_opt<T: ToString>(self, name: &str, value: Option<T>) -> Self { | |
| 265 | + | match value { | |
| 266 | + | Some(v) => self.query(name, v), | |
| 267 | + | None => self, | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | pub fn field(mut self, name: &str, value: impl Into<Value>) -> Self { | |
| 272 | + | self.body | |
| 273 | + | .get_or_insert_with(Map::new) | |
| 274 | + | .insert(name.to_owned(), value.into()); | |
| 275 | + | self | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | pub fn field_opt<T: Into<Value>>(self, name: &str, value: Option<T>) -> Self { | |
| 279 | + | match value { | |
| 280 | + | Some(v) => self.field(name, v), | |
| 281 | + | None => self, | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | pub fn path(&self) -> String { | |
| 286 | + | let mut path = self.op.path.to_owned(); | |
| 287 | + | for p in self.op.path_params() { | |
| 288 | + | let value = self | |
| 289 | + | .path | |
| 290 | + | .get(p.name) | |
| 291 | + | .unwrap_or_else(|| panic!("{} needs {}", self.op.id, p.name)); | |
| 292 | + | path = path.replace(&format!("{{{}}}", p.name), &encode_segment(value)); | |
| 293 | + | } | |
| 294 | + | path | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | pub fn send(self) -> Result<Value> { | |
| 298 | + | let path = self.path(); | |
| 299 | + | let body = match (self.body, self.op.method) { | |
| 300 | + | (Some(map), _) => Some(Value::Object(map)), | |
| 301 | + | (None, "POST" | "PUT" | "PATCH") => Some(Value::Object(Map::new())), | |
| 302 | + | (None, _) => None, | |
| 303 | + | }; | |
| 304 | + | self.client | |
| 305 | + | .send(self.op.method, &path, &self.query, body.as_ref()) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[cfg(test)] | |
| 310 | + | mod tests { | |
| 311 | + | use super::*; | |
| 312 | + | ||
| 313 | + | #[test] | |
| 314 | + | fn segments_are_encoded() { | |
| 315 | + | assert_eq!(encode_segment("feature/x y"), "feature%2Fx%20y"); | |
| 316 | + | assert_eq!(encode_segment("NPM_TOKEN"), "NPM_TOKEN"); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn calls_fill_their_path() { | |
| 321 | + | let client = Client::new(Host::hosted(), None); | |
| 322 | + | let repo = Repo { | |
| 323 | + | host: "g1t.sh".into(), | |
| 324 | + | owner: "acme".into(), | |
| 325 | + | name: "web".into(), | |
| 326 | + | }; | |
| 327 | + | let call = client.op("get_issue").repo(&repo).arg("number", 12); | |
| 328 | + | assert_eq!(call.path(), "/repos/acme/web/issues/12"); | |
| 329 | + | } | |
| 330 | + | } |
| 1 | + | //! `g1t alias`: shortcuts, kept in config.json and expanded before the | |
| 2 | + | //! command line is read. | |
| 3 | + | ||
| 4 | + | use clap::CommandFactory; | |
| 5 | + | ||
| 6 | + | use crate::cli::{AliasCommand, Cli}; | |
| 7 | + | use crate::config::Config; | |
| 8 | + | use crate::error::{Error, Kind, Result}; | |
| 9 | + | ||
| 10 | + | pub enum Expanded { | |
| 11 | + | Args(Vec<String>), | |
| 12 | + | /// A `!` alias ran in the shell, with this exit code. | |
| 13 | + | Ran(i32), | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | fn builtin(name: &str) -> bool { | |
| 17 | + | Cli::command() | |
| 18 | + | .get_subcommands() | |
| 19 | + | .any(|c| c.get_name() == name || c.get_all_aliases().any(|a| a == name)) | |
| 20 | + | || matches!(name, "help") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Splits `text` into words the way a shell would, quotes included. | |
| 24 | + | pub fn split(text: &str) -> Result<Vec<String>> { | |
| 25 | + | let mut words = Vec::new(); | |
| 26 | + | let mut word = String::new(); | |
| 27 | + | let mut in_word = false; | |
| 28 | + | let mut chars = text.chars(); | |
| 29 | + | let mut quote: Option<char> = None; | |
| 30 | + | while let Some(c) = chars.next() { | |
| 31 | + | match (quote, c) { | |
| 32 | + | (Some(q), c) if c == q => quote = None, | |
| 33 | + | (Some('"'), '\\') => { | |
| 34 | + | if let Some(next) = chars.next() { | |
| 35 | + | word.push(next); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | (Some(_), c) => word.push(c), | |
| 39 | + | (None, '\'' | '"') => { | |
| 40 | + | quote = Some(c); | |
| 41 | + | in_word = true; | |
| 42 | + | } | |
| 43 | + | (None, '\\') => { | |
| 44 | + | if let Some(next) = chars.next() { | |
| 45 | + | word.push(next); | |
| 46 | + | in_word = true; | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | (None, c) if c.is_whitespace() => { | |
| 50 | + | if in_word { | |
| 51 | + | words.push(std::mem::take(&mut word)); | |
| 52 | + | in_word = false; | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | (None, c) => { | |
| 56 | + | word.push(c); | |
| 57 | + | in_word = true; | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | if quote.is_some() { | |
| 62 | + | return Err(Error::usage(format!("Unclosed quote in `{text}`."))); | |
| 63 | + | } | |
| 64 | + | if in_word { | |
| 65 | + | words.push(word); | |
| 66 | + | } | |
| 67 | + | Ok(words) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Puts `args` into `expansion`'s `$1`, `$2`...; the ones not used go after. | |
| 71 | + | pub fn substitute(expansion: &[String], args: &[String]) -> Vec<String> { | |
| 72 | + | let mut used = vec![false; args.len()]; | |
| 73 | + | let mut out: Vec<String> = expansion | |
| 74 | + | .iter() | |
| 75 | + | .map(|word| { | |
| 76 | + | let mut word = word.clone(); | |
| 77 | + | for (i, arg) in args.iter().enumerate().rev() { | |
| 78 | + | let marker = format!("${}", i + 1); | |
| 79 | + | if word.contains(&marker) { | |
| 80 | + | word = word.replace(&marker, arg); | |
| 81 | + | used[i] = true; | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | word | |
| 85 | + | }) | |
| 86 | + | .collect(); | |
| 87 | + | out.extend( | |
| 88 | + | args.iter() | |
| 89 | + | .zip(&used) | |
| 90 | + | .filter(|(_, u)| !**u) | |
| 91 | + | .map(|(a, _)| a.clone()), | |
| 92 | + | ); | |
| 93 | + | out | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | pub fn expand(args: Vec<String>) -> Result<Expanded> { | |
| 97 | + | let Some(first) = args.get(1) else { | |
| 98 | + | return Ok(Expanded::Args(args)); | |
| 99 | + | }; | |
| 100 | + | if first.starts_with('-') || builtin(first) { | |
| 101 | + | return Ok(Expanded::Args(args)); | |
| 102 | + | } | |
| 103 | + | let config = Config::load()?; | |
| 104 | + | let Some(expansion) = config.aliases.get(first) else { | |
| 105 | + | return Ok(Expanded::Args(args)); | |
| 106 | + | }; | |
| 107 | + | let rest = &args[2..]; | |
| 108 | + | if let Some(shell_line) = expansion.strip_prefix('!') { | |
| 109 | + | let shell_args = substitute(&[shell_line.to_owned()], rest); | |
| 110 | + | let line = shell_args.join(" "); | |
| 111 | + | let status = std::process::Command::new("sh") | |
| 112 | + | .args(["-c", &line]) | |
| 113 | + | .status() | |
| 114 | + | .map_err(|e| Error::failed(format!("Could not run the alias {first} in sh: {e}")))?; | |
| 115 | + | return Ok(Expanded::Ran(status.code().unwrap_or(1))); | |
| 116 | + | } | |
| 117 | + | let words = split(expansion)?; | |
| 118 | + | let mut out = vec![args[0].clone()]; | |
| 119 | + | out.extend(substitute(&words, rest)); | |
| 120 | + | Ok(Expanded::Args(out)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | pub fn run(command: AliasCommand) -> Result<()> { | |
| 124 | + | let mut config = Config::load()?; | |
| 125 | + | match command { | |
| 126 | + | AliasCommand::Set(args) => { | |
| 127 | + | if builtin(&args.name) { | |
| 128 | + | return Err(Error::usage(format!( | |
| 129 | + | "`{}` is already a g1t command.", | |
| 130 | + | args.name | |
| 131 | + | ))); | |
| 132 | + | } | |
| 133 | + | if !args.expansion.starts_with('!') { | |
| 134 | + | let words = split(&args.expansion)?; | |
| 135 | + | match words.first() { | |
| 136 | + | Some(first) if builtin(first) || config.aliases.contains_key(first) => {} | |
| 137 | + | _ => { | |
| 138 | + | return Err(Error::usage(format!( | |
| 139 | + | "`{}` does not start with a g1t command. Start it with ! to run it in the shell.", | |
| 140 | + | args.expansion | |
| 141 | + | ))); | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | let replaced = config | |
| 146 | + | .aliases | |
| 147 | + | .insert(args.name.clone(), args.expansion.clone()) | |
| 148 | + | .is_some(); | |
| 149 | + | config.save()?; | |
| 150 | + | eprintln!( | |
| 151 | + | "✓ {} {} now runs `g1t {}`.", | |
| 152 | + | if replaced { "Changed:" } else { "Added:" }, | |
| 153 | + | args.name, | |
| 154 | + | args.expansion | |
| 155 | + | ); | |
| 156 | + | Ok(()) | |
| 157 | + | } | |
| 158 | + | AliasCommand::List => { | |
| 159 | + | if config.aliases.is_empty() { | |
| 160 | + | eprintln!("No aliases. Add one: g1t alias set co 'pr checkout'"); | |
| 161 | + | return Ok(()); | |
| 162 | + | } | |
| 163 | + | for (name, expansion) in &config.aliases { | |
| 164 | + | println!("{name}: {expansion}"); | |
| 165 | + | } | |
| 166 | + | Ok(()) | |
| 167 | + | } | |
| 168 | + | AliasCommand::Delete(args) => { | |
| 169 | + | if config.aliases.remove(&args.name).is_none() { | |
| 170 | + | return Err(Error { | |
| 171 | + | kind: Kind::Failed, | |
| 172 | + | message: format!("No alias {}.", args.name), | |
| 173 | + | silent: false, | |
| 174 | + | }); | |
| 175 | + | } | |
| 176 | + | config.save()?; | |
| 177 | + | eprintln!("✓ Deleted the alias {}.", args.name); | |
| 178 | + | Ok(()) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[cfg(test)] | |
| 184 | + | mod tests { | |
| 185 | + | use super::*; | |
| 186 | + | ||
| 187 | + | fn v(words: &[&str]) -> Vec<String> { | |
| 188 | + | words.iter().map(|s| s.to_string()).collect() | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | #[test] | |
| 192 | + | fn words_split_like_a_shell() { | |
| 193 | + | assert_eq!(split("pr checkout").unwrap(), v(&["pr", "checkout"])); | |
| 194 | + | assert_eq!( | |
| 195 | + | split(r#"issue list --label "good first issue""#).unwrap(), | |
| 196 | + | v(&["issue", "list", "--label", "good first issue"]) | |
| 197 | + | ); | |
| 198 | + | assert_eq!( | |
| 199 | + | split("api '/repos/{owner}/{name}'").unwrap(), | |
| 200 | + | v(&["api", "/repos/{owner}/{name}"]) | |
| 201 | + | ); | |
| 202 | + | assert_eq!(split(r"a\ b c").unwrap(), v(&["a b", "c"])); | |
| 203 | + | assert_eq!(split("x ''").unwrap(), v(&["x", ""])); | |
| 204 | + | assert!(split("'open").is_err()); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn arguments_fill_their_places() { | |
| 209 | + | assert_eq!( | |
| 210 | + | substitute(&v(&["pr", "checkout"]), &v(&["12"])), | |
| 211 | + | v(&["pr", "checkout", "12"]) | |
| 212 | + | ); | |
| 213 | + | assert_eq!( | |
| 214 | + | substitute( | |
| 215 | + | &v(&["issue", "list", "--label", "$1"]), | |
| 216 | + | &v(&["bug", "-L", "5"]) | |
| 217 | + | ), | |
| 218 | + | v(&["issue", "list", "--label", "bug", "-L", "5"]) | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[test] | |
| 223 | + | fn commands_are_builtin() { | |
| 224 | + | assert!(builtin("pr")); | |
| 225 | + | assert!(builtin("help")); | |
| 226 | + | assert!(!builtin("co")); | |
| 227 | + | } | |
| 228 | + | } |
Comments
Sign in to comment.