mod common;
use common::init_repo;
use gwm::github::{
self, parse_issue_json, parse_pr_head_json, parse_pr_json, BranchLink, CiState, IssueState, LinkSource, PrState,
};
fn make_branch(repo: &git2::Repository, name: &str) {
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch(name, &head, false).unwrap();
}
#[test]
fn read_link_returns_none_when_branch_name_has_no_issue() {
let (_dir, repo) = init_repo();
make_branch(&repo, "random-branch");
let link = github::read_link(&repo, "random-branch").unwrap();
assert_eq!(link.issue, None);
assert_eq!(link.pr, None);
assert_eq!(link.issue_source, LinkSource::None);
assert_eq!(link.pr_source, LinkSource::None);
}
#[test]
fn read_link_auto_detects_issue_from_branch_name() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
assert_eq!(link.pr, None);
assert_eq!(link.pr_source, LinkSource::None);
}
#[test]
fn link_issue_writes_branch_config_overriding_auto_detect() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::link_issue(&repo, "feat/#42-tui-search", 99).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(99));
assert_eq!(link.issue_source, LinkSource::Explicit);
}
#[test]
fn unlink_issue_removes_explicit_override_and_falls_back_to_branch_name() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::link_issue(&repo, "feat/#42-tui-search", 99).unwrap();
github::unlink_issue(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
}
#[test]
fn unlink_issue_on_unlinked_branch_is_idempotent() {
let (_dir, repo) = init_repo();
make_branch(&repo, "random-branch");
github::unlink_issue(&repo, "random-branch").unwrap();
github::unlink_issue(&repo, "random-branch").unwrap();
}
#[test]
fn link_pr_writes_branch_config() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(61));
assert_eq!(link.pr_source, LinkSource::Explicit);
}
#[test]
fn unlink_pr_clears_the_pr_link_only() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::link_issue(&repo, "feat/#42-tui-search", 99).unwrap();
github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
github::unlink_pr(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(99));
assert_eq!(link.pr, None);
}
#[test]
fn persist_detected_pr_is_read_back_as_detected_source() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(77));
assert_eq!(link.pr_source, LinkSource::Detected);
}
#[test]
fn read_link_round_trips_persisted_issue_title() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
repo
.config()
.unwrap()
.set_str("branch.feat/#42-tui-search.gwm-issue-title", "TUI title \"quoted\"")
.unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
assert_eq!(link.issue_title.as_deref(), Some("TUI title \"quoted\""));
}
#[test]
fn read_link_round_trips_explicit_and_detected_pr_titles() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr", "61").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-title", "Explicit PR title")
.unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr-detected", "77").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-title", "Detected PR title")
.unwrap();
}
let explicit = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(explicit.pr, Some(61));
assert_eq!(explicit.pr_source, LinkSource::Explicit);
assert_eq!(explicit.pr_title.as_deref(), Some("Explicit PR title"));
github::unlink_pr(&repo, "feat/#42-tui-search").unwrap();
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
repo
.config()
.unwrap()
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-title", "Detected PR title")
.unwrap();
let detected = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(detected.pr, Some(77));
assert_eq!(detected.pr_source, LinkSource::Detected);
assert_eq!(detected.pr_title.as_deref(), Some("Detected PR title"));
}
#[test]
fn read_link_round_trips_persisted_issue_state() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_issue_state(&repo, "feat/#42-tui-search", IssueState::Closed).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
assert_eq!(link.issue_state, Some(IssueState::Closed));
}
#[test]
fn read_link_round_trips_explicit_and_detected_pr_states() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
github::persist_pr_state(&repo, "feat/#42-tui-search", PrState::Merged).unwrap();
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::persist_detected_pr_state(&repo, "feat/#42-tui-search", PrState::Draft).unwrap();
let explicit = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(explicit.pr, Some(61));
assert_eq!(explicit.pr_source, LinkSource::Explicit);
assert_eq!(explicit.pr_state, Some(PrState::Merged));
github::unlink_pr(&repo, "feat/#42-tui-search").unwrap();
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::persist_detected_pr_state(&repo, "feat/#42-tui-search", PrState::Draft).unwrap();
let detected = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(detected.pr, Some(77));
assert_eq!(detected.pr_source, LinkSource::Detected);
assert_eq!(detected.pr_state, Some(PrState::Draft));
}
#[test]
fn unlink_issue_clears_persisted_issue_title() {
let (_dir, repo) = init_repo();
make_branch(&repo, "random-branch");
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.random-branch.gwm-issue", "99").unwrap();
cfg
.set_str("branch.random-branch.gwm-issue-title", "Stale issue title")
.unwrap();
cfg.set_str("branch.random-branch.gwm-issue-state", "closed").unwrap();
}
github::unlink_issue(&repo, "random-branch").unwrap();
let link = github::read_link(&repo, "random-branch").unwrap();
assert_eq!(link.issue, None);
assert_eq!(link.issue_title, None);
assert_eq!(link.issue_state, None);
}
#[test]
fn unlink_pr_clears_explicit_and_detected_pr_titles() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr", "61").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-title", "Explicit PR title")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-state", "merged")
.unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr-detected", "77").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-title", "Detected PR title")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-state", "draft")
.unwrap();
}
github::unlink_pr(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, None);
assert_eq!(link.pr_title, None);
assert_eq!(link.pr_state, None);
}
#[test]
fn clear_persisted_detected_pr_clears_detected_title() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr-detected", "77").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-title", "Detected PR title")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-state", "draft")
.unwrap();
}
github::clear_persisted_detected_pr(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, None);
assert_eq!(link.pr_title, None);
assert_eq!(link.pr_state, None);
}
#[test]
fn explicit_pr_overrides_persisted_detected_pr() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(61));
assert_eq!(link.pr_source, LinkSource::Explicit);
}
#[test]
fn persist_detected_pr_overwrites_a_previous_detection() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::persist_detected_pr_title(&repo, "feat/#42-tui-search", "Old detected title").unwrap();
github::persist_detected_pr_state(&repo, "feat/#42-tui-search", PrState::Merged).unwrap();
github::persist_detected_pr(&repo, "feat/#42-tui-search", 88).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(88));
assert_eq!(link.pr_title, None);
assert_eq!(link.pr_state, None);
assert_eq!(link.pr_source, LinkSource::Detected);
}
#[test]
fn persist_detected_pr_keeps_title_when_number_is_unchanged() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::persist_detected_pr_title(&repo, "feat/#42-tui-search", "Detected PR title").unwrap();
github::persist_detected_pr_state(&repo, "feat/#42-tui-search", PrState::Draft).unwrap();
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(77));
assert_eq!(link.pr_title.as_deref(), Some("Detected PR title"));
assert_eq!(link.pr_state, Some(PrState::Draft));
assert_eq!(link.pr_source, LinkSource::Detected);
}
#[test]
fn unlink_pr_also_clears_a_persisted_detection() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
github::unlink_pr(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, None);
assert_eq!(link.pr_source, LinkSource::None);
}
#[test]
fn clear_persisted_detected_pr_removes_the_detected_link() {
let (_dir, repo) = init_repo();
make_branch(&repo, "feat/#42-tui-search");
github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
github::clear_persisted_detected_pr(&repo, "feat/#42-tui-search").unwrap();
let link = github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, None);
assert_eq!(link.pr_source, LinkSource::None);
}
fn set_origin(repo: &git2::Repository, url: &str) {
let _ = repo.remote("origin", url);
}
#[test]
fn repo_slug_from_ssh_origin() {
let (_dir, repo) = init_repo();
set_origin(&repo, "git@github.com:kbrdn1/gwm-cli.git");
let slug = github::repo_slug(&repo).unwrap();
assert_eq!(slug, "kbrdn1/gwm-cli");
}
#[test]
fn repo_slug_from_https_origin() {
let (_dir, repo) = init_repo();
set_origin(&repo, "https://github.com/kbrdn1/gwm-cli.git");
let slug = github::repo_slug(&repo).unwrap();
assert_eq!(slug, "kbrdn1/gwm-cli");
}
#[test]
fn repo_slug_strips_trailing_dot_git_when_absent() {
let (_dir, repo) = init_repo();
set_origin(&repo, "https://github.com/kbrdn1/gwm-cli");
let slug = github::repo_slug(&repo).unwrap();
assert_eq!(slug, "kbrdn1/gwm-cli");
}
#[test]
fn repo_slug_handles_trailing_slash_after_dot_git() {
let (_dir, repo) = init_repo();
set_origin(&repo, "https://github.com/kbrdn1/gwm-cli.git/");
let slug = github::repo_slug(&repo).unwrap();
assert_eq!(slug, "kbrdn1/gwm-cli");
}
#[test]
fn repo_slug_handles_trailing_slash_without_dot_git() {
let (_dir, repo) = init_repo();
set_origin(&repo, "https://github.com/kbrdn1/gwm-cli/");
let slug = github::repo_slug(&repo).unwrap();
assert_eq!(slug, "kbrdn1/gwm-cli");
}
#[test]
fn repo_slug_errors_when_no_origin_remote() {
let (_dir, repo) = init_repo();
let err = github::repo_slug(&repo).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("origin"), "error should mention origin remote: {}", msg);
}
#[test]
fn repo_slug_errors_when_origin_is_not_github() {
let (_dir, repo) = init_repo();
set_origin(&repo, "https://gitlab.com/kbrdn1/something.git");
let err = github::repo_slug(&repo).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("github"), "error should mention github: {}", msg);
}
#[test]
fn parse_issue_json_extracts_open_state_and_labels() {
let json = r#"{
"number": 42,
"title": "TUI: fuzzy search",
"state": "OPEN",
"url": "https://github.com/kbrdn1/gwm-cli/issues/42",
"labels": [
{"name": "feature", "color": "0e8a16"},
{"name": "tui", "color": "5319e7"}
],
"updatedAt": "2026-05-19T10:00:00Z"
}"#;
let issue = parse_issue_json(json).unwrap();
assert_eq!(issue.number, 42);
assert_eq!(issue.title, "TUI: fuzzy search");
assert_eq!(issue.state, IssueState::Open);
assert_eq!(issue.url, "https://github.com/kbrdn1/gwm-cli/issues/42");
assert_eq!(issue.labels, vec!["feature", "tui"]);
}
#[test]
fn parse_issue_json_handles_closed_state() {
let json = r#"{
"number": 7,
"title": "old bug",
"state": "CLOSED",
"url": "https://github.com/x/y/issues/7",
"labels": [],
"updatedAt": "2025-01-01T00:00:00Z"
}"#;
let issue = parse_issue_json(json).unwrap();
assert_eq!(issue.state, IssueState::Closed);
assert!(issue.labels.is_empty());
}
#[test]
fn parse_pr_json_extracts_state_draft_and_checks() {
let json = r#"{
"number": 61,
"title": "feat(tui): fuzzy search",
"state": "OPEN",
"isDraft": true,
"url": "https://github.com/kbrdn1/gwm-cli/pull/61",
"statusCheckRollup": [
{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "fmt", "status": "IN_PROGRESS", "conclusion": null}
],
"updatedAt": "2026-05-19T10:00:00Z"
}"#;
let pr = parse_pr_json(json).unwrap();
assert_eq!(pr.number, 61);
assert_eq!(pr.title, "feat(tui): fuzzy search");
assert_eq!(pr.state, PrState::Draft);
assert_eq!(pr.url, "https://github.com/kbrdn1/gwm-cli/pull/61");
assert_eq!(pr.checks_passed, 2);
assert_eq!(pr.checks_total, 3);
}
#[test]
fn parse_pr_json_merged_state_overrides_open() {
let json = r#"{
"number": 61,
"title": "feat(tui): fuzzy search",
"state": "MERGED",
"isDraft": false,
"url": "https://github.com/kbrdn1/gwm-cli/pull/61",
"statusCheckRollup": [],
"updatedAt": "2026-05-19T10:00:00Z"
}"#;
let pr = parse_pr_json(json).unwrap();
assert_eq!(pr.state, PrState::Merged);
assert_eq!(pr.checks_total, 0);
}
#[test]
fn parse_pr_json_handles_missing_status_check_rollup() {
let json = r#"{
"number": 5,
"title": "x",
"state": "OPEN",
"isDraft": false,
"url": "https://github.com/x/y/pull/5",
"updatedAt": "2026-05-19T10:00:00Z"
}"#;
let pr = parse_pr_json(json).unwrap();
assert_eq!(pr.checks_total, 0);
assert_eq!(pr.checks_passed, 0);
assert_eq!(pr.state, PrState::Open);
}
fn pr_json_with_rollup(rollup: &str) -> String {
format!(
r#"{{
"number": 7,
"title": "x",
"state": "OPEN",
"isDraft": false,
"url": "https://github.com/x/y/pull/7",
"statusCheckRollup": {rollup},
"updatedAt": "2026-06-15T10:00:00Z"
}}"#
)
}
#[test]
fn ci_state_is_passing_when_all_checks_succeed() {
let json = pr_json_with_rollup(
r#"[
{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS"}
]"#,
);
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::Passing);
}
#[test]
fn ci_state_treats_neutral_and_skipped_as_passing() {
let json = pr_json_with_rollup(
r#"[
{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "optional", "status": "COMPLETED", "conclusion": "NEUTRAL"},
{"name": "deploy", "status": "COMPLETED", "conclusion": "SKIPPED"}
]"#,
);
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::Passing);
}
#[test]
fn ci_state_is_running_when_a_check_is_in_flight() {
let json = pr_json_with_rollup(
r#"[
{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "fmt", "status": "IN_PROGRESS", "conclusion": null}
]"#,
);
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::Running);
}
#[test]
fn ci_state_treats_queued_and_pending_as_running() {
let json = pr_json_with_rollup(
r#"[
{"name": "queued", "status": "QUEUED", "conclusion": null},
{"name": "pending", "status": "PENDING", "conclusion": null}
]"#,
);
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::Running);
}
#[test]
fn ci_state_is_failing_on_any_failed_conclusion() {
for conclusion in ["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"] {
let json = pr_json_with_rollup(&format!(
r#"[
{{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"}},
{{"name": "broken", "status": "COMPLETED", "conclusion": "{conclusion}"}}
]"#
));
assert_eq!(
parse_pr_json(&json).unwrap().ci,
CiState::Failing,
"conclusion {conclusion} must read as Failing"
);
}
}
#[test]
fn ci_state_failing_outranks_running() {
let json = pr_json_with_rollup(
r#"[
{"name": "still-going", "status": "IN_PROGRESS", "conclusion": null},
{"name": "broken", "status": "COMPLETED", "conclusion": "FAILURE"}
]"#,
);
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::Failing);
}
#[test]
fn ci_state_is_none_when_there_are_no_checks() {
let json = pr_json_with_rollup("[]");
assert_eq!(parse_pr_json(&json).unwrap().ci, CiState::None);
}
#[test]
fn ci_state_classifies_legacy_status_context_state() {
let failing = pr_json_with_rollup(r#"[{"context": "ci/ext", "state": "FAILURE"}]"#);
assert_eq!(parse_pr_json(&failing).unwrap().ci, CiState::Failing);
let error = pr_json_with_rollup(r#"[{"context": "ci/ext", "state": "ERROR"}]"#);
assert_eq!(parse_pr_json(&error).unwrap().ci, CiState::Failing);
let success = pr_json_with_rollup(r#"[{"context": "ci/ext", "state": "SUCCESS"}]"#);
assert_eq!(parse_pr_json(&success).unwrap().ci, CiState::Passing);
let pending = pr_json_with_rollup(r#"[{"context": "ci/ext", "state": "PENDING"}]"#);
assert_eq!(parse_pr_json(&pending).unwrap().ci, CiState::Running);
}
#[test]
fn ci_state_treats_non_success_terminal_conclusions_as_failing() {
for conclusion in ["STARTUP_FAILURE", "STALE"] {
let json = pr_json_with_rollup(&format!(
r#"[{{"name": "broken", "status": "COMPLETED", "conclusion": "{conclusion}"}}]"#
));
assert_eq!(
parse_pr_json(&json).unwrap().ci,
CiState::Failing,
"conclusion {conclusion} must read as Failing"
);
}
}
#[test]
fn checks_passed_counts_accepted_terminals_so_count_matches_ci_label() {
let json = pr_json_with_rollup(
r#"[
{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"},
{"name": "optional", "status": "COMPLETED", "conclusion": "NEUTRAL"},
{"name": "deploy", "status": "COMPLETED", "conclusion": "SKIPPED"}
]"#,
);
let pr = parse_pr_json(&json).unwrap();
assert_eq!(pr.ci, CiState::Passing);
assert_eq!(pr.checks_passed, 3);
assert_eq!(pr.checks_total, 3);
}
#[test]
fn parse_labels_json_returns_remote_labels() {
let json = r#"[
{"name": "bug", "color": "d73a4a", "description": "Something isn't working"},
{"name": "enhancement", "color": "a2eeef", "description": ""},
{"name": "good first issue", "color": "7057ff", "description": "Good for newcomers"}
]"#;
let labels = github::parse_labels_json(json).unwrap();
assert_eq!(labels.len(), 3);
assert_eq!(labels[0].name, "bug");
assert_eq!(labels[0].color, "d73a4a");
assert_eq!(labels[0].description.as_deref(), Some("Something isn't working"));
assert_eq!(labels[1].description.as_deref(), Some(""));
assert_eq!(labels[2].name, "good first issue");
}
#[test]
fn parse_labels_json_handles_empty_array() {
let json = r#"[]"#;
let labels = github::parse_labels_json(json).unwrap();
assert!(labels.is_empty());
}
#[test]
fn parse_labels_json_tolerates_missing_description_field() {
let json = r#"[{"name": "wip", "color": "ededed"}]"#;
let labels = github::parse_labels_json(json).unwrap();
assert_eq!(labels[0].name, "wip");
assert_eq!(labels[0].description, None);
}
#[test]
fn parse_labels_json_rejects_malformed_payload() {
let err = github::parse_labels_json("not json").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("labels"), "should mention labels: {}", msg);
}
#[test]
fn parse_labels_json_normalises_uppercase_color() {
let json = r#"[{"name": "bug", "color": "D73A4A", "description": "broken"}]"#;
let labels = github::parse_labels_json(json).unwrap();
assert_eq!(labels[0].color, "d73a4a");
}
#[test]
fn parse_labels_json_rejects_missing_color_field() {
let json = r#"[{"name": "bug", "description": "broken"}]"#;
let err = github::parse_labels_json(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("color") || msg.contains("missing"),
"error should mention the missing color field: {}",
msg
);
}
#[test]
fn label_create_argv_carries_name_color_description_and_force() {
use gwm::labels::LabelSpec;
let spec = LabelSpec {
name: "good first issue".into(),
description: Some("Good for newcomers".into()),
color: "7057ff".into(),
};
let argv = github::label_create_argv("kbrdn1/gwm-cli", &spec);
let joined = argv.join(" ");
assert!(argv.contains(&"label".to_string()));
assert!(argv.contains(&"create".to_string()));
assert!(argv.contains(&"good first issue".to_string()));
assert!(argv.contains(&"--force".to_string()));
assert!(joined.contains("--color 7057ff"), "color flag missing in {}", joined);
assert!(
joined.contains("--description Good for newcomers"),
"description flag missing in {}",
joined
);
assert!(joined.contains("--repo kbrdn1/gwm-cli"));
}
#[test]
fn label_create_argv_omits_description_when_absent() {
use gwm::labels::LabelSpec;
let spec = LabelSpec {
name: "wip".into(),
description: None,
color: "ededed".into(),
};
let argv = github::label_create_argv("kbrdn1/gwm-cli", &spec);
assert!(
!argv.iter().any(|a| a == "--description"),
"no --description flag when desc absent, got {:?}",
argv
);
}
#[test]
fn label_delete_argv_carries_name_repo_and_yes() {
let argv = github::label_delete_argv("kbrdn1/gwm-cli", "wontfix");
assert!(argv.contains(&"label".to_string()));
assert!(argv.contains(&"delete".to_string()));
assert!(argv.contains(&"wontfix".to_string()));
assert!(argv.contains(&"--yes".to_string()));
assert!(argv.join(" ").contains("--repo kbrdn1/gwm-cli"));
}
#[test]
fn parse_milestones_json_returns_remote_milestones() {
let json = r#"[
{"number": 1, "title": "v0.7.0", "state": "open", "description": "Configurability sprint", "due_on": "2026-07-15T23:59:59Z"},
{"number": 2, "title": "v0.6.0", "state": "closed", "description": "", "due_on": null},
{"number": 3, "title": "Backlog", "state": "open"}
]"#;
let milestones = github::parse_milestones_json(json).unwrap();
assert_eq!(milestones.len(), 3);
assert_eq!(milestones[0].number, 1);
assert_eq!(milestones[0].title, "v0.7.0");
assert_eq!(milestones[0].state, gwm::milestones::MilestoneState::Open);
assert_eq!(milestones[0].description.as_deref(), Some("Configurability sprint"));
assert_eq!(milestones[0].due_on.as_deref(), Some("2026-07-15T23:59:59Z"));
assert_eq!(milestones[1].state, gwm::milestones::MilestoneState::Closed);
assert_eq!(milestones[1].description.as_deref(), Some(""));
assert_eq!(milestones[1].due_on, None);
assert_eq!(milestones[2].title, "Backlog");
assert_eq!(milestones[2].description, None);
assert_eq!(milestones[2].due_on, None);
}
#[test]
fn parse_milestones_json_handles_empty_array() {
let json = r#"[]"#;
let milestones = github::parse_milestones_json(json).unwrap();
assert!(milestones.is_empty());
}
#[test]
fn parse_milestones_json_rejects_unknown_state() {
let json = r#"[{"number": 1, "title": "x", "state": "draft"}]"#;
let err = github::parse_milestones_json(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("draft") || msg.contains("state"),
"should mention state: {}",
msg
);
}
#[test]
fn parse_milestones_json_rejects_malformed_payload() {
let err = github::parse_milestones_json("not json").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("milestones"), "should mention milestones: {}", msg);
}
#[test]
fn milestone_list_argv_uses_repos_endpoint_with_state_all() {
let argv = github::milestone_list_argv("kbrdn1/gwm-cli");
let joined = argv.join(" ");
assert!(argv.contains(&"api".to_string()), "expected 'api', got {:?}", argv);
assert!(
joined.contains("repos/kbrdn1/gwm-cli/milestones"),
"expected milestones endpoint, got {}",
joined
);
assert!(joined.contains("state=all"), "expected state=all, got {}", joined);
}
#[test]
fn milestone_list_argv_uses_paginate_for_repos_with_many_milestones() {
let argv = github::milestone_list_argv("kbrdn1/gwm-cli");
assert!(
argv.contains(&"--paginate".to_string()),
"expected --paginate flag, got {:?}",
argv
);
}
#[test]
fn milestone_create_argv_uses_post_with_title_and_state() {
use gwm::milestones::{MilestoneSpec, MilestoneState};
let spec = MilestoneSpec {
title: "v0.7.0".into(),
description: Some("Configurability sprint".into()),
due_on: Some("2026-07-15T23:59:59Z".into()),
state: MilestoneState::Open,
};
let argv = github::milestone_create_argv("kbrdn1/gwm-cli", &spec);
let joined = argv.join(" ");
assert!(argv.contains(&"api".to_string()));
assert!(argv.contains(&"-X".to_string()));
assert!(argv.contains(&"POST".to_string()));
assert!(
joined.contains("repos/kbrdn1/gwm-cli/milestones"),
"expected milestones endpoint, got {}",
joined
);
assert!(joined.contains("title=v0.7.0"), "missing title=…: {}", joined);
assert!(
joined.contains("description=Configurability sprint"),
"missing description=…: {}",
joined
);
assert!(
joined.contains("due_on=2026-07-15T23:59:59Z"),
"missing due_on=…: {}",
joined
);
assert!(joined.contains("state=open"), "missing state=…: {}", joined);
}
#[test]
fn milestone_create_argv_omits_description_and_due_on_when_absent() {
use gwm::milestones::{MilestoneSpec, MilestoneState};
let spec = MilestoneSpec {
title: "Backlog".into(),
description: None,
due_on: None,
state: MilestoneState::Open,
};
let argv = github::milestone_create_argv("kbrdn1/gwm-cli", &spec);
let joined = argv.join(" ");
assert!(
!joined.contains("description="),
"no description= flag when desc absent, got {}",
joined
);
assert!(
!joined.contains("due_on="),
"no due_on= flag when due_on absent, got {}",
joined
);
assert!(joined.contains("title=Backlog"));
assert!(joined.contains("state=open"));
}
#[test]
fn milestone_update_argv_uses_patch_with_number_in_path() {
use gwm::milestones::{MilestoneSpec, MilestoneState};
let spec = MilestoneSpec {
title: "v0.7.0".into(),
description: None,
due_on: Some("2026-07-15T23:59:59Z".into()),
state: MilestoneState::Closed,
};
let argv = github::milestone_update_argv("kbrdn1/gwm-cli", 42, &spec);
let joined = argv.join(" ");
assert!(argv.contains(&"-X".to_string()));
assert!(argv.contains(&"PATCH".to_string()));
assert!(
joined.contains("repos/kbrdn1/gwm-cli/milestones/42"),
"expected number in path, got {}",
joined
);
assert!(joined.contains("state=closed"));
assert!(joined.contains("due_on=2026-07-15T23:59:59Z"));
}
#[test]
fn milestone_delete_argv_uses_delete_with_number_in_path() {
let argv = github::milestone_delete_argv("kbrdn1/gwm-cli", 7);
let joined = argv.join(" ");
assert!(argv.contains(&"-X".to_string()));
assert!(argv.contains(&"DELETE".to_string()));
assert!(
joined.contains("repos/kbrdn1/gwm-cli/milestones/7"),
"expected number in path, got {}",
joined
);
}
#[test]
fn branch_link_summary_renders_human_readable() {
let link = BranchLink {
issue: Some(42),
pr: Some(61),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::BranchName,
pr_source: LinkSource::Explicit,
};
let s = link.summary();
assert!(s.contains("#42"), "summary should mention issue #42: {}", s);
assert!(s.contains("#61"), "summary should mention PR #61: {}", s);
}
#[test]
fn delete_label_refuses_dash_prefixed_remote_name_before_shelling_out() {
let err = github::delete_label("owner/repo", "-h").unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("labels (remote)"),
"error must scope itself to the remote prune path; got: {}",
msg
);
assert!(
msg.contains("\"-h\"") || msg.contains("-h"),
"error must name the offending label; got: {}",
msg
);
}
#[test]
fn apply_detected_pr_sets_pr_with_detected_source_when_none_linked() {
let mut link = BranchLink {
issue: Some(42),
pr: None,
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::BranchName,
pr_source: LinkSource::None,
};
github::apply_detected_pr(&mut link, Some(128));
assert_eq!(link.pr, Some(128));
assert_eq!(link.pr_source, LinkSource::Detected);
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
}
#[test]
fn apply_detected_pr_leaves_explicit_pr_untouched() {
let mut link = BranchLink {
issue: None,
pr: Some(61),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::None,
pr_source: LinkSource::Explicit,
};
github::apply_detected_pr(&mut link, Some(128));
assert_eq!(link.pr, Some(61));
assert_eq!(link.pr_source, LinkSource::Explicit);
}
#[test]
fn apply_detected_pr_is_noop_when_nothing_detected() {
let mut link = BranchLink::empty();
github::apply_detected_pr(&mut link, None);
assert_eq!(link.pr, None);
assert_eq!(link.pr_source, LinkSource::None);
}
#[test]
fn detected_pr_renders_in_summary_like_any_pr() {
let mut link = BranchLink::empty();
github::apply_detected_pr(&mut link, Some(128));
assert_eq!(link.summary(), "PR #128");
}
#[test]
fn find_pr_argv_pins_the_gh_pr_list_contract() {
let argv = github::find_pr_argv("kbrdn1/gwm-cli", "feat/#181-auto-detect-pr");
assert_eq!(
argv,
vec![
"pr",
"list",
"--repo",
"kbrdn1/gwm-cli",
"--head",
"feat/#181-auto-detect-pr",
"--state",
"all",
"--json",
"number",
"--limit",
"1",
]
);
}
#[test]
fn parse_pr_list_number_returns_first_pr() {
assert_eq!(github::parse_pr_list_number(r#"[{"number":128}]"#).unwrap(), Some(128));
}
#[test]
fn parse_pr_list_number_returns_none_for_empty_array() {
assert_eq!(github::parse_pr_list_number("[]").unwrap(), None);
}
#[test]
fn parse_pr_list_number_errors_on_malformed_json() {
assert!(github::parse_pr_list_number("not json").is_err());
}
#[test]
fn parse_pr_head_json_extracts_author_head_and_base() {
let json = r#"{
"number": 312,
"author": { "login": "alice" },
"headRefName": "feat/spike-x",
"baseRefName": "main"
}"#;
let head = parse_pr_head_json(json).unwrap();
assert_eq!(head.number, 312);
assert_eq!(head.author, "alice");
assert_eq!(head.head_ref_name, "feat/spike-x");
assert_eq!(head.base_ref_name, "main");
}
#[test]
fn parse_pr_head_json_tolerates_a_null_author() {
let json = r#"{ "number": 7, "author": null, "headRefName": "x", "baseRefName": "dev" }"#;
let head = parse_pr_head_json(json).unwrap();
assert_eq!(head.author, "");
assert_eq!(head.base_ref_name, "dev");
}
#[test]
fn gh_command_line_uses_the_program_basename_and_joins_args() {
use std::ffi::{OsStr, OsString};
let args: Vec<OsString> = ["issue", "view", "226", "--json", "title,body"]
.iter()
.map(OsString::from)
.collect();
assert_eq!(
github::gh_command_line(OsStr::new("/opt/homebrew/bin/gh"), &args),
"gh issue view 226 --json title,body"
);
assert_eq!(
github::gh_command_line(OsStr::new("gh"), &args),
"gh issue view 226 --json title,body"
);
}