feat(integrations): add create integration review request API and UI
Add a new Tauri command and front-end flow to create PRs/MRs. The backend builds and validates provider-specific payloads and endpoints, sends creation requests, and parses responses into the app's review model. A Svelte dialog and a JS wrapper wire the UI to the command so users can create requests from the Review Center. - Implement provider payload and endpoint logic with unit tests - Expose create_integration_review_request as a Tauri command - Add CreateReviewDialog UI and integrate a creation button in Review Center
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
|
||||
import { createIntegrationReviewRequest, listIntegrationRepositories } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||
|
||||
let { source, de, loadCredential, onClose, onCreated }: {
|
||||
source: GitIntegrationSource; de: boolean;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
|
||||
} = $props();
|
||||
let dialog: HTMLDialogElement;
|
||||
let repositories = $state<GitIntegrationRepository[]>([]);
|
||||
let repositoryId = $state("");
|
||||
let sourceBranch = $state("");
|
||||
let targetBranch = $state("");
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
const mr = $derived(source.provider.startsWith("gitlab"));
|
||||
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
|
||||
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
|
||||
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
|
||||
const valid = $derived(repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
|
||||
|
||||
onMount(() => { dialog.showModal(); void loadRepositories(); });
|
||||
async function loadRepositories() {
|
||||
loading = true; error = "";
|
||||
try {
|
||||
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
|
||||
if (repositories.length === 1) repositoryId = repositories[0].id;
|
||||
} catch (cause) { error = String(cause); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !valid) return;
|
||||
const repository = repositories.find(item => item.id === repositoryId);
|
||||
if (!repository) return;
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||
onCreated(request);
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
|
||||
<form onsubmit={submit}>
|
||||
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||
<div class="body">
|
||||
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
|
||||
<label>Repository<select bind:value={repositoryId} disabled={loading || busy} required><option value="" disabled>{loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")}</option>{#each repositories as repository}<option value={repository.id}>{repository.fullName}</option>{/each}</select></label>
|
||||
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
|
||||
<div class="branches"><label>{de ? "Quellbranch" : "Source branch"}<input bind:value={sourceBranch} disabled={busy} placeholder="feature/my-change" required autocomplete="off" /></label><ArrowRight size={16}/><label>{de ? "Zielbranch" : "Target branch"}<input bind:value={targetBranch} disabled={busy} placeholder="main" required autocomplete="off" /></label></div>
|
||||
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
|
||||
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
|
||||
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
||||
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
|
||||
</div>
|
||||
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog{margin:auto;width:min(600px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--color-surface);color:var(--color-ink);box-shadow:0 24px 90px #0005;font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 24px;border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,select,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:17px;padding:24px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,select,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}select{height:38px;padding:7px 11px;line-height:22px}input:focus,select:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 24px;border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user