feat(integrations): add merge-method selection and provider-specific payloads

Introduce dedicated merge handling for integration review merges:

- Add src-tauri/src/integrations/merge.rs: implements merge_options (read provider repo settings), merge_payload (build provider-specific merge body) and a Tauri command get_integration_review_merge_options. Includes unit tests for behavior.
- Wire merge module into integrations.rs and pass an optional merge_method into provider-specific review action functions (GitHub, GitLab, Gitea, Azure DevOps). run_integration_review_action now accepts an optional merge_method, validates it early, and includes provider-specific merge payloads when performing a merge.
- Export the new command in src-tauri/src/main.rs so the frontend can request merge options.

Frontend changes to support selecting a merge method before merging:

- ConfirmDialog.svelte: add SelectMenu support and a select field to confirm requests.
- ReviewCenter.svelte: fetch integration merge options, show a merge-method selector in the merge confirmation, and pass the chosen method to the review action.
- Update types and git bindings to surface IntegrationMergeOptions / IntegrationMergeMethod and the getIntegrationReviewMergeOptions call (git.ts / types.ts changes staged).

Effect: users can pick a merge method appropriate to the provider/project; the integration layer generates the correct API payload per provider. Tests added for merge logic.
This commit is contained in:
2026-09-18 15:12:25 +02:00
parent ed484f5477
commit 6a40159f9f
7 changed files with 202 additions and 29 deletions
+11 -2
View File
@@ -5,6 +5,7 @@
* untranslated, event-blocking browser dialog.
*/
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
import { t } from "../i18n.svelte";
export interface ConfirmRequest {
@@ -25,6 +26,7 @@
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
/** Destructive actions get the red confirm button and warning icon. */
danger?: boolean;
select?: { label: string; value: string; options: { value: string; label: string }[] };
}
interface Props {
@@ -47,13 +49,13 @@
let value = $state("");
let inputElement = $state<HTMLInputElement | null>(null);
let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0);
let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput);
let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput || (Boolean(request.select) && !request.select?.options.some(option => option.value === value)));
$effect(() => {
// Start from the defaults again whenever a different confirmation is shown.
request.title;
checked = request.checkbox?.defaultChecked ?? false;
value = request.input?.value ?? "";
value = request.select?.value ?? request.input?.value ?? "";
});
$effect(() => {
@@ -144,6 +146,13 @@
</label>
{/if}
{#if request.select}
<div class="confirm-input">
<span>{request.select.label}</span>
<SelectMenu {value} options={request.select.options} ariaLabel={request.select.label} disabled={isBusy} onChange={(selected) => value = selected} />
</div>
{/if}
{#if request.checkbox}
<label class="confirm-check">
<input type="checkbox" bind:checked disabled={isBusy} />
+35 -15
View File
@@ -13,9 +13,9 @@
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { addIntegrationReviewComment, getIntegrationReviewMergeOptions, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
import type { AppLanguage, GitIntegrationSettings, IntegrationMergeMethod, IntegrationMergeOptions, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
@@ -384,36 +384,48 @@
return de ? "Request wieder öffnen" : "Reopen request";
}
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((confirmed: boolean) => void) | null = null;
function mergeMethodLabel(method: IntegrationMergeMethod, request: IntegrationReviewRequest): string {
if (request.provider.startsWith("gitlab")) {
if (method === "merge") return de ? "Ohne Squash zusammenführen" : "Merge without squashing";
if (method === "squash") return de ? "Mit Squash zusammenführen" : "Squash and merge";
}
switch (method) {
case "merge": return de ? "Merge-Commit erstellen" : "Create merge commit";
case "rebase": return de ? "Rebase, dann Fast-forward" : "Rebase, then fast-forward";
case "rebase-merge": return de ? "Rebase, dann Merge-Commit erstellen" : "Rebase, then create merge commit";
case "squash": return de ? "Squash-Commit erstellen" : "Create squash commit";
case "fast-forward-only": return de ? "Nur Fast-forward" : "Fast-forward only";
default: return de ? "Projektstandard verwenden" : "Use project default";
}
}
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<boolean> {
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod }) => void) | null = null;
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }> {
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 }
? { 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 }
: 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<boolean>((resolve) => {
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }>((resolve) => {
reviewConfirmResolve = resolve;
});
}
function answerReviewConfirmation(confirmed: boolean) {
function answerReviewConfirmation(confirmed: boolean, value?: string) {
const method = reviewConfirmRequest?.select?.options.find(option => option.value === value)?.value as IntegrationMergeMethod | undefined;
const resolve = reviewConfirmResolve;
reviewConfirmRequest = null;
reviewConfirmResolve = null;
resolve?.(confirmed);
resolve?.({ confirmed, method });
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const confirmed = await askReviewConfirmation(request, action);
if (!confirmed) return;
}
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
@@ -421,7 +433,15 @@
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.");
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
let mergeMethod: IntegrationMergeMethod | undefined;
if (action !== "approve") {
const options = action === "merge" ? await withTimeout(getIntegrationReviewMergeOptions(source.provider, source.baseUrl, credential.password, request), source.label) : undefined;
if (options && options.methods.length === 0) throw new Error(de ? "Für dieses Repository ist keine unterstützte Merge-Methode freigegeben." : "No supported merge method is enabled for this repository.");
const result = await askReviewConfirmation(request, action, options);
if (!result.confirmed) return;
mergeMethod = result.method;
}
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();
@@ -723,7 +743,7 @@
{#if reviewConfirmRequest}
<ConfirmDialog
request={reviewConfirmRequest}
onConfirm={() => answerReviewConfirmation(true)}
onConfirm={({ value }) => answerReviewConfirmation(true, value)}
onCancel={() => answerReviewConfirmation(false)}
/>
{/if}
+8 -2
View File
@@ -19,6 +19,8 @@ import type {
GitIntegrationRepository,
IntegrationReviewRequest,
IntegrationReviewAction,
IntegrationMergeMethod,
IntegrationMergeOptions,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
@@ -66,8 +68,12 @@ export function listIntegrationReviewRequests(provider: GitIntegrationProvider,
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
}
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
export function getIntegrationReviewMergeOptions(provider: GitIntegrationProvider, baseUrl: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationMergeOptions> {
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 getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
+3
View File
@@ -66,6 +66,9 @@ export interface GitIntegrationRepository {
}
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
export type IntegrationMergeMethod = "default" | "merge" | "squash" | "rebase" | "rebase-merge" | "fast-forward-only";
export interface IntegrationMergeOptions { methods: IntegrationMergeMethod[]; defaultMethod: IntegrationMergeMethod; }
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
export interface IntegrationReviewComment {