hyperhive/hive-forge/src/client.rs

414 lines
17 KiB
Rust

//! 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::{Deserialize, 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";
/// Forgejo client pair: the typed `/api/v1` client plus a raw
/// `reqwest` client for web-router-only routes.
pub struct Client {
/// 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 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`.
/// `forge_label` (from the global `-f/--forge` flag) targets a
/// dashboard-provisioned external forge account instead of the
/// internal forge — see [`resolve_credentials`].
pub fn from_env(
repo_override: Option<String>,
json_mode: bool,
forge_label: Option<String>,
) -> Result<Self> {
let (base, token) = resolve_credentials(forge_label.as_deref())?;
let default_repo = repo_override
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
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 web = HttpClient::builder()
.default_headers(headers)
.build()
.context("build reqwest client")?;
Ok(Self {
api,
web,
base,
token,
default_repo,
json_mode,
})
}
/// 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`.
/// 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
}
/// 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
}
/// 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.
/// 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)
}
/// 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 `<base>/<owner>/<repo>/actions/runs/<n>/artifacts/<name>`.
/// `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/`, 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
/// 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<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
let url = self.web_url(path);
let resp = self
.web
.post(&url)
.header(CONTENT_TYPE, "application/json")
.json(body)
.send()
.context("POST")?;
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.
/// 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.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}"))
}
/// GET a JSON `/api/v1` route and deserialize into `T`, bypassing the
/// typed `forgejo-api` client. Use this where the typed client's
/// structs are too strict against the running Forgejo version: the
/// crate pins one schema, but the server tracks the latest release
/// line, so a drifted field breaks deserialization for the whole
/// call. Callers pass a *lenient* local struct (only the fields they
/// use, all `#[serde(default)]`) so a schema change can't wedge the
/// call. `path` starts with `/` and is relative to `/api/v1`; `query`
/// is appended as URL query parameters.
///
/// # Errors
/// Returns an error on transport failure, a non-2xx response (body
/// included), or if the response body doesn't deserialize into `T`.
pub fn get_api_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T> {
let base = format!("{}/api/v1{path}", self.base);
let url = url::Url::parse_with_params(&base, query.iter().copied())
.with_context(|| format!("build url {base}"))?;
let resp = self.web.get(url.clone()).send().context("GET")?;
let resp = check_status(resp, &format!("GET {url}"))?;
resp.json::<T>()
.with_context(|| format!("decode JSON for GET {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"))
}
/// Resolve the `(base_url, token)` pair the client authenticates with.
/// `None` (the default) resolves the internal forge exactly as before:
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
/// `Some(label)` (from `-f/--forge <label>`) instead resolves a
/// dashboard-provisioned external forge account: the token comes from
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
/// the `base_url` key of the sibling `forge-<label>.json` sidecar —
/// the exact same two files `dashboard/extra_forges.rs` writes, so the
/// read side can't drift from the write side. An unknown label (either
/// file missing) is a clear error listing the labels actually found in
/// the state dir, not a raw file-not-found. A label outside the plain
/// identifier charset the dashboard accepts (e.g. a typo containing
/// `/` or `..`) is rejected up front with the same charset spelled out,
/// rather than silently building a nonsense/traversing path and
/// surfacing a confusing file error later.
fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
let Some(label) = forge_label else {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let token = read_token().context("read forge-token")?;
return Ok((base, token));
};
if !is_plain_ident(label) {
bail!(
"hive-forge: invalid --forge label {label:?} — must be lowercase \
letters, digits, and hyphens only (same rule the dashboard's \
FORGES tab enforces)"
);
}
let dir = state_dir();
let token_path = dir.join(format!("forge-{label}-token"));
let sidecar_path = dir.join(format!("forge-{label}.json"));
let token = std::fs::read_to_string(&token_path).map(|s| s.trim().to_owned());
let sidecar = std::fs::read_to_string(&sidecar_path)
.ok()
.and_then(|s| serde_json::from_str::<ForgeSidecar>(&s).ok());
if let (Ok(token), Some(sidecar)) = (token, sidecar) {
return Ok((sidecar.base_url, token));
}
let known = provisioned_labels(&dir);
let known = if known.is_empty() {
"(none provisioned)".to_owned()
} else {
known.join(", ")
};
bail!("hive-forge: no such forge {label:?} — provisioned forges: {known}");
}
/// Plain-identifier check matching `dashboard/extra_forges.rs`'s
/// `is_plain_ident` (itself matching hive-priv's `validate_name_chars`)
/// — lowercase ascii + digits + hyphens only. Rejecting anything else
/// up front (rather than just letting a weird label fail to resolve a
/// file) turns a confusing "no such forge" surprise into a precise
/// "that's not a valid label" one, and incidentally means a label like
/// `../../etc` can't be used to build a path outside the state dir.
fn is_plain_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Sidecar shape `dashboard/extra_forges.rs` writes alongside each
/// `forge-<label>-token` file: just the base URL, pinned to the same
/// `base_url` JSON key the write side uses.
#[derive(Deserialize)]
struct ForgeSidecar {
base_url: String,
}
/// Scan the state dir for every `forge-<label>-token` file (mirroring
/// `dashboard/extra_forges.rs`'s own listing logic) and return the
/// labels found, sorted. Used to build a helpful "did you mean one of
/// these" error when `--forge <label>` doesn't resolve. Best-effort:
/// an unreadable state dir yields an empty list rather than erroring
/// (the caller already has its own error to report).
fn provisioned_labels(dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut labels: Vec<String> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|ft| ft.is_file()))
.filter_map(|e| {
e.file_name()
.to_str()?
.strip_prefix("forge-")?
.strip_suffix("-token")
.filter(|label| !label.is_empty())
.map(str::to_owned)
})
.collect();
labels.sort();
labels
}
/// The configured `HYPERHIVE_STATE_DIR`, falling back to `$PWD` when
/// unset — matches `read_token`'s fallback for the plain internal-forge
/// path.
fn state_dir() -> PathBuf {
match std::env::var("HYPERHIVE_STATE_DIR") {
Ok(s) if !s.is_empty() => PathBuf::from(s),
_ => PathBuf::from("."),
}
}
/// 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 path = 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 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() {
return Ok(resp);
}
let body = resp.text().unwrap_or_default();
bail!("hive-forge: {op} failed ({status}): {body}");
}
#[cfg(test)]
mod tests {
use super::split_repo;
#[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());
}
}