//! 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 dependency; 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 forgejo_api::structs::Attachment; 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 } /// Render an unresolved-name list with a `did you mean` where one fits. /// /// A filter value that doesn't resolve is a **near-miss far more often /// than an invention** (`area/opps` for `area/ops`), and an error that /// only lists all 18 available names makes the reader do the diff by eye /// — on the one occasion they already know they mistyped something. pub(crate) fn with_suggestions(unresolved: &[&str], available: &[&str]) -> String { unresolved .iter() .map(|u| match nearest(u, available) { Some(s) => format!("{u} (did you mean \"{s}\"?)"), None => (*u).to_owned(), }) .collect::>() .join(", ") } /// Closest candidate to `needle`, when one is close enough to be worth /// suggesting. /// /// Thresholded rather than always returning the minimum: **a wrong /// suggestion is worse than none**, because it invites a second failed /// attempt at a name that was never there. The bound scales with the /// needle (a third of its length, capped at 3) so a short name doesn't /// match half the repo and a long one still tolerates a typo or two. fn nearest<'a>(needle: &str, candidates: &[&'a str]) -> Option<&'a str> { let limit = (needle.chars().count() / 3).clamp(1, 3); candidates .iter() .map(|c| (edit_distance(needle, c), *c)) .filter(|(d, _)| *d <= limit) // Tie-break on the shorter candidate, then alphabetically, so the // suggestion is stable rather than dependent on the order the // forge happened to return its labels in. .min_by_key(|(d, c)| (*d, c.len(), *c)) .map(|(_, c)| c) } /// Levenshtein distance, two-row DP. Small enough not to justify a /// workspace dependency for the one place it is used. fn edit_distance(a: &str, b: &str) -> usize { let b: Vec = b.chars().collect(); let mut prev: Vec = (0..=b.len()).collect(); let mut cur = vec![0_usize; b.len() + 1]; for (i, ca) in a.chars().enumerate() { cur[0] = i + 1; for (j, cb) in b.iter().enumerate() { let cost = usize::from(ca != *cb); cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1); } std::mem::swap(&mut prev, &mut cur); } prev[b.len()] } /// 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. /// /// ⚠️ **`stale` here is not forgejo's flag alone.** That flag is eventually /// consistent: seconds after a push it still reports the pre-push answer, so /// a verdict against the previous head reads as current in exactly the window /// where someone runs the CLI right after pushing. [`latest_reviews`] therefore /// ORs it with a direct comparison of the review's own `commit_id` against the /// PR head — the flag is right *eventually*, the comparison is right /// *immediately*, and either alone is worse than both. 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 } } /// Whether this review was submitted against a commit that is no longer the /// PR head — the race-free half of the staleness check. /// /// Fails **closed on unknowns**, i.e. "not stale": either side missing means /// we cannot show the head moved, and the cost of guessing wrong in that /// direction is one redundant re-review, where the other direction would /// silently void every verdict on the PR (blocking nothing, but reporting a /// ready PR as unreviewed and inviting a re-request that dismisses a real /// approval). fn reviewed_older_head(reviewed_sha: Option<&str>, head_sha: Option<&str>) -> bool { let (Some(head), Some(reviewed)) = (head_sha, reviewed_sha) else { return false; }; // Both emptiness checks matter, and for the same reason: a blank string // is a value the forge sent, not a sha it has. Treating one as real would // make every review compare unequal and mark the whole PR stale — the // direction this whole function exists to avoid. !head.is_empty() && !reviewed.is_empty() && reviewed != head } /// 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)?; // The head this PR currently points at, used to age out verdicts forgejo // has not marked stale yet (see `ReviewInfo`). Best-effort: on any failure // we fall back to forgejo's flag alone, which is today's behaviour — a // missing head must never make every review look superseded. let head_sha = client .api() .repo_get_pull_request(owner, name, pr) .send() .ok() .and_then(|pull| pull.head.as_ref().and_then(|h| h.sha.clone())); // 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) || reviewed_older_head(r.commit_id.as_deref(), head_sha.as_deref()); 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, with its `number`/`title`/ /// `state`. Forgejo's dependency endpoint works on the shared issue/PR /// index (PRs are issues internally under the hood), so `issue show`, /// `pr show`, and `list`'s dep-progress annotation all 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. /// `state` was added alongside `list`'s annotation so a caller can tell /// open deps from closed ones without a second fetch. /// /// # 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, "state": d.state })) .collect()) } /// A single attachment as one display line — `[file: ] `, /// mirroring the `[file: ...]` marker convention `read_room` already /// uses for matrix attachments. `None` when the attachment has no /// download URL (shouldn't happen server-side, but a missing pointer /// is worse silently dropped than shown as "?"). /// /// Forgejo already returns `assets` inline on the same `Comment`/`Issue` /// fetch every render path here already makes — this just reads a field /// that was sitting unused, the gap that made an attachment link /// unreadable from a non-visual CLI read without guessing the UUID by /// hand (hit in practice on the swarm-controller extraction thread). pub(crate) fn attachment_line(a: &Attachment) -> Option { let name = a.name.as_deref().unwrap_or("?"); let url = a.browser_download_url.as_ref()?; Some(format!("[file: {name}] {url}")) } /// JSON form of an attachment list (`{"name", "url"}` per entry), for /// `--json` output — same data [`attachment_line`] renders as text. pub(crate) fn attachment_json(assets: Option<&[Attachment]>) -> Vec { assets .unwrap_or_default() .iter() .map(|a| { json!({ "name": a.name, "url": a.browser_download_url.as_ref().map(ToString::to_string), }) }) .collect() } #[cfg(test)] mod tests { use super::{pct_encode, reviewed_older_head}; #[test] fn review_on_an_older_commit_is_stale() { // The bug this exists for: forgejo still reports `stale: false` here // in the seconds after a push, so the comparison has to catch it. assert!(reviewed_older_head(Some("81292f14"), Some("f4c47088"))); } #[test] fn review_on_the_current_head_is_not_stale() { assert!(!reviewed_older_head(Some("f4c47088"), Some("f4c47088"))); } /// Every unknown fails *toward* keeping the verdict. Voiding every review /// on a forge that stopped reporting one of these would be a far louder /// wrong answer than one redundant re-review. #[test] fn unknown_commit_or_head_is_not_stale() { assert!(!reviewed_older_head(None, Some("f4c47088"))); assert!(!reviewed_older_head(Some("81292f14"), None)); assert!(!reviewed_older_head(Some(""), Some("f4c47088"))); // Absent and blank have to behave the same on BOTH sides — a blank // head that counted as real would mark every review on the PR stale. assert!(!reviewed_older_head(Some("81292f14"), Some(""))); assert!(!reviewed_older_head(Some(""), Some(""))); } #[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"); } #[test] fn nearest_finds_the_one_character_typo() { let all = ["area/ops", "area/broker", "type/bug"]; assert_eq!(super::nearest("area/opps", &all), Some("area/ops")); assert_eq!(super::nearest("type/bugs", &all), Some("type/bug")); } #[test] fn nearest_suggests_nothing_for_an_invention() { // The important half: a wrong suggestion invites a second failed // attempt at a name that was never there, so far-away input must // fall back to "here is everything". let all = ["area/ops", "area/broker", "type/bug"]; assert_eq!(super::nearest("frontend", &all), None); assert_eq!(super::nearest("", &all), None); } #[test] fn nearest_is_stable_when_two_candidates_tie() { // Both are distance 1 from "v3"; the answer must not depend on // the order the forge returned them in. let forward = ["v1", "v2"]; let reversed = ["v2", "v1"]; assert_eq!( super::nearest("v3", &forward), super::nearest("v3", &reversed) ); } #[test] fn with_suggestions_annotates_only_the_near_misses() { let all = ["area/ops", "type/bug"]; let rendered = super::with_suggestions(&["area/opps", "frontend"], &all); assert!( rendered.contains(r#"area/opps (did you mean "area/ops"?)"#), "{rendered}" ); assert!(rendered.contains("frontend"), "{rendered}"); assert!( !rendered.contains(r"frontend (did you mean"), "invented name must not get a suggestion: {rendered}" ); } }