Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-forge/src/main.rs
atlas 529e9416c1 hive-forge: say which flat forms were removed and which are staying
`docs/tools/forge.md` said the flat forms "still work as hidden
back-compat aliases" and named `pr-create` and `pr-status` as its
examples. Both refuse now: nine flat verbs parse only far enough to
print the namespaced form that replaced them.

The doc had no way to be read correctly, because the seven that do still
work are hidden too and `--help` shows neither group. Naming the split
here is the only place the difference is visible.

At the enum the kind-agnostic seven now read as staying rather than as
"still work", and `credential-helper` is marked as belonging to neither
group: it is hidden because git invokes it, so a sweep reading
`hide = true` as "deprecated" would remove checkout auth.

Refs #3974.
2026-09-03 02:16:55 +02:00

359 lines
17 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 a prior bash
//! script 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, bail};
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 hidden verbs below are back-compat aliases of the `pr <verb>` /
// `issue <verb>` forms, and they are at two different stages:
//
// - **Pure renames** (`pr-status`, `pr-create`, `pr-merge`,
// `pr-reviews`, `pr-commits`, `pr-assign-reviewer`, `issue-create`,
// `issue-edit`, `diff`) now *refuse* and name their replacement —
// see `renamed`. They are removed outright in a later release.
// The test is PR-only-ness: each has an exact scoped equivalent and
// no `issue <verb>` form, so a caller holding just a number can be
// sent somewhere unambiguous.
// - **Kind-agnostic** (`view`, `comment`, `comments`, `close`,
// `assign`, `labels`, `timeline`) **stay** — hidden, not deprecated.
// Each exists as both `issue <verb>` and `pr <verb>`, so there is no
// single replacement to send a caller to when all they hold is a
// number: refusing these would remove a capability rather than
// rename one.
//
// `credential-helper` is hidden for a third reason and is in neither
// group — git invokes it, no human does. A sweep that reads
// `hide = true` as "deprecated" would take out checkout auth.
/// 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|dependency|reaction|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|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|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),
/// List CI Actions runs, newest first (`--workflow`, `--branch`,
/// `--limit`, `--page`) — the run numbers `ci-log`/`ci-rerun --run`/
/// `artifact-get --run` take.
CiRuns(verbs::ci_runs::Args),
/// Git credential-helper protocol (`get|store|erase`) — not a verb
/// you run by hand. `clone` configures each checkout's
/// `credential.helper` to invoke this, so git asks it for a token
/// fresh on every fetch/push instead of one being embedded in the
/// remote URL.
#[command(hide = true)]
CredentialHelper(verbs::credential_helper::Args),
/// Emit the full CLI reference as Markdown, walking hive-forge's own
/// clap command tree.
///
/// Hidden tooling command used by the docs build to keep the published
/// `hive-forge` reference in lockstep with the code.
#[command(hide = true)]
MarkdownDocs,
}
/// 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 {
restore_sigpipe();
if let Err(e) = run() {
eprintln!("hive-forge: FAILED: {e:#}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
/// Give `SIGPIPE` back its default disposition before any output happens.
///
/// Rust's runtime sets `SIGPIPE` to `SIG_IGN` at startup, so writing to a
/// pipe whose reader has gone away returns `EPIPE` and `println!` panics.
/// `hive-forge <verb> | head` therefore dies with a panic message and exit
/// **101** where every other CLI in a pipeline exits quietly — and 101 is
/// a real failure to a caller running `set -o pipefail`, so a shell script
/// that pipes our output stops on a condition that is not an error.
///
/// Restoring `SIG_DFL` makes the process die by signal instead, which is
/// what the surrounding shell already knows how to interpret.
fn restore_sigpipe() {
// SAFETY: called first thing in `main`, before any thread exists and
// before any output — so no concurrent handler mutation is possible,
// and no write can race the change.
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
let verb = cli.verb;
// `credential-helper` bypasses the normal client construction below:
// it needs no repo (unlike `Client::from_env`, which errors without
// one), and git may invoke it from a directory that isn't a resolved
// checkout yet (mid-`clone`, before the destination exists).
if let Verb::CredentialHelper(args) = verb {
return verbs::credential_helper::run(args, cli.forge.as_deref());
}
// Same reasoning as `credential-helper` above: no repo, no forge
// credentials — just a pure render of the clap tree the docs build
// pipes into `docs/tools/forge-cli.md`.
if let Verb::MarkdownDocs = verb {
print!("{}", clap_markdown::help_markdown::<Cli>());
return Ok(());
}
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.
//
// `client.repo()` is fallible now (a repo-independent verb like
// `repo-search` may have none at all) — this wrapper must not
// become the thing that revives the eager-bail bug one frame down,
// so an unresolved repo just means no context is attached, not a
// failure. A verb that actually needs a repo still fails, from
// wherever it calls `client.repo()`/`client.owner_repo()` itself.
match client.repo().ok().map(str::to_owned) {
Some(repo) => dispatch(&client, verb).with_context(|| format!("repo {repo}")),
None => dispatch(&client, verb),
}
}
/// Refuse a flat alias that has an exact scoped replacement, naming it.
///
/// Deprecation step for the aliases that are **pure renames**: `pr status`
/// does precisely what `pr-status` did, so the caller can be sent somewhere
/// specific. They still parse, only to say this — the variants come out
/// entirely in a later release.
///
/// The kind-agnostic aliases (`view`, `comments`, `close`, …) are
/// deliberately still working: those exist as *both* `issue <verb>` and
/// `pr <verb>`, so a caller holding only a number cannot be told which one
/// to run, and an error naming neither would be worse than the alias.
fn renamed(old: &str, new: &str) -> Result<()> {
bail!(
"`{old}` is now `{new}` — run `hive-forge {new}` instead. \
The old name still parses only to print this, and is removed in a \
later release."
)
}
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(_) => renamed("issue-create", "issue create"),
Verb::IssueEdit(_) => renamed("issue-edit", "issue edit"),
Verb::Pr(a) => verbs::pr_cmd::run(client, a),
Verb::PrCommits(_) => renamed("pr-commits", "pr commits"),
Verb::PrCreate(_) => renamed("pr-create", "pr create"),
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(_) => renamed("pr-status", "pr status"),
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(_) => renamed("pr-merge", "pr merge"),
Verb::PrReviews(_) => renamed("pr-reviews", "pr reviews"),
Verb::PrAssignReviewer(_) => renamed("pr-assign-reviewer", "pr assign-reviewer"),
Verb::Branches(a) => verbs::branches::run(client, a),
Verb::TreeSha(a) => verbs::tree_sha::run(client, a),
Verb::Diff(_) => renamed("diff", "pr diff"),
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),
Verb::CiRuns(a) => verbs::ci_runs::run(client, a),
Verb::CredentialHelper(_) | Verb::MarkdownDocs => {
unreachable!("handled in `run` before client construction")
}
}
}