Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a4e380c9 | ||
|
|
b8eb598329 | ||
|
|
fa73cd1ddb | ||
|
|
6e05863f02 |
3 changed files with 78 additions and 16 deletions
|
|
@ -30,7 +30,13 @@ dimension directly.
|
||||||
neglected owner.
|
neglected owner.
|
||||||
- **Stale branches** - `hive-forge lint stale-branches --days <n>`
|
- **Stale branches** - `hive-forge lint stale-branches --days <n>`
|
||||||
finds remote branches with no recent commits (skips branches that are
|
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
|
## Using them as a sweep
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 unassigned # open issues/PRs with no assignee
|
||||||
hive-forge lint no-reviewer # PRs with zero formally requested reviewers
|
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 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 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 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)
|
hive-forge pr-status --pr 42 # PR health: mergeable, CI, reviews, last comment (exit 0 = ready)
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ use std::collections::BTreeMap;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||||||
use forgejo_api::structs::{
|
use forgejo_api::structs::{
|
||||||
Issue, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
Issue, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, PullRequest,
|
||||||
RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
RepoListPullRequestsQuery, RepoListPullRequestsQueryState, StateType,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
@ -43,8 +43,8 @@ enum Sub {
|
||||||
Unassigned(UnassignedArgs),
|
Unassigned(UnassignedArgs),
|
||||||
/// List PRs with no formally requested reviewer.
|
/// List PRs with no formally requested reviewer.
|
||||||
NoReviewer(NoReviewerArgs),
|
NoReviewer(NoReviewerArgs),
|
||||||
/// List remote branches with no commits in N days.
|
/// List remote branches with no commits in N days, each with its
|
||||||
/// Skips branches that are heads of open PRs.
|
/// merge outcome (skips branches that are heads of open PRs).
|
||||||
StaleBranches(StaleBranchesArgs),
|
StaleBranches(StaleBranchesArgs),
|
||||||
/// Group open issues + PRs by assignee.
|
/// Group open issues + PRs by assignee.
|
||||||
Assignments(AssignmentsArgs),
|
Assignments(AssignmentsArgs),
|
||||||
|
|
@ -281,12 +281,26 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Collect active PR head refs to skip — a branch with an open PR
|
// One pass over every PR, any state, doing double duty: an open
|
||||||
// isn't "stale", it's "in review".
|
// head isn't "stale", it's "in review" (skip-list, as before), and
|
||||||
let mut active_heads: std::collections::HashSet<String> = std::collections::HashSet::new();
|
// 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 {
|
for page in 1..=MAX_PAGES {
|
||||||
let query = RepoListPullRequestsQuery {
|
let query = RepoListPullRequestsQuery {
|
||||||
state: Some(RepoListPullRequestsQueryState::Open),
|
state: Some(RepoListPullRequestsQueryState::All),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let (_, batch) = client
|
let (_, batch) = client
|
||||||
|
|
@ -296,14 +310,32 @@ fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||||||
.page_size(PAGE_LIMIT)
|
.page_size(PAGE_LIMIT)
|
||||||
.send()?;
|
.send()?;
|
||||||
let short = batch.len() < PAGE_LIMIT as usize;
|
let short = batch.len() < PAGE_LIMIT as usize;
|
||||||
active_heads.extend(
|
prs.extend(batch);
|
||||||
batch
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| p.head.as_ref().and_then(|h| h.r#ref.clone())),
|
|
||||||
);
|
|
||||||
if short {
|
if short {
|
||||||
break;
|
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();
|
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.
|
// date-granular, matching the old YYYY-MM-DD prefix math.
|
||||||
let age_days = (today - ts.date()).whole_days();
|
let age_days = (today - ts.date()).whole_days();
|
||||||
if age_days >= args.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!({
|
stale.push(json!({
|
||||||
"name": branch_name,
|
"name": branch_name,
|
||||||
"last_commit": rfc3339(Some(ts)),
|
"last_commit": rfc3339(Some(ts)),
|
||||||
"age_days": age_days,
|
"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| {
|
emit(client, &stale, |it| {
|
||||||
let name = it.get("name").and_then(Value::as_str).unwrap_or("?");
|
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);
|
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}")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue