From dc4c5460d5676919959e2a703b040689e67a04a8 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 22 Jun 2026 14:15:38 +0200 Subject: [PATCH] feat(#1877): restructure hive-forge into pr/issue sub-verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group issue/PR operations under `pr` and `issue` parent commands (`hive-forge pr close 42`, `issue create …`, `pr status --pr 42`) per the operator decision — kind-namespaced verbs replace the flat surface. - new `verbs::pr_cmd` / `verbs::issue_cmd` parent commands wrap the existing per-verb modules (reuse their Args + run fns) under `#[command(subcommand)]`. - kind-validation (the win over the old generic verbs): the generics that work on both (view/comment/comments/close/labels/assign/timeline) call `assert_kind` first, so `pr close ` / `issue close ` are rejected with a 'use the other command' message. PR-only / issue-only verbs are kind-correct by construction. `number` exposed `pub(crate)` on the shared verbs so the wrappers can probe it. - every flat kind verb (`close`, `pr-create`, `pr-status`, `issue-edit`, …) kept as a `#[command(hide = true)]` back-compat alias — still parses, dropped from --help; removed in a later sweep once usage migrates. (`pr`/`issue` bare-show become `pr show` / `issue show` — the names are now parents.) - docs/tools/forge.md documents the new surface + the deprecated aliases. cargo build/clippy/fmt clean, 54 tests pass; --help surface + alias parsing smoke-tested. --- docs/tools/forge.md | 18 ++++++ hive-forge/src/main.rs | 31 +++++++++-- hive-forge/src/verbs/assign.rs | 2 +- hive-forge/src/verbs/close.rs | 2 +- hive-forge/src/verbs/comment.rs | 2 +- hive-forge/src/verbs/comments.rs | 2 +- hive-forge/src/verbs/issue_cmd.rs | 81 +++++++++++++++++++++++++++ hive-forge/src/verbs/labels.rs | 2 +- hive-forge/src/verbs/mod.rs | 33 +++++++++++ hive-forge/src/verbs/pr_cmd.rs | 93 +++++++++++++++++++++++++++++++ hive-forge/src/verbs/timeline.rs | 2 +- hive-forge/src/verbs/view.rs | 2 +- 12 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 hive-forge/src/verbs/issue_cmd.rs create mode 100644 hive-forge/src/verbs/pr_cmd.rs diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 53b5cb51..42e50906 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -12,7 +12,25 @@ as a proper Rust binary). Use it instead of ad-hoc curl pipelines. ## Verbs +**Kind-namespaced commands (preferred):** issue/PR operations are grouped +under `issue` and `pr` parent commands — `hive-forge pr close 42`, +`hive-forge issue create --title …`, `hive-forge pr status --pr 42`. The +`pr ` / `issue ` forms validate the number's kind (e.g. `pr close` +refuses an issue number, which the old generic `close` couldn't). Run +`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand +list (show/create/edit/status/merge/reviews/commits/diff/view/comment/ +comments/close/labels/assign/timeline as applicable). + +The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work +as **hidden back-compat aliases** during the transition and are dropped from +`--help`; prefer the namespaced form. They'll be removed in a later sweep. + ```bash +hive-forge pr close 42 # close a PR (kind-validated) +hive-forge issue close 42 # close an issue (kind-validated) +hive-forge pr status --pr 42 # PR health (mergeable / CI / reviews) +hive-forge issue create --title "..." --body "..." +# --- flat aliases below remain valid (hidden) --- hive-forge view 42 # title + body + comments hive-forge comments 42 # list all comments (human-readable) hive-forge comments 42 --tail 10 # last 10 comments (count-then-page; efficient on long threads) diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 9aab6341..bc0f26a9 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -47,39 +47,54 @@ struct Cli { #[derive(Subcommand)] enum Verb { + // The kind verbs below are hidden back-compat aliases of the new + // `pr ` / `issue ` 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), - /// Print key fields of an issue as JSON. - Issue(verbs::issue::Args), + /// Issue-scoped commands: `issue …`. + 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), - /// Print key fields of a PR as JSON. - Pr(verbs::pr::Args), + /// PR-scoped commands: `pr …`. + Pr(verbs::pr_cmd::Args), /// List a PR's commits as JSON (sha, message, author date, author). /// Survives rebase-rewritten shas — message + author date let a /// caller match the rows against linear `main` history. + #[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 `). `--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`. @@ -113,21 +128,25 @@ enum Verb { /// 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), /// 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 or set this user's watch subscription on a repo. 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), @@ -152,10 +171,10 @@ fn main() -> Result<()> { let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?; match cli.verb { Verb::View(a) => verbs::view::run(&client, a), - Verb::Issue(a) => verbs::issue::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::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), diff --git a/hive-forge/src/verbs/assign.rs b/hive-forge/src/verbs/assign.rs index d257e239..c4a7ff8f 100644 --- a/hive-forge/src/verbs/assign.rs +++ b/hive-forge/src/verbs/assign.rs @@ -13,7 +13,7 @@ use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, /// User login to assign (or unassign with `--remove`). user: String, /// Remove the user instead of adding. diff --git a/hive-forge/src/verbs/close.rs b/hive-forge/src/verbs/close.rs index 0d01ce27..d67c5aea 100644 --- a/hive-forge/src/verbs/close.rs +++ b/hive-forge/src/verbs/close.rs @@ -10,7 +10,7 @@ use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, } pub fn run(client: &Client, args: Args) -> Result<()> { diff --git a/hive-forge/src/verbs/comment.rs b/hive-forge/src/verbs/comment.rs index 2a99a380..f5bd774c 100644 --- a/hive-forge/src/verbs/comment.rs +++ b/hive-forge/src/verbs/comment.rs @@ -12,7 +12,7 @@ use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, /// Inline body text. #[arg(long, conflicts_with = "body_file")] body: Option, diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 6c5bcb6a..36783665 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -31,7 +31,7 @@ const PAGE_SIZE: usize = 50; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, /// Page size for the head-of-thread shape (Forgejo caps at 50). /// Mutually exclusive with `--tail`. #[arg(long, default_value_t = 50, conflicts_with = "tail")] diff --git a/hive-forge/src/verbs/issue_cmd.rs b/hive-forge/src/verbs/issue_cmd.rs new file mode 100644 index 00000000..993cbaeb --- /dev/null +++ b/hive-forge/src/verbs/issue_cmd.rs @@ -0,0 +1,81 @@ +//! `issue ` — issue-scoped sub-commands. Wraps the per-verb modules +//! under an `issue` parent so `hive-forge issue close 42`, `issue create …`, +//! etc. read as kind-namespaced commands. The generic verbs that also work on +//! PRs (view/comment/comments/close/labels/assign/timeline) kind-check the +//! number is an issue first (`assert_kind`); the issue-only verbs are +//! kind-correct by construction. The flat `issue-*` + bare generic verbs stay +//! as hidden back-compat aliases (see `main.rs`). + +use anyhow::Result; +use clap::{Args as ClapArgs, Subcommand}; + +use crate::client::Client; +use crate::verbs::{self, Kind, assert_kind}; + +#[derive(ClapArgs)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show issue metadata as JSON. + Show(verbs::issue::Args), + /// Create an issue. + Create(verbs::issue_create::Args), + /// Edit an issue's title / body / state / milestone. + Edit(verbs::issue_edit::Args), + /// Show title + body + comments. + View(verbs::view::Args), + /// Post a comment on the issue. + Comment(verbs::comment::Args), + /// List comments on the issue. + Comments(verbs::comments::Args), + /// Close the issue. + Close(verbs::close::Args), + /// List / add / remove labels. + Labels(verbs::labels::Args), + /// Assign or unassign a user. + Assign(verbs::assign::Args), + /// List timeline events. + Timeline(verbs::timeline::Args), +} + +pub fn run(client: &Client, args: Args) -> Result<()> { + match args.cmd { + // Issue-only verbs. + Cmd::Show(a) => verbs::issue::run(client, a), + Cmd::Create(a) => verbs::issue_create::run(client, a), + Cmd::Edit(a) => verbs::issue_edit::run(client, a), + // Generics shared with `pr` — verify the number is an issue first. + Cmd::View(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::view::run(client, a) + } + Cmd::Comment(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::comment::run(client, a) + } + Cmd::Comments(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::comments::run(client, a) + } + Cmd::Close(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::close::run(client, a) + } + Cmd::Labels(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::labels::run(client, a) + } + Cmd::Assign(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::assign::run(client, a) + } + Cmd::Timeline(a) => { + assert_kind(client, a.number, Kind::Issue)?; + verbs::timeline::run(client, a) + } + } +} diff --git a/hive-forge/src/verbs/labels.rs b/hive-forge/src/verbs/labels.rs index 20db93b1..a3118303 100644 --- a/hive-forge/src/verbs/labels.rs +++ b/hive-forge/src/verbs/labels.rs @@ -11,7 +11,7 @@ use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, #[command(subcommand)] action: Option, } diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index e56f99ff..e1b28c33 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -17,6 +17,7 @@ pub mod comment_show; pub mod comments; pub mod diff; pub mod issue; +pub mod issue_cmd; pub mod issue_create; pub mod issue_edit; pub mod labels; @@ -24,6 +25,7 @@ pub mod lint; pub mod list; pub mod milestone; pub mod pr; +pub mod pr_cmd; pub mod pr_commits; pub mod pr_create; pub mod pr_merge; @@ -52,6 +54,37 @@ pub(crate) fn print_json(v: &Value) -> Result<()> { Ok(()) } +/// Issue-vs-PR kind, for the `pr ` / `issue ` sub-command +/// validation. +#[derive(Clone, Copy)] +pub(crate) enum Kind { + Pr, + Issue, +} + +/// Verify `number` is the expected kind before a kind-namespaced verb (one +/// of the generics that work on both — close/comment/labels/…) acts on it — +/// the validation win the `pr ` / `issue ` split buys over the +/// old generic verbs. Forgejo's `/issues/{n}` endpoint serves both issues and +/// PRs and marks PRs with a non-null `pull_request` field, so one GET +/// classifies it. Errors with a "use the other command" message on mismatch. +pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> { + let repo = client.repo(); + let v = client.get_json(&format!("/repos/{repo}/issues/{number}"))?; + let is_pr = v.get("pull_request").is_some_and(|p| !p.is_null()); + match (expected, is_pr) { + (Kind::Pr, false) => { + anyhow::bail!( + "#{number} is an issue, not a PR — use `hive-forge issue {number}`" + ) + } + (Kind::Issue, true) => { + anyhow::bail!("#{number} is a PR, not an issue — use `hive-forge pr {number}`") + } + _ => Ok(()), + } +} + /// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of /// characters that show up in the values we splice into request paths — /// usernames, label names, artifact names — without pulling in a fresh diff --git a/hive-forge/src/verbs/pr_cmd.rs b/hive-forge/src/verbs/pr_cmd.rs new file mode 100644 index 00000000..ea380166 --- /dev/null +++ b/hive-forge/src/verbs/pr_cmd.rs @@ -0,0 +1,93 @@ +//! `pr ` — PR-scoped sub-commands. Wraps the per-verb modules under a +//! `pr` parent so `hive-forge pr close 42`, `pr status --pr 42`, etc. read as +//! kind-namespaced commands. The generic verbs that also work on issues +//! (view/comment/comments/close/labels/assign/timeline) kind-check the number +//! is a PR first (`assert_kind`); the PR-only verbs hit `/pulls/…` and are +//! kind-correct by construction. The flat `pr-*` + bare generic verbs stay as +//! hidden back-compat aliases (see `main.rs`). + +use anyhow::Result; +use clap::{Args as ClapArgs, Subcommand}; + +use crate::client::Client; +use crate::verbs::{self, Kind, assert_kind}; + +#[derive(ClapArgs)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show PR metadata as JSON. + Show(verbs::pr::Args), + /// List the PR's commits as JSON. + Commits(verbs::pr_commits::Args), + /// Create a pull request. + Create(verbs::pr_create::Args), + /// PR health view: mergeable / CI / reviews. + Status(verbs::pr_status::Args), + /// Merge the PR. + Merge(verbs::pr_merge::Args), + /// List a PR's reviews, or submit one. + Reviews(verbs::pr_reviews::Args), + /// Print the PR's unified diff. + Diff(verbs::diff::Args), + /// Show title + body + comments. + View(verbs::view::Args), + /// Post a comment on the PR. + Comment(verbs::comment::Args), + /// List comments on the PR. + Comments(verbs::comments::Args), + /// Close the PR. + Close(verbs::close::Args), + /// List / add / remove labels. + Labels(verbs::labels::Args), + /// Assign or unassign a user. + Assign(verbs::assign::Args), + /// List timeline events. + Timeline(verbs::timeline::Args), +} + +pub fn run(client: &Client, args: Args) -> Result<()> { + match args.cmd { + // PR-only verbs — kind-correct by construction (hit `/pulls/…`). + Cmd::Show(a) => verbs::pr::run(client, a), + Cmd::Commits(a) => verbs::pr_commits::run(client, a), + Cmd::Create(a) => verbs::pr_create::run(client, a), + Cmd::Status(a) => verbs::pr_status::run(client, a), + Cmd::Merge(a) => verbs::pr_merge::run(client, a), + Cmd::Reviews(a) => verbs::pr_reviews::run(client, a), + Cmd::Diff(a) => verbs::diff::run(client, a), + // Generics shared with `issue` — verify the number is a PR first. + Cmd::View(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::view::run(client, a) + } + Cmd::Comment(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::comment::run(client, a) + } + Cmd::Comments(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::comments::run(client, a) + } + Cmd::Close(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::close::run(client, a) + } + Cmd::Labels(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::labels::run(client, a) + } + Cmd::Assign(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::assign::run(client, a) + } + Cmd::Timeline(a) => { + assert_kind(client, a.number, Kind::Pr)?; + verbs::timeline::run(client, a) + } + } +} diff --git a/hive-forge/src/verbs/timeline.rs b/hive-forge/src/verbs/timeline.rs index bd662e72..4b99a159 100644 --- a/hive-forge/src/verbs/timeline.rs +++ b/hive-forge/src/verbs/timeline.rs @@ -26,7 +26,7 @@ use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, /// Page size (Forgejo caps at 50). Returns the first `N` events. #[arg(long, default_value_t = 50)] limit: u64, diff --git a/hive-forge/src/verbs/view.rs b/hive-forge/src/verbs/view.rs index 01494cdc..b0ab2474 100644 --- a/hive-forge/src/verbs/view.rs +++ b/hive-forge/src/verbs/view.rs @@ -10,7 +10,7 @@ use crate::client::Client; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. - number: u64, + pub(crate) number: u64, } pub fn run(client: &Client, args: Args) -> Result<()> {