hive-forge: add lint unlabeled --scope subcommand

This commit is contained in:
damocles 2026-07-26 16:12:23 +02:00 committed by mara
commit c0c8e2a2d6
3 changed files with 54 additions and 1 deletions

View file

@ -70,6 +70,7 @@ hive-forge lint unassigned # open issues/PRs with no assignee
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)
hive-forge pr-status --pr 42 # PR health: mergeable, CI, reviews, last comment (exit 0 = ready)
hive-forge pr-status --sha <sha> # CI-only fast path for an explicit commit sha
hive-forge pr-merge 42 # merge (refuses unless mergeable + CI not red + no changes-requested); deletes head branch

View file

@ -117,7 +117,7 @@ enum Verb {
RepoLabels(verbs::repo_labels::Args),
/// Search the forge for repositories by keyword, topic, or description.
RepoSearch(verbs::repo_search::Args),
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments / unlabeled).
Lint(verbs::lint::Args),
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
/// `--author`, `--label`, `--limit`). Pretty rows by default; pass

View file

@ -8,6 +8,7 @@
//! - `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]`
use std::collections::BTreeMap;
@ -47,6 +48,11 @@ enum Sub {
StaleBranches(StaleBranchesArgs),
/// Group open issues + PRs by assignee.
Assignments(AssignmentsArgs),
/// List issues/PRs with no exclusive scoped label in `--scope`
/// (e.g. `--scope type` flags items missing any `type/*` label).
/// Generic — the scope is whatever the repo's label taxonomy
/// actually uses, nothing hardcoded here.
Unlabeled(UnlabeledArgs),
}
#[derive(Copy, Clone, ValueEnum)]
@ -129,12 +135,29 @@ struct AssignmentsArgs {
user: Option<String>,
}
#[derive(ClapArgs)]
struct UnlabeledArgs {
/// Label scope to check for — the part of a scoped label's name
/// before the `/` (e.g. `type` for `type/bug`, `type/feature`).
/// Required: this command has no built-in notion of a repo's label
/// taxonomy, so there's no sane default to fall back to.
#[arg(long)]
scope: String,
/// Filter by item kind.
#[arg(long, value_enum, default_value_t = Kind::All)]
r#type: Kind,
/// Filter by item state.
#[arg(long, value_enum, default_value_t = State::Open)]
state: State,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
match args.sub {
Sub::Unassigned(a) => run_unassigned(client, a),
Sub::NoReviewer(a) => run_no_reviewer(client, a),
Sub::StaleBranches(a) => run_stale_branches(client, a),
Sub::Assignments(a) => run_assignments(client, a),
Sub::Unlabeled(a) => run_unlabeled(client, a),
}
}
@ -370,6 +393,35 @@ fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
}
}
// ───────────────────────── unlabeled ──────────────────────────
fn run_unlabeled(client: &Client, args: UnlabeledArgs) -> Result<()> {
if args.scope.trim().is_empty() {
bail!("hive-forge lint unlabeled: --scope must not be empty");
}
let prefix = format!("{}/", args.scope);
let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?;
let filtered: Vec<Value> = items
.iter()
.filter(|it| !has_scoped_label(it, &prefix))
.map(trim_issue)
.collect();
emit(client, &filtered, |it| {
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
})
}
/// True if `it` carries an *exclusive* scoped label whose name starts
/// with `prefix` (e.g. `"type/"`). Exclusivity is Forgejo's actual
/// scoped-label marker — a plain label that merely happens to contain
/// a `/` in its name doesn't count, so this can't misfire on an
/// unrelated label naming convention.
fn has_scoped_label(it: &Issue, prefix: &str) -> bool {
it.labels.as_deref().unwrap_or_default().iter().any(|l| {
l.exclusive == Some(true) && l.name.as_deref().is_some_and(|n| n.starts_with(prefix))
})
}
// ───────────────────────── shared helpers ─────────────────────
/// Assignee logins from a typed user list (missing logins dropped).