Compare commits
7 changed files with 11 additions and 346 deletions
|
|
@ -292,7 +292,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||||
close, labels, list, milestone, branches,
|
close, labels, list, milestone, branches,
|
||||||
tree-sha, diff, subscription, attach-issue,
|
tree-sha, diff, subscription, attach-issue,
|
||||||
attach-comment, attachment-get, lint,
|
attach-comment, attachment-get, lint,
|
||||||
pr-status, clone). Replaces the
|
pr-status). Replaces the
|
||||||
600-line hive-forge-tools.nix bash script.
|
600-line hive-forge-tools.nix bash script.
|
||||||
|
|
||||||
hive-matrix-mcp/ per-agent matrix-sdk integration.
|
hive-matrix-mcp/ per-agent matrix-sdk integration.
|
||||||
|
|
|
||||||
|
|
@ -53,38 +53,8 @@ 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 attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
||||||
hive-forge subscription --watch # subscribe to repo notifications
|
hive-forge subscription --watch # subscribe to repo notifications
|
||||||
hive-forge subscription --unwatch # unsubscribe
|
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.
|
`hive-forge <verb> --help` prints the full signature for any verb.
|
||||||
|
|
||||||
### `pr-status`
|
### `pr-status`
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,6 @@ const DEFAULT_REPO: &str = "hyperhive/hyperhive";
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
base: String,
|
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
|
/// Default repo used when a verb doesn't carry an explicit
|
||||||
/// `[repo]` override.
|
/// `[repo]` override.
|
||||||
pub default_repo: String,
|
pub default_repo: String,
|
||||||
|
|
@ -63,29 +59,11 @@ impl Client {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
base,
|
base,
|
||||||
token,
|
|
||||||
default_repo,
|
default_repo,
|
||||||
json_mode,
|
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 `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.
|
/// True when the operator passed the global `--json` flag.
|
||||||
/// Verbs that have a human-readable default branch on this to
|
/// Verbs that have a human-readable default branch on this to
|
||||||
/// emit JSON instead. Verbs whose only output format is JSON
|
/// emit JSON instead. Verbs whose only output format is JSON
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,6 @@ enum Verb {
|
||||||
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
|
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
|
||||||
/// CI-only fast path. Exit code is a merge-readiness verdict.
|
/// CI-only fast path. Exit code is a merge-readiness verdict.
|
||||||
PrStatus(verbs::pr_status::Args),
|
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).
|
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
||||||
Lint(verbs::lint::Args),
|
Lint(verbs::lint::Args),
|
||||||
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
||||||
|
|
@ -129,7 +126,6 @@ fn main() -> Result<()> {
|
||||||
Verb::Close(a) => verbs::close::run(&client, a),
|
Verb::Close(a) => verbs::close::run(&client, a),
|
||||||
Verb::Labels(a) => verbs::labels::run(&client, a),
|
Verb::Labels(a) => verbs::labels::run(&client, a),
|
||||||
Verb::PrStatus(a) => verbs::pr_status::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::Lint(a) => verbs::lint::run(&client, a),
|
||||||
Verb::List(a) => verbs::list::run(&client, a),
|
Verb::List(a) => verbs::list::run(&client, a),
|
||||||
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
//! `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.
|
|
||||||
|
|
||||||
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,7 +7,6 @@ pub mod assign;
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod attachment_get;
|
pub mod attachment_get;
|
||||||
pub mod branches;
|
pub mod branches;
|
||||||
pub mod clone;
|
|
||||||
pub mod close;
|
pub mod close;
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
pub mod comment_edit;
|
pub mod comment_edit;
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,6 @@
|
||||||
//! through. Default behaviour is unchanged: no push unless asked.
|
//! through. Default behaviour is unchanged: no push unless asked.
|
||||||
//! Closes the auto-push half of #222 per operator decision (opt-in
|
//! Closes the auto-push half of #222 per operator decision (opt-in
|
||||||
//! flag).
|
//! 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`.
|
|
||||||
|
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
|
@ -32,10 +25,9 @@ pub struct Args {
|
||||||
/// PR title.
|
/// PR title.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
title: String,
|
title: String,
|
||||||
/// Head branch. Not required (and ignored) in `--agit` mode, which
|
/// Head branch.
|
||||||
/// pushes the current `HEAD`.
|
#[arg(long)]
|
||||||
#[arg(long, required_unless_present = "agit")]
|
head: String,
|
||||||
head: Option<String>,
|
|
||||||
/// Base branch (default: main).
|
/// Base branch (default: main).
|
||||||
#[arg(long, default_value = "main")]
|
#[arg(long, default_value = "main")]
|
||||||
base: String,
|
base: String,
|
||||||
|
|
@ -45,8 +37,7 @@ pub struct Args {
|
||||||
/// Read body from a file. `-` means stdin.
|
/// Read body from a file. `-` means stdin.
|
||||||
#[arg(long = "body-file")]
|
#[arg(long = "body-file")]
|
||||||
body_file: Option<String>,
|
body_file: Option<String>,
|
||||||
/// Open as draft. Ignored in `--agit` mode (the `AGit` push has no
|
/// Open as draft.
|
||||||
/// draft push-option).
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
draft: bool,
|
draft: bool,
|
||||||
/// Push the local `--head` branch to `--remote` before creating
|
/// Push the local `--head` branch to `--remote` before creating
|
||||||
|
|
@ -54,22 +45,10 @@ pub struct Args {
|
||||||
/// hint block (we print the URL ourselves).
|
/// hint block (we print the URL ourselves).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
push: bool,
|
push: bool,
|
||||||
/// Remote name to push to. Defaults to the hyperhive convention
|
/// Remote name to push to when `--push` is set. Defaults to the
|
||||||
/// `forge` for the normal flow, and `origin` in `--agit` mode (the
|
/// hyperhive convention `forge`.
|
||||||
/// remote a `hive-forge clone` sets up).
|
#[arg(long, default_value = "forge")]
|
||||||
#[arg(long)]
|
remote: String,
|
||||||
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
|
/// # Errors
|
||||||
|
|
@ -81,23 +60,13 @@ pub struct Args {
|
||||||
/// URL to stdout.
|
/// URL to stdout.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
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 {
|
if args.push {
|
||||||
push_branch(remote, head)?;
|
push_branch(&args.remote, &args.head)?;
|
||||||
}
|
}
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"title": args.title,
|
"title": args.title,
|
||||||
"head": head,
|
"head": args.head,
|
||||||
"base": args.base,
|
"base": args.base,
|
||||||
"body": body,
|
"body": body,
|
||||||
"draft": args.draft,
|
"draft": args.draft,
|
||||||
|
|
@ -110,100 +79,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
Ok(())
|
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
|
/// Run `git push <remote> <head>` and pass stderr through with the
|
||||||
/// post-push PR-hint block filtered out. On failure, bubble up
|
/// 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.
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn strips_create_pr_hint_block() {
|
fn strips_create_pr_hint_block() {
|
||||||
let stderr = "\
|
let stderr = "\
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue