From 703018106af9f58a2fb8ac4198e91b376714c0eb Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 27 May 2026 10:25:36 +0200 Subject: [PATCH] hive-forge: add lint verb for triage queries (closes #505) --- hive-forge/src/client.rs | 28 +++ hive-forge/src/main.rs | 3 + hive-forge/src/verbs/lint.rs | 446 +++++++++++++++++++++++++++++++++++ hive-forge/src/verbs/mod.rs | 1 + 4 files changed, 478 insertions(+) create mode 100644 hive-forge/src/verbs/lint.rs diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 0c41a0c6..6e7d47e4 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -93,6 +93,34 @@ impl Client { decode_json(resp, &format!("GET {url}")) } + /// GET a paginated list endpoint and concatenate all pages. + /// `path` should NOT include `page=` (we own it); other query + /// params (`?limit=N&state=open&...`) are preserved. Pages drain + /// while the response carries a `Link: rel="next"` header, up to + /// `max_pages` (the runaway-loop safety cap). Returns the merged + /// array. Used by `lint` for repo-wide queries (closes #505). + pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result> { + let sep = if path.contains('?') { '&' } else { '?' }; + let mut merged = Vec::new(); + for page in 1..=max_pages { + let url = format!("{}{}{sep}page={page}", self.api(), path); + let resp = self.http.get(&url).send().context("GET")?; + let has_next = resp + .headers() + .get(reqwest::header::LINK) + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| s.contains("rel=\"next\"")); + let v = decode_json(resp, &format!("GET {url}"))?; + let arr = v.as_array().cloned().unwrap_or_default(); + let empty = arr.is_empty(); + merged.extend(arr); + if empty || !has_next { + break; + } + } + Ok(merged) + } + /// GET `/` and return the raw response body as text /// (used by `diff` which fetches a `text/plain` blob). pub fn get_text(&self, path: &str, accept: &str) -> Result { diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index ed5a9563..e26a06f4 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -73,6 +73,8 @@ enum Verb { Close(verbs::close::Args), /// List, add, or remove labels on an issue or PR. Labels(verbs::labels::Args), + /// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments). + Lint(verbs::lint::Args), /// Manage milestones (list / create / close). Milestone(verbs::milestone::Args), /// List reviews on a PR. @@ -109,6 +111,7 @@ fn main() -> Result<()> { Verb::Assign(a) => verbs::assign::run(&client, a), Verb::Close(a) => verbs::close::run(&client, a), Verb::Labels(a) => verbs::labels::run(&client, a), + Verb::Lint(a) => verbs::lint::run(&client, a), Verb::Milestone(a) => verbs::milestone::run(&client, a), Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a), Verb::Branches(a) => verbs::branches::run(&client, a), diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs new file mode 100644 index 00000000..64c2cc2f --- /dev/null +++ b/hive-forge/src/verbs/lint.rs @@ -0,0 +1,446 @@ +//! `lint ` — issue/PR/branch lint queries for triage +//! workflows (closes #505). Replaces ad-hoc curl + jq filtering with +//! typed commands that always emit JSON via the global `--json` +//! (default is a compact one-line-per-item human shape). +//! +//! Sub-commands: +//! - `unassigned [--type issues|pulls|all] [--state open|closed|all]` +//! - `no-reviewer [--state open|closed|all] [--reviewer NAME]` +//! - `stale-branches [--days N]` +//! - `assignments [--user NAME]` + +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use clap::{Args as ClapArgs, Subcommand, ValueEnum}; +use serde_json::{Value, json}; + +use crate::client::Client; +use crate::verbs::print_json; + +/// Safety cap on paginated walks: 20 pages × 50 items = 1000. +/// Plenty for the hyperhive repo today; bump if a future repo trips it. +const MAX_PAGES: u32 = 20; + +/// Page size on list endpoints (Forgejo caps at 50 by default). +const PAGE_LIMIT: u32 = 50; + +#[derive(ClapArgs)] +pub struct Args { + #[command(subcommand)] + sub: Sub, +} + +#[derive(Subcommand)] +enum Sub { + /// List issues or PRs without an assignee. + Unassigned(UnassignedArgs), + /// List PRs with no `@reviewer` mention in any comment. + NoReviewer(NoReviewerArgs), + /// List remote branches with no commits in N days. + /// Skips branches that are heads of open PRs. + StaleBranches(StaleBranchesArgs), + /// Group open issues + PRs by assignee. + Assignments(AssignmentsArgs), +} + +#[derive(Copy, Clone, ValueEnum)] +enum Kind { + Issues, + Pulls, + All, +} + +impl Kind { + fn forgejo_type(self) -> &'static str { + match self { + Kind::Issues => "issues", + Kind::Pulls => "pulls", + Kind::All => "all", + } + } +} + +#[derive(Copy, Clone, ValueEnum)] +enum State { + Open, + Closed, + All, +} + +impl State { + fn as_str(self) -> &'static str { + match self { + State::Open => "open", + State::Closed => "closed", + State::All => "all", + } + } +} + +#[derive(ClapArgs)] +struct UnassignedArgs { + /// Filter by item kind. + #[arg(long, value_enum, default_value_t = Kind::All)] + r#type: Kind, + /// Filter by item state. + #[arg(long, value_enum, default_value_t = State::Open)] + state: State, +} + +#[derive(ClapArgs)] +struct NoReviewerArgs { + /// Filter by PR state. + #[arg(long, value_enum, default_value_t = State::Open)] + state: State, + /// Reviewer login to look for (matches `@` in comments). + #[arg(long, default_value = "argus")] + reviewer: String, +} + +#[derive(ClapArgs)] +struct StaleBranchesArgs { + /// Threshold in days since the last commit. + #[arg(long, default_value_t = 14)] + days: i64, +} + +#[derive(ClapArgs)] +struct AssignmentsArgs { + /// Restrict to a single user. + #[arg(long)] + user: Option, +} + +pub fn run(client: &Client, args: Args) -> Result<()> { + match args.sub { + Sub::Unassigned(a) => run_unassigned(client, a), + Sub::NoReviewer(a) => run_no_reviewer(client, a), + Sub::StaleBranches(a) => run_stale_branches(client, a), + Sub::Assignments(a) => run_assignments(client, a), + } +} + +// ───────────────────────── unassigned ───────────────────────── + +fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> { + let repo = client.repo(); + let items = client.get_json_all( + &format!( + "/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}", + args.r#type.forgejo_type(), + args.state.as_str() + ), + MAX_PAGES, + )?; + let filtered: Vec = items + .into_iter() + .filter(|it| { + it.get("assignees") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + }) + .map(trim_item) + .collect(); + emit(client, &filtered, |it| { + format!("#{} [{}] {}", num(it), kind_label(it), title(it)) + }) +} + +// ───────────────────────── no-reviewer ──────────────────────── + +fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> { + let repo = client.repo(); + // PR-only: `/repos/{repo}/pulls` doesn't return issues. + let pulls = client.get_json_all( + &format!( + "/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}", + args.state.as_str() + ), + MAX_PAGES, + )?; + let needle = format!("@{}", args.reviewer); + let mut missing: Vec = Vec::new(); + for pr in pulls { + let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0); + if number == 0 { + continue; + } + // Check PR body itself first — saves a comment-fetch on freshly-opened PRs + // that already @reviewer in the description. + let body = pr.get("body").and_then(Value::as_str).unwrap_or(""); + if body.contains(&needle) { + continue; + } + let comments = + client.get_json(&format!("/repos/{repo}/issues/{number}/comments?limit=50"))?; + let mentioned = comments.as_array().is_some_and(|a| { + a.iter().any(|c| { + c.get("body") + .and_then(Value::as_str) + .is_some_and(|body| body.contains(&needle)) + }) + }); + if !mentioned { + missing.push(trim_item(pr)); + } + } + emit(client, &missing, |it| format!("#{} {}", num(it), title(it))) +} + +// ─────────────────────── stale-branches ─────────────────────── + +fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { + if args.days < 0 { + bail!("--days must be non-negative"); + } + let repo = client.repo(); + let branches = client.get_json_all( + &format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"), + MAX_PAGES, + )?; + // Collect active PR head refs to skip — a branch with an open PR + // isn't "stale", it's "in review". + let open_pulls = client.get_json_all( + &format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"), + MAX_PAGES, + )?; + let active_heads: std::collections::HashSet = open_pulls + .iter() + .filter_map(|p| { + p.get("head") + .and_then(|h| h.get("ref")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect(); + + let cutoff_days = today_days_utc().context("compute today")? - args.days; + let mut stale: Vec = Vec::new(); + for br in branches { + let name = br.get("name").and_then(Value::as_str).unwrap_or(""); + if name.is_empty() || active_heads.contains(name) { + continue; + } + let ts = br + .get("commit") + .and_then(|c| c.get("timestamp")) + .and_then(Value::as_str) + .unwrap_or(""); + let Some(date_str) = ts.get(..10) else { + continue; + }; + let Some(days) = parse_yyyy_mm_dd_days(date_str) else { + continue; + }; + if days <= cutoff_days { + stale.push(json!({ + "name": name, + "last_commit": ts, + "age_days": (today_days_utc().unwrap_or(days) - days), + })); + } + } + emit(client, &stale, |it| { + let name = it.get("name").and_then(Value::as_str).unwrap_or("?"); + let age = it.get("age_days").and_then(Value::as_i64).unwrap_or(0); + format!("{name} ({age}d)") + }) +} + +// ───────────────────────── assignments ──────────────────────── + +fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> { + let repo = client.repo(); + let items = client.get_json_all( + &format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"), + MAX_PAGES, + )?; + let mut by_user: BTreeMap> = BTreeMap::new(); + for it in items { + let assignees: Vec = it + .get("assignees") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| x.get("login").and_then(Value::as_str)) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + if assignees.is_empty() { + continue; + } + let slim = trim_item(it); + for u in assignees { + if args.user.as_deref().is_some_and(|w| w != u) { + continue; + } + by_user.entry(u).or_default().push(slim.clone()); + } + } + + if client.json_mode() { + let out: Value = by_user + .into_iter() + .map(|(u, items)| { + ( + u, + json!({ + "count": items.len(), + "items": items, + }), + ) + }) + .collect::>() + .into(); + print_json(&out) + } else { + for (u, items) in &by_user { + println!("{u}: {} items", items.len()); + for it in items.iter().take(3) { + println!(" #{} [{}] {}", num(it), kind_label(it), title(it)); + } + if items.len() > 3 { + println!(" ... (+{} more)", items.len() - 3); + } + } + Ok(()) + } +} + +// ───────────────────────── shared helpers ───────────────────── + +/// Strip a Forgejo issue/PR JSON down to the fields lint output cares +/// about. Mirrors the trim pattern in `verbs/issue.rs`. +fn trim_item(it: Value) -> Value { + json!({ + "number": it.get("number"), + "title": it.get("title"), + "state": it.get("state"), + "url": it.get("html_url"), + // Forgejo's unified /issues endpoint always emits a + // `pull_request` key — `null` for plain issues, an object + // (with merged/url/etc.) for PRs. Treat any non-null as a PR. + "is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()), + "assignees": it + .get("assignees") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| x.get("login").cloned()) + .collect::>() + }) + .unwrap_or_default(), + }) +} + +fn num(it: &Value) -> i64 { + it.get("number").and_then(Value::as_i64).unwrap_or(0) +} + +fn title(it: &Value) -> &str { + it.get("title").and_then(Value::as_str).unwrap_or("") +} + +fn kind_label(it: &Value) -> &'static str { + if it.get("is_pr").and_then(Value::as_bool).unwrap_or(false) { + "pr" + } else { + "issue" + } +} + +/// Either pretty-print the JSON array (when --json) or fall back to a +/// caller-supplied human one-liner per item. +fn emit(client: &Client, items: &[Value], fmt: F) -> Result<()> +where + F: Fn(&Value) -> String, +{ + if client.json_mode() { + print_json(&Value::Array(items.to_vec())) + } else { + if items.is_empty() { + println!("(no matches)"); + } + for it in items { + println!("{}", fmt(it)); + } + Ok(()) + } +} + +// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ─── + +/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock). +fn today_days_utc() -> Result { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before epoch")? + .as_secs(); + // `as_secs()` returns u64; clamp into i64 (won't overflow until y2554). + Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX)) +} + +/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp) +/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse +/// failure rather than panicking — lint output stays best-effort. +fn parse_yyyy_mm_dd_days(stamp: &str) -> Option { + let bytes = stamp.as_bytes(); + if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?; + let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?; + let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + Some(days_from_civil(year, month, day)) +} + +/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since +/// 1970-01-01. Public-domain reference algorithm. Handles negative years. +fn days_from_civil(year: i32, month: u32, day: u32) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = i64::from(y - era * 400); // [0, 399] + let m = i64::from(month); + let d = i64::from(day); + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + i64::from(era) * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_is_day_zero() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + } + + #[test] + fn known_dates() { + // Hinnant reference values + assert_eq!(days_from_civil(2000, 1, 1), 10_957); + assert_eq!(days_from_civil(2020, 2, 29), 18_321); + } + + #[test] + fn parses_iso_prefix() { + assert_eq!( + parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"), + Some(18_321) + ); + } + + #[test] + fn rejects_bad_input() { + assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None); + assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None); + assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None); + } +} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index a32ef2a8..d8f0bcb0 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -16,6 +16,7 @@ pub mod issue; pub mod issue_create; pub mod issue_edit; pub mod labels; +pub mod lint; pub mod milestone; pub mod pr; pub mod pr_create;