hive-forge: pr-create --push opts into branch push + hint suppression (#222)
This commit is contained in:
parent
def06fb388
commit
1d7feeb029
3 changed files with 181 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <branch>`, `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 <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (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 <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` 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 <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (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 <branch> [--base main] [--body "..." | --body-file <path>] [--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 <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
<!-- role:agent -->
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
//! `pr-create --title <t> --head <branch> [--base <b>] [body sources]
|
||||
//! [--draft] [repo]` — create a PR. Prints the PR URL.
|
||||
//! [--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.
|
||||
//! 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 <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 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue