use crate::common::{OutputAssertions, TestRepo};
use std::fs;
use std::path::Path;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn write_test_config(home: &Path, api_base_url: &str) {
let config_dir = home.join(".config").join("stax");
fs::create_dir_all(&config_dir).expect("failed to create test config dir");
fs::write(
config_dir.join("config.toml"),
format!(
"[remote]\napi_base_url = \"{api_base_url}\"\n\n\
[submit]\nstack_links = \"off\"\nnative_stack = \"off\"\n"
),
)
.expect("failed to write test config");
}
fn pr_fixture(number: u64, branch: &str, base: &str) -> serde_json::Value {
serde_json::json!({
"url": format!("https://api.github.com/repos/test-owner/test-repo/pulls/{number}"),
"id": number,
"number": number,
"state": "open",
"draft": false,
"title": format!("PR {number}"),
"body": "",
"head": { "ref": branch, "sha": "aaaa", "label": format!("test-owner:{branch}") },
"base": { "ref": base, "sha": "bbbb" },
"html_url": format!("https://github.com/test-owner/test-repo/pull/{number}")
})
}
async fn mock_existing_pr_reads(mock_server: &MockServer, number: u64, branch: &str, base: &str) {
Mock::given(method("GET"))
.and(path(format!("/repos/test-owner/test-repo/pulls/{number}")))
.respond_with(ResponseTemplate::new(200).set_body_json(pr_fixture(number, branch, base)))
.mount(mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!(
"/repos/test-owner/test-repo/issues/{number}/comments"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(mock_server)
.await;
}
fn write_branch_pr_metadata(repo: &TestRepo, branch: &str, parent: &str, pr_number: u64) {
let parent_revision = {
let output = repo.git(&["rev-parse", parent]);
output.assert_success();
TestRepo::stdout(&output).trim().to_string()
};
let metadata = serde_json::json!({
"parentBranchName": parent,
"parentBranchRevision": parent_revision,
"prInfo": {
"number": pr_number,
"state": "OPEN",
"isDraft": false
}
});
let metadata_file = tempfile::NamedTempFile::new().expect("metadata temp file");
fs::write(metadata_file.path(), metadata.to_string()).expect("write metadata temp file");
let hash = repo.git(&[
"hash-object",
"-w",
metadata_file.path().to_str().expect("metadata path"),
]);
hash.assert_success();
let blob = TestRepo::stdout(&hash);
repo.git(&[
"update-ref",
&format!("refs/branch-metadata/{branch}"),
blob.trim(),
])
.assert_success();
}
#[tokio::test]
async fn submit_does_not_repatch_base_when_only_pushing_new_commits() {
let mock_server = MockServer::start().await;
let repo = TestRepo::new_with_remote();
let home = repo.clean_home();
write_test_config(Path::new(&home), &mock_server.uri());
repo.configure_github_like_submit_remote();
repo.create_stack(&["base-stable"]);
let branch = repo.current_branch();
repo.git(&["push", "-u", "origin", &branch])
.assert_success();
write_branch_pr_metadata(&repo, &branch, "main", 501);
mock_existing_pr_reads(&mock_server, 501, &branch, "main").await;
repo.create_file("base-stable-extra.txt", "more work\n");
repo.commit("More work on base-stable");
let output = repo.run_stax_with_env(
&["submit", "--yes", "--no-prompt", "--no-template"],
&[("STAX_GITHUB_TOKEN", "test-token")],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
assert!(
!requests.iter().any(|r| r.method.as_str() == "PATCH"
&& r.url.path() == "/repos/test-owner/test-repo/pulls/501"),
"submit should not PATCH an unchanged PR base: {requests:#?}"
);
}
#[tokio::test]
async fn submit_treats_native_stack_base_lock_as_non_fatal() {
let mock_server = MockServer::start().await;
let repo = TestRepo::new_with_remote();
let home = repo.clean_home();
write_test_config(Path::new(&home), &mock_server.uri());
repo.configure_github_like_submit_remote();
repo.create_stack(&["base-locked"]);
let branch = repo.current_branch();
write_branch_pr_metadata(&repo, &branch, "main", 502);
mock_existing_pr_reads(&mock_server, 502, &branch, "stale-base").await;
Mock::given(method("PATCH"))
.and(path("/repos/test-owner/test-repo/pulls/502"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"message": "Validation Failed",
"documentation_url": "https://docs.github.com/rest/pulls/pulls#update-a-pull-request",
"errors": [{
"code": "invalid",
"field": "base",
"message": "Cannot change the base branch because the pull request is part of a stack.",
"resource": "PullRequest"
}]
})))
.mount(&mock_server)
.await;
let output = repo.run_stax_with_env(
&["submit", "--yes", "--no-prompt", "--no-template"],
&[("STAX_GITHUB_TOKEN", "test-token")],
);
assert!(
output.status.success(),
"submit should not abort on a native-stack base lock: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("skipped base update") && stdout.contains("native Stack"),
"expected a soft note about the locked base, got: {stdout}"
);
}
#[tokio::test]
async fn update_treats_native_stack_base_lock_as_non_fatal() {
let mock_server = MockServer::start().await;
let repo = TestRepo::new_with_remote();
let home = repo.clean_home();
write_test_config(Path::new(&home), &mock_server.uri());
repo.configure_github_like_submit_remote();
repo.create_stack(&["base-locked-update"]);
let branch = repo.current_branch();
repo.git(&["push", "-u", "origin", &branch])
.assert_success();
write_branch_pr_metadata(&repo, &branch, "main", 503);
mock_existing_pr_reads(&mock_server, 503, &branch, "stale-base").await;
Mock::given(method("PATCH"))
.and(path("/repos/test-owner/test-repo/pulls/503"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"message": "Validation Failed",
"documentation_url": "https://docs.github.com/rest/pulls/pulls#update-a-pull-request",
"errors": [{
"code": "invalid",
"field": "base",
"message": "Cannot change the base branch because the pull request is part of a stack.",
"resource": "PullRequest"
}]
})))
.mount(&mock_server)
.await;
let output = repo.run_stax_with_env(
&["update", "--force", "--yes", "--no-prompt"],
&[("STAX_GITHUB_TOKEN", "test-token")],
);
assert!(
output.status.success(),
"update should not abort on a native-stack base lock: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("skipped base update") && stdout.contains("native Stack"),
"expected a soft note about the locked base, got: {stdout}"
);
}