527 lines
19 KiB
Rust
527 lines
19 KiB
Rust
//! `pr-create --title <t> --head <branch> [--base <b>] [body sources]
|
|
//! [--draft] [--push] [--remote <name>] [repo]` — create a PR. Prints
|
|
//! the PR URL.
|
|
//!
|
|
//! With `--push` the local `--head` branch is pushed to `--remote`
|
|
//! (default `forge`) before the API call, and forgejo's post-push
|
|
//! "Create a new pull request" / "Visit the existing pull request"
|
|
//! hint block is filtered out of git's stderr (we print the canonical
|
|
//! URL ourselves once the API returns). Other git stderr passes
|
|
//! through. Default behaviour is unchanged: no push unless asked.
|
|
//! Adds the auto-push path 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`.
|
|
//! A multi-line `--body` can't ride as a git push option (the wire
|
|
//! protocol forbids newlines there), so it's pushed title-only and the
|
|
//! body is set on the resulting PR via a REST PATCH.
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Args as ClapArgs;
|
|
use forgejo_api::structs::{CreatePullRequestOption, EditIssueOption, IssueLabelsOption};
|
|
use serde_json::json;
|
|
|
|
use crate::body;
|
|
use crate::client::{Client, index};
|
|
use crate::verbs::labels;
|
|
|
|
#[derive(ClapArgs)]
|
|
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<String>,
|
|
/// Base branch (default: main).
|
|
#[arg(long, default_value = "main")]
|
|
base: String,
|
|
/// Inline body text.
|
|
#[arg(long, conflicts_with = "body_file")]
|
|
body: Option<String>,
|
|
/// Read body from a file. `-` means stdin.
|
|
#[arg(long = "body-file")]
|
|
body_file: Option<String>,
|
|
/// Open as draft. Ignored in `--agit` mode.
|
|
#[arg(long)]
|
|
draft: bool,
|
|
/// Push the local `--head` branch to `--remote` before creating the PR.
|
|
#[arg(long)]
|
|
push: bool,
|
|
/// Remote to push to (default: `forge`, or `origin` in `--agit` mode).
|
|
#[arg(long)]
|
|
remote: Option<String>,
|
|
/// Open the PR via Forgejo's `AGit` flow instead of pushing a branch —
|
|
/// works for read-only collaborators. Run from inside a cloned repo.
|
|
#[arg(long)]
|
|
agit: bool,
|
|
/// `AGit` topic — groups repeated pushes into one PR (re-run with the
|
|
/// same topic to update it). Defaults to the branch name. Only
|
|
/// meaningful with `--agit`.
|
|
#[arg(long)]
|
|
topic: Option<String>,
|
|
/// Label name to attach, repeatable (e.g. `--label area/ops --label
|
|
/// type/bug`). Same spelling `labels add` accepts. In `--agit` mode
|
|
/// this is applied as a follow-up call once the PR number is known
|
|
/// (the `AGit` push itself has no label field), so it's silently
|
|
/// skipped if the PR URL couldn't be parsed back out of the push
|
|
/// output — same fallback as the deferred multi-line body.
|
|
#[arg(long = "label")]
|
|
labels: Vec<String>,
|
|
}
|
|
|
|
/// # Errors
|
|
///
|
|
/// Propagates any I/O error from the body input (`--body-file`,
|
|
/// stdin) or the `--push` shellout to git, any transport error from
|
|
/// the Forgejo REST call (network unreachable, 4xx/5xx response,
|
|
/// token missing/invalid), and any I/O error from writing the PR
|
|
/// 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(client, &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)?;
|
|
}
|
|
let (owner, name) = client.owner_repo()?;
|
|
let label_ids = if args.labels.is_empty() {
|
|
None
|
|
} else {
|
|
let all = labels::repo_labels(client)?;
|
|
Some(labels::resolve_ids(&all, &args.labels))
|
|
};
|
|
// Note: Forgejo's CreatePullRequestOption has no `draft` /
|
|
// `allow_maintainer_edit` fields (verified against the instance's
|
|
// swagger) — the raw client used to send both and the server
|
|
// silently dropped them, so omitting them here changes nothing.
|
|
let payload = CreatePullRequestOption {
|
|
assignee: None,
|
|
assignees: None,
|
|
base: Some(args.base.clone()),
|
|
body: Some(body),
|
|
due_date: None,
|
|
head: Some(head.to_owned()),
|
|
labels: label_ids,
|
|
milestone: None,
|
|
title: Some(args.title.clone()),
|
|
};
|
|
let resp = client
|
|
.api()
|
|
.repo_create_pull_request(owner, name, payload)
|
|
.send()?;
|
|
if let Some(url) = resp.html_url {
|
|
println!("{url}");
|
|
}
|
|
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).
|
|
///
|
|
/// The git wire protocol forbids newline characters in push options, so
|
|
/// a multi-line `description=` would make the push fail outright. A
|
|
/// single-line body still rides along as a push option (it's guaranteed
|
|
/// to land even if we can't parse the PR URL back out); a multi-line
|
|
/// body is pushed title-only and then set on the resulting PR via a REST
|
|
/// PATCH, mirroring the manual `pr-create --title …` + `issue-edit
|
|
/// --body-file` two-step.
|
|
fn agit_create(client: &Client, 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()),
|
|
};
|
|
// A body with any newline can't be a push option; defer it to a REST
|
|
// PATCH after the push. A single-line body is safe inline.
|
|
let inline_body = !body.is_empty() && !body.contains('\n');
|
|
let deferred_body = !body.is_empty() && body.contains('\n');
|
|
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 inline_body {
|
|
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}");
|
|
}
|
|
let Some(url) = parse_pr_url(&stderr) 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}");
|
|
if deferred_body {
|
|
eprintln!(
|
|
"warning: multi-line body NOT set — PR URL not parsed, so it could \
|
|
not be PATCHed. Set it manually: hive-forge issue-edit <pr#> --body-file -"
|
|
);
|
|
}
|
|
println!(
|
|
"AGit push to refs/for/{}/{topic} succeeded (PR URL not parsed from output above)",
|
|
args.base
|
|
);
|
|
return Ok(());
|
|
};
|
|
if deferred_body {
|
|
if let Some(number) = pr_number_from_url(&url) {
|
|
let (owner, name) = client.owner_repo()?;
|
|
let payload = EditIssueOption {
|
|
assignee: None,
|
|
assignees: None,
|
|
body: Some(body.to_owned()),
|
|
due_date: None,
|
|
milestone: None,
|
|
r#ref: None,
|
|
state: None,
|
|
title: None,
|
|
unset_due_date: None,
|
|
updated_at: None,
|
|
};
|
|
client
|
|
.api()
|
|
.issue_edit_issue(owner, name, index(number)?, payload)
|
|
.send()
|
|
.with_context(|| format!("set body on AGit PR #{number}"))?;
|
|
} else {
|
|
eprintln!(
|
|
"warning: multi-line body NOT set — could not parse PR number from {url}. \
|
|
Set it manually: hive-forge issue-edit <pr#> --body-file -"
|
|
);
|
|
}
|
|
}
|
|
if !args.labels.is_empty() {
|
|
apply_agit_labels(client, &args.labels, &url)?;
|
|
}
|
|
println!("{url}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Follow-up label-add call for an `--agit`-created PR: the `AGit` push
|
|
/// itself has no label field, so this mirrors the deferred-body PATCH —
|
|
/// resolve the PR number back out of the parsed URL, then add the
|
|
/// resolved label ids via the same `issue_add_label` call `labels add`
|
|
/// uses. Warns (doesn't fail the whole command) if the PR number
|
|
/// couldn't be parsed, same fallback as the deferred body.
|
|
fn apply_agit_labels(client: &Client, names: &[String], url: &str) -> Result<()> {
|
|
let Some(number) = pr_number_from_url(url) else {
|
|
eprintln!(
|
|
"warning: --label NOT applied — could not parse PR number from {url}. \
|
|
Set it manually: hive-forge labels <pr#> add <name>..."
|
|
);
|
|
return Ok(());
|
|
};
|
|
let (owner, name) = client.owner_repo()?;
|
|
let all = labels::repo_labels(client)?;
|
|
let ids: Vec<serde_json::Value> = labels::resolve_ids(&all, names)
|
|
.into_iter()
|
|
.map(|id| json!(id))
|
|
.collect();
|
|
client
|
|
.api()
|
|
.issue_add_label(
|
|
owner,
|
|
name,
|
|
index(number)?,
|
|
IssueLabelsOption {
|
|
labels: Some(ids),
|
|
updated_at: None,
|
|
},
|
|
)
|
|
.send()
|
|
.with_context(|| format!("set labels on AGit PR #{number}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract the PR index from a Forgejo PR URL (`…/pulls/<n>`). Returns
|
|
/// `None` if the last path segment isn't a number.
|
|
fn pr_number_from_url(url: &str) -> Option<u64> {
|
|
url.trim_end_matches('/')
|
|
.rsplit('/')
|
|
.next()
|
|
.and_then(|seg| seg.parse().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.
|
|
fn push_branch(remote: &str, head: &str) -> Result<()> {
|
|
let output = Command::new("git")
|
|
.args(["push", remote, head])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to spawn `git push {remote} {head}`"))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("`git push {remote} {head}` failed:\n{stderr}");
|
|
}
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let filtered = strip_pr_hint_block(&stderr);
|
|
if !filtered.is_empty() {
|
|
eprint!("{filtered}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop the 3-line PR-hint block forgejo appends after push:
|
|
///
|
|
/// ```text
|
|
/// remote: Create a new pull request for 'BRANCH':
|
|
/// remote: http://.../compare/...
|
|
/// remote:
|
|
/// ```
|
|
///
|
|
/// or the existing-PR variant. Other `remote:` lines (and everything
|
|
/// else) pass through unchanged.
|
|
fn strip_pr_hint_block(stderr: &str) -> String {
|
|
let mut out = String::with_capacity(stderr.len());
|
|
let mut lines = stderr.split_inclusive('\n').peekable();
|
|
while let Some(line) = lines.next() {
|
|
if is_pr_hint_opener(line) {
|
|
// Eat up to two follow-on `remote:` lines (URL + blank).
|
|
for _ in 0..2 {
|
|
if lines.peek().is_some_and(|next| is_remote_line(next)) {
|
|
lines.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
out.push_str(line);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn remote_content(line: &str) -> Option<&str> {
|
|
line.trim_end_matches(['\n', '\r'])
|
|
.trim_start()
|
|
.strip_prefix("remote:")
|
|
.map(str::trim_start)
|
|
}
|
|
|
|
fn is_remote_line(line: &str) -> bool {
|
|
remote_content(line).is_some()
|
|
}
|
|
|
|
fn is_pr_hint_opener(line: &str) -> bool {
|
|
remote_content(line).is_some_and(|c| {
|
|
c.starts_with("Create a new pull request")
|
|
|| c.starts_with("Visit the existing pull request")
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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 pr_number_parses_trailing_index() {
|
|
assert_eq!(
|
|
pr_number_from_url("http://forge.example/internal/knowledge/pulls/42"),
|
|
Some(42)
|
|
);
|
|
// tolerant of a trailing slash
|
|
assert_eq!(
|
|
pr_number_from_url("http://forge.example/org/repo/pulls/7/"),
|
|
Some(7)
|
|
);
|
|
// not a number → None (caller falls back to the manual-edit hint)
|
|
assert_eq!(
|
|
pr_number_from_url("http://forge.example/org/repo/pulls/"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
pr_number_from_url("http://forge.example/org/repo/compare"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_create_pr_hint_block() {
|
|
let stderr = "\
|
|
remote: Create a new pull request for 'feat/foo':\n\
|
|
remote: http://localhost:3000/hyperhive/hyperhive/compare/main...feat/foo\n\
|
|
remote: \n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
* [new branch] feat/foo -> feat/foo\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Create a new pull request"));
|
|
assert!(!cleaned.contains("compare/main"));
|
|
assert!(cleaned.contains("[new branch]"));
|
|
assert!(cleaned.contains("To http://localhost:3000/hyperhive/hyperhive.git"));
|
|
}
|
|
|
|
#[test]
|
|
fn strips_visit_existing_pr_hint_block() {
|
|
let stderr = "\
|
|
remote: Visit the existing pull request:\n\
|
|
remote: http://localhost:3000/hyperhive/hyperhive/pulls/527 merges into main\n\
|
|
remote: \n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
55430bb..bdfd437 feat/519-unify-prompts -> feat/519-unify-prompts\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Visit the existing pull request"));
|
|
assert!(!cleaned.contains("/pulls/527"));
|
|
assert!(cleaned.contains("55430bb..bdfd437"));
|
|
}
|
|
|
|
#[test]
|
|
fn passes_unrelated_remote_lines_through() {
|
|
// Real-world: pre-receive hooks, lint output, anything the
|
|
// forge or hooks decide to print via the sideband. Only the
|
|
// PR-hint block opener triggers the skip.
|
|
let stderr = "\
|
|
remote: pre-receive hook says: looks fine\n\
|
|
remote: actions queued: 2\n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert_eq!(cleaned, stderr);
|
|
}
|
|
|
|
#[test]
|
|
fn handles_empty_stderr() {
|
|
assert_eq!(strip_pr_hint_block(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn stops_eating_after_two_followups() {
|
|
// Defensive: if the forge ever drops the trailing blank
|
|
// `remote:` line, we still only eat what we expect (URL line)
|
|
// and pass the next line through.
|
|
let stderr = "\
|
|
remote: Create a new pull request for 'feat/foo':\n\
|
|
remote: http://localhost:3000/.../compare/...\n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
* [new branch] feat/foo -> feat/foo\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Create a new pull request"));
|
|
assert!(!cleaned.contains("/compare/"));
|
|
assert!(cleaned.contains("To http://"));
|
|
assert!(cleaned.contains("[new branch]"));
|
|
}
|
|
|
|
#[test]
|
|
fn opener_detector_recognises_both_phrasings() {
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Create a new pull request for 'x':\n"
|
|
));
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Visit the existing pull request:\n"
|
|
));
|
|
assert!(!is_pr_hint_opener("remote: some other thing\n"));
|
|
assert!(!is_pr_hint_opener("To http://example.com\n"));
|
|
// Trailing-CRLF safety on windows-cloned forge clones.
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Create a new pull request for 'x':\r\n"
|
|
));
|
|
}
|
|
}
|