diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md new file mode 100644 index 00000000..01b60fbb --- /dev/null +++ b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md @@ -0,0 +1,47 @@ +--- +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 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<()> {