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:
@@ -6117,6 +6117,14 @@
|
||||
localResolutionMessage={reviewConflictMessage}
|
||||
loadCredential={loadStoredCredential}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
onCleanupStateChange={async (busy, path) => {
|
||||
if (busy) {
|
||||
operation = appLanguage === "de" ? "PR zusammenführen und Branch aufräumen" : "Merging PR and cleaning up branch";
|
||||
} else {
|
||||
try { if (sameRepoPath(activeRepoPath, path)) await refreshRepositorySnapshot(path); }
|
||||
finally { operation = ""; }
|
||||
}
|
||||
}}
|
||||
onStartLocalResolution={startReviewConflictResolution}
|
||||
onOpenLocalResolver={reopenReviewConflictResolver}
|
||||
onContinueLocalResolution={continueReviewConflictMerge}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
localResolutionMessage?: string;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
onOpenSettings: () => void;
|
||||
onCleanupStateChange?: (busy: boolean, path: string) => void | Promise<void>;
|
||||
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
|
||||
onOpenLocalResolver?: () => void | Promise<void>;
|
||||
onContinueLocalResolution?: () => void | Promise<void>;
|
||||
@@ -38,7 +39,7 @@
|
||||
onPushLocalResolution?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { aiSettings, localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||
let { aiSettings, localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onCleanupStateChange = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||
let createOpen = $state(false);
|
||||
let requests = $state<IntegrationReviewRequest[]>([]);
|
||||
let loading = $state(false);
|
||||
@@ -400,27 +401,29 @@
|
||||
}
|
||||
|
||||
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
|
||||
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod }) => void) | null = null;
|
||||
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }) => void) | null = null;
|
||||
|
||||
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }> {
|
||||
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }> {
|
||||
const values = { number: request.number };
|
||||
reviewConfirmRequest = action === "merge"
|
||||
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false, select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
|
||||
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false,
|
||||
checkbox: { label: de ? "Quellbranch remote und lokal löschen" : "Delete source branch remotely and locally", note: de ? `Nach dem Merge zu „${request.targetBranch}“ wechseln und „${request.sourceBranch}“ löschen. Ein passendes lokales Repository ohne ungesicherte Änderungen muss geöffnet sein.` : `After merging, switch to “${request.targetBranch}” and delete “${request.sourceBranch}”. Open the matching local repository with a clean working tree first.`, defaultChecked: false },
|
||||
select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
|
||||
: action === "close"
|
||||
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
|
||||
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
|
||||
|
||||
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }>((resolve) => {
|
||||
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }>((resolve) => {
|
||||
reviewConfirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function answerReviewConfirmation(confirmed: boolean, value?: string) {
|
||||
function answerReviewConfirmation(confirmed: boolean, value?: string, checked = false) {
|
||||
const method = reviewConfirmRequest?.select?.options.find(option => option.value === value)?.value as IntegrationMergeMethod | undefined;
|
||||
const resolve = reviewConfirmResolve;
|
||||
reviewConfirmRequest = null;
|
||||
reviewConfirmResolve = null;
|
||||
resolve?.({ confirmed, method });
|
||||
resolve?.({ confirmed, method, deleteBranch: checked });
|
||||
}
|
||||
|
||||
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||
@@ -430,6 +433,7 @@
|
||||
actionNotice = "";
|
||||
actionBusyId = request.id;
|
||||
errors = [];
|
||||
let cleanupPath: string | undefined;
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
@@ -440,15 +444,35 @@
|
||||
const result = await askReviewConfirmation(request, action, options);
|
||||
if (!result.confirmed) return;
|
||||
mergeMethod = result.method;
|
||||
if (action === "merge" && result.deleteBranch) {
|
||||
if (!localRepositoryPath) throw new Error(de ? "Öffne zuerst das passende lokale Repository, um den Quellbranch remote und lokal zu löschen." : "Open the matching local repository before deleting the source branch remotely and locally.");
|
||||
cleanupPath = localRepositoryPath;
|
||||
await onCleanupStateChange(true, cleanupPath);
|
||||
}
|
||||
}
|
||||
let cleanupError = "";
|
||||
try {
|
||||
// Cleanup can include fetch, checkout and push; do not time out a still-running mutation.
|
||||
await runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod, cleanupPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.startsWith("MERGE_ACCEPTED_CLEANUP_FAILED:")) throw error;
|
||||
cleanupError = (de ? "Merge vom Anbieter angenommen, Branch-Aufräumen nicht abgeschlossen: " : "Merge accepted by the provider, branch cleanup incomplete: ") + message.replace("MERGE_ACCEPTED_CLEANUP_FAILED:", "").trim();
|
||||
}
|
||||
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod), source.label);
|
||||
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
|
||||
requests = [];
|
||||
loadedStates = new Set();
|
||||
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
|
||||
if (cleanupError) errors = [...errors, { source: source.label, message: cleanupError }];
|
||||
else actionNotice = cleanupPath
|
||||
? (de ? `PR zusammengeführt. Zu „${request.targetBranch}“ gewechselt und „${request.sourceBranch}“ remote und lokal gelöscht.` : `PR merged. Switched to “${request.targetBranch}” and deleted “${request.sourceBranch}” remotely and locally.`)
|
||||
: (de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`);
|
||||
} catch (error) {
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
} finally {
|
||||
if (cleanupPath) {
|
||||
try { await onCleanupStateChange(false, cleanupPath); }
|
||||
catch (error) { errors = [...errors, { source: source.label, message: String(error) }]; }
|
||||
}
|
||||
actionBusyId = "";
|
||||
}
|
||||
}
|
||||
@@ -743,7 +767,7 @@
|
||||
{#if reviewConfirmRequest}
|
||||
<ConfirmDialog
|
||||
request={reviewConfirmRequest}
|
||||
onConfirm={({ value }) => answerReviewConfirmation(true, value)}
|
||||
onConfirm={({ value, checked }) => answerReviewConfirmation(true, value, checked)}
|
||||
onCancel={() => answerReviewConfirmation(false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -72,8 +72,8 @@ export function getIntegrationReviewMergeOptions(provider: GitIntegrationProvide
|
||||
return invoke("get_integration_review_merge_options", { provider, baseUrl, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName });
|
||||
}
|
||||
|
||||
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeMethod?: IntegrationMergeMethod): Promise<void> {
|
||||
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action, mergeMethod });
|
||||
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeMethod?: IntegrationMergeMethod, cleanupPath?: string): Promise<void> {
|
||||
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action, mergeMethod, cleanupPath });
|
||||
}
|
||||
|
||||
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
|
||||
|
||||
Reference in New Issue
Block a user