//! 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::Serialize; use serde_json::Value; /// Default Forgejo URL when `HIVE_FORGE_URL` is unset. const DEFAULT_URL: &str = "http://localhost:3000"; /// Default repo when `HIVE_FORGE_REPO` is unset and the verb doesn't /// take a repo override. const DEFAULT_REPO: &str = "hyperhive/hyperhive"; /// 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_override` (from the global `-r/--repo` flag) takes /// priority over `HIVE_FORGE_REPO`; the env var is the fallback /// default. `json_mode` comes from the global `--json` flag — /// per-verb output formatters key off it via `Client::json_mode`. pub fn from_env(repo_override: Option, json_mode: bool) -> Result { let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned()); let default_repo = repo_override .or_else(|| std::env::var("HIVE_FORGE_REPO").ok()) .unwrap_or_else(|| DEFAULT_REPO.to_owned()); let token = read_token().context("read forge-token")?; 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}")) } } /// 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")) } /// Locate and read the forge token. Falls back to `$PWD/forge-token` /// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. fn read_token() -> Result { let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); let path = if state_dir.is_empty() { PathBuf::from("forge-token") } else { PathBuf::from(state_dir).join("forge-token") }; let raw = std::fs::read_to_string(&path) .with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?; Ok(raw.trim().to_owned()) } /// Surface non-2xx HTTP responses on the raw web routes as anyhow /// errors with the response body included (matches `curl /// --fail-with-body`) — turns silent failures into errors with a /// clear message. fn check_status(resp: Response, op: &str) -> Result { let status = resp.status(); if status.is_success() { return Ok(resp); } let body = resp.text().unwrap_or_default(); bail!("hive-forge: {op} failed ({status}): {body}"); } #[cfg(test)] mod tests { use super::split_repo; #[test] fn split_repo_accepts_owner_name() { assert_eq!( split_repo("hyperhive/hyperhive").unwrap(), ("hyperhive", "hyperhive") ); assert_eq!( split_repo("internal/knowledge").unwrap(), ("internal", "knowledge") ); } #[test] fn split_repo_rejects_malformed() { assert!(split_repo("no-slash").is_err()); assert!(split_repo("/name").is_err()); assert!(split_repo("owner/").is_err()); assert!(split_repo("a/b/c").is_err()); assert!(split_repo("").is_err()); } }