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:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user