//! 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 ci_rerun; 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_assign_reviewer; 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 reopen; pub mod repo_add_collaborator; pub mod repo_create; pub mod repo_labels; pub mod repo_search; 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, json}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use crate::client::{Client, index}; /// 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(()) } /// Format an optional timestamp as its RFC 3339 string — the shape the /// raw API emitted, so output stays stable across the typed-client /// port. `None` (and the never-in-practice unformattable timestamp) /// map to `None` so callers keep their existing null/placeholder /// handling. pub(crate) fn rfc3339(ts: Option) -> Option { ts.and_then(|t| t.format(&Rfc3339).ok()) } /// Parse a `--since`/`--before` CLI argument as RFC 3339 — the inverse of /// [`rfc3339`], so a value copied straight from this tool's own output /// (every row prints its `created_at` in this exact shape) round-trips /// without reformatting. A bad value gets a message naming what was /// typed, not a bare parser error. pub(crate) fn parse_rfc3339(s: &str) -> Result { OffsetDateTime::parse(s, &Rfc3339) .map_err(|e| anyhow::anyhow!("`{s}` isn't a valid RFC 3339 timestamp: {e}")) } /// Forgejo's per-page cap, shared by every listing verb that over-fetches /// by one to detect truncation without an exact total (`timeline`'s /// `--limit`, `comments`' `--since`). The API silently clamps a requested /// page size to this value, so it's pinned explicitly rather than left as /// a hidden default downstream math could drift out of sync with. pub(crate) const PAGE_SIZE: u64 = 50; /// The highest `--limit` an over-fetch-by-one truncation check /// (`fetch_limit = limit + 1`) can still detect: `PAGE_SIZE - 1`. At /// `limit == PAGE_SIZE` the `+1` request silently clamps to `PAGE_SIZE` /// server-side and the truncation check goes blind exactly when there's /// the most data to miss. pub(crate) const MAX_LIMIT: u64 = PAGE_SIZE - 1; /// Cap `requested` at [`MAX_LIMIT`], reporting whether it had to. Pure so /// the boundary math is unit-testable without a network call. pub(crate) fn clamp_limit(requested: u64) -> (u64, bool) { let limit = requested.min(MAX_LIMIT); (limit, limit < requested) } #[cfg(test)] mod page_limit_tests { use super::{MAX_LIMIT, PAGE_SIZE, clamp_limit}; #[test] fn clamp_limit_passes_small_requests_through() { assert_eq!(clamp_limit(10), (10, false)); assert_eq!(clamp_limit(MAX_LIMIT), (MAX_LIMIT, false)); } #[test] fn clamp_limit_caps_requests_above_the_boundary() { // Regression: `limit + 1` must never exceed Forgejo's PAGE_SIZE, // or the over-fetch-by-one truncation check goes silently blind. assert_eq!(clamp_limit(PAGE_SIZE), (MAX_LIMIT, true)); assert_eq!(clamp_limit(1000), (MAX_LIMIT, true)); } } /// 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 (owner, name) = client.owner_repo()?; let issue = client .api() .issue_get_issue(owner, name, index(number)?) .send()?; let is_pr = issue.pull_request.is_some(); 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 *web-route* paths /// (the typed client encodes its own path segments) — 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. Used by `artifact-get` (the /// artifact-name path segment on the web download route). 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 } /// One reviewer's latest verdict on a PR, plus forgejo's `stale` / /// `dismissed` bits. /// /// `stale` is set by forgejo when the PR head commit changed after this /// review was submitted (branch protection then wants a fresh review); /// `dismissed` is set when the review was explicitly dismissed. Either way /// the review no longer applies to the current head even though its `state` /// string still reads `APPROVED` / `REQUEST_CHANGES` — so surfacing them /// stops the CLI from reporting a no-longer-valid review as still-good, and /// [`ReviewInfo::superseded`] rolls both into one "doesn't count" check. pub(crate) struct ReviewInfo { pub login: String, pub state: String, pub stale: bool, pub dismissed: bool, } impl ReviewInfo { /// True when the review no longer applies to the current head — stale /// (head moved) or dismissed. Such a verdict neither blocks a merge nor /// counts as a fresh approval. pub(crate) fn superseded(&self) -> bool { self.stale || self.dismissed } } /// Latest non-comment review per reviewer on a PR. 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` (health view + readiness verdict) and /// `pr-merge` (pre-merge changes-requested gate) so the verdict semantics /// stay in one place. /// /// # Errors /// /// Propagates the forge API errors from listing the PR's reviews. pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result> { let (owner, name) = crate::client::split_repo(repo)?; let pr = index(pr)?; // Paginate (50/page, 10-page runaway cap — same ceiling the raw // client used) so a heavily re-reviewed PR doesn't truncate. let mut reviews = Vec::new(); for page in 1..=10u32 { let (_, batch) = client .api() .repo_list_pull_reviews(owner, name, pr) .page(page) .page_size(50) .send()?; let short = batch.len() < 50; reviews.extend(batch); if short { break; } } let mut latest: Vec = Vec::new(); for r in &reviews { let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else { continue; }; let st = r.state.as_deref().unwrap_or(""); if st == "COMMENT" || st == "PENDING" || st.is_empty() { continue; } let stale = r.stale.unwrap_or(false); let dismissed = r.dismissed.unwrap_or(false); if let Some(slot) = latest.iter_mut().find(|info| info.login == login) { st.clone_into(&mut slot.state); slot.stale = stale; slot.dismissed = dismissed; } else { latest.push(ReviewInfo { login: login.to_owned(), state: st.to_owned(), stale, dismissed, }); } } Ok(latest) } /// The current dependency list for an issue or PR — each entry names /// another issue/PR this one is blocked on. Forgejo's dependency endpoint /// works on the shared issue/PR index (PRs are issues internally under /// the hood), so `issue show` and `pr show` both call this instead of /// duplicating the fetch-and-shape step. A reviewer asked whether /// `show`/`view` surface dependencies — they didn't (only `timeline` /// rendered them, as history); this is the current-state complement. /// /// # Errors /// /// Propagates the forge API errors from listing dependencies. pub(crate) fn dependency_summaries( client: &Client, owner: &str, name: &str, number: u64, ) -> Result> { let deps = client .api() .issue_list_issue_dependencies(owner, name, index(number)?) .send()?; Ok(deps .into_iter() .map(|d| json!({ "number": d.number, "title": d.title })) .collect()) } #[cfg(test)] mod tests { use super::pct_encode; #[test] fn pct_encode_passes_unreserved_through() { // Plain 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() { // `&` / `/` / spaces 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"); } }