feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs
This commit is contained in:
parent
e7a68472fd
commit
c103ae5f10
7 changed files with 347 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
123
hive-forge/src/verbs/clone.rs
Normal file
123
hive-forge/src/verbs/clone.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = "\
|
||||
|
|
|
|||
Loading…
Reference in a new issue