Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
3 changed files with 78 additions and 16 deletions

View file

@ -30,7 +30,13 @@ 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) — 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

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,26 @@ 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). 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
// 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 {
state: Some(RepoListPullRequestsQueryState::Open),
state: Some(RepoListPullRequestsQueryState::All),
..Default::default()
};
let (_, batch) = client
@ -296,14 +310,32 @@ 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;
}
if page == MAX_PAGES {
prs_truncated = true;
}
}
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();
@ -320,17 +352,41 @@ 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. 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,
}));
}
}
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),
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"),
(_, _, true) => "unknown (PR history too large to fully scan)".to_string(),
_ => "no PR".to_string(),
};
format!("{name} ({age}d) — {verdict}")
})
}