use gwm::forge::{CheckOutcome, CiState, IssueState, PrState};
use gwm::gitlab;
use gwm::labels::LabelSpec;
use gwm::milestones::{MilestoneSpec, MilestoneState};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn clean_env() -> std::sync::MutexGuard<'static, ()> {
let guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
for v in [
"GITLAB_SUBFOLDER",
"CI_SERVER_URL",
"GLAB_ENABLE_CI_AUTOLOGIN",
"GITLAB_CI",
] {
std::env::remove_var(v);
}
}
guard
}
const ISSUE_JSON: &str = r#"{
"id": 76,
"iid": 42,
"project_id": 1,
"title": "TUI: fuzzy search",
"state": "opened",
"labels": ["feature", "tui"],
"updated_at": "2026-05-19T10:00:00.000Z",
"web_url": "https://gitlab.com/group/proj/-/issues/42"
}"#;
#[test]
fn parse_issue_json_maps_iid_not_id() {
let issue = gitlab::parse_issue_json(ISSUE_JSON).unwrap();
assert_eq!(issue.number, 42);
}
#[test]
fn parse_issue_json_maps_opened_to_open() {
let issue = gitlab::parse_issue_json(ISSUE_JSON).unwrap();
assert_eq!(issue.state, IssueState::Open);
assert_eq!(issue.title, "TUI: fuzzy search");
assert_eq!(issue.url, "https://gitlab.com/group/proj/-/issues/42");
assert_eq!(issue.updated_at, "2026-05-19T10:00:00.000Z");
}
#[test]
fn parse_issue_json_reads_labels_as_a_bare_string_array() {
let issue = gitlab::parse_issue_json(ISSUE_JSON).unwrap();
assert_eq!(issue.labels, vec!["feature", "tui"]);
}
#[test]
fn parse_issue_json_handles_closed() {
let json = r#"{"id":1,"iid":7,"title":"old","state":"closed","labels":[],
"updated_at":"2025-01-01T00:00:00Z","web_url":"https://gitlab.com/g/p/-/issues/7"}"#;
let issue = gitlab::parse_issue_json(json).unwrap();
assert_eq!(issue.state, IssueState::Closed);
}
#[test]
fn parse_issue_json_rejects_an_unknown_state() {
let json = r#"{"id":1,"iid":7,"title":"x","state":"quantum","labels":[],
"updated_at":"","web_url":""}"#;
let err = gitlab::parse_issue_json(json).unwrap_err();
assert!(err.to_string().contains("quantum"), "should name the state: {}", err);
}
const MR_JSON: &str = r#"{
"id": 155016530,
"iid": 61,
"title": "feat(tui): fuzzy search",
"state": "opened",
"draft": false,
"work_in_progress": false,
"web_url": "https://gitlab.com/group/proj/-/merge_requests/61",
"updated_at": "2026-05-19T10:00:00.000Z",
"source_branch": "feat/fuzzy",
"target_branch": "main",
"author": {"username": "alice", "name": "Alice"},
"head_pipeline": {
"id": 900,
"iid": 12,
"status": "success",
"web_url": "https://gitlab.com/group/proj/-/pipelines/900",
"created_at": "2026-05-19T09:00:00.000Z",
"started_at": "2026-05-19T09:01:00.000Z",
"finished_at": "2026-05-19T09:09:00.000Z"
}
}"#;
#[test]
fn parse_mr_json_maps_iid_state_and_url() {
let pr = gitlab::parse_mr_json(MR_JSON).unwrap();
assert_eq!(pr.number, 61);
assert_eq!(pr.state, PrState::Open);
assert_eq!(pr.title, "feat(tui): fuzzy search");
assert_eq!(pr.url, "https://gitlab.com/group/proj/-/merge_requests/61");
}
#[test]
fn parse_mr_json_maps_draft_to_the_draft_state() {
let json = MR_JSON.replace("\"draft\": false", "\"draft\": true");
let pr = gitlab::parse_mr_json(&json).unwrap();
assert_eq!(pr.state, PrState::Draft);
}
#[test]
fn parse_mr_json_falls_back_to_the_legacy_work_in_progress_flag() {
let json = r#"{"id":1,"iid":5,"title":"x","state":"opened","work_in_progress":true,
"web_url":"https://gitlab.com/g/p/-/merge_requests/5","updated_at":"",
"source_branch":"a","target_branch":"main"}"#;
let pr = gitlab::parse_mr_json(json).unwrap();
assert_eq!(pr.state, PrState::Draft);
}
#[test]
fn parse_mr_json_maps_merged_and_closed_and_locked() {
for (raw, want) in [
("merged", PrState::Merged),
("closed", PrState::Closed),
("locked", PrState::Open),
] {
let json = MR_JSON.replace("\"state\": \"opened\"", &format!("\"state\": \"{raw}\""));
let pr = gitlab::parse_mr_json(&json).unwrap();
assert_eq!(pr.state, want, "state {raw}");
}
}
#[test]
fn parse_mr_json_rejects_an_unknown_state() {
let json = MR_JSON.replace("\"state\": \"opened\"", "\"state\": \"teleported\"");
let err = gitlab::parse_mr_json(&json).unwrap_err();
assert!(err.to_string().contains("teleported"), "should name the state: {}", err);
}
#[test]
fn parse_mr_json_synthesises_one_check_from_the_head_pipeline() {
let pr = gitlab::parse_mr_json(MR_JSON).unwrap();
assert_eq!(pr.checks_total, 1);
assert_eq!(pr.checks_passed, 1);
assert_eq!(pr.ci, CiState::Passing);
assert_eq!(pr.checks.len(), 1);
assert_eq!(pr.checks[0].outcome, CheckOutcome::Passing);
assert_eq!(
pr.checks[0].url.as_deref(),
Some("https://gitlab.com/group/proj/-/pipelines/900")
);
assert_eq!(pr.checks[0].started_at.as_deref(), Some("2026-05-19T09:01:00.000Z"));
assert_eq!(pr.checks[0].completed_at.as_deref(), Some("2026-05-19T09:09:00.000Z"));
}
#[test]
fn parse_mr_json_without_a_pipeline_reports_no_ci() {
let json = r#"{"id":1,"iid":5,"title":"x","state":"opened","draft":false,
"web_url":"","updated_at":"","source_branch":"a","target_branch":"main"}"#;
let pr = gitlab::parse_mr_json(json).unwrap();
assert_eq!(pr.ci, CiState::None);
assert_eq!(pr.checks_total, 0);
assert!(pr.checks.is_empty());
}
#[test]
fn pipeline_status_classification_covers_the_gitlab_vocabulary() {
for (status, want) in [
("success", CheckOutcome::Passing),
("skipped", CheckOutcome::Passing),
("manual", CheckOutcome::Running),
("failed", CheckOutcome::Failing),
("canceled", CheckOutcome::Failing),
("canceling", CheckOutcome::Failing),
("created", CheckOutcome::Running),
("waiting_for_resource", CheckOutcome::Running),
("preparing", CheckOutcome::Running),
("pending", CheckOutcome::Running),
("running", CheckOutcome::Running),
("scheduled", CheckOutcome::Running),
] {
assert_eq!(
gitlab::classify_pipeline_status(status),
want,
"pipeline status {status}"
);
}
}
#[test]
fn an_unrecognised_pipeline_status_is_unknown_not_green() {
assert_eq!(
gitlab::classify_pipeline_status("some_future_status"),
CheckOutcome::Unknown
);
}
#[test]
fn an_unknown_pipeline_status_does_not_aggregate_to_passing() {
let json = MR_JSON.replace("\"status\": \"success\"", "\"status\": \"some_future_status\"");
let pr = gitlab::parse_mr_json(&json).unwrap();
assert_ne!(pr.ci, CiState::Passing, "an unknown status must never read as green");
assert_eq!(pr.checks_passed, 0);
assert_eq!(pr.checks[0].outcome, CheckOutcome::Unknown);
}
#[test]
fn parse_mr_head_json_extracts_author_and_branches() {
let head = gitlab::parse_mr_head_json(MR_JSON).unwrap();
assert_eq!(head.number, 61);
assert_eq!(head.author, "alice");
assert_eq!(head.head_ref_name, "feat/fuzzy");
assert_eq!(head.base_ref_name, "main");
}
#[test]
fn parse_mr_head_json_tolerates_a_deleted_author() {
let json = r#"{"id":1,"iid":5,"state":"opened","author":null,
"source_branch":"a","target_branch":"main","web_url":"","updated_at":"","title":"x"}"#;
let head = gitlab::parse_mr_head_json(json).unwrap();
assert_eq!(head.author, "");
}
#[test]
fn parse_mr_list_number_returns_the_first_iid() {
let json = r#"[{"id":900,"iid":61},{"id":901,"iid":62}]"#;
assert_eq!(gitlab::parse_mr_list_number(json).unwrap(), Some(61));
}
#[test]
fn parse_mr_list_number_returns_none_on_an_empty_array() {
assert_eq!(gitlab::parse_mr_list_number("[]").unwrap(), None);
}
#[test]
fn mr_list_argv_pins_the_source_branch_and_all_states() {
let argv = gitlab::mr_list_argv("group/proj", "feat/x");
assert_eq!(
argv,
vec![
"mr",
"list",
"--repo",
"group/proj",
"--source-branch",
"feat/x",
"--all",
"--output",
"json",
"--per-page",
"100",
]
);
}
#[test]
fn issue_view_argv_pins_the_json_output_flag() {
let argv = gitlab::issue_view_argv("group/proj", 42);
assert_eq!(
argv,
vec!["issue", "view", "42", "--repo", "group/proj", "--output", "json"]
);
}
#[test]
fn mr_view_argv_pins_the_json_output_flag() {
let argv = gitlab::mr_view_argv("group/proj", 61);
assert_eq!(
argv,
vec!["mr", "view", "61", "--repo", "group/proj", "--output", "json"]
);
}
#[test]
fn parse_labels_json_strips_the_leading_hash_and_lowercases() {
let json = r##"[{"id":1,"name":"bug","color":"#D9534F","description":"Something broke"},
{"id":2,"name":"tui","color":"#5319e7","description":null}]"##;
let labels = gitlab::parse_labels_json(json).unwrap();
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].name, "bug");
assert_eq!(labels[0].color, "d9534f");
assert_eq!(labels[0].description.as_deref(), Some("Something broke"));
assert_eq!(labels[1].color, "5319e7");
assert_eq!(labels[1].description, None);
}
#[test]
fn label_create_argv_re_adds_the_hash_gitlab_expects() {
let spec = LabelSpec {
name: "bug".into(),
description: Some("Something broke".into()),
color: "d9534f".into(),
};
let argv = gitlab::label_create_argv("group/proj", &spec);
assert_eq!(
argv,
vec![
"api",
"-X",
"POST",
"projects/group%2Fproj/labels",
"--raw-field",
"name=bug",
"--raw-field",
"color=#d9534f",
"--raw-field",
"description=Something broke",
]
);
}
#[test]
fn label_create_argv_omits_an_empty_description() {
let spec = LabelSpec {
name: "bug".into(),
description: None,
color: "d9534f".into(),
};
let argv = gitlab::label_create_argv("g/p", &spec);
assert!(!argv.iter().any(|a| a.starts_with("description=")));
}
#[test]
fn label_update_argv_keys_on_the_name_not_a_numeric_id() {
let spec = LabelSpec {
name: "good first issue".into(),
description: None,
color: "7057ff".into(),
};
let argv = gitlab::label_update_argv("group/proj", &spec);
assert_eq!(
argv,
vec![
"api",
"-X",
"PUT",
"projects/group%2Fproj/labels/good%20first%20issue",
"--raw-field",
"color=#7057ff",
"--raw-field",
"description=",
]
);
}
#[test]
fn label_delete_argv_url_encodes_the_name() {
let argv = gitlab::label_delete_argv("group/proj", "good first issue");
assert_eq!(
argv,
vec![
"api",
"-X",
"DELETE",
"projects/group%2Fproj/labels/good%20first%20issue"
]
);
}
#[test]
fn parse_milestones_json_maps_active_to_open_and_normalises_the_due_date() {
let json = r#"[{"id":12,"iid":3,"title":"v1.5.0","description":"next",
"state":"active","due_date":"2026-07-15"},
{"id":13,"iid":4,"title":"v1.0.0","description":null,
"state":"closed","due_date":null}]"#;
let ms = gitlab::parse_milestones_json(json).unwrap();
assert_eq!(ms.len(), 2);
assert_eq!(ms[0].number, 12, "the update path keys on the global `id`");
assert_eq!(ms[0].title, "v1.5.0");
assert_eq!(ms[0].state, MilestoneState::Open);
assert_eq!(ms[0].due_on.as_deref(), Some("2026-07-15T23:59:59Z"));
assert_eq!(ms[1].state, MilestoneState::Closed);
assert_eq!(ms[1].due_on, None);
}
#[test]
fn parse_milestones_json_rejects_an_unknown_state() {
let json = r#"[{"id":1,"iid":1,"title":"x","state":"paused","due_date":null}]"#;
let err = gitlab::parse_milestones_json(json).unwrap_err();
assert!(err.to_string().contains("paused"), "should name the state: {}", err);
}
#[test]
fn milestone_create_argv_sends_a_bare_due_date() {
let spec = MilestoneSpec {
title: "v1.5.0".into(),
description: Some("next".into()),
due_on: Some("2026-07-15T23:59:59Z".into()),
state: MilestoneState::Open,
};
let argv = gitlab::milestone_create_argv("group/proj", &spec);
assert_eq!(
argv,
vec![
"api",
"-X",
"POST",
"projects/group%2Fproj/milestones",
"--raw-field",
"title=v1.5.0",
"--raw-field",
"description=next",
"--raw-field",
"due_date=2026-07-15",
]
);
}
#[test]
fn milestone_update_argv_uses_state_event_not_state() {
let spec = MilestoneSpec {
title: "v1.0.0".into(),
description: None,
due_on: None,
state: MilestoneState::Closed,
};
let argv = gitlab::milestone_update_argv("group/proj", 13, &spec);
assert_eq!(
argv,
vec![
"api",
"-X",
"PUT",
"projects/group%2Fproj/milestones/13",
"--raw-field",
"title=v1.0.0",
"--raw-field",
"description=",
"--raw-field",
"due_date=",
"--raw-field",
"state_event=close",
]
);
}
#[test]
fn milestone_update_argv_reopens_with_activate() {
let spec = MilestoneSpec {
title: "v1.0.0".into(),
description: None,
due_on: None,
state: MilestoneState::Open,
};
let argv = gitlab::milestone_update_argv("g/p", 13, &spec);
assert!(argv.iter().any(|a| a == "state_event=activate"));
}
#[test]
fn milestone_list_argv_paginates() {
let argv = gitlab::milestone_list_argv("group/proj");
assert_eq!(
argv,
vec!["api", "--paginate", "projects/group%2Fproj/milestones?per_page=100"]
);
}
#[test]
fn milestone_delete_argv_targets_the_numeric_id() {
let argv = gitlab::milestone_delete_argv("group/proj", 13);
assert_eq!(argv, vec!["api", "-X", "DELETE", "projects/group%2Fproj/milestones/13"]);
}
#[test]
fn a_blocking_manual_pipeline_is_not_green() {
assert_eq!(gitlab::classify_pipeline_status("manual"), CheckOutcome::Running);
assert_eq!(gitlab::classify_pipeline_status("skipped"), CheckOutcome::Passing);
}
#[test]
fn a_manual_pipeline_does_not_count_as_a_passed_check() {
let json = MR_JSON.replace("\"status\": \"success\"", "\"status\": \"manual\"");
let pr = gitlab::parse_mr_json(&json).unwrap();
assert_eq!(pr.checks_passed, 0);
assert_ne!(pr.ci, CiState::Passing);
}
#[test]
fn a_due_on_carrying_a_time_is_refused_rather_than_looping_forever() {
let spec = MilestoneSpec {
title: "v1.5.0".into(),
description: None,
due_on: Some("2026-07-15T17:00:00Z".into()),
state: MilestoneState::Open,
};
let err = gitlab::check_due_on_is_date_only(&spec).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("v1.5.0"), "should name the milestone: {msg}");
assert!(msg.contains("date"), "should name the cause: {msg}");
}
#[test]
fn an_end_of_day_or_bare_date_due_on_is_accepted() {
for due in ["2026-07-15T23:59:59Z", "2026-07-15"] {
let spec = MilestoneSpec {
title: "v1.5.0".into(),
description: None,
due_on: Some(due.into()),
state: MilestoneState::Open,
};
assert!(gitlab::check_due_on_is_date_only(&spec).is_ok(), "due {due}");
}
}
fn origin(url: &str) -> gwm::forge::RemoteRef {
gwm::forge::parse_remote_url(url).unwrap()
}
#[test]
fn the_host_is_pinned_only_when_the_remote_actually_carried_one() {
let http = origin("https://gitlab.acme.internal:8443/team/proj.git");
assert_eq!(
gitlab::glab_env(&http),
vec![
(
"GITLAB_HOST".to_string(),
"https://gitlab.acme.internal:8443".to_string()
),
("API_PROTOCOL".to_string(), "https".to_string()),
]
);
}
#[test]
fn an_ssh_remote_does_not_override_the_users_glab_configuration() {
assert!(gitlab::glab_env(&origin("ssh://git@gitlab-ssh.acme:2222/team/proj.git")).is_empty());
assert!(gitlab::glab_env(&origin("git@gitlab-ssh.acme:team/proj.git")).is_empty());
}
#[test]
fn an_empty_slug_never_pins_a_host() {
let mut o = origin("https://gitlab.com/g/p.git");
o.path = String::new();
assert!(gitlab::glab_env(&o).is_empty());
}
#[test]
fn label_update_clears_a_description_the_config_dropped() {
let spec = LabelSpec {
name: "bug".into(),
description: None,
color: "d9534f".into(),
};
let argv = gitlab::label_update_argv("g/p", &spec);
assert!(
argv.windows(2).any(|w| w[1] == "description="),
"update must send an empty description to clear it: {argv:?}"
);
}
#[test]
fn label_create_still_omits_an_absent_description() {
let spec = LabelSpec {
name: "bug".into(),
description: None,
color: "d9534f".into(),
};
let argv = gitlab::label_create_argv("g/p", &spec);
assert!(!argv.iter().any(|a| a.starts_with("description=")), "{argv:?}");
}
#[test]
fn milestone_update_clears_optional_fields_the_config_dropped() {
let spec = MilestoneSpec {
title: "v1.5.0".into(),
description: None,
due_on: None,
state: MilestoneState::Open,
};
let argv = gitlab::milestone_update_argv("g/p", 13, &spec);
assert!(
argv.windows(2).any(|w| w[1] == "description="),
"must clear the description: {argv:?}"
);
assert!(
argv.windows(2).any(|w| w[1] == "due_date="),
"must clear the due date: {argv:?}"
);
}
#[test]
fn milestone_create_still_omits_absent_optional_fields() {
let spec = MilestoneSpec {
title: "v1.5.0".into(),
description: None,
due_on: None,
state: MilestoneState::Open,
};
let argv = gitlab::milestone_create_argv("g/p", &spec);
assert!(!argv.iter().any(|a| a.starts_with("description=")), "{argv:?}");
assert!(!argv.iter().any(|a| a.starts_with("due_date=")), "{argv:?}");
}
#[test]
fn label_list_excludes_ancestor_group_labels() {
let argv = gitlab::label_list_argv("group/proj");
assert_eq!(
argv,
vec![
"api",
"--paginate",
"projects/group%2Fproj/labels?per_page=100&include_ancestor_groups=false",
]
);
}
#[test]
fn parse_labels_json_drops_group_labels_that_slipped_through() {
let json = r##"[{"id":1,"name":"proj-only","color":"#d9534f","is_project_label":true},
{"id":2,"name":"from-group","color":"#5319e7","is_project_label":false},
{"id":3,"name":"legacy","color":"#111111"}]"##;
let labels = gitlab::parse_labels_json(json).unwrap();
let names: Vec<&str> = labels.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["proj-only", "legacy"]);
}
#[test]
fn a_guessed_origin_lets_glab_resolve_the_project_from_the_repo() {
let _env = clean_env();
let dir = tempfile::tempdir().unwrap();
let f = gwm::forge::for_kind_in(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url("git@gitlab-ssh.acme:team/proj.git").unwrap(),
Some(dir.path().to_path_buf()),
);
assert_eq!(f.repo_selector(), "", "a guessed origin must not pin a selector");
}
#[test]
fn an_authoritative_origin_keeps_its_explicit_selector() {
let _env = clean_env();
let dir = tempfile::tempdir().unwrap();
let f = gwm::forge::for_kind_in(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url("https://gitlab.acme/team/proj.git").unwrap(),
Some(dir.path().to_path_buf()),
);
assert_eq!(f.repo_selector(), "team/proj");
}
#[test]
fn a_guessed_origin_without_a_workdir_still_pins_the_selector() {
let _env = clean_env();
let f = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url("git@gitlab-ssh.acme:team/proj.git").unwrap(),
);
assert_eq!(f.repo_selector(), "team/proj");
}
#[test]
fn an_empty_selector_drops_the_repo_flag_from_every_builder() {
assert!(!gitlab::issue_view_argv("", 42).iter().any(|a| a == "--repo"));
assert!(!gitlab::mr_view_argv("", 61).iter().any(|a| a == "--repo"));
assert!(!gitlab::mr_list_argv("", "feat/x").iter().any(|a| a == "--repo"));
}
#[test]
fn an_empty_selector_makes_glab_api_resolve_the_project_itself() {
assert_eq!(
gitlab::label_list_argv(""),
vec![
"api",
"--paginate",
"projects/:fullpath/labels?per_page=100&include_ancestor_groups=false",
]
);
assert_eq!(
gitlab::milestone_list_argv(""),
vec!["api", "--paginate", "projects/:fullpath/milestones?per_page=100"]
);
}
#[test]
fn mr_detection_ignores_a_fork_with_the_same_branch_name() {
let json = r#"[{"iid":900,"project_id":7,"source_project_id":99},
{"iid":61,"project_id":7,"source_project_id":7}]"#;
assert_eq!(gitlab::parse_mr_list_number(json).unwrap(), Some(61));
}
#[test]
fn mr_detection_keeps_an_mr_that_does_not_report_its_source_project() {
let json = r#"[{"iid":61,"project_id":7}]"#;
assert_eq!(gitlab::parse_mr_list_number(json).unwrap(), Some(61));
}
#[test]
fn mr_list_argv_asks_for_a_full_page_not_a_sample() {
let argv = gitlab::mr_list_argv("g/p", "feat/x");
assert!(
argv.windows(2).any(|w| w[0] == "--per-page" && w[1] == "100"),
"one row cannot be filtered: {argv:?}"
);
}
#[test]
fn create_argv_never_carries_the_body() {
let issue = gwm::gitlab::issue_create_api_argv("group/proj");
let mr = gwm::gitlab::mr_create_api_argv("group/proj");
for argv in [&issue, &mr] {
assert!(!argv.iter().any(|a| a == "--description"), "{argv:?}");
assert!(
argv.windows(2).any(|w| w[0] == "--input" && w[1] == "-"),
"the body must travel on stdin: {argv:?}"
);
assert!(argv.windows(2).any(|w| w[0] == "-X" && w[1] == "POST"), "{argv:?}");
}
assert!(issue.iter().any(|a| a.ends_with("/issues")), "{issue:?}");
assert!(mr.iter().any(|a| a.ends_with("/merge_requests")), "{mr:?}");
}
#[test]
fn issue_create_payload_carries_title_description_and_labels() {
let labels = vec!["bug".to_string(), "p1".to_string()];
let json = gwm::gitlab::issue_create_payload("Title", "Body\nwith newline", &labels);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["title"], "Title");
assert_eq!(v["description"], "Body\nwith newline");
assert_eq!(v["labels"], "bug,p1");
}
#[test]
fn mr_create_payload_carries_both_branches() {
let json = gwm::gitlab::mr_create_payload("T", "B", "feat/x", Some("dev"), false).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["source_branch"], "feat/x");
assert_eq!(v["target_branch"], "dev");
assert_eq!(v["title"], "T");
}
#[test]
fn mr_create_payload_expresses_draft_in_the_title() {
let json = gwm::gitlab::mr_create_payload("Add thing", "B", "feat/x", Some("dev"), true).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["title"], "Draft: Add thing");
}
#[test]
fn mr_create_payload_requires_a_target_branch() {
let err = gwm::gitlab::mr_create_payload("T", "B", "feat/x", None, false).unwrap_err();
assert!(err.to_string().contains("target branch"), "{err}");
}
#[test]
fn parse_created_api_reads_the_iid_and_the_server_url() {
let (number, url) = gwm::gitlab::parse_created_api(
r#"{"iid":42,"web_url":"https://gitlab.com/group/proj/-/issues/42"}"#,
"issue",
)
.unwrap();
assert_eq!(number, 42);
assert_eq!(url, "https://gitlab.com/group/proj/-/issues/42");
}
#[test]
fn create_endpoints_stay_project_relative_when_the_slug_is_empty() {
let issue = gwm::gitlab::issue_create_api_argv("");
let mr = gwm::gitlab::mr_create_api_argv("");
assert!(issue.iter().any(|a| a == "projects/:fullpath/issues"), "{issue:?}");
assert!(mr.iter().any(|a| a == "projects/:fullpath/merge_requests"), "{mr:?}");
assert!(!issue.iter().any(|a| a == "--repo"), "{issue:?}");
assert!(!mr.iter().any(|a| a == "--repo"), "{mr:?}");
}
#[test]
fn ci_autologin_on_another_instance_is_refused_not_silently_retargeted() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_FQDN", "runner-gitlab.other.example");
}
let diverged = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal/team/proj.git"));
let agreed = gwm::gitlab::ci_autologin_conflict(&origin("https://runner-gitlab.other.example/team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_FQDN");
}
let msg = diverged.expect("a divergent CI instance must refuse");
assert!(msg.contains("runner-gitlab.other.example"), "{msg}");
assert!(msg.contains("gitlab.acme.internal"), "{msg}");
assert!(agreed.is_none(), "matching hosts must not refuse: {agreed:?}");
}
#[test]
fn no_ci_autologin_means_no_refusal() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::set_var("CI_SERVER_FQDN", "runner-gitlab.other.example");
}
let out = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal/team/proj.git"));
unsafe {
std::env::remove_var("CI_SERVER_FQDN");
}
assert!(out.is_none(), "{out:?}");
}
#[test]
fn ci_autologin_compares_host_and_port_not_the_raw_fqdn() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_FQDN", "gitlab.acme.internal:8443");
std::env::remove_var("CI_SERVER_HOST");
std::env::remove_var("CI_SERVER_PORT");
}
let same = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal:8443/team/proj.git"));
let other = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.other.example/team/proj.git"));
let port = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal:9443/team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_FQDN");
}
assert!(same.is_none(), "a legitimate pipeline must not be refused: {same:?}");
assert!(other.is_some(), "a different host must refuse");
assert!(port.is_some(), "a different port is a different instance");
}
#[test]
fn ci_guard_stays_out_of_it_when_the_origin_is_only_an_ssh_endpoint() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_HOST", "gitlab.acme");
std::env::remove_var("CI_SERVER_PORT");
std::env::remove_var("CI_SERVER_FQDN");
std::env::remove_var("CI_SERVER_URL");
std::env::remove_var("CI_SERVER_PROTOCOL");
}
let guessed = gwm::gitlab::ci_autologin_conflict(&origin("git@ssh.gitlab.acme:team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_HOST");
}
assert!(
guessed.is_none(),
"an SSH-only origin cannot prove a divergence: {guessed:?}"
);
}
#[test]
fn ci_guard_resolves_the_implicit_port_from_the_scheme() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_URL", "https://gitlab.acme.internal:8443");
std::env::remove_var("CI_SERVER_HOST");
std::env::remove_var("CI_SERVER_PORT");
std::env::remove_var("CI_SERVER_FQDN");
std::env::remove_var("CI_SERVER_PROTOCOL");
}
let implicit = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal/team/proj.git"));
let matching = gwm::gitlab::ci_autologin_conflict(&origin("https://gitlab.acme.internal:8443/team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_URL");
}
assert!(implicit.is_some(), "443 and 8443 are different instances");
assert!(
matching.is_none(),
"the same instance must not be refused: {matching:?}"
);
}
#[test]
fn ci_guard_compares_an_ssh_origin_against_the_ssh_host_variable() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_HOST", "gitlab.acme");
std::env::set_var("CI_SERVER_SHELL_SSH_HOST", "ssh.gitlab.acme");
std::env::remove_var("CI_SERVER_PORT");
std::env::remove_var("CI_SERVER_FQDN");
std::env::remove_var("CI_SERVER_URL");
std::env::remove_var("CI_SERVER_PROTOCOL");
}
let ours = gwm::gitlab::ci_autologin_conflict(&origin("git@ssh.gitlab.acme:team/proj.git"));
let theirs = gwm::gitlab::ci_autologin_conflict(&origin("git@ssh.gitlab.other:team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_HOST");
std::env::remove_var("CI_SERVER_SHELL_SSH_HOST");
}
assert!(ours.is_none(), "the runner's own SSH host must not refuse: {ours:?}");
assert!(theirs.is_some(), "a foreign SSH host is a provable divergence");
}
#[test]
fn ci_guard_still_abstains_when_no_ssh_host_is_published() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("CI_SERVER_HOST", "gitlab.acme");
std::env::remove_var("CI_SERVER_SHELL_SSH_HOST");
}
let out = gwm::gitlab::ci_autologin_conflict(&origin("git@ssh.gitlab.acme:team/proj.git"));
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_HOST");
}
assert!(out.is_none(), "{out:?}");
}
#[test]
fn the_instance_subfolder_is_stripped_from_the_api_selector() {
let _env = clean_env();
unsafe {
std::env::set_var("GITLAB_SUBFOLDER", "gitlab");
}
let dir = tempfile::tempdir().unwrap();
let f = gwm::forge::for_kind_in(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url("https://host.example/gitlab/group/proj.git").unwrap(),
Some(dir.path().to_path_buf()),
);
let selector = f.repo_selector().to_string();
let issue_url = f.issue_url(5);
unsafe {
std::env::remove_var("GITLAB_SUBFOLDER");
}
assert_eq!(selector, "group/proj", "the API base already has the subfolder");
assert_eq!(issue_url, "https://host.example/gitlab/group/proj/-/issues/5");
}
#[test]
fn a_namespace_that_merely_looks_like_the_subfolder_is_kept() {
let _env = clean_env();
let f = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url("https://gitlab.com/gitlab/group/proj.git").unwrap(),
);
assert_eq!(f.repo_selector(), "gitlab/group/proj");
}
#[test]
fn the_ci_autologin_gate_mirrors_glabs_own_two_variable_condition() {
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let repo = origin("https://gitlab.acme.internal/team/proj.git");
unsafe {
std::env::set_var("CI_SERVER_FQDN", "runner-gitlab.other.example");
std::env::remove_var("GITLAB_CI");
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
}
let no_gitlab_ci = gwm::gitlab::ci_autologin_conflict(&repo);
unsafe {
std::env::set_var("GITLAB_CI", "true");
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "1");
}
let truthy_but_not_true = gwm::gitlab::ci_autologin_conflict(&repo);
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "1");
}
let ci_truthy_but_not_true = gwm::gitlab::ci_autologin_conflict(&repo);
unsafe {
std::env::set_var("GITLAB_CI", "true");
}
let both_set = gwm::gitlab::ci_autologin_conflict(&repo);
unsafe {
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
std::env::remove_var("CI_SERVER_FQDN");
}
assert!(
no_gitlab_ci.is_none(),
"outside a pipeline glab never auto-logs in: {no_gitlab_ci:?}"
);
assert!(
truthy_but_not_true.is_none(),
"glab compares the literal string `true`: {truthy_but_not_true:?}"
);
assert!(
ci_truthy_but_not_true.is_none(),
"`GITLAB_CI=1` is not `true` either: {ci_truthy_but_not_true:?}"
);
assert!(both_set.is_some(), "the real divergence must still refuse");
}
#[test]
fn the_pin_covers_the_api_endpoint_and_its_scheme_not_only_the_host() {
let pinned = gwm::gitlab::glab_env_remove(&origin("https://gitlab.acme/team/proj.git"), false);
let guessed = gwm::gitlab::glab_env_remove(&origin("git@gitlab.acme:team/proj.git"), false);
assert!(pinned.contains(&"GITLAB_API_HOST"), "{pinned:?}");
assert!(pinned.contains(&"GL_HOST"), "{pinned:?}");
assert!(!guessed.contains(&"GL_HOST"), "{guessed:?}");
assert!(!guessed.contains(&"GITLAB_URI"), "{guessed:?}");
let https = gwm::gitlab::glab_env(&origin("https://gitlab.acme/team/proj.git"));
let http = gwm::gitlab::glab_env(&origin("http://gitlab.acme:8080/team/proj.git"));
assert!(
https.iter().any(|(k, v)| k == "API_PROTOCOL" && v == "https"),
"{https:?}"
);
assert!(
http.iter().any(|(k, v)| k == "API_PROTOCOL" && v == "http"),
"clearing it would force https onto a plain-http instance: {http:?}"
);
assert!(
gwm::gitlab::glab_env(&origin("git@gitlab.acme:team/proj.git")).is_empty(),
"a guessed origin still pins nothing"
);
}
#[test]
fn the_subfolder_falls_back_to_the_ci_server_url_like_glab_does() {
let _env = clean_env();
let url = "https://host.example/gitlab/group/proj.git";
unsafe {
std::env::set_var("CI_SERVER_URL", "https://host.example/gitlab");
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
}
let without_autologin = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url(url).unwrap(),
)
.repo_selector()
.to_string();
unsafe {
std::env::set_var("GLAB_ENABLE_CI_AUTOLOGIN", "true");
std::env::set_var("GITLAB_CI", "true");
}
let with_autologin = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url(url).unwrap(),
)
.repo_selector()
.to_string();
unsafe {
std::env::set_var("GITLAB_SUBFOLDER", "elsewhere");
}
let explicit_wins = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url(url).unwrap(),
)
.repo_selector()
.to_string();
unsafe {
std::env::remove_var("GITLAB_SUBFOLDER");
std::env::set_var("CI_SERVER_URL", "https://host.example");
}
let no_path_in_url = gwm::forge::for_kind(
gwm::forge::ForgeKind::GitLab,
gwm::forge::parse_remote_url(url).unwrap(),
)
.repo_selector()
.to_string();
unsafe {
std::env::remove_var("CI_SERVER_URL");
std::env::remove_var("GLAB_ENABLE_CI_AUTOLOGIN");
std::env::remove_var("GITLAB_CI");
}
assert_eq!(
without_autologin, "gitlab/group/proj",
"outside auto-login glab reads GITLAB_SUBFOLDER alone"
);
assert_eq!(with_autologin, "group/proj");
assert_eq!(
explicit_wins, "gitlab/group/proj",
"GITLAB_SUBFOLDER is first in glab's own override list"
);
assert_eq!(
no_path_in_url, "gitlab/group/proj",
"a URL with no path yields no subfolder"
);
}
#[test]
fn a_paginated_list_arrives_as_one_array_per_page() {
let page1 = r##"[{"name":"bug","color":"#d73a4a","description":"a","is_project_label":true}]"##;
let page2 = r##"[{"name":"docs","color":"#0075ca","description":null,"is_project_label":true}]"##;
let merged = gwm::gitlab::parse_labels_json(&format!("{page1}{page2}")).unwrap();
assert_eq!(
merged.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
vec!["bug", "docs"]
);
let spaced = gwm::gitlab::parse_labels_json(&format!("{page1}\n{page2}")).unwrap();
assert_eq!(spaced.len(), 2);
assert_eq!(gwm::gitlab::parse_labels_json(page1).unwrap().len(), 1);
assert_eq!(gwm::gitlab::parse_labels_json("[]").unwrap().len(), 0);
assert!(gwm::gitlab::parse_labels_json("").is_err());
assert!(gwm::gitlab::parse_labels_json(" ").is_err());
let m1 = r#"[{"id":1,"iid":1,"title":"v1","description":"","state":"active","due_date":null}]"#;
let m2 = r#"[{"id":2,"iid":2,"title":"v2","description":"","state":"closed","due_date":null}]"#;
let ms = gwm::gitlab::parse_milestones_json(&format!("{m1}{m2}")).unwrap();
assert_eq!(
ms.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
vec!["v1", "v2"]
);
assert!(gwm::gitlab::parse_milestones_json("").is_err());
}
#[test]
fn the_api_overrides_go_even_when_the_host_is_only_delegated() {
let guessed = gwm::gitlab::glab_env_remove(&origin("git@gitlab-ssh.acme:team/proj.git"), false);
let pinned = gwm::gitlab::glab_env_remove(&origin("https://gitlab.acme/team/proj.git"), false);
for vars in [&guessed, &pinned] {
assert!(vars.contains(&"GITLAB_API_HOST"), "{vars:?}");
assert!(vars.contains(&"API_PROTOCOL"), "{vars:?}");
}
assert!(!guessed.contains(&"GITLAB_URI"), "{guessed:?}");
assert!(!guessed.contains(&"GL_HOST"), "{guessed:?}");
assert!(pinned.contains(&"GITLAB_URI"), "{pinned:?}");
let nothing = gwm::forge::RemoteRef {
host: "gitlab.com".into(),
path: String::new(),
web_origin: "https://gitlab.com".into(),
trust: gwm::forge::OriginTrust::Guessed,
};
assert!(gwm::gitlab::glab_env_remove(¬hing, false).is_empty());
}
#[test]
fn a_confirmed_same_project_mr_outranks_an_unidentifiable_one() {
let confirmed_second = r#"[
{"iid":9,"project_id":1},
{"iid":61,"project_id":1,"source_project_id":1}
]"#;
assert_eq!(gitlab::parse_mr_list_number(confirmed_second).unwrap(), Some(61));
let no_ids = r#"[{"iid":9},{"iid":61}]"#;
assert_eq!(gitlab::parse_mr_list_number(no_ids).unwrap(), Some(9));
let fork_then_unknown = r#"[
{"iid":9,"project_id":1,"source_project_id":2},
{"iid":61,"project_id":1}
]"#;
assert_eq!(gitlab::parse_mr_list_number(fork_then_unknown).unwrap(), Some(61));
let fork_only = r#"[{"iid":9,"project_id":1,"source_project_id":2}]"#;
assert_eq!(gitlab::parse_mr_list_number(fork_only).unwrap(), Some(9));
assert_eq!(gitlab::parse_mr_list_number("[]").unwrap(), None);
}