Files
max-sixty__worktrunk/tests/integration_tests/ci_status.rs
Worktrunk Bot aa4e527f35 refactor(git): drop Branch::push_remote, unused since the @{push} fix (#3833)
Nightly sweep finding. `Branch::push_remote()` has had no callers —
production or test — since
[#769](https://github.com/max-sixty/worktrunk/pull/769) (Jan 2026),
which replaced its only caller in the CI-status path with
`push_remote_url()`. This drops it, and fixes a stale doc comment that
named it.

## Why it went unnoticed

It's `pub` on a public lib type, so rustc's `dead_code` lint never fires
on it. `git log -S` puts the last caller's removal in `c4f1c0730`
(#769): `gh pr checkout` sets `branch.<name>.pushremote` to a URL rather
than a remote name, and `@{push}` — which is what `push_remote()`
resolves through — fails in that case. `push_remote_url()` uses
`%(push:remotename)` instead, which handles both.

So the method isn't merely unused; its docstring still advertises the
`@{push}` resolution chain that #769 established is wrong for the case
the codebase actually hits, which makes it read as a live alternative to
the function that superseded it.

## The stale comment

`setup_push_tracking` in `tests/integration_tests/default_branch.rs` was
documented as existing so `branch.push_remote()` and `github_push_url()`
work. The first is what this PR deletes; the second has never existed
anywhere in the tree (`grep` finds that comment as its only occurrence).
Its four call sites all call `push_remote_url()`, so the comment now
names that.

## Verification

No regression test accompanies this — there's no behavior to pin, since
the deleted method had no callers to change the behavior of. The proof
is negative and the compiler carries it: `cargo build --all-targets` and
`cargo clippy --all-targets -- -D warnings` both pass, which they could
not if any call site remained.

Also ran the suites covering the touched area: `cargo test --test
integration default_branch` (63 passed) and `cargo test --lib
git::repository` (178 passed).

<details><summary>Confirming there are no callers</summary>

Every mention of the bare identifier in the tree before this change:

```
src/git/repository/branch.rs:151:    pub fn push_remote(&self) -> Option<String> {          # the definition
src/git/repository/branch.rs:186:                let push_remote = self                    # local var in push_remote_url
src/git/repository/branch.rs:197:                if push_remote.contains("://") ...        # same local
src/git/repository/branch.rs:198:                    Some(push_remote)                     # same local
src/git/repository/branch.rs:200:                    self.repo.effective_remote_url(...)   # same local
tests/integration_tests/default_branch.rs:439:  /// ... `branch.push_remote()` ...            # the stale comment
```

The lines in `push_remote_url` are a local binding of the same name, not
calls. `switch.rs:808` writes the `branch.<name>.pushRemote` git-config
key and is unrelated.

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-17 01:48:38 -07:00

1528 lines
54 KiB
Rust

//! Tests for CI status detection and parsing
//!
//! These tests verify that the CI status parsing code correctly handles
//! JSON responses from GitHub (gh) and GitLab (glab) CLI tools.
use crate::common::{
TestRepo, make_snapshot_cmd,
mock_commands::{MockConfig, MockResponse},
repo, setup_snapshot_settings, wt_command,
};
use ansi_str::AnsiStr;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::path::Path;
use std::process::Command;
/// Get the HEAD commit SHA for a branch
fn branch_sha(repo: &TestRepo, branch: &str) -> String {
repo.git_output(&["rev-parse", branch])
}
/// Set up tracking for all branches so `push_remote_url()` resolves a push
/// destination.
///
/// `%(push:remotename)` reads the `branch.<name>.{remote,merge}` config. The
/// remote-tracking ref that fetch/push would normally create is set up
/// alongside it, so the fixture matches a real clone.
fn setup_tracking_for_all_branches(repo: &TestRepo, remote: &str) {
for branch in ["feature", "feature-a", "feature-b", "feature-c", "main"] {
repo.run_git(&["config", &format!("branch.{}.remote", branch), remote]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", branch),
&format!("refs/heads/{}", branch),
]);
// Create the remote-tracking ref
repo.run_git(&[
"update-ref",
&format!("refs/remotes/{}/{}", remote, branch),
branch,
]);
}
}
/// Helper to run a CI status test with the given mock data
fn run_ci_status_test(repo: &mut TestRepo, snapshot_name: &str, pr_json: &str) {
repo.setup_mock_gh_with_ci_data(pr_json);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
/// Setup mock `gh` with configurable `pr list` and `api repos/.../check-runs`
/// responses.
fn setup_mock_gh_with_api_data(
repo: &TestRepo,
pr_json: &str,
api_responses: &[(&str, &str)],
) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
let mut gh = MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("pr list", MockResponse::output(pr_json));
for (command, response) in api_responses {
gh = gh.command(command, MockResponse::output(response));
}
gh.command("_default", MockResponse::exit(1))
.write(&mock_bin);
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
mock_bin
}
/// Configure command environment for local gh/glab mocks.
fn configure_mock_ci_env(cmd: &mut Command, mock_bin: &Path) {
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", mock_bin);
let (path_var_name, current_path) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));
let mut paths: Vec<std::path::PathBuf> = current_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.to_path_buf());
let new_path = std::env::join_paths(&paths).unwrap();
cmd.env(path_var_name, new_path);
}
/// Setup a repo with GitHub remote and feature worktree, returns head SHA
fn setup_github_repo_with_feature(repo: &mut TestRepo) -> String {
// Set origin URL (origin already exists from fixture, just update URL)
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-owner/test-repo.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
// =============================================================================
// PR conflict status
// =============================================================================
#[rstest]
fn test_list_full_with_github_pr_conflicts(mut repo: TestRepo) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "DIRTY",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "github_pr_conflicts", &pr_json);
}
#[rstest]
fn test_list_full_json_ci_repo_from_pr_url(mut repo: TestRepo) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
repo.setup_mock_gh_with_ci_data(&pr_json);
let mut cmd = repo.wt_command();
cmd.args(["list", "--full", "--format=json"]);
repo.configure_mock_commands(&mut cmd);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt list --full --format=json should succeed\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let rows: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let row = rows
.iter()
.find(|row| row["branch"].as_str() == Some("feature"))
.expect("feature row should be present");
let ci = &row["ci"];
assert_eq!(
ci["repo_url"].as_str(),
Some("https://github.com/test-owner/test-repo")
);
assert_eq!(
ci["repo"]["url"].as_str(),
Some("https://github.com/test-owner/test-repo")
);
assert_eq!(ci["repo"]["provider"].as_str(), Some("github"));
assert_eq!(ci["repo"]["host"].as_str(), Some("github.com"));
assert_eq!(ci["repo"]["owner"].as_str(), Some("test-owner"));
assert_eq!(ci["repo"]["name"].as_str(), Some("test-repo"));
assert!(
ci["repo"].get("remote").is_none(),
"ci.repo should not include the local remote name"
);
}
#[rstest]
fn test_list_full_json_ci_repo_uses_configured_provider_for_opaque_host(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://git.company.com/test-owner/test-repo.git",
]);
repo.write_project_config("[forge]\nplatform = \"github\"\n");
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://git.company.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
repo.setup_mock_gh_with_ci_data(&pr_json);
let mut cmd = repo.wt_command();
cmd.args(["list", "--full", "--format=json"]);
repo.configure_mock_commands(&mut cmd);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt list --full --format=json should succeed\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let rows: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
let row = rows
.iter()
.find(|row| row["branch"].as_str() == Some("feature"))
.expect("feature row should be present");
assert_eq!(row["repo"]["provider"].as_str(), Some("github"));
assert_eq!(row["repo"]["host"].as_str(), Some("git.company.com"));
let ci = &row["ci"];
assert_eq!(
ci["repo_url"].as_str(),
Some("https://git.company.com/test-owner/test-repo")
);
assert_eq!(
ci["repo"]["url"].as_str(),
Some("https://git.company.com/test-owner/test-repo")
);
assert_eq!(ci["repo"]["provider"].as_str(), Some("github"));
assert_eq!(ci["repo"]["host"].as_str(), Some("git.company.com"));
assert_eq!(ci["repo"]["owner"].as_str(), Some("test-owner"));
assert_eq!(ci["repo"]["name"].as_str(), Some("test-repo"));
}
// =============================================================================
// Review state
// =============================================================================
#[rstest]
fn test_list_full_with_github_changes_requested(mut repo: TestRepo) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}},
"reviewDecision": "CHANGES_REQUESTED",
"isDraft": false
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "github_pr_changes_requested", &pr_json);
}
// =============================================================================
// Special case tests (unique scenarios that don't fit parameterization)
// =============================================================================
#[rstest]
fn test_list_full_with_stale_pr(mut repo: TestRepo) {
setup_github_repo_with_feature(&mut repo);
// Make additional commit locally (not pushed)
let worktree_path = repo.worktrees.get("feature").unwrap().clone();
std::fs::write(worktree_path.join("new_file.txt"), "new content").unwrap();
repo.stage_all(&worktree_path);
repo.run_git_in(&worktree_path, &["commit", "-m", "Local commit"]);
// PR HEAD differs from local HEAD - simulates stale PR
let pr_json = r#"[{
"number": 1,
"headRefOid": "old_sha_from_before_local_commit",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{"status": "COMPLETED", "conclusion": "SUCCESS"}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {"login": "test-owner"}
}]"#;
run_ci_status_test(&mut repo, "stale_pr", pr_json);
}
#[rstest]
fn test_list_full_filters_by_repo_owner(mut repo: TestRepo) {
// Use different org name
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/my-org/test-repo.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
// Multiple PRs - only one from our org (should filter to my-org's PR)
let pr_json = format!(
r#"[
{{
"number": 99,
"headRefOid": "wrong_sha",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [{{"status": "COMPLETED", "conclusion": "FAILURE"}}],
"url": "https://github.com/other-org/test-repo/pull/99",
"headRepositoryOwner": {{"login": "other-org"}}
}},
{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [{{"status": "COMPLETED", "conclusion": "SUCCESS"}}],
"url": "https://github.com/my-org/test-repo/pull/1",
"headRepositoryOwner": {{"login": "my-org"}}
}}
]"#,
head_sha
);
run_ci_status_test(&mut repo, "filters_by_repo_owner", &pr_json);
}
#[rstest]
fn test_list_full_with_configured_platform_github(mut repo: TestRepo) {
// Set a non-GitHub remote (bitbucket) as origin - platform won't be auto-detected
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://bitbucket.org/test-owner/test-repo.git",
]);
// Add a GitHub remote for PR detection (the configured platform still needs a
// GitHub remote to determine which repo's PRs to check)
repo.run_git(&[
"remote",
"add",
"github",
"https://github.com/test-owner/test-repo.git",
]);
// Set the platform explicitly in project config
repo.write_project_config(
r#"
[ci]
platform = "github"
"#,
);
// Create a feature branch with tracking to the github remote
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "github");
// Get actual commit SHA
let head_sha = branch_sha(&repo, "feature");
// Setup mock gh with PR data - this should work because the platform is set to github
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
repo.setup_mock_gh_with_ci_data(&pr_json);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
// The configured platform should force GitHub detection even with a bitbucket remote
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_list_full_with_gitlab_remote(mut repo: TestRepo) {
// Set GitLab remote URL - tests get_gitlab_host_for_repo path
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.example.com/test-owner/test-repo.git",
]);
// Create a feature branch
repo.add_worktree("feature");
// No mock glab setup - this tests the hint path when glab isn't available
// The get_gitlab_host_for_repo function is called to detect GitLab platform
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
// Don't configure mocks - we want to test the "no CI tool" hint path
// which exercises get_gitlab_host_for_repo
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_list_full_with_gitea_forge_platform(mut repo: TestRepo) {
// `forge.platform = "gitea"` resolves to the (experimental) Gitea CI
// backend, but without `tea` installed there's nothing to show — CI stays
// blank, and `wt list` must not warn that the value is "invalid". (Gitea CI
// detection with a mocked `tea` is covered by the `gitea_*` tests below.)
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/test-owner/test-repo.git",
]);
repo.write_project_config("[forge]\nplatform = \"gitea\"\n");
repo.add_worktree("feature");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_list_full_with_invalid_configured_platform(mut repo: TestRepo) {
// Set GitHub remote URL
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-owner/test-repo.git",
]);
// Set an invalid platform value - should warn and fall back to URL detection
repo.write_project_config(
r#"
[ci]
platform = "invalid_platform"
"#,
);
// Create a feature branch with tracking
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
// Setup mock gh - platform should fall back to GitHub via URL detection
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
repo.setup_mock_gh_with_ci_data(&pr_json);
let mut settings = setup_snapshot_settings(&repo);
// Normalize worker thread ID prefix in log output (e.g., [n], [z], [A] -> [W]).
// `label_for_thread_index` emits `0`, `a`-`z`, `A`-`Z`, and `?` (the latter
// for thread IDs above 52, which appear on high-core machines where the
// rayon pools span enough threads); the filter covers the whole alphabet.
settings.add_filter(r"\[[a-zA-Z0-9?]\]", "[W]");
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
// The snapshot asserts on the `log::warn!` diagnostic for the
// invalid platform value, which is suppressed at the default
// baseline filter (`-v 0` → Off). Opt this test into Warn-level
// logging via `RUST_LOG`, which `logging::init` merges with the
// verbose flag via `tracing_subscriber::EnvFilter` (env wins
// when set).
cmd.env("RUST_LOG", "warn");
// Invalid platform should fall back to URL detection (GitHub)
assert_cmd_snapshot!(cmd);
});
}
// =============================================================================
// GitLab MR status tests
// =============================================================================
/// Helper to run a GitLab CI status test with the given mock data
fn run_gitlab_ci_status_test(
repo: &mut TestRepo,
snapshot_name: &str,
mr_json: &str,
project_id: Option<u64>,
) {
repo.setup_mock_glab_with_ci_data(mr_json, project_id);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
/// Setup a repo with GitLab remote and feature worktree, returns head SHA
fn setup_gitlab_repo_with_feature(repo: &mut TestRepo) -> String {
// Set origin URL (origin already exists from fixture, just update URL)
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/test-group/test-project.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
#[rstest]
fn test_list_full_with_gitlab_mr_conflicts(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": true,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha
);
run_gitlab_ci_status_test(&mut repo, "gitlab_mr_conflicts", &mr_json, Some(12345));
}
#[rstest]
fn test_list_full_with_gitlab_stale_mr(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
// Make additional commit locally (not pushed)
let worktree_path = repo.worktrees.get("feature").unwrap().clone();
std::fs::write(worktree_path.join("new_file.txt"), "new content").unwrap();
repo.stage_all(&worktree_path);
repo.run_git_in(&worktree_path, &["commit", "-m", "Local commit"]);
// MR HEAD differs from local HEAD - simulates stale MR
let mr_json = r#"[{
"iid": 1,
"sha": "old_sha_from_before_local_commit",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {"status": "success"},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}]"#;
run_gitlab_ci_status_test(&mut repo, "gitlab_stale_mr", mr_json, Some(12345));
}
#[rstest]
fn test_list_full_with_gitlab_filters_by_project_id(mut repo: TestRepo) {
// Use a specific project for our repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/my-group/my-project.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
// Multiple MRs - only one from our project (should filter to project 99999)
// The "other" MR is listed first to prove filtering works (not just taking first element)
let mr_json = format!(
r#"[
{{
"iid": 99,
"sha": "wrong_sha",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "failed"}},
"source_project_id": 11111,
"web_url": "https://gitlab.com/other-group/other-project/-/merge_requests/99"
}},
{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 99999,
"web_url": "https://gitlab.com/my-group/my-project/-/merge_requests/1"
}}
]"#,
head_sha
);
run_gitlab_ci_status_test(
&mut repo,
"gitlab_filters_by_project_id",
&mr_json,
Some(99999),
);
}
// =============================================================================
// GitLab project ID edge cases (PR #846 panic prevention)
// =============================================================================
/// Test that single MR without project ID works (unambiguous case).
///
/// When `glab repo view` fails to return a project ID but there's only one MR,
/// we can safely use it since there's no ambiguity.
#[rstest]
fn test_list_full_with_gitlab_single_mr_no_project_id(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
// Single MR - should work even without project ID to filter by
let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha
);
// Pass None for project_id to trigger "no project ID" path
run_gitlab_ci_status_test(&mut repo, "gitlab_single_mr_no_project_id", &mr_json, None);
}
/// Test that empty MR list without project ID returns None gracefully.
///
/// When no MRs are found and we don't have a project ID, the code should
/// return None without panicking. This falls through to pipeline detection.
#[rstest]
fn test_list_full_with_gitlab_empty_mr_list_no_project_id(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
// Empty MR list + no project ID -> falls through to pipeline check
run_gitlab_ci_status_test(
&mut repo,
"gitlab_empty_mr_list_no_project_id",
"[]", // Empty MR list
None, // No project ID
);
}
/// Test that multiple MRs without project ID are skipped (ambiguous case).
///
/// When there are multiple MRs with the same branch name and we can't determine
/// which project we're in, we skip CI detection rather than showing the wrong one.
/// This falls through to pipeline detection via `glab ci list`.
#[rstest]
fn test_list_full_with_gitlab_multiple_mrs_no_project_id(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
// Multiple MRs from different projects - ambiguous without project ID
let mr_json = format!(
r#"[
{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "failed"}},
"source_project_id": 11111,
"web_url": "https://gitlab.com/org-a/project/-/merge_requests/1"
}},
{{
"iid": 2,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 22222,
"web_url": "https://gitlab.com/org-b/project/-/merge_requests/2"
}}
]"#,
head_sha, head_sha
);
// Pass None for project_id - should skip MR detection due to ambiguity
// and fall through to pipeline detection (which will show NoCI since
// our mock returns empty pipeline list)
run_gitlab_ci_status_test(
&mut repo,
"gitlab_multiple_mrs_no_project_id",
&mr_json,
None,
);
}
// =============================================================================
// URL-based pushremote tests (gh pr checkout scenario)
// =============================================================================
/// Test that CI status works when pushremote is a URL instead of a remote name.
///
/// This simulates the `gh pr checkout` scenario where git sets:
/// - branch.<name>.pushremote = https://github.com/fork-owner/repo.git (a URL)
/// - branch.<name>.merge = refs/pull/123/head (a PR ref)
///
/// Git's @{push} syntax fails when the push remote is a URL, so
/// `push_remote_url()` reads `%(push:remotename)`, which returns the URL directly.
#[rstest]
fn test_list_full_with_url_based_pushremote(mut repo: TestRepo) {
// Set origin URL (the upstream repo where PRs are opened)
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/upstream-owner/test-repo.git",
]);
repo.add_worktree("feature");
let head_sha = branch_sha(&repo, "feature");
// Simulate `gh pr checkout` behavior:
// - Sets pushremote to the fork URL (not a remote name)
// - Sets merge to a PR ref (not a normal branch ref)
repo.run_git(&[
"config",
"branch.feature.pushremote",
"https://github.com/fork-owner/test-repo.git", // URL, not remote name
]);
repo.run_git(&[
"config",
"branch.feature.merge",
"refs/pull/123/head", // PR ref, not branch ref
]);
// The PR comes from the fork owner (matches pushremote URL)
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/upstream-owner/test-repo/pull/123",
"headRepositoryOwner": {{"login": "fork-owner"}}
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "url_based_pushremote", &pr_json);
}
/// When a branch has no PR yet, fallback check-runs detection should query the
/// branch's pushremote repository rather than the first GitHub remote in the
/// repo.
#[rstest]
fn test_list_full_with_branch_fallback_using_fork_pushremote(mut repo: TestRepo) {
setup_github_repo_with_feature(&mut repo);
let feature_a_sha = branch_sha(&repo, "feature-a");
repo.run_git(&[
"config",
"branch.feature-a.pushremote",
"https://github.com/fork-owner/test-repo.git",
]);
let fork_checks = r#"[{"status":"COMPLETED","conclusion":"SUCCESS"}]"#;
let mock_bin = setup_mock_gh_with_api_data(
&repo,
"[]",
&[
(
&format!("api repos/upstream-owner/test-repo/commits/{feature_a_sha}/check-runs"),
"[]",
),
(
&format!("api repos/fork-owner/test-repo/commits/{feature_a_sha}/check-runs"),
fork_checks,
),
],
);
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
configure_mock_ci_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt list should succeed");
let stdout = String::from_utf8_lossy(&output.stdout)
.ansi_strip()
.into_owned();
let feature_a_line = stdout
.lines()
.find(|line| line.contains("feature-a"))
.expect("expected feature-a line in wt list output");
assert!(
feature_a_line.contains("#"),
"expected feature-a to show passed branch CI from the fork repo fallback\nstdout:\n{stdout}",
);
}
// =============================================================================
// GitLab error path tests
// =============================================================================
/// Test that when `glab mr view` fails after finding an MR, we show error status (not NoCI).
#[rstest]
fn test_list_full_with_gitlab_mr_view_failure(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
// Set up mock where mr list succeeds but mr view fails
let mr_list_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"source_project_id": 12345,
"web_url": "https://gitlab.com/test/repo/-/merge_requests/1"
}}]"#,
head_sha
);
repo.setup_mock_glab_with_failing_mr_view(&mr_list_json, Some(12345));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitlab_mr_view_failure", cmd);
});
}
/// Test that rate limit errors in `glab ci list` show error status (not NoCI).
///
/// This exercises the `is_retriable_error` check in `detect_gitlab_pipeline`,
/// which is the fallback path when no MR exists for a branch.
#[rstest]
fn test_list_full_with_gitlab_ci_rate_limit(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
// Mock returns empty MR list (no MRs), so we fall through to ci list,
// which returns a rate limit error
repo.setup_mock_glab_with_ci_rate_limit(Some(12345));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitlab_ci_rate_limit", cmd);
});
}
/// A branch pipeline with no MR renders the bare `#` colored by pipeline
/// status, hyperlinked to the pipeline page (the success path of
/// `detect_gitlab_pipeline`; the rate-limit test above covers its error path).
#[rstest]
fn test_list_full_with_gitlab_pipeline(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let pipeline_json = format!(
r#"[{{
"status": "success",
"sha": "{head_sha}",
"web_url": "https://gitlab.com/test-group/test-project/-/pipelines/123"
}}]"#
);
repo.setup_mock_glab_with_pipeline(&pipeline_json, Some(12345));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitlab_pipeline_success", cmd);
});
}
/// An expired CI cache entry is refetched, not served: a stale "failed"
/// entry past its 30-60s TTL must not mask the fresh status.
#[rstest]
fn test_ci_cache_expired_entry_refetches(mut repo: TestRepo) {
use crate::common::TEST_EPOCH;
let head_sha = setup_github_repo_with_feature(&mut repo);
// Fresh fetch reports passed
let pr_json = format!(
r#"[{{
"number": 1,
"headRefOid": "{head_sha}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [{{"status": "COMPLETED", "conclusion": "SUCCESS"}}],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#
);
repo.setup_mock_gh_with_ci_data(&pr_json);
// Expired cache entry claiming the CI failed
let git_dir = repo.git_output(&["rev-parse", "--git-common-dir"]);
let git_path = if Path::new(&git_dir).is_absolute() {
std::path::PathBuf::from(&git_dir)
} else {
repo.root_path().join(&git_dir)
};
let cache_dir = git_path.join("wt").join("cache").join("ci-status");
std::fs::create_dir_all(&cache_dir).unwrap();
let expired_at = TEST_EPOCH - 3600;
std::fs::write(
cache_dir.join("feature.json"),
format!(
r##"{{"status":{{"ci_status":"failed","source":"pr","is_stale":false,"number":{{"number":1,"sigil":"#"}}}},"checked_at":{expired_at},"head":"{head_sha}","branch":"feature"}}"##
),
)
.unwrap();
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
// Debug logging evaluates the cache-expiry diagnostics' format args
cmd.env("RUST_LOG", "worktrunk=debug");
cmd.env("CLICOLOR_FORCE", "1");
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
// Fresh fetch wins: green #1, and the stale red #1 is gone
assert!(
stdout.contains("\x1b[32m#1"),
"expired entry should refetch as green #1:\n{stdout}"
);
assert!(
!stdout.contains("\x1b[31m#1"),
"stale failed entry must not be served:\n{stdout}"
);
}
// =============================================================================
// Azure DevOps CI status tests
// =============================================================================
/// Set up a repo with an Azure DevOps remote and a `feature` worktree.
/// Returns the `feature` branch HEAD SHA.
fn setup_azure_repo_with_feature(repo: &mut TestRepo) -> String {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
/// Run an Azure DevOps CI status test with the given `az repos pr list` and
/// `az pipelines runs list` mock responses.
fn run_azure_ci_status_test(
repo: &mut TestRepo,
snapshot_name: &str,
pr_list_json: &str,
runs_json: &str,
) {
repo.setup_mock_az_with_ci_data(pr_list_json, runs_json);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
/// An active PR with `mergeStatus: "conflicts"` surfaces as a Conflicts
/// indicator (exercises `detect_azure_pr`).
#[rstest]
fn test_list_full_with_azure_pr_conflicts(mut repo: TestRepo) {
let head_sha = setup_azure_repo_with_feature(&mut repo);
let pr_list_json = format!(
r#"[{{
"pullRequestId": 7,
"mergeStatus": "conflicts",
"lastMergeSourceCommit": {{"commitId": "{}"}},
"repository": {{"name": "test-repo", "project": {{"name": "myproject"}}}}
}}]"#,
head_sha
);
run_azure_ci_status_test(&mut repo, "azure_pr_conflicts", &pr_list_json, "[]");
}
/// An active PR with `mergeStatus: "queued"` surfaces as a Running indicator.
#[rstest]
fn test_list_full_with_azure_pr_queued(mut repo: TestRepo) {
let head_sha = setup_azure_repo_with_feature(&mut repo);
let pr_list_json = format!(
r#"[{{
"pullRequestId": 7,
"mergeStatus": "queued",
"lastMergeSourceCommit": {{"commitId": "{}"}},
"repository": {{"name": "test-repo", "project": {{"name": "myproject"}}}}
}}]"#,
head_sha
);
run_azure_ci_status_test(&mut repo, "azure_pr_queued", &pr_list_json, "[]");
}
/// No PR for the branch falls back to the latest pipeline run
/// (exercises `detect_azure_pipeline` via `parse_azure_pipeline_status`).
#[rstest]
fn test_list_full_with_azure_passed_pipeline(mut repo: TestRepo) {
let head_sha = setup_azure_repo_with_feature(&mut repo);
let runs_json = format!(
r#"[{{
"id": 4242,
"status": "completed",
"result": "succeeded",
"sourceVersion": "{}"
}}]"#,
head_sha
);
run_azure_ci_status_test(&mut repo, "azure_pipeline_passed", "[]", &runs_json);
}
/// A pipeline run from a different SHA than local HEAD is marked stale (dimmed).
#[rstest]
fn test_list_full_with_azure_stale_pipeline(mut repo: TestRepo) {
setup_azure_repo_with_feature(&mut repo);
let runs_json = r#"[{
"id": 4242,
"status": "completed",
"result": "succeeded",
"sourceVersion": "0000000000000000000000000000000000000000"
}]"#;
run_azure_ci_status_test(&mut repo, "azure_stale_pipeline", "[]", runs_json);
}
/// No PR and no pipeline runs → no CI indicator.
#[rstest]
fn test_list_full_with_azure_no_ci(mut repo: TestRepo) {
setup_azure_repo_with_feature(&mut repo);
run_azure_ci_status_test(&mut repo, "azure_no_ci", "[]", "[]");
}
/// A retriable error from `az repos pr list` (e.g., HTTP 429) surfaces as an
/// error indicator rather than NoCI (exercises the `is_retriable_error` branch
/// in `detect_azure_pr`).
#[rstest]
fn test_list_full_with_azure_pr_list_retriable_error(mut repo: TestRepo) {
setup_azure_repo_with_feature(&mut repo);
repo.setup_mock_az_with_detection_errors(
Some("ERROR: HTTP error 429: Too Many Requests"),
None,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("azure_pr_list_retriable_error", cmd);
});
}
/// A retriable error from `az pipelines runs list` surfaces as an error
/// indicator (exercises the `is_retriable_error` branch in
/// `detect_azure_pipeline`, reached when no PR exists for the branch).
#[rstest]
fn test_list_full_with_azure_pipeline_retriable_error(mut repo: TestRepo) {
setup_azure_repo_with_feature(&mut repo);
repo.setup_mock_az_with_detection_errors(
None,
Some("ERROR: HTTP error 429: Too Many Requests"),
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("azure_pipeline_retriable_error", cmd);
});
}
// =============================================================================
// Gitea CI status tests
// =============================================================================
/// Set up a repo with a Gitea remote and a `feature` worktree carrying its own
/// commit (so its HEAD SHA differs from `main`'s, keeping the per-branch
/// commit-status lookups distinct). Returns the `feature` HEAD SHA.
fn setup_gitea_repo_with_feature(repo: &mut TestRepo) -> String {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(
&feature_wt,
"gitea-ci.txt",
"gitea ci test",
"feat: gitea feature",
);
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
/// Run a Gitea CI status test with the given `tea api .../pulls` and
/// `tea api .../commits/{sha}/status` mock responses, each an
/// `(HTTP status, body)` pair.
fn run_gitea_ci_status_test(
repo: &mut TestRepo,
snapshot_name: &str,
head_sha: &str,
pulls: (&str, &str),
status: (&str, &str),
) {
repo.setup_mock_tea_with_ci_data("owner", "test-repo", head_sha, pulls, status);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
/// Gitea's `APIError` body, which the API returns in place of the resource
/// whenever a request fails — and which `tea api` copies to stdout with exit 0,
/// since it never reads the HTTP status. The message is a 500's wrapped
/// internal error (Gitea passes it through for an admin token), so
/// `is_retriable_error` recognizes the cause.
const GITEA_API_ERROR_BODY: &str = r#"{
"message": "pq: dial tcp 10.0.0.5:5432: connect: connection refused",
"url": "https://gitea.example.com/api/swagger"
}"#;
/// The same body a production Gitea sends a non-admin token for that 500:
/// `APIError` with the message blanked. Nothing in it says "error", which is
/// why the status line rather than the body is what the backend reads.
const GITEA_BLANK_ERROR_BODY: &str = r#"{
"message": "",
"url": "https://gitea.example.com/api/swagger"
}"#;
/// Build a one-PR `tea api .../pulls` response for the `feature` branch.
fn gitea_feature_pr_json(head_sha: &str, mergeable: bool) -> String {
format!(
r#"[{{
"number": 7,
"mergeable": {mergeable},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/7",
"head": {{
"ref": "feature",
"sha": "{head_sha}",
"repo": {{"owner": {{"login": "owner"}}}}
}}
}}]"#
)
}
/// An open PR with `mergeable: false` surfaces as a Conflicts indicator
/// (exercises `detect_gitea_pr`).
#[rstest]
fn test_list_full_with_gitea_pr_conflicts(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_pr_conflicts",
&head_sha,
("200 OK", &gitea_feature_pr_json(&head_sha, false)),
("200 OK", r#"{"state":"","total_count":0}"#),
);
}
/// No PR for the branch falls back to the HEAD commit's combined status
/// (exercises `detect_gitea_commit_status` and the `failure` state mapping).
#[rstest]
fn test_list_full_with_gitea_commit_status(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_commit_status",
&head_sha,
("200 OK", "[]"),
("200 OK", r#"{"state":"failure","total_count":1}"#),
);
}
/// No PR and no commit statuses → no CI indicator.
#[rstest]
fn test_list_full_with_gitea_no_ci(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_no_ci",
&head_sha,
("200 OK", "[]"),
("200 OK", r#"{"state":"","total_count":0}"#),
);
}
/// A Gitea 500 whose `APIError` body names a retriable cause surfaces as an
/// error indicator rather than NoCI. `tea api` exits 0 here — it copies the
/// response body through whatever the status — so the status line `--include`
/// puts on stderr is what separates this from a PR list.
#[rstest]
fn test_list_full_with_gitea_pr_error_body(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_pr_error_body",
&head_sha,
("500 Internal Server Error", GITEA_API_ERROR_BODY),
("200 OK", r#"{"state":"","total_count":0}"#),
);
}
/// The same `APIError` body from the commit-status lookup (when no PR exists
/// for the branch). Read as data this one is the quieter bug: every field of
/// `GiteaCombinedStatus` defaults, so the error body would deserialize as "no
/// statuses" and paint a blank cell.
#[rstest]
fn test_list_full_with_gitea_commit_status_error_body(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_commit_status_error_body",
&head_sha,
("200 OK", "[]"),
("500 Internal Server Error", GITEA_API_ERROR_BODY),
);
}
/// A 500 the message of which Gitea blanked still reaches the cell as an error.
/// The status is the whole basis: the body says nothing, so the text sniff that
/// used to decide had nothing to match and painted the same blank cell as a
/// healthy branch with no CI.
#[rstest]
fn test_list_full_with_gitea_blanked_500(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_blanked_500",
&head_sha,
("500 Internal Server Error", GITEA_BLANK_ERROR_BODY),
("200 OK", r#"{"state":"","total_count":0}"#),
);
}
/// A 404 is the other half of that: also an error, also carrying no useful
/// text, but nothing a later `wt list` would answer differently — so the cell
/// stays blank rather than showing an indicator that never clears. Pairs with
/// the 500 above; between them the status is doing the deciding, not the body.
#[rstest]
fn test_list_full_with_gitea_not_found(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
run_gitea_ci_status_test(
&mut repo,
"gitea_not_found",
&head_sha,
(
"404 Not Found",
r#"{"message":"user redirect does not exist [name: owner]"}"#,
),
("200 OK", r#"{"state":"","total_count":0}"#),
);
}
/// Run `wt list --full` against the given `tea api .../pulls` response — an
/// `(HTTP status, body)` pair — and return stderr.
///
/// `RUST_LOG=warn` because `parse_json`'s warning is a `tracing` record and the
/// stderr layer is off at the default verbosity; `-v` would turn it on but bury
/// it under a template expansion per worktree.
fn gitea_ci_status_stderr(repo: &mut TestRepo, head_sha: &str, pulls: (&str, &str)) -> String {
repo.setup_mock_tea_with_ci_data(
"owner",
"test-repo",
head_sha,
pulls,
("200 OK", r#"{"state":"","total_count":0}"#),
);
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.env("RUST_LOG", "warn");
cmd.args(["list", "--full"]).current_dir(repo.root_path());
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(output.status.success(), "wt list --full failed: {stderr}");
stderr
}
/// "May indicate a Gitea API change" is reserved for a 2xx whose body isn't the
/// resource, which is the only response that is one.
///
/// The status decides, so the two bodies that used to be the hard cases are
/// now read by what accompanied them. A 500 whose message Gitea blanked says
/// nothing about being an error, and a proxy's HTML page isn't Gitea's shape at
/// all; both reach the PR list as errors on the strength of the status line
/// alone, and the CI cell stays blank because neither carries retriable text.
/// The 200 case is the control: it pins where the warning does belong, and
/// keeps the others from passing merely because nothing logs at all.
///
/// One case per repo, not several runs in one: `wt list` caches CI status per
/// branch for 30s, so a second run against the same HEAD never reaches `tea`.
#[rstest]
#[case::blanked_500_is_an_error(
("500 Internal Server Error", GITEA_BLANK_ERROR_BODY),
false
)]
#[case::proxy_page_is_an_error(("502 Bad Gateway", "<html>Bad Gateway</html>"), false)]
#[case::unknown_200_body_is_a_parse_failure(("200 OK", r#"{"unexpected":1}"#), true)]
fn test_list_full_gitea_parse_warning_is_reserved_for_unknown_bodies(
mut repo: TestRepo,
#[case] pulls: (&str, &str),
#[case] expect_parse_warning: bool,
) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
let stderr = gitea_ci_status_stderr(&mut repo, &head_sha, pulls);
assert_eq!(
stderr.contains("Failed to parse tea api pulls JSON"),
expect_parse_warning,
"wrong diagnosis for {pulls:?}: {stderr}"
);
}
/// `tea` itself failing on `tea api .../pulls` surfaces as an error indicator
/// rather than NoCI (exercises the `is_retriable_error` branch in
/// `detect_gitea_pr`). A transport failure is the case that does exit non-zero,
/// and `tea` names it on stderr.
#[rstest]
fn test_list_full_with_gitea_retriable_error(mut repo: TestRepo) {
setup_gitea_repo_with_feature(&mut repo);
repo.setup_mock_tea_with_detection_error(
r#"Error: Get "https://gitea.example.com/api/v1/repos/owner/test-repo/pulls": dial tcp 10.0.0.5:443: connect: connection refused"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitea_retriable_error", cmd);
});
}
/// A retriable error from the commit-status lookup (when no PR exists for the
/// branch) surfaces as an error indicator (exercises the `is_retriable_error`
/// branch in `fetch_combined_status`, reached via `detect_gitea_commit_status`).
#[rstest]
fn test_list_full_with_gitea_commit_status_retriable_error(mut repo: TestRepo) {
let head_sha = setup_gitea_repo_with_feature(&mut repo);
repo.setup_mock_tea_commit_status_error(
&head_sha,
r#"Error: Get "https://gitea.example.com/api/v1/repos/owner/test-repo/commits/HEAD/status": dial tcp 10.0.0.5:443: connect: connection refused"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitea_commit_status_retriable_error", cmd);
});
}
/// `wt list --remotes --full` exercises the commit-status fallback for a
/// remote-only branch and proves it queries the branch's own remote, not the
/// primary one. Two Gitea remotes (`origin` → `owner/test-repo`, `fork` →
/// `forkowner/test-repo`) plus a remote-only `fork/feature-remote` ref. The
/// mock answers only `forkowner/test-repo` SHA-status requests, so a primary-
/// remote lookup in the fallback would return no CI; the green `#` in the
/// snapshot proves the SHA-status path honors `branch.remote`. (The PR path
/// intentionally uses the primary remote, mirroring the `gh` backend; for this
/// branch it returns no PR and we fall through to the SHA-status path.)
#[rstest]
fn test_list_remotes_full_with_gitea_remote_branch(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
repo.run_git(&[
"remote",
"add",
"fork",
"https://gitea.example.com/forkowner/test-repo.git",
]);
// Build the remote-only `fork/feature-remote` ref in a temporary local
// branch (mirroring how `test_list_with_remotes_and_full` does it for
// origin), then drop the local copy so the row appears as remote-only.
repo.run_git(&["checkout", "-b", "feature-remote"]);
std::fs::write(repo.root_path().join("gitea-fork.txt"), "fork content").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "feat: fork feature"]);
let head_sha = branch_sha(&repo, "feature-remote");
repo.run_git(&["update-ref", "refs/remotes/fork/feature-remote", &head_sha]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["branch", "-D", "feature-remote"]);
repo.setup_mock_tea_with_ci_data(
"forkowner",
"test-repo",
&head_sha,
("200 OK", "[]"),
("200 OK", r#"{"state":"success","total_count":1}"#),
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--remotes", "--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitea_remote_branch_uses_branch_remote", cmd);
});
}
/// A PR opened from a fork (head owner ≠ the queried upstream owner) is
/// matched: `wt list --full` lists the *primary* remote's open PRs and filters
/// by the branch's push owner against `head.repo.owner.login`. Two Gitea
/// remotes (`origin` → `upstream/test-repo`, `fork` → `forkowner/test-repo`)
/// and the `feature` branch pushes to `fork`. The mock returns two open PRs for
/// the `feature` ref — a decoy from `other-owner` that must be filtered out and
/// the real one from `forkowner` — and answers the upstream's commit-status
/// lookup with `success`. The green `#7` proves the fork PR matched; a revert to
/// querying/filtering by the branch's own remote would query `forkowner`'s
/// (unmocked) `/pulls` and lose the indicator. Mirrors the GitHub backend's
/// `test_list_full_filters_by_repo_owner`.
#[rstest]
fn test_list_full_with_gitea_fork_pr(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/upstream/test-repo.git",
]);
repo.run_git(&[
"remote",
"add",
"fork",
"https://gitea.example.com/forkowner/test-repo.git",
]);
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(
&feature_wt,
"gitea-fork-ci.txt",
"gitea fork ci test",
"feat: gitea fork feature",
);
setup_tracking_for_all_branches(&repo, "fork");
let head_sha = branch_sha(&repo, "feature");
let pulls_json = format!(
r#"[
{{
"number": 98,
"mergeable": true,
"html_url": "https://gitea.example.com/upstream/test-repo/pulls/98",
"head": {{"ref": "feature", "sha": "wrong_sha", "repo": {{"owner": {{"login": "other-owner"}}}}}}
}},
{{
"number": 7,
"mergeable": true,
"html_url": "https://gitea.example.com/upstream/test-repo/pulls/7",
"head": {{"ref": "feature", "sha": "{head_sha}", "repo": {{"owner": {{"login": "forkowner"}}}}}}
}}
]"#
);
repo.setup_mock_tea_with_ci_data(
"upstream",
"test-repo",
&head_sha,
("200 OK", &pulls_json),
("200 OK", r#"{"state":"success","total_count":1}"#),
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitea_fork_pr", cmd);
});
}