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
+2
View File
@@ -1,3 +1,5 @@
mod issue_creation;
pub use issue_creation::*;
mod issue_actions;
pub use issue_actions::*;
mod issue_comments;
@@ -0,0 +1,109 @@
use super::*;
use super::issue_comments::{comment_client, comment_url, request};
use serde_json::{json, Value};
fn azure_url(base: &str, segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?;
url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend(segments.iter().copied());
url.set_query(Some("api-version=7.1"));
Ok(url)
}
fn read(response: Response, provider: &str) -> Result<Value, String> {
if !response.status().is_success() { return Err(response_error(response, provider)); }
response.json().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_azure_issue_projects(base_url: String, username: String, token: String) -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
let client = comment_client()?;
let mut projects = BTreeSet::new();
let mut cursor = String::new();
let mut seen = BTreeSet::new();
loop {
let mut url = azure_url(&base_url, &["_apis", "projects"])?;
url.query_pairs_mut().append_pair("$top", "100");
if !cursor.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &cursor); }
let response = request(&client, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?;
let next = response.headers().get("x-ms-continuationtoken").and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
let value = read(response, "azure-devops")?;
let entries = value["value"].as_array().ok_or("Azure returned no project list.")?;
for entry in entries {
if let Some(name) = entry["name"].as_str().filter(|name| !name.is_empty()) { projects.insert(name.to_owned()); }
}
if next.is_empty() { break; }
if !seen.insert(next.clone()) { return Err("Azure repeated a project page.".into()); }
cursor = next;
}
Ok(projects.into_iter().collect())
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn list_azure_issue_types(base_url: String, username: String, token: String, project: String) -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
if project.trim().is_empty() { return Err("Select an Azure project.".into()); }
let url = azure_url(&base_url, &[&project, "_apis", "wit", "workitemtypes"])?;
let value = read(request(&comment_client()?, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
let entries = value["value"].as_array().ok_or("Azure returned no work item types.")?;
Ok(entries.iter().filter(|entry| entry["isDisabled"] != true).filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect())
}).await.map_err(|e| e.to_string())?
}
fn creation_request(provider: &str, base: &str, repository: &str, title: &str, description: &str, work_item_type: &str) -> Result<(reqwest::Url, Value), String> {
if title.trim().is_empty() { return Err("An issue title is required.".into()); }
if repository.trim().is_empty() { return Err("Select a repository or project.".into()); }
if provider == "azure-devops" {
if work_item_type.trim().is_empty() { return Err("Select a work item type.".into()); }
let url = azure_url(base, &[repository, "_apis", "wit", "workitems", &format!("${work_item_type}")])?;
// Azure descriptions are HTML; preserve literal user text and line breaks.
let html = description.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\n', "<br>");
let mut payload = vec![json!({"op":"add","path":"/fields/System.Title","value":title.trim()})];
if !description.is_empty() { payload.push(json!({"op":"add","path":"/fields/System.Description","value":html})); }
Ok((url, json!(payload)))
} else {
let mut url = comment_url(provider, base, repository, 1)?;
url.path_segments_mut().map_err(|_| "Invalid issue URL.")?.pop().pop();
let payload = if provider.starts_with("gitlab") { json!({"title":title.trim(),"description":description}) } else { json!({"title":title.trim(),"body":description}) };
Ok((url, payload))
}
}
#[tauri::command]
pub async fn create_integration_issue(provider: String, base_url: String, username: String, token: String, repository: String, title: String, description: String, work_item_type: String) -> Result<IntegrationIssue, String> {
tauri::async_runtime::spawn_blocking(move || {
let (url, payload) = creation_request(&provider, &base_url, &repository, &title, &description, &work_item_type)?;
let client = comment_client()?;
let mut req = request(&client, reqwest::Method::POST, url, &provider, &username, &token)?;
if provider == "azure-devops" { req = req.header("Content-Type", "application/json-patch+json"); }
// Never retry a creation automatically: a lost response may still mean success.
let response = req.json(&payload).send().map_err(|e| format!("Creation was not confirmed. Check the provider before retrying: {e}"))?;
let value = read(response, &provider)?;
super::issues::created_issue(&value, &provider, &base_url, &repository)
}).await.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_issue_creation_and_preserves_markdown() {
for (provider, path) in [("gitea", "/sub/api/v1/repos/team/repo/issues"), ("github", "/repos/team/repo/issues"), ("gitlab", "/sub/api/v4/projects/team%2Frepo/issues"), ("gitlab-self-hosted", "/sub/api/v4/projects/team%2Frepo/issues")] {
let (url, body) = creation_request(provider, "https://git.test/sub", "team/repo", " Title ", "**Details**", "").unwrap();
assert_eq!(url.path(), path);
assert_eq!(body["title"], "Title");
assert_eq!(body[if provider.starts_with("gitlab") { "description" } else { "body" }], "**Details**");
}
}
#[test]
fn azure_uses_custom_type_and_escapes_description() {
let (url, body) = creation_request("azure-devops", "https://dev.azure.com/org", "My Project", "Task", "<script>&\nNext", "Custom Task").unwrap();
assert_eq!(url.path(), "/org/My%20Project/_apis/wit/workitems/$Custom%20Task");
assert_eq!(url.query(), Some("api-version=7.1"));
assert_eq!(body[1]["value"], "&lt;script&gt;&amp;<br>Next");
assert!(creation_request("gitea", "https://git.test", "team/repo", " ", "", "").is_err());
assert!(creation_request("azure-devops", "https://dev.azure.com/org", "Project", "Task", "", "").is_err());
assert!(creation_request("gitea", "https://git.test", "invalid", "Task", "", "").is_err());
}
}
+17
View File
@@ -87,6 +87,13 @@ fn parse_issue(value: &serde_json::Value, provider: &str) -> Option<IntegrationI
})
}
pub(super) fn created_issue(value: &serde_json::Value, provider: &str, base: &str, repository: &str) -> Result<IntegrationIssue, String> {
let mut issue = if provider == "azure-devops" { azure_issue(value, base) } else { parse_issue(value, provider).ok_or("The provider returned no issue. Check the provider before retrying.")? };
if issue.number == 0 || value_u64(value, "id") == 0 { return Err("The provider returned no issue number. Check the provider before retrying.".into()); }
issue.repository_name = repository.to_owned();
Ok(issue)
}
fn json_response(
request: reqwest::blocking::RequestBuilder,
provider: &str,
@@ -299,6 +306,16 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn created_issue_keeps_target_when_response_omits_repository() {
let issue = created_issue(&serde_json::json!({"id":71,"number":4,"title":"New","state":"open"}), "gitea", "https://git.test", "team/repo").unwrap();
assert_eq!(issue.id, "gitea:71");
assert_eq!(issue.repository_name, "team/repo");
let azure = created_issue(&serde_json::json!({"id":42,"fields":{"System.Title":"Task","System.State":"New"}}), "azure-devops", "https://dev.azure.com/org", "Project").unwrap();
assert_eq!(azure.repository_name, "Project");
assert_eq!(azure.number, 42);
assert!(created_issue(&serde_json::json!({"number":4}), "gitea", "https://git.test", "team/repo").is_err());
}
#[test]
fn excludes_pull_requests_and_maps_github_issues() {
assert!(parse_issue(&json!({"number": 2, "pull_request": {}}), "github").is_none());
let issue = parse_issue(&json!({"id": 4, "number": 2, "repository_url": "https://api.github.com/repos/team/app", "labels": [{"name":"bug"}], "state":"open"}), "github").unwrap();
+4
View File
@@ -36,6 +36,7 @@ use git::{
use integrations::{
create_integration_review_request, list_integration_repository_branches,
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state,
};
use std::path::{Path, PathBuf};
@@ -448,6 +449,9 @@ async fn main() {
move_integration_board_card,
list_integration_issue_comments,
add_integration_issue_comment,
create_integration_issue,
list_azure_issue_projects,
list_azure_issue_types,
close_integration_issue,
list_azure_issue_states,
set_azure_issue_state,
+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)}