From c103ae5f1084390ba95ec8f287bdfe062b14d727 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 5 Jun 2026 19:10:09 +0200 Subject: [PATCH] feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs --- CLAUDE.md | 2 +- docs/tools/forge.md | 30 +++++ hive-forge/src/client.rs | 22 ++++ hive-forge/src/main.rs | 4 + hive-forge/src/verbs/clone.rs | 123 +++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 1 + hive-forge/src/verbs/pr_create.rs | 176 ++++++++++++++++++++++++++++-- 7 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 hive-forge/src/verbs/clone.rs diff --git a/CLAUDE.md b/CLAUDE.md index 802312f7..f626bef4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,7 +292,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary) close, labels, list, milestone, branches, tree-sha, diff, subscription, attach-issue, attach-comment, attachment-get, lint, - pr-status). Replaces the + pr-status, clone). Replaces the 600-line hive-forge-tools.nix bash script. hive-matrix-mcp/ per-agent matrix-sdk integration. diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 96a3f53a..f3ff1afb 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -53,8 +53,38 @@ hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a c hive-forge attachment-get # download an attachment; prints resolved path to stdout hive-forge subscription --watch # subscribe to repo notifications hive-forge subscription --unwatch # unsubscribe +hive-forge -r internal/knowledge clone # clone with creds auto-injected +hive-forge -r internal/knowledge pr-create --agit --topic foo --title "..." # open PR via AGit (no fork) ``` +### Contributing to a read-only repo (`clone` + `pr-create --agit`) + +Agents are read-only collaborators on some repos (e.g. +`internal/knowledge`) and so can't push branches. Forgejo's AGit flow +lets a read-only user open a PR by pushing the current `HEAD` to the +magic ref `refs/for//`. Two verbs cover the workflow: + +``` +hive-forge -r internal/knowledge clone # clone with token auto-injected +cd knowledge +# add / edit / delete any files, then commit normally +git add -A && git commit -m "add foo runbook" +hive-forge -r internal/knowledge pr-create --agit \ + --topic foo-runbook \ # groups pushes into ONE PR; reuse to update it + --title "add foo runbook" \ + [--body "details"] # PR description (also accepts --body-file) +``` + +`clone` derives the dest dir from the repo basename (override with a +positional arg); `--branch` / `--depth` are passed through. The token +is injected into the clone's `origin` remote so `pr-create --agit` +(default remote `origin`) can push without re-auth. + +`pr-create --agit` prints the PR URL. Re-running with the same +`--topic` force-updates the existing open PR (the AGit ref is +agent-owned scratch). Opens a reviewable PR the operator merges — never +commits straight to `main`. + `hive-forge --help` prints the full signature for any verb. ### `pr-status` diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 1f61dc2b..e197e1af 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -23,6 +23,10 @@ const DEFAULT_REPO: &str = "hyperhive/hyperhive"; pub struct Client { http: HttpClient, base: String, + /// Per-agent forge token. Kept alongside the pre-built auth header + /// so verbs that shell out to `git` (e.g. `knowledge`) 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, @@ -59,11 +63,29 @@ impl Client { Ok(Self { http, base, + token, default_repo, json_mode, }) } + /// 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://:@host/.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 `knowledge` 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 diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 219b2a51..d086a556 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -77,6 +77,9 @@ enum Verb { /// review verdicts, last-comment time (`--pr `). `--sha` is a /// CI-only fast path. Exit code is a merge-readiness verdict. PrStatus(verbs::pr_status::Args), + /// Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with + /// credentials auto-injected. Pairs with `pr-create --agit`. + Clone(verbs::clone::Args), /// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments). Lint(verbs::lint::Args), /// List issues / PRs with filters (`--kind`, `--state`, `--assignee`, @@ -126,6 +129,7 @@ fn main() -> Result<()> { Verb::Close(a) => verbs::close::run(&client, a), Verb::Labels(a) => verbs::labels::run(&client, a), Verb::PrStatus(a) => verbs::pr_status::run(&client, a), + Verb::Clone(a) => verbs::clone::run(&client, a), Verb::Lint(a) => verbs::lint::run(&client, a), Verb::List(a) => verbs::list::run(&client, a), Verb::Milestone(a) => verbs::milestone::run(&client, a), diff --git a/hive-forge/src/verbs/clone.rs b/hive-forge/src/verbs/clone.rs new file mode 100644 index 00000000..d6b42d49 --- /dev/null +++ b/hive-forge/src/verbs/clone.rs @@ -0,0 +1,123 @@ +//! `clone [] [--branch ] [--depth ]` — 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 (closes #1399). + +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Args as ClapArgs; + +use crate::client::Client; + +#[derive(ClapArgs)] +pub struct Args { + /// Destination directory. Defaults to the repo's basename + /// (e.g. `internal/knowledge` → `knowledge`). + dest: Option, + /// Branch to check out after cloning. + #[arg(long)] + branch: Option, + /// Shallow-clone depth (omit for a full clone). + #[arg(long)] + depth: Option, +} + +/// # Errors +/// +/// Returns an error if the `git clone` shellout fails (bad repo, auth +/// rejected, network) or the destination can't be derived. +pub fn run(client: &Client, args: Args) -> Result<()> { + let repo = client.repo(); + let dest = match args.dest { + Some(d) => d, + None => repo + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?, + }; + let url = client.authed_git_url(repo); + + let mut git_args = vec!["clone".to_owned()]; + if let Some(depth) = args.depth { + git_args.push(format!("--depth={depth}")); + } + if let Some(branch) = &args.branch { + git_args.push("--branch".to_owned()); + git_args.push(branch.clone()); + } + git_args.push(url); + git_args.push(dest.clone()); + + let arg_refs: Vec<&str> = git_args.iter().map(String::as_str).collect(); + let out = Command::new("git") + .args(&arg_refs) + .output() + .context("spawn `git clone`")?; + if !out.status.success() { + // Scrub the token from any URL echoed back in git's error. + let stderr = scrub_credentials(&String::from_utf8_lossy(&out.stderr)); + bail!("clone: git clone {repo} failed:\n{stderr}"); + } + // Print the destination so callers can `cd` into it. + println!("{dest}"); + Ok(()) +} + +/// Redact the `user:token@` userinfo from any URL in `s` so a token +/// never lands in an error message / log. Replaces the credential span +/// with `***` while keeping the rest of the URL legible. +fn scrub_credentials(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(scheme_at) = rest.find("://") { + let after_scheme = scheme_at + 3; + // Userinfo runs from after `://` up to the next `@`, but only if + // that `@` comes before the next `/` (i.e. it's in the authority). + let authority = &rest[after_scheme..]; + let at = authority.find('@'); + let slash = authority.find('/'); + match (at, slash) { + (Some(a), maybe_slash) if maybe_slash.is_none_or(|sl| a < sl) => { + out.push_str(&rest[..after_scheme]); + out.push_str("***"); + out.push('@'); + rest = &authority[a + 1..]; + } + _ => { + // No credentials in this URL; emit up to here and move on. + out.push_str(&rest[..after_scheme]); + rest = authority; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scrubs_token_userinfo() { + let s = "fatal: unable to access 'http://damocles:deadbeef@localhost:3000/internal/knowledge.git/'"; + let scrubbed = scrub_credentials(s); + assert!(!scrubbed.contains("deadbeef")); + assert!(scrubbed.contains("http://***@localhost:3000/internal/knowledge.git")); + } + + #[test] + fn leaves_credential_free_urls_intact() { + let s = "Cloning into 'http://localhost:3000/x/y.git'..."; + assert_eq!(scrub_credentials(s), s); + } + + #[test] + fn handles_no_url() { + assert_eq!(scrub_credentials("plain message"), "plain message"); + } +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 29743197..ad0d8fbd 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -7,6 +7,7 @@ pub mod assign; pub mod attach; pub mod attachment_get; pub mod branches; +pub mod clone; pub mod close; pub mod comment; pub mod comment_edit; diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index a537a1c4..6b1a0c1d 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -10,6 +10,14 @@ //! through. Default behaviour is unchanged: no push unless asked. //! Closes the auto-push half of #222 per operator decision (opt-in //! flag). +//! +//! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of +//! the REST API: the current `HEAD` is pushed to `refs/for//` +//! (run from inside a cloned repo), which opens a PR even when the user +//! is a read-only collaborator who can't push branches. Re-running with +//! the same `--topic` updates the open PR. This is the path agents use +//! to contribute to `internal/knowledge`; pair it with `hive-forge clone` +//! (closes #1399). use std::process::{Command, Stdio}; @@ -25,9 +33,10 @@ pub struct Args { /// PR title. #[arg(long)] title: String, - /// Head branch. - #[arg(long)] - head: String, + /// Head branch. Not required (and ignored) in `--agit` mode, which + /// pushes the current `HEAD`. + #[arg(long, required_unless_present = "agit")] + head: Option, /// Base branch (default: main). #[arg(long, default_value = "main")] base: String, @@ -37,7 +46,8 @@ pub struct Args { /// Read body from a file. `-` means stdin. #[arg(long = "body-file")] body_file: Option, - /// Open as draft. + /// Open as draft. Ignored in `--agit` mode (the `AGit` push has no + /// draft push-option). #[arg(long)] draft: bool, /// Push the local `--head` branch to `--remote` before creating @@ -45,10 +55,22 @@ pub struct Args { /// hint block (we print the URL ourselves). #[arg(long)] push: bool, - /// Remote name to push to when `--push` is set. Defaults to the - /// hyperhive convention `forge`. - #[arg(long, default_value = "forge")] - remote: String, + /// Remote name to push to. Defaults to the hyperhive convention + /// `forge` for the normal flow, and `origin` in `--agit` mode (the + /// remote a `hive-forge clone` sets up). + #[arg(long)] + remote: Option, + /// Open the PR via Forgejo's `AGit` flow: push the current `HEAD` + /// to `refs/for//` instead of calling the REST API. + /// Works for read-only collaborators (no branch-push needed). Run + /// from inside a cloned repo. + #[arg(long)] + agit: bool, + /// `AGit` topic — groups repeated pushes into ONE PR (re-running + /// with the same topic updates it). Defaults to the current branch + /// name, or `contribution`. Only meaningful with `--agit`. + #[arg(long)] + topic: Option, } /// # Errors @@ -60,13 +82,23 @@ pub struct Args { /// URL to stdout. pub fn run(client: &Client, args: Args) -> Result<()> { let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default(); + if args.agit { + return agit_create(&args, &body); + } + // Normal REST flow. `--head` is required (clap enforces it unless + // `--agit`), so unwrap is safe here. + let head = args + .head + .as_deref() + .expect("clap requires --head unless --agit"); + let remote = args.remote.as_deref().unwrap_or("forge"); if args.push { - push_branch(&args.remote, &args.head)?; + push_branch(remote, head)?; } let repo = client.repo(); let payload = json!({ "title": args.title, - "head": args.head, + "head": head, "base": args.base, "body": body, "draft": args.draft, @@ -79,6 +111,100 @@ pub fn run(client: &Client, args: Args) -> Result<()> { Ok(()) } +/// Open (or update) a PR via Forgejo's `AGit` flow: push the current +/// `HEAD` to `refs/for//` with the PR metadata carried as +/// push options. Run from inside the cloned repo's working tree. +/// `force-push=true` lets a re-run with the same topic UPDATE the open +/// PR (the new commit branches from base's tip, which the forge sees as +/// non-fast-forward against the existing PR head and rejects without it). +fn agit_create(args: &Args, body: &str) -> Result<()> { + let remote = args.remote.as_deref().unwrap_or("origin"); + let topic = match &args.topic { + Some(t) => t.clone(), + None => current_branch().unwrap_or_else(|| "contribution".to_owned()), + }; + let refspec = format!("HEAD:refs/for/{}/{topic}", args.base); + let mut push_args = vec![ + "push".to_owned(), + remote.to_owned(), + refspec, + "-o".to_owned(), + format!("topic={topic}"), + "-o".to_owned(), + format!("title={}", args.title), + "-o".to_owned(), + "force-push=true".to_owned(), + ]; + if !body.is_empty() { + push_args.push("-o".to_owned()); + push_args.push(format!("description={body}")); + } + let arg_refs: Vec<&str> = push_args.iter().map(String::as_str).collect(); + let output = Command::new("git") + .args(&arg_refs) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `git push {remote}` (AGit)"))?; + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + anyhow::bail!("`git push {remote}` (AGit) failed:\n{stderr}"); + } + if let Some(url) = parse_pr_url(&stderr) { + println!("{url}"); + } else { + // Push landed but no PR URL in the sideband — surface the raw + // remote output so the caller can find the link themselves. + eprint!("{stderr}"); + println!( + "AGit push to refs/for/{}/{topic} succeeded (PR URL not parsed from output above)", + args.base + ); + } + Ok(()) +} + +/// Current git branch name (`git rev-parse --abbrev-ref HEAD`), or +/// `None` on detached HEAD / error. +fn current_branch() -> Option { + let out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let name = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + if name.is_empty() || name == "HEAD" { + None + } else { + Some(name) + } +} + +/// Scan `git push` stderr for the PR URL. The forge prints it on a +/// `remote:` sideband line following the "Create a new pull request" / +/// "Visit the existing pull request" opener; rather than match the +/// exact phrasing, we pick the first `remote:` URL containing `/pulls/`. +fn parse_pr_url(stderr: &str) -> Option { + for line in stderr.lines() { + let Some(content) = line.trim_start().strip_prefix("remote:") else { + continue; + }; + let content = content.trim_start(); + if let Some(start) = content.find("http") { + let url = content[start..] + .split_whitespace() + .next() + .unwrap_or_default(); + if url.contains("/pulls/") { + return Some(url.to_owned()); + } + } + } + None +} + /// Run `git push ` and pass stderr through with the /// post-push PR-hint block filtered out. On failure, bubble up /// git's full stderr so the user sees why the push didn't land. @@ -153,6 +279,36 @@ fn is_pr_hint_opener(line: &str) -> bool { mod tests { use super::*; + #[test] + fn agit_parse_pr_url_picks_pulls_link() { + let stderr = "\ +remote: Visit the existing pull request:\n\ +remote: http://forge.example/internal/knowledge/pulls/2\n\ +remote: \n\ +To http://localhost:3000/internal/knowledge.git\n\ + * [new reference] HEAD -> refs/pull/2/head\n"; + assert_eq!( + parse_pr_url(stderr).as_deref(), + Some("http://forge.example/internal/knowledge/pulls/2") + ); + } + + #[test] + fn agit_parse_pr_url_handles_created_phrasing() { + let stderr = "\ +remote: Create a new pull request:\n\ +remote: http://forge.example/internal/knowledge/pulls/7\n"; + assert_eq!( + parse_pr_url(stderr).as_deref(), + Some("http://forge.example/internal/knowledge/pulls/7") + ); + } + + #[test] + fn agit_parse_pr_url_none_when_absent() { + assert_eq!(parse_pr_url("To http://localhost:3000/x/y.git\n"), None); + } + #[test] fn strips_create_pr_hint_block() { let stderr = "\