hive-forge: rewrite bash CLI helper as a rust binary (closes #280)
This commit is contained in:
parent
560360d2e3
commit
595e3c040c
28 changed files with 1434 additions and 612 deletions
12
CLAUDE.md
12
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/<verb>.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)
|
||||
|
||||
|
|
|
|||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
18
hive-forge/Cargo.toml
Normal file
18
hive-forge/Cargo.toml
Normal 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
56
hive-forge/src/body.rs
Normal 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
173
hive-forge/src/client.rs
Normal 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
106
hive-forge/src/main.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
58
hive-forge/src/verbs/assign.rs
Normal file
58
hive-forge/src/verbs/assign.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
69
hive-forge/src/verbs/attach.rs
Normal file
69
hive-forge/src/verbs/attach.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
34
hive-forge/src/verbs/branches.rs
Normal file
34
hive-forge/src/verbs/branches.rs
Normal 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(())
|
||||
}
|
||||
28
hive-forge/src/verbs/close.rs
Normal file
28
hive-forge/src/verbs/close.rs
Normal 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"),
|
||||
}))
|
||||
}
|
||||
37
hive-forge/src/verbs/comment.rs
Normal file
37
hive-forge/src/verbs/comment.rs
Normal 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"),
|
||||
}))
|
||||
}
|
||||
42
hive-forge/src/verbs/comment_edit.rs
Normal file
42
hive-forge/src/verbs/comment_edit.rs
Normal 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"),
|
||||
}))
|
||||
}
|
||||
40
hive-forge/src/verbs/comment_show.rs
Normal file
40
hive-forge/src/verbs/comment_show.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
21
hive-forge/src/verbs/diff.rs
Normal file
21
hive-forge/src/verbs/diff.rs
Normal 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(())
|
||||
}
|
||||
37
hive-forge/src/verbs/issue.rs
Normal file
37
hive-forge/src/verbs/issue.rs
Normal 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)
|
||||
}
|
||||
41
hive-forge/src/verbs/issue_create.rs
Normal file
41
hive-forge/src/verbs/issue_create.rs
Normal 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(())
|
||||
}
|
||||
87
hive-forge/src/verbs/issue_edit.rs
Normal file
87
hive-forge/src/verbs/issue_edit.rs
Normal 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")),
|
||||
}))
|
||||
}
|
||||
115
hive-forge/src/verbs/labels.rs
Normal file
115
hive-forge/src/verbs/labels.rs
Normal 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));
|
||||
}
|
||||
105
hive-forge/src/verbs/milestone.rs
Normal file
105
hive-forge/src/verbs/milestone.rs
Normal 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"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
35
hive-forge/src/verbs/mod.rs
Normal file
35
hive-forge/src/verbs/mod.rs
Normal 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(())
|
||||
}
|
||||
32
hive-forge/src/verbs/pr.rs
Normal file
32
hive-forge/src/verbs/pr.rs
Normal 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)
|
||||
}
|
||||
51
hive-forge/src/verbs/pr_create.rs
Normal file
51
hive-forge/src/verbs/pr_create.rs
Normal 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(())
|
||||
}
|
||||
38
hive-forge/src/verbs/pr_reviews.rs
Normal file
38
hive-forge/src/verbs/pr_reviews.rs
Normal 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))
|
||||
}
|
||||
49
hive-forge/src/verbs/subscription.rs
Normal file
49
hive-forge/src/verbs/subscription.rs
Normal 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"),
|
||||
}))
|
||||
}
|
||||
40
hive-forge/src/verbs/tree_sha.rs
Normal file
40
hive-forge/src/verbs/tree_sha.rs
Normal 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(())
|
||||
}
|
||||
62
hive-forge/src/verbs/view.rs
Normal file
62
hive-forge/src/verbs/view.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -1,614 +1,36 @@
|
|||
{ pkgs, lib }:
|
||||
# Single `hive-forge <verb>` 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 <verb> --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 <verb> [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"
|
||||
# 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";
|
||||
};
|
||||
}
|
||||
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 <number> [repo]
|
||||
# Dump title + body + all comments for an issue or PR.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge view <number> [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 <number> [repo]
|
||||
# Print key fields of an issue as JSON.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge issue <number> [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 <number> [repo]
|
||||
# Print key fields of a PR as JSON.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge pr <number> [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 <id> [--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 <id> [--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 <number> [--body <text> | -f <file> | -] [repo]
|
||||
# Post a comment on an issue or PR.
|
||||
# Body sources, in priority order:
|
||||
# --body <text> | -f <file> | - (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 <<EOF ...
|
||||
# form posts the HEREDOC and treats `owner/repo` as a repo
|
||||
# override — instead of silently dropping the HEREDOC on the
|
||||
# floor (closes #379).
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge comment <number> [--body <text> | -f <file> | -] [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 <text>, -f <file>, - (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 <id> [--body <text> | -f <file> | -] [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 <id> [--body <text> | -f <file> | -] [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 <text>, -f <file>, - (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 <number> <user> [--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 <number> <user> [--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 <number>
|
||||
# Close an issue or PR.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge close <number>" >&2; exit 1; fi
|
||||
forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \
|
||||
| jq '{number,state}'
|
||||
}
|
||||
|
||||
cmd_labels() {
|
||||
# labels <number> [list|add|remove] [labels...]
|
||||
# Manage labels on an issue or PR. Default action is list.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge labels <number> [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 <number> [repo]
|
||||
# List reviews on a PR with id/state/user/body.
|
||||
if [ $# -lt 1 ]; then echo "usage: hive-forge pr-reviews <number> [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 <ref> [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 <ref> [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 <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
|
||||
'';
|
||||
}
|
||||
''
|
||||
mkdir -p $out/bin
|
||||
ln -s ${pkgs.hyperhive}/bin/hive-forge $out/bin/hive-forge
|
||||
''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue