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
This commit is contained in:
2026-09-07 18:54:06 +02:00
parent c46a875a91
commit 13660c801e
2 changed files with 70 additions and 6 deletions
+23 -2
View File
@@ -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 ─────────────────────────────────────────────────────────
+47 -4
View File
@@ -48,6 +48,7 @@
let collapsedRepositories = $state(new Set<string>());
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<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
return new Promise<T>((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 @@
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions">
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || isWaitingForResolvedStatus(request) || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || isWaitingForResolvedStatus(request) || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
{#if actionMenuId === request.id}
<span class="action-menu" role="menu" tabindex="-1">
<button type="button" role="menuitem" onclick={() => openDetail(request)}><PanelRightOpen size={13} />{de ? "Request prüfen" : "Review request"}</button>
{#if request.state === "open" || request.state === "draft"}
{#if hasConflicts(request)}<button type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
{#if isWaitingForResolvedStatus(request)}<button type="button" role="menuitem" disabled><LoaderCircle class="spin" size={13} />{de ? "Merge-Status wird geprüft" : "Checking merge status"}</button>{:else if hasConflicts(request)}<button type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><Check size={13} />{reviewActionLabel("approve")}</button>
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
{:else if request.state === "closed"}
@@ -440,7 +483,7 @@
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
<section class="comments-section">
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
<div class:conflict={hasConflicts(selected)} class="merge-summary">{#if detailLoadingId === selected.id}<LoaderCircle class="spin" size={14} /><span>{de ? "Merge-Status wird geladen …" : "Loading merge status …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>
<div class:conflict={hasConflicts(selected) && !isWaitingForResolvedStatus(selected)} class="merge-summary">{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}<LoaderCircle class="spin" size={14} /><span>{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{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.")}</span></div>
@@ -457,7 +500,7 @@
</section>
</main>
<aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span></div></section>
</aside>
</div>