279 lines
11 KiB
Rust
279 lines
11 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::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`.
|
|
pub fn from_env(repo_override: Option<String>, json_mode: bool) -> Result<Self> {
|
|
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
|
|
let default_repo = repo_override
|
|
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
|
|
.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 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}"))
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
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 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());
|
|
}
|
|
}
|