294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! 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<reqwest::Client>` 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. Closes #421.
|
|
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<String>, json_mode: bool) -> Result<Self> {
|
|
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://<user>:<token>@host/<repo>.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 (`<base>/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 `<api>/<path>` and decode JSON.
|
|
pub fn get_json(&self, path: &str) -> Result<Value> {
|
|
let url = format!("{}{}", self.api(), path);
|
|
let resp = self.http.get(&url).send().context("GET")?;
|
|
decode_json(resp, &format!("GET {url}"))
|
|
}
|
|
|
|
/// GET `<api>/<path>` 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<Option<Value>> {
|
|
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 (closes #505).
|
|
pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result<Vec<Value>> {
|
|
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 `<api>/<path>` 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<String> {
|
|
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 `<api>/<path>` and decode the response.
|
|
pub fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
|
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 `<api>/<path>` and decode the response.
|
|
pub fn patch_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
|
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 `<api>/<path>` and decode the response.
|
|
pub fn put_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
|
|
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}"))
|
|
}
|
|
|
|
/// DELETE `<api>/<path>`. 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 `<base>/attachments/<uuid>`, 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)
|
|
}
|
|
|
|
/// 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<Vec<u8>> {
|
|
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<Value> {
|
|
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<String> {
|
|
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`). Closes #353's
|
|
/// "silent failures with no clue what went wrong" case.
|
|
fn check_status(resp: Response, op: &str) -> Result<Response> {
|
|
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<Value> {
|
|
let resp = check_status(resp, op)?;
|
|
resp.json::<Value>()
|
|
.with_context(|| format!("decode JSON for {op}"))
|
|
}
|
|
|
|
fn decode_text(resp: Response, op: &str) -> Result<String> {
|
|
let resp = check_status(resp, op)?;
|
|
resp.text().with_context(|| format!("decode text for {op}"))
|
|
}
|