hyperhive/hive-forge/src/verbs/lint.rs
damocles d59bfb899f hive-forge: drop boilerplate # Errors from pure-GET verbs (mara on #827, option A)
mara: 'those comments seem very redundant'. true — the 16 pure-GET
verbs all got the same 'transport error + stdout I/O' boilerplate,
which just restates the Result<()> contract that's trivially
derivable from the type.

dropped # Errors from: assign, branches, close, comment_show,
comments, diff, issue, labels, lint, list, milestone, pr,
pr_reviews, subscription, timeline, tree_sha, view (17 files).

kept on the 7 verbs that have a non-Forgejo failure surface worth
documenting:
- comment, comment_edit, issue_create, issue_edit — body input I/O
  via --body-file / stdin
- pr_create — body input + --push shellout to git
- attach::run_issue, attach::run_comment — explicit bail! on
  missing file

net: 23 verbs touched in the original PR → 17 trimmed back to
no-doc, 6 kept (with the 7th call being attach::run_comment in the
same file). 38 tests still pass.
2026-05-31 16:22:05 +02:00

451 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 (closes #505). 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 std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
use serde_json::{Value, json};
use crate::client::Client;
use crate::verbs::print_json;
/// 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 {
fn forgejo_type(self) -> &'static str {
match self {
Kind::Issues => "issues",
Kind::Pulls => "pulls",
Kind::All => "all",
}
}
}
#[derive(Copy, Clone, ValueEnum)]
enum State {
Open,
Closed,
All,
}
impl State {
fn as_str(self) -> &'static str {
match self {
State::Open => "open",
State::Closed => "closed",
State::All => "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
/// (mara's nit on #507).
#[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),
}
}
// ───────────────────────── unassigned ─────────────────────────
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
let repo = client.repo();
let items = client.get_json_all(
&format!(
"/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}",
args.r#type.forgejo_type(),
args.state.as_str()
),
MAX_PAGES,
)?;
let filtered: Vec<Value> = items
.into_iter()
.filter(|it| {
it.get("assignees")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty)
})
.map(trim_item)
.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 repo = client.repo();
// PR-only: `/repos/{repo}/pulls` doesn't return issues.
let pulls = client.get_json_all(
&format!(
"/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}",
args.state.as_str()
),
MAX_PAGES,
)?;
let needle = format!("@{}", args.reviewer);
let mut missing: Vec<Value> = Vec::new();
for pr in pulls {
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
if number == 0 {
continue;
}
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
// that already @reviewer in the description.
let body = pr.get("body").and_then(Value::as_str).unwrap_or("");
if body.contains(&needle) {
continue;
}
// Paginate so PRs with >50 comments don't yield false positives
// (argus nit on #507). Same 1000-comment ceiling as elsewhere.
let comments = client.get_json_all(
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
MAX_PAGES,
)?;
let mentioned = comments.iter().any(|c| {
c.get("body")
.and_then(Value::as_str)
.is_some_and(|body| body.contains(&needle))
});
if !mentioned {
missing.push(trim_item(pr));
}
}
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 repo = client.repo();
let branches = client.get_json_all(
&format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"),
MAX_PAGES,
)?;
// Collect active PR head refs to skip — a branch with an open PR
// isn't "stale", it's "in review".
let open_pulls = client.get_json_all(
&format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"),
MAX_PAGES,
)?;
let active_heads: std::collections::HashSet<String> = open_pulls
.iter()
.filter_map(|p| {
p.get("head")
.and_then(|h| h.get("ref"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.collect();
let cutoff_days = today_days_utc().context("compute today")? - args.days;
let mut stale: Vec<Value> = Vec::new();
for br in branches {
let name = br.get("name").and_then(Value::as_str).unwrap_or("");
if name.is_empty() || active_heads.contains(name) {
continue;
}
let ts = br
.get("commit")
.and_then(|c| c.get("timestamp"))
.and_then(Value::as_str)
.unwrap_or("");
let Some(date_str) = ts.get(..10) else {
continue;
};
let Some(days) = parse_yyyy_mm_dd_days(date_str) else {
continue;
};
if days <= cutoff_days {
stale.push(json!({
"name": name,
"last_commit": ts,
"age_days": (today_days_utc().unwrap_or(days) - 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 repo = client.repo();
let items = client.get_json_all(
&format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"),
MAX_PAGES,
)?;
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for it in items {
let assignees: Vec<String> = it
.get("assignees")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.get("login").and_then(Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
if assignees.is_empty() {
continue;
}
let slim = trim_item(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 ─────────────────────
/// Strip a Forgejo issue/PR JSON down to the fields lint output cares
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
fn trim_item(it: Value) -> Value {
json!({
"number": it.get("number"),
"title": it.get("title"),
"state": it.get("state"),
"url": it.get("html_url"),
// Forgejo's unified /issues endpoint always emits a
// `pull_request` key — `null` for plain issues, an object
// (with merged/url/etc.) for PRs. Treat any non-null as a PR.
"is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()),
"assignees": it
.get("assignees")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.get("login").cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default(),
})
}
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(())
}
}
// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ───
/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock).
fn today_days_utc() -> Result<i64> {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock before epoch")?
.as_secs();
// `as_secs()` returns u64; clamp into i64 (won't overflow until y2554).
Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX))
}
/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp)
/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse
/// failure rather than panicking — lint output stays best-effort.
fn parse_yyyy_mm_dd_days(stamp: &str) -> Option<i64> {
let bytes = stamp.as_bytes();
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
Some(days_from_civil(year, month, day))
}
/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since
/// 1970-01-01. Public-domain reference algorithm. Handles negative years.
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = i64::from(y - era * 400); // [0, 399]
let m = i64::from(month);
let d = i64::from(day);
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
i64::from(era) * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_is_day_zero() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn known_dates() {
// Hinnant reference values
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
assert_eq!(days_from_civil(2020, 2, 29), 18_321);
}
#[test]
fn parses_iso_prefix() {
assert_eq!(
parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"),
Some(18_321)
);
}
#[test]
fn rejects_bad_input() {
assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None);
assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None);
assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None);
}
}