//! Blocking HTTP client for the Forgejo REST API. Identity is the //! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All //! verbs go through this client so error surfaces, header set, and //! 4xx/5xx body unwrapping stay consistent. use std::path::PathBuf; use anyhow::{Context, Result, bail}; 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"; /// Blocking Forgejo API client. Cheap to clone (wraps an /// `Arc` internally). pub struct Client { http: HttpClient, base: String, /// Per-agent forge token. Kept alongside the pre-built auth header /// 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 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 http = HttpClient::builder() .default_headers(headers) .build() .context("build reqwest client")?; Ok(Self { http, base, token, default_repo, json_mode, }) } /// 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 } /// Resolve the API base path (`/api/v1`). fn api(&self) -> String { format!("{}/api/v1", self.base) } /// 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 } /// GET `/` and decode JSON. pub fn get_json(&self, path: &str) -> Result { let url = format!("{}{}", self.api(), path); let resp = self.http.get(&url).send().context("GET")?; decode_json(resp, &format!("GET {url}")) } /// GET `/` and decode JSON. Returns `None` if the /// server responds with 404 (resource absent rather than an error). pub fn get_json_optional(&self, path: &str) -> Result> { let url = format!("{}{}", self.api(), path); let resp = self.http.get(&url).send().context("GET")?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } decode_json(resp, &format!("GET {url}")).map(Some) } /// GET a paginated list endpoint and concatenate all pages. /// `path` should NOT include `page=` (we own it); other query /// params (`?limit=N&state=open&...`) are preserved. Pages drain /// while the response carries a `Link: rel="next"` header, up to /// `max_pages` (the runaway-loop safety cap). Returns the merged /// array. Used by `lint` for repo-wide queries. pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result> { let sep = if path.contains('?') { '&' } else { '?' }; let mut merged = Vec::new(); for page in 1..=max_pages { let url = format!("{}{}{sep}page={page}", self.api(), path); let resp = self.http.get(&url).send().context("GET")?; let has_next = resp .headers() .get(reqwest::header::LINK) .and_then(|v| v.to_str().ok()) .is_some_and(|s| s.contains("rel=\"next\"")); let v = decode_json(resp, &format!("GET {url}"))?; let arr = v.as_array().cloned().unwrap_or_default(); let empty = arr.is_empty(); merged.extend(arr); if empty || !has_next { break; } } Ok(merged) } /// GET `/` and return the raw response body as text /// (used by `diff` which fetches a `text/plain` blob). pub fn get_text(&self, path: &str, accept: &str) -> Result { let url = format!("{}{}", self.api(), path); let resp = self .http .get(&url) .header(ACCEPT, accept) .send() .context("GET")?; decode_text(resp, &format!("GET {url}")) } /// POST a JSON body to `/` and decode the response. pub fn post_json(&self, path: &str, body: &B) -> Result { let url = format!("{}{}", self.api(), path); let resp = self .http .post(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("POST")?; decode_json(resp, &format!("POST {url}")) } /// PATCH a JSON body to `/` and decode the response. pub fn patch_json(&self, path: &str, body: &B) -> Result { let url = format!("{}{}", self.api(), path); let resp = self .http .patch(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("PATCH")?; decode_json(resp, &format!("PATCH {url}")) } /// PUT a JSON body to `/` and decode the response. pub fn put_json(&self, path: &str, body: &B) -> Result { let url = format!("{}{}", self.api(), path); let resp = self .http .put(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("PUT")?; decode_json(resp, &format!("PUT {url}")) } /// PUT a JSON body to `/` for an endpoint that returns /// `204 No Content` (empty body), so there is nothing to decode. /// Used by `repo-add-collaborator` (Forgejo's add-collaborator PUT /// answers 204 on success). /// /// # Errors /// /// Returns an error if the request fails to send (transport/network /// error) or the server responds with a non-2xx status (the response /// body is included in the error). pub fn put_no_content(&self, path: &str, body: &B) -> Result<()> { let url = format!("{}{}", self.api(), path); let resp = self .http .put(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("PUT")?; check_status(resp, &format!("PUT {url}"))?; Ok(()) } /// POST a JSON body to `/` for an endpoint that returns a /// 2xx with an empty body (so there is nothing to decode). Used by /// `pr-merge` — Forgejo's merge endpoint answers `200 OK` with no body /// on success and a non-2xx (e.g. `405`) when the PR is not mergeable. /// /// # Errors /// /// Returns an error if the request fails to send (transport/network /// error) or the server responds with a non-2xx status (the response /// body is included in the error). pub fn post_no_content(&self, path: &str, body: &B) -> Result<()> { let url = format!("{}{}", self.api(), path); let resp = self .http .post(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("POST")?; check_status(resp, &format!("POST {url}"))?; Ok(()) } /// DELETE `/`. Optional JSON body for endpoints that /// need it (Forgejo's subscription unwatch uses bodyless DELETE). pub fn delete(&self, path: &str, body: Option<&Value>) -> Result<()> { let url = format!("{}{}", self.api(), path); let mut req = self.http.delete(&url); if let Some(b) = body { req = req.header(CONTENT_TYPE, "application/json").json(b); } let resp = req.send().context("DELETE")?; check_status(resp, &format!("DELETE {url}"))?; Ok(()) } /// 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/`) 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 .http .post(&url) .header(CONTENT_TYPE, "application/json") .json(body) .send() .context("POST")?; decode_json(resp, &format!("POST {url}")) } /// GET a raw (non-API) URL and return the response body as bytes. /// 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.http.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}")) } /// POST a multipart file upload, returning the parsed response. /// Used by `attach-issue` / `attach-comment`. pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result { let url = format!("{}{}", self.api(), path); let form = reqwest::blocking::multipart::Form::new() .file("attachment", file) .with_context(|| format!("read {}", file.display()))?; let resp = self .http .post(&url) .multipart(form) .send() .context("POST")?; decode_json(resp, &format!("POST {url}")) } } /// 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 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}"); } fn decode_json(resp: Response, op: &str) -> Result { let resp = check_status(resp, op)?; resp.json::() .with_context(|| format!("decode JSON for {op}")) } fn decode_text(resp: Response, op: &str) -> Result { let resp = check_status(resp, op)?; resp.text().with_context(|| format!("decode text for {op}")) }