diff --git a/CLAUDE.md b/CLAUDE.md index 735ee1c..bded37d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,18 +149,6 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries manager.md — manager system prompt claude-settings.json — --settings JSON -hive-forge/ Forgejo CLI wrapper (`hive-forge` binary) - src/main.rs clap dispatch over the verbs/ - src/client.rs blocking reqwest client (Forgejo REST API) - src/body.rs body input resolution (--body / --body-file / piped stdin) - src/verbs/.rs one module per verb (view, issue, pr, comment, - comment-show, comment-edit, issue-create, - issue-edit, pr-create, pr-reviews, assign, - close, labels, milestone, branches, - tree-sha, diff, subscription, attach-issue, - attach-comment). Replaces the 600-line - hive-forge-tools.nix bash script (closes #280). - hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, ManagerRequest/Response, Message, Approval, HelperEvent) diff --git a/Cargo.lock b/Cargo.lock index e156349..9e8e75b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,17 +601,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "hive-forge" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "reqwest", - "serde", - "serde_json", -] - [[package]] name = "hive-sh4re" version = "0.1.0" @@ -1213,9 +1202,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -1224,7 +1211,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/Cargo.toml b/Cargo.toml index b990949..c388004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"] [workspace.package] edition = "2024" diff --git a/docs/gotchas.md b/docs/gotchas.md index 986471f..b2a4484 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -118,24 +118,17 @@ files in subdirectories) fails with `EPERM`. Fix: pass ## `hive-forge`: prefer over raw curl pipelines Every agent container has `hive-forge` in PATH (installed via -`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary -since #280). Use it instead of ad-hoc curl pipelines: +`harness-base.nix`). Use it instead of ad-hoc curl pipelines: ```bash -hive-forge view 42 # title + body + comments -hive-forge comment 42 --body "..." # post comment (inline body) -hive-forge comment 42 --body-file - < --help` prints the full signature for any verb. -Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default -repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the -global `-r/--repo` flag. +Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; +default repo from `$HIVE_FORGE_REPO`. diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 0c95724..5b70abc 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -26,7 +26,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`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. 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`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`. To create a PR: `hive-forge pr-create --title "..." --head [--base main] [--body "..."] [--draft] [repo]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..."] [--assignee ] [repo]`. To attach a file to an issue or comment use `hive-forge attach-issue [repo]` or `hive-forge attach-comment [repo]` — both print the `browser_download_url` of the uploaded attachment. Key ops: `hive-forge diff [repo]` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch] [repo]` 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-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 6dc649d..587ffb1 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -92,7 +92,7 @@ Keep messages short — a few sentences each. For anything big (digests, agent r - To the operator: write to your own `/state/` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look. - For shared artifacts (coordination, common reference data): write to `/shared/`. Only put things here you're willing to lose — other agents may delete them. -**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head `, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo from `HIVE_FORGE_REPO`; `-r ` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head [--base main] [--body "..." | --body-file ] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file ] [--assignee ]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment --body-file - <` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon. +**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head `, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Use `hive-forge pr-create --title "..." --head [--base main] [--body "..."] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..."] [--assignee ]` to file an issue; `diff ` prints unified diff; `subscription [--watch|--ignore|--unwatch] [repo]` manages watch state. Forge notifications arrive via the internal message daemon. A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time. diff --git a/hive-forge/Cargo.toml b/hive-forge/Cargo.toml deleted file mode 100644 index def1915..0000000 --- a/hive-forge/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "hive-forge" -edition.workspace = true -version.workspace = true - -[[bin]] -name = "hive-forge" -path = "src/main.rs" - -[dependencies] -anyhow = { workspace = true } -clap = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls", "blocking", "multipart"] } -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/hive-forge/src/body.rs b/hive-forge/src/body.rs deleted file mode 100644 index 39341ad..0000000 --- a/hive-forge/src/body.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Body-input resolution shared by every verb that posts a body. -//! Matches the bash `resolve_body` helper (#382): exactly one source -//! between `--body`, `--body-file`, and piped stdin. Passing both -//! `--body` and `--body-file` is a clear error. - -use std::io::{IsTerminal, Read}; - -use anyhow::{Context, Result, bail}; - -/// Resolve the body to send, given the user's explicit flags and the -/// current stdin state. Returns `None` when none of the sources are -/// available (the caller decides whether that's allowed — e.g. -/// `issue-edit` treats absent body as "leave unchanged", while -/// `comment` treats absent body as a hard error). -pub fn resolve(body: Option<&str>, file: Option<&str>) -> Result> { - if body.is_some() && file.is_some() { - bail!("hive-forge: --body and --body-file are mutually exclusive"); - } - if let Some(b) = body { - return Ok(Some(b.to_owned())); - } - if let Some(path) = file { - if path == "-" { - return Ok(Some(read_stdin().context("read stdin for --body-file -")?)); - } - let s = std::fs::read_to_string(path) - .with_context(|| format!("read --body-file {path}"))?; - return Ok(Some(s)); - } - if !std::io::stdin().is_terminal() { - return Ok(Some(read_stdin().context("read piped stdin")?)); - } - Ok(None) -} - -/// Resolve, then require a non-empty result (whitespace-trimmed). -/// Used by `comment` / `comment-edit` which refuse to post empty -/// bodies. -pub fn resolve_required(body: Option<&str>, file: Option<&str>, verb: &str) -> Result { - let resolved = resolve(body, file)?; - let Some(s) = resolved else { - bail!( - "hive-forge {verb}: no body — pass --body , --body-file , or pipe via stdin" - ); - }; - if s.trim().is_empty() { - bail!("hive-forge {verb}: refusing to post empty body"); - } - Ok(s) -} - -fn read_stdin() -> std::io::Result { - let mut s = String::new(); - std::io::stdin().read_to_string(&mut s)?; - Ok(s) -} diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs deleted file mode 100644 index 882e9da..0000000 --- a/hive-forge/src/client.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Blocking HTTP client for the Forgejo REST API. Identity is the -//! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All -//! verbs go through this client so error surfaces, header set, and -//! 4xx/5xx body unwrapping stay consistent. - -use std::path::PathBuf; - -use anyhow::{Context, Result, bail}; -use reqwest::blocking::{Client as HttpClient, Response}; -use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; -use serde::Serialize; -use serde_json::Value; - -/// Default Forgejo URL when `HIVE_FORGE_URL` is unset. -const DEFAULT_URL: &str = "http://localhost:3000"; - -/// Default repo when `HIVE_FORGE_REPO` is unset and the verb doesn't -/// take a repo override. -const DEFAULT_REPO: &str = "hyperhive/hyperhive"; - -/// Blocking Forgejo API client. Cheap to clone (wraps an -/// `Arc` internally). -pub struct Client { - http: HttpClient, - base: String, - /// Default repo used when a verb doesn't carry an explicit - /// `[repo]` override. - pub default_repo: String, -} - -impl Client { - /// Build a client from the standard environment variables. - /// `repo_override` (from the global `-r/--repo` flag) takes - /// priority over `HIVE_FORGE_REPO`; the env var is the fallback - /// default. - pub fn from_env(repo_override: Option) -> Result { - let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned()); - let default_repo = repo_override - .or_else(|| std::env::var("HIVE_FORGE_REPO").ok()) - .unwrap_or_else(|| DEFAULT_REPO.to_owned()); - let token = read_token().context("read forge-token")?; - - let mut headers = HeaderMap::new(); - let auth = format!("token {token}"); - let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?; - auth_val.set_sensitive(true); - headers.insert(AUTHORIZATION, auth_val); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - - let http = HttpClient::builder() - .default_headers(headers) - .build() - .context("build reqwest client")?; - Ok(Self { - http, - base, - default_repo, - }) - } - - /// Resolve the API base path (`/api/v1`). - fn api(&self) -> String { - format!("{}/api/v1", self.base) - } - - /// Return the active repo. `from_env` already folded the - /// `-r/--repo` override into `default_repo`, so verbs just read - /// it as-is — no per-verb override plumbing. - #[must_use] - pub fn repo(&self) -> &str { - &self.default_repo - } - - /// GET `/` and decode JSON. - pub fn get_json(&self, path: &str) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self.http.get(&url).send().context("GET")?; - decode_json(resp, &format!("GET {url}")) - } - - /// GET `/` and return the raw response body as text - /// (used by `diff` which fetches a `text/plain` blob). - pub fn get_text(&self, path: &str, accept: &str) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .get(&url) - .header(ACCEPT, accept) - .send() - .context("GET")?; - decode_text(resp, &format!("GET {url}")) - } - - /// POST a JSON body to `/` and decode the response. - pub fn post_json(&self, path: &str, body: &B) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .post(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("POST")?; - decode_json(resp, &format!("POST {url}")) - } - - /// PATCH a JSON body to `/` and decode the response. - pub fn patch_json(&self, path: &str, body: &B) -> Result { - let url = format!("{}{}", self.api(), path); - let resp = self - .http - .patch(&url) - .header(CONTENT_TYPE, "application/json") - .json(body) - .send() - .context("PATCH")?; - decode_json(resp, &format!("PATCH {url}")) - } - - /// DELETE `/`. Optional JSON body for endpoints that - /// need it (Forgejo's subscription unwatch uses bodyless DELETE). - pub fn delete(&self, path: &str, body: Option<&Value>) -> Result<()> { - let url = format!("{}{}", self.api(), path); - let mut req = self.http.delete(&url); - if let Some(b) = body { - req = req.header(CONTENT_TYPE, "application/json").json(b); - } - let resp = req.send().context("DELETE")?; - check_status(resp, &format!("DELETE {url}"))?; - Ok(()) - } - - /// POST a multipart file upload, returning the parsed response. - /// Used by `attach-issue` / `attach-comment`. - pub fn post_multipart_file(&self, path: &str, file: &std::path::Path) -> Result { - let url = format!("{}{}", self.api(), path); - let form = reqwest::blocking::multipart::Form::new() - .file("attachment", file) - .with_context(|| format!("read {}", file.display()))?; - let resp = self.http.post(&url).multipart(form).send().context("POST")?; - decode_json(resp, &format!("POST {url}")) - } -} - -/// Locate and read the forge token. Falls back to `$PWD/forge-token` -/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. -fn read_token() -> Result { - let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); - let path = if state_dir.is_empty() { - PathBuf::from("forge-token") - } else { - PathBuf::from(state_dir).join("forge-token") - }; - let raw = std::fs::read_to_string(&path) - .with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?; - Ok(raw.trim().to_owned()) -} - -/// Surface non-2xx HTTP responses as anyhow errors with the response -/// body included (matches `curl --fail-with-body`). Closes #353's -/// "silent failures with no clue what went wrong" case. -fn check_status(resp: Response, op: &str) -> Result { - let status = resp.status(); - if status.is_success() { - return Ok(resp); - } - let body = resp.text().unwrap_or_default(); - bail!("hive-forge: {op} failed ({status}): {body}"); -} - -fn decode_json(resp: Response, op: &str) -> Result { - let resp = check_status(resp, op)?; - resp.json::() - .with_context(|| format!("decode JSON for {op}")) -} - -fn decode_text(resp: Response, op: &str) -> Result { - let resp = check_status(resp, op)?; - resp.text().with_context(|| format!("decode text for {op}")) -} diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs deleted file mode 100644 index 62569b0..0000000 --- a/hive-forge/src/main.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! `hive-forge` — typed CLI wrapper around the in-cluster Forgejo's -//! REST API. Reads credentials from the environment: -//! -//! `HIVE_FORGE_URL` — base URL, e.g. `http://localhost:3000` -//! `HIVE_FORGE_REPO` — default repo, e.g. `hyperhive/hyperhive` -//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here -//! -//! Single binary with verb subcommands. Replaces the prior bash -//! script (`hive-forge-tools.nix`) so that agents and operators get -//! the same error handling, exit codes, and JSON shapes regardless -//! of how the bash mood was that day (closes #280). - -#![warn(missing_docs)] -// Clap-derived `Args` structs are intentionally consumed by their -// per-verb `run` handler so we can move owned String fields out -// without cloning. The pedantic lint flags every one of them. -#![allow(clippy::needless_pass_by_value)] - -mod body; -mod client; -mod verbs; - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - name = "hive-forge", - about = "Forgejo CLI wrapper for hyperhive (closes #280)", - disable_help_subcommand = true -)] -struct Cli { - /// Repo override (default from `HIVE_FORGE_REPO`). - /// Applies to any verb; replaces the per-verb `[repo]` trailing - /// positional the bash helper used. - #[arg(short = 'r', long, global = true)] - repo: Option, - #[command(subcommand)] - verb: Verb, -} - -#[derive(Subcommand)] -enum Verb { - /// Dump title + body + all comments for an issue or PR. - View(verbs::view::Args), - /// Print key fields of an issue as JSON. - Issue(verbs::issue::Args), - /// Create an issue. Prints the issue URL on success. - IssueCreate(verbs::issue_create::Args), - /// Edit an issue's title, body, state, or milestone. - IssueEdit(verbs::issue_edit::Args), - /// Print key fields of a PR as JSON. - Pr(verbs::pr::Args), - /// Create a pull request. Prints the PR URL on success. - PrCreate(verbs::pr_create::Args), - /// Post a comment on an issue or PR. - Comment(verbs::comment::Args), - /// Print the body (or full JSON) of a single comment by id. - CommentShow(verbs::comment_show::Args), - /// Edit an existing comment by id. - CommentEdit(verbs::comment_edit::Args), - /// Assign or unassign a user on an issue or PR. - Assign(verbs::assign::Args), - /// Close an issue or PR. - Close(verbs::close::Args), - /// List, add, or remove labels on an issue or PR. - Labels(verbs::labels::Args), - /// Manage milestones (list / create / close). - Milestone(verbs::milestone::Args), - /// List reviews on a PR. - PrReviews(verbs::pr_reviews::Args), - /// List branches, optionally filtered. - Branches(verbs::branches::Args), - /// Print the tree SHA at a branch or commit. - TreeSha(verbs::tree_sha::Args), - /// Print the unified diff for a PR. - Diff(verbs::diff::Args), - /// Get or set this user's watch subscription on a repo. - Subscription(verbs::subscription::Args), - /// Upload a file as an attachment to an issue. - AttachIssue(verbs::attach::IssueArgs), - /// Upload a file as an attachment to a comment. - AttachComment(verbs::attach::CommentArgs), -} - -fn main() -> Result<()> { - let cli = Cli::parse(); - let client = client::Client::from_env(cli.repo).context("initialize forge client")?; - match cli.verb { - Verb::View(a) => verbs::view::run(&client, a), - Verb::Issue(a) => verbs::issue::run(&client, a), - Verb::IssueCreate(a) => verbs::issue_create::run(&client, a), - Verb::IssueEdit(a) => verbs::issue_edit::run(&client, a), - Verb::Pr(a) => verbs::pr::run(&client, a), - Verb::PrCreate(a) => verbs::pr_create::run(&client, a), - Verb::Comment(a) => verbs::comment::run(&client, a), - Verb::CommentShow(a) => verbs::comment_show::run(&client, a), - Verb::CommentEdit(a) => verbs::comment_edit::run(&client, a), - Verb::Assign(a) => verbs::assign::run(&client, a), - Verb::Close(a) => verbs::close::run(&client, a), - Verb::Labels(a) => verbs::labels::run(&client, a), - Verb::Milestone(a) => verbs::milestone::run(&client, a), - Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a), - Verb::Branches(a) => verbs::branches::run(&client, a), - Verb::TreeSha(a) => verbs::tree_sha::run(&client, a), - Verb::Diff(a) => verbs::diff::run(&client, a), - Verb::Subscription(a) => verbs::subscription::run(&client, a), - Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a), - Verb::AttachComment(a) => verbs::attach::run_comment(&client, a), - } -} diff --git a/hive-forge/src/verbs/assign.rs b/hive-forge/src/verbs/assign.rs deleted file mode 100644 index 5c9ed71..0000000 --- a/hive-forge/src/verbs/assign.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! `assign [--remove]` — add or remove a user from an -//! issue/PR's assignee list. Forgejo has no dedicated POST endpoint — -//! we read the current list, mutate, and PATCH the issue back (closes -//! #353's "no such endpoint" trap; matches the bash helper's logic). - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - number: u64, - /// User login to assign (or unassign with `--remove`). - user: String, - /// Remove the user instead of adding. - #[arg(long)] - remove: bool, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; - let mut assignees: Vec = current - .get("assignees") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|u| u.get("login").and_then(Value::as_str).map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); - if args.remove { - assignees.retain(|u| u != &args.user); - } else if !assignees.contains(&args.user) { - assignees.push(args.user.clone()); - } - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "assignees": assignees }), - )?; - let logins: Vec<&str> = resp - .get("assignees") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|u| u.get("login").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); - print_json(&json!({ - "number": resp.get("number"), - "assignees": logins, - })) -} diff --git a/hive-forge/src/verbs/attach.rs b/hive-forge/src/verbs/attach.rs deleted file mode 100644 index 91e621b..0000000 --- a/hive-forge/src/verbs/attach.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! `attach-issue ` and `attach-comment -//! ` — upload a file as an attachment. Prints the browser -//! download URL. - -use std::path::PathBuf; - -use anyhow::{Result, bail}; -use clap::Args as ClapArgs; -use serde_json::Value; - -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct IssueArgs { - /// Issue number. - number: u64, - /// File path to upload. - file: PathBuf, -} - -#[derive(ClapArgs)] -pub struct CommentArgs { - /// Comment id. - id: u64, - /// File path to upload. - file: PathBuf, -} - -pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> { - if !args.file.is_file() { - bail!( - "hive-forge attach-issue: file not found: {}", - args.file.display() - ); - } - let repo = client.repo(); - let resp = client.post_multipart_file( - &format!("/repos/{repo}/issues/{}/assets", args.number), - &args.file, - )?; - print_url(&resp); - Ok(()) -} - -pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> { - if !args.file.is_file() { - bail!( - "hive-forge attach-comment: file not found: {}", - args.file.display() - ); - } - let repo = client.repo(); - let resp = client.post_multipart_file( - &format!("/repos/{repo}/issues/comments/{}/assets", args.id), - &args.file, - )?; - print_url(&resp); - Ok(()) -} - -fn print_url(v: &Value) { - if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) { - println!("{url}"); - } -} diff --git a/hive-forge/src/verbs/branches.rs b/hive-forge/src/verbs/branches.rs deleted file mode 100644 index 287f502..0000000 --- a/hive-forge/src/verbs/branches.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! `branches [pattern] [repo]` — list branches, optionally filtered. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::Value; - -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct Args { - /// Substring pattern to filter branch names. - pattern: Option, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?; - let names: Vec<&str> = v - .as_array() - .map(|a| { - a.iter() - .filter_map(|b| b.get("name").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); - for n in names { - if args.pattern.as_deref().is_none_or(|p| n.contains(p)) { - println!("{n}"); - } - } - Ok(()) -} diff --git a/hive-forge/src/verbs/close.rs b/hive-forge/src/verbs/close.rs deleted file mode 100644 index 0d01ce2..0000000 --- a/hive-forge/src/verbs/close.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `close ` — close an issue or PR. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::json; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &json!({ "state": "closed" }), - )?; - print_json(&json!({ - "number": resp.get("number"), - "state": resp.get("state"), - })) -} diff --git a/hive-forge/src/verbs/comment.rs b/hive-forge/src/verbs/comment.rs deleted file mode 100644 index e2aece1..0000000 --- a/hive-forge/src/verbs/comment.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! `comment [body sources] [repo]` — post a comment on an -//! issue or PR. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::json; - -use crate::body; -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - number: u64, - /// 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, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let body = body::resolve_required(args.body.as_deref(), args.body_file.as_deref(), "comment")?; - let repo = client.repo(); - let resp = client.post_json( - &format!("/repos/{repo}/issues/{}/comments", args.number), - &json!({ "body": body }), - )?; - print_json(&json!({ - "id": resp.get("id"), - "url": resp.get("html_url"), - })) -} diff --git a/hive-forge/src/verbs/comment_edit.rs b/hive-forge/src/verbs/comment_edit.rs deleted file mode 100644 index c271a39..0000000 --- a/hive-forge/src/verbs/comment_edit.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! `comment-edit [body sources] [repo]` — edit an existing -//! comment by id. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::json; - -use crate::body; -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Comment id. - id: u64, - /// 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, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let body = body::resolve_required( - args.body.as_deref(), - args.body_file.as_deref(), - "comment-edit", - )?; - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/comments/{}", args.id), - &json!({ "body": body }), - )?; - print_json(&json!({ - "id": resp.get("id"), - "user": resp.get("user").and_then(|u| u.get("login")), - "url": resp.get("html_url"), - })) -} diff --git a/hive-forge/src/verbs/comment_show.rs b/hive-forge/src/verbs/comment_show.rs deleted file mode 100644 index 9a2e41d..0000000 --- a/hive-forge/src/verbs/comment_show.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! `comment-show [--json] [repo]` — print the body (or full -//! JSON) of a single comment by id. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Comment id. - id: u64, - /// Print full JSON envelope instead of just the body text. - #[arg(long)] - json: bool, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?; - if args.json { - let trimmed = json!({ - "id": v.get("id"), - "user": v.get("user").and_then(|u| u.get("login")), - "created_at": v.get("created_at"), - "updated_at": v.get("updated_at"), - "body": v.get("body"), - "url": v.get("html_url"), - }); - print_json(&trimmed) - } else { - let body = v.get("body").and_then(Value::as_str).unwrap_or(""); - println!("{body}"); - Ok(()) - } -} diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs deleted file mode 100644 index aa64c5c..0000000 --- a/hive-forge/src/verbs/diff.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! `diff [repo]` — print the unified diff for a PR. - -use anyhow::Result; -use clap::Args as ClapArgs; - -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct Args { - /// PR number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?; - print!("{diff}"); - Ok(()) -} diff --git a/hive-forge/src/verbs/issue.rs b/hive-forge/src/verbs/issue.rs deleted file mode 100644 index fdca71d..0000000 --- a/hive-forge/src/verbs/issue.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! `issue [repo]` — print key fields of an issue as JSON. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; - let trimmed = json!({ - "number": v.get("number"), - "title": v.get("title"), - "state": v.get("state"), - "user": v.get("user").and_then(|u| u.get("login")), - "assignees": v.get("assignees") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::>()) - .unwrap_or_default(), - "labels": v.get("labels") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::>()) - .unwrap_or_default(), - "body": v.get("body"), - }); - print_json(&trimmed) -} diff --git a/hive-forge/src/verbs/issue_create.rs b/hive-forge/src/verbs/issue_create.rs deleted file mode 100644 index b80fc02..0000000 --- a/hive-forge/src/verbs/issue_create.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! `issue-create --title [body sources] [--assignee ] [repo]` -//! — create an issue. Prints the issue URL. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::body; -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue title (required). - #[arg(long)] - title: 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, - /// Initial assignee login. - #[arg(long)] - assignee: Option, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default(); - let repo = client.repo(); - let mut payload = json!({ "title": args.title, "body": body }); - if let Some(a) = args.assignee { - payload["assignees"] = json!([a]); - } - let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?; - if let Some(url) = resp.get("html_url").and_then(Value::as_str) { - println!("{url}"); - } - Ok(()) -} diff --git a/hive-forge/src/verbs/issue_edit.rs b/hive-forge/src/verbs/issue_edit.rs deleted file mode 100644 index ac76162..0000000 --- a/hive-forge/src/verbs/issue_edit.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! `issue-edit [--title ] [body sources] [--state s] -//! [--milestone id] [repo]` — partial update of an issue. Fields not -//! provided are left unchanged. - -use anyhow::Result; -use clap::{Args as ClapArgs, ValueEnum}; -use serde_json::{Map, Value, json}; - -use crate::body; -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(Copy, Clone, ValueEnum)] -pub enum StateArg { - Open, - Closed, -} - -impl StateArg { - fn as_str(self) -> &'static str { - match self { - Self::Open => "open", - Self::Closed => "closed", - } - } -} - -#[derive(ClapArgs)] -pub struct Args { - /// Issue number. - number: u64, - /// New title (omit to leave unchanged). - #[arg(long)] - title: Option, - /// Inline body text (omit to leave unchanged). - #[arg(long, conflicts_with = "body_file")] - body: Option, - /// Read body from a file. `-` means stdin. - #[arg(long = "body-file")] - body_file: Option, - /// New state. - #[arg(long, value_enum)] - state: Option, - /// Milestone id (0 to unset). - #[arg(long)] - milestone: Option, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - // Body is partial: only update the body field if a source was - // actually given. Piped stdin without --body/--body-file leaves - // body alone (the partial-update contract). - let body_explicit = args.body.is_some() || args.body_file.is_some(); - let body = if body_explicit { - body::resolve(args.body.as_deref(), args.body_file.as_deref())? - } else { - None - }; - - let mut payload = Map::new(); - if let Some(t) = args.title { - payload.insert("title".into(), Value::String(t)); - } - if let Some(b) = body { - payload.insert("body".into(), Value::String(b)); - } - if let Some(s) = args.state { - payload.insert("state".into(), Value::String(s.as_str().to_owned())); - } - if let Some(m) = args.milestone { - payload.insert("milestone".into(), Value::Number(m.into())); - } - - let repo = client.repo(); - let resp = client.patch_json( - &format!("/repos/{repo}/issues/{}", args.number), - &Value::Object(payload), - )?; - print_json(&json!({ - "number": resp.get("number"), - "title": resp.get("title"), - "state": resp.get("state"), - "milestone": resp.get("milestone").and_then(|m| m.get("title")), - })) -} diff --git a/hive-forge/src/verbs/labels.rs b/hive-forge/src/verbs/labels.rs deleted file mode 100644 index a034675..0000000 --- a/hive-forge/src/verbs/labels.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! `labels [list|add|remove] [labels...]` — manage labels on -//! an issue or PR. Default action: list. - -use anyhow::{Result, bail}; -use clap::{Args as ClapArgs, Subcommand}; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - number: u64, - #[command(subcommand)] - action: Option, -} - -#[derive(Subcommand)] -enum Action { - /// List labels (default when no action is given). - List, - /// Add labels by name. - Add { - /// Label names to add. - labels: Vec, - }, - /// Remove labels by name. - Remove { - /// Label names to remove. - labels: Vec, - }, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - match args.action.unwrap_or(Action::List) { - Action::List => { - let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; - print_label_names(&labels); - } - Action::Add { labels } => { - if labels.is_empty() { - bail!("hive-forge labels add: pass at least one label name"); - } - let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; - let ids = resolve_ids(&all, &labels); - let resp = client.post_json( - &format!("/repos/{repo}/issues/{}/labels", args.number), - &json!({ "labels": ids }), - )?; - print_label_names(&resp); - } - Action::Remove { labels } => { - if labels.is_empty() { - bail!("hive-forge labels remove: pass at least one label name"); - } - let all = client.get_json(&format!("/repos/{repo}/labels?limit=100"))?; - for name in &labels { - if let Some(id) = lookup_id(&all, name) { - let _ = client.delete( - &format!("/repos/{repo}/issues/{}/labels/{id}", args.number), - None, - ); - } - } - let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; - print_label_names(&labels); - } - } - Ok(()) -} - -fn resolve_ids(all: &Value, names: &[String]) -> Vec { - let Some(arr) = all.as_array() else { - return Vec::new(); - }; - names - .iter() - .filter_map(|n| { - arr.iter().find_map(|l| { - let lname = l.get("name").and_then(Value::as_str)?; - if lname == n { - l.get("id").and_then(Value::as_u64) - } else { - None - } - }) - }) - .collect() -} - -fn lookup_id(all: &Value, name: &str) -> Option { - let arr = all.as_array()?; - arr.iter().find_map(|l| { - let lname = l.get("name").and_then(Value::as_str)?; - if lname == name { - l.get("id").and_then(Value::as_u64) - } else { - None - } - }) -} - -fn print_label_names(v: &Value) { - let names: Vec<&str> = v - .as_array() - .map(|a| { - a.iter() - .filter_map(|l| l.get("name").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); - let _ = print_json(&json!(names)); -} diff --git a/hive-forge/src/verbs/milestone.rs b/hive-forge/src/verbs/milestone.rs deleted file mode 100644 index 5ce9000..0000000 --- a/hive-forge/src/verbs/milestone.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! `milestone list|create|close` — manage milestones. Default action: -//! list. - -use anyhow::Result; -use clap::{Args as ClapArgs, Subcommand}; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - #[command(subcommand)] - action: Option, -} - -#[derive(Subcommand)] -enum Action { - /// List open milestones as JSON. - List, - /// Create a milestone, print {id,title}. - Create { - /// Milestone title. - #[arg(long)] - title: String, - /// Description. - #[arg(long)] - desc: Option, - /// Due date YYYY-MM-DD. - #[arg(long)] - due: Option, - }, - /// Close a milestone by id. - Close { - /// Milestone id. - id: u64, - }, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - match args.action.unwrap_or(Action::List) { - Action::List => { - let v = - client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?; - let trimmed: Vec = v - .as_array() - .map(|a| { - a.iter() - .map(|m| { - json!({ - "id": m.get("id"), - "title": m.get("title"), - "open_issues": m.get("open_issues"), - "closed_issues": m.get("closed_issues"), - "due_on": m.get("due_on"), - "description": m.get("description"), - }) - }) - .collect() - }) - .unwrap_or_default(); - print_json(&Value::Array(trimmed)) - } - Action::Create { title, desc, due } => { - let mut payload = json!({ "title": title }); - if let Some(d) = desc.filter(|s| !s.is_empty()) { - payload["description"] = Value::String(d); - } - if let Some(d) = due.filter(|s| !s.is_empty()) { - payload["due_on"] = Value::String(format!("{d}T00:00:00Z")); - } - let resp = client.post_json(&format!("/repos/{repo}/milestones"), &payload)?; - print_json(&json!({ - "id": resp.get("id"), - "title": resp.get("title"), - })) - } - Action::Close { id } => { - let resp = client.patch_json( - &format!("/repos/{repo}/milestones/{id}"), - &json!({ "state": "closed" }), - )?; - print_json(&json!({ - "id": resp.get("id"), - "title": resp.get("title"), - "state": resp.get("state"), - })) - } - } -} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs deleted file mode 100644 index 2cea9d9..0000000 --- a/hive-forge/src/verbs/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Per-verb subcommand modules. Each module exposes a `Args` struct -//! (clap-derived) and a `run` fn taking `(&Client, Args) -> Result<()>`. -//! Splitting one verb per module keeps each handler small and avoids -//! the bash script's monolithic `case` statement. - -pub mod assign; -pub mod attach; -pub mod branches; -pub mod close; -pub mod comment; -pub mod comment_edit; -pub mod comment_show; -pub mod diff; -pub mod issue; -pub mod issue_create; -pub mod issue_edit; -pub mod labels; -pub mod milestone; -pub mod pr; -pub mod pr_create; -pub mod pr_reviews; -pub mod subscription; -pub mod tree_sha; -pub mod view; - -use anyhow::Result; -use serde_json::Value; - -/// Pretty-print a `serde_json` value to stdout with a trailing newline, -/// matching the bash script's `| jq` output shape. -pub(crate) fn print_json(v: &Value) -> Result<()> { - let s = serde_json::to_string_pretty(v)?; - println!("{s}"); - Ok(()) -} diff --git a/hive-forge/src/verbs/pr.rs b/hive-forge/src/verbs/pr.rs deleted file mode 100644 index a3e101a..0000000 --- a/hive-forge/src/verbs/pr.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! `pr [repo]` — print key fields of a PR as JSON. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::json; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// PR number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/pulls/{}", args.number))?; - let trimmed = json!({ - "number": v.get("number"), - "title": v.get("title"), - "state": v.get("state"), - "merged": v.get("merged"), - "user": v.get("user").and_then(|u| u.get("login")), - "head_sha": v.get("head").and_then(|h| h.get("sha")), - "head_branch": v.get("head").and_then(|h| h.get("label")), - "base_branch": v.get("base").and_then(|b| b.get("label")), - }); - print_json(&trimmed) -} diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs deleted file mode 100644 index 2298fff..0000000 --- a/hive-forge/src/verbs/pr_create.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! `pr-create --title --head [--base ] [body sources] -//! [--draft] [repo]` — create a PR. Prints the PR URL. - -use anyhow::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, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default(); - 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(()) -} diff --git a/hive-forge/src/verbs/pr_reviews.rs b/hive-forge/src/verbs/pr_reviews.rs deleted file mode 100644 index dbf9577..0000000 --- a/hive-forge/src/verbs/pr_reviews.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! `pr-reviews [repo]` — list PR reviews with id/state/user/body. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// PR number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let v = client.get_json(&format!("/repos/{repo}/pulls/{}/reviews", args.number))?; - let trimmed: Vec = v - .as_array() - .map(|a| { - a.iter() - .map(|r| { - json!({ - "id": r.get("id"), - "state": r.get("state"), - "user": r.get("user").and_then(|u| u.get("login")), - "body": r.get("body"), - "comments_count": r.get("comments_count"), - }) - }) - .collect() - }) - .unwrap_or_default(); - print_json(&Value::Array(trimmed)) -} diff --git a/hive-forge/src/verbs/subscription.rs b/hive-forge/src/verbs/subscription.rs deleted file mode 100644 index 8fc16d6..0000000 --- a/hive-forge/src/verbs/subscription.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! `subscription [--watch|--ignore|--unwatch] [repo]` — get or set -//! the current user's watch subscription for a repo. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::{Value, json}; - -use crate::client::Client; -use crate::verbs::print_json; - -#[derive(ClapArgs)] -pub struct Args { - /// Subscribe (watch the repo). - #[arg(long, group = "action")] - watch: bool, - /// Mute (mark ignored). - #[arg(long, group = "action")] - ignore: bool, - /// Unsubscribe (clear watch + ignore). - #[arg(long, group = "action")] - unwatch: bool, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - if args.unwatch { - client.delete(&format!("/repos/{repo}/subscription"), None)?; - println!("unsubscribed"); - return Ok(()); - } - if args.watch || args.ignore { - let (subscribed, ignored) = if args.ignore { (false, true) } else { (true, false) }; - let resp = client.post_json( - &format!("/repos/{repo}/subscription"), - &json!({ "subscribed": subscribed, "ignored": ignored }), - )?; - return print_json(&json!({ - "subscribed": resp.get("subscribed"), - "ignored": resp.get("ignored"), - })); - } - let resp: Value = client.get_json(&format!("/repos/{repo}/subscription"))?; - print_json(&json!({ - "subscribed": resp.get("subscribed"), - "ignored": resp.get("ignored"), - })) -} diff --git a/hive-forge/src/verbs/tree_sha.rs b/hive-forge/src/verbs/tree_sha.rs deleted file mode 100644 index e2599c9..0000000 --- a/hive-forge/src/verbs/tree_sha.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! `tree-sha [repo]` — print the tree SHA of the commit at a -//! branch name or commit SHA. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::Value; - -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct Args { - /// Branch name or commit SHA. - reference: String, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - // Try branch first; the bash helper silently treats failure as - // "not a branch, use the reference as a commit sha directly". - let commit_sha = match client.get_json(&format!("/repos/{repo}/branches/{}", args.reference)) { - Ok(v) => v - .get("commit") - .and_then(|c| c.get("id")) - .and_then(Value::as_str) - .unwrap_or(&args.reference) - .to_owned(), - Err(_) => args.reference.clone(), - }; - let commit = client.get_json(&format!("/repos/{repo}/git/commits/{commit_sha}"))?; - let sha = commit - .get("tree") - .and_then(|t| t.get("sha")) - .and_then(Value::as_str) - .or_else(|| commit.get("sha").and_then(Value::as_str)) - .unwrap_or(""); - println!("{sha}"); - Ok(()) -} diff --git a/hive-forge/src/verbs/view.rs b/hive-forge/src/verbs/view.rs deleted file mode 100644 index 01494cd..0000000 --- a/hive-forge/src/verbs/view.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! `view [repo]` — dump title + body + all comments as -//! markdown. - -use anyhow::Result; -use clap::Args as ClapArgs; -use serde_json::Value; - -use crate::client::Client; - -#[derive(ClapArgs)] -pub struct Args { - /// Issue or PR number. - number: u64, -} - -pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let issue = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?; - let title = issue.get("title").and_then(Value::as_str).unwrap_or(""); - let body = issue.get("body").and_then(Value::as_str).unwrap_or(""); - let state = issue.get("state").and_then(Value::as_str).unwrap_or("?"); - let user = issue - .get("user") - .and_then(|u| u.get("login")) - .and_then(Value::as_str) - .unwrap_or("?"); - let kind = if issue.get("pull_request").is_some_and(|p| !p.is_null()) { - "PR" - } else { - "issue" - }; - println!("# {kind} #{} [{state}] by {user}", args.number); - println!("{title}"); - if !body.is_empty() { - println!(); - println!("{body}"); - } - let comments = client.get_json(&format!( - "/repos/{repo}/issues/{}/comments?limit=50", - args.number - ))?; - let arr = comments.as_array().cloned().unwrap_or_default(); - if !arr.is_empty() { - println!(); - println!("---"); - println!("## Comments ({})", arr.len()); - println!(); - for c in &arr { - let cu = c - .get("user") - .and_then(|u| u.get("login")) - .and_then(Value::as_str) - .unwrap_or("?"); - let cb = c.get("body").and_then(Value::as_str).unwrap_or(""); - println!("**{cu}**: {cb}"); - println!(); - } - } - Ok(()) -} diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix index e8c28f0..a8f2eee 100644 --- a/nix/packages/hive-forge-tools.nix +++ b/nix/packages/hive-forge-tools.nix @@ -1,36 +1,614 @@ { pkgs, lib }: -# hive-forge — Forgejo CLI wrapper for hyperhive (closes #280). +# Single `hive-forge ` CLI wrapping common Forgejo API operations. +# Reads credentials from the environment: # -# Previously a ~600-line bash script. Rewritten as a proper Rust -# binary in `/hive-forge` so we get: -# - typed clap subcommands (`hive-forge --help` instead of -# reading the case statement), -# - one reqwest client with consistent error surfaces (no more -# `curl --fail-with-body` repeated per verb), -# - sane shell quoting (no more HEREDOC-eaten-by-positional traps -# like #379), -# - and a single test surface. +# HIVE_FORGE_URL -- base URL, e.g. http://localhost:3000 +# HIVE_FORGE_REPO -- default repo, e.g. hyperhive/hyperhive +# HYPERHIVE_STATE_DIR -- state dir; forge-token lives here # -# This Nix file is now a thin extractor: it pulls just the -# `hive-forge` binary out of the hyperhive workspace package so -# agents that already imported this file via -# `pkgs.callPackage ../packages/hive-forge-tools.nix { }` keep -# working, getting only the verb they need on PATH (not the full -# `hive-c0re` / `hive-ag3nt` / `hive-m1nd` surface). -# -# Requires the hyperhive overlay (see `flake.nix`'s `overlays.default`) -# so `pkgs.hyperhive` resolves. -let - _ = lib; # placeholder; kept so callers don't break when reading the args. -in -pkgs.runCommand "hive-forge" - { - meta = { - description = "Forgejo CLI wrapper for hyperhive (Rust)"; - mainProgram = "hive-forge"; - }; - } - '' - mkdir -p $out/bin - ln -s ${pkgs.hyperhive}/bin/hive-forge $out/bin/hive-forge - '' +# Usage: hive-forge [args...] +# Verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, +# branches, tree-sha +pkgs.writeShellApplication { + name = "hive-forge"; + runtimeInputs = [ + pkgs.curl + pkgs.jq + ]; + text = '' + : "''${HIVE_FORGE_URL:=http://localhost:3000}" + : "''${HIVE_FORGE_REPO:=hyperhive/hyperhive}" + _state_dir="''${HYPERHIVE_STATE_DIR:-}" + _token_file="''${_state_dir:+$_state_dir/}forge-token" + if [ ! -f "$_token_file" ]; then + echo "hive-forge: no forge-token at $_token_file" >&2 + exit 1 + fi + _token=$(cat "$_token_file") + FORGE_API="$HIVE_FORGE_URL/api/v1" + + # `-sS --fail-with-body` makes curl quiet on success, print errors to + # stderr on transport failures, AND surface the response body for HTTP + # error codes (4xx/5xx) before exiting non-zero. Without this, a 500 / + # 404 from forge silently returned an empty stdout — combined with the + # script's `set -o pipefail` + the `| jq ...` consumer, the failure + # propagated as "no output" with no clue about what went wrong (closes + # #353). + forge_get() { + ${pkgs.curl}/bin/curl -sS --fail-with-body \ + -H "Authorization: token $_token" \ + -H "Accept: application/json" \ + "$1" + } + forge_post() { + ${pkgs.curl}/bin/curl -sS --fail-with-body -X POST \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$2" "$1" + } + forge_patch() { + ${pkgs.curl}/bin/curl -sS --fail-with-body -X PATCH \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$2" "$1" + } + forge_delete() { + local _url="$1" + local _body="''${2:-}" + if [ -n "$_body" ]; then + ${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$_body" "$_url" + else + ${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \ + -H "Authorization: token $_token" \ + -H "Accept: application/json" \ + "$_url" + fi + } + + # Pick a body string from --body, --body-file (with `-` meaning + # stdin), or piped stdin. Exactly one source — passing both + # `--body` and `--body-file` errors out so the caller is + # forced to pick. Shared between pr-create / issue-create / + # issue-edit so the body-input surface stays consistent across + # body-accepting verbs (#382). Falling all the way through + # (no flag, no piped stdin) yields empty; callers that + # require a body check after this returns. + resolve_body() { + local _body="$1" + local _file="$2" + if [ -n "$_body" ] && [ -n "$_file" ]; then + echo "hive-forge: --body and --body-file are mutually exclusive" >&2 + exit 1 + fi + if [ -n "$_file" ]; then + if [ "$_file" = "-" ]; then + cat + else + cat "$_file" + fi + elif [ -n "$_body" ]; then + printf '%s' "$_body" + elif [ ! -t 0 ]; then + cat + fi + } + + cmd_view() { + # view [repo] + # Dump title + body + all comments for an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge view [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _issue _title _body _state _user _is_pr _kind + _issue=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n") + _title=$(printf '%s' "$_issue" | jq -r '.title') + _body=$(printf '%s' "$_issue" | jq -r '.body // ""') + _state=$(printf '%s' "$_issue" | jq -r '.state') + _user=$(printf '%s' "$_issue" | jq -r '.user.login') + _is_pr=$(printf '%s' "$_issue" | jq -r '.pull_request != null') + _kind="issue" + if [ "$_is_pr" = "true" ]; then _kind="PR"; fi + printf '# %s #%s [%s] by %s\n' "$_kind" "$_n" "$_state" "$_user" + printf '%s\n' "$_title" + if [ -n "$_body" ]; then printf '\n%s\n' "$_body"; fi + local _comments _count + _comments=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n/comments?limit=50") + _count=$(printf '%s' "$_comments" | jq 'length') + if [ "$_count" -gt 0 ]; then + printf '\n---\n## Comments (%s)\n\n' "$_count" + printf '%s' "$_comments" | jq -r '.[] | "**\(.user.login)**: \(.body)\n"' + fi + } + + cmd_issue() { + # issue [repo] + # Print key fields of an issue as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge issue [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/issues/$_n" \ + | jq '{number,title,state,user:.user.login,assignees:[.assignees[]?.login],labels:[.labels[]?.name],body}' + } + + cmd_pr() { + # pr [repo] + # Print key fields of a PR as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/pulls/$_n" \ + | jq '{number,title,state,merged,user:.user.login,head_sha:.head.sha,head_branch:.head.label,base_branch:.base.label}' + } + + cmd_comment_show() { + # comment-show [--json] [repo] + # Print the body (or full JSON with --json) of a single comment by its id. + if [ $# -lt 1 ]; then echo "usage: hive-forge comment-show [--json] [repo]" >&2; exit 1; fi + local _id="$1"; shift + local _json=false _repo="$HIVE_FORGE_REPO" + while [ $# -gt 0 ]; do + case "$1" in + --json) _json=true; shift ;; + *) _repo="$1"; shift ;; + esac + done + local _resp + _resp=$(forge_get "$FORGE_API/repos/$_repo/issues/comments/$_id") + if [ "$_json" = true ]; then + printf '%s\n' "$_resp" | jq '{id,user:.user.login,created_at,updated_at,body,url:.html_url}' + else + printf '%s\n' "$_resp" | jq -r '.body' + fi + } + + cmd_comment() { + # comment [--body | -f | -] [repo] + # Post a comment on an issue or PR. + # Body sources, in priority order: + # --body | -f | - (explicit stdin) + # piped stdin (HEREDOC / shell pipe — `! -t 0`) + # bare positional argument + # Stdin wins over a bare positional so the natural + # hive-forge comment 377 owner/repo < [--body | -f | -] [repo]" >&2; exit 1; fi + local _n="$1"; shift + local _body _repo="$HIVE_FORGE_REPO" + if [ "''${1:-}" = "--body" ]; then + _body="$2"; shift 2 || true + elif [ "''${1:-}" = "-f" ]; then + _body=$(cat "$2"); shift 2 || true + elif [ "''${1:-}" = "-" ]; then + _body=$(cat); shift + elif [[ "''${1:-}" == --* ]]; then + echo "hive-forge comment: unknown flag '$1' (did you mean --body?)" >&2; exit 1 + elif [ ! -t 0 ]; then + # Stdin is piped — use it for the body. Any trailing + # positional is a repo override (the #379 trap). + _body=$(cat) + elif [ $# -eq 0 ]; then + echo "hive-forge comment: no body — pass --body , -f , - (stdin), or a bare positional" >&2; exit 1 + else + _body="$1"; shift + fi + # Any leftover positional (after the body source consumed its + # args) is a repo override, same shape as comment-edit / + # issue-edit / etc. + if [ $# -gt 0 ]; then _repo="$1"; fi + if [ -z "$(printf '%s' "$_body" | tr -d '[:space:]')" ]; then + echo "hive-forge comment: refusing to post empty comment body" >&2; exit 1 + fi + local _payload + _payload=$(jq -n --arg body "$_body" '{body:$body}') + forge_post "$FORGE_API/repos/$_repo/issues/$_n/comments" "$_payload" \ + | jq '{id,url:.html_url}' + } + + cmd_comment_edit() { + # comment-edit [--body | -f | -] [repo] + # Edit an existing comment by its id. + # Same precedence rules as `comment` (see #379): piped stdin + # always wins over a bare positional, so the natural HEREDOC + # form doesn't silently get its body replaced by the repo arg. + if [ $# -lt 1 ]; then echo "usage: hive-forge comment-edit [--body | -f | -] [repo]" >&2; exit 1; fi + local _id="$1"; shift + local _body _repo="$HIVE_FORGE_REPO" + if [ "''${1:-}" = "--body" ]; then + _body="$2"; shift 2 || true + elif [ "''${1:-}" = "-f" ]; then + _body=$(cat "$2"); shift 2 || true + elif [ "''${1:-}" = "-" ]; then + _body=$(cat); shift + elif [[ "''${1:-}" == --* ]]; then + echo "hive-forge comment-edit: unknown flag '$1'" >&2; exit 1 + elif [ ! -t 0 ]; then + _body=$(cat) + elif [ $# -eq 0 ]; then + echo "hive-forge comment-edit: no body — pass --body , -f , - (stdin), or a bare positional" >&2; exit 1 + else + _body="$1"; shift + fi + if [ $# -gt 0 ]; then _repo="$1"; fi + if [ -z "$(printf '%s' "$_body" | tr -d '[:space:]')" ]; then + echo "hive-forge comment-edit: refusing to post empty comment body" >&2; exit 1 + fi + local _payload + _payload=$(jq -n --arg body "$_body" '{body:$body}') + forge_patch "$FORGE_API/repos/$_repo/issues/comments/$_id" "$_payload" \ + | jq '{id,user:.user.login,url:.html_url}' + } + + cmd_assign() { + # assign [--remove] + # Assign or unassign a user on an issue or PR. + # + # The Forgejo API has no dedicated `POST /issues/{n}/assignees` + # endpoint (closes #353) — assignees are an EditIssueOption field + # on the issue itself. We patch the full assignee list: + # - add: GET current assignees, append `_user`, PATCH back + # - remove: GET current assignees, drop `_user`, PATCH back + # The PATCH is idempotent: re-adding an existing assignee or + # removing one that's not on the list is a no-op (the resulting + # assignee list is unchanged). + if [ $# -lt 2 ]; then echo "usage: hive-forge assign [--remove]" >&2; exit 1; fi + local _n="$1" _user="$2" _remove="''${3:-}" + local _current _payload + _current=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" \ + | jq -c '[.assignees[]?.login]') + if [ "$_remove" = "--remove" ]; then + _payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \ + '{assignees: ($cur - [$u])}') + else + _payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \ + '{assignees: (($cur + [$u]) | unique)}') + fi + forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" "$_payload" \ + | jq '{number,assignees:[.assignees[]?.login]}' + } + + cmd_close() { + # close + # Close an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge close " >&2; exit 1; fi + forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \ + | jq '{number,state}' + } + + cmd_labels() { + # labels [list|add|remove] [labels...] + # Manage labels on an issue or PR. Default action is list. + if [ $# -lt 1 ]; then echo "usage: hive-forge labels [list|add|remove] [labels...]" >&2; exit 1; fi + local _n="$1" _action="''${2:-list}" + shift; shift || true + local _all _ids _id _label + case "$_action" in + list) + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' + ;; + add) + _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") + _ids=$(printf '%s' "$_all" | jq -r --args '[.[] | select(.name == ($ARGS.positional[])) | .id]' -- "$@") + forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" "{\"labels\":$_ids}" \ + | jq '[.[].name]' + ;; + remove) + _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") + for _label in "$@"; do + _id=$(printf '%s' "$_all" | jq -r --arg n "$_label" '.[] | select(.name==$n) | .id') + if [ -n "$_id" ]; then + forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels/$_id" + fi + done + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' + ;; + *) + echo "unknown action: $_action (use list, add, remove)" >&2; exit 1 + ;; + esac + } + + cmd_pr_reviews() { + # pr-reviews [repo] + # List reviews on a PR with id/state/user/body. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr-reviews [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/pulls/$_n/reviews" \ + | jq '[.[] | {id,state,user:.user.login,body,comments_count}]' + } + + cmd_branches() { + # branches [pattern] [repo] + # List branches, optionally filtered by a grep pattern. + local _pattern="''${1:-}" _repo="''${2:-$HIVE_FORGE_REPO}" + local _result + _result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" | jq -r '.[].name') + if [ -n "$_pattern" ]; then + printf '%s\n' "$_result" | grep "$_pattern" || true + else + printf '%s\n' "$_result" + fi + } + + cmd_tree_sha() { + # tree-sha [repo] + # Print the tree SHA of the commit at the given branch or commit SHA. + if [ $# -lt 1 ]; then echo "usage: hive-forge tree-sha [repo]" >&2; exit 1; fi + local _ref="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _commit_sha + _commit_sha=$(forge_get "$FORGE_API/repos/$_repo/branches/$_ref" 2>/dev/null \ + | jq -r '.commit.id // empty') || true + if [ -z "$_commit_sha" ]; then + _commit_sha="$_ref" + fi + forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \ + | jq -r '.tree.sha // .sha' + } + + cmd_pr_create() { + # pr-create --title --head <branch> [--base <base>] + # [--body <body> | --body-file <path>] [--draft] [repo] + # Create a pull request. Prints the PR URL on success. + # Body sources (priority): --body, --body-file <path> (use `-` + # for stdin), piped stdin (when neither flag is set). Closes + # #382. + local _title="" _head="" _base="main" _body="" _body_file="" _draft="false" _repo="$HIVE_FORGE_REPO" + while [ $# -gt 0 ]; do + case "$1" in + --title) _title="$2"; shift 2 ;; + --head) _head="$2"; shift 2 ;; + --base) _base="$2"; shift 2 ;; + --body) _body="$2"; shift 2 ;; + --body-file) _body_file="$2"; shift 2 ;; + --draft) _draft="true"; shift ;; + *) _repo="$1"; shift ;; + esac + done + if [ -z "$_title" ] || [ -z "$_head" ]; then + echo "usage: hive-forge pr-create --title <title> --head <branch> [--base <base>] [--body <body> | --body-file <path>] [--draft] [repo]" >&2 + exit 1 + fi + _body=$(resolve_body "$_body" "$_body_file") + local _payload + _payload=$(jq -n \ + --arg title "$_title" \ + --arg head "$_head" \ + --arg base "$_base" \ + --arg body "$_body" \ + --argjson draft "$_draft" \ + '{title:$title,head:$head,base:$base,body:$body,draft:$draft,allow_maintainer_edit:true}') + forge_post "$FORGE_API/repos/$_repo/pulls" "$_payload" \ + | jq -r '.html_url' + } + + cmd_issue_create() { + # issue-create --title <title> [--body <body> | --body-file <path>] + # [--assignee <user>] [repo] + # Create an issue. Prints the issue URL on success. + # Body sources (priority): --body, --body-file <path> (use `-` + # for stdin), piped stdin (when neither flag is set). Closes + # #382. + local _title="" _body="" _body_file="" _assignee="" _repo="$HIVE_FORGE_REPO" + while [ $# -gt 0 ]; do + case "$1" in + --title) _title="$2"; shift 2 ;; + --body) _body="$2"; shift 2 ;; + --body-file) _body_file="$2"; shift 2 ;; + --assignee) _assignee="$2"; shift 2 ;; + *) _repo="$1"; shift ;; + esac + done + if [ -z "$_title" ]; then + echo "usage: hive-forge issue-create --title <title> [--body <body> | --body-file <path>] [--assignee <user>] [repo]" >&2 + exit 1 + fi + _body=$(resolve_body "$_body" "$_body_file") + local _payload + if [ -n "$_assignee" ]; then + _payload=$(jq -n --arg t "$_title" --arg b "$_body" --arg a "$_assignee" \ + '{title:$t,body:$b,assignees:[$a]}') + else + _payload=$(jq -n --arg t "$_title" --arg b "$_body" '{title:$t,body:$b}') + fi + forge_post "$FORGE_API/repos/$_repo/issues" "$_payload" \ + | jq -r '.html_url' + } + + cmd_issue_edit() { + # issue-edit <number> [--title <title>] [--body <body> | --body-file <path>] + # [--state open|closed] [--milestone <id>] [repo] + # Edit an issue's title, body, state, or milestone. Only + # provided fields are changed. Body sources for #382 parity: + # --body, --body-file <path> (use `-` for stdin), or piped + # stdin (only when neither --body nor --body-file is set; the + # partial-update contract still treats unset → "leave body + # alone", so piping nothing is a no-op rather than a clobber). + if [ $# -lt 1 ]; then echo "usage: hive-forge issue-edit <number> [--title <title>] [--body <body> | --body-file <path>] [--state open|closed] [--milestone <id>] [repo]" >&2; exit 1; fi + local _n="$1"; shift + local _title="" _body="" _body_file="" _state="" _milestone="" _repo="$HIVE_FORGE_REPO" + while [ $# -gt 0 ]; do + case "$1" in + --title) _title="$2"; shift 2 ;; + --body) _body="$2"; shift 2 ;; + --body-file) _body_file="$2"; shift 2 ;; + --state) _state="$2"; shift 2 ;; + --milestone) _milestone="$2"; shift 2 ;; + *) _repo="$1"; shift ;; + esac + done + _body=$(resolve_body "$_body" "$_body_file") + local _payload + _payload=$(jq -n \ + --arg title "$_title" \ + --arg body "$_body" \ + --arg state "$_state" \ + --argjson milestone "$([ -n "$_milestone" ] && echo "$_milestone" || echo "null")" \ + '{} | + if $title != "" then . + {title:$title} else . end | + if $body != "" then . + {body:$body} else . end | + if $state != "" then . + {state:$state} else . end | + if $milestone != null then . + {milestone:$milestone} else . end') + forge_patch "$FORGE_API/repos/$_repo/issues/$_n" "$_payload" \ + | jq '{number,title,state,milestone:.milestone.title}' + } + + cmd_attach_issue() { + # attach-issue <number> <file> [repo] + # Upload a file as an attachment to an issue. Prints the download URL. + if [ $# -lt 2 ]; then echo "usage: hive-forge attach-issue <number> <file> [repo]" >&2; exit 1; fi + local _n="$1" _file="$2" _repo="''${3:-$HIVE_FORGE_REPO}" + if [ ! -f "$_file" ]; then echo "hive-forge attach-issue: file not found: $_file" >&2; exit 1; fi + ${pkgs.curl}/bin/curl -sf -X POST \ + -H "Authorization: token $_token" \ + -F "attachment=@$_file" \ + "$FORGE_API/repos/$_repo/issues/$_n/assets" \ + | jq -r '.browser_download_url' + } + + cmd_attach_comment() { + # attach-comment <comment-id> <file> [repo] + # Upload a file as an attachment to an issue comment. Prints the download URL. + if [ $# -lt 2 ]; then echo "usage: hive-forge attach-comment <comment-id> <file> [repo]" >&2; exit 1; fi + local _id="$1" _file="$2" _repo="''${3:-$HIVE_FORGE_REPO}" + if [ ! -f "$_file" ]; then echo "hive-forge attach-comment: file not found: $_file" >&2; exit 1; fi + ${pkgs.curl}/bin/curl -sf -X POST \ + -H "Authorization: token $_token" \ + -F "attachment=@$_file" \ + "$FORGE_API/repos/$_repo/issues/comments/$_id/assets" \ + | jq -r '.browser_download_url' + } + + cmd_diff() { + # diff <pr> [repo] + # Print the unified diff for a PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge diff <pr> [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + ${pkgs.curl}/bin/curl -sf \ + -H "Authorization: token $_token" \ + -H "Accept: text/plain" \ + "$FORGE_API/repos/$_repo/pulls/$_n.diff" + } + + cmd_subscription() { + # subscription [--watch|--ignore|--unwatch] [repo] + # Get or set the current user's watch subscription for a repo. + # No flag: print current subscription status as JSON. + # --watch: subscribe; --ignore: ignore; --unwatch: unsubscribe. + local _action="" _repo="$HIVE_FORGE_REPO" + while [ $# -gt 0 ]; do + case "$1" in + --watch) _action="watch"; shift ;; + --ignore) _action="ignore"; shift ;; + --unwatch) _action="unwatch"; shift ;; + *) _repo="$1"; shift ;; + esac + done + if [ -z "$_action" ]; then + forge_get "$FORGE_API/repos/$_repo/subscription" \ + | jq '{subscribed,ignored}' + elif [ "$_action" = "unwatch" ]; then + forge_delete "$FORGE_API/repos/$_repo/subscription" > /dev/null + echo "unsubscribed" + else + local _subscribed="true" _ignored="false" + if [ "$_action" = "ignore" ]; then _subscribed="false"; _ignored="true"; fi + forge_post "$FORGE_API/repos/$_repo/subscription" \ + "{\"subscribed\":$_subscribed,\"ignored\":$_ignored}" \ + | jq '{subscribed,ignored}' + fi + } + + cmd_milestone() { + # milestone [list|create|close] [args...] [repo] + # Manage milestones. Default action: list. + # list [repo] -- list open milestones as JSON + # create --title <title> [--desc <desc>] [--due YYYY-MM-DD] [repo] -- create milestone, print id+title + # close <id> [repo] -- close a milestone + local _action="''${1:-list}" + shift || true + local _repo="$HIVE_FORGE_REPO" + case "$_action" in + list) + if [ $# -gt 0 ] && [[ "$1" != --* ]]; then _repo="$1"; shift; fi + forge_get "$FORGE_API/repos/$_repo/milestones?state=open&limit=50" \ + | jq '[.[] | {id,title,open_issues,closed_issues,due_on,description}]' + ;; + create) + local _title="" _desc="" _due="" + while [ $# -gt 0 ]; do + case "$1" in + --title) _title="$2"; shift 2 ;; + --desc) _desc="$2"; shift 2 ;; + --due) _due="$2"; shift 2 ;; + *) _repo="$1"; shift ;; + esac + done + if [ -z "$_title" ]; then + echo "usage: hive-forge milestone create --title <title> [--desc <desc>] [--due YYYY-MM-DD] [repo]" >&2; exit 1 + fi + local _payload + _payload=$(jq -n --arg t "$_title" --arg d "$_desc" --arg due "$_due" \ + '{title:$t} | + if $d != "" then . + {description:$d} else . end | + if $due != "" then . + {due_on:($due+"T00:00:00Z")} else . end') + forge_post "$FORGE_API/repos/$_repo/milestones" "$_payload" \ + | jq '{id,title}' + ;; + close) + if [ $# -lt 1 ]; then echo "usage: hive-forge milestone close <id> [repo]" >&2; exit 1; fi + local _id="$1"; shift + if [ $# -gt 0 ]; then _repo="$1"; fi + forge_patch "$FORGE_API/repos/$_repo/milestones/$_id" '{"state":"closed"}' \ + | jq '{id,title,state}' + ;; + *) + echo "unknown milestone action: $_action (use list, create, close)" >&2; exit 1 + ;; + esac + } + + VERB="''${1:-}" + if [ -z "$VERB" ]; then + echo "usage: hive-forge <verb> [args...]" >&2 + echo "verbs: view, issue, issue-create, issue-edit, pr, pr-create, comment, comment-show," >&2 + echo " comment-edit, assign, close, labels, milestone, pr-reviews, branches, tree-sha," >&2 + echo " diff, subscription, attach-issue, attach-comment" >&2 + exit 1 + fi + shift + + case "$VERB" in + view) cmd_view "$@" ;; + issue) cmd_issue "$@" ;; + issue-create) cmd_issue_create "$@" ;; + issue-edit) cmd_issue_edit "$@" ;; + pr) cmd_pr "$@" ;; + pr-create) cmd_pr_create "$@" ;; + comment) cmd_comment "$@" ;; + comment-show) cmd_comment_show "$@" ;; + comment-edit) cmd_comment_edit "$@" ;; + assign) cmd_assign "$@" ;; + close) cmd_close "$@" ;; + labels) cmd_labels "$@" ;; + milestone) cmd_milestone "$@" ;; + pr-reviews) cmd_pr_reviews "$@" ;; + branches) cmd_branches "$@" ;; + tree-sha) cmd_tree_sha "$@" ;; + diff) cmd_diff "$@" ;; + subscription) cmd_subscription "$@" ;; + attach-issue) cmd_attach_issue "$@" ;; + attach-comment) cmd_attach_comment "$@" ;; + *) + echo "hive-forge: unknown verb '$VERB'" >&2 + echo "verbs: view, issue, issue-create, issue-edit, pr, pr-create, comment, comment-show," >&2 + echo " comment-edit, assign, close, labels, milestone, pr-reviews, branches, tree-sha," >&2 + echo " diff, subscription, attach-issue, attach-comment" >&2 + exit 1 + ;; + esac + ''; +}