feat(#1877): restructure hive-forge into pr/issue sub-verbs
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>` / `issue close <pr>` 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.
This commit is contained in:
parent
fe974c580d
commit
dc4c5460d5
12 changed files with 257 additions and 13 deletions
|
|
@ -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 <verb>` / `issue <verb>` 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)
|
||||
|
|
|
|||
|
|
@ -47,39 +47,54 @@ struct Cli {
|
|||
|
||||
#[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),
|
||||
/// Print key fields of an issue as JSON.
|
||||
Issue(verbs::issue::Args),
|
||||
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|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),
|
||||
/// Print key fields of a PR as JSON.
|
||||
Pr(verbs::pr::Args),
|
||||
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|labels|assign|timeline> …`.
|
||||
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 <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`.
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
81
hive-forge/src/verbs/issue_cmd.rs
Normal file
81
hive-forge/src/verbs/issue_cmd.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! `issue <verb>` — 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Action>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <verb>` / `issue <verb>` 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 <verb>` / `issue <verb>` 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 <verb> {number}`"
|
||||
)
|
||||
}
|
||||
(Kind::Issue, true) => {
|
||||
anyhow::bail!("#{number} is a PR, not an issue — use `hive-forge pr <verb> {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
|
||||
|
|
|
|||
93
hive-forge/src/verbs/pr_cmd.rs
Normal file
93
hive-forge/src/verbs/pr_cmd.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! `pr <verb>` — 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue