//! App-level Forgejo client wrapper. Identity is the per-agent token //! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through //! the typed [`forgejo_api::sync::Forgejo`] client (exposed via //! [`Client::api`]); a minimal raw `reqwest` client remains for the //! few *web-router* routes Forgejo does not serve under `/api/v1/` //! (attachment downloads, Actions artifact/log routes). use std::path::PathBuf; use anyhow::{Context, Result, bail}; use forgejo_api::{Auth, ForgejoError}; use reqwest::blocking::{Client as HttpClient, Response}; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Default Forgejo URL when `HIVE_FORGE_URL` is unset. const DEFAULT_URL: &str = "http://localhost:3000"; /// Forgejo client pair: the typed `/api/v1` client plus a raw /// `reqwest` client for web-router-only routes. pub struct Client { /// Typed Forgejo REST client — every `/api/v1` call goes through /// this (see [`Client::api`]). api: forgejo_api::sync::Forgejo, /// Raw HTTP client for the routes Forgejo serves only through its /// web router, NOT under `/api/v1/` (so `forgejo-api` has no /// method for them): attachment downloads at `/attachments/`, /// Actions artifact zips, the run-view log streamer, and the /// persisted-log download. Carries the same `Authorization: token` /// header the typed client sends. web: HttpClient, base: String, /// Per-agent forge token. Kept so verbs that shell out to `git` /// (e.g. `clone`) can assemble an authenticated push URL without /// re-reading the token file. token: String, /// Default repo used when a verb doesn't carry an explicit /// `[repo]` override. pub default_repo: String, /// Global `--json` flag — verbs that have a human-readable /// default path branch on `client.json_mode()` to pick the /// JSON output shape instead. json_mode: bool, } impl Client { /// Build a client from the standard environment variables. /// /// Repo resolution, highest priority first: the global `-r/--repo` /// flag (`repo_override`); the `origin` remote of the git checkout /// containing the current working directory (see /// [`infer_repo_from_cwd`]); the `HIVE_FORGE_REPO` env var (kept as /// a last-resort override for callers that aren't in any checkout — /// nothing in this hive's nix config sets it, so it's opt-in only). /// No repo resolving from any of those is an error rather than a /// silent default — a single hardcoded fallback repo was exactly /// what made agents assume it was "the only repo they're allowed to /// operate on". /// /// `json_mode` comes from the global `--json` flag — per-verb /// output formatters key off it via `Client::json_mode`. /// `forge_label` (from the global `-f/--forge` flag) targets a /// dashboard-provisioned external forge account instead of the /// internal forge — see [`resolve_credentials`]. pub fn from_env( repo_override: Option, json_mode: bool, forge_label: Option, ) -> Result { let (base, token) = resolve_credentials(forge_label.as_deref())?; let Some(default_repo) = repo_override .or_else(infer_repo_from_cwd) .or_else(|| std::env::var("HIVE_FORGE_REPO").ok()) else { bail!( "hive-forge: no repo specified — pass -r/--repo, run from inside a \ git checkout with an `origin` remote, or set HIVE_FORGE_REPO" ) }; let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?; let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url) .context("build forgejo client")?; let mut headers = HeaderMap::new(); let auth = format!("token {token}"); let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?; auth_val.set_sensitive(true); headers.insert(AUTHORIZATION, auth_val); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); let web = HttpClient::builder() .default_headers(headers) .build() .context("build reqwest client")?; Ok(Self { api, web, base, token, default_repo, json_mode, }) } /// The typed Forgejo REST client. All `/api/v1` traffic goes /// through this. #[must_use] pub fn api(&self) -> &forgejo_api::sync::Forgejo { &self.api } /// Assemble an authenticated git URL for `repo` (e.g. /// `internal/knowledge`) by injecting the agent's forge user + /// token into the base URL's authority: `http://:@host/.git`. /// The user comes from `HIVE_LABEL` (the agent's forge login), /// falling back to `oauth2` which Forgejo also accepts as the /// token-bearer username. Used by `clone` to clone/push. #[must_use] pub fn authed_git_url(&self, repo: &str) -> String { let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned()); // Split scheme from authority so credentials land in the right spot. let (scheme, host) = self .base .split_once("://") .unwrap_or(("http", self.base.as_str())); format!("{scheme}://{user}:{}@{host}/{repo}.git", self.token) } /// True when the operator passed the global `--json` flag. /// Verbs that have a human-readable default branch on this to /// emit JSON instead. Verbs whose only output format is JSON /// (e.g. `issue`, `pr`) can ignore it. #[must_use] pub fn json_mode(&self) -> bool { self.json_mode } /// Return the active repo. `from_env` already folded the /// `-r/--repo` override into `default_repo`, so verbs just read /// it as-is — no per-verb override plumbing. #[must_use] pub fn repo(&self) -> &str { &self.default_repo } /// The active repo split into `(owner, name)` for the typed /// client's per-segment path arguments. pub fn owner_repo(&self) -> Result<(&str, &str)> { split_repo(self.repo()) } /// Build the full URL for a Forgejo attachment by UUID. /// Attachments live at `/attachments/`, NOT under /// `/api/v1/`, so this uses `self.base` directly. #[must_use] pub fn attachment_url(&self, uuid: &str) -> String { format!("{}/attachments/{uuid}", self.base) } /// Build a full URL for a base-relative *web* path (i.e. NOT under /// `/api/v1/`). Used for endpoints Forgejo only serves through its /// web UI rather than the REST API --- e.g. Actions artifact /// downloads at `///actions/runs//artifacts/`. /// `path` should start with `/`. #[must_use] pub fn web_url(&self, path: &str) -> String { format!("{}{path}", self.base) } /// POST a JSON body to a base-relative *web* path (NOT under /// `/api/v1/`, so no `forgejo-api` method exists) and decode the /// JSON response. Used for endpoints Forgejo only serves through /// its web router — e.g. the Actions run-view log streamer at /// `///actions/runs//jobs/`. The /// agent's `Authorization: token` header is sent as usual; the /// web router accepts a token-authed doer and, because the handler /// consumes a JSON body rather than a CSRF-bound HTML form, no /// `_csrf` token is required (same auth path `get_bytes_raw` uses /// for the web artifact-download route). `path` should start `/`. pub fn post_json_web(&self, path: &str, body: &B) -> Result { let url = self.web_url(path); let resp = self .web .post(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("POST")?; let resp = check_status(resp, &format!("POST {url}"))?; resp.json::() .with_context(|| format!("decode JSON for POST {url}")) } /// GET a raw (non-API) URL and return the response body as bytes. /// Stays on the raw `reqwest` client because these are *web-router* /// routes (`/attachments/`, Actions artifact / persisted-log /// downloads) with no `/api/v1` equivalent. The client's auth /// headers are still sent — Forgejo requires them for private /// attachment downloads. Uses the full URL as-is; the caller is /// responsible for constructing it (see `attachment_url`). pub fn get_bytes_raw(&self, url: &str) -> Result> { let resp = self.web.get(url).send().context("GET")?; let resp = check_status(resp, &format!("GET {url}"))?; resp.bytes() .map(|b| b.to_vec()) .with_context(|| format!("read bytes for GET {url}")) } /// GET a JSON `/api/v1` route and deserialize into `T`, bypassing the /// typed `forgejo-api` client. Use this where the typed client's /// structs are too strict against the running Forgejo version: the /// crate pins one schema, but the server tracks the latest release /// line, so a drifted field breaks deserialization for the whole /// call. Callers pass a *lenient* local struct (only the fields they /// use, all `#[serde(default)]`) so a schema change can't wedge the /// call. `path` starts with `/` and is relative to `/api/v1`; `query` /// is appended as URL query parameters. /// /// # Errors /// Returns an error on transport failure, a non-2xx response (body /// included), or if the response body doesn't deserialize into `T`. pub fn get_api_json( &self, path: &str, query: &[(&str, &str)], ) -> Result { let base = format!("{}/api/v1{path}", self.base); let url = url::Url::parse_with_params(&base, query.iter().copied()) .with_context(|| format!("build url {base}"))?; let resp = self.web.get(url.clone()).send().context("GET")?; let resp = check_status(resp, &format!("GET {url}"))?; resp.json::() .with_context(|| format!("decode JSON for GET {url}")) } } /// Infer `owner/name` from the `origin` remote of the git checkout /// containing the current working directory. Shells out to /// `git remote get-url origin` rather than hand-parsing `.git/config` — /// git already knows how to find the enclosing repo from any /// subdirectory, honours `include`/worktrees/whatever config shape is /// actually on disk, and is one process spawn cheaper to trust than a /// bespoke parser. Returns `None` (never an error) on any failure — cwd /// isn't inside a repo, no `origin` remote, `git` isn't on `PATH`, or /// the URL doesn't parse into `owner/name` — so callers fall through to /// the next resolution step. fn infer_repo_from_cwd() -> Option { let output = std::process::Command::new("git") .args(["remote", "get-url", "origin"]) .output() .ok()?; if !output.status.success() { return None; } let url = String::from_utf8(output.stdout).ok()?; owner_repo_from_git_url(url.trim()) } /// Extract `owner/name` from a git remote URL. Handles the shapes this /// hive's remotes actually take: `http(s)://[user[:token]@]host/owner/name[.git]`. /// Strips the scheme, then any embedded credentials (up to the last /// `@`), then the host, then a trailing `.git`; the remaining path must /// be exactly two non-empty segments. Anything else (SSH `git@host:` / /// `ssh://` remotes, nested paths, malformed URLs) returns `None` — /// this hive's forge only ever serves flat `owner/name` repos over /// HTTP, so there's nothing else to support. fn owner_repo_from_git_url(url: &str) -> Option { let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); let without_creds = without_scheme .rsplit_once('@') .map_or(without_scheme, |(_, rest)| rest); let (_host, path) = without_creds.split_once('/')?; let path = path.strip_suffix(".git").unwrap_or(path); let mut segments = path.split('/').filter(|s| !s.is_empty()); let owner = segments.next()?; let name = segments.next()?; if segments.next().is_some() { return None; } Some(format!("{owner}/{name}")) } /// Split an `owner/name` repo string into its two path segments for /// the typed client. Errors on anything that isn't exactly /// `owner/name` with both halves non-empty. pub fn split_repo(repo: &str) -> Result<(&str, &str)> { match repo.split_once('/') { Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => { Ok((owner, name)) } _ => bail!("hive-forge: repo must be of the form owner/name, got {repo:?}"), } } /// True when a typed-client error is Forgejo saying 404 (resource /// absent rather than a transport / auth / server failure). Used by /// verbs that treat "not there" as a normal state (e.g. /// `subscription` reads a 404 as "not watching"). #[must_use] pub fn is_not_found(err: &ForgejoError) -> bool { match err { ForgejoError::ApiError(e) => { matches!(e.error_kind(), forgejo_api::ApiErrorKind::NotFound { .. }) } ForgejoError::UnexpectedStatusCode(code) => *code == reqwest::StatusCode::NOT_FOUND, _ => false, } } /// Convert a CLI-side `u64` issue/PR/comment number to the `i64` the /// typed client's path arguments use. Numbers past `i64::MAX` don't /// exist on any forge; error instead of wrapping. pub fn index(n: u64) -> Result { i64::try_from(n).with_context(|| format!("number {n} out of range")) } /// Resolve the `(base_url, token)` pair the client authenticates with. /// `None` (the default) resolves the internal forge exactly as before: /// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`. /// `Some(label)` (from `-f/--forge