hive-forge: lint no-reviewer checks actual requested-reviewers, not a text mention

This commit is contained in:
damocles 2026-07-27 17:30:13 +02:00 committed by mara
commit 9b29c6a172
3 changed files with 38 additions and 46 deletions

View file

@ -19,10 +19,12 @@ dimension directly.
whatever your repo's own label taxonomy uses - there's nothing
hardcoded, so check `repo-labels` first if you don't already know the
scopes in use.
- **PRs with no reviewer engagement** - `hive-forge lint no-reviewer
--reviewer <name>` flags PRs where the named reviewer hasn't left a
formal review or mention. The reviewer name is per-repo/per-team, not
a fixed value.
- **PRs with no formally requested reviewer** - `hive-forge lint
no-reviewer` flags PRs with zero requested reviewers at all; add
`--reviewer <name>` to instead flag PRs where that specific person
isn't among the requested reviewers. This checks the forge's actual
reviewer-request state, not a text `@name` mention anywhere in the
thread.
- **Assignment load** - `hive-forge lint assignments` groups open
issues + PRs by assignee, useful for spotting an overloaded or
neglected owner.

View file

@ -69,7 +69,8 @@ hive-forge branches deployed/ # filter branches by pattern
hive-forge tree-sha main # git tree SHA for a ref
hive-forge -r other-org/other-repo pr 7 # target a different repo
hive-forge lint unassigned # open issues/PRs with no assignee
hive-forge lint no-reviewer --reviewer argus # PRs missing a reviewer comment from argus
hive-forge lint no-reviewer # PRs with zero formally requested reviewers
hive-forge lint no-reviewer --reviewer argus # PRs where argus specifically isn't a requested reviewer
hive-forge lint stale-branches --days 14 # branches with no recent activity
hive-forge lint assignments # per-assignee open item count
hive-forge lint unlabeled --scope type # open issues/PRs with no exclusive type/* label (any scope works, e.g. --scope area)

View file

@ -5,7 +5,7 @@
//!
//! Sub-commands:
//! - `unassigned [--type issues|pulls|all] [--state open|closed|all]`
//! - `no-reviewer --reviewer NAME [--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]`
@ -15,8 +15,8 @@ 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,
Issue, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
@ -41,7 +41,7 @@ pub struct Args {
enum Sub {
/// List issues or PRs without an assignee.
Unassigned(UnassignedArgs),
/// List PRs with no `@reviewer` mention in any comment.
/// 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.
@ -115,10 +115,11 @@ 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 the PR body
/// or any comment).
/// 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: String,
reviewer: Option<String>,
}
#[derive(ClapArgs)]
@ -228,49 +229,37 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
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!({
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<()> {