Same bug as the swarmctl/hivectl fix, a third instance argus's review didn't name but nix/checks.nix's hive-forge-docs freshness check (same pattern as hivectl-docs/swarmctl-docs) caught in CI: the earlier Contractions/Foreign/Auto batches edited docs/tools/forge-cli.md directly instead of the clap #[arg(...)]/doc-comment strings in hive-forge/src/main.rs and hive-forge/src/verbs/*.rs. Applied the same 13 wording changes to source that the earlier commits already made to the generated .md, matched 1:1 against 'git diff origin/main HEAD -- docs/tools/forge-cli.md' rather than guessed. Several source doc comments feed two rendered sections each (e.g. reaction.rs's one Add-variant doc renders under both 'issue reaction add' and 'pr reaction add', since both subcommands share the same enum) -- one source fix, two generated-doc fixes. Regenerated from the now-fixed source and confirmed byte-identical to what's already committed (diff exit 0) -- source and generated output are back in sync, same as the swarmctl/hivectl fix. cargo clippy -p hive-forge --all-targets -- -D warnings and scripts/check-doc-refs.sh both clean.
697 lines
25 KiB
Rust
697 lines
25 KiB
Rust
//! `lint <subcommand>` — issue/PR/branch lint queries for triage
|
||
//! workflows. Replaces ad-hoc curl + jq filtering with
|
||
//! typed commands that always emit JSON via the global `--json`
|
||
//! (default is a compact one-line-per-item human shape).
|
||
//!
|
||
//! Sub-commands:
|
||
//! - `unassigned [--type issues|pulls|all] [--state open|closed|all]`
|
||
//! - `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;
|
||
|
||
use anyhow::{Result, bail};
|
||
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||
use forgejo_api::structs::{
|
||
Issue, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, PullRequest,
|
||
RepoListPullRequestsQuery, RepoListPullRequestsQueryState, StateType,
|
||
};
|
||
use serde_json::{Value, json};
|
||
use time::OffsetDateTime;
|
||
|
||
use crate::client::Client;
|
||
use crate::verbs::{print_json, rfc3339};
|
||
|
||
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
||
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
||
const MAX_PAGES: u32 = 20;
|
||
|
||
/// Page size on list endpoints (Forgejo caps at 50 by default).
|
||
const PAGE_LIMIT: u32 = 50;
|
||
|
||
#[derive(ClapArgs)]
|
||
pub struct Args {
|
||
#[command(subcommand)]
|
||
sub: Sub,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Sub {
|
||
/// List issues or PRs without an assignee.
|
||
Unassigned(UnassignedArgs),
|
||
/// List PRs with no formally requested reviewer.
|
||
NoReviewer(NoReviewerArgs),
|
||
/// 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),
|
||
/// List issues/PRs with no exclusive scoped label in `--scope`
|
||
/// (for example `--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)]
|
||
enum Kind {
|
||
Issues,
|
||
Pulls,
|
||
All,
|
||
}
|
||
|
||
impl Kind {
|
||
/// Forgejo's `type` filter: `issues` / `pulls`, or absent for the
|
||
/// both-kinds slice (the forge returns everything when `type` is
|
||
/// omitted).
|
||
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
||
match self {
|
||
Kind::Issues => Some(IssueListIssuesQueryType::Issues),
|
||
Kind::Pulls => Some(IssueListIssuesQueryType::Pulls),
|
||
Kind::All => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Copy, Clone, ValueEnum)]
|
||
enum State {
|
||
Open,
|
||
Closed,
|
||
All,
|
||
}
|
||
|
||
impl State {
|
||
fn issue_state(self) -> IssueListIssuesQueryState {
|
||
match self {
|
||
State::Open => IssueListIssuesQueryState::Open,
|
||
State::Closed => IssueListIssuesQueryState::Closed,
|
||
State::All => IssueListIssuesQueryState::All,
|
||
}
|
||
}
|
||
|
||
fn pull_state(self) -> RepoListPullRequestsQueryState {
|
||
match self {
|
||
State::Open => RepoListPullRequestsQueryState::Open,
|
||
State::Closed => RepoListPullRequestsQueryState::Closed,
|
||
State::All => RepoListPullRequestsQueryState::All,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(ClapArgs)]
|
||
struct UnassignedArgs {
|
||
/// 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,
|
||
}
|
||
|
||
#[derive(ClapArgs)]
|
||
struct NoReviewerArgs {
|
||
/// Filter by PR state.
|
||
#[arg(long, value_enum, default_value_t = State::Open)]
|
||
state: State,
|
||
/// Reviewer login to check for. Omit to flag any PR with zero
|
||
/// formally requested reviewers; pass it to instead flag PRs where
|
||
/// this specific login isn't among the requested reviewers.
|
||
#[arg(long)]
|
||
reviewer: Option<String>,
|
||
}
|
||
|
||
#[derive(ClapArgs)]
|
||
struct StaleBranchesArgs {
|
||
/// Threshold in days since the last commit.
|
||
#[arg(long, default_value_t = 14)]
|
||
days: i64,
|
||
}
|
||
|
||
#[derive(ClapArgs)]
|
||
struct AssignmentsArgs {
|
||
/// Restrict to a single user.
|
||
#[arg(long)]
|
||
user: Option<String>,
|
||
}
|
||
|
||
#[derive(ClapArgs)]
|
||
struct UnlabeledArgs {
|
||
/// Label scope to check for — the part of a scoped label's name
|
||
/// before the `/` (for example `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),
|
||
}
|
||
}
|
||
|
||
/// Drain the repo's issue list (both kinds unless filtered) across
|
||
/// pages, up to the runaway cap.
|
||
fn fetch_issues(
|
||
client: &Client,
|
||
r#type: Option<IssueListIssuesQueryType>,
|
||
state: IssueListIssuesQueryState,
|
||
) -> Result<Vec<Issue>> {
|
||
let (owner, name) = client.owner_repo()?;
|
||
let mut items = Vec::new();
|
||
for page in 1..=MAX_PAGES {
|
||
let query = IssueListIssuesQuery {
|
||
state: Some(state),
|
||
r#type,
|
||
..Default::default()
|
||
};
|
||
let (_, batch) = client
|
||
.api()
|
||
.issue_list_issues(owner, name, query)
|
||
.page(page)
|
||
.page_size(PAGE_LIMIT)
|
||
.send()?;
|
||
let short = batch.len() < PAGE_LIMIT as usize;
|
||
items.extend(batch);
|
||
if short {
|
||
break;
|
||
}
|
||
}
|
||
Ok(items)
|
||
}
|
||
|
||
// ───────────────────────── unassigned ─────────────────────────
|
||
|
||
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||
let items = fetch_issues(client, args.r#type.query_type(), args.state.issue_state())?;
|
||
let filtered: Vec<Value> = items
|
||
.iter()
|
||
.filter(|it| it.assignees.as_ref().is_none_or(Vec::is_empty))
|
||
.map(trim_issue)
|
||
.collect();
|
||
emit(client, &filtered, |it| {
|
||
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
||
})
|
||
}
|
||
|
||
// ───────────────────────── no-reviewer ────────────────────────
|
||
|
||
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||
let (owner, name) = client.owner_repo()?;
|
||
// PR-only: the pulls endpoint doesn't return issues.
|
||
let mut pulls = Vec::new();
|
||
for page in 1..=MAX_PAGES {
|
||
let query = RepoListPullRequestsQuery {
|
||
state: Some(args.state.pull_state()),
|
||
..Default::default()
|
||
};
|
||
let (_, batch) = client
|
||
.api()
|
||
.repo_list_pull_requests(owner, name, query)
|
||
.page(page)
|
||
.page_size(PAGE_LIMIT)
|
||
.send()?;
|
||
let short = batch.len() < PAGE_LIMIT as usize;
|
||
pulls.extend(batch);
|
||
if short {
|
||
break;
|
||
}
|
||
}
|
||
let missing: Vec<Value> = pulls
|
||
.iter()
|
||
.filter(|pr| !has_requested_reviewer(pr, args.reviewer.as_deref()))
|
||
.map(|pr| {
|
||
json!({
|
||
"number": pr.number,
|
||
"title": pr.title,
|
||
"state": pr.state,
|
||
"url": pr.html_url,
|
||
"is_pr": true,
|
||
"assignees": logins(pr.assignees.as_deref()),
|
||
})
|
||
})
|
||
.collect();
|
||
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
||
}
|
||
|
||
/// True if the PR's *formal* `requested_reviewers` list (Forgejo's own
|
||
/// review-request state, set by `pr assign-reviewer` — not a text
|
||
/// `@name` mention anywhere in the body/comments) already satisfies the
|
||
/// check: with `wanted` set, that specific login must be among the
|
||
/// requested reviewers; with `wanted` absent, any requested reviewer at
|
||
/// all counts.
|
||
fn has_requested_reviewer(pr: &forgejo_api::structs::PullRequest, wanted: Option<&str>) -> bool {
|
||
let requested = pr.requested_reviewers.as_deref().unwrap_or_default();
|
||
match wanted {
|
||
Some(login) => requested.iter().any(|u| u.login.as_deref() == Some(login)),
|
||
None => !requested.is_empty(),
|
||
}
|
||
}
|
||
|
||
// ─────────────────────── stale-branches ───────────────────────
|
||
|
||
fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||
if args.days < 0 {
|
||
bail!("--days must be non-negative");
|
||
}
|
||
let (owner, name) = client.owner_repo()?;
|
||
let mut branches = Vec::new();
|
||
for page in 1..=MAX_PAGES {
|
||
let (_, batch) = client
|
||
.api()
|
||
.repo_list_branches(owner, name)
|
||
.page(page)
|
||
.page_size(PAGE_LIMIT)
|
||
.send()?;
|
||
let short = batch.len() < PAGE_LIMIT as usize;
|
||
branches.extend(batch);
|
||
if short {
|
||
break;
|
||
}
|
||
}
|
||
// 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::All),
|
||
..Default::default()
|
||
};
|
||
let (_, batch) = client
|
||
.api()
|
||
.repo_list_pull_requests(owner, name, query)
|
||
.page(page)
|
||
.page_size(PAGE_LIMIT)
|
||
.send()?;
|
||
let short = batch.len() < PAGE_LIMIT as usize;
|
||
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();
|
||
let mut stale: Vec<Value> = Vec::new();
|
||
for br in &branches {
|
||
let branch_name = br.name.as_deref().unwrap_or("");
|
||
if branch_name.is_empty() || active_heads.contains(branch_name) {
|
||
continue;
|
||
}
|
||
let Some(ts) = br.commit.as_ref().and_then(|c| c.timestamp) else {
|
||
continue;
|
||
};
|
||
// Whole days between the commit's calendar date and today —
|
||
// 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);
|
||
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}")
|
||
})
|
||
}
|
||
|
||
// ───────────────────────── assignments ────────────────────────
|
||
|
||
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||
let items = fetch_issues(client, None, IssueListIssuesQueryState::Open)?;
|
||
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||
for it in &items {
|
||
let assignees = logins(it.assignees.as_deref());
|
||
if assignees.is_empty() {
|
||
continue;
|
||
}
|
||
let slim = trim_issue(it);
|
||
for u in assignees {
|
||
if args.user.as_deref().is_some_and(|w| w != u) {
|
||
continue;
|
||
}
|
||
by_user.entry(u).or_default().push(slim.clone());
|
||
}
|
||
}
|
||
|
||
if client.json_mode() {
|
||
let out: Value = by_user
|
||
.into_iter()
|
||
.map(|(u, items)| {
|
||
(
|
||
u,
|
||
json!({
|
||
"count": items.len(),
|
||
"items": items,
|
||
}),
|
||
)
|
||
})
|
||
.collect::<serde_json::Map<_, _>>()
|
||
.into();
|
||
print_json(&out)
|
||
} else {
|
||
for (u, items) in &by_user {
|
||
println!("{u}: {} items", items.len());
|
||
for it in items.iter().take(3) {
|
||
println!(" #{} [{}] {}", num(it), kind_label(it), title(it));
|
||
}
|
||
if items.len() > 3 {
|
||
println!(" ... (+{} more)", items.len() - 3);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── 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).
|
||
fn logins(users: Option<&[forgejo_api::structs::User]>) -> Vec<String> {
|
||
users
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|u| u.login.clone())
|
||
.collect()
|
||
}
|
||
|
||
/// Strip a Forgejo issue/PR down to the fields lint output cares
|
||
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
||
fn trim_issue(it: &Issue) -> Value {
|
||
json!({
|
||
"number": it.number,
|
||
"title": it.title,
|
||
"state": it.state,
|
||
"url": it.html_url,
|
||
// The unified issues endpoint marks PRs with a `pull_request`
|
||
// object (absent/null for plain issues).
|
||
"is_pr": it.pull_request.is_some(),
|
||
"assignees": logins(it.assignees.as_deref()),
|
||
})
|
||
}
|
||
|
||
fn num(it: &Value) -> i64 {
|
||
it.get("number").and_then(Value::as_i64).unwrap_or(0)
|
||
}
|
||
|
||
fn title(it: &Value) -> &str {
|
||
it.get("title").and_then(Value::as_str).unwrap_or("")
|
||
}
|
||
|
||
fn kind_label(it: &Value) -> &'static str {
|
||
if it.get("is_pr").and_then(Value::as_bool).unwrap_or(false) {
|
||
"pr"
|
||
} else {
|
||
"issue"
|
||
}
|
||
}
|
||
|
||
/// Either pretty-print the JSON array (when --json) or fall back to a
|
||
/// caller-supplied human one-liner per item.
|
||
fn emit<F>(client: &Client, items: &[Value], fmt: F) -> Result<()>
|
||
where
|
||
F: Fn(&Value) -> String,
|
||
{
|
||
if client.json_mode() {
|
||
print_json(&Value::Array(items.to_vec()))
|
||
} else {
|
||
if items.is_empty() {
|
||
println!("(no matches)");
|
||
}
|
||
for it in items {
|
||
println!("{}", fmt(it));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Every field on the forgejo structs is `Option`, so a fixture names
|
||
/// only what a case is about. Going through `from_value` rather than a
|
||
/// struct literal means these exercise the same deserialisation a real
|
||
/// API response does — a wire-shape change breaks the tests too.
|
||
fn issue(v: Value) -> Issue {
|
||
serde_json::from_value(v).expect("fixture is a valid Issue")
|
||
}
|
||
|
||
fn pull(v: Value) -> PullRequest {
|
||
serde_json::from_value(v).expect("fixture is a valid PullRequest")
|
||
}
|
||
|
||
/// `exclusive` is Forgejo's real scoped-label marker. A label that
|
||
/// merely *looks* scoped (a `/` in a plain label's name) must not
|
||
/// count, or `lint unlabeled` silently stops reporting an item that
|
||
/// genuinely has no `type/*`.
|
||
#[test]
|
||
fn only_an_exclusive_label_counts_as_scoped() {
|
||
let scoped = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": true }] }));
|
||
assert!(has_scoped_label(&scoped, "area/"));
|
||
|
||
let lookalike = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": false }] }));
|
||
assert!(
|
||
!has_scoped_label(&lookalike, "area/"),
|
||
"a non-exclusive label with a slash in it is not a scoped label"
|
||
);
|
||
|
||
let unset = issue(json!({ "labels": [{ "name": "area/ops" }] }));
|
||
assert!(
|
||
!has_scoped_label(&unset, "area/"),
|
||
"absent means not exclusive"
|
||
);
|
||
}
|
||
|
||
/// The caller builds the prefix as `"<scope>/"`, which is what stops
|
||
/// `--scope area` from matching a label called `areaology`.
|
||
#[test]
|
||
fn the_scope_prefix_stops_at_the_slash() {
|
||
let other = issue(json!({ "labels": [{ "name": "areaology", "exclusive": true }] }));
|
||
assert!(!has_scoped_label(&other, "area/"));
|
||
|
||
let wrong_scope = issue(json!({ "labels": [{ "name": "type/bug", "exclusive": true }] }));
|
||
assert!(!has_scoped_label(&wrong_scope, "area/"));
|
||
assert!(has_scoped_label(&wrong_scope, "type/"));
|
||
}
|
||
|
||
#[test]
|
||
fn an_item_with_no_labels_has_no_scoped_label() {
|
||
assert!(!has_scoped_label(&issue(json!({ "labels": [] })), "area/"));
|
||
assert!(!has_scoped_label(&issue(json!({})), "area/"));
|
||
}
|
||
|
||
/// `--reviewer NAME` and a bare `no-reviewer` are different questions:
|
||
/// "is X on it" versus "is anyone on it".
|
||
#[test]
|
||
fn requested_reviewer_asks_a_different_question_with_and_without_a_name() {
|
||
let pr = pull(json!({ "requested_reviewers": [{ "login": "argus" }] }));
|
||
assert!(has_requested_reviewer(&pr, None), "someone is requested");
|
||
assert!(has_requested_reviewer(&pr, Some("argus")));
|
||
assert!(!has_requested_reviewer(&pr, Some("iris")));
|
||
|
||
let bare = pull(json!({ "requested_reviewers": [] }));
|
||
assert!(!has_requested_reviewer(&bare, None));
|
||
assert!(!has_requested_reviewer(&bare, Some("argus")));
|
||
|
||
// `null`, not an omitted key: `requested_reviewers` carries a
|
||
// `deserialize_with`, so serde requires it to be present even though
|
||
// its type is `Option`. The forge sends the key with a null value.
|
||
let absent = pull(json!({ "requested_reviewers": null }));
|
||
assert!(
|
||
!has_requested_reviewer(&absent, None),
|
||
"null is not a reviewer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn logins_drops_users_that_have_none() {
|
||
let users: Vec<forgejo_api::structs::User> =
|
||
serde_json::from_value(json!([{ "login": "atlas" }, {}, { "login": "mara" }]))
|
||
.expect("fixture");
|
||
assert_eq!(logins(Some(&users)), vec!["atlas", "mara"]);
|
||
assert!(logins(None).is_empty());
|
||
}
|
||
|
||
/// Issues and PRs come back from the *same* endpoint; the only thing
|
||
/// telling them apart is whether `pull_request` is present. Getting
|
||
/// this wrong mislabels every row `lint` prints.
|
||
#[test]
|
||
fn is_pr_is_decided_by_the_pull_request_object() {
|
||
let plain = trim_issue(&issue(json!({ "number": 7, "title": "t" })));
|
||
assert_eq!(plain["is_pr"], json!(false));
|
||
assert_eq!(kind_label(&plain), "issue");
|
||
|
||
let pr = trim_issue(&issue(json!({
|
||
"number": 8, "title": "t", "pull_request": { "merged": false },
|
||
})));
|
||
assert_eq!(pr["is_pr"], json!(true));
|
||
assert_eq!(kind_label(&pr), "pr");
|
||
}
|
||
|
||
#[test]
|
||
fn trim_issue_keeps_the_fields_the_output_prints() {
|
||
let v = trim_issue(&issue(json!({
|
||
"number": 42,
|
||
"title": "a title",
|
||
"assignees": [{ "login": "atlas" }, { "login": "argus" }],
|
||
})));
|
||
assert_eq!(num(&v), 42);
|
||
assert_eq!(title(&v), "a title");
|
||
assert_eq!(v["assignees"], json!(["atlas", "argus"]));
|
||
}
|
||
|
||
#[test]
|
||
fn accessors_survive_a_row_that_is_missing_everything() {
|
||
let empty = json!({});
|
||
assert_eq!(num(&empty), 0);
|
||
assert_eq!(title(&empty), "");
|
||
assert_eq!(kind_label(&empty), "issue");
|
||
}
|
||
|
||
/// `All` is **absence** of the filter, not a third value — the forge
|
||
/// returns both kinds when `type` is omitted. Turning it into a value
|
||
/// would silently narrow every `--type all` query.
|
||
#[test]
|
||
fn the_both_kinds_filter_is_an_absent_query_param() {
|
||
assert!(Kind::All.query_type().is_none());
|
||
assert!(matches!(
|
||
Kind::Issues.query_type(),
|
||
Some(IssueListIssuesQueryType::Issues)
|
||
));
|
||
assert!(matches!(
|
||
Kind::Pulls.query_type(),
|
||
Some(IssueListIssuesQueryType::Pulls)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn state_maps_onto_both_query_enums() {
|
||
assert!(matches!(
|
||
State::Open.issue_state(),
|
||
IssueListIssuesQueryState::Open
|
||
));
|
||
assert!(matches!(
|
||
State::Closed.issue_state(),
|
||
IssueListIssuesQueryState::Closed
|
||
));
|
||
assert!(matches!(
|
||
State::All.issue_state(),
|
||
IssueListIssuesQueryState::All
|
||
));
|
||
assert!(matches!(
|
||
State::Open.pull_state(),
|
||
RepoListPullRequestsQueryState::Open
|
||
));
|
||
assert!(matches!(
|
||
State::Closed.pull_state(),
|
||
RepoListPullRequestsQueryState::Closed
|
||
));
|
||
assert!(matches!(
|
||
State::All.pull_state(),
|
||
RepoListPullRequestsQueryState::All
|
||
));
|
||
}
|
||
}
|