hive-forge: add -f/--forge <label> selector for external forge accounts
Targets a dashboard-provisioned external forge account (FORGES tab) instead of the internal forge: resolves `forge-<label>-token` for the token and `forge-<label>.json`'s base_url for the URL, the same two files dashboard/extra_forges.rs writes, instead of HIVE_FORGE_URL/forge-token. Falls back to today's behavior when unset. Orthogonal to -r/--repo. An unknown label gives a clear error listing the labels actually found in the state dir instead of a raw file-not-found. The base_url JSON key is read via a typed sidecar struct pinned to what extra_forges.rs writes, so the read side can't silently drift from the write side.
This commit is contained in:
parent
60a253a2f6
commit
8e58793f79
2 changed files with 110 additions and 11 deletions
|
|
@ -11,7 +11,7 @@ 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::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
|
||||
|
|
@ -54,12 +54,18 @@ impl Client {
|
|||
/// 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());
|
||||
/// `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 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)
|
||||
|
|
@ -225,15 +231,93 @@ 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.
|
||||
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));
|
||||
};
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
/// 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 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 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())
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
//! `HIVE_FORGE_REPO` — default repo, e.g. `hyperhive/hyperhive`
|
||||
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
|
||||
//!
|
||||
//! The global `-f/--forge <label>` flag targets a dashboard-provisioned
|
||||
//! external forge account instead: it reads `forge-<label>-token` +
|
||||
//! `forge-<label>.json` (base URL) from the state dir rather than
|
||||
//! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`.
|
||||
//!
|
||||
//! Single binary with verb subcommands. Replaces the prior bash
|
||||
//! script (`hive-forge-tools.nix`) so that agents and operators get
|
||||
//! the same error handling, exit codes, and JSON shapes regardless
|
||||
|
|
@ -36,6 +41,15 @@ struct Cli {
|
|||
/// positional the bash helper used.
|
||||
#[arg(short = 'r', long, global = true)]
|
||||
repo: Option<String>,
|
||||
/// Target an external forge account provisioned via the dashboard's
|
||||
/// FORGES tab, by label, instead of the internal forge. Reads
|
||||
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` for the token and
|
||||
/// `forge-<label>.json` for the base URL (the same two files the
|
||||
/// dashboard writes) instead of `HIVE_FORGE_URL`/`forge-token`.
|
||||
/// Orthogonal to `-r/--repo`, which still just picks which repo on
|
||||
/// whichever forge is selected.
|
||||
#[arg(short = 'f', long, global = true)]
|
||||
forge: Option<String>,
|
||||
/// Emit JSON output instead of the verb's default human-readable
|
||||
/// shape, for verbs that support both. Verbs whose
|
||||
/// only output is already JSON (`issue`, `pr`, etc.) ignore this
|
||||
|
|
@ -181,7 +195,8 @@ enum Verb {
|
|||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
|
||||
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
|
||||
.context("initialize forge client")?;
|
||||
match cli.verb {
|
||||
Verb::View(a) => verbs::view::run(&client, a),
|
||||
Verb::Issue(a) => verbs::issue_cmd::run(&client, a),
|
||||
|
|
|
|||
Loading…
Reference in a new issue