diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md index 4f42689e..01b60fbb 100644 --- a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md +++ b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md @@ -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 ` 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 ` 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. diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 50aaaaa0..b9f83ea6 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -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) diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index c0e72077..fffdb986 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -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 `@` 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, } #[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 = 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 = 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<()> {