hive-forge: lint stale-branches reports each branch's merge outcome

This commit is contained in:
damocles 2026-08-28 15:23:14 +02:00 committed by mara
commit 6e05863f02
3 changed files with 54 additions and 16 deletions

View file

@ -30,7 +30,10 @@ dimension directly.
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.
heads of open PRs) and reports each one's merge outcome (PR merged /
closed unmerged / no PR at all) — squash-merge ancestry can't tell
those apart, so don't delete a stale branch on the age alone; check
the verdict first.
## Using them as a sweep

View file

@ -85,7 +85,7 @@ 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 stale-branches --days 14 # branches with no recent activity
hive-forge lint stale-branches --days 14 # branches with no recent activity, each with its merge outcome (PR #n merged / closed unmerged / no PR)
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 pr-status --pr 42 # PR health: mergeable, CI, reviews, last comment (exit 0 = ready)

View file

@ -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, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, PullRequest,
RepoListPullRequestsQuery, RepoListPullRequestsQueryState, StateType,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
@ -43,8 +43,8 @@ enum Sub {
Unassigned(UnassignedArgs),
/// 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.
/// List remote branches with no commits in N days, each with its
/// merge outcome (skips branches that are heads of open PRs).
StaleBranches(StaleBranchesArgs),
/// Group open issues + PRs by assignee.
Assignments(AssignmentsArgs),
@ -281,12 +281,18 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
break;
}
}
// Collect active PR head refs to skip — a branch with an open PR
// isn't "stale", it's "in review".
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
// One pass over every PR, any state, doing double duty: an open
// head isn't "stale", it's "in review" (skip-list, as before), and
// a closed one tells a surviving stale branch its actual fate —
// merged (branch is a leftover copy, safe to delete) vs. closed
// unmerged / never had a PR (the branch is the only copy). Because
// hyperhive squash-merges, a merged branch's tip is never an
// ancestor of main, so ancestry can't tell these apart; this is the
// only signal that can.
let mut prs = Vec::new();
for page in 1..=MAX_PAGES {
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
state: Some(RepoListPullRequestsQueryState::All),
..Default::default()
};
let (_, batch) = client
@ -296,15 +302,30 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
.page_size(PAGE_LIMIT)
.send()?;
let short = batch.len() < PAGE_LIMIT as usize;
active_heads.extend(
batch
.iter()
.filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())),
);
prs.extend(batch);
if short {
break;
}
}
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
// Highest-numbered (most recent) PR per head branch, in case a
// branch name was reused across more than one PR over its life.
let mut latest_pr_by_head: std::collections::HashMap<String, &PullRequest> =
std::collections::HashMap::new();
for pr in &prs {
let Some(head_ref) = pr.head.as_ref().and_then(|h| h.r#ref.clone()) else {
continue;
};
if pr.state == Some(StateType::Open) {
active_heads.insert(head_ref.clone());
}
let is_newer = latest_pr_by_head
.get(&head_ref)
.is_none_or(|existing| pr.number.unwrap_or(0) > existing.number.unwrap_or(0));
if is_newer {
latest_pr_by_head.insert(head_ref, pr);
}
}
let today = OffsetDateTime::now_utc().date();
let mut stale: Vec<Value> = Vec::new();
@ -320,17 +341,31 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
// date-granular, matching the old YYYY-MM-DD prefix math.
let age_days = (today - ts.date()).whole_days();
if age_days >= args.days {
// A branch surviving to here can only have a *closed* PR
// (an open one would already be in `active_heads`), so
// `merged` is unambiguous when a PR exists at all.
let pr = latest_pr_by_head.get(branch_name);
stale.push(json!({
"name": branch_name,
"last_commit": rfc3339(Some(ts)),
"age_days": age_days,
"pr": pr.and_then(|p| p.number),
"merged": pr.and_then(|p| p.merged),
}));
}
}
emit(client, &stale, |it| {
let name = it.get("name").and_then(Value::as_str).unwrap_or("?");
let age = it.get("age_days").and_then(Value::as_i64).unwrap_or(0);
format!("{name} ({age}d)")
let verdict = match (
it.get("pr").and_then(Value::as_i64),
it.get("merged").and_then(Value::as_bool),
) {
(Some(pr), Some(true)) => format!("PR #{pr} merged"),
(Some(pr), Some(false)) => format!("PR #{pr} closed, not merged"),
_ => "no PR".to_string(),
};
format!("{name} ({age}d) — {verdict}")
})
}