hyperhive/hive-forge/src/verbs/lint.rs

473 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `lint <subcommand>` — 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<IssueListIssuesQueryType> {
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<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<String>,
}
#[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<IssueListIssuesQueryType>,
state: IssueListIssuesQueryState,
) -> Result<Vec<Issue>> {
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<Value> = 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<Value> = 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<String> = 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<Value> = 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<String, Vec<Value>> = 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::<serde_json::Map<_, _>>()
.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<Value> = 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<String> {
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<F>(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(())
}
}