Compare commits
31 changed files with 621 additions and 1414 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -149,18 +149,6 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
|
|||
manager.md — manager system prompt
|
||||
claude-settings.json — --settings JSON
|
||||
|
||||
hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||
src/main.rs clap dispatch over the verbs/
|
||||
src/client.rs blocking reqwest client (Forgejo REST API)
|
||||
src/body.rs body input resolution (--body / --body-file / piped stdin)
|
||||
src/verbs/<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,17 +601,6 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hive-forge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hive-sh4re"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1213,9 +1202,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
@ -1224,7 +1211,6 @@ dependencies = [
|
|||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-sh4re"]
|
||||
members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
|
|
|||
|
|
@ -118,24 +118,17 @@ files in subdirectories) fails with `EPERM`. Fix: pass
|
|||
## `hive-forge`: prefer over raw curl pipelines
|
||||
|
||||
Every agent container has `hive-forge` in PATH (installed via
|
||||
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
|
||||
since #280). Use it instead of ad-hoc curl pipelines:
|
||||
`harness-base.nix`). Use it instead of ad-hoc curl pipelines:
|
||||
|
||||
```bash
|
||||
hive-forge view 42 # title + body + comments
|
||||
hive-forge comment 42 --body "..." # post comment (inline body)
|
||||
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
||||
multi-line body
|
||||
EOF
|
||||
hive-forge view 42 # title + body + comments
|
||||
hive-forge comment 42 "..." # post comment
|
||||
hive-forge assign 42 damocles
|
||||
hive-forge close 42
|
||||
hive-forge labels 42 add feature
|
||||
hive-forge pr 42 # PR metadata as JSON
|
||||
hive-forge branches deployed/ # filter branches by pattern
|
||||
hive-forge -r other-org/other-repo pr 7 # target a different repo
|
||||
hive-forge pr 42 # PR metadata as JSON
|
||||
hive-forge branches deployed/ # filter branches by pattern
|
||||
```
|
||||
|
||||
`hive-forge <verb> --help` prints the full signature for any verb.
|
||||
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
|
||||
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
|
||||
global `-r/--repo` flag.
|
||||
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`;
|
||||
default repo from `$HIVE_FORGE_REPO`.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
|
|||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
|
||||
|
||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..."] [--draft] [repo]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..."] [--assignee <user>] [repo]`. To attach a file to an issue or comment use `hive-forge attach-issue <number> <file> [repo]` or `hive-forge attach-comment <comment-id> <file> [repo]` — both print the `browser_download_url` of the uploaded attachment. Key ops: `hive-forge diff <pr> [repo]` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch] [repo]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ Keep messages short — a few sentences each. For anything big (digests, agent r
|
|||
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
|
||||
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
|
||||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Default repo from `HIVE_FORGE_REPO`; `-r <repo>` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment <n> --body-file - <<EOF ... EOF`. `diff <pr>` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon.
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `assign`, `close`, `labels`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..."] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..."] [--assignee <user>]` to file an issue; `diff <pr>` prints unified diff; `subscription [--watch|--ignore|--unwatch] [repo]` manages watch state. Forge notifications arrive via the internal message daemon.
|
||||
|
||||
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
[package]
|
||||
name = "hive-forge"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "hive-forge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls", "blocking", "multipart"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
//! Body-input resolution shared by every verb that posts a body.
|
||||
//! Matches the bash `resolve_body` helper (#382): exactly one source
|
||||
//! between `--body`, `--body-file`, and piped stdin. Passing both
|
||||
//! `--body` and `--body-file` is a clear error.
|
||||
|
||||
use std::io::{IsTerminal, Read};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// Resolve the body to send, given the user's explicit flags and the
|
||||
/// current stdin state. Returns `None` when none of the sources are
|
||||
/// available (the caller decides whether that's allowed — e.g.
|
||||
/// `issue-edit` treats absent body as "leave unchanged", while
|
||||
/// `comment` treats absent body as a hard error).
|
||||
pub fn resolve(body: Option<&str>, file: Option<&str>) -> Result<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)
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
//! Blocking HTTP client for the Forgejo REST API. Identity is the
|
||||
//! per-agent token under `${HYPERHIVE_STATE_DIR}/forge-token`. All
|
||||
//! verbs go through this client so error surfaces, header set, and
|
||||
//! 4xx/5xx body unwrapping stay consistent.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use reqwest::blocking::{Client as HttpClient, Response};
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
|
||||
const DEFAULT_URL: &str = "http://localhost:3000";
|
||||
|
||||
/// Default repo when `HIVE_FORGE_REPO` is unset and the verb doesn't
|
||||
/// take a repo override.
|
||||
const DEFAULT_REPO: &str = "hyperhive/hyperhive";
|
||||
|
||||
/// Blocking Forgejo API client. Cheap to clone (wraps an
|
||||
/// `Arc<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.
|
||||
/// `repo_override` (from the global `-r/--repo` flag) takes
|
||||
/// priority over `HIVE_FORGE_REPO`; the env var is the fallback
|
||||
/// default.
|
||||
pub fn from_env(repo_override: Option<String>) -> Result<Self> {
|
||||
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
|
||||
let default_repo = repo_override
|
||||
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
|
||||
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
|
||||
let token = read_token().context("read forge-token")?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
let auth = format!("token {token}");
|
||||
let mut auth_val = HeaderValue::from_str(&auth).context("auth header")?;
|
||||
auth_val.set_sensitive(true);
|
||||
headers.insert(AUTHORIZATION, auth_val);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
|
||||
let http = HttpClient::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
Ok(Self {
|
||||
http,
|
||||
base,
|
||||
default_repo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the API base path (`<base>/api/v1`).
|
||||
fn api(&self) -> String {
|
||||
format!("{}/api/v1", self.base)
|
||||
}
|
||||
|
||||
/// Return the active repo. `from_env` already folded the
|
||||
/// `-r/--repo` override into `default_repo`, so verbs just read
|
||||
/// it as-is — no per-verb override plumbing.
|
||||
#[must_use]
|
||||
pub fn repo(&self) -> &str {
|
||||
&self.default_repo
|
||||
}
|
||||
|
||||
/// GET `<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}"))
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
//! `hive-forge` — typed CLI wrapper around the in-cluster Forgejo's
|
||||
//! REST API. Reads credentials from the environment:
|
||||
//!
|
||||
//! `HIVE_FORGE_URL` — base URL, e.g. `http://localhost:3000`
|
||||
//! `HIVE_FORGE_REPO` — default repo, e.g. `hyperhive/hyperhive`
|
||||
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
|
||||
//!
|
||||
//! Single binary with verb subcommands. Replaces the prior bash
|
||||
//! script (`hive-forge-tools.nix`) so that agents and operators get
|
||||
//! the same error handling, exit codes, and JSON shapes regardless
|
||||
//! of how the bash mood was that day (closes #280).
|
||||
|
||||
#![warn(missing_docs)]
|
||||
// Clap-derived `Args` structs are intentionally consumed by their
|
||||
// per-verb `run` handler so we can move owned String fields out
|
||||
// without cloning. The pedantic lint flags every one of them.
|
||||
#![allow(clippy::needless_pass_by_value)]
|
||||
|
||||
mod body;
|
||||
mod client;
|
||||
mod verbs;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "hive-forge",
|
||||
about = "Forgejo CLI wrapper for hyperhive (closes #280)",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
struct Cli {
|
||||
/// Repo override (default from `HIVE_FORGE_REPO`).
|
||||
/// Applies to any verb; replaces the per-verb `[repo]` trailing
|
||||
/// positional the bash helper used.
|
||||
#[arg(short = 'r', long, global = true)]
|
||||
repo: Option<String>,
|
||||
#[command(subcommand)]
|
||||
verb: Verb,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Verb {
|
||||
/// Dump title + body + all comments for an issue or PR.
|
||||
View(verbs::view::Args),
|
||||
/// Print key fields of an issue as JSON.
|
||||
Issue(verbs::issue::Args),
|
||||
/// Create an issue. Prints the issue URL on success.
|
||||
IssueCreate(verbs::issue_create::Args),
|
||||
/// Edit an issue's title, body, state, or milestone.
|
||||
IssueEdit(verbs::issue_edit::Args),
|
||||
/// Print key fields of a PR as JSON.
|
||||
Pr(verbs::pr::Args),
|
||||
/// Create a pull request. Prints the PR URL on success.
|
||||
PrCreate(verbs::pr_create::Args),
|
||||
/// Post a comment on an issue or PR.
|
||||
Comment(verbs::comment::Args),
|
||||
/// Print the body (or full JSON) of a single comment by id.
|
||||
CommentShow(verbs::comment_show::Args),
|
||||
/// Edit an existing comment by id.
|
||||
CommentEdit(verbs::comment_edit::Args),
|
||||
/// Assign or unassign a user on an issue or PR.
|
||||
Assign(verbs::assign::Args),
|
||||
/// Close an issue or PR.
|
||||
Close(verbs::close::Args),
|
||||
/// List, add, or remove labels on an issue or PR.
|
||||
Labels(verbs::labels::Args),
|
||||
/// Manage milestones (list / create / close).
|
||||
Milestone(verbs::milestone::Args),
|
||||
/// List reviews on a PR.
|
||||
PrReviews(verbs::pr_reviews::Args),
|
||||
/// List branches, optionally filtered.
|
||||
Branches(verbs::branches::Args),
|
||||
/// Print the tree SHA at a branch or commit.
|
||||
TreeSha(verbs::tree_sha::Args),
|
||||
/// Print the unified diff for a PR.
|
||||
Diff(verbs::diff::Args),
|
||||
/// Get or set this user's watch subscription on a repo.
|
||||
Subscription(verbs::subscription::Args),
|
||||
/// Upload a file as an attachment to an issue.
|
||||
AttachIssue(verbs::attach::IssueArgs),
|
||||
/// Upload a file as an attachment to a comment.
|
||||
AttachComment(verbs::attach::CommentArgs),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let client = client::Client::from_env(cli.repo).context("initialize forge client")?;
|
||||
match cli.verb {
|
||||
Verb::View(a) => verbs::view::run(&client, a),
|
||||
Verb::Issue(a) => verbs::issue::run(&client, a),
|
||||
Verb::IssueCreate(a) => verbs::issue_create::run(&client, a),
|
||||
Verb::IssueEdit(a) => verbs::issue_edit::run(&client, a),
|
||||
Verb::Pr(a) => verbs::pr::run(&client, a),
|
||||
Verb::PrCreate(a) => verbs::pr_create::run(&client, a),
|
||||
Verb::Comment(a) => verbs::comment::run(&client, a),
|
||||
Verb::CommentShow(a) => verbs::comment_show::run(&client, a),
|
||||
Verb::CommentEdit(a) => verbs::comment_edit::run(&client, a),
|
||||
Verb::Assign(a) => verbs::assign::run(&client, a),
|
||||
Verb::Close(a) => verbs::close::run(&client, a),
|
||||
Verb::Labels(a) => verbs::labels::run(&client, a),
|
||||
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
||||
Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a),
|
||||
Verb::Branches(a) => verbs::branches::run(&client, a),
|
||||
Verb::TreeSha(a) => verbs::tree_sha::run(&client, a),
|
||||
Verb::Diff(a) => verbs::diff::run(&client, a),
|
||||
Verb::Subscription(a) => verbs::subscription::run(&client, a),
|
||||
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
|
||||
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
//! `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();
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
//! `attach-issue <number> <file>` and `attach-comment <comment-id>
|
||||
//! <file>` — upload a file as an attachment. Prints the browser
|
||||
//! download URL.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct IssueArgs {
|
||||
/// Issue number.
|
||||
number: u64,
|
||||
/// File path to upload.
|
||||
file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct CommentArgs {
|
||||
/// Comment id.
|
||||
id: u64,
|
||||
/// File path to upload.
|
||||
file: PathBuf,
|
||||
}
|
||||
|
||||
pub fn run_issue(client: &Client, args: IssueArgs) -> Result<()> {
|
||||
if !args.file.is_file() {
|
||||
bail!(
|
||||
"hive-forge attach-issue: file not found: {}",
|
||||
args.file.display()
|
||||
);
|
||||
}
|
||||
let repo = client.repo();
|
||||
let resp = client.post_multipart_file(
|
||||
&format!("/repos/{repo}/issues/{}/assets", args.number),
|
||||
&args.file,
|
||||
)?;
|
||||
print_url(&resp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_comment(client: &Client, args: CommentArgs) -> Result<()> {
|
||||
if !args.file.is_file() {
|
||||
bail!(
|
||||
"hive-forge attach-comment: file not found: {}",
|
||||
args.file.display()
|
||||
);
|
||||
}
|
||||
let repo = client.repo();
|
||||
let resp = client.post_multipart_file(
|
||||
&format!("/repos/{repo}/issues/comments/{}/assets", args.id),
|
||||
&args.file,
|
||||
)?;
|
||||
print_url(&resp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_url(v: &Value) {
|
||||
if let Some(url) = v.get("browser_download_url").and_then(Value::as_str) {
|
||||
println!("{url}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
//! `branches [pattern] [repo]` — list branches, optionally filtered.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Substring pattern to filter branch names.
|
||||
pattern: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!("/repos/{repo}/branches?limit=100"))?;
|
||||
let names: Vec<&str> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|b| b.get("name").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for n in names {
|
||||
if args.pattern.as_deref().is_none_or(|p| n.contains(p)) {
|
||||
println!("{n}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
//! `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,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/{}", args.number),
|
||||
&json!({ "state": "closed" }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"number": resp.get("number"),
|
||||
"state": resp.get("state"),
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
//! `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>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let body = body::resolve_required(args.body.as_deref(), args.body_file.as_deref(), "comment")?;
|
||||
let repo = client.repo();
|
||||
let resp = client.post_json(
|
||||
&format!("/repos/{repo}/issues/{}/comments", args.number),
|
||||
&json!({ "body": body }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"url": resp.get("html_url"),
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
//! `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>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let body = body::resolve_required(
|
||||
args.body.as_deref(),
|
||||
args.body_file.as_deref(),
|
||||
"comment-edit",
|
||||
)?;
|
||||
let repo = client.repo();
|
||||
let resp = client.patch_json(
|
||||
&format!("/repos/{repo}/issues/comments/{}", args.id),
|
||||
&json!({ "body": body }),
|
||||
)?;
|
||||
print_json(&json!({
|
||||
"id": resp.get("id"),
|
||||
"user": resp.get("user").and_then(|u| u.get("login")),
|
||||
"url": resp.get("html_url"),
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
//! `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,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!("/repos/{repo}/issues/comments/{}", args.id))?;
|
||||
if args.json {
|
||||
let trimmed = json!({
|
||||
"id": v.get("id"),
|
||||
"user": v.get("user").and_then(|u| u.get("login")),
|
||||
"created_at": v.get("created_at"),
|
||||
"updated_at": v.get("updated_at"),
|
||||
"body": v.get("body"),
|
||||
"url": v.get("html_url"),
|
||||
});
|
||||
print_json(&trimmed)
|
||||
} else {
|
||||
let body = v.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
println!("{body}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//! `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,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
|
||||
print!("{diff}");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
//! `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,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!("/repos/{repo}/issues/{}", args.number))?;
|
||||
let trimmed = json!({
|
||||
"number": v.get("number"),
|
||||
"title": v.get("title"),
|
||||
"state": v.get("state"),
|
||||
"user": v.get("user").and_then(|u| u.get("login")),
|
||||
"assignees": v.get("assignees")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("login")).cloned().collect::<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)
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
//! `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>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
||||
let repo = client.repo();
|
||||
let mut payload = json!({ "title": args.title, "body": body });
|
||||
if let Some(a) = args.assignee {
|
||||
payload["assignees"] = json!([a]);
|
||||
}
|
||||
let resp = client.post_json(&format!("/repos/{repo}/issues"), &payload)?;
|
||||
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
||||
println!("{url}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue