diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 118fcb8..08e77cf 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,5 @@ +mod issue_creation; +pub use issue_creation::*; mod issue_actions; pub use issue_actions::*; mod issue_comments; diff --git a/src-tauri/src/integrations/issue_creation.rs b/src-tauri/src/integrations/issue_creation.rs new file mode 100644 index 0000000..4e5f4c3 --- /dev/null +++ b/src-tauri/src/integrations/issue_creation.rs @@ -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 { + 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 { + 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, 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, 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\n', "
"); + 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 { + 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", " + + { event.preventDefault(); if (!busy) onClose(); }}> +
+

{de ? "Neues Issue" : "New issue"}

{source.label}

+
+
{azure ? (de ? "Projekt" : "Project") : "Repository"} + void selectTarget(value)}/> +
+ {#if loadError} + {:else if !loading && !targets.length}

{de ? "Keine Repositories oder Projekte verfügbar." : "No repositories or projects available."}

{/if} + {#if azure} +
{de ? "Work-Item-Typ" : "Work item type"} ({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}/>
+ {#if typeError} + {:else if repository && !typesLoading && !types.length}

{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}

{/if} + {/if} + + + {#if error}{/if} +
+
+
+
+ + diff --git a/src/lib/components/IssueCenter.svelte b/src/lib/components/IssueCenter.svelte index 4847d69..a4464dc 100644 --- a/src/lib/components/IssueCenter.svelte +++ b/src/lib/components/IssueCenter.svelte @@ -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; 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 @@ { repositoryFilter = value; selectedId = ""; }} /> { stateFilter = value; }} /> + {#if source}{/if} {#if error}{/if}
@@ -317,3 +341,7 @@ {/if} {/if} + +{#if createSource} + { createSource = null; }} onCreated={issueCreated}/> +{/if} diff --git a/src/lib/git.ts b/src/lib/git.ts index 5965a68..46bc91b 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -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 { return invoke("set_azure_issue_state", { baseUrl, username, token, repository, number, state }); } + +export function listAzureIssueProjects(baseUrl: string, username: string, token: string): Promise { + return invoke("list_azure_issue_projects", { baseUrl, username, token }); +} +export function listAzureIssueTypes(baseUrl: string, username: string, token: string, project: string): Promise { + 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 { + return invoke("create_integration_issue", { provider, baseUrl, username, token, repository, title, description, workItemType }); +} diff --git a/src/lib/issueWorkspace.css b/src/lib/issueWorkspace.css index 81717e3..b7cd849 100644 --- a/src/lib/issueWorkspace.css +++ b/src/lib/issueWorkspace.css @@ -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)}