feat(integrations): add issue creation and Azure work item support

Add creation flow for integration issues and Azure work items.
Expose Tauri commands to list Azure projects and types.
Also add a command to create integration issues and return the created item.
Preserve the target repository when provider responses omit it and return
the created issue information to the UI.

- Implement Azure-specific URL and payload handling and creation logic
- Add a Svelte CreateIssueDialog and integrate it into IssueCenter
- Add tests to validate creation requests and created-issue parsing
This commit is contained in:
2026-09-11 09:53:59 +02:00
parent 088cbc5e18
commit 5c39fa3e30
8 changed files with 298 additions and 2 deletions
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { CirclePlus, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
let { source, de, initialRepository = "", loadCredential, onClose, onCreated }: {
source: GitIntegrationSource; de: boolean; initialRepository?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onClose: () => void; onCreated: (issue: IntegrationIssue) => void;
} = $props();
let dialog: HTMLDialogElement;
let titleInput: HTMLInputElement;
let targets = $state<{ value: string; label: string; group?: string }[]>([]);
let repository = $state("");
let types = $state<string[]>([]);
let workItemType = $state("");
let title = $state("");
let description = $state("");
let loading = $state(true);
let typesLoading = $state(false);
let busy = $state(false);
let loadError = $state("");
let typeError = $state("");
let error = $state("");
let destroyed = false;
let typeGeneration = 0;
const azure = $derived(source.provider === "azure-devops");
const canSubmit = $derived(!loading && !typesLoading && !busy && targets.some(item => item.value === repository) && !!title.trim() && (!azure || types.includes(workItemType)));
async function credential() {
const result = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
return result;
}
async function loadTargets() {
loading = true; loadError = "";
try {
let options: typeof targets;
if (azure) {
const auth = await credential();
const projects = await listAzureIssueProjects(source.baseUrl, auth.username, auth.password);
options = projects.map(value => ({ value, label: value }));
} else {
const repos = await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId);
options = [...repos].sort((a,b) => a.fullName.localeCompare(b.fullName)).map(repo => ({value: repo.fullName, label: repo.name, group: repo.fullName.includes("/") ? repo.fullName.slice(0, repo.fullName.lastIndexOf("/")) : undefined}));
}
if (destroyed) return;
targets = options;
const preferred = options.some(item => item.value === initialRepository) ? initialRepository : options.length === 1 ? options[0].value : "";
await selectTarget(preferred);
} catch (cause) { if (!destroyed) loadError = String(cause); }
finally { if (!destroyed) loading = false; }
}
async function selectTarget(value: string) {
repository = value; types = []; workItemType = ""; typeError = "";
const generation = ++typeGeneration;
typesLoading = azure && !!value;
if (!typesLoading) return;
try {
const auth = await credential();
const result = await listAzureIssueTypes(source.baseUrl, auth.username, auth.password, value);
if (destroyed || generation !== typeGeneration) return;
types = result;
if (result.length === 1) workItemType = result[0];
} catch (cause) { if (!destroyed && generation === typeGeneration) typeError = String(cause); }
finally { if (!destroyed && generation === typeGeneration) typesLoading = false; }
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
busy = true; error = "";
try {
const auth = await credential();
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
onCreated(issue);
} catch (cause) { if (!destroyed) error = String(cause); }
finally { if (!destroyed) busy = false; }
}
onMount(() => { dialog.showModal(); titleInput.focus(); void loadTargets(); });
onDestroy(() => { destroyed = true; typeGeneration++; });
</script>
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
<form onsubmit={submit}>
<header><CirclePlus size={20}/><div><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
<div class="body">
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
</div>
{#if loadError}<p class="error" role="alert">{loadError}</p><button type="button" disabled={loading || busy} onclick={loadTargets}>{de ? "Erneut laden" : "Retry"}</button>
{:else if !loading && !targets.length}<p>{de ? "Keine Repositories oder Projekte verfügbar." : "No repositories or projects available."}</p>{/if}
{#if azure}
<div class="field"><span>{de ? "Work-Item-Typ" : "Work item type"}</span><SelectMenu value={workItemType} options={types.map(value => ({value,label:value}))} disabled={!repository || typesLoading || busy} ariaLabel={de ? "Work-Item-Typ" : "Work item type"} placeholder={typesLoading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Typ auswählen" : "Select type")} onChange={value => workItemType = value}/></div>
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
{/if}
<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>
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"}></textarea></label>
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if}
</div>
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
</form>
</dialog>
<style>
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
dialog::backdrop {background:#0007}
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
header>div {flex:1} header>:global(svg) {color:var(--color-accent)}
h2 {font-size:17px;margin:0} p {margin:4px 0;color:var(--color-ink-dim);font-size:12px}
.body {display:grid;gap:18px;padding:22px 24px}.field,label {display:grid;gap:8px;min-width:0;font-size:12px}
button,input,textarea {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
input,textarea {box-sizing:border-box;width:100%;padding:10px;font-size:13px}textarea {resize:vertical;line-height:1.5}
button {padding:8px 12px;cursor:pointer;font-size:12px}button:disabled {opacity:.5;cursor:default}
input:focus-visible,textarea:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
.dialog-close {display:grid;place-items:center;padding:6px;border:0;background:transparent}
.error {color:var(--color-danger,#e0737b);overflow-wrap:anywhere;line-height:1.5}
footer {display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}
.primary {background:var(--color-accent);border-color:var(--color-accent);color:#fff}
</style>
+30 -2
View File
@@ -6,8 +6,9 @@
import { onMount, onDestroy, tick } from "svelte";
import { fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { ChevronRight, CircleDot, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
import { ChevronRight, CircleDot, Plus, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
import "../issueWorkspace.css";
import CreateIssueDialog from "./CreateIssueDialog.svelte";
import IssueComments from "./IssueComments.svelte";
import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte";
@@ -17,13 +18,16 @@
import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
import { listIntegrationIssues, closeIntegrationIssue, listAzureIssueStates, setAzureIssueState, openInBrowser } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, IntegrationIssue, StoredCredential } from "../types";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
export let language: "de" | "en" = "en";
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenSettings: () => void;
let createSource: GitIntegrationSource | null = null;
let createKey = "";
let createRepository = "";
let viewMode: "list" | "board" = "list";
let sourceId = "";
let issues: IntegrationIssue[] = [];
@@ -216,6 +220,25 @@
} finally { closingId = ""; }
}
function issueCreated(issue: IntegrationIssue) {
const key = createKey;
const saved = cache.get(key);
const next = [issue, ...(saved?.issues ?? []).filter(item => item.id !== issue.id)];
cache.set(key, { issues: next, nextCursor: saved?.nextCursor ?? null });
createSource = null;
if (sourceKey !== key) return;
generation++; // Ignore any list response started before creation.
loading = false;
issues = next;
viewMode = "list";
repositoryFilter = ""; stateFilter = ""; query = "";
const collapsed = new Set(collapsedRepositories);
collapsed.delete(JSON.stringify([key, issue.repositoryName]));
collapsedRepositories = collapsed;
selectedId = issue.id;
error = ""; actionError = "";
}
async function openIssue(url: string) {
try { await openInBrowser(url); }
catch (cause) { error = String(cause); }
@@ -249,6 +272,7 @@
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selectedId = ""; }} />
<SelectMenu class="state-select" value={stateFilter} options={stateOptions} ariaLabel={de ? "Status filtern" : "Filter state"} onChange={value => { stateFilter = value; }} />
<button class="workspace-button icon-button" disabled={loading} onclick={() => loadIssues()} title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"}><RefreshCw size={15} /></button>
{#if source}<button class="workspace-button issue-create-button" onclick={() => { createKey = sourceKey; createRepository = repositoryFilter; createSource = source; }}><Plus size={14}/>{de ? "Neues Issue" : "New issue"}</button>{/if}
</div>
{#if error}<div class="workspace-notice error" role="alert">{error}<p>{de ? "Bitte Token und Leserechte für Issues bzw. Azure Boards prüfen." : "Check the token and read permissions for issues or Azure Boards."}</p></div>{/if}
<div class="issue-list">
@@ -317,3 +341,7 @@
{/if}
{/if}
</section>
{#if createSource}
<CreateIssueDialog source={createSource} {de} initialRepository={createRepository} {loadCredential} onClose={() => { createSource = null; }} onCreated={issueCreated}/>
{/if}
+10
View File
@@ -718,3 +718,13 @@ export function listAzureIssueStates(baseUrl: string, username: string, token: s
export function setAzureIssueState(baseUrl: string, username: string, token: string, repository: string, number: number, state: string): Promise<string> {
return invoke("set_azure_issue_state", { baseUrl, username, token, repository, number, state });
}
export function listAzureIssueProjects(baseUrl: string, username: string, token: string): Promise<string[]> {
return invoke("list_azure_issue_projects", { baseUrl, username, token });
}
export function listAzureIssueTypes(baseUrl: string, username: string, token: string, project: string): Promise<string[]> {
return invoke("list_azure_issue_types", { baseUrl, username, token, project });
}
export function createIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, title: string, description: string, workItemType: string): Promise<import("./types").IntegrationIssue> {
return invoke("create_integration_issue", { provider, baseUrl, username, token, repository, title, description, workItemType });
}
+3
View File
@@ -253,3 +253,6 @@
}
.issue-center .issue-state-action {width:100%;margin-top:8px;justify-content:center}
.issue-center .workspace-button.issue-create-button {margin-left:auto;flex-shrink:0;background:var(--color-primary);border-color:var(--color-primary);color:#fff}
.issue-center .workspace-button.issue-create-button:hover:not(:disabled) {background:var(--color-primary);border-color:var(--color-primary);color:#fff;filter:brightness(1.08)}