//! `lint ` — issue/PR/branch lint queries for triage //! workflows. 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 anyhow::{Result, bail}; use clap::{Args as ClapArgs, Subcommand, ValueEnum}; use forgejo_api::structs::{ Issue, IssueGetCommentsQuery, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, }; use serde_json::{Value, json}; use time::OffsetDateTime; use crate::client::Client; use crate::verbs::{print_json, rfc3339}; /// 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 { /// Forgejo's `type` filter: `issues` / `pulls`, or absent for the /// both-kinds slice (the forge returns everything when `type` is /// omitted). fn query_type(self) -> Option { match self { Kind::Issues => Some(IssueListIssuesQueryType::Issues), Kind::Pulls => Some(IssueListIssuesQueryType::Pulls), Kind::All => None, } } } #[derive(Copy, Clone, ValueEnum)] enum State { Open, Closed, All, } impl State { fn issue_state(self) -> IssueListIssuesQueryState { match self { State::Open => IssueListIssuesQueryState::Open, State::Closed => IssueListIssuesQueryState::Closed, State::All => IssueListIssuesQueryState::All, } } fn pull_state(self) -> RepoListPullRequestsQueryState { match self { State::Open => RepoListPullRequestsQueryState::Open, State::Closed => RepoListPullRequestsQueryState::Closed, State::All => RepoListPullRequestsQueryState::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 /// (flagged in review). #[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), } } /// Drain the repo's issue list (both kinds unless filtered) across /// pages, up to the runaway cap. fn fetch_issues( client: &Client, r#type: Option, state: IssueListIssuesQueryState, ) -> Result> { let (owner, name) = client.owner_repo()?; let mut items = Vec::new(); for page in 1..=MAX_PAGES { let query = IssueListIssuesQuery { state: Some(state), r#type, ..Default::default() }; let (_, batch) = client .api() .issue_list_issues(owner, name, query) .page(page) .page_size(PAGE_LIMIT) .send()?; let short = batch.len() < PAGE_LIMIT as usize; items.extend(batch); if short { break; } } Ok(items) } // ───────────────────────── unassigned ───────────────────────── fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> { let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?; let filtered: Vec = items .iter() .filter(|it| it.assignees.as_ref().is_none_or(Vec::is_empty)) .map(trim_issue) .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 (owner, name) = client.owner_repo()?; // PR-only: the pulls endpoint doesn't return issues. let mut pulls = Vec::new(); for page in 1..=MAX_PAGES { let query = RepoListPullRequestsQuery { state: Some(args.state.pull_state()), ..Default::default() }; let (_, batch) = client .api() .repo_list_pull_requests(owner, name, query) .page(page) .page_size(PAGE_LIMIT) .send()?; let short = batch.len() < PAGE_LIMIT as usize; pulls.extend(batch); if short { break; } } let needle = format!("@{}", args.reviewer); let mut missing: Vec = Vec::new(); for pr in &pulls { let Some(number) = pr.number.filter(|n| *n > 0) else { continue; }; // Check PR body itself first — saves a comment-fetch on freshly-opened PRs // that already @reviewer in the description. if pr.body.as_deref().unwrap_or("").contains(&needle) { continue; } // Paginate so PRs with >50 comments don't yield false positives // (flagged in review). Same 1000-comment ceiling as elsewhere. let mut mentioned = false; for page in 1..=MAX_PAGES { let (_, comments) = client .api() .issue_get_comments(owner, name, number, IssueGetCommentsQuery::default()) .page(page) .page_size(PAGE_LIMIT) .send()?; let short = comments.len() < PAGE_LIMIT as usize; mentioned = comments .iter() .any(|c| c.body.as_deref().is_some_and(|body| body.contains(&needle))); if mentioned || short { break; } } if !mentioned { missing.push(json!({ "number": pr.number, "title": pr.title, "state": pr.state, "url": pr.html_url, "is_pr": true, "assignees": logins(pr.assignees.as_deref()), })); } } 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 (owner, name) = client.owner_repo()?; let mut branches = Vec::new(); for page in 1..=MAX_PAGES { let (_, batch) = client .api() .repo_list_branches(owner, name) .page(page) .page_size(PAGE_LIMIT) .send()?; let short = batch.len() < PAGE_LIMIT as usize; branches.extend(batch); if short { break; } } // Collect active PR head refs to skip — a branch with an open PR // isn't "stale", it's "in review". let mut active_heads: std::collections::HashSet = std::collections::HashSet::new(); for page in 1..=MAX_PAGES { let query = RepoListPullRequestsQuery { state: Some(RepoListPullRequestsQueryState::Open), ..Default::default() }; let (_, batch) = client .api() .repo_list_pull_requests(owner, name, query) .page(page) .page_size(PAGE_LIMIT) .send()?; let short = batch.len() < PAGE_LIMIT as usize; active_heads.extend( batch .iter() .filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())), ); if short { break; } } let today = OffsetDateTime::now_utc().date(); let mut stale: Vec = Vec::new(); for br in &branches { let branch_name = br.name.as_deref().unwrap_or(""); if branch_name.is_empty() || active_heads.contains(branch_name) { continue; } let Some(ts) = br.commit.as_ref().and_then(|c| c.timestamp) else { continue; }; // Whole days between the commit's calendar date and today — // date-granular, matching the old YYYY-MM-DD prefix math. let age_days = (today - ts.date()).whole_days(); if age_days >= args.days { stale.push(json!({ "name": branch_name, "last_commit": rfc3339(Some(ts)), "age_days": age_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 items = fetch_issues(client, None, IssueListIssuesQueryState::Open)?; let mut by_user: BTreeMap> = BTreeMap::new(); for it in &items { let assignees = logins(it.assignees.as_deref()); if assignees.is_empty() { continue; } let slim = trim_issue(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 ───────────────────── /// Assignee logins from a typed user list (missing logins dropped). fn logins(users: Option<&[forgejo_api::structs::User]>) -> Vec { users .unwrap_or_default() .iter() .filter_map(|u| u.login.clone()) .collect() } /// Strip a Forgejo issue/PR down to the fields lint output cares /// about. Mirrors the trim pattern in `verbs/issue.rs`. fn trim_issue(it: &Issue) -> Value { json!({ "number": it.number, "title": it.title, "state": it.state, "url": it.html_url, // The unified issues endpoint marks PRs with a `pull_request` // object (absent/null for plain issues). "is_pr": it.pull_request.is_some(), "assignees": logins(it.assignees.as_deref()), }) } 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(()) } }