From 5b5b1c7435a6bb00d1efeab67fc606d14e96c680 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 12:53:23 -0400 Subject: [PATCH 1/8] docs: draft core library split design specification (#2) --- .../2026-07-20-core-library-split-design.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-core-library-split-design.md diff --git a/docs/superpowers/specs/2026-07-20-core-library-split-design.md b/docs/superpowers/specs/2026-07-20-core-library-split-design.md new file mode 100644 index 0000000..917bbcf --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-core-library-split-design.md @@ -0,0 +1,25 @@ +# Design Specification: Core Library Split (Ref #2) + +## Project Goals +Separate the core logic and infrastructure of `gor` into a dedicated library crate (`gor-core`) to improve modularity, testability, and clear separation of concerns between business logic and CLI presentation. + +## Proposed Structure +1. **gor-core**: A library crate containing: + - Network communication (HTTP/REST) via `reqwest`. + - Core data types and error handling (using `thiserror`). + - Keyring management for authentication tokens. + - Configuration logic and validation. +2. **gor** (CLI): The command-line interface wrapper. It will: + - Parse CLI arguments with `clap`. + - Provide progress reporting (`indicatif`) and pretty formatting (`console`/`eyre`). + - Act as a thin layer that maps input to `gor-core` calls and decodes results for display. + +## Design Decisions & Constraints (Ref #2) +- **Stability**: The CLI must remain behaviorally identical. No new flags or changes to existing output formatting are permitted during this refactor. +- **Core Clarity**: Core logic in `gor-core` must not depend on any UI/CLI libraries (`clap`, `indicatif`, etc.). +- **Type Safety**: Replace raw `serde_json::Value` usage inside the core with strongly typed models where possible. + +## Roadmap +1. **Phase 1: Infrastructure Migration**. Move common files (`client`, `host`, `error`, `config`) to `gor-core`. +2. **Phase 2: Feature Extraction**. Iteratively move command logic (e.g., `label`, `issue`, `pr`) into the core library, ensuring they are fully testable in isolation. +3. **Phase 3: Final Cleanup**. Refine internal helper utilities and publish both crates. From 26e8b186369171070f1681245c48fb0e430c15e9 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 13:14:07 -0400 Subject: [PATCH 2/8] build: split workspace into gor-core + gor crates --- Cargo.lock | 18 +++++- Cargo.toml | 59 +------------------ crates/gor-core/Cargo.toml | 33 +++++++++++ {src => crates/gor-core/src}/auth/device.rs | 0 {src => crates/gor-core/src}/auth/mod.rs | 0 {src => crates/gor-core/src}/auth/token.rs | 6 +- {src => crates/gor-core/src}/client.rs | 4 +- {src => crates/gor-core/src}/config.rs | 0 {src => crates/gor-core/src}/error.rs | 0 {src => crates/gor-core/src}/host.rs | 10 ++-- {src => crates/gor-core/src}/keyring_store.rs | 0 crates/gor-core/src/lib.rs | 21 +++++++ {src => crates/gor-core/src}/repository.rs | 41 +++++++++---- crates/gor/Cargo.toml | 44 ++++++++++++++ {src => crates/gor/src}/cli.rs | 0 {src => crates/gor/src}/cmd/alias.rs | 2 +- {src => crates/gor/src}/cmd/api.rs | 2 +- {src => crates/gor/src}/cmd/attestation.rs | 4 +- {src => crates/gor/src}/cmd/auth.rs | 12 ++-- {src => crates/gor/src}/cmd/browse.rs | 2 +- {src => crates/gor/src}/cmd/cache.rs | 6 +- {src => crates/gor/src}/cmd/classroom.rs | 4 +- {src => crates/gor/src}/cmd/codespace.rs | 4 +- {src => crates/gor/src}/cmd/completion.rs | 0 {src => crates/gor/src}/cmd/config.rs | 2 +- {src => crates/gor/src}/cmd/copilot.rs | 4 +- {src => crates/gor/src}/cmd/extension.rs | 4 +- {src => crates/gor/src}/cmd/gist.rs | 6 +- {src => crates/gor/src}/cmd/issue.rs | 6 +- {src => crates/gor/src}/cmd/keys.rs | 4 +- {src => crates/gor/src}/cmd/label.rs | 6 +- {src => crates/gor/src}/cmd/mod.rs | 0 {src => crates/gor/src}/cmd/org.rs | 4 +- {src => crates/gor/src}/cmd/pr.rs | 6 +- {src => crates/gor/src}/cmd/project.rs | 6 +- {src => crates/gor/src}/cmd/release.rs | 6 +- {src => crates/gor/src}/cmd/repo.rs | 6 +- {src => crates/gor/src}/cmd/ruleset.rs | 6 +- {src => crates/gor/src}/cmd/run.rs | 6 +- {src => crates/gor/src}/cmd/search.rs | 4 +- {src => crates/gor/src}/cmd/secret.rs | 6 +- {src => crates/gor/src}/cmd/util.rs | 0 {src => crates/gor/src}/cmd/variable.rs | 6 +- {src => crates/gor/src}/cmd/workflow.rs | 8 +-- {src => crates/gor/src}/lib.rs | 9 +-- {src => crates/gor/src}/main.rs | 0 src/output.rs => crates/gor/src/render.rs | 6 +- 47 files changed, 223 insertions(+), 160 deletions(-) create mode 100644 crates/gor-core/Cargo.toml rename {src => crates/gor-core/src}/auth/device.rs (100%) rename {src => crates/gor-core/src}/auth/mod.rs (100%) rename {src => crates/gor-core/src}/auth/token.rs (95%) rename {src => crates/gor-core/src}/client.rs (99%) rename {src => crates/gor-core/src}/config.rs (100%) rename {src => crates/gor-core/src}/error.rs (100%) rename {src => crates/gor-core/src}/host.rs (96%) rename {src => crates/gor-core/src}/keyring_store.rs (100%) create mode 100644 crates/gor-core/src/lib.rs rename {src => crates/gor-core/src}/repository.rs (88%) create mode 100644 crates/gor/Cargo.toml rename {src => crates/gor/src}/cli.rs (100%) rename {src => crates/gor/src}/cmd/alias.rs (99%) rename {src => crates/gor/src}/cmd/api.rs (99%) rename {src => crates/gor/src}/cmd/attestation.rs (98%) rename {src => crates/gor/src}/cmd/auth.rs (97%) rename {src => crates/gor/src}/cmd/browse.rs (97%) rename {src => crates/gor/src}/cmd/cache.rs (98%) rename {src => crates/gor/src}/cmd/classroom.rs (98%) rename {src => crates/gor/src}/cmd/codespace.rs (99%) rename {src => crates/gor/src}/cmd/completion.rs (100%) rename {src => crates/gor/src}/cmd/config.rs (98%) rename {src => crates/gor/src}/cmd/copilot.rs (98%) rename {src => crates/gor/src}/cmd/extension.rs (98%) rename {src => crates/gor/src}/cmd/gist.rs (99%) rename {src => crates/gor/src}/cmd/issue.rs (99%) rename {src => crates/gor/src}/cmd/keys.rs (99%) rename {src => crates/gor/src}/cmd/label.rs (99%) rename {src => crates/gor/src}/cmd/mod.rs (100%) rename {src => crates/gor/src}/cmd/org.rs (98%) rename {src => crates/gor/src}/cmd/pr.rs (99%) rename {src => crates/gor/src}/cmd/project.rs (99%) rename {src => crates/gor/src}/cmd/release.rs (99%) rename {src => crates/gor/src}/cmd/repo.rs (99%) rename {src => crates/gor/src}/cmd/ruleset.rs (97%) rename {src => crates/gor/src}/cmd/run.rs (99%) rename {src => crates/gor/src}/cmd/search.rs (99%) rename {src => crates/gor/src}/cmd/secret.rs (98%) rename {src => crates/gor/src}/cmd/util.rs (100%) rename {src => crates/gor/src}/cmd/variable.rs (98%) rename {src => crates/gor/src}/cmd/workflow.rs (98%) rename {src => crates/gor/src}/lib.rs (93%) rename {src => crates/gor/src}/main.rs (100%) rename src/output.rs => crates/gor/src/render.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 7e12eec..aff1128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1517,18 +1517,32 @@ dependencies = [ "console", "dirs", "gix", + "gor-core", "indicatif", - "keyring", "miette", "reqwest 0.12.28", "serde", "serde_json", "serde_yaml_ng", - "thiserror 2.0.18", "tracing", "tracing-subscriber", ] +[[package]] +name = "gor-core" +version = "0.1.0" +dependencies = [ + "dirs", + "gix", + "keyring", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml_ng", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "h2" version = "0.4.15" diff --git a/Cargo.toml b/Cargo.toml index 2fd2982..e19d8f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["."] +members = ["crates/*"] resolver = "2" [workspace.package] @@ -51,63 +51,6 @@ too_many_lines = "allow" [workspace.metadata.cargo-shear] ignored = ["dirs", "gix", "miette", "indicatif", "console"] -# Package name is `gor-cli` because the `gor` name was already taken on -# crates.io (an unrelated VCS crate). The binary and lib are still named `gor` -# below, so users get a `gor` command and `use gor::` keeps working. -# ponytail: rename over vendoring/forking — crates.io has no namespacing. -[package] -name = "gor-cli" -version = { workspace = true } -edition = { workspace = true } -rust-version = { workspace = true } -license = { workspace = true } -repository = { workspace = true } -documentation = { workspace = true } -homepage = { workspace = true } -description = "A Rust CLI for GitHub — a 'gh' clone" -readme = "README.md" -keywords = ["github", "cli", "gh", "git"] -categories = ["command-line-utilities", "development-tools"] - -[lib] -name = "gor" - -[[bin]] -name = "gor" -path = "src/main.rs" - -[lints] -workspace = true - -[features] -default = [] -keyring = ["dep:keyring"] - -[dependencies] -clap = { workspace = true } -clap_complete = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml_ng = { workspace = true } -keyring = { workspace = true, optional = true } -thiserror = { workspace = true } -dirs = { workspace = true } -gix = { workspace = true } -anyhow = { workspace = true } -miette = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -indicatif = { workspace = true } -console = { workspace = true } - -[dev-dependencies] -# Additional test dependencies for integration tests: -# - assert_cmd, assert_fs: CLI integration testing -# - insta: snapshot testing -# - tempfile: temporary file/directory fixtures -# - wiremock: HTTP mocking for GitHub API - [profile.release] opt-level = "z" lto = true diff --git a/crates/gor-core/Cargo.toml b/crates/gor-core/Cargo.toml new file mode 100644 index 0000000..11e6848 --- /dev/null +++ b/crates/gor-core/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "gor-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Core library for the gor GitHub CLI — typed operations and models" +readme = "README.md" +keywords = ["github", "api", "gh", "git"] +categories = ["api-bindings", "web-programming::http-client"] + +[lib] +name = "gor_core" + +[lints] +workspace = true + +[features] +default = [] +keyring = ["dep:keyring"] + +[dependencies] +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } +thiserror = { workspace = true } +dirs = { workspace = true } +gix = { workspace = true } +tracing = { workspace = true } +keyring = { workspace = true, optional = true } diff --git a/src/auth/device.rs b/crates/gor-core/src/auth/device.rs similarity index 100% rename from src/auth/device.rs rename to crates/gor-core/src/auth/device.rs diff --git a/src/auth/mod.rs b/crates/gor-core/src/auth/mod.rs similarity index 100% rename from src/auth/mod.rs rename to crates/gor-core/src/auth/mod.rs diff --git a/src/auth/token.rs b/crates/gor-core/src/auth/token.rs similarity index 95% rename from src/auth/token.rs rename to crates/gor-core/src/auth/token.rs index 79e387a..574046d 100644 --- a/src/auth/token.rs +++ b/crates/gor-core/src/auth/token.rs @@ -25,8 +25,8 @@ pub struct UserResponse { /// # Examples /// /// ```no_run -/// use gor::auth::token::verify_token; -/// use gor::host::Host; +/// use gor_core::auth::token::verify_token; +/// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// let login = verify_token(&host, "gho_abc123").unwrap(); @@ -81,7 +81,7 @@ pub fn verify_token(host: &Host, token: &str) -> Result { /// # Examples /// /// ```no_run -/// use gor::auth::token::read_token_from_stdin; +/// use gor_core::auth::token::read_token_from_stdin; /// /// let token = read_token_from_stdin().unwrap(); /// ``` diff --git a/src/client.rs b/crates/gor-core/src/client.rs similarity index 99% rename from src/client.rs rename to crates/gor-core/src/client.rs index 322772f..93cfb04 100644 --- a/src/client.rs +++ b/crates/gor-core/src/client.rs @@ -30,7 +30,7 @@ impl Client { /// # Examples /// /// ```no_run - /// use gor::client::Client; + /// use gor_core::client::Client; /// /// let client = Client::new("github.com").unwrap(); /// ``` @@ -53,7 +53,7 @@ impl Client { /// # Examples /// /// ```no_run - /// use gor::client::Client; + /// use gor_core::client::Client; /// /// let client = Client::with_token("github.com", "gho_abc123").unwrap(); /// ``` diff --git a/src/config.rs b/crates/gor-core/src/config.rs similarity index 100% rename from src/config.rs rename to crates/gor-core/src/config.rs diff --git a/src/error.rs b/crates/gor-core/src/error.rs similarity index 100% rename from src/error.rs rename to crates/gor-core/src/error.rs diff --git a/src/host.rs b/crates/gor-core/src/host.rs similarity index 96% rename from src/host.rs rename to crates/gor-core/src/host.rs index 8b84346..0ca63f8 100644 --- a/src/host.rs +++ b/crates/gor-core/src/host.rs @@ -22,7 +22,7 @@ impl Host { /// # Examples /// /// ``` - /// use gor::host::Host; + /// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// assert_eq!(host.api_base(), "https://api.github.com"); @@ -62,7 +62,7 @@ impl Host { /// # Examples /// /// ``` - /// use gor::host::Host; + /// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// assert_eq!(host.api_url("/user"), "https://api.github.com/user"); @@ -77,7 +77,7 @@ impl Host { /// # Examples /// /// ``` - /// use gor::host::Host; + /// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// assert_eq!( @@ -95,7 +95,7 @@ impl Host { /// # Examples /// /// ``` - /// use gor::host::Host; + /// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// assert_eq!( @@ -113,7 +113,7 @@ impl Host { /// # Examples /// /// ``` - /// use gor::host::Host; + /// use gor_core::host::Host; /// /// let host = Host::new("github.com"); /// assert_eq!(host.device_activation_url(), "https://github.com/login/device"); diff --git a/src/keyring_store.rs b/crates/gor-core/src/keyring_store.rs similarity index 100% rename from src/keyring_store.rs rename to crates/gor-core/src/keyring_store.rs diff --git a/crates/gor-core/src/lib.rs b/crates/gor-core/src/lib.rs new file mode 100644 index 0000000..aebdd6e --- /dev/null +++ b/crates/gor-core/src/lib.rs @@ -0,0 +1,21 @@ +//! # gor-core — Core library for the gor GitHub CLI +//! +//! Provides typed operations, client infrastructure, and domain models +//! for the GitHub REST API. Used by the `gor` CLI binary and available +//! as a reusable library for other Rust applications. + +#![deny(missing_docs)] +#![deny(unsafe_code)] + +pub mod auth; +pub mod client; +pub mod config; +pub mod error; +pub mod host; +pub mod keyring_store; +pub mod repository; + +/// Convenience re-exports of key types. +pub use client::Client; +pub use error::GorError; +pub use repository::RepoSplit; diff --git a/src/repository.rs b/crates/gor-core/src/repository.rs similarity index 88% rename from src/repository.rs rename to crates/gor-core/src/repository.rs index a02fcaf..875bada 100644 --- a/src/repository.rs +++ b/crates/gor-core/src/repository.rs @@ -3,12 +3,14 @@ //! Provides utilities for parsing `OWNER/REPO` strings and detecting //! repository information from the current directory's git remote. +use crate::error::GorError; + /// A parsed repository specification consisting of an owner and repo name. /// /// # Examples /// /// ``` -/// use gor::repository::{parse_repo_spec, RepoSplit}; +/// use gor_core::repository::{parse_repo_spec, RepoSplit}; /// /// let spec = parse_repo_spec("octocat/hello-world").expect("valid spec"); /// assert_eq!(spec.owner, "octocat"); @@ -28,7 +30,7 @@ impl RepoSplit { /// # Examples /// /// ``` - /// use gor::repository::RepoSplit; + /// use gor_core::repository::RepoSplit; /// /// let spec = RepoSplit::new("octocat", "hello-world"); /// assert_eq!(spec.owner, "octocat"); @@ -49,7 +51,7 @@ impl std::fmt::Display for RepoSplit { /// # Examples /// /// ``` - /// use gor::repository::RepoSplit; + /// use gor_core::repository::RepoSplit; /// /// let spec = RepoSplit::new("octocat", "hello-world"); /// assert_eq!(spec.to_string(), "octocat/hello-world"); @@ -72,7 +74,7 @@ impl std::fmt::Display for RepoSplit { /// # Examples /// /// ``` -/// use gor::repository::parse_repo_spec; +/// use gor_core::repository::parse_repo_spec; /// /// let spec = parse_repo_spec("octocat/hello-world").expect("valid spec"); /// assert_eq!(spec.owner, "octocat"); @@ -87,21 +89,34 @@ impl std::fmt::Display for RepoSplit { /// assert!(parse_repo_spec("no-slash").is_err()); /// assert!(parse_repo_spec("too/many/slashes").is_err()); /// ``` -pub fn parse_repo_spec(input: &str) -> anyhow::Result { +pub fn parse_repo_spec(input: &str) -> Result { let input = input.trim(); - anyhow::ensure!(!input.is_empty(), "repository spec cannot be empty"); + if input.is_empty() { + return Err(GorError::InvalidInput( + "repository spec cannot be empty".to_string(), + )); + } let parts: Vec<&str> = input.split('/').collect(); - anyhow::ensure!( - parts.len() == 2, - "invalid repository spec '{input}': expected OWNER/REPO format" - ); + if parts.len() != 2 { + return Err(GorError::InvalidInput(format!( + "invalid repository spec '{input}': expected OWNER/REPO format" + ))); + } let owner = parts[0].trim(); let repo = parts[1].trim(); - anyhow::ensure!(!owner.is_empty(), "repository owner cannot be empty"); - anyhow::ensure!(!repo.is_empty(), "repository name cannot be empty"); + if owner.is_empty() { + return Err(GorError::InvalidInput( + "repository owner cannot be empty".to_string(), + )); + } + if repo.is_empty() { + return Err(GorError::InvalidInput( + "repository name cannot be empty".to_string(), + )); + } // Strip trailing .git if present let repo = repo.strip_suffix(".git").unwrap_or(repo).to_string(); @@ -127,7 +142,7 @@ pub fn parse_repo_spec(input: &str) -> anyhow::Result { /// # Examples /// /// ```no_run -/// use gor::repository::detect_remote; +/// use gor_core::repository::detect_remote; /// /// // This will return None if not in a git repo with a GitHub remote /// let result = detect_remote(); diff --git a/crates/gor/Cargo.toml b/crates/gor/Cargo.toml new file mode 100644 index 0000000..49267c2 --- /dev/null +++ b/crates/gor/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "gor-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +documentation.workspace = true +homepage.workspace = true +description = "A Rust CLI for GitHub — a 'gh' clone" +readme = "README.md" +keywords = ["github", "cli", "gh", "git"] +categories = ["command-line-utilities", "development-tools"] + +[lib] +name = "gor" + +[[bin]] +name = "gor" +path = "src/main.rs" + +[lints] +workspace = true + +[features] +default = [] +keyring = ["gor-core/keyring"] + +[dependencies] +gor-core = { path = "../gor-core" } +clap = { workspace = true } +clap_complete = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +miette = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +indicatif = { workspace = true } +console = { workspace = true } +gix = { workspace = true } +serde_yaml_ng = { workspace = true } +dirs = { workspace = true } diff --git a/src/cli.rs b/crates/gor/src/cli.rs similarity index 100% rename from src/cli.rs rename to crates/gor/src/cli.rs diff --git a/src/cmd/alias.rs b/crates/gor/src/cmd/alias.rs similarity index 99% rename from src/cmd/alias.rs rename to crates/gor/src/cmd/alias.rs index 601d549..f9f8cf6 100644 --- a/src/cmd/alias.rs +++ b/crates/gor/src/cmd/alias.rs @@ -6,8 +6,8 @@ #![allow(clippy::print_stdout)] use crate::cli::AliasCommand; -use crate::config; use anyhow::Context; +use gor_core::config; /// Run the `gor alias` subcommand. /// diff --git a/src/cmd/api.rs b/crates/gor/src/cmd/api.rs similarity index 99% rename from src/cmd/api.rs rename to crates/gor/src/cmd/api.rs index bc46839..e157bb5 100644 --- a/src/cmd/api.rs +++ b/crates/gor/src/cmd/api.rs @@ -8,8 +8,8 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use crate::cli::ApiCommand; -use crate::client::Client; use anyhow::Context; +use gor_core::client::Client; use std::fmt::Write as FmtWrite; use std::io::Read; diff --git a/src/cmd/attestation.rs b/crates/gor/src/cmd/attestation.rs similarity index 98% rename from src/cmd/attestation.rs rename to crates/gor/src/cmd/attestation.rs index 1fe15da..ba31999 100644 --- a/src/cmd/attestation.rs +++ b/crates/gor/src/cmd/attestation.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::AttestationCommand; -use crate::client::Client; -use crate::repository; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository; /// Run the `gor attestation` subcommand. /// diff --git a/src/cmd/auth.rs b/crates/gor/src/cmd/auth.rs similarity index 97% rename from src/cmd/auth.rs rename to crates/gor/src/cmd/auth.rs index bc2bf4c..1f0cc0d 100644 --- a/src/cmd/auth.rs +++ b/crates/gor/src/cmd/auth.rs @@ -4,11 +4,11 @@ #![allow(clippy::print_stdout)] -use crate::auth::{device, token}; use crate::cli::AuthCommand; -use crate::client::Client; -use crate::host::Host; use anyhow::Context; +use gor_core::auth::{device, token}; +use gor_core::client::Client; +use gor_core::host::Host; /// Run the `gor auth` subcommand. /// @@ -193,7 +193,7 @@ fn logout(hostname: &str) -> anyhow::Result<()> { return Ok(()); } - crate::keyring_store::delete_token(hostname) + gor_core::keyring_store::delete_token(hostname) .map_err(|e| anyhow::anyhow!("failed to remove token: {e}"))?; println!("Logged out of {hostname}"); Ok(()) @@ -237,7 +237,7 @@ fn token(hostname: &str, refresh: bool, scopes: &[String], secure: bool) -> anyh .map_err(|e| anyhow::anyhow!("{e}"))?; // Store the new token. - crate::keyring_store::set_token(hostname, &access_token) + gor_core::keyring_store::set_token(hostname, &access_token) .map_err(|e| anyhow::anyhow!("failed to store token: {e}"))?; // Print the token. @@ -246,7 +246,7 @@ fn token(hostname: &str, refresh: bool, scopes: &[String], secure: bool) -> anyh } // Try to get the token from the keyring first. - let token = crate::keyring_store::get_token(hostname) + let token = gor_core::keyring_store::get_token(hostname) .map_err(|e| anyhow::anyhow!("failed to read token: {e}"))? .or_else(|| std::env::var("GITHUB_TOKEN").ok()) .or_else(|| std::env::var("GH_TOKEN").ok()); diff --git a/src/cmd/browse.rs b/crates/gor/src/cmd/browse.rs similarity index 97% rename from src/cmd/browse.rs rename to crates/gor/src/cmd/browse.rs index e780a9a..d0f5e01 100644 --- a/src/cmd/browse.rs +++ b/crates/gor/src/cmd/browse.rs @@ -6,8 +6,8 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use crate::cli::BrowseCommand; -use crate::repository::{detect_remote, parse_repo_spec}; use anyhow::Context; +use gor_core::repository::{detect_remote, parse_repo_spec}; /// Run the `gor browse` subcommand. /// diff --git a/src/cmd/cache.rs b/crates/gor/src/cmd/cache.rs similarity index 98% rename from src/cmd/cache.rs rename to crates/gor/src/cmd/cache.rs index 88ae021..668bfdb 100644 --- a/src/cmd/cache.rs +++ b/crates/gor/src/cmd/cache.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::CacheCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository; use std::fmt::Write; diff --git a/src/cmd/classroom.rs b/crates/gor/src/cmd/classroom.rs similarity index 98% rename from src/cmd/classroom.rs rename to crates/gor/src/cmd/classroom.rs index 78b4611..7254b68 100644 --- a/src/cmd/classroom.rs +++ b/crates/gor/src/cmd/classroom.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::ClassroomCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; /// Run the `gor classroom` subcommand. /// diff --git a/src/cmd/codespace.rs b/crates/gor/src/cmd/codespace.rs similarity index 99% rename from src/cmd/codespace.rs rename to crates/gor/src/cmd/codespace.rs index e8d1d6e..cec3082 100644 --- a/src/cmd/codespace.rs +++ b/crates/gor/src/cmd/codespace.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout, clippy::option_if_let_else)] use crate::cli::CodespaceCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; /// Run the `gor codespace` subcommand. /// diff --git a/src/cmd/completion.rs b/crates/gor/src/cmd/completion.rs similarity index 100% rename from src/cmd/completion.rs rename to crates/gor/src/cmd/completion.rs diff --git a/src/cmd/config.rs b/crates/gor/src/cmd/config.rs similarity index 98% rename from src/cmd/config.rs rename to crates/gor/src/cmd/config.rs index 196447a..17e20a3 100644 --- a/src/cmd/config.rs +++ b/crates/gor/src/cmd/config.rs @@ -5,7 +5,7 @@ #![allow(clippy::print_stdout)] use crate::cli::ConfigCommand; -use crate::config::{self, GorConfig}; +use gor_core::config::{self, GorConfig}; /// Run the `gor config` subcommand. /// diff --git a/src/cmd/copilot.rs b/crates/gor/src/cmd/copilot.rs similarity index 98% rename from src/cmd/copilot.rs rename to crates/gor/src/cmd/copilot.rs index c616736..e1765d6 100644 --- a/src/cmd/copilot.rs +++ b/crates/gor/src/cmd/copilot.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::CopilotCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; /// Run the `gor copilot` subcommand. /// diff --git a/src/cmd/extension.rs b/crates/gor/src/cmd/extension.rs similarity index 98% rename from src/cmd/extension.rs rename to crates/gor/src/cmd/extension.rs index 6dc8c94..3f3b4a3 100644 --- a/src/cmd/extension.rs +++ b/crates/gor/src/cmd/extension.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::ExtensionCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; /// Run the `gor extension` subcommand. /// diff --git a/src/cmd/gist.rs b/crates/gor/src/cmd/gist.rs similarity index 99% rename from src/cmd/gist.rs rename to crates/gor/src/cmd/gist.rs index b914b9f..75f23c8 100644 --- a/src/cmd/gist.rs +++ b/crates/gor/src/cmd/gist.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::GistCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; use std::fs; /// Run the `gor gist` subcommand. @@ -434,7 +434,7 @@ fn print_gist_table(gists: &[serde_json::Value]) { let file_count = gist["files"].as_object().map_or(0, serde_json::Map::len); let updated = gist["updated_at"] .as_str() - .map_or_else(|| "—".to_string(), crate::output::format_date); + .map_or_else(|| "—".to_string(), crate::render::format_date); let desc_truncated = crate::cmd::util::truncate(description, desc_width); diff --git a/src/cmd/issue.rs b/crates/gor/src/cmd/issue.rs similarity index 99% rename from src/cmd/issue.rs rename to crates/gor/src/cmd/issue.rs index 0aad4bb..c03b3cf 100644 --- a/src/cmd/issue.rs +++ b/crates/gor/src/cmd/issue.rs @@ -6,10 +6,10 @@ #![allow(clippy::print_stdout)] use crate::cli::IssueCommand; -use crate::client::Client; -use crate::output::{format_date, print_json}; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::{format_date, print_json}; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; /// Run the `gor issue` subcommand. /// diff --git a/src/cmd/keys.rs b/crates/gor/src/cmd/keys.rs similarity index 99% rename from src/cmd/keys.rs rename to crates/gor/src/cmd/keys.rs index dcb705d..e57dba9 100644 --- a/src/cmd/keys.rs +++ b/crates/gor/src/cmd/keys.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::{GpgKeyCommand, SshKeyCommand}; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; use std::fs; /// Run the `gor ssh-key` subcommand. diff --git a/src/cmd/label.rs b/crates/gor/src/cmd/label.rs similarity index 99% rename from src/cmd/label.rs rename to crates/gor/src/cmd/label.rs index ea880ad..66688d6 100644 --- a/src/cmd/label.rs +++ b/crates/gor/src/cmd/label.rs @@ -6,10 +6,10 @@ #![allow(clippy::print_stdout)] use crate::cli::LabelCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; /// Run the `gor label` subcommand. /// diff --git a/src/cmd/mod.rs b/crates/gor/src/cmd/mod.rs similarity index 100% rename from src/cmd/mod.rs rename to crates/gor/src/cmd/mod.rs diff --git a/src/cmd/org.rs b/crates/gor/src/cmd/org.rs similarity index 98% rename from src/cmd/org.rs rename to crates/gor/src/cmd/org.rs index e997387..d9091eb 100644 --- a/src/cmd/org.rs +++ b/crates/gor/src/cmd/org.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::OrgCommand; -use crate::client::Client; -use crate::output::print_json; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; /// Run the `gor org` subcommand. /// diff --git a/src/cmd/pr.rs b/crates/gor/src/cmd/pr.rs similarity index 99% rename from src/cmd/pr.rs rename to crates/gor/src/cmd/pr.rs index 273f92e..ef23351 100644 --- a/src/cmd/pr.rs +++ b/crates/gor/src/cmd/pr.rs @@ -6,11 +6,11 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use crate::cli::PrCommand; -use crate::client::Client; -use crate::output::{format_date, print_json}; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::{format_date, print_json}; use anyhow::Context; use gix::bstr::ByteSlice; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; use std::collections::BTreeMap; use std::io::Write; diff --git a/src/cmd/project.rs b/crates/gor/src/cmd/project.rs similarity index 99% rename from src/cmd/project.rs rename to crates/gor/src/cmd/project.rs index 5e06ba6..62dd941 100644 --- a/src/cmd/project.rs +++ b/crates/gor/src/cmd/project.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::ProjectCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository; /// Run the `gor project` subcommand. /// diff --git a/src/cmd/release.rs b/crates/gor/src/cmd/release.rs similarity index 99% rename from src/cmd/release.rs rename to crates/gor/src/cmd/release.rs index 3657739..f4fe8ad 100644 --- a/src/cmd/release.rs +++ b/crates/gor/src/cmd/release.rs @@ -6,10 +6,10 @@ #![allow(clippy::print_stdout)] use crate::cli::ReleaseCommand; -use crate::client::Client; -use crate::output::{format_date, print_json}; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::{format_date, print_json}; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; use std::io::Write; /// Run the `gor release` subcommand. diff --git a/src/cmd/repo.rs b/crates/gor/src/cmd/repo.rs similarity index 99% rename from src/cmd/repo.rs rename to crates/gor/src/cmd/repo.rs index 72de913..c596e1a 100644 --- a/src/cmd/repo.rs +++ b/crates/gor/src/cmd/repo.rs @@ -6,10 +6,10 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use crate::cli::RepoCommand; -use crate::client::Client; -use crate::output::{format_count, format_date, print_json}; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::{format_count, format_date, print_json}; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; use std::io::Write; /// Run the `gor repo` subcommand. diff --git a/src/cmd/ruleset.rs b/crates/gor/src/cmd/ruleset.rs similarity index 97% rename from src/cmd/ruleset.rs rename to crates/gor/src/cmd/ruleset.rs index add9603..670f2e7 100644 --- a/src/cmd/ruleset.rs +++ b/crates/gor/src/cmd/ruleset.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::RulesetCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; /// Run the `gor ruleset` subcommand. /// diff --git a/src/cmd/run.rs b/crates/gor/src/cmd/run.rs similarity index 99% rename from src/cmd/run.rs rename to crates/gor/src/cmd/run.rs index 3acf93c..50c298d 100644 --- a/src/cmd/run.rs +++ b/crates/gor/src/cmd/run.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout, clippy::option_if_let_else)] use crate::cli::RunCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository; use std::fmt::Write; /// Run the `gor run` subcommand. diff --git a/src/cmd/search.rs b/crates/gor/src/cmd/search.rs similarity index 99% rename from src/cmd/search.rs rename to crates/gor/src/cmd/search.rs index c9f829d..d2042cd 100644 --- a/src/cmd/search.rs +++ b/crates/gor/src/cmd/search.rs @@ -5,9 +5,9 @@ #![allow(clippy::print_stdout)] use crate::cli::SearchCommand; -use crate::client::Client; -use crate::output::{format_count, format_date, print_json}; +use crate::render::{format_count, format_date, print_json}; use anyhow::Context; +use gor_core::client::Client; use std::fmt::Write as FmtWrite; /// Run the `gor search` subcommand. diff --git a/src/cmd/secret.rs b/crates/gor/src/cmd/secret.rs similarity index 98% rename from src/cmd/secret.rs rename to crates/gor/src/cmd/secret.rs index 84e9e32..24b37f5 100644 --- a/src/cmd/secret.rs +++ b/crates/gor/src/cmd/secret.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::SecretCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository::detect_remote; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::detect_remote; /// Run the `gor secret` subcommand. /// diff --git a/src/cmd/util.rs b/crates/gor/src/cmd/util.rs similarity index 100% rename from src/cmd/util.rs rename to crates/gor/src/cmd/util.rs diff --git a/src/cmd/variable.rs b/crates/gor/src/cmd/variable.rs similarity index 98% rename from src/cmd/variable.rs rename to crates/gor/src/cmd/variable.rs index 726aa85..d55b0e5 100644 --- a/src/cmd/variable.rs +++ b/crates/gor/src/cmd/variable.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::VariableCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository::detect_remote; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::detect_remote; /// Run the `gor variable` subcommand. /// diff --git a/src/cmd/workflow.rs b/crates/gor/src/cmd/workflow.rs similarity index 98% rename from src/cmd/workflow.rs rename to crates/gor/src/cmd/workflow.rs index bedb231..8485e7f 100644 --- a/src/cmd/workflow.rs +++ b/crates/gor/src/cmd/workflow.rs @@ -5,10 +5,10 @@ #![allow(clippy::print_stdout)] use crate::cli::WorkflowCommand; -use crate::client::Client; -use crate::output::print_json; -use crate::repository::{detect_remote, parse_repo_spec}; +use crate::render::print_json; use anyhow::Context; +use gor_core::client::Client; +use gor_core::repository::{detect_remote, parse_repo_spec}; /// Run the `gor workflow` subcommand. /// @@ -240,7 +240,7 @@ fn view( let branch = run["head_branch"].as_str().unwrap_or("—"); let created = run["created_at"] .as_str() - .map_or_else(|| "—".to_string(), crate::output::format_date); + .map_or_else(|| "—".to_string(), crate::render::format_date); println!( " {run_id:<8} {run_status:<10} {conclusion:<12} {branch:<20} {created:<16}", diff --git a/src/lib.rs b/crates/gor/src/lib.rs similarity index 93% rename from src/lib.rs rename to crates/gor/src/lib.rs index 7c61dff..3e93093 100644 --- a/src/lib.rs +++ b/crates/gor/src/lib.rs @@ -28,16 +28,9 @@ #![deny(missing_docs)] #![deny(unsafe_code)] -pub mod auth; pub mod cli; -pub mod client; pub mod cmd; -pub mod config; -pub mod error; -pub mod host; -pub mod keyring_store; -pub mod output; -pub mod repository; +pub mod render; use clap::Parser; use cli::Args; diff --git a/src/main.rs b/crates/gor/src/main.rs similarity index 100% rename from src/main.rs rename to crates/gor/src/main.rs diff --git a/src/output.rs b/crates/gor/src/render.rs similarity index 98% rename from src/output.rs rename to crates/gor/src/render.rs index cd6ba6b..74813e9 100644 --- a/src/output.rs +++ b/crates/gor/src/render.rs @@ -22,7 +22,7 @@ use serde_json::Value; /// # Examples /// /// ```no_run -/// use gor::output::print_json; +/// use gor::render::print_json; /// use serde_json::json; /// /// let data = json!({"name": "hello-world", "stars": 42}); @@ -65,7 +65,7 @@ pub fn print_json(value: &T, fields: Option<&[String]>) { /// # Examples /// /// ``` -/// use gor::output::format_date; +/// use gor::render::format_date; /// /// assert_eq!(format_date("2024-01-15T10:30:00Z"), "Jan 15, 2024"); /// assert_eq!(format_date("2023-12-25T00:00:00Z"), "Dec 25, 2023"); @@ -112,7 +112,7 @@ pub fn format_date(iso_date: &str) -> String { /// # Examples /// /// ``` -/// use gor::output::format_count; +/// use gor::render::format_count; /// /// assert_eq!(format_count(0), "0"); /// assert_eq!(format_count(42), "42"); From 793f7332a0aeaa25f7923ada511df99b0e0c5235 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 13:24:21 -0400 Subject: [PATCH 3/8] refactor(label): extract typed ops into gor-core --- crates/gor-core/src/label.rs | 438 ++++++++++++++++++++++++++++++ crates/gor-core/src/lib.rs | 3 + crates/gor-core/src/util.rs | 52 ++++ crates/gor/src/cmd/label.rs | 508 ++++++++--------------------------- 4 files changed, 602 insertions(+), 399 deletions(-) create mode 100644 crates/gor-core/src/label.rs create mode 100644 crates/gor-core/src/util.rs diff --git a/crates/gor-core/src/label.rs b/crates/gor-core/src/label.rs new file mode 100644 index 0000000..31a56b0 --- /dev/null +++ b/crates/gor-core/src/label.rs @@ -0,0 +1,438 @@ +//! Typed operations and models for GitHub repository labels. +//! +//! Provides functions to list, create, update, delete, and clone labels +//! on GitHub repositories, returning typed [`Label`] structs instead of +//! raw JSON values. + +use crate::client::Client; +use crate::error::GorError; +use crate::repository::RepoSplit; +use crate::util::urlencode_label_name; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// A GitHub repository label. +/// +/// Fields are hand-picked for gor's usage. Unknown fields from the API +/// are captured in [`extra`](Self::extra) via `#[serde(flatten)]`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Label { + /// The label's display name. + pub name: String, + /// The hex color code (without `#`). + pub color: String, + /// An optional description of the label. + #[serde(default)] + pub description: Option, + /// Any additional fields returned by the API not in this struct. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Options for [`list`]. +#[derive(Debug, Default)] +pub struct ListOptions { + /// Filter labels whose name contains this substring (case-insensitive). + pub search: Option, + /// Maximum number of labels to return. + pub limit: u32, +} + +/// Options for [`create`]. +#[derive(Debug, Default)] +pub struct CreateOptions { + /// The hex color code (without `#`). Defaults to `"ededed"`. + pub color: Option, + /// An optional description. + pub description: Option, +} + +/// Options for [`update`]. +#[derive(Debug, Default)] +pub struct UpdateOptions { + /// New name for the label. + pub new_name: Option, + /// New hex color code. + pub color: Option, + /// New description. + pub description: Option, +} + +/// Result of a [`clone_from`] operation. +#[derive(Debug, Clone)] +pub struct CloneResult { + /// Number of labels created in the target repo. + pub created: u32, + /// Number of labels updated in the target repo. + pub updated: u32, + /// Number of labels skipped (already exist and `force` was false, or an error occurred). + pub skipped: u32, +} + +/// List labels in a repository. +/// +/// # Errors +/// +/// Returns [`GorError::NotFound`] if the repository does not exist, +/// or [`GorError::Http`] on HTTP failures. +pub fn list(client: &Client, spec: &RepoSplit, opts: &ListOptions) -> Result, GorError> { + let path = format!( + "/repos/{}/{}/labels?per_page={}", + spec.owner, + spec.repo, + opts.limit.min(100) + ); + let response = client.get(&path)?; + + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err(GorError::NotFound(format!("repository '{spec}' not found"))); + } + if !status.is_success() { + if let Err(e) = response.error_for_status_ref() { + return Err(GorError::Http(e)); + } + } + + let mut labels: Vec