hyperhive/hive-forge/src/verbs/mod.rs
atlas dc4c5460d5 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.
2026-06-22 16:01:13 +02:00

165 lines
5.4 KiB
Rust

//! Per-verb subcommand modules. Each module exposes a `Args` struct
//! (clap-derived) and a `run` fn taking `(&Client, Args) -> Result<()>`.
//! Splitting one verb per module keeps each handler small and avoids
//! the bash script's monolithic `case` statement.
pub mod artifact_get;
pub mod assign;
pub mod attach;
pub mod attachment_get;
pub mod branches;
pub mod ci_log;
pub mod clone;
pub mod close;
pub mod comment;
pub mod comment_edit;
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;
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;
pub mod pr_reviews;
pub mod pr_status;
pub mod repo_add_collaborator;
pub mod repo_create;
pub mod repo_labels;
pub mod subscription;
pub mod timeline;
pub mod tree_sha;
pub mod view;
use std::fmt::Write as _;
use anyhow::Result;
use serde_json::Value;
use crate::client::Client;
/// Pretty-print a `serde_json` value to stdout with a trailing newline,
/// matching the bash script's `| jq` output shape.
pub(crate) fn print_json(v: &Value) -> Result<()> {
let s = serde_json::to_string_pretty(v)?;
println!("{s}");
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
/// workspace dep. Unreserved bytes (`[A-Za-z0-9-._~]`) pass through, so
/// the common identifier case is a no-op; everything else is `%XX`-escaped.
/// Shared by `list` (query-string filters) and `artifact-get` (the
/// artifact-name path segment).
pub(crate) fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
write!(out, "%{b:02X}").unwrap();
}
}
out
}
/// Latest non-comment review verdict per reviewer on a PR, as
/// `(login, state)`. Reviews come oldest-first, so a later verdict from
/// the same user supersedes an earlier one; `COMMENT` / `PENDING`
/// reviews carry no verdict and are skipped. Shared by `pr-status` (for
/// its health view + readiness verdict) and `pr-merge` (for its
/// pre-merge changes-requested gate) so the verdict semantics stay in
/// one place.
pub(crate) fn latest_reviews(
client: &Client,
repo: &str,
pr: u64,
) -> Result<Vec<(String, String)>> {
let reviews = client.get_json_all(&format!("/repos/{repo}/pulls/{pr}/reviews"), 10)?;
let mut latest: Vec<(String, String)> = Vec::new();
for r in &reviews {
let Some(login) = r
.get("user")
.and_then(|u| u.get("login"))
.and_then(Value::as_str)
else {
continue;
};
let st = r.get("state").and_then(Value::as_str).unwrap_or("");
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
continue;
}
if let Some(slot) = latest.iter_mut().find(|(l, _)| l == login) {
st.clone_into(&mut slot.1);
} else {
latest.push((login.to_owned(), st.to_owned()));
}
}
Ok(latest)
}
#[cfg(test)]
mod tests {
use super::pct_encode;
#[test]
fn pct_encode_passes_unreserved_through() {
// Usernames + plain label/artifact names round-trip verbatim —
// no performance regression on the common case.
assert_eq!(pct_encode("damocles"), "damocles");
assert_eq!(pct_encode("area-ops"), "area-ops");
assert_eq!(pct_encode("area_ops"), "area_ops");
assert_eq!(pct_encode("pr1ma-paper-pdf"), "pr1ma-paper-pdf");
}
#[test]
fn pct_encode_escapes_reserved() {
// Forgejo labels can contain spaces ("good first issue" is the
// canonical example); `&` / `/` in any spliced value must escape
// so they can't break out of the path/query segment.
assert_eq!(pct_encode("good first issue"), "good%20first%20issue");
assert_eq!(pct_encode("x&y"), "x%26y");
assert_eq!(pct_encode("a/b"), "a%2Fb");
}
}