//! `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]` //! - `unlabeled --scope NAME [--type issues|pulls|all] [--state open|closed|all]` use std::collections::BTreeMap; use anyhow::{Result, bail}; use clap::{Args as ClapArgs, Subcommand, ValueEnum}; use forgejo_api::structs::{ Issue, 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 formally requested reviewer. 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), /// List issues/PRs with no exclusive scoped label in `--scope` /// (e.g. `--scope type` flags items missing any `type/*` label). /// Generic — the scope is whatever the repo's label taxonomy /// actually uses, nothing hardcoded here. Unlabeled(UnlabeledArgs), } #[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 check for. Omit to flag any PR with zero /// formally requested reviewers; pass it to instead flag PRs where /// this specific login isn't among the requested reviewers. #[arg(long)] reviewer: Option, } #[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, } #[derive(ClapArgs)] struct UnlabeledArgs { /// Label scope to check for — the part of a scoped label's name /// before the `/` (e.g. `type` for `type/bug`, `type/feature`). /// Required: this command has no built-in notion of a repo's label /// taxonomy, so there's no sane default to fall back to. #[arg(long)] scope: String, /// 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, } 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), Sub::Unlabeled(a) => run_unlabeled(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 missing: Vec = pulls .iter() .filter(|pr| !has_requested_reviewer(pr, args.reviewer.as_deref())) .map(|pr| { json!({ "number": pr.number, "title": pr.title, "state": pr.state, "url": pr.html_url, "is_pr": true, "assignees": logins(pr.assignees.as_deref()), }) }) .collect(); emit(client, &missing, |it| format!("#{} {}", num(it), title(it))) } /// True if the PR's *formal* `requested_reviewers` list (Forgejo's own /// review-request state, set by `pr assign-reviewer` — not a text /// `@name` mention anywhere in the body/comments) already satisfies the /// check: with `wanted` set, that specific login must be among the /// requested reviewers; with `wanted` absent, any requested reviewer at /// all counts. fn has_requested_reviewer(pr: &forgejo_api::structs::PullRequest, wanted: Option<&str>) -> bool { let requested = pr.requested_reviewers.as_deref().unwrap_or_default(); match wanted { Some(login) => requested.iter().any(|u| u.login.as_deref() == Some(login)), None => !requested.is_empty(), } } // ─────────────────────── 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(()) } } // ───────────────────────── unlabeled ────────────────────────── fn run_unlabeled(client: &Client, args: UnlabeledArgs) -> Result<()> { if args.scope.trim().is_empty() { bail!("hive-forge lint unlabeled: --scope must not be empty"); } let prefix = format!("{}/", args.scope); let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?; let filtered: Vec = items .iter() .filter(|it| !has_scoped_label(it, &prefix)) .map(trim_issue) .collect(); emit(client, &filtered, |it| { format!("#{} [{}] {}", num(it), kind_label(it), title(it)) }) } /// True if `it` carries an *exclusive* scoped label whose name starts /// with `prefix` (e.g. `"type/"`). Exclusivity is Forgejo's actual /// scoped-label marker — a plain label that merely happens to contain /// a `/` in its name doesn't count, so this can't misfire on an /// unrelated label naming convention. fn has_scoped_label(it: &Issue, prefix: &str) -> bool { it.labels.as_deref().unwrap_or_default().iter().any(|l| { l.exclusive == Some(true) && l.name.as_deref().is_some_and(|n| n.starts_with(prefix)) }) } // ───────────────────────── 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(()) } }