From 13660c801efcb615e7071290ef0207b680d19e98 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 18:54:06 +0200 Subject: [PATCH] feat(review): auto-continue merge and poll provider after push Coordinate local conflict resolutions with automated push and provider- side merge status checks. When a local resolution completes the app advances the merge workflow, pushes the updated branch, and attempts to continue the merge. The UI shows a checking state and disables relevant actions while the provider rechecks to prevent duplicate operations. This streamlines finishing conflict resolution and keeps PR status in sync with remote providers. - Automatically push and continue merge when local resolutions finish - Poll provider for updated mergeability and refresh request details - Add guards to disable UI actions while waiting for remote status --- src/App.svelte | 25 ++++++++++++- src/lib/components/ReviewCenter.svelte | 51 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 6f1ee0f..a24b505 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2700,7 +2700,6 @@ const sourceBranch = localSource?.name ?? remoteSource?.name; if (!sourceBranch) throw new Error(appLanguage === "de" ? `Der Quellbranch „${request.sourceBranch}“ wurde lokal und auf ${match.remote.name} nicht gefunden.` : `The source branch “${request.sourceBranch}” was not found locally or on ${match.remote.name}.`); if (status?.current_branch !== request.sourceBranch) applyStatus(await checkoutBranch(match.path, sourceBranch)); - if ((status?.ahead ?? 0) > 0) throw new Error(appLanguage === "de" ? `Der lokale Branch „${request.sourceBranch}“ enthält noch nicht gepushte Commits. Pushe oder sichere sie zuerst.` : `The local branch “${request.sourceBranch}” contains unpushed commits. Push or preserve them first.`); if (remoteSource) applyStatus(await mergeBranch(match.path, remoteSource.name, "ff-only")); const targetBranch = availableBranches.some((branch) => branch.remote && branch.name === `${match.remote.name}/${request.targetBranch}`) ? `${match.remote.name}/${request.targetBranch}` @@ -2717,6 +2716,8 @@ } else { reviewConflictPhase = "ready-to-push"; reviewConflictMessage = appLanguage === "de" ? "Der Zielbranch wurde ohne Dateikonflikte übernommen. Der Quellbranch kann jetzt gepusht werden." : "The target branch was merged without file conflicts. The source branch is ready to push."; + await tick(); + await pushReviewConflictResolution(); } trackEvent("review_conflict_resolution_started"); } catch (error) { @@ -2731,12 +2732,19 @@ } async function continueReviewConflictMerge() { - if (reviewConflictPhase !== "ready-to-continue" || hasConflicts || !mergeInProgress) return; + if (!reviewConflictRequestId || hasConflicts || !mergeInProgress || isBusy) return; + reviewConflictPhase = "ready-to-continue"; + reviewConflictMessage = appLanguage === "de" ? "Der Merge-Commit wird erstellt …" : "Creating the merge commit …"; await continueMerge(); activeView = "review-center"; + await tick(); if (!errorMessage && !mergeInProgress) { reviewConflictPhase = "ready-to-push"; reviewConflictMessage = appLanguage === "de" ? "Der Merge ist abgeschlossen. Pushe den Quellbranch, damit der PR aktualisiert wird." : "The merge is complete. Push the source branch to update the pull request."; + await tick(); + await pushReviewConflictResolution(); + } else if (errorMessage) { + reviewConflictMessage = appLanguage === "de" ? `Der Merge-Commit konnte nicht erstellt werden: ${errorMessage}` : `The merge commit could not be created: ${errorMessage}`; } } @@ -5273,6 +5281,7 @@ async function applyPreparedResolutions() { if (!activeRepoPath || isBusy || Object.keys(preparedResolutions).length === 0) return; const entries = Object.entries(preparedResolutions); + let completeReviewMerge = false; await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => { let nextStatus: GitStatus | null = null; for (const [file, prepared] of entries) { @@ -5291,6 +5300,12 @@ resolveDialogOpen = false; conflict = null; conflictTarget = ""; + completeReviewMerge = Boolean( + reviewConflictRequestId + && reviewConflictRepoPath + && sameRepoPath(activeRepoPath, reviewConflictRepoPath) + && nextStatus?.merge_in_progress, + ); } else { await loadConflict(remaining[0].path); } @@ -5299,6 +5314,12 @@ remaining: remaining.length, }); }); + if (completeReviewMerge && !errorMessage) { + reviewConflictPhase = "ready-to-continue"; + activeView = "review-center"; + await tick(); + await continueReviewConflictMerge(); + } } // ── Event handlers ───────────────────────────────────────────────────────── diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index ecbf4e2..913a1d6 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -48,6 +48,7 @@ let collapsedRepositories = $state(new Set()); let loadGeneration = 0; let loadedStates = $state(new Set<"open" | "merged" | "closed">()); + let completedResolutionRefreshKey = ""; const SOURCE_TIMEOUT_MS = 46_000; const de = $derived(language === "de"); @@ -81,6 +82,15 @@ return () => window.removeEventListener("click", closeActionMenu); }); + $effect(() => { + const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId + ? `${selectedSourceId}:${localResolutionRequestId}` + : ""; + if (!key || key === completedResolutionRefreshKey) return; + completedResolutionRefreshKey = key; + void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId); + }); + function withTimeout(promise: Promise, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { const seconds = Math.round(timeoutMs / 1_000); @@ -246,12 +256,43 @@ } finally { detailLoadingId = ""; } } + async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) { + const source = sources.find((candidate) => candidate.id === sourceId); + const initialRequest = requests.find((candidate) => candidate.id === requestId); + if (!source || !initialRequest) return; + let request: IntegrationReviewRequest = initialRequest; + actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …"; + try { + const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000); + if (!credential?.password) return; + for (const delay of [700, 1_500, 3_000, 5_000]) { + await new Promise((resolve) => window.setTimeout(resolve, delay)); + if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return; + const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000); + requests = requests.map((candidate) => candidate.id === details.id ? details : candidate); + request = details; + if (details.mergeStatus === "mergeable") { + actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged."; + return; + } + if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return; + } + actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds."; + } catch (error) { + errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }]; + } + } + function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; } function isLocalResolutionActive(request: IntegrationReviewRequest): boolean { return request.id === localResolutionRequestId && localResolutionPhase !== "idle"; } + function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean { + return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request); + } + async function startLocalResolution(request: IntegrationReviewRequest) { const source = activeSource; if (!source || !request.sourceBranch || !request.targetBranch) return; @@ -262,6 +303,7 @@ } async function runPrimaryAction(request: IntegrationReviewRequest) { + if (isWaitingForResolvedStatus(request)) return; if (hasConflicts(request)) await startLocalResolution(request); else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge"); else if (request.state === "closed") await performReviewAction(request, "reopen"); @@ -269,6 +311,7 @@ } function primaryActionLabel(request: IntegrationReviewRequest): string { + if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status"; if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict"; if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge"; if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen"; @@ -402,13 +445,13 @@ {request.repositoryName}{#if request.sourceBranch && request.targetBranch}{request.sourceBranch}{request.targetBranch}{:else}{de ? "Branches werden geladen …" : "Loading branches …"}{/if} - + {#if actionMenuId === request.id} {#if request.state === "open" || request.state === "draft"} - {#if hasConflicts(request)}{:else}{/if} + {#if isWaitingForResolvedStatus(request)}{:else if hasConflicts(request)}{:else}{/if} {:else if request.state === "closed"} @@ -440,7 +483,7 @@

{de ? "Beschreibung" : "Description"}

{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}

{de ? "Kommentare" : "Comments"}

{selected.comments?.length ?? 0}
-
{#if detailLoadingId === selected.id}{de ? "Merge-Status wird geladen …" : "Loading merge status …"}{:else if hasConflicts(selected)}{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}{:else}{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}{/if}
+
{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}{:else if hasConflicts(selected)}{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}{:else}{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}{/if}
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
{#if localResolutionPhase === "preparing"}{:else if localResolutionPhase === "complete"}{:else}{/if}{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}
@@ -457,7 +500,7 @@