Compare commits

..
3 changed files with 42 additions and 79 deletions

View file

@ -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 <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 <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.
- **Stale branches** - `hive-forge lint stale-branches --days <n>`
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.

View file

@ -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)

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, 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 `@<reviewer>` in the PR body
/// or any comment).
#[arg(long)]
reviewer: Option<String>,
reviewer: String,
}
#[derive(ClapArgs)]
@ -229,35 +228,47 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
break;
}
}
let missing: Vec<Value> = pulls
.iter()
.filter(|pr| !has_requested_reviewer(pr, args.reviewer.as_deref()))
.map(|pr| {
json!({
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!({
"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 ───────────────────────