From 6e05863f02b8d5ac5430a50e0d2013bbe306c309 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 28 Aug 2026 15:23:14 +0200 Subject: [PATCH 1/4] hive-forge: lint stale-branches reports each branch's merge outcome --- .../plugins/base/skills/forge-triage/SKILL.md | 5 +- docs/tools/forge.md | 2 +- hive-forge/src/verbs/lint.rs | 63 ++++++++++++++----- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md index 01b60fbb..ffc96883 100644 --- a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md +++ b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md @@ -30,7 +30,10 @@ dimension directly. 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. + 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 diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 6ec5581a..b7f7d3fd 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -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) diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index fffdb986..287de2f5 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -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 = 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 = 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 = + 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 = 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}") }) } From fa73cd1ddbfa6fd6b0961e85234d4c219fab4dc1 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 28 Aug 2026 15:37:07 +0200 Subject: [PATCH 2/4] hive-forge: flag stale-branches merge outcome as unknown when the PR fetch is truncated --- .../plugins/base/skills/forge-triage/SKILL.md | 5 +++- hive-forge/src/verbs/lint.rs | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md index ffc96883..a0a438cd 100644 --- a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md +++ b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md @@ -33,7 +33,10 @@ dimension directly. 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. + the verdict first. On a repo with enough PRs to exceed the verb's own + page cap, an old branch's outcome may come back "unknown" instead of + a verdict — that's not the same claim as "no PR", treat it as + "can't tell, don't delete" too. ## Using them as a sweep diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index 287de2f5..b85e1805 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -289,6 +289,12 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { // 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. + // Whether the walk below hit `MAX_PAGES` without ever seeing a + // short page — i.e. there are more PRs than the cap fetched, so + // `latest_pr_by_head` is missing an unknown number of the oldest + // ones. Distinct from "no PR was found": that's a claim this walk + // can only make honestly when it actually saw everything. + let mut prs_truncated = false; let mut prs = Vec::new(); for page in 1..=MAX_PAGES { let query = RepoListPullRequestsQuery { @@ -306,6 +312,9 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { if short { break; } + if page == MAX_PAGES { + prs_truncated = true; + } } let mut active_heads: std::collections::HashSet = std::collections::HashSet::new(); // Highest-numbered (most recent) PR per head branch, in case a @@ -343,14 +352,20 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { 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. + // `merged` is unambiguous when a PR exists at all. But + // "no PR found" is only true when the PR walk above wasn't + // truncated — otherwise this branch's PR may simply be + // older than the page cap reached, and reporting "no PR" + // would misrepresent an unknown as a verified negative. let pr = latest_pr_by_head.get(branch_name); + let outcome_unknown = pr.is_none() && prs_truncated; 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), + "outcome_unknown": outcome_unknown, })); } } @@ -360,9 +375,13 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { let verdict = match ( it.get("pr").and_then(Value::as_i64), it.get("merged").and_then(Value::as_bool), + it.get("outcome_unknown") + .and_then(Value::as_bool) + .unwrap_or(false), ) { - (Some(pr), Some(true)) => format!("PR #{pr} merged"), - (Some(pr), Some(false)) => format!("PR #{pr} closed, not merged"), + (Some(pr), Some(true), _) => format!("PR #{pr} merged"), + (Some(pr), Some(false), _) => format!("PR #{pr} closed, not merged"), + (_, _, true) => "unknown (PR history too large to fully scan)".to_string(), _ => "no PR".to_string(), }; format!("{name} ({age}d) — {verdict}") From b8eb598329594749f7b1086d607fe21a130b94be Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 28 Aug 2026 17:09:38 +0200 Subject: [PATCH 3/4] =?UTF-8?q?hive-forge:=20fix=20stale-branches=20commen?= =?UTF-8?q?t/docs=20=E2=80=94=20hyperhive=20rebase+ff=20merges,=20not=20sq?= =?UTF-8?q?uash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/base/skills/forge-triage/SKILL.md | 12 ++++++------ hive-forge/src/verbs/lint.rs | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md index a0a438cd..267f0d20 100644 --- a/claude-plugins/plugins/base/skills/forge-triage/SKILL.md +++ b/claude-plugins/plugins/base/skills/forge-triage/SKILL.md @@ -31,12 +31,12 @@ dimension directly. - **Stale branches** - `hive-forge lint stale-branches --days ` finds remote branches with no recent commits (skips branches that are 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. On a repo with enough PRs to exceed the verb's own - page cap, an old branch's outcome may come back "unknown" instead of - a verdict — that's not the same claim as "no PR", treat it as - "can't tell, don't delete" too. + closed unmerged / no PR at all) — hyperhive's rebase+ff merges mean + ancestry can't tell those apart, so don't delete a stale branch on the + age alone; check the verdict first. On a repo with enough PRs to + exceed the verb's own page cap, an old branch's outcome may come back + "unknown" instead of a verdict — that's not the same claim as "no PR", + treat it as "can't tell, don't delete" too. ## Using them as a sweep diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index b85e1805..00aee08c 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -286,9 +286,11 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { // 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. + // hyperhive merges via rebase + fast-forward, the branch's commits + // are replayed onto main's current tip as new commits before main + // moves — so the original branch tip's SHA is never an ancestor of + // main even once merged, and ancestry can't tell these apart; this + // is the only signal that can. // Whether the walk below hit `MAX_PAGES` without ever seeing a // short page — i.e. there are more PRs than the cap fetched, so // `latest_pr_by_head` is missing an unknown number of the oldest From f5a4e380c9206654d9fcab25fb13cb96c8ad4433 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 28 Aug 2026 17:13:23 +0200 Subject: [PATCH 4/4] hive-forge: precise wording on stale-branches rebase+ff ancestry claim --- hive-forge/src/verbs/lint.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index 00aee08c..86046482 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -285,12 +285,12 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> { // 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 merges via rebase + fast-forward, the branch's commits - // are replayed onto main's current tip as new commits before main - // moves — so the original branch tip's SHA is never an ancestor of - // main even once merged, and ancestry can't tell these apart; this - // is the only signal that can. + // unmerged / never had a PR (the branch is the only copy). hyperhive + // merges via rebase + fast-forward: a branch needing an actual + // rebase gets its commits replayed with new SHAs before main moves, + // so its original tip is usually no longer an ancestor — though a + // branch that needed no rebase (already current) still is. Ancestry + // therefore answers inconsistently, which is why this map exists. // Whether the walk below hit `MAX_PAGES` without ever seeing a // short page — i.e. there are more PRs than the cap fetched, so // `latest_pr_by_head` is missing an unknown number of the oldest