diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index 86046482..4ba7a0d4 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -497,6 +497,166 @@ fn num(it: &Value) -> i64 { it.get("number").and_then(Value::as_i64).unwrap_or(0) } +#[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 `"/"`, 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 = + 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 + )); + } +} + fn title(it: &Value) -> &str { it.get("title").and_then(Value::as_str).unwrap_or("") }