//! `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 --reviewer NAME [--state open|closed|all]` //! - `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 PR body or /// any comment). Required — defaulting to a specific name would /// bake one deployment's reviewer convention into the binary /// (mara's nit on #507). #[arg(long)] 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; } // Paginate so PRs with >50 comments don't yield false positives // (argus nit on #507). Same 1000-comment ceiling as elsewhere. let comments = client.get_json_all( &format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"), MAX_PAGES, )?; let mentioned = comments.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); } }