//! `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. //! 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//` //! (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, /// 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. 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, /// 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, /// 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, } /// # 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//` 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 --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 --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 add ..." ); return Ok(()); }; let (owner, name) = client.owner_repo()?; let all = labels::repo_labels(client)?; let ids: Vec = 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/`). Returns /// `None` if the last path segment isn't a number. fn pr_number_from_url(url: &str) -> Option { 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 { 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 { 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 ` 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" )); } }