Files
GitLite/src/lib/components/CreateReviewDialog.svelte
T
Christoph 8e63f2a939 feat(integrations): add assignee/assignment support
Add a new integrations/assignees backend (src-tauri/src/integrations/assignees.rs)
and expose commands to list, read and set assignees:
- list_integration_assignees
- get_integration_assignees
- set_integration_assignees

Introduce IntegrationAssignee and AssignmentTarget types and provider-specific
URL/payload logic (GitHub, Gitea, GitLab / self-hosted, Azure DevOps). The code
handles pagination, provider quirks (Gitea legacy fields, GitLab assignee_ids,
Azure reviewer vs work-item differences) and validates/verifies assignments.
Unit tests cover routing and payload behavior.

Add UI components AssigneePicker.svelte and AssignmentEditor.svelte and update
CreateIssueDialog, CreateReviewDialog, IssueCenter, ReviewCenter, git types and
git.ts to use the new assignment functionality.
2026-09-22 10:21:57 +02:00

201 lines
19 KiB
Svelte

<script lang="ts">
import AssigneePicker from "./AssigneePicker.svelte";
import { setIntegrationAssignees } from "../git";
import type { IntegrationAssignee } from "../types";
import SelectMenu from "./SelectMenu.svelte";
import CommentEditor from "./CommentEditor.svelte";
import { onMount } from "svelte";
import { GitBranch, GitPullRequest, LockKeyhole, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte";
import { credLoad, pullRequestAiGenerate, createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { AiSettings, GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
let { aiSettings, source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
aiSettings: AiSettings;
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
} = $props();
let dialog: HTMLDialogElement;
let titleInput: HTMLInputElement;
let repositories = $state<GitIntegrationRepository[]>([]);
/** Repository options grouped by owner, like the clone dialog's list. */
const repositoryOptions = $derived(repositories.map((repository) => {
const separator = repository.fullName.lastIndexOf("/");
return {
value: repository.id,
label: separator > 0 ? repository.fullName.slice(separator + 1) : repository.fullName,
group: separator > 0 ? repository.fullName.slice(0, separator) : source.label,
};
}));
function repositoryById(id: string): GitIntegrationRepository | undefined {
return repositories.find((repository) => repository.id === id);
}
function formatUpdatedAt(value: string): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
}
let repositoryId = $state("");
let sourceBranch = $state("");
let targetBranch = $state("");
let branches = $state<string[]>([]);
let defaultBranch = $state("");
let branchesLoading = $state(false);
let branchError = $state("");
let branchGeneration = 0;
const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined})));
$effect(() => {
const repository = repositories.find(item => item.id === repositoryId);
void loadRepositoryBranches(repository);
});
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
const generation = ++branchGeneration;
assignees = [];
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
branchesLoading = !!repository;
if (!repository) return;
try {
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository);
if (generation !== branchGeneration) return;
branches = result.branches;
defaultBranch = result.defaultBranch;
targetBranch = branches.includes(defaultBranch) ? defaultBranch : "";
// Only suggest a local branch when its remote matches this exact repository.
if (localRepositoryPath) {
try {
const remotes = await listRemotes(localRepositoryPath);
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url)));
if (matches) {
const localBranches = await listBranches(localRepositoryPath);
if (generation !== branchGeneration) return;
const current = localBranches.find(branch => branch.current)?.name;
if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current;
}
} catch { /* Remote branch selection remains available without local metadata. */ }
}
if (generation !== branchGeneration) return;
if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? "";
} catch (cause) {
if (generation === branchGeneration) branchError = String(cause);
} finally { if (generation === branchGeneration) branchesLoading = false; }
}
let generating = $state(false);
let title = $state("");
let description = $state("");
let assignees = $state<IntegrationAssignee[]>([]);
let created = $state<IntegrationReviewRequest | null>(null);
function finish() { if (created) onCreated(created); else onClose(); }
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(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
onMount(() => { dialog.showModal(); titleInput.focus({ preventScroll: true }); 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 generateDraft() {
if (busy || generating || branchesLoading || !sourceBranch || !targetBranch || sameBranch) return;
const repository = repositories.find(item => item.id === repositoryId);
if (!repository) return;
generating = true; error = "";
const context = `${repositoryId}:${sourceBranch}:${targetBranch}`;
try {
if (!localRepositoryPath) throw new Error(de ? "Öffne zuerst das passende lokale Repository und führe Fetch aus." : "Open the matching local repository and fetch it first.");
const remotes = await listRemotes(localRepositoryPath);
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
const remote = remotes.find(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url)));
if (!remote) throw new Error(de ? "Das offene lokale Repository passt nicht zum ausgewählten PR-Repository." : "The open local repository does not match the selected PR repository.");
const settings = { ...aiSettings };
const credential = await credLoad(`ai:${settings.provider}`);
const draft = await pullRequestAiGenerate(localRepositoryPath, remote.name, sourceBranch, targetBranch, {
provider: settings.provider,
model: settings.provider === "openai" ? settings.openaiModel : settings.provider === "anthropic" ? settings.anthropicModel : settings.customModel,
baseUrl: settings.provider === "custom" ? settings.customBaseUrl : undefined,
apiKey: credential?.password, language: de ? "de" : "en",
});
if (context !== `${repositoryId}:${sourceBranch}:${targetBranch}`) return;
title = draft.title; description = draft.description;
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
finally { generating = false; }
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || generating || !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.");
created ??= await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
if (assignees.length) await setIntegrationAssignees(source.provider, source.baseUrl, credential.username, credential.password,
{ repository: created.repositoryName, repositoryId: created.repositoryId, number: created.number, kind: "review" }, assignees);
onCreated(created);
} catch (cause) { error = (created ? (de ? "PR wurde erstellt, aber die Zuweisung konnte nicht bestätigt werden. Du kannst nur die Zuweisung erneut versuchen oder mit Fertig fortfahren. " : "PR created, but assignment could not be confirmed. Retry the assignment or continue with Done. ") : "") + (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 && !generating) finish(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) finish(); } }}>
<form onsubmit={submit}>
<header class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || busy} onclick={finish}><X size={18}/></button></header>
<fieldset class="body" disabled={!!created}>
{#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}
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
<SelectMenu value={repositoryId} options={repositoryOptions} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} showSelectedGroup onChange={value => repositoryId = value}>
{#snippet optionIcon()}<GitBranch size={14} aria-hidden="true" />{/snippet}
{#snippet optionMeta(option)}
{@const repository = repositoryById(option.value)}
{#if repository?.private}<LockKeyhole size={11} aria-label={de ? "Privat" : "Private"} />{/if}
{formatUpdatedAt(repository?.updatedAt ?? "")}
{/snippet}
</SelectMenu>
</div>
{#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">
<div class="repository-field"><span>{de ? "Quellbranch" : "Source branch"}</span><SelectMenu value={sourceBranch} options={branchOptions.map(option => ({...option,disabled:option.value === targetBranch}))} disabled={generating || busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/></div>
<ArrowRight size={16}/>
<div class="repository-field"><span>{de ? "Zielbranch" : "Target branch"}</span><SelectMenu value={targetBranch} options={branchOptions.map(option => ({...option,disabled:option.value === sourceBranch}))} disabled={generating || busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/></div>
</div>
{#if branchError}<div class="error" role="alert">{branchError}<button type="button" onclick={() => loadRepositoryBranches(repositories.find(item => item.id === repositoryId))}>{de ? "Erneut laden" : "Retry"}</button></div>{:else if repositoryId && !branchesLoading && !branches.length}<p>{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}</p>{/if}
{#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>
<div class="ai-draft-action"><button type="button" disabled={generating || busy || branchesLoading || !sourceBranch || !targetBranch || sameBranch} onclick={generateDraft}>{#if generating}<LoaderCircle class="spin" size={15}/>{:else}<Sparkles size={15}/>{/if}{generating ? (de ? "Wird generiert …" : "Generating…") : (de ? "Mit KI erstellen" : "Generate with AI")}</button><small>{de ? "Erstellt Titel und Beschreibung aus dem lokalen Stand der Remote-Branches. Vorher Fetch ausführen." : "Creates a title and description from locally fetched remote branches. Fetch first."}</small></div>
<AssigneePicker {source} target={{ repository: repositoryById(repositoryId)?.fullName ?? "", repositoryId, number: 0, kind: "review" }} {de} {loadCredential} bind:value={assignees} disabled={generating || busy || !!created}/>
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={generating || busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
<div class="repository-field">
<span>{de ? "Beschreibung" : "Description"}</span>
<CommentEditor bind:value={description} language={de ? "de" : "en"} disabled={generating || busy} rows={7}
ariaLabel={de ? "Beschreibung" : "Description"}
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
placeholder={de ? "Beschreibe deine Änderungen …" : "Describe your changes…"} />
</div>
</fieldset>
<footer><button type="button" disabled={generating || busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={generating || busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : created ? (de ? "Zuweisung erneut versuchen" : "Retry assignment") : heading}</button></footer>
</form>
</dialog>
<style>
fieldset.body {border:0;margin:0;min-width:0}
.ai-draft-action{display:flex;align-items:center;gap:12px}.ai-draft-action small{color:var(--color-ink-muted);line-height:1.5}.ai-draft-action button{flex-shrink:0}
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent);backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);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{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:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input{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}input:focus{outline:2px solid var(--color-accent);outline-offset:1px}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);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);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent-solid);border-color:var(--color-accent-solid);color:var(--color-on-accent)}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
</style>