refactor(hive-forge): port CLI verbs to forgejo-api

This commit is contained in:
müde 2026-07-07 09:24:53 +02:00
commit 4636987469
36 changed files with 1463 additions and 1153 deletions

View file

@ -1,11 +1,14 @@
//! 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.
//! 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;
@ -18,14 +21,23 @@ const DEFAULT_URL: &str = "http://localhost:3000";
/// take a repo override.
const DEFAULT_REPO: &str = "hyperhive/hyperhive";
/// Blocking Forgejo API client. Cheap to clone (wraps an
/// `Arc<reqwest::Client>` internally).
/// Forgejo client pair: the typed `/api/v1` client plus a raw
/// `reqwest` client for web-router-only routes.
pub struct Client {
http: HttpClient,
/// 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/<uuid>`,
/// 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 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.
/// 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.
@ -49,19 +61,24 @@ impl Client {
.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 http = HttpClient::builder()
let web = HttpClient::builder()
.default_headers(headers)
.build()
.context("build reqwest client")?;
Ok(Self {
http,
api,
web,
base,
token,
default_repo,
@ -69,6 +86,13 @@ impl Client {
})
}
/// 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://<user>:<token>@host/<repo>.git`.
@ -95,11 +119,6 @@ impl Client {
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.
@ -108,177 +127,10 @@ impl Client {
&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.
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}"))
}
/// PUT a JSON body to `<api>/<path>` 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<B: Serialize>(&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(())
}
/// PATCH `<api>/<path>` for an endpoint that returns a 2xx with an
/// empty body (nothing to decode). Used to mark a notification thread
/// read (`/notifications/threads/{id}` answers `205 Reset Content`).
///
/// # 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 patch_no_content(&self, path: &str) -> Result<()> {
let url = format!("{}{}", self.api(), path);
let resp = self.http.patch(&url).send().context("PATCH")?;
check_status(resp, &format!("PATCH {url}"))?;
Ok(())
}
/// POST a JSON body to `<api>/<path>` 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<B: Serialize>(&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 `<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(())
/// 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.
@ -300,9 +152,9 @@ impl Client {
}
/// 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
/// `/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
/// `<base>/<owner>/<repo>/actions/runs/<run>/jobs/<job>`. The
/// agent's `Authorization: token` header is sent as usual; the
/// web router accepts a token-authed doer and, because the handler
@ -312,44 +164,67 @@ impl Client {
pub fn post_json_web<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
let url = self.web_url(path);
let resp = self
.http
.web
.post(&url)
.header(CONTENT_TYPE, "application/json")
.json(body)
.send()
.context("POST")?;
decode_json(resp, &format!("POST {url}"))
let resp = check_status(resp, &format!("POST {url}"))?;
resp.json::<Value>()
.with_context(|| format!("decode JSON for 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`).
/// Stays on the raw `reqwest` client because these are *web-router*
/// routes (`/attachments/<uuid>`, 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<Vec<u8>> {
let resp = self.http.get(url).send().context("GET")?;
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}"))
}
}
/// 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}"))
/// 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> {
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<String> {
@ -364,9 +239,10 @@ fn read_token() -> Result<String> {
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.
/// 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<Response> {
let status = resp.status();
if status.is_success() {
@ -376,13 +252,28 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
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}"))
}
#[cfg(test)]
mod tests {
use super::split_repo;
fn decode_text(resp: Response, op: &str) -> Result<String> {
let resp = check_status(resp, op)?;
resp.text().with_context(|| format!("decode text for {op}"))
#[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());
}
}