//! 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 ` / `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 /// 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> { 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"); } }