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 whatever your repo's own label taxonomy uses - there's nothing
hardcoded, so check `repo-labels` first if you don't already know the hardcoded, so check `repo-labels` first if you don't already know the
scopes in use. scopes in use.
- **PRs with no reviewer engagement** - `hive-forge lint no-reviewer - **PRs with no formally requested reviewer** - `hive-forge lint
--reviewer <name>` flags PRs where the named reviewer hasn't left a no-reviewer` flags PRs with zero requested reviewers at all; add
formal review or mention. The reviewer name is per-repo/per-team, not `--reviewer <name>` to instead flag PRs where that specific person
a fixed value. 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 - **Assignment load** - `hive-forge lint assignments` groups open
issues + PRs by assignee, useful for spotting an overloaded or issues + PRs by assignee, useful for spotting an overloaded or
neglected owner. 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 tree-sha main # git tree SHA for a ref
hive-forge -r other-org/other-repo pr 7 # target a different repo 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 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 stale-branches --days 14 # branches with no recent activity
hive-forge lint assignments # per-assignee open item count 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) 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: //! Sub-commands:
//! - `unassigned [--type issues|pulls|all] [--state open|closed|all]` //! - `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]` //! - `stale-branches [--days N]`
//! - `assignments [--user NAME]` //! - `assignments [--user NAME]`
//! - `unlabeled --scope NAME [--type issues|pulls|all] [--state open|closed|all]` //! - `unlabeled --scope NAME [--type issues|pulls|all] [--state open|closed|all]`
@ -15,8 +15,8 @@ use std::collections::BTreeMap;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Args as ClapArgs, Subcommand, ValueEnum}; use clap::{Args as ClapArgs, Subcommand, ValueEnum};
use forgejo_api::structs::{ use forgejo_api::structs::{
Issue, IssueGetCommentsQuery, IssueListIssuesQuery, IssueListIssuesQueryState, Issue, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
IssueListIssuesQueryType, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use time::OffsetDateTime; use time::OffsetDateTime;
@ -41,7 +41,7 @@ pub struct Args {
enum Sub { enum Sub {
/// List issues or PRs without an assignee. /// List issues or PRs without an assignee.
Unassigned(UnassignedArgs), Unassigned(UnassignedArgs),
/// List PRs with no `@reviewer` mention in any comment. /// List PRs with no formally requested reviewer.
NoReviewer(NoReviewerArgs), NoReviewer(NoReviewerArgs),
/// List remote branches with no commits in N days. /// List remote branches with no commits in N days.
/// Skips branches that are heads of open PRs. /// Skips branches that are heads of open PRs.
@ -115,10 +115,11 @@ struct NoReviewerArgs {
/// Filter by PR state. /// Filter by PR state.
#[arg(long, value_enum, default_value_t = State::Open)] #[arg(long, value_enum, default_value_t = State::Open)]
state: State, state: State,
/// Reviewer login to look for (matches `@<reviewer>` in the PR body /// Reviewer login to check for. Omit to flag any PR with zero
/// or any comment). /// formally requested reviewers; pass it to instead flag PRs where
/// this specific login isn't among the requested reviewers.
#[arg(long)] #[arg(long)]
reviewer: String, reviewer: Option<String>,
} }
#[derive(ClapArgs)] #[derive(ClapArgs)]
@ -228,49 +229,37 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
break; break;
} }
} }
let needle = format!("@{}", args.reviewer); let missing: Vec<Value> = pulls
let mut missing: Vec<Value> = Vec::new(); .iter()
for pr in &pulls { .filter(|pr| !has_requested_reviewer(pr, args.reviewer.as_deref()))
let Some(number) = pr.number.filter(|n| *n > 0) else { .map(|pr| {
continue; json!({
};
// 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, "number": pr.number,
"title": pr.title, "title": pr.title,
"state": pr.state, "state": pr.state,
"url": pr.html_url, "url": pr.html_url,
"is_pr": true, "is_pr": true,
"assignees": logins(pr.assignees.as_deref()), "assignees": logins(pr.assignees.as_deref()),
})); })
} })
} .collect();
emit(client, &missing, |it| format!("#{} {}", num(it), title(it))) 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 ─────────────────────── // ─────────────────────── stale-branches ───────────────────────
fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {