hyperhive/hive-forge/src/verbs/lint.rs
iris 17554ea563 fix: sync generated hive-forge CLI docs with their clap source strings
Same bug as the swarmctl/hivectl fix, a third instance argus's review
didn't name but nix/checks.nix's hive-forge-docs freshness check (same
pattern as hivectl-docs/swarmctl-docs) caught in CI: the earlier
Contractions/Foreign/Auto batches edited docs/tools/forge-cli.md
directly instead of the clap #[arg(...)]/doc-comment strings in
hive-forge/src/main.rs and hive-forge/src/verbs/*.rs.

Applied the same 13 wording changes to source that the earlier commits
already made to the generated .md, matched 1:1 against
'git diff origin/main HEAD -- docs/tools/forge-cli.md' rather than
guessed. Several source doc comments feed two rendered sections each
(e.g. reaction.rs's one Add-variant doc renders under both
'issue reaction add' and 'pr reaction add', since both subcommands
share the same enum) -- one source fix, two generated-doc fixes.

Regenerated from the now-fixed source and confirmed byte-identical to
what's already committed (diff exit 0) -- source and generated output
are back in sync, same as the swarmctl/hivectl fix.

cargo clippy -p hive-forge --all-targets -- -D warnings and
scripts/check-doc-refs.sh both clean.
2026-09-07 16:28:06 +02:00

697 lines
25 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`
/// (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<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 `/` (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<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(())
}
}
#[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 `"<scope>/"`, 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<forgejo_api::structs::User> =
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
));
}
}