diff --git a/docs/gotchas.md b/docs/gotchas.md index b85376d1..eff10354 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -133,6 +133,7 @@ hive-forge assign 42 damocles hive-forge close 42 hive-forge labels 42 add feature hive-forge pr 42 # PR metadata as JSON +hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`, suppressing the post-push "Create a pull request" hint (#222) hive-forge diff 42 # unified diff (lockfile hunks collapsed by default) hive-forge diff 42 --full # include unfiltered lockfile hunks hive-forge branches deployed/ # filter branches by pattern diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index da02004d..721feccd 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -130,7 +130,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head `, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`). -The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. +The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint ` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r ` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft] [--push [--remote forge]]` — prints the PR URL. Add `--push` to also `git push` the head branch before the API call (default remote: `forge`); the noisy post-push "Create a pull request" hint is suppressed since we print the canonical URL ourselves. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment --body-file - < ` / `hive-forge attach-comment ` — both print the `browser_download_url`. Key ops: `hive-forge diff ` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon. Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent. diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index 2298fff7..3449ac9e 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -1,7 +1,19 @@ //! `pr-create --title --head [--base ] [body sources] -//! [--draft] [repo]` — create a PR. Prints the PR URL. +//! [--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 anyhow::Result; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; use clap::Args as ClapArgs; use serde_json::{Value, json}; @@ -28,10 +40,22 @@ pub struct Args { /// 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, } 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, @@ -47,3 +71,156 @@ pub fn run(client: &Client, args: Args) -> Result<()> { } 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" + )); + } +}