diff --git a/CLAUDE.md b/CLAUDE.md index bded37d..735ee1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,18 @@ 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 9e8e75b..e156349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,17 @@ 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" @@ -1202,7 +1213,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1211,6 +1224,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/Cargo.toml b/Cargo.toml index c388004..b990949 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-sh4re"] [workspace.package] edition = "2024" diff --git a/docs/gotchas.md b/docs/gotchas.md index b2a4484..986471f 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -118,17 +118,24 @@ 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`). Use it instead of ad-hoc curl pipelines: +`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary +since #280). Use it instead of ad-hoc curl pipelines: ```bash -hive-forge view 42 # title + body + comments -hive-forge comment 42 "..." # post comment +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. diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 5b70abc..0c95724 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`, `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. +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. 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 587ffb1..6dc649d 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`, `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. +**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. 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 new file mode 100644 index 0000000..def1915 --- /dev/null +++ b/hive-forge/Cargo.toml @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 0000000..39341ad --- /dev/null +++ b/hive-forge/src/body.rs @@ -0,0 +1,56 @@ +//! 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 new file mode 100644 index 0000000..882e9da --- /dev/null +++ b/hive-forge/src/client.rs @@ -0,0 +1,180 @@ +//! 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 new file mode 100644 index 0000000..62569b0 --- /dev/null +++ b/hive-forge/src/main.rs @@ -0,0 +1,111 @@ +//! `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 new file mode 100644 index 0000000..5c9ed71 --- /dev/null +++ b/hive-forge/src/verbs/assign.rs @@ -0,0 +1,58 @@ +//! `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 new file mode 100644 index 0000000..91e621b --- /dev/null +++ b/hive-forge/src/verbs/attach.rs @@ -0,0 +1,65 @@ +//! `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 new file mode 100644 index 0000000..287f502 --- /dev/null +++ b/hive-forge/src/verbs/branches.rs @@ -0,0 +1,32 @@ +//! `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 new file mode 100644 index 0000000..0d01ce2 --- /dev/null +++ b/hive-forge/src/verbs/close.rs @@ -0,0 +1,26 @@ +//! `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 new file mode 100644 index 0000000..e2aece1 --- /dev/null +++ b/hive-forge/src/verbs/comment.rs @@ -0,0 +1,35 @@ +//! `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 new file mode 100644 index 0000000..c271a39 --- /dev/null +++ b/hive-forge/src/verbs/comment_edit.rs @@ -0,0 +1,40 @@ +//! `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 new file mode 100644 index 0000000..9a2e41d --- /dev/null +++ b/hive-forge/src/verbs/comment_show.rs @@ -0,0 +1,38 @@ +//! `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 new file mode 100644 index 0000000..aa64c5c --- /dev/null +++ b/hive-forge/src/verbs/diff.rs @@ -0,0 +1,19 @@ +//! `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 new file mode 100644 index 0000000..fdca71d --- /dev/null +++ b/hive-forge/src/verbs/issue.rs @@ -0,0 +1,35 @@ +//! `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 new file mode 100644 index 0000000..b80fc02 --- /dev/null +++ b/hive-forge/src/verbs/issue_create.rs @@ -0,0 +1,39 @@ +//! `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 new file mode 100644 index 0000000..ac76162 --- /dev/null +++ b/hive-forge/src/verbs/issue_edit.rs @@ -0,0 +1,85 @@ +//! `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 new file mode 100644 index 0000000..a034675 --- /dev/null +++ b/hive-forge/src/verbs/labels.rs @@ -0,0 +1,115 @@ +//! `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 new file mode 100644 index 0000000..5ce9000 --- /dev/null +++ b/hive-forge/src/verbs/milestone.rs @@ -0,0 +1,91 @@ +//! `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 new file mode 100644 index 0000000..2cea9d9 --- /dev/null +++ b/hive-forge/src/verbs/mod.rs @@ -0,0 +1,35 @@ +//! 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 new file mode 100644 index 0000000..a3e101a --- /dev/null +++ b/hive-forge/src/verbs/pr.rs @@ -0,0 +1,30 @@ +//! `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 new file mode 100644 index 0000000..2298fff --- /dev/null +++ b/hive-forge/src/verbs/pr_create.rs @@ -0,0 +1,49 @@ +//! `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 new file mode 100644 index 0000000..dbf9577 --- /dev/null +++ b/hive-forge/src/verbs/pr_reviews.rs @@ -0,0 +1,36 @@ +//! `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 new file mode 100644 index 0000000..8fc16d6 --- /dev/null +++ b/hive-forge/src/verbs/subscription.rs @@ -0,0 +1,47 @@ +//! `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 new file mode 100644 index 0000000..e2599c9 --- /dev/null +++ b/hive-forge/src/verbs/tree_sha.rs @@ -0,0 +1,38 @@ +//! `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 new file mode 100644 index 0000000..01494cd --- /dev/null +++ b/hive-forge/src/verbs/view.rs @@ -0,0 +1,60 @@ +//! `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 a8f2eee..e8c28f0 100644 --- a/nix/packages/hive-forge-tools.nix +++ b/nix/packages/hive-forge-tools.nix @@ -1,614 +1,36 @@ { pkgs, lib }: -# Single `hive-forge ` CLI wrapping common Forgejo API operations. -# Reads credentials from the environment: +# hive-forge — Forgejo CLI wrapper for hyperhive (closes #280). # -# 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 +# 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. # -# 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 - ''; -} +# 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 + ''