hive-forge: rewrite bash CLI helper as a rust binary (closes #280)
This commit is contained in:
parent
560360d2e3
commit
595e3c040c
28 changed files with 1434 additions and 612 deletions
173
hive-forge/src/client.rs
Normal file
173
hive-forge/src/client.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! 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,
|
||||
/// Default repo used when a verb doesn't carry an explicit
|
||||
/// `[repo]` override.
|
||||
pub default_repo: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Build a client from the standard environment variables.
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
|
||||
let default_repo =
|
||||
std::env::var("HIVE_FORGE_REPO").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,
|
||||
default_repo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the API base path (`<base>/api/v1`).
|
||||
fn api(&self) -> String {
|
||||
format!("{}/api/v1", self.base)
|
||||
}
|
||||
|
||||
/// Pick the user-supplied repo or fall back to the default.
|
||||
pub fn repo<'a>(&'a self, override_: Option<&'a str>) -> &'a str {
|
||||
override_.unwrap_or(&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 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}"))
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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}"))
|
||||
}
|
||||
Loading…
Reference in a new issue