//! `pr-create --title --head [--base ] [body sources] //! [--draft] [--push] [--remote ] [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. //! Closes the auto-push half of #222 per operator decision (opt-in //! flag). use std::process::{Command, Stdio}; use anyhow::{Context, Result}; use clap::Args as ClapArgs; use serde_json::{Value, json}; use crate::body; use crate::client::Client; #[derive(ClapArgs)] pub struct Args { /// PR title. #[arg(long)] title: String, /// Head branch. #[arg(long)] head: String, /// Base branch (default: main). #[arg(long, default_value = "main")] base: String, /// Inline body text. #[arg(long, conflicts_with = "body_file")] body: Option, /// Read body from a file. `-` means stdin. #[arg(long = "body-file")] body_file: Option, /// Open as draft. #[arg(long)] draft: bool, /// Push the local `--head` branch to `--remote` before creating /// the PR. Suppresses forgejo's "Create / Visit a pull request" /// 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, } /// # 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.push { push_branch(&args.remote, &args.head)?; } let repo = client.repo(); let payload = json!({ "title": args.title, "head": args.head, "base": args.base, "body": body, "draft": args.draft, "allow_maintainer_edit": true, }); let resp = client.post_json(&format!("/repos/{repo}/pulls"), &payload)?; if let Some(url) = resp.get("html_url").and_then(Value::as_str) { println!("{url}"); } Ok(()) } /// 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. 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 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" )); } }