use super::*; use crate::git::review_cleanup::{self, CleanupPlan}; struct ReviewHead { source: String, target: String, sha: String, merged: bool, urls: Vec, } fn read_payload( client: &Client, base: &str, username: &str, token: &str, provider: &str, repository_id: &str, repository_name: &str, number: u64, ) -> Result { let request = match provider { "github" => client .get(format!( "{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base)? )) .bearer_auth(token) .header(ACCEPT, "application/vnd.github+json"), "gitea" => client .get(format!( "{base}/api/v1/repos/{repository_name}/pulls/{number}" )) .header("Authorization", format!("token {token}")), "gitlab" | "gitlab-self-hosted" => client .get(format!( "{base}/api/v4/projects/{repository_id}/merge_requests/{number}" )) .header("PRIVATE-TOKEN", token), "azure-devops" => client .get(azure_review_endpoint( base, repository_name, repository_id, number, )?) .basic_auth( if username.is_empty() { "gitty" } else { username }, Some(token), ), _ => return Err("Unsupported integration provider.".into()), }; let response = request .header(USER_AGENT, "Gitty") .send() .map_err(|err| format!("Could not verify PR cleanup: {err}"))?; if !response.status().is_success() { return Err(response_error(response, provider)); } response .json() .map_err(|err| format!("Could not read PR cleanup details: {err}")) } fn review_head(provider: &str, payload: &serde_json::Value) -> Result { let (source, target, sha, merged, repository) = match provider { "github" | "gitea" => { let head_id = payload.pointer("/head/repo/id").filter(|id| !id.is_null()); let base_id = payload.pointer("/base/repo/id").filter(|id| !id.is_null()); if head_id.is_none() || head_id != base_id { return Err("Automatic local and remote cleanup is only supported for PRs within the same repository. Disable cleanup for this fork PR.".into()); } ( value_string(payload, &["head", "ref"]), value_string(payload, &["base", "ref"]), value_string(payload, &["head", "sha"]), payload.get("merged").and_then(serde_json::Value::as_bool) == Some(true), &payload["base"]["repo"], ) } "gitlab" | "gitlab-self-hosted" => { let source_id = payload.get("source_project_id").filter(|id| !id.is_null()); if source_id.is_none() || source_id != payload.get("target_project_id") { return Err("Automatic cleanup is only supported for merge requests within the same project. Disable cleanup for this fork MR.".into()); } ( value_string(payload, &["source_branch"]), value_string(payload, &["target_branch"]), value_string(payload, &["sha"]), value_string(payload, &["state"]) == "merged", &payload["gitty_repository"], ) } "azure-devops" => { if payload .get("forkSource") .is_some_and(|fork| !fork.is_null()) { return Err( "Automatic cleanup is not supported for fork PRs. Disable cleanup for this PR." .into(), ); } ( value_string(payload, &["sourceRefName"]) .trim_start_matches("refs/heads/") .into(), value_string(payload, &["targetRefName"]) .trim_start_matches("refs/heads/") .into(), value_string(payload, &["lastMergeSourceCommit", "commitId"]), value_string(payload, &["status"]) == "completed", &payload["repository"], ) } _ => return Err("Unsupported integration provider.".into()), }; let default_branch = value_string( repository, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }], ); if !source.is_empty() && source == default_branch.trim_start_matches("refs/heads/") { return Err("The repository's default branch cannot be deleted by PR cleanup.".into()); } let urls = [ "clone_url", "ssh_url", "http_url_to_repo", "ssh_url_to_repo", "remoteUrl", "sshUrl", ] .iter() .filter_map(|key| { repository .get(key) .and_then(serde_json::Value::as_str) .filter(|url| !url.is_empty()) .map(str::to_string) }) .collect(); Ok(ReviewHead { source, target, sha, merged, urls, }) } pub(super) struct PreparedCleanup { plan: CleanupPlan, source: String, target: String, sha: String, } pub(super) fn prepare( client: &Client, base: &str, username: &str, token: &str, provider: &str, repository_id: &str, repository_name: &str, number: u64, path: &str, ) -> Result { let mut payload = read_payload( client, base, username, token, provider, repository_id, repository_name, number, )?; if provider.starts_with("gitlab") { let response = client .get(format!("{base}/api/v4/projects/{repository_id}")) .header(USER_AGENT, "Gitty") .header("PRIVATE-TOKEN", token) .send() .map_err(|err| err.to_string())?; if !response.status().is_success() { return Err(response_error(response, "GitLab")); } payload["gitty_repository"] = response.json().map_err(|err| err.to_string())?; } let head = review_head(provider, &payload)?; if head.merged { return Err("This PR is already merged. Refresh the review list.".into()); } let plan = review_cleanup::prepare( path, &head.source, &head.target, &head.sha, &head.urls, username, token, )?; Ok(PreparedCleanup { plan, source: head.source, target: head.target, sha: head.sha, }) } pub(super) fn finish( prepared: &PreparedCleanup, client: &Client, base: &str, username: &str, token: &str, provider: &str, repository_id: &str, repository_name: &str, number: u64, ) -> Result<(), String> { // Providers may accept a merge asynchronously. Never delete a branch on acceptance alone. for attempt in 0..10 { let payload = read_payload( client, base, username, token, provider, repository_id, repository_name, number, )?; let head = review_head(provider, &payload)?; if head.source != prepared.source || head.target != prepared.target || head.sha != prepared.sha { return Err( "The PR branches or source commit changed during merging. Branches were preserved." .into(), ); } if head.merged { return review_cleanup::finish(&prepared.plan, username, token); } if attempt < 9 { std::thread::sleep(Duration::from_millis(500)); } } Err("The provider has not confirmed that the merge completed. No branches were deleted.".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn cleanup_rejects_forks_and_default_branch_and_reads_merge_confirmation() { let mut payload = serde_json::json!({"head":{"ref":"feature","sha":"abc","repo":{"id":1}},"base":{"ref":"release","repo":{"id":1,"default_branch":"main","clone_url":"https://git.test/team/app.git"}},"merged":true}); let head = review_head("gitea", &payload).unwrap(); assert!(head.merged); assert_eq!(head.target, "release"); assert_eq!(head.urls, ["https://git.test/team/app.git"]); payload["head"]["repo"]["id"] = serde_json::json!(2); assert!(review_head("gitea", &payload).is_err()); payload["head"]["repo"]["id"] = serde_json::json!(1); payload["head"]["ref"] = serde_json::json!("main"); assert!(review_head("github", &payload).is_err()); } }