feat(integrations): add labels integration and expose shared IntegrationApi

Add a new labels integration (src-tauri/src/integrations/labels.rs) that implements listing,
reading and updating issue labels for multiple providers (GitHub, Gitea, GitLab and Azure
DevOps). The new module provides Tauri commands:
- list_integration_labels
- get_integration_issue_labels
- set_integration_issue_labels

Refactor assignees integration to make core HTTP helpers reusable:
- Rename AssignmentApi -> IntegrationApi and make its fields/methods pub(super).
- Make helper functions api_url, repository_parts and target_url pub(super) so labels.rs
  can construct and call provider endpoints.
- Adjust wording of a few error messages (e.g. "Assignment request..." -> "Integration request...")
  and genericize some page/result error text.

Export the new labels module from integrations.rs and relax test helper visibility
(pub(crate)) so the labels tests can reuse the existing fixture utilities.

This commit introduces provider-specific parsing and payload logic for labels and
reuses the shared IntegrationApi to perform authenticated requests.
This commit is contained in:
2026-09-22 11:28:33 +02:00
parent 8e63f2a939
commit aec0d431f9
9 changed files with 720 additions and 27 deletions
+17 -6
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { CirclePlus, X } from "@lucide/svelte";
import IssueLabelEditor from "./IssueLabelEditor.svelte";
import AssigneePicker from "./AssigneePicker.svelte";
import { setIntegrationAssignees } from "../git";
import type { IntegrationAssignee } from "../types";
import { setIntegrationAssignees, setIntegrationIssueLabels } from "../git";
import type { IntegrationAssignee, IntegrationLabel } from "../types";
import SelectMenu from "./SelectMenu.svelte";
import CommentEditor from "./CommentEditor.svelte";
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
@@ -24,6 +25,9 @@
let title = $state("");
let description = $state("");
let assignees = $state<IntegrationAssignee[]>([]);
let labels = $state<IntegrationLabel[]>([]);
let assigneesSaved = false;
let labelsSaved = false;
let created = $state<IntegrationIssue | null>(null);
function finish() { if (created) onCreated(created); else onClose(); }
let loading = $state(true);
@@ -62,7 +66,7 @@
finally { if (!destroyed) loading = false; }
}
async function selectTarget(value: string) {
assignees = [];
assignees = []; labels = []; assigneesSaved = false; labelsSaved = false;
repository = value; types = []; workItemType = ""; typeError = "";
const generation = ++typeGeneration;
typesLoading = azure && !!value;
@@ -83,10 +87,16 @@
try {
const auth = await credential();
created ??= await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
if (assignees.length) {
if (assignees.length && !assigneesSaved) {
const assigned = await setIntegrationAssignees(source.provider, source.baseUrl, auth.username, auth.password,
{ repository: created.repositoryName, number: created.number, kind: "issue" }, assignees);
created.assignees = assigned.map(user => azure ? user.name || user.username : user.username || user.name);
assigneesSaved = true;
}
if (labels.length && !labelsSaved) {
const saved = await setIntegrationIssueLabels(source.provider, source.baseUrl, auth.username, auth.password, created.repositoryName, created.number, labels);
created.labels = saved.map(label => label.name);
labelsSaved = true;
}
onCreated(created);
} catch (cause) { if (!destroyed) error = String(cause); }
@@ -111,6 +121,7 @@
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
{/if}
<AssigneePicker {source} target={{ repository, number: 0, kind: "issue" }} {de} {loadCredential} bind:value={assignees} disabled={busy || !!created}/>
<IssueLabelEditor {source} {repository} {de} {loadCredential} bind:value={labels} disabled={busy || !!created}/>
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
<div class="field">
<span>{de ? "Beschreibung" : "Description"}</span>
@@ -119,9 +130,9 @@
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
</div>
{#if error}<p class="error" role="alert">{created ? (de ? "Issue wurde erstellt, aber die Zuweisung konnte nicht bestätigt werden. Du kannst nur die Zuweisung erneut versuchen oder mit Fertig fortfahren." : "Issue created, but assignment could not be confirmed. Retry the assignment or continue with Done.") : (de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed.")} {error}</p>{/if}
{#if error}<p class="error" role="alert">{created ? (de ? "Issue wurde erstellt, aber Zuweisung oder Labels konnten nicht vollständig gespeichert werden. Erneut versuchen speichert nur die ausstehenden Angaben." : "Issue created, but assignment or labels could not be fully saved. Retry saves only the remaining details.") : (de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed.")} {error}</p>{/if}
</fieldset>
<footer><button type="button" disabled={busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : created ? (de ? "Zuweisung erneut versuchen" : "Retry assignment") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
<footer><button type="button" disabled={busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : created ? (de ? "Angaben erneut speichern" : "Retry saving details") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
</form>
</dialog>
+13 -1
View File
@@ -10,6 +10,7 @@
import "../issueWorkspace.css";
import CreateIssueDialog from "./CreateIssueDialog.svelte";
import IssueComments from "./IssueComments.svelte";
import IssueLabelEditor from "./IssueLabelEditor.svelte";
import IssueLabels from "./IssueLabels.svelte";
import AssignmentEditor from "./AssignmentEditor.svelte";
import IssueAssignees from "./IssueAssignees.svelte";
@@ -344,7 +345,18 @@
}}/>
{/key}
</section>
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
<section>
{#key `${sourceKey}:${selected.id}`}
{@const issueId = selected.id}
{@const labelSourceKey = sourceKey}
<IssueLabelEditor {source} repository={selected.repositoryName} number={selected.number} {de} {loadCredential} disabled={!!closingId || loading} onSaved={savedLabels => {
const labels = savedLabels.map(label => label.name);
const saved = cache.get(labelSourceKey);
if (saved) cache.set(labelSourceKey, { ...saved, issues: saved.issues.map(item => item.id === issueId ? { ...item, labels } : item) });
if (sourceKey === labelSourceKey) issues = issues.map(item => item.id === issueId ? { ...item, labels } : item);
}}/>
{/key}
</section>
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
</div>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { X } from "@lucide/svelte";
import { listIntegrationLabels, getIntegrationIssueLabels, setIntegrationIssueLabels } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationLabel, StoredCredential } from "../types";
let { source, repository, number = 0, de, loadCredential, value = $bindable<IntegrationLabel[]>([]), disabled = false, onSaved = () => {} }: {
source: GitIntegrationSource; repository: string; number?: number; de: boolean;
loadCredential: (key: string) => Promise<StoredCredential | null>;
value?: IntegrationLabel[]; disabled?: boolean; onSaved?: (labels: IntegrationLabel[]) => void;
} = $props();
let catalog = $state<IntegrationLabel[]>([]);
let original = $state<IntegrationLabel[]>([]);
let loading = $state(false);
let loaded = $state(false);
let busy = $state(false);
let catalogError = $state("");
let error = $state("");
let retry = $state(0);
let generation = 0;
const heading = $derived(source.provider === "azure-devops" ? "Tags" : "Labels");
const names = (labels: IntegrationLabel[]) => JSON.stringify(labels.map(label => label.name).sort());
const dirty = $derived(names(value) !== names(original));
const options = $derived(catalog.filter(label => !value.some(selected => selected.name === label.name)).map(label => ({value:label.id,label:label.name})));
function colorFor(label?: IntegrationLabel): string {
const color = label?.color || catalog.find(item => item.name === label?.name)?.color || "";
return /^#[0-9a-f]{6}$/i.test(color) ? color : "var(--color-ink-dim)";
}
async function auth(current: GitIntegrationSource) {
const result = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
return result;
}
$effect(() => {
const current = source, repo = repository, issue = number;
void retry;
const currentGeneration = ++generation;
catalog = []; original = []; catalogError = ""; error = ""; busy = false;
loading = !!repo; loaded = !issue;
if (issue) value = [];
if (repo) void (async () => {
try {
const credential = await auth(current);
const [available, selected] = await Promise.allSettled([
listIntegrationLabels(current.provider, current.baseUrl, credential.username, credential.password, repo),
issue ? getIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue) : Promise.resolve(null),
]);
if (currentGeneration !== generation) return;
if (available.status === "fulfilled") catalog = available.value;
else catalogError = String(available.reason);
if (selected.status === "fulfilled") {
if (selected.value) { original = selected.value; value = [...selected.value]; }
loaded = true;
} else error = String(selected.reason);
} catch (cause) { if (currentGeneration === generation) catalogError = String(cause); }
finally { if (currentGeneration === generation) loading = false; }
})();
return () => { generation++; };
});
function add(id: string) {
if (disabled || busy || loading || !loaded) return;
const label = catalog.find(label => label.id === id);
if (label && !value.some(selected => selected.name === label.name)) value = [...value, label];
}
async function save() {
if (disabled || busy || loading || !loaded || !number || !dirty) return;
const current = source, repo = repository, issue = number, labels = [...value], expected = original.map(label => label.name), currentGeneration = generation, savedCallback = onSaved;
busy = true; error = "";
try {
const credential = await auth(current);
const saved = await setIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue, labels, expected);
savedCallback(saved);
if (currentGeneration !== generation) return;
original = saved; value = [...saved];
} catch (cause) { if (currentGeneration === generation) error = String(cause); }
finally { if (currentGeneration === generation) busy = false; }
}
</script>
<div class="label-editor" aria-busy={loading || busy}>
<span class="field-label">{heading}</span>
{#if value.length}<ul aria-label={heading}>
{#each value as label (label.name)}
<li title={label.description || label.name}><span class="color-dot" style:background={colorFor(label)}></span><span class="label-name">{label.name}</span><button type="button" disabled={disabled || busy || loading || !loaded} aria-label={`${de ? "Label entfernen" : "Remove label"}: ${label.name}`} onclick={() => value = value.filter(selected => selected.name !== label.name)}><X size={13}/></button></li>
{/each}
</ul>{/if}
<SelectMenu value="" {options} searchable disabled={disabled || loading || busy || !loaded || !repository || !!catalogError} ariaLabel={heading}
placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : !repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : `${heading} ${de ? "auswählen …" : "…"}`}
searchPlaceholder={de ? `${heading} suchen ` : `Search ${heading.toLowerCase()} `} emptyText={de ? "Keine passenden Einträge" : "No matching entries"} onChange={add}>
{#snippet optionIcon(option)}<span class="color-dot" style:background={colorFor(catalog.find(label => label.id === option.value))}></span>{/snippet}
</SelectMenu>
{#if !loading && repository && !catalog.length && !catalogError}<small>{de ? `Keine ${heading} im Repository/Projekt vorhanden.` : `No ${heading.toLowerCase()} available in this repository/project.`}</small>{/if}
{#if catalogError || error}<p role="alert">{catalogError || error}</p><button class="retry" type="button" disabled={disabled || busy || loading} onclick={() => retry++}>{de ? `${heading} neu laden` : `Reload ${heading.toLowerCase()}`}</button>{/if}
{#if number && loaded && dirty}<div class="actions"><button type="button" disabled={disabled || busy || loading} onclick={save}>{busy ? (de ? "Wird gespeichert …" : "Saving …") : (de ? `${heading} speichern` : `Save ${heading.toLowerCase()}`)}</button><button type="button" disabled={disabled || busy || loading} onclick={() => { value = [...original]; error = ""; }}>{de ? "Abbrechen" : "Cancel"}</button></div>{/if}
</div>
<style>
.label-editor{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)}.label-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.color-dot{display:inline-block;width:9px;height:9px;border-radius:50%;flex-shrink:0}
button{font:inherit;font-size:11px;padding:6px 8px;color:inherit;background:var(--color-surface);border:1px solid var(--color-border);cursor:pointer}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}.actions{display:flex;flex-wrap:wrap;gap:6px}small{color:var(--color-ink-dim);font-size:11px;line-height:1.5}p{margin:0;color:var(--color-danger);overflow-wrap:anywhere;line-height:1.5}
</style>
+10
View File
@@ -767,3 +767,13 @@ export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUr
export function setIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget, users: import("./types").IntegrationAssignee[]): Promise<import("./types").IntegrationAssignee[]> {
return invoke("set_integration_assignees", { provider, baseUrl, username, token, target, users });
}
export function listIntegrationLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string): Promise<import("./types").IntegrationLabel[]> {
return invoke("list_integration_labels", { provider, baseUrl, username, token, repository });
}
export function getIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<import("./types").IntegrationLabel[]> {
return invoke("get_integration_issue_labels", { provider, baseUrl, username, token, repository, number });
}
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
}
+7
View File
@@ -524,3 +524,10 @@ export interface AssignmentTarget {
number: number;
kind: "issue" | "review";
}
export interface IntegrationLabel {
id: string;
name: string;
color: string;
description: string;
}