hive-forge: rewrite bash CLI helper as a rust binary (closes #280)

This commit is contained in:
damocles 2026-05-25 01:30:44 +02:00 committed by Mara
parent 560360d2e3
commit 595e3c040c
28 changed files with 1434 additions and 612 deletions

18
hive-forge/Cargo.toml Normal file
View file

@ -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

56
hive-forge/src/body.rs Normal file
View file

@ -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<Option<String>> {
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<String> {
let resolved = resolve(body, file)?;
let Some(s) = resolved else {
bail!(
"hive-forge {verb}: no body — pass --body <text>, --body-file <path>, 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<String> {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
Ok(s)
}

173
hive-forge/src/client.rs Normal file
View file

@ -0,0 +1,173 @@
//! 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<reqwest::Client>` 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.
pub fn from_env() -> Result<Self> {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let default_repo =
std::env::var("HIVE_FORGE_REPO").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 (`<base>/api/v1`).
fn api(&self) -> String {
format!("{}/api/v1", self.base)
}
/// Pick the user-supplied repo or fall back to the default.
pub fn repo<'a>(&'a self, override_: Option<&'a str>) -> &'a str {
override_.unwrap_or(&self.default_repo)
}
/// GET `<api>/<path>` and decode JSON.
pub fn get_json(&self, path: &str) -> Result<Value> {
let url = format!("{}{}", self.api(), path);
let resp = self.http.get(&url).send().context("GET")?;
decode_json(resp, &format!("GET {url}"))
}
/// GET `<api>/<path>` 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<String> {
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 `<api>/<path>` and decode the response.
pub fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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 `<api>/<path>` and decode the response.
pub fn patch_json<B: Serialize>(&self, path: &str, body: &B) -> Result<Value> {
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 `<api>/<path>`. 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<Value> {
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<String> {
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<Response> {
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<Value> {
let resp = check_status(resp, op)?;
resp.json::<Value>()
.with_context(|| format!("decode JSON for {op}"))
}
fn decode_text(resp: Response, op: &str) -> Result<String> {
let resp = check_status(resp, op)?;
resp.text().with_context(|| format!("decode text for {op}"))
}

106
hive-forge/src/main.rs Normal file
View file

@ -0,0 +1,106 @@
//! `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 {
#[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().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),
}
}

View file

@ -0,0 +1,58 @@
//! `assign <number> <user> [--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(None);
let current = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
let mut assignees: Vec<String> = 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,
}))
}

View file

@ -0,0 +1,69 @@
//! `attach-issue <number> <file> [repo]` and `attach-comment
//! <comment-id> <file> [repo]` — 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,
/// Repo override.
repo: Option<String>,
}
#[derive(ClapArgs)]
pub struct CommentArgs {
/// Comment id.
id: u64,
/// File path to upload.
file: PathBuf,
/// Repo override.
repo: Option<String>,
}
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(args.repo.as_deref());
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(args.repo.as_deref());
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}");
}
}

View file

@ -0,0 +1,34 @@
//! `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<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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(())
}

View file

@ -0,0 +1,28 @@
//! `close <number>` — 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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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"),
}))
}

View file

@ -0,0 +1,37 @@
//! `comment <number> [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<String>,
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Repo override.
repo: Option<String>,
}
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(args.repo.as_deref());
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"),
}))
}

View file

@ -0,0 +1,42 @@
//! `comment-edit <id> [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<String>,
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Repo override.
repo: Option<String>,
}
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(args.repo.as_deref());
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"),
}))
}

View file

@ -0,0 +1,40 @@
//! `comment-show <id> [--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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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(())
}
}

View file

@ -0,0 +1,21 @@
//! `diff <pr> [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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
print!("{diff}");
Ok(())
}

View file

@ -0,0 +1,37 @@
//! `issue <number> [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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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::<Vec<_>>())
.unwrap_or_default(),
"labels": v.get("labels")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|x| x.get("name")).cloned().collect::<Vec<_>>())
.unwrap_or_default(),
"body": v.get("body"),
});
print_json(&trimmed)
}

View file

@ -0,0 +1,41 @@
//! `issue-create --title <t> [body sources] [--assignee <u>] [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<String>,
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Initial assignee login.
#[arg(long)]
assignee: Option<String>,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
let repo = client.repo(args.repo.as_deref());
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(())
}

View file

@ -0,0 +1,87 @@
//! `issue-edit <number> [--title <t>] [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<String>,
/// Inline body text (omit to leave unchanged).
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// New state.
#[arg(long, value_enum)]
state: Option<StateArg>,
/// Milestone id (0 to unset).
#[arg(long)]
milestone: Option<u64>,
/// Repo override.
repo: Option<String>,
}
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(args.repo.as_deref());
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")),
}))
}

View file

@ -0,0 +1,115 @@
//! `labels <number> [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<Action>,
}
#[derive(Subcommand)]
enum Action {
/// List labels (default when no action is given).
List,
/// Add labels by name.
Add {
/// Label names to add.
labels: Vec<String>,
},
/// Remove labels by name.
Remove {
/// Label names to remove.
labels: Vec<String>,
},
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(None);
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<u64> {
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<u64> {
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));
}

View file

@ -0,0 +1,105 @@
//! `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<Action>,
}
#[derive(Subcommand)]
enum Action {
/// List open milestones as JSON.
List {
/// Repo override.
repo: Option<String>,
},
/// Create a milestone, print {id,title}.
Create {
/// Milestone title.
#[arg(long)]
title: String,
/// Description.
#[arg(long)]
desc: Option<String>,
/// Due date YYYY-MM-DD.
#[arg(long)]
due: Option<String>,
/// Repo override.
repo: Option<String>,
},
/// Close a milestone by id.
Close {
/// Milestone id.
id: u64,
/// Repo override.
repo: Option<String>,
},
}
pub fn run(client: &Client, args: Args) -> Result<()> {
match args.action.unwrap_or(Action::List { repo: None }) {
Action::List { repo } => {
let repo = client.repo(repo.as_deref());
let v =
client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
let trimmed: Vec<Value> = 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,
repo,
} => {
let repo = client.repo(repo.as_deref());
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, repo } => {
let repo = client.repo(repo.as_deref());
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"),
}))
}
}
}

View file

@ -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(())
}

View file

@ -0,0 +1,32 @@
//! `pr <number> [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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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)
}

View file

@ -0,0 +1,51 @@
//! `pr-create --title <t> --head <branch> [--base <b>] [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<String>,
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Open as draft.
#[arg(long)]
draft: bool,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
let repo = client.repo(args.repo.as_deref());
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(())
}

View file

@ -0,0 +1,38 @@
//! `pr-reviews <number> [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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
let v = client.get_json(&format!("/repos/{repo}/pulls/{}/reviews", args.number))?;
let trimmed: Vec<Value> = 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))
}

View file

@ -0,0 +1,49 @@
//! `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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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"),
}))
}

View file

@ -0,0 +1,40 @@
//! `tree-sha <ref> [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,
/// Repo override.
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
// 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(())
}

View file

@ -0,0 +1,62 @@
//! `view <number> [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,
/// Repo override (default from `HIVE_FORGE_REPO`).
repo: Option<String>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(args.repo.as_deref());
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(())
}