feat(review-center): add local pull-request conflict resolution flow

Add a local conflict resolution workflow that allows resolving PR
merge conflicts from the review center. The change implements host and
repository matching, prepares and merges branches locally, opens the
resolve editor, and tracks a multi-phase state machine to continue,
abort, or push the resolved branch back to the remote.

- Core resolution logic to locate matching repos, prepare merges,
  and manage phases (preparing → conflicts → ready-to-push → complete).
- UI wiring and callbacks to start, reopen, continue, abort, and push
  local resolutions from the review center.
- Helpers to open the conflict editor, monitor merge state, and mark
  completion after a successful push.
This commit is contained in:
2026-09-07 17:33:54 +02:00
parent c6553218a2
commit c46a875a91
3 changed files with 245 additions and 8 deletions
+196
View File
@@ -168,6 +168,7 @@
GitIntegrationSecretUpdate,
GitIntegrationSettings,
GitIntegrationProvider,
IntegrationReviewRequest,
GitBlameLine,
GitBranch as GitBranchInfo,
GitCommit,
@@ -218,6 +219,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "review-center" | "repository";
type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
@@ -317,6 +319,12 @@
let activeView: AppView = "management";
let reviewCenterInitialQuery = "";
let reviewCenterInitialSourceId = "";
let reviewConflictRequestId = "";
let reviewConflictPhase: ReviewConflictPhase = "idle";
let reviewConflictMessage = "";
let reviewConflictRepoPath = "";
let reviewConflictRemote = "";
let reviewConflictPushRequested = false;
let repoTabs: RepoTab[] = [];
let repoTabContextMenu: RepoTabContextMenu | null = null;
let recentRepoPaths: string[] = [];
@@ -2598,6 +2606,172 @@
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
}
function reviewRemoteIdentity(value: string): { host: string; repository: string } | null {
const trimmed = value.trim();
if (!trimmed) return null;
let host = "";
let pathname = "";
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
if (scp) {
host = scp[1];
pathname = scp[2];
} else {
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
host = url.hostname;
pathname = url.pathname;
} catch {
return null;
}
}
const parts = pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
const repository = gitIndex > 0 && parts[gitIndex + 1]
? `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`
: parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4
? `${parts[2]}/${parts[3]}`
: parts.join("/");
return { host: host.toLocaleLowerCase().replace(/^ssh\./, ""), repository: repository.toLocaleLowerCase() };
}
function reviewRepositoryMatches(remoteRepository: string, requestRepository: string): boolean {
const request = requestRepository.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
return Boolean(request) && (remoteRepository === request || remoteRepository.endsWith(`/${request}`) || request.endsWith(`/${remoteRepository}`));
}
async function findReviewConflictRepository(request: IntegrationReviewRequest, sourceId: string): Promise<{ path: string; remote: GitRemote } | null> {
const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
if (!source) return null;
let sourceHost = "";
try { sourceHost = new URL(source.baseUrl).hostname.toLocaleLowerCase().replace(/^ssh\./, ""); }
catch { return null; }
for (const repo of dashboardRepos) {
try {
const remotes = (await listRemotes(repo.path)).sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"));
const remote = remotes.find((candidate) => {
const identity = reviewRemoteIdentity(candidate.fetch_url);
return identity?.host === sourceHost && reviewRepositoryMatches(identity.repository, request.repositoryName);
});
if (remote) return { path: repo.path, remote };
} catch {
// Invalid or unavailable dashboard repositories are ignored while matching.
}
}
return null;
}
function setReviewConflictError(message: string) {
reviewConflictPhase = "error";
reviewConflictMessage = message;
activeView = "review-center";
}
async function startReviewConflictResolution(request: IntegrationReviewRequest, sourceId: string) {
if (isBusy || !request.sourceBranch || !request.targetBranch) return;
reviewConflictRequestId = request.id;
reviewConflictPhase = "preparing";
reviewConflictMessage = appLanguage === "de" ? "Lokales Repository und Branches werden vorbereitet …" : "Preparing the local repository and branches …";
reviewConflictRepoPath = "";
reviewConflictRemote = "";
reviewConflictPushRequested = false;
try {
const match = await findReviewConflictRepository(request, sourceId);
if (!match) throw new Error(appLanguage === "de" ? "Kein passendes lokales Repository gefunden. Öffne oder klone das Repository zuerst im Dashboard." : "No matching local repository was found. Open or clone the repository from the dashboard first.");
await openRepo(match.path);
activeView = "review-center";
if (!sameRepoPath(activeRepoPath, match.path) || !status) throw new Error(errorMessage || (appLanguage === "de" ? "Das lokale Repository konnte nicht geöffnet werden." : "The local repository could not be opened."));
if (status.merge_in_progress) throw new Error(appLanguage === "de" ? "In diesem Repository läuft bereits ein Merge. Schließe ihn zuerst ab oder brich ihn ab." : "A merge is already in progress in this repository. Complete or abort it first.");
if (!status.clean) throw new Error(appLanguage === "de" ? "Der Working Tree enthält Änderungen. Committe oder stashe sie, bevor du den PR-Konflikt löst." : "The working tree contains changes. Commit or stash them before resolving the PR conflict.");
const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
if (!source) throw new Error(appLanguage === "de" ? "Die Integration ist nicht mehr verfügbar." : "The integration is no longer available.");
const credential = await loadStoredCredential(integrationCredentialKey(source.provider, source.accountId));
if (!credential?.password) throw new Error(appLanguage === "de" ? "Für diese Integration ist kein Token gespeichert." : "No token is stored for this integration.");
operation = appLanguage === "de" ? "PR-Konflikt wird vorbereitet" : "Preparing PR conflict resolution";
errorMessage = "";
reviewConflictRepoPath = match.path;
reviewConflictRemote = match.remote.name;
selectedRemote = match.remote.name;
applyStatus(await fetchRemote(match.path, credential.username, credential.password, false, match.remote.name));
const availableBranches = await listBranches(match.path);
const localSource = availableBranches.find((branch) => !branch.remote && branch.name === request.sourceBranch);
const remoteSource = availableBranches.find((branch) => branch.remote && branch.name === `${match.remote.name}/${request.sourceBranch}`);
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}`
: request.targetBranch;
applyStatus(await mergeBranch(match.path, targetBranch, "default"));
await refreshRepositoryViews(match.path);
activeView = "review-center";
operation = "";
if (statusHasConflicts(status)) {
reviewConflictPhase = "conflicts";
reviewConflictMessage = appLanguage === "de" ? "Wähle für jede Konfliktstelle die gewünschte Version." : "Choose the version to keep for each conflict.";
await tick();
await openReviewResolveDialog();
} 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.";
}
trackEvent("review_conflict_resolution_started");
} catch (error) {
operation = "";
setReviewConflictError(errorToMessage(error));
}
}
async function reopenReviewConflictResolver() {
if (reviewConflictPhase !== "conflicts" || !hasConflicts) return;
await openReviewResolveDialog();
}
async function continueReviewConflictMerge() {
if (reviewConflictPhase !== "ready-to-continue" || hasConflicts || !mergeInProgress) return;
await continueMerge();
activeView = "review-center";
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.";
}
}
async function abortReviewConflictResolution() {
if (mergeInProgress) await abortMerge();
if (mergeInProgress) return;
resolveDialogOpen = false;
reviewConflictPhase = "idle";
reviewConflictRequestId = "";
reviewConflictMessage = "";
reviewConflictPushRequested = false;
activeView = "review-center";
}
async function pushReviewConflictResolution() {
if (reviewConflictPhase !== "ready-to-push" || !reviewConflictRepoPath || isBusy) return;
selectedRemote = reviewConflictRemote;
reviewConflictPushRequested = true;
await pushRepo();
activeView = "review-center";
completeReviewConflictPushIfReady();
}
$: if (reviewConflictPhase === "conflicts" && reviewConflictRequestId && mergeInProgress && !hasConflicts) {
reviewConflictPhase = "ready-to-continue";
reviewConflictMessage = appLanguage === "de" ? "Alle Dateien sind gelöst. Schließe jetzt den lokalen Merge ab." : "All files are resolved. Complete the local merge now.";
}
function completeReviewConflictPushIfReady() {
if (reviewConflictPhase !== "ready-to-push" || !reviewConflictPushRequested || !reviewConflictRepoPath || !sameRepoPath(activeRepoPath, reviewConflictRepoPath) || errorMessage || credDialogOpen || mergeInProgress || (status?.ahead ?? 0) !== 0) return;
reviewConflictPhase = "complete";
reviewConflictMessage = appLanguage === "de" ? "Der gelöste Branch wurde gepusht. Der Anbieter prüft den Merge-Status erneut." : "The resolved branch was pushed. The provider is checking the merge status again.";
trackEvent("review_conflict_resolution_pushed");
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
@@ -3906,6 +4080,7 @@
}
handleRemoteResult("push", key, fromStore, username, mode);
completeReviewConflictPushIfReady();
}
async function doActualRemoteRename(
@@ -5042,6 +5217,19 @@
});
}
async function openReviewResolveDialog() {
if (!hasConflicts || isBusy) return;
const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
trackEvent("review_conflict_editor_opened", {
conflicts: conflictedFiles.length,
});
});
}
async function selectConflictFile(path: string) {
if (path === conflictTarget || isBusy) return;
const openExternally = externalToolsSettings.mergeOpenMode === "external";
@@ -5360,8 +5548,16 @@
integrations={gitIntegrationSettings}
initialQuery={reviewCenterInitialQuery}
initialSourceId={reviewCenterInitialSourceId}
localResolutionRequestId={reviewConflictRequestId}
localResolutionPhase={reviewConflictPhase}
localResolutionMessage={reviewConflictMessage}
loadCredential={loadStoredCredential}
onOpenSettings={() => { appSettingsOpen = true; }}
onStartLocalResolution={startReviewConflictResolution}
onOpenLocalResolver={reopenReviewConflictResolver}
onContinueLocalResolution={continueReviewConflictMerge}
onAbortLocalResolution={abortReviewConflictResolution}
onPushLocalResolution={pushReviewConflictResolution}
/>
{:else}
<!-- Workspace -->