diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 6e7d47e4..0c41a0c6 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -93,34 +93,6 @@ 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 e26a06f4..ed5a9563 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -73,8 +73,6 @@ 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. @@ -111,7 +109,6 @@ 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 deleted file mode 100644 index af24ffac..00000000 --- a/hive-forge/src/verbs/lint.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! `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); - } -} diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index d8f0bcb0..a32ef2a8 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -16,7 +16,6 @@ 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;