feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs

This commit is contained in:
damocles 2026-06-05 19:10:09 +02:00 committed by mara
commit c103ae5f10
7 changed files with 347 additions and 11 deletions

View file

@ -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.

View file

@ -53,8 +53,38 @@ hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a c
hive-forge attachment-get <uuid> # 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/<base>/<topic>`. 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 <verb> --help` prints the full signature for any verb.
### `pr-status`

View file

@ -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://<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 `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

View file

@ -77,6 +77,9 @@ enum Verb {
/// review verdicts, last-comment time (`--pr <n>`). `--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),

View file

@ -0,0 +1,123 @@
//! `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 (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<String>,
/// Branch to check out after cloning.
#[arg(long)]
branch: Option<String>,
/// Shallow-clone depth (omit for a full clone).
#[arg(long)]
depth: Option<u32>,
}
/// # 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");
}
}

View file

@ -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;

View file

@ -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/<base>/<topic>`
//! (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<String>,
/// 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<String>,
/// 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<String>,
/// Open the PR via Forgejo's `AGit` flow: push the current `HEAD`
/// to `refs/for/<base>/<topic>` 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<String>,
}
/// # 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/<base>/<topic>` 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<String> {
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<String> {
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 <remote> <head>` 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 = "\