hive-forge: infer active repo from cwd's git remote, demote HIVE_FORGE_REPO
This commit is contained in:
parent
a588ed42ce
commit
f034ffb9d8
9 changed files with 201 additions and 25 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1689,6 +1689,7 @@ dependencies = [
|
|||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ as a proper Rust binary). Use it instead of ad-hoc curl pipelines.
|
|||
## Credentials and repo defaults
|
||||
|
||||
- Credentials: `$HYPERHIVE_STATE_DIR/forge-token`
|
||||
- Default repo: `$HIVE_FORGE_REPO`
|
||||
- Per-invocation override: global `-r/--repo` flag
|
||||
- Active repo resolves, highest priority first: global `-r/--repo` flag
|
||||
> the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO`
|
||||
(last-resort override, unset by default) > a hard error. No single
|
||||
repo is assumed by default — see `client::Client::from_env`.
|
||||
|
||||
## Verbs
|
||||
|
||||
|
|
@ -207,7 +209,8 @@ and print its URL. Key flags:
|
|||
- `--description <TEXT>` — repo description
|
||||
|
||||
**`repo-add-collaborator <user>`** — grant a forge user access to the
|
||||
active repo (`-r`/`HIVE_FORGE_REPO`). Companion to `repo-create`. The
|
||||
active repo (see the repo-resolution chain above). Companion to
|
||||
`repo-create`. The
|
||||
`--permission` flag accepts `read` / `write` (default) / `admin`.
|
||||
`hive-c0re` uses this internally when an agent's config repo is
|
||||
initialised.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
|
|||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
|
||||
|
||||
The `hive-forge` CLI is the supported interface to the Forgejo — issues, PRs, comments, labels, reviews, CI status, attachments, triage (`lint`). **Discover the verb list and each verb's full signature with `hive-forge --help` and `hive-forge <verb> --help`** rather than memorising them. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. A few conventions `--help` won't surface: **never `curl` the forge** — the CLI handles auth and is the only supported path; to check a PR's CI + mergeability use `hive-forge pr-status --pr <n>` (or `--sha <commit>` for a CI-only fast path; exit code is a merge-readiness verdict), not curl. `--body-file -` reads the body from stdin, so a HEREDOC works for multi-line comments/issues: `hive-forge comment <num> --body-file - <<EOF ... EOF`. `hive-forge pr-create --title "..." --head <branch> [--push]` opens a PR and prints its URL; `--push` `git push`es the head branch first (default remote `forge`). Forge notifications are delivered via the internal message daemon (sender `forge`), not polling. A `forge` notification stays unread **on the forge** until you actually read its thread — viewing the referenced issue/PR with `hive-forge comments <n>` or `view <n>` marks that notification read (it's the forge's own read-state, not a local mirror). So when a `forge` message points you at a thread, read the thread to clear the notification instead of letting the same one linger and re-surface. (`hive-forge comment` does the opposite — it *refuses* to post to a thread with unread activity until you've read it, so read first, then comment.)
|
||||
The `hive-forge` CLI is the supported interface to the Forgejo — issues, PRs, comments, labels, reviews, CI status, attachments, triage (`lint`). **Discover the verb list and each verb's full signature with `hive-forge --help` and `hive-forge <verb> --help`** rather than memorising them. The active repo is whichever git checkout you're standing in (inferred from the `origin` remote), or pass `-r <repo>` (global flag, works before or after the verb) to target a different one explicitly — there's no single hardcoded default repo, so don't assume one. A few conventions `--help` won't surface: **never `curl` the forge** — the CLI handles auth and is the only supported path; to check a PR's CI + mergeability use `hive-forge pr-status --pr <n>` (or `--sha <commit>` for a CI-only fast path; exit code is a merge-readiness verdict), not curl. `--body-file -` reads the body from stdin, so a HEREDOC works for multi-line comments/issues: `hive-forge comment <num> --body-file - <<EOF ... EOF`. `hive-forge pr-create --title "..." --head <branch> [--push]` opens a PR and prints its URL; `--push` `git push`es the head branch first (default remote `forge`). Forge notifications are delivered via the internal message daemon (sender `forge`), not polling. A `forge` notification stays unread **on the forge** until you actually read its thread — viewing the referenced issue/PR with `hive-forge comments <n>` or `view <n>` marks that notification read (it's the forge's own read-state, not a local mirror). So when a `forge` message points you at a thread, read the thread to clear the notification instead of letting the same one linger and re-surface. (`hive-forge comment` does the opposite — it *refuses* to post to a thread with unread activity until you've read it, so read first, then comment.)
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,5 +39,8 @@ reqwest = { workspace = true, features = [
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ consistent error handling, exit codes, and JSON shapes. This is the
|
|||
never `curl` it directly.
|
||||
|
||||
Reads credentials from the environment (`HIVE_FORGE_URL`,
|
||||
`HIVE_FORGE_REPO`, `HYPERHIVE_STATE_DIR`); `-f/--forge <label>`
|
||||
retargets a dashboard-provisioned external forge account instead.
|
||||
`HYPERHIVE_STATE_DIR`); `-f/--forge <label>` retargets a
|
||||
dashboard-provisioned external forge account instead. The active repo
|
||||
resolves `-r/--repo` > the `origin` remote of the cwd's git checkout >
|
||||
`HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard
|
||||
error — see `client::Client::from_env`.
|
||||
|
||||
## When to use it
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,6 @@ 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 {
|
||||
|
|
@ -50,10 +46,20 @@ pub struct Client {
|
|||
|
||||
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`.
|
||||
///
|
||||
/// Repo resolution, highest priority first: the global `-r/--repo`
|
||||
/// flag (`repo_override`); the `origin` remote of the git checkout
|
||||
/// containing the current working directory (see
|
||||
/// [`infer_repo_from_cwd`]); the `HIVE_FORGE_REPO` env var (kept as
|
||||
/// a last-resort override for callers that aren't in any checkout —
|
||||
/// nothing in this hive's nix config sets it, so it's opt-in only).
|
||||
/// No repo resolving from any of those is an error rather than a
|
||||
/// silent default — a single hardcoded fallback repo was exactly
|
||||
/// what made agents assume it was "the only repo they're allowed to
|
||||
/// operate on".
|
||||
///
|
||||
/// `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`].
|
||||
|
|
@ -63,9 +69,15 @@ impl Client {
|
|||
forge_label: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let (base, token) = resolve_credentials(forge_label.as_deref())?;
|
||||
let default_repo = repo_override
|
||||
let Some(default_repo) = repo_override
|
||||
.or_else(infer_repo_from_cwd)
|
||||
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
|
||||
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
|
||||
else {
|
||||
bail!(
|
||||
"hive-forge: no repo specified — pass -r/--repo, run from inside a \
|
||||
git checkout with an `origin` remote, or set HIVE_FORGE_REPO"
|
||||
)
|
||||
};
|
||||
|
||||
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)
|
||||
|
|
@ -224,6 +236,83 @@ impl Client {
|
|||
}
|
||||
}
|
||||
|
||||
/// Infer `owner/name` from the `origin` remote of the git checkout
|
||||
/// containing the current working directory. Walks upward from `$PWD`
|
||||
/// looking for a `.git` directory (so it works from any subdirectory of
|
||||
/// a checkout, not just the root), reads its `config` file for the
|
||||
/// `[remote "origin"]` section's `url`, and extracts the repo path.
|
||||
/// Returns `None` (never an error) on any failure — an unreadable cwd,
|
||||
/// no `.git` directory found, no `origin` remote, or a URL that doesn't
|
||||
/// parse into `owner/name` — so callers can fall through to the next
|
||||
/// resolution step.
|
||||
fn infer_repo_from_cwd() -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok()?;
|
||||
let git_dir = find_git_dir(&cwd)?;
|
||||
let config = std::fs::read_to_string(git_dir.join("config")).ok()?;
|
||||
let url = origin_url(&config)?;
|
||||
owner_repo_from_git_url(&url)
|
||||
}
|
||||
|
||||
/// Walk upward from `start` looking for a `.git` directory. Plain
|
||||
/// checkouts only — a `.git` *file* (submodules, linked worktrees)
|
||||
/// isn't followed, since agents don't use either shape here.
|
||||
fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> {
|
||||
let mut dir = start;
|
||||
loop {
|
||||
let candidate = dir.join(".git");
|
||||
if candidate.is_dir() {
|
||||
return Some(candidate);
|
||||
}
|
||||
dir = dir.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the `[remote "origin"]` section's `url` value from a git
|
||||
/// config file's contents. Minimal line-based INI parsing — just
|
||||
/// enough for what `git init`/`git remote add` actually write, not a
|
||||
/// general config parser.
|
||||
fn origin_url(config: &str) -> Option<String> {
|
||||
let mut in_origin = false;
|
||||
for line in config.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(section) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
in_origin = section == "remote \"origin\"";
|
||||
continue;
|
||||
}
|
||||
if in_origin
|
||||
&& let Some(rest) = trimmed.strip_prefix("url")
|
||||
&& let Some(value) = rest.trim_start().strip_prefix('=')
|
||||
{
|
||||
return Some(value.trim().to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `owner/name` from a git remote URL. Handles the shapes this
|
||||
/// hive's remotes actually take: `http(s)://[user[:token]@]host/owner/name[.git]`.
|
||||
/// Strips the scheme, then any embedded credentials (up to the last
|
||||
/// `@`), then the host, then a trailing `.git`; the remaining path must
|
||||
/// be exactly two non-empty segments. Anything else (SSH `git@host:` /
|
||||
/// `ssh://` remotes, nested paths, malformed URLs) returns `None` —
|
||||
/// this hive's forge only ever serves flat `owner/name` repos over
|
||||
/// HTTP, so there's nothing else to support.
|
||||
fn owner_repo_from_git_url(url: &str) -> Option<String> {
|
||||
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let without_creds = without_scheme
|
||||
.rsplit_once('@')
|
||||
.map_or(without_scheme, |(_, rest)| rest);
|
||||
let (_host, path) = without_creds.split_once('/')?;
|
||||
let path = path.strip_suffix(".git").unwrap_or(path);
|
||||
let mut segments = path.split('/').filter(|s| !s.is_empty());
|
||||
let owner = segments.next()?;
|
||||
let name = segments.next()?;
|
||||
if segments.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{owner}/{name}"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -389,7 +478,78 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_repo;
|
||||
use super::{find_git_dir, origin_url, owner_repo_from_git_url, split_repo};
|
||||
|
||||
#[test]
|
||||
fn owner_repo_from_git_url_strips_scheme_and_creds() {
|
||||
assert_eq!(
|
||||
owner_repo_from_git_url(
|
||||
"http://damocles:abc123token@forge.pr1ma.darkest.space/hyperhive/hyperhive.git"
|
||||
),
|
||||
Some("hyperhive/hyperhive".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_repo_from_git_url_handles_plain_https_no_creds() {
|
||||
assert_eq!(
|
||||
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/hyperhive/hive-claude.git"),
|
||||
Some("hyperhive/hive-claude".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_repo_from_git_url_tolerates_missing_dot_git_suffix() {
|
||||
assert_eq!(
|
||||
owner_repo_from_git_url("https://forge.pr1ma.darkest.space/internal/knowledge"),
|
||||
Some("internal/knowledge".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_repo_from_git_url_rejects_nested_paths() {
|
||||
assert_eq!(
|
||||
owner_repo_from_git_url("https://forge.example/a/b/c.git"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_repo_from_git_url_rejects_host_only() {
|
||||
assert_eq!(owner_repo_from_git_url("https://forge.example"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_url_finds_url_in_origin_section_only() {
|
||||
let config = "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = https://wrong/repo.git\n[remote \"origin\"]\n\turl = https://forge.pr1ma.darkest.space/hyperhive/hyperhive.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n";
|
||||
assert_eq!(
|
||||
origin_url(config),
|
||||
Some("https://forge.pr1ma.darkest.space/hyperhive/hyperhive.git".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_url_none_without_origin_remote() {
|
||||
let config = "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = https://wrong/repo.git\n";
|
||||
assert_eq!(origin_url(config), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_dir_walks_up_from_subdirectory() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
let nested = root.path().join("a").join("b");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(find_git_dir(&nested), Some(root.path().join(".git")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_dir_none_when_no_ancestor_has_one() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let nested = root.path().join("a");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(find_git_dir(&nested), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_repo_accepts_owner_name() {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
//! REST API. Reads credentials from the environment:
|
||||
//!
|
||||
//! `HIVE_FORGE_URL` — base URL, e.g. `http://localhost:3000`
|
||||
//! `HIVE_FORGE_REPO` — default repo, e.g. `hyperhive/hyperhive`
|
||||
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
|
||||
//!
|
||||
//! The active repo resolves `-r/--repo` > the `origin` remote of the
|
||||
//! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset
|
||||
//! by default) > a hard error — see `client::Client::from_env`.
|
||||
//!
|
||||
//! 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
|
||||
|
|
@ -37,8 +40,9 @@ use std::process::ExitCode;
|
|||
disable_help_subcommand = true
|
||||
)]
|
||||
struct Cli {
|
||||
/// Repo to act on, as `owner/name` (default: `HIVE_FORGE_REPO`).
|
||||
/// Works with any verb.
|
||||
/// Repo to act on, as `owner/name` (default: inferred from the
|
||||
/// cwd's git `origin` remote, then `HIVE_FORGE_REPO`). Works with
|
||||
/// any verb.
|
||||
#[arg(short = 'r', long, global = true)]
|
||||
repo: Option<String>,
|
||||
/// Act as a dashboard-provisioned external forge account (by its
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! `clone [<dest>] [--branch <b>] [--depth <n>]` — clone a forge repo
|
||||
//! with credentials auto-injected, so agents don't hand-assemble
|
||||
//! token-bearing URLs. The repo is the standard `-r/--repo` (default
|
||||
//! `HIVE_FORGE_REPO`). Pairs with `pr-create --agit`: clone, edit +
|
||||
//! commit normally, then open a PR via the `AGit` ref.
|
||||
//! token-bearing URLs. The repo is the standard `-r/--repo` (see
|
||||
//! `client::Client::from_env` for the full resolution chain). Pairs
|
||||
//! with `pr-create --agit`: clone, edit + commit normally, then open a
|
||||
//! PR via the `AGit` ref.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! `repo-add-collaborator <user> [--permission read|write|admin] [-r <repo>]`
|
||||
//! — add `<user>` as a collaborator on the active repo (from `-r` /
|
||||
//! `HIVE_FORGE_REPO`). Prints a one-line confirmation.
|
||||
//! — add `<user>` as a collaborator on the active repo (see
|
||||
//! `client::Client::from_env` for how it resolves). Prints a one-line
|
||||
//! confirmation.
|
||||
//!
|
||||
//! Wraps `PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator}`.
|
||||
//! A fresh agent-namespace repo (see `repo-create`) usually needs peers
|
||||
|
|
|
|||
Loading…
Reference in a new issue