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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user