refactor(hive-forge): port CLI verbs to forgejo-api
This commit is contained in:
parent
b8a3927c43
commit
4636987469
36 changed files with 1463 additions and 1153 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue