//! 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_common; pub mod ci_log; pub mod ci_rerun; pub mod ci_runs; pub mod clone; pub mod close; pub mod comment; pub mod comment_edit; pub mod comment_show; pub mod comments; pub mod credential_helper; 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 reaction; 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, Reaction}; use serde::Deserialize; 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, } /// Whether `number` is a PR rather than a plain issue. Forgejo's /// `/issues/{n}` endpoint serves both and marks PRs with a non-null /// `pull_request` field, so one GET classifies it — shared by /// [`assert_kind`] and by `dependency`'s failure-diagnosis path, which /// needs the same classification without wanting an error on mismatch. pub(crate) fn is_pr(client: &Client, owner: &str, name: &str, number: u64) -> Result { let issue = client .api() .issue_get_issue(owner, name, index(number)?) .send()?; Ok(issue.pull_request.is_some()) } /// 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. 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 is_pr_result = is_pr(client, owner, name, number)?; match (expected, is_pr_result) { (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()) } /// One entry in a `GET .../blocks` response — same `number`/`title`/ /// `state` shape [`dependency_summaries`] maps down to, but only those /// fields: this struct exists to be lenient (see /// [`Client::get_api_json`]'s doc comment), not to model the full Issue /// response body. #[derive(Deserialize)] struct BlockingIssue { #[serde(default)] number: u64, #[serde(default)] title: String, #[serde(default)] state: String, } /// The issues *blocked by* `number` — the reverse of /// [`dependency_summaries`]. Forgejo's dependency API is one-directional /// in `forgejo-api`'s generated client (only the forward `GET /// .../dependencies` is wrapped), but the reverse route is real — `GET /// .../blocks`, confirmed against Forgejo/Gitea's actual API surface, just /// not covered by the crate — so this goes through /// [`Client::get_api_json`] instead, same escape hatch [`issue_reactions`] /// already uses for an uncovered route. /// /// # Errors /// /// Propagates the forge API errors from listing blocking issues. pub(crate) fn blocking_summaries( client: &Client, owner: &str, name: &str, number: u64, ) -> Result> { let blocking: NullableVec = client.get_api_json( &format!("/repos/{owner}/{name}/issues/{number}/blocks"), &[], )?; Ok(blocking .0 .into_iter() .map(|b| json!({ "number": b.number, "title": b.title, "state": b.state })) .collect()) } /// Deserialize a possibly-null JSON array as an empty `Vec`. /// /// Several Forgejo list endpoints return an explicit `null` body instead of /// `[]` when the collection is empty — a commit's combined-status /// `statuses` field when no CI is configured (`pr_status`'s own use of /// this), and issue/comment reactions when nothing has reacted yet (a real /// incident: `issue show`/`pr show` failing on *every* item because every /// item's reaction fetch hit this). `#[serde(default)]` alone only /// covers a *missing* key — a *present* `null` still fails to deserialize /// into a bare `Vec`, which is why this explicit `deserialize_with` is /// needed rather than the derive default. pub(crate) fn null_as_empty<'de, D, T>(de: D) -> Result, D::Error> where D: serde::Deserializer<'de>, T: Deserialize<'de>, { Ok(Option::>::deserialize(de)?.unwrap_or_default()) } /// A JSON array that tolerates Forgejo's null-for-empty quirk (see /// [`null_as_empty`]). Used to fetch reactions directly via /// [`Client::get_api_json`] rather than through forgejo-api's generated /// client, which deserializes straight into a bare `Vec` with no /// null tolerance and fails on exactly the common case of "nothing has /// reacted to this yet". struct NullableVec(Vec); impl<'de, T: Deserialize<'de>> Deserialize<'de> for NullableVec { fn deserialize(de: D) -> Result where D: serde::Deserializer<'de>, { Ok(Self(null_as_empty(de)?)) } } /// The current reactions on an issue or PR itself (not a comment) — each /// entry's `content` is Forgejo's shortcode (`"+1"`, `"heart"`, …, the /// same vocabulary GitHub uses), `user` its login. Same shared-index /// rationale as [`dependency_summaries`]: PRs are issues internally, so /// `issue`/`pr`/`view` and the `reaction` verb's own listing all call /// this instead of duplicating the fetch-and-shape step. /// /// # Errors /// /// Propagates the forge API errors from listing reactions. pub(crate) fn issue_reactions( client: &Client, owner: &str, name: &str, number: u64, ) -> Result> { let reactions: NullableVec = client.get_api_json( &format!("/repos/{owner}/{name}/issues/{number}/reactions"), &[], )?; Ok(reaction_values(reactions.0)) } /// The current reactions on a single comment, by comment id (not the /// parent issue/PR number — Forgejo's comment-reaction endpoints are /// keyed on the comment alone, same as [`comment_show`]'s lookup). /// /// # Errors /// /// Propagates the forge API errors from listing reactions. pub(crate) fn comment_reactions( client: &Client, owner: &str, name: &str, comment_id: u64, ) -> Result> { let reactions: NullableVec = client.get_api_json( &format!("/repos/{owner}/{name}/issues/comments/{comment_id}/reactions"), &[], )?; Ok(reaction_values(reactions.0)) } fn reaction_values(reactions: Vec) -> Vec { reactions .into_iter() .map(|r| json!({ "content": r.content, "user": r.user.and_then(|u| u.login) })) .collect() } /// A one-line `content×count` summary of a reaction list (e.g. `+1×2 /// heart×1`), grouped and sorted by content name for a stable rendering. /// `None` for an empty list, so a row/section with no reactions omits the /// summary entirely rather than printing something empty. pub(crate) fn reaction_summary(reactions: &[Value]) -> Option { let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); for r in reactions { if let Some(content) = r.get("content").and_then(Value::as_str) { *counts.entry(content).or_insert(0) += 1; } } if counts.is_empty() { return None; } Some( counts .into_iter() .map(|(content, n)| format!("{content}×{n}")) .collect::>() .join(" "), ) } /// 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::{NullableVec, pct_encode, reviewed_older_head}; #[test] fn nullable_vec_treats_a_json_null_as_empty() { // The exact shape of the real incident this guards: Forgejo answers // a reactions (or combined-status) list with a bare `null` body // when the collection is empty, not `[]` — deserializing straight // into `Vec` fails on it, `NullableVec` must not. let v: NullableVec = serde_json::from_str("null").unwrap(); assert_eq!(v.0, Vec::::new()); } #[test] fn nullable_vec_passes_a_real_array_through() { let v: NullableVec = serde_json::from_str("[1,2,3]").unwrap(); assert_eq!(v.0, vec![1, 2, 3]); } #[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}" ); } }