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/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..51e4f8d --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,251 @@ +# Handoff: gor-core Library Split (Issue #2) + +## Project Overview + +**Goal:** Split the single-crate `gor` CLI into a Cargo workspace with: +- **`gor-core`** — reusable library crate with typed GitHub API operations, models, and infrastructure +- **`gor`** — thin CLI binary for argument parsing, rendering, and dispatching to `gor-core` + +**Binary:** `gor` (unchanged) +**Package names:** `gor-cli` (crates.io), `gor-core` (new) +**Branch:** `refactor/split-core-library` +**Draft PR:** [#3](https://github.com/kerryhatcher/gor/pull/3) +**Issue:** [#2](https://github.com/kerryhatcher/gor/issues/2) — full design spec + +--- + +## What's Been Done + +### Phase 0 — Setup +- Branch `refactor/split-core-library` created and pushed +- Draft PR opened, linked to issue #2 +- Design spec at `docs/superpowers/specs/2026-07-20-core-library-split-design.md` + +### Phase 1 — Workspace Restructuring +- Root `Cargo.toml` is a pure workspace with `members = ["crates/*"]` +- `crates/gor-core/` — library (`name = "gor_core"`), keeps: `reqwest`, `serde`, `serde_json`, `serde_yaml_ng`, `keyring` (optional), `thiserror`, `dirs`, `gix`, `tracing` +- `crates/gor/` — binary (`name = "gor"`), depends on `gor-core`, keeps: `clap`, `clap_complete`, `anyhow`, `miette`, `tracing-subscriber`, `indicatif`, `console`, `gix`, `serde_yaml_ng`, `dirs` +- All imports updated: core files use `gor_core::` prefix, `output.rs` → `render.rs` +- **Commit:** `26e8b18` — `build: split workspace into gor-core + gor crates` + +### Phase 2 — Label Template +- `crates/gor-core/src/label.rs` — typed `Label` struct (`#[non_exhaustive]` + capture-rest `extra`), `Label::list/create/update/delete/clone_from` ops +- `crates/gor-core/src/util.rs` — `urlencode_label_name` helper +- CLI handler thinned to args → op → render pattern +- **Tests:** 49 gor-core unit tests + 120 gor unit tests +- **Commit:** `793f733` — `refactor(label): extract typed ops into gor-core` + +### Phase 3a–3b — 8 More Domains Converted + +| Domain | Core Module | CLI Handler | Commit | +|--------|------------|-------------|--------| +| **cache** | `crates/gor-core/src/cache.rs` | `crates/gor/src/cmd/cache.rs` | `02de2b3` | +| **org** | `crates/gor-core/src/org.rs` | `crates/gor/src/cmd/org.rs` | `02de2b3` | +| **secret** | `crates/gor-core/src/secret.rs` | `crates/gor/src/cmd/secret.rs` | `4621f83` | +| **variable** | `crates/gor-core/src/variable.rs` | `crates/gor/src/cmd/variable.rs` | `4621f83` | +| **project** | `crates/gor-core/src/project.rs` | `crates/gor/src/cmd/project.rs` | `0b409e2` | +| **keys** | `crates/gor-core/src/keys.rs` | `crates/gor/src/cmd/keys.rs` | `27f90b9` | +| **search** | `crates/gor-core/src/search.rs` | `crates/gor/src/cmd/search.rs` | `27f90b9` | +| **workflow** | `crates/gor-core/src/workflow.rs` | `crates/gor/src/cmd/workflow.rs` | `27f90b9` | + +--- + +## What Remains + +### Phase 3c–3d — 7 domains still to convert + +| Domain | LOC | Priority | Notes | +|--------|-----|----------|-------| +| `gist` | 518 | 3c | Create/list/view/edit/delete | +| `run` | 636 | 3c | List/view/watch/cancel/rerun/download | +| `codespace` | 647 | 3c | List/create/delete/ssh/stop | +| `release` | 1313 | 3d | Create/edit/delete/list/view/upload/download | +| `repo` | 1317 | 3d | View/list/create/fork/delete/edit/clone/sync/transfer | +| `issue` | 1332 | 3d | List/view/create/edit/close/reopen/comment/transfer | +| `pr` | 2236 | 3d | List/view/create/close/reopen/merge/diff/checkout/comment/review/checks/ready/edit | + +### Phase 4 — Cleanup & Publishability (not started) +- Delete dead bin helpers (e.g., old `urlencoding` in label.rs → now in `gor_core::util`) +- Move shared pagination/field-selection into `gor_core::util` +- Audit `gor-core` for any remaining `anyhow`/`print_stdout`/`console`/`indicatif` usage +- Add `gor-core` metadata: `description`, `keywords`, `categories` +- Verify `cargo doc --no-deps -p gor-core` clean + +### Phase 5 — Merge & Release (not started) +- Merge PR, referencing `closes #2` +- `release-plz` publishes `gor-core` 0.1.0 + bumped `gor-cli` +- Update README with consumer example + +--- + +## Conversion Pattern (follow this for remaining domains) + +### 1. Core Module (`crates/gor-core/src/.rs`) +- Typed `struct` with `#[non_exhaustive]` and `#[serde(flatten)] extra` +- Options structs (`ListOptions`, `CreateOptions`, etc.) — use owned `String` not `&str` +- Operation functions returning `Result` (never `anyhow`, never print) +- Status→error mapping: `404 → GorError::NotFound`, `422 → GorError::InvalidInput`, other failures → `GorError::InvalidInput(msg)` +- `#![allow(clippy::missing_errors_doc)]` at module top (to avoid per-function doc burden) +- `///` doc comments on all structs and fields (required by `#![deny(missing_docs)]`) + +### 2. Register in `crates/gor-core/src/lib.rs` +- Add `pub mod ;` in alphabetical order + +### 3. Thin CLI Handler (`crates/gor/src/cmd/.rs`) +- Keep `pub fn run(cmd: DomainCommand)` — dispatches to private helpers +- Helpers: resolve args → call `gor_core::::*` → render output +- Import `gor_core::::{self, TypeName}` +- Use `Client::new(host).map_err(|e| anyhow::anyhow!("..."))` pattern +- Keep all `println!`, prompts, and rendering in the CLI handler +- Keep `#![allow(clippy::print_stdout)]` (allowed for CLI crate) +- Doc comments on `pub fn run` (required by `#![deny(missing_docs)]`) + +### 4. Add `pub mod ;` to `crates/gor/src/cmd/mod.rs` + +### 5. Gate +- `cargo build` must pass +- `cargo clippy --all-targets` must pass +- `cargo test` must pass (existing 118 gor + 49 gor-core + doc tests = ~187 tests) +- Commit: `refactor(): extract typed ops into gor-core` + +--- + +## Key Constraints & Pitfalls + +### Lint rules (deny, will fail CI): +- **`#![deny(missing_docs)]`** in both crates — every pub item, struct field, and enum variant needs a doc comment +- **`unwrap_used = "deny"`** — no `.unwrap()` or `.unwrap_err()` ever. Use `?` or pattern matching +- **`dbg_macro = "deny"`** — no `dbg!()` +- **`todo = "deny"`** — no `todo!()` +- **`clippy::use_self`** — in `impl` blocks, use `Self::Variant` not `EnumName::Variant` +- **`clippy::missing_errors_doc`** — suppress with `#![allow(clippy::missing_errors_doc)]` at module top +- **`clippy::uninlined_format_args`** — use `format!("{var}")` not `format!("{}", var)` +- **`clippy::format_push_string`** — use `write!` macro instead of `push_str(&format!(...))` +- **`clippy::too_many_arguments`** — suppress with `#![allow(clippy::too_many_arguments)]` + +### Error handling: +- Core ops return `Result` — never `anyhow::Result` +- `GorError` variants: `Http(reqwest::Error)`, `Auth(String)`, `NotFound(String)`, `RateLimit(String)`, `InvalidInput(String)`, `Io(std::io::Error)`, `Keyring(String)`, `DeviceTimeout(String)`, `DeviceDeclined` +- CLI handlers return `anyhow::Result<()>` — wrap errors with `map_err(|e| anyhow::anyhow!("..."))` + +### `#[non_exhaustive]` structs: +- Cannot be constructed outside the defining crate +- Test data must use `serde_json::from_value(json!({...}))` to construct instances + +### `extra` field idiom: +```rust +/// Any additional fields returned by the API. +#[serde(flatten)] +pub extra: serde_json::Map, +``` +- Always include this on typed models to capture unknown API fields +- Requires a doc comment + +### CLI handler spec resolution helper (copy-paste template): +```rust +fn resolve_spec(repo: Option<&str>) -> anyhow::Result { + match repo { + Some(s) => Ok(parse_repo_spec(s)?), + None => detect_remote().ok_or_else(|| { + anyhow::anyhow!("could not detect repository; specify OWNER/REPO with --repo") + }), + } +} + +fn build_client(hostname: Option<&str>) -> anyhow::Result { + let host = hostname.unwrap_or("github.com"); + Client::new(host).map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}")) +} +``` + +--- + +## Current File Structure + +``` +Cargo.toml # Workspace root +crates/ + gor-core/ + Cargo.toml + src/ + lib.rs # Exports all core modules + client.rs # HTTP client (reqwest::blocking) + host.rs # Host URL derivation + config.rs # Config management + error.rs # GorError enum + keyring_store.rs # OS keyring integration + repository.rs # Repo spec parsing, remote detection + auth/ # OAuth device flow, token verification + mod.rs + device.rs + token.rs + cache.rs # ✅ Converted + keys.rs # ✅ Converted + label.rs # ✅ Converted (template) + org.rs # ✅ Converted + project.rs # ✅ Converted + search.rs # ✅ Converted + secret.rs # ✅ Converted + util.rs # urlencode_label_name + variable.rs # ✅ Converted + workflow.rs # ✅ Converted + gor/ + Cargo.toml + src/ + lib.rs # Gor struct, re-exports cli/cmd/render + main.rs # Thin entry point + cli.rs # clap derive structs + render.rs # print_json, format_date, format_count + cmd/ + mod.rs # Dispatches all subcommands + alias.rs # Bin-only + api.rs # Bin-only + attestation.rs # Bin-only + auth.rs # Bin-only + browse.rs # Bin-only + cache.rs # ✅ Converted + classroom.rs # Bin-only + codespace.rs # 🔜 Needs conversion + completion.rs # Bin-only + config.rs # Bin-only + copilot.rs # Bin-only + extension.rs # Bin-only + gist.rs # 🔜 Needs conversion + issue.rs # 🔜 Needs conversion + keys.rs # ✅ Converted + label.rs # ✅ Converted + org.rs # ✅ Converted + pr.rs # 🔜 Needs conversion + project.rs # ✅ Converted + release.rs # 🔜 Needs conversion + repo.rs # 🔜 Needs conversion + ruleset.rs # Bin-only + run.rs # 🔜 Needs conversion + search.rs # ✅ Converted + secret.rs # ✅ Converted + util.rs # truncate helper + variable.rs # ✅ Converted + workflow.rs # ✅ Converted +``` + +--- + +## Quick Build & Test + +```bash +just build # cargo build +just test # cargo test (expect ~187 tests passing) +just lint # cargo fmt + clippy +just ci # Full CI gate +``` + +--- + +## Critical Reminders + +1. **The `#[allow(clippy::elided_lifetimes_in_paths)]` is a rustc lint, not clippy** — use `#[allow(elided_lifetimes_in_paths)]` without `clippy::` prefix +2. **After every successful conversion**, commit and push to the `refactor/split-core-library` branch +3. **All existing tests must continue to pass** — no behavior changes +4. **The `pr.rs` file (2236 LOC) is the largest and most complex** — may need to be split into submodules (`pr::list`, `pr::view`, `pr::merge`, etc.) +5. **`release`, `repo`, `issue`, `pr` have `#[cfg(test)]` blocks** — preserve these tests when converting +6. **The `just` pre-commit hooks run `cargo fmt`, `cargo clippy`, `cargo check`, and `typos`** — ensure all pass before the commit will succeed 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/crates/gor-core/src/cache.rs b/crates/gor-core/src/cache.rs new file mode 100644 index 0000000..a15489f --- /dev/null +++ b/crates/gor-core/src/cache.rs @@ -0,0 +1,89 @@ +//! Typed operations and models for GitHub Actions caches. + +use crate::client::Client; +use crate::error::GorError; +use crate::repository::RepoSplit; +use serde::{Deserialize, Serialize}; + +/// A GitHub Actions cache entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Cache { + /// The cache key. + pub key: String, + /// Size in bytes. + #[serde(rename = "size_in_bytes")] + pub size_in_bytes: u64, + /// ISO 8601 creation timestamp. + #[serde(rename = "created_at")] + pub created_at: Option, + /// Any additional fields returned by the API not captured above. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// List caches in a repository. +/// +/// # Errors +/// +/// Returns [`GorError::Http`] on HTTP failures. +pub fn list(client: &Client, spec: &RepoSplit) -> Result, GorError> { + let path = format!( + "/repos/{}/{}/actions/caches?per_page=100", + spec.owner, spec.repo + ); + let response = client.get(&path)?; + let status = response.status(); + if !status.is_success() { + return Err(GorError::InvalidInput(format!( + "failed to list caches: HTTP {status}" + ))); + } + let result: serde_json::Value = response.json().map_err(GorError::Http)?; + let caches: Vec = serde_json::from_value(result["actions_caches"].clone()) + .map_err(|e| GorError::InvalidInput(format!("failed to parse caches: {e}")))?; + Ok(caches) +} + +/// Options for [`delete`]. +#[derive(Debug, Default)] +pub struct DeleteOptions<'a> { + /// Delete a specific cache key. + pub key: Option<&'a str>, + /// Delete caches with a key prefix. + pub key_prefix: Option<&'a str>, + /// Filter by Git ref. + pub ref_: Option<&'a str>, +} + +/// Delete caches from a repository. +/// +/// # Errors +/// +/// Returns [`GorError::Http`] on HTTP failures. +pub fn delete( + client: &Client, + spec: &RepoSplit, + opts: &DeleteOptions<'_>, +) -> Result { + use std::fmt::Write; + let mut path = format!("/repos/{}/{}/actions/caches", spec.owner, spec.repo); + if let Some(k) = opts.key { + let _ = write!(path, "?key={k}"); + } else if let Some(prefix) = opts.key_prefix { + let _ = write!(path, "?key={prefix}"); + } + if let Some(r) = opts.ref_ { + let sep = if path.contains('?') { "&" } else { "?" }; + let _ = write!(path, "{sep}ref={r}"); + } + let response = client.request("DELETE", &path, &[], None)?; + let status = response.status(); + if !status.is_success() { + return Err(GorError::InvalidInput(format!( + "failed to delete caches: HTTP {status}" + ))); + } + let result: serde_json::Value = response.json().map_err(GorError::Http)?; + Ok(result["total_count"].as_u64().unwrap_or(0)) +} 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/keys.rs b/crates/gor-core/src/keys.rs new file mode 100644 index 0000000..4c6aaa4 --- /dev/null +++ b/crates/gor-core/src/keys.rs @@ -0,0 +1,118 @@ +//! Typed operations and models for SSH and GPG keys. + +#![allow(clippy::missing_errors_doc)] +use crate::client::Client; +use crate::error::GorError; +use serde::{Deserialize, Serialize}; + +/// An SSH key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct SshKey { + /// The key ID. + pub id: u64, + /// The key title. + pub title: String, + /// The full key string. + pub key: String, + /// Any additional fields returned by the API. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// A GPG key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct GpgKey { + /// The key ID string. + pub key_id: Option, + /// The key name. + pub name: Option, + /// Any additional fields returned by the API. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// List SSH keys for the authenticated user. +pub fn list_ssh(client: &Client) -> Result, GorError> { + let response = client.get("/user/keys")?; + let status = response.status(); + if !status.is_success() { + return Err(GorError::InvalidInput(format!( + "failed to list SSH keys: HTTP {status}" + ))); + } + response.json().map_err(GorError::Http) +} + +/// Add an SSH key. +pub fn add_ssh(client: &Client, title: &str, key_body: &str) -> Result { + let body = serde_json::json!({"title": title, "key": key_body}); + let response = client.post("/user/keys", &body)?; + let status = response.status(); + if !status.is_success() { + let err: serde_json::Value = response.json().unwrap_or_default(); + let msg = err["message"].as_str().unwrap_or("add failed"); + return Err(GorError::InvalidInput(format!( + "failed to add SSH key: {msg}" + ))); + } + response.json().map_err(GorError::Http) +} + +/// Delete an SSH key. +pub fn delete_ssh(client: &Client, key_id: u64) -> Result<(), GorError> { + let path = format!("/user/keys/{key_id}"); + let response = client.request("DELETE", &path, &[], None)?; + let status = response.status(); + if !status.is_success() { + let err: serde_json::Value = response.json().unwrap_or_default(); + let msg = err["message"].as_str().unwrap_or("delete failed"); + return Err(GorError::InvalidInput(format!( + "failed to delete SSH key: {msg}" + ))); + } + Ok(()) +} + +/// List GPG keys for the authenticated user. +pub fn list_gpg(client: &Client) -> Result, GorError> { + let response = client.get("/user/gpg_keys")?; + let status = response.status(); + if !status.is_success() { + return Err(GorError::InvalidInput(format!( + "failed to list GPG keys: HTTP {status}" + ))); + } + response.json().map_err(GorError::Http) +} + +/// Add a GPG key. +pub fn add_gpg(client: &Client, armored_key: &str) -> Result { + let body = serde_json::json!({"armored_public_key": armored_key}); + let response = client.post("/user/gpg_keys", &body)?; + let status = response.status(); + if !status.is_success() { + let err: serde_json::Value = response.json().unwrap_or_default(); + let msg = err["message"].as_str().unwrap_or("add failed"); + return Err(GorError::InvalidInput(format!( + "failed to add GPG key: {msg}" + ))); + } + response.json().map_err(GorError::Http) +} + +/// Delete a GPG key. +pub fn delete_gpg(client: &Client, key_id: &str) -> Result<(), GorError> { + let path = format!("/user/gpg_keys/{key_id}"); + let response = client.request("DELETE", &path, &[], None)?; + let status = response.status(); + if !status.is_success() { + let err: serde_json::Value = response.json().unwrap_or_default(); + let msg = err["message"].as_str().unwrap_or("delete failed"); + return Err(GorError::InvalidInput(format!( + "failed to delete GPG key: {msg}" + ))); + } + Ok(()) +} 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