feat(integrations): support automatic branch cleanup after merge
Add a new git::review_cleanup module that implements a CleanupPlan with prepare() and finish() routines to safely remove/clean tracking and local branches after a PR/MR is merged. The cleanup logic validates branch names, ensures a clean worktree, checks remotes/URLs, verifies commits/ancestry, protects against concurrent worktrees or divergent local/remote commits, and performs authenticated fetch/push and ref updates. Unit tests for the cleanup behavior are included. Wire provider-side cleanup into integrations: - add an integrations/cleanup module to read provider PR payloads and derive cleanup inputs - run cleanup::prepare(...) before performing a merge when an optional cleanup_path is provided - after a successful provider merge, run cleanup::finish(...); any failure is reported as MERGE_ACCEPTED_CLEANUP_FAILED Also: - export the new git review_cleanup module (src-tauri/src/git.rs) - accept an optional cleanup_path parameter in run_integration_review_action - remove the previous REVIEW_REQUEST_TIMEOUT wrapper around the spawned blocking task (the integration action is no longer wrapped with the 35s timeout)
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
use crate::git::review_cleanup::{self, CleanupPlan};
|
||||
|
||||
struct ReviewHead {
|
||||
source: String,
|
||||
target: String,
|
||||
sha: String,
|
||||
merged: bool,
|
||||
urls: Vec<String>,
|
||||
}
|
||||
|
||||
fn read_payload(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
username: &str,
|
||||
token: &str,
|
||||
provider: &str,
|
||||
repository_id: &str,
|
||||
repository_name: &str,
|
||||
number: u64,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<ReviewHead, String> {
|
||||
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<PreparedCleanup, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user