Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-forge/src/verbs/lint.rs

529 lines
19 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, 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`
/// (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;
}
}
// 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<String> = 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<String, &PullRequest> =
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<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 {
// 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<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(())
}
}