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

434 lines
14 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]`
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<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 look for (matches `@<reviewer>` 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<String>,
}
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<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 needle = format!("@{}", args.reviewer);
let mut missing: Vec<Value> = 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<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(())
}
}
// ───────────────────────── 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(())
}
}