414 lines
17 KiB
Rust
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());
|
|
}
|
|
}
|