diff --git a/CLAUDE.md b/CLAUDE.md index f626bef4..802312f7 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, clone). Replaces the + pr-status). 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 f3ff1afb..96a3f53a 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -53,38 +53,8 @@ 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 3308fc9e..1f61dc2b 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -23,10 +23,6 @@ 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. `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, @@ -63,29 +59,11 @@ 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 `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 diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index d086a556..219b2a51 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -77,9 +77,6 @@ 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`, @@ -129,7 +126,6 @@ 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 deleted file mode 100644 index 753fc42f..00000000 --- a/hive-forge/src/verbs/clone.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! `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. - -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 ad0d8fbd..29743197 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -7,7 +7,6 @@ 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 3ca586bf..a537a1c4 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -10,13 +10,6 @@ //! 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`. use std::process::{Command, Stdio}; @@ -32,10 +25,9 @@ pub struct Args { /// PR title. #[arg(long)] title: String, - /// Head branch. Not required (and ignored) in `--agit` mode, which - /// pushes the current `HEAD`. - #[arg(long, required_unless_present = "agit")] - head: Option, + /// Head branch. + #[arg(long)] + head: String, /// Base branch (default: main). #[arg(long, default_value = "main")] base: String, @@ -45,8 +37,7 @@ pub struct Args { /// Read body from a file. `-` means stdin. #[arg(long = "body-file")] body_file: Option, - /// Open as draft. Ignored in `--agit` mode (the `AGit` push has no - /// draft push-option). + /// Open as draft. #[arg(long)] draft: bool, /// Push the local `--head` branch to `--remote` before creating @@ -54,22 +45,10 @@ pub struct Args { /// hint block (we print the URL ourselves). #[arg(long)] push: bool, - /// 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, + /// Remote name to push to when `--push` is set. Defaults to the + /// hyperhive convention `forge`. + #[arg(long, default_value = "forge")] + remote: String, } /// # Errors @@ -81,23 +60,13 @@ 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(remote, head)?; + push_branch(&args.remote, &args.head)?; } let repo = client.repo(); let payload = json!({ "title": args.title, - "head": head, + "head": args.head, "base": args.base, "body": body, "draft": args.draft, @@ -110,100 +79,6 @@ 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. @@ -278,36 +153,6 @@ 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 = "\