diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md deleted file mode 100644 index 01b60fbb..00000000 --- a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: forge-triage -description: Verify triage coverage on a forge repo using the existing lint verbs instead of manually scanning issues/PRs - unassigned items, missing labels by scope, PRs with no reviewer, and stale branches each have a dedicated check. Use this whenever you want to confirm nothing has fallen through the cracks (new activity landed, a periodic sweep, before reporting "everything's triaged"). ---- - -# Forge Triage - -Checking triage coverage doesn't need a bespoke script or a manual scan -of the tracker - the forge CLI's `lint` verbs already answer each -dimension directly. - -## The checks - -- **Unassigned items** - open issues or PRs with no assignee: - `hive-forge lint unassigned` -- **Missing labels by scope** - items missing any label in a given - scope (e.g. every issue should carry a `type/*` label, or an `area/*` - one): `hive-forge lint unlabeled --scope `. The scope name is - 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 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. -- **Stale branches** - `hive-forge lint stale-branches --days ` - finds remote branches with no recent commits (skips branches that are - heads of open PRs), useful for spotting abandoned work. - -## Using them as a sweep - -Run the checks relevant to what you're verifying rather than assuming -one covers everything - "is triage caught up" usually means unassigned -+ unlabeled at minimum, with no-reviewer added if reviews matter for -your workflow. Each verb supports `--json` if you want to fold the -results into something else instead of reading the human-readable output. - -This is reactive/on-demand coverage checking, not a scheduled rollup or -historical trend - if you need to track compliance *over time* (was -triage worse last week than this week), that's a different, bigger -ask than running these checks - don't build that unprompted just -because the checks exist. diff --git a/docs/tools/forge.md b/docs/tools/forge.md index b9f83ea6..50aaaaa0 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -69,8 +69,7 @@ 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 # 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 no-reviewer --reviewer argus # PRs missing a reviewer comment from argus 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 fffdb986..c0e72077 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, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, - RepoListPullRequestsQuery, RepoListPullRequestsQueryState, + Issue, IssueGetCommentsQuery, 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 formally requested reviewer. + /// 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. @@ -115,11 +115,10 @@ 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. + /// Reviewer login to look for (matches `@` in the PR body + /// or any comment). #[arg(long)] - reviewer: Option, + reviewer: String, } #[derive(ClapArgs)] @@ -229,35 +228,47 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> { break; } } - let missing: Vec = pulls - .iter() - .filter(|pr| !has_requested_reviewer(pr, args.reviewer.as_deref())) - .map(|pr| { - json!({ + 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!({ "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(), + })); + } } + emit(client, &missing, |it| format!("#{} {}", num(it), title(it))) } // ─────────────────────── stale-branches ───────────────────────