hive-forge: add lint unlabeled --scope subcommand
This commit is contained in:
parent
5c4a637941
commit
c0c8e2a2d6
3 changed files with 54 additions and 1 deletions
|
|
@ -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 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 stale-branches --days 14 # branches with no recent activity
|
||||||
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 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)
|
||||||
hive-forge pr-status --sha <sha> # CI-only fast path for an explicit commit sha
|
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
|
hive-forge pr-merge 42 # merge (refuses unless mergeable + CI not red + no changes-requested); deletes head branch
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ enum Verb {
|
||||||
RepoLabels(verbs::repo_labels::Args),
|
RepoLabels(verbs::repo_labels::Args),
|
||||||
/// Search the forge for repositories by keyword, topic, or description.
|
/// Search the forge for repositories by keyword, topic, or description.
|
||||||
RepoSearch(verbs::repo_search::Args),
|
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),
|
Lint(verbs::lint::Args),
|
||||||
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
||||||
/// `--author`, `--label`, `--limit`). Pretty rows by default; pass
|
/// `--author`, `--label`, `--limit`). Pretty rows by default; pass
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
//! - `no-reviewer --reviewer NAME [--state open|closed|all]`
|
//! - `no-reviewer --reviewer NAME [--state open|closed|all]`
|
||||||
//! - `stale-branches [--days N]`
|
//! - `stale-branches [--days N]`
|
||||||
//! - `assignments [--user NAME]`
|
//! - `assignments [--user NAME]`
|
||||||
|
//! - `unlabeled --scope NAME [--type issues|pulls|all] [--state open|closed|all]`
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
|
@ -47,6 +48,11 @@ enum Sub {
|
||||||
StaleBranches(StaleBranchesArgs),
|
StaleBranches(StaleBranchesArgs),
|
||||||
/// Group open issues + PRs by assignee.
|
/// Group open issues + PRs by assignee.
|
||||||
Assignments(AssignmentsArgs),
|
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)]
|
#[derive(Copy, Clone, ValueEnum)]
|
||||||
|
|
@ -129,12 +135,29 @@ struct AssignmentsArgs {
|
||||||
user: Option<String>,
|
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<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
match args.sub {
|
match args.sub {
|
||||||
Sub::Unassigned(a) => run_unassigned(client, a),
|
Sub::Unassigned(a) => run_unassigned(client, a),
|
||||||
Sub::NoReviewer(a) => run_no_reviewer(client, a),
|
Sub::NoReviewer(a) => run_no_reviewer(client, a),
|
||||||
Sub::StaleBranches(a) => run_stale_branches(client, a),
|
Sub::StaleBranches(a) => run_stale_branches(client, a),
|
||||||
Sub::Assignments(a) => run_assignments(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 ─────────────────────
|
// ───────────────────────── shared helpers ─────────────────────
|
||||||
|
|
||||||
/// Assignee logins from a typed user list (missing logins dropped).
|
/// Assignee logins from a typed user list (missing logins dropped).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue