From c0c8e2a2d6162664eb106e81f85d028e93f70c8f Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 26 Jul 2026 16:12:23 +0200 Subject: [PATCH] hive-forge: add lint unlabeled --scope subcommand --- docs/tools/forge.md | 1 + hive-forge/src/main.rs | 2 +- hive-forge/src/verbs/lint.rs | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/tools/forge.md b/docs/tools/forge.md index a7ce6138..145a5541 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -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 # 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 diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 3f9960f3..84e85d95 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -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 diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index 079fd253..c0e72077 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -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, } +#[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 = 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).