//! `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, PullRequest, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, StateType, }; 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, each with its /// merge outcome (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` /// (for example `--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 `/` (for example `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; } } // One pass over every PR, any state, doing double duty: an open // head isn't "stale", it's "in review" (skip-list, as before), and // a closed one tells a surviving stale branch its actual fate — // merged (branch is a leftover copy, safe to delete) vs. closed // unmerged / never had a PR (the branch is the only copy). hyperhive // merges via rebase + fast-forward: a branch needing an actual // rebase gets its commits replayed with new SHAs before main moves, // so its original tip is usually no longer an ancestor — though a // branch that needed no rebase (already current) still is. Ancestry // therefore answers inconsistently, which is why this map exists. // Whether the walk below hit `MAX_PAGES` without ever seeing a // short page — i.e. there are more PRs than the cap fetched, so // `latest_pr_by_head` is missing an unknown number of the oldest // ones. Distinct from "no PR was found": that's a claim this walk // can only make honestly when it actually saw everything. let mut prs_truncated = false; let mut prs = Vec::new(); for page in 1..=MAX_PAGES { let query = RepoListPullRequestsQuery { state: Some(RepoListPullRequestsQueryState::All), ..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; prs.extend(batch); if short { break; } if page == MAX_PAGES { prs_truncated = true; } } let mut active_heads: std::collections::HashSet = std::collections::HashSet::new(); // Highest-numbered (most recent) PR per head branch, in case a // branch name was reused across more than one PR over its life. let mut latest_pr_by_head: std::collections::HashMap = std::collections::HashMap::new(); for pr in &prs { let Some(head_ref) = pr.head.as_ref().and_then(|h| h.r#ref.clone()) else { continue; }; if pr.state == Some(StateType::Open) { active_heads.insert(head_ref.clone()); } let is_newer = latest_pr_by_head .get(&head_ref) .is_none_or(|existing| pr.number.unwrap_or(0) > existing.number.unwrap_or(0)); if is_newer { latest_pr_by_head.insert(head_ref, pr); } } 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 { // A branch surviving to here can only have a *closed* PR // (an open one would already be in `active_heads`), so // `merged` is unambiguous when a PR exists at all. But // "no PR found" is only true when the PR walk above wasn't // truncated — otherwise this branch's PR may simply be // older than the page cap reached, and reporting "no PR" // would misrepresent an unknown as a verified negative. let pr = latest_pr_by_head.get(branch_name); let outcome_unknown = pr.is_none() && prs_truncated; stale.push(json!({ "name": branch_name, "last_commit": rfc3339(Some(ts)), "age_days": age_days, "pr": pr.and_then(|p| p.number), "merged": pr.and_then(|p| p.merged), "outcome_unknown": outcome_unknown, })); } } 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); let verdict = match ( it.get("pr").and_then(Value::as_i64), it.get("merged").and_then(Value::as_bool), it.get("outcome_unknown") .and_then(Value::as_bool) .unwrap_or(false), ) { (Some(pr), Some(true), _) => format!("PR #{pr} merged"), (Some(pr), Some(false), _) => format!("PR #{pr} closed, not merged"), (_, _, true) => "unknown (PR history too large to fully scan)".to_string(), _ => "no PR".to_string(), }; format!("{name} ({age}d) — {verdict}") }) } // ───────────────────────── 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(()) } } #[cfg(test)] mod tests { use super::*; /// Every field on the forgejo structs is `Option`, so a fixture names /// only what a case is about. Going through `from_value` rather than a /// struct literal means these exercise the same deserialisation a real /// API response does — a wire-shape change breaks the tests too. fn issue(v: Value) -> Issue { serde_json::from_value(v).expect("fixture is a valid Issue") } fn pull(v: Value) -> PullRequest { serde_json::from_value(v).expect("fixture is a valid PullRequest") } /// `exclusive` is Forgejo's real scoped-label marker. A label that /// merely *looks* scoped (a `/` in a plain label's name) must not /// count, or `lint unlabeled` silently stops reporting an item that /// genuinely has no `type/*`. #[test] fn only_an_exclusive_label_counts_as_scoped() { let scoped = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": true }] })); assert!(has_scoped_label(&scoped, "area/")); let lookalike = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": false }] })); assert!( !has_scoped_label(&lookalike, "area/"), "a non-exclusive label with a slash in it is not a scoped label" ); let unset = issue(json!({ "labels": [{ "name": "area/ops" }] })); assert!( !has_scoped_label(&unset, "area/"), "absent means not exclusive" ); } /// The caller builds the prefix as `"/"`, which is what stops /// `--scope area` from matching a label called `areaology`. #[test] fn the_scope_prefix_stops_at_the_slash() { let other = issue(json!({ "labels": [{ "name": "areaology", "exclusive": true }] })); assert!(!has_scoped_label(&other, "area/")); let wrong_scope = issue(json!({ "labels": [{ "name": "type/bug", "exclusive": true }] })); assert!(!has_scoped_label(&wrong_scope, "area/")); assert!(has_scoped_label(&wrong_scope, "type/")); } #[test] fn an_item_with_no_labels_has_no_scoped_label() { assert!(!has_scoped_label(&issue(json!({ "labels": [] })), "area/")); assert!(!has_scoped_label(&issue(json!({})), "area/")); } /// `--reviewer NAME` and a bare `no-reviewer` are different questions: /// "is X on it" versus "is anyone on it". #[test] fn requested_reviewer_asks_a_different_question_with_and_without_a_name() { let pr = pull(json!({ "requested_reviewers": [{ "login": "argus" }] })); assert!(has_requested_reviewer(&pr, None), "someone is requested"); assert!(has_requested_reviewer(&pr, Some("argus"))); assert!(!has_requested_reviewer(&pr, Some("iris"))); let bare = pull(json!({ "requested_reviewers": [] })); assert!(!has_requested_reviewer(&bare, None)); assert!(!has_requested_reviewer(&bare, Some("argus"))); // `null`, not an omitted key: `requested_reviewers` carries a // `deserialize_with`, so serde requires it to be present even though // its type is `Option`. The forge sends the key with a null value. let absent = pull(json!({ "requested_reviewers": null })); assert!( !has_requested_reviewer(&absent, None), "null is not a reviewer" ); } #[test] fn logins_drops_users_that_have_none() { let users: Vec = serde_json::from_value(json!([{ "login": "atlas" }, {}, { "login": "mara" }])) .expect("fixture"); assert_eq!(logins(Some(&users)), vec!["atlas", "mara"]); assert!(logins(None).is_empty()); } /// Issues and PRs come back from the *same* endpoint; the only thing /// telling them apart is whether `pull_request` is present. Getting /// this wrong mislabels every row `lint` prints. #[test] fn is_pr_is_decided_by_the_pull_request_object() { let plain = trim_issue(&issue(json!({ "number": 7, "title": "t" }))); assert_eq!(plain["is_pr"], json!(false)); assert_eq!(kind_label(&plain), "issue"); let pr = trim_issue(&issue(json!({ "number": 8, "title": "t", "pull_request": { "merged": false }, }))); assert_eq!(pr["is_pr"], json!(true)); assert_eq!(kind_label(&pr), "pr"); } #[test] fn trim_issue_keeps_the_fields_the_output_prints() { let v = trim_issue(&issue(json!({ "number": 42, "title": "a title", "assignees": [{ "login": "atlas" }, { "login": "argus" }], }))); assert_eq!(num(&v), 42); assert_eq!(title(&v), "a title"); assert_eq!(v["assignees"], json!(["atlas", "argus"])); } #[test] fn accessors_survive_a_row_that_is_missing_everything() { let empty = json!({}); assert_eq!(num(&empty), 0); assert_eq!(title(&empty), ""); assert_eq!(kind_label(&empty), "issue"); } /// `All` is **absence** of the filter, not a third value — the forge /// returns both kinds when `type` is omitted. Turning it into a value /// would silently narrow every `--type all` query. #[test] fn the_both_kinds_filter_is_an_absent_query_param() { assert!(Kind::All.query_type().is_none()); assert!(matches!( Kind::Issues.query_type(), Some(IssueListIssuesQueryType::Issues) )); assert!(matches!( Kind::Pulls.query_type(), Some(IssueListIssuesQueryType::Pulls) )); } #[test] fn state_maps_onto_both_query_enums() { assert!(matches!( State::Open.issue_state(), IssueListIssuesQueryState::Open )); assert!(matches!( State::Closed.issue_state(), IssueListIssuesQueryState::Closed )); assert!(matches!( State::All.issue_state(), IssueListIssuesQueryState::All )); assert!(matches!( State::Open.pull_state(), RepoListPullRequestsQueryState::Open )); assert!(matches!( State::Closed.pull_state(), RepoListPullRequestsQueryState::Closed )); assert!(matches!( State::All.pull_state(), RepoListPullRequestsQueryState::All )); } }