Wraps the whole verb dispatch in run() with .with_context(|| format!("repo {repo}"))
instead of threading context through ~34 individual verb files. A body-decode
failure surfaces from forgejo-api as a bare ReqwestError with no status code or
URL retained (no client-injection point to capture more), so without this the
error alone can't distinguish a mistyped org/repo from a transient flake — see
the recent hive-forge triage-automation thread this was filed from.
Split run()'s match into a dispatch() fn so the repo can be captured once before
dispatching and the with_context wrap applied once after, uniformly, regardless
of which verb failed.
257 lines
12 KiB
Rust
257 lines
12 KiB
Rust
//! `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`
|
|
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
|
|
//!
|
|
//! The active repo resolves `-r/--repo` > the `origin` remote of the
|
|
//! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset
|
|
//! by default) > a hard error — see `client::Client::from_env`.
|
|
//!
|
|
//! The global `-f/--forge <label>` flag targets a dashboard-provisioned
|
|
//! external forge account instead: it reads `forge-<label>-token` +
|
|
//! `forge-<label>.json` (base URL) from the state dir rather than
|
|
//! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`.
|
|
//!
|
|
//! 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.
|
|
|
|
#![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 notify;
|
|
mod verbs;
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::{Parser, Subcommand};
|
|
use std::process::ExitCode;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "hive-forge",
|
|
about = "Forgejo CLI wrapper for hyperhive",
|
|
disable_help_subcommand = true
|
|
)]
|
|
struct Cli {
|
|
/// Repo to act on, as `owner/name` (default: inferred from the
|
|
/// cwd's git `origin` remote, then `HIVE_FORGE_REPO`). Works with
|
|
/// any verb.
|
|
#[arg(short = 'r', long, global = true)]
|
|
repo: Option<String>,
|
|
/// Act as a dashboard-provisioned external forge account (by its
|
|
/// FORGES-tab label) instead of the internal forge. Independent of
|
|
/// `-r/--repo`.
|
|
#[arg(short = 'f', long, global = true)]
|
|
forge: Option<String>,
|
|
/// Emit JSON instead of the default human-readable output (for verbs
|
|
/// that support both).
|
|
#[arg(long, global = true)]
|
|
json: bool,
|
|
#[command(subcommand)]
|
|
verb: Verb,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Verb {
|
|
// The kind verbs below are hidden back-compat aliases of the new
|
|
// `pr <verb>` / `issue <verb>` forms (`pr-close` -> `pr close`, bare
|
|
// `close` -> `pr close` / `issue close`, etc.). They still parse but are
|
|
// dropped from `--help`; a later change removes them once usage migrates.
|
|
/// Dump title + body + all comments for an issue or PR.
|
|
#[command(hide = true)]
|
|
View(verbs::view::Args),
|
|
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|timeline> …`.
|
|
Issue(verbs::issue_cmd::Args),
|
|
/// Create an issue. Prints the issue URL on success.
|
|
#[command(hide = true)]
|
|
IssueCreate(verbs::issue_create::Args),
|
|
/// Edit an issue's title, body, state, or milestone.
|
|
#[command(hide = true)]
|
|
IssueEdit(verbs::issue_edit::Args),
|
|
/// PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|comment|comments|close|reopen|labels|assign-committer|timeline> …`.
|
|
Pr(verbs::pr_cmd::Args),
|
|
/// List a PR's commits as JSON (sha, message, author date, author).
|
|
#[command(hide = true)]
|
|
PrCommits(verbs::pr_commits::Args),
|
|
/// Create a pull request. Prints the PR URL on success.
|
|
#[command(hide = true)]
|
|
PrCreate(verbs::pr_create::Args),
|
|
/// Post a comment on an issue or PR.
|
|
#[command(hide = true)]
|
|
Comment(verbs::comment::Args),
|
|
/// List all comments on an issue or PR.
|
|
#[command(hide = true)]
|
|
Comments(verbs::comments::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.
|
|
#[command(hide = true)]
|
|
Assign(verbs::assign::Args),
|
|
/// Close an issue or PR.
|
|
#[command(hide = true)]
|
|
Close(verbs::close::Args),
|
|
/// List, add, or remove labels on an issue or PR.
|
|
#[command(hide = true)]
|
|
Labels(verbs::labels::Args),
|
|
/// PR health view: mergeable state, CI checks, requested reviewers +
|
|
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
|
|
/// CI-only fast path. Exit code is a merge-readiness verdict.
|
|
#[command(hide = true)]
|
|
PrStatus(verbs::pr_status::Args),
|
|
/// Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with
|
|
/// credentials auto-injected. Pairs with `pr-create --agit`.
|
|
Clone(verbs::clone::Args),
|
|
/// Create a forge repo under the current user (or `--org`). Prints
|
|
/// the repo URL.
|
|
RepoCreate(verbs::repo_create::Args),
|
|
/// Add a collaborator to the active repo (`-r`/`HIVE_FORGE_REPO`)
|
|
/// with a permission level. Companion to `repo-create`.
|
|
RepoAddCollaborator(verbs::repo_add_collaborator::Args),
|
|
/// List every label defined on the repo (name + description),
|
|
/// optionally filtered by a name substring.
|
|
RepoLabels(verbs::repo_labels::Args),
|
|
/// Search the forge for repositories by keyword, topic, or description.
|
|
RepoSearch(verbs::repo_search::Args),
|
|
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments / unlabeled).
|
|
Lint(verbs::lint::Args),
|
|
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
|
/// `--author`, `--label`, `--limit`). Pretty rows by default; pass
|
|
/// `--json` for raw JSON.
|
|
// Discovery aliases: the verb is `list`, but `issues` / `issue-list`
|
|
// are the names people reach for first (and clap's "did you mean"
|
|
// tip points at `issue` / `issue-create`, not here). Visible so they
|
|
// show in `--help`.
|
|
#[command(visible_alias = "issues", visible_alias = "issue-list")]
|
|
List(verbs::list::Args),
|
|
/// Manage milestones (list / create / close).
|
|
Milestone(verbs::milestone::Args),
|
|
/// Merge a PR (`--method merge|rebase`, default merge). Refuses unless
|
|
/// mergeable + CI not red + no changes requested (`--force` overrides).
|
|
/// Deletes the head branch unless `--keep-branch`. No squash option.
|
|
#[command(hide = true)]
|
|
PrMerge(verbs::pr_merge::Args),
|
|
/// List a PR's reviews, or submit one: `--approve` /
|
|
/// `--request-changes` / `--comment` (with `-m` for the body).
|
|
#[command(hide = true)]
|
|
PrReviews(verbs::pr_reviews::Args),
|
|
/// Request (or withdraw with `--remove`) a review from a user on a PR.
|
|
#[command(hide = true)]
|
|
PrAssignReviewer(verbs::pr_assign_reviewer::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.
|
|
#[command(hide = true)]
|
|
Diff(verbs::diff::Args),
|
|
/// Get/set this user's watch subscription on a repo, or --list all watched repos.
|
|
Subscription(verbs::subscription::Args),
|
|
/// List timeline events on an issue or PR (closes, label adds,
|
|
/// assignments, commit refs, pushes, etc.) — the audit trail
|
|
/// `view` + `comments` don't surface.
|
|
#[command(hide = true)]
|
|
Timeline(verbs::timeline::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),
|
|
/// Download an attachment by UUID or URL. Saves to a temp file and
|
|
/// prints the path (pass `-o -` to stream raw bytes to stdout).
|
|
AttachmentGet(verbs::attachment_get::Args),
|
|
/// Download a CI Actions artifact from a run (`<name> --run <n>`).
|
|
/// Saves a zip, or pass `-o -` to stream to stdout.
|
|
ArtifactGet(verbs::artifact_get::Args),
|
|
/// Print a CI Actions run's job step logs
|
|
/// (`--run <n> [--job i] [--step i]`).
|
|
CiLog(verbs::ci_log::Args),
|
|
/// Re-run CI without an empty commit. Pass one of `--pr <n>`,
|
|
/// `--run <n>`, or `--branch <name>`; `--workflow` defaults to `ci.yml`.
|
|
CiRerun(verbs::ci_rerun::Args),
|
|
}
|
|
|
|
/// Wrapper over [`run`] that owns how a failure reaches the operator.
|
|
///
|
|
/// `fn main() -> Result<()>` would format the error with anyhow's `Debug`
|
|
/// impl, which prints a bare `Error:` header. `hive-forge` is almost always
|
|
/// invoked from an agent's bash task, where stdout is what gets surfaced
|
|
/// first and stderr is easy to miss — so failures are prefixed with the
|
|
/// binary name to be unmistakably ours, and rendered with `{:#}`
|
|
/// (alternate `Display`), which keeps the full `context` chain inline
|
|
/// rather than dropping it the way plain `Display` would.
|
|
fn main() -> ExitCode {
|
|
if let Err(e) = run() {
|
|
eprintln!("hive-forge: FAILED: {e:#}");
|
|
return ExitCode::FAILURE;
|
|
}
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn run() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
|
|
.context("initialize forge client")?;
|
|
// Attach the resolved repo to every verb's error uniformly here,
|
|
// rather than threading `.with_context` through 30-odd verb files
|
|
// individually. Most verb failures bottom out in `forgejo-api`'s
|
|
// `ForgejoError`, which for a body-decode failure (an empty/wrong
|
|
// body on a write) carries no status code or URL — see
|
|
// `client::check_status`'s doc comment for the raw-route case this
|
|
// can't reach. Without the repo in the message, a failure on a
|
|
// mistyped `owner/name` (nonexistent org/repo) is indistinguishable
|
|
// from a transient flake; this turns "EOF while parsing a value at
|
|
// line 1 column 0" into "repo typo-org/repo: EOF while parsing a
|
|
// value at line 1 column 0", which is diagnosable on sight.
|
|
let repo = client.repo().to_owned();
|
|
dispatch(&client, cli.verb).with_context(|| format!("repo {repo}"))
|
|
}
|
|
|
|
fn dispatch(client: &client::Client, verb: Verb) -> Result<()> {
|
|
match verb {
|
|
Verb::View(a) => verbs::view::run(client, a),
|
|
Verb::Issue(a) => verbs::issue_cmd::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_cmd::run(client, a),
|
|
Verb::PrCommits(a) => verbs::pr_commits::run(client, a),
|
|
Verb::PrCreate(a) => verbs::pr_create::run(client, a),
|
|
Verb::Comment(a) => verbs::comment::run(client, a),
|
|
Verb::Comments(a) => verbs::comments::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::PrStatus(a) => verbs::pr_status::run(client, a),
|
|
Verb::Clone(a) => verbs::clone::run(client, a),
|
|
Verb::RepoCreate(a) => verbs::repo_create::run(client, a),
|
|
Verb::RepoAddCollaborator(a) => verbs::repo_add_collaborator::run(client, a),
|
|
Verb::RepoLabels(a) => verbs::repo_labels::run(client, a),
|
|
Verb::RepoSearch(a) => verbs::repo_search::run(client, a),
|
|
Verb::Lint(a) => verbs::lint::run(client, a),
|
|
Verb::List(a) => verbs::list::run(client, a),
|
|
Verb::Milestone(a) => verbs::milestone::run(client, a),
|
|
Verb::PrMerge(a) => verbs::pr_merge::run(client, a),
|
|
Verb::PrReviews(a) => verbs::pr_reviews::run(client, a),
|
|
Verb::PrAssignReviewer(a) => verbs::pr_assign_reviewer::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::Timeline(a) => verbs::timeline::run(client, a),
|
|
Verb::AttachIssue(a) => verbs::attach::run_issue(client, a),
|
|
Verb::AttachComment(a) => verbs::attach::run_comment(client, a),
|
|
Verb::AttachmentGet(a) => verbs::attachment_get::run(client, a),
|
|
Verb::ArtifactGet(a) => verbs::artifact_get::run(client, a),
|
|
Verb::CiLog(a) => verbs::ci_log::run(client, a),
|
|
Verb::CiRerun(a) => verbs::ci_rerun::run(client, a),
|
|
}
|
|
}
|