Files
GitLite/src/lib/components/AssigneePicker.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

76 lines
5.1 KiB
Svelte

<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { X } from "@lucide/svelte";
import { listIntegrationAssignees } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { AssignmentTarget, GitIntegrationSource, IntegrationAssignee, StoredCredential } from "../types";
let { source, target, de, loadCredential, value = $bindable<IntegrationAssignee[]>([]), disabled = false }: {
source: GitIntegrationSource; target: AssignmentTarget; de: boolean;
loadCredential: (key: string) => Promise<StoredCredential | null>;
value?: IntegrationAssignee[]; disabled?: boolean;
} = $props();
let users = $state<IntegrationAssignee[]>([]);
let loading = $state(false);
let error = $state("");
let retry = $state(0);
const reviewer = $derived(source.provider === "azure-devops" && target.kind === "review");
const single = $derived(source.provider === "azure-devops" && target.kind === "issue");
const label = $derived(reviewer ? "Reviewer" : (de ? "Zugewiesen an" : "Assignees"));
function displayName(user: IntegrationAssignee): string {
return [user.name, user.username].map(name => name.trim()).find(name => name && !name.includes("@")) || (de ? "Benutzer" : "User");
}
const options = $derived(users.filter(user => !value.some(selected => selected.id === user.id)).map(user => ({
value: user.id, label: displayName(user),
})));
$effect(() => {
const current = source;
const context = { repository: target.repository, repositoryId: target.repositoryId, kind: target.kind, number: 0 };
void retry;
let cancelled = false;
users = []; error = ""; loading = !!context.repository;
if (context.repository) void (async () => {
try {
const auth = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
if (!auth?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const result = await listIntegrationAssignees(current.provider, current.baseUrl, auth.username, auth.password, context);
if (!cancelled) users = result;
} catch (cause) { if (!cancelled) error = String(cause); }
finally { if (!cancelled) loading = false; }
})();
return () => { cancelled = true; };
});
function add(id: string) {
if (disabled) return;
const user = users.find(user => user.id === id);
if (user) value = single ? [user] : [...value, user];
}
</script>
<div class="assignee-picker">
<span class="field-label">{label}</span>
{#if value.length}
<ul aria-label={label}>
{#each value as user (user.id)}
<li><span>{displayName(user)}</span><button type="button" {disabled} aria-label={`${de ? "Entfernen" : "Remove"}: ${displayName(user)}`} onclick={() => value = value.filter(selected => selected.id !== user.id)}><X size={13}/></button></li>
{/each}
</ul>
{/if}
<SelectMenu value="" {options} disabled={disabled || loading || !target.repository || !!error} searchable ariaLabel={label}
placeholder={loading ? (de ? "Benutzer werden geladen …" : "Loading users …") : !target.repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : single && value.length ? (de ? "Benutzer wechseln …" : "Change user …") : (de ? "Benutzer auswählen …" : "Select user …")}
searchPlaceholder={de ? "Benutzer suchen …" : "Search users …"} emptyText={de ? "Keine verfügbaren Benutzer" : "No available users"} onChange={add}/>
{#if error}<div class="error" role="alert">{de ? "Benutzer konnten nicht geladen werden." : "Could not load users."} {error}<button type="button" {disabled} onclick={() => retry++}>{de ? "Erneut laden" : "Retry"}</button></div>
{:else if target.repository && !loading && !users.length}<small>{de ? "Keine zuweisbaren Benutzer gefunden." : "No assignable users found."}</small>{/if}
{#if source.provider === "azure-devops"}<small>{reviewer ? (de ? "Azure-PRs verwenden Reviewer. Auswahl aus den Projektteams." : "Azure PRs use reviewers. Select from project teams.") : (de ? "Auswahl aus den Projektteams; eine Person pro Work Item." : "Select from project teams; one person per work item.")}</small>{/if}
</div>
<style>
.assignee-picker{display:grid;gap:8px;min-width:0;font-size:12px;color:var(--color-ink)}
.field-label{font-weight:500}ul{display:flex;flex-wrap:wrap;gap:6px;list-style:none;margin:0;padding:0}
li{display:flex;align-items:center;gap:6px;max-width:100%;padding:4px 6px;background:var(--color-surface);border:1px solid var(--color-border)}
li span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
button{font:inherit;color:inherit;cursor:pointer;background:var(--color-surface);border:1px solid var(--color-border);padding:4px 8px}li button{display:grid;place-items:center;border:0;padding:2px;background:transparent}
button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
small{font-size:11px;color:var(--color-ink-dim);line-height:1.5}.error{color:var(--color-danger);overflow-wrap:anywhere}.error button{margin-top:6px;display:block}
</style>