From d0dd79354ad66da826005bf24caaedda7d4d34b8 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 11 Sep 2026 08:43:06 +0200 Subject: [PATCH] feat(integrations): add Azure work item state management and UI tweaks Add support for listing and setting Azure DevOps work item states from the integration layer. New helpers build and validate JSON-patch payloads and expose two Tauri commands so the frontend can enumerate available states and apply state changes. The Issue Center now loads Azure states and can change an issue's state; dialog dismiss controls and related CSS were consolidated and board UI feedback was simplified for clarity. - Add Tauri commands to list and set Azure work item states. - Load and present Azure states in Issue Center and allow state changes. - Unify dialog dismiss attributes and refine hover/focus styling. --- src-tauri/src/integrations/issue_actions.rs | 80 +++++++++++++++---- src-tauri/src/main.rs | 4 +- src/app.css | 20 ++--- src/lib/components/CreateReviewDialog.svelte | 2 +- .../components/IntegrationBoardView.svelte | 20 +---- src/lib/components/IssueCenter.svelte | 63 ++++++++++++--- src/lib/components/LinePatchDialog.svelte | 2 +- src/lib/components/RepositoryDashboard.svelte | 2 +- src/lib/components/ResolveDialog.svelte | 2 +- src/lib/components/ReviewCenter.svelte | 2 +- src/lib/git.ts | 8 ++ src/lib/issueWorkspace.css | 12 +-- 12 files changed, 151 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/integrations/issue_actions.rs b/src-tauri/src/integrations/issue_actions.rs index 0a108a6..89886bc 100644 --- a/src-tauri/src/integrations/issue_actions.rs +++ b/src-tauri/src/integrations/issue_actions.rs @@ -10,6 +10,56 @@ fn completed_state(value: &Value) -> Result { if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());} Ok(states[0].into()) } +fn azure_issue_context(client: &Client, base: &str, username: &str, token: &str, project: &str, number: u64) -> Result<(reqwest::Url, Value, Value), String> { + let mut url = comment_url("azure-devops", base, project, number)?; + url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop(); + url.set_query(Some("api-version=7.1")); + let item = read(request(client, reqwest::Method::GET, url.clone(), "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?; + let kind = value_string(&item, &["fields", "System.WorkItemType"]); + if kind.is_empty() { return Err("Azure returned no work item type.".into()); } + let mut states_url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?; + states_url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend([project, "_apis", "wit", "workitemtypes", &kind, "states"]); + states_url.set_query(Some("api-version=7.1")); + let states = read(request(client, reqwest::Method::GET, states_url, "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?; + Ok((url, item, states)) +} + +fn azure_state_patch(item: &Value, states: &Value, state: &str) -> Result { + if state.is_empty() || !states["value"].as_array().into_iter().flatten().any(|entry| entry["name"].as_str() == Some(state)) { + return Err("This state is not configured for the work item type.".into()); + } + let rev = item["rev"].as_u64().ok_or("Azure returned no revision.")?; + Ok(json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}])) +} + +fn set_azure_state(client: &Client, url: reqwest::Url, item: &Value, states: &Value, username: &str, token: &str, state: &str) -> Result { + let payload = azure_state_patch(item, states, state)?; + if item["fields"]["System.State"] == state { return Ok(state.into()); } + let result = read(request(client, reqwest::Method::PATCH, url, "azure-devops", username, token)?.header("Content-Type", "application/json-patch+json").json(&payload).send().map_err(|e| e.to_string())?, "azure-devops")?; + let actual = value_string(&result, &["fields", "System.State"]); + if actual != state { return Err("Azure did not confirm the requested state.".into()); } + Ok(actual) +} + +#[tauri::command] +pub async fn list_azure_issue_states(base_url: String, username: String, token: String, repository: String, number: u64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let (_, _, states) = azure_issue_context(&comment_client()?, &base_url, &username, &token, &repository, number)?; + let names: Vec = states["value"].as_array().into_iter().flatten().filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect(); + if names.is_empty() { return Err("Azure returned no work item states.".into()); } + Ok(names) + }).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn set_azure_issue_state(base_url: String, username: String, token: String, repository: String, number: u64, state: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let client = comment_client()?; + let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?; + set_azure_state(&client, url, &item, &states, &username, &token, &state) + }).await.map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result { tauri::async_runtime::spawn_blocking(move || { @@ -17,21 +67,9 @@ pub async fn close_integration_issue(provider:String,base_url:String,username:St let mut url=comment_url(&provider,&base_url,&repository,number)?; url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop(); if provider=="azure-devops" { - url.set_query(Some("api-version=7.1")); - let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?; - let kind=value_string(&item,&["fields","System.WorkItemType"]); - let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?; - states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]); - states_url.set_query(Some("api-version=7.1")); - let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?; - let state=completed_state(&states)?; - if item["fields"]["System.State"]==state {return Ok(state);} - let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?; - let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]); - let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?; - let actual=value_string(&result,&["fields","System.State"]); - if actual!=state {return Err("Azure did not confirm the completed state.".into());} - Ok(actual) + let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?; + let state = completed_state(&states)?; + set_azure_state(&client, url, &item, &states, &username, &token, &state) } else { let gitlab=provider.starts_with("gitlab"); let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH}; @@ -45,6 +83,18 @@ pub async fn close_integration_issue(provider:String,base_url:String,username:St } #[cfg(test)] mod tests { use super::*; + #[test] fn state_changes_validate_custom_states_and_guard_revision() { + let item = json!({"rev":17,"fields":{"System.State":"New"}}); + let states = json!({"value":[{"name":"Ready for QA"},{"name":"Active"}]}); + let patch = azure_state_patch(&item, &states, "Ready for QA").unwrap(); + assert_eq!(patch, json!([ + {"op":"test","path":"/rev","value":17}, + {"op":"add","path":"/fields/System.State","value":"Ready for QA"} + ])); + assert!(azure_state_patch(&item, &states, "Closed").is_err()); + assert!(azure_state_patch(&item, &states, "").is_err()); + assert!(azure_state_patch(&json!({}), &states, "Active").is_err()); + } #[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() { assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered"); assert!(completed_state(&json!({"value":[]})).is_err()); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6377614..5e8bee3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -36,7 +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, - 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, + 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}; use std::sync::Mutex; @@ -449,6 +449,8 @@ async fn main() { list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, + list_azure_issue_states, + set_azure_issue_state, get_integration_review_details, add_integration_review_comment, run_integration_review_action, diff --git a/src/app.css b/src/app.css index 12834af..f93b945 100644 --- a/src/app.css +++ b/src/app.css @@ -9031,30 +9031,24 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s color: #d7dae0; } -/* Keep every dialog close action neutral until it is intentionally targeted. */ -:root .dialog-close:hover:not(:disabled), -:root .dialog-close:focus-visible:not(:disabled), -:root .dialog-icon-button:hover:not(:disabled), -:root .dialog-icon-button:focus-visible:not(:disabled), -:root .cred-close:hover:not(:disabled), -:root .cred-close:focus-visible:not(:disabled) { +/* All dialog dismiss buttons share hover and keyboard-focus feedback. */ +:root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { color: #ffffff; border-color: #f0646d; background: #d93641; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); } -:root[data-theme="light"] .dialog-close:hover:not(:disabled), -:root[data-theme="light"] .dialog-close:focus-visible:not(:disabled), -:root[data-theme="light"] .dialog-icon-button:hover:not(:disabled), -:root[data-theme="light"] .dialog-icon-button:focus-visible:not(:disabled), -:root[data-theme="light"] .cred-close:hover:not(:disabled), -:root[data-theme="light"] .cred-close:focus-visible:not(:disabled) { +:root[data-theme="light"] :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { color: #ffffff; border-color: #a9212b; background: #c92f3a; } +:root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) svg { + color: inherit; +} + /* Keep the repository overview as a compact icon tab. */ .repo-tab.management { flex: 0 0 38px; diff --git a/src/lib/components/CreateReviewDialog.svelte b/src/lib/components/CreateReviewDialog.svelte index 2019265..2780dc7 100644 --- a/src/lib/components/CreateReviewDialog.svelte +++ b/src/lib/components/CreateReviewDialog.svelte @@ -97,7 +97,7 @@ { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
-

{heading}

{source.label}

+

{heading}

{source.label}

{#if error}{/if}
Repository{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}
diff --git a/src/lib/components/IntegrationBoardView.svelte b/src/lib/components/IntegrationBoardView.svelte index be3993e..413223a 100644 --- a/src/lib/components/IntegrationBoardView.svelte +++ b/src/lib/components/IntegrationBoardView.svelte @@ -29,7 +29,6 @@ let moving = false; let dragged: { cardId: string; columnId: string } | null = null; let dropColumn = ""; - let moveMessage = ""; $: canMove = board?.columns.some(column => !!column.moveTarget) ?? false; let error = ""; let query = ""; @@ -169,7 +168,6 @@ dragged = null; dropColumn = ""; error = ""; - moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …"; try { const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000); if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored."); @@ -178,17 +176,14 @@ boardCache.delete(`${preferenceKey}:${url}`); if (destroyed || generation !== requestGeneration) return; boardCache.set(`${preferenceKey}:${url}`, board!); - moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …"; moving = false; if (selected?.id === cardId) selectedColumnId = to; await loadBoard(url); - if (!destroyed) moveMessage = error ? (de ? "Verschoben; der angezeigte Stand konnte noch nicht aktualisiert werden." : "Moved; the displayed board could not be refreshed yet.") : (de ? "Karte verschoben." : "Card moved."); } catch (cause) { if (!destroyed && generation === requestGeneration) { board = previousBoard; if (selected?.id === cardId) selectedColumnId = previousSelectedColumn; error = `${de ? "Verschieben nicht bestätigt. Bitte aktualisieren und Schreibrechte prüfen." : "Move not confirmed. Refresh and check write access."} ${String(cause)}`; - moveMessage = ""; } } finally { if (!destroyed) moving = false; } } @@ -205,15 +200,6 @@ async function openOriginal(url: string) { try { await openInBrowser(url); } catch (cause) { error = String(cause); } } - - function notice(text: string) { - if (!de) return text; - if (text.startsWith("Team scope")) return "Team-Zuordnung, Spalten, geteilte Spalten und Swimlane-Namen werden übernommen. Temporäre Browser-Filter und das Alterslimit für abgeschlossene Karten werden nicht angewendet."; - if (text.startsWith("Saved board scope")) return "Die gespeicherte Board-Konfiguration wird angewendet. Temporäre Suchfilter aus dem Browser werden nicht übernommen."; - if (text.startsWith("Archived cards")) return "Archivierte Karten werden ausgeblendet. Die Spalten entsprechen den Auswahloptionen des Board-Feldes."; - if (text.startsWith("The saved GitHub view filter")) return `Der gespeicherte GitHub-Ansichtsfilter wird nicht angewendet. Alle nicht archivierten Projektkarten erscheinen in ihren ursprünglichen Spalten. ${text.split(": ")[1]?.split(". All non-archived")[0] ?? ""}`; - return text; - } { if (event.key === "Escape") selected = null; }} /> @@ -228,7 +214,7 @@ {#if discoveryWarnings.length}
{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length}){#each discoveryWarnings as warning}

{warning}

{/each}
{/if} {#if board}
-

{boardLabel({ title: board.title, webUrl: board.webUrl, scope: "" })}

{source.label} · {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} · {de ? "Aktualisierung läuft …" : "Updating …"}{/if}
+

{boardLabel({ title: board.title, webUrl: board.webUrl, scope: "" })}

{ repositoryFilter = value; selected = null; }} /> @@ -237,10 +223,8 @@
{/if} - {#if moveMessage}

{moveMessage}

{/if} {#if error}{/if} {#if board} - {#if board.notice}

{notice(board.notice)}

{/if}
{#each columns as column (column.id)} @@ -272,7 +256,7 @@
{source.label} · {de ? "Karte" : "Card"}
{#if selected.webUrl}{/if} - +
diff --git a/src/lib/components/IssueCenter.svelte b/src/lib/components/IssueCenter.svelte index 030be3b..4847d69 100644 --- a/src/lib/components/IssueCenter.svelte +++ b/src/lib/components/IssueCenter.svelte @@ -15,7 +15,7 @@ import IntegrationBoardView from "./IntegrationBoardView.svelte"; import SelectMenu from "./SelectMenu.svelte"; import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences"; - import { listIntegrationIssues, closeIntegrationIssue, openInBrowser } from "../git"; + import { listIntegrationIssues, closeIntegrationIssue, listAzureIssueStates, setAzureIssueState, openInBrowser } from "../git"; import { configuredIntegrationSources, integrationCredentialKey } from "../integrations"; import type { GitIntegrationSettings, IntegrationIssue, StoredCredential } from "../types"; @@ -40,6 +40,12 @@ let closingId = ""; let closingKey = ""; let actionError = ""; + let azureStates: string[] = []; + let statesLoading = false; + let statesError = ""; + let statesGeneration = 0; + let lastStateKey = ""; + let targetState = ""; let closedStates = new Map(); let error = ""; let mounted = false; @@ -74,6 +80,17 @@ $: stateOptions = [{ value: "", label: de ? "Alle Status" : "All states" }, ...[...new Set(issues.map(issue => issue.state))].sort().map(value => ({ value, label: value }))]; $: selected = issues.find(issue => issue.id === selectedId); $: description = selected ? plainDescription(selected) : ""; + $: stateKey = source?.provider === "azure-devops" && selected ? JSON.stringify([sourceKey, selected.id]) : ""; + $: if (stateKey !== lastStateKey) { + lastStateKey = stateKey; + statesGeneration++; + azureStates = []; + statesError = ""; + statesLoading = false; + actionError = ""; + targetState = ""; + if (stateKey) void loadAzureStates(); + } onMount(() => { const saved = readWorkspacePreferences(preferenceKey); @@ -93,7 +110,7 @@ window.removeEventListener("pointerdown", closeDetailsOutside, true); }; }); - onDestroy(() => { generation++; }); + onDestroy(() => { generation++; statesGeneration++; }); function restoreFilters(key: string) { const saved = readWorkspacePreferences(`${preferenceKey}:${key}`); @@ -157,7 +174,26 @@ return document.body.textContent?.trim() ?? ""; } - async function closeIssue() { + async function loadAzureStates() { + if (!selected || source?.provider !== "azure-devops") return; + const current = source; + const issue = selected; + const requestGeneration = ++statesGeneration; + statesLoading = true; + statesError = ""; + try { + const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000); + if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored."); + const states = await withTimeout(listAzureIssueStates(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number), 70_000); + if (requestGeneration !== statesGeneration) return; + azureStates = states; + targetState = issue.state; + } catch (cause) { + if (requestGeneration === statesGeneration) statesError = `${de ? "Status konnten nicht geladen werden." : "Could not load states."} ${String(cause)}`; + } finally { if (requestGeneration === statesGeneration) statesLoading = false; } + } + + async function changeIssueState(nextState?: string) { if (!selected || !source || closingId || loading) return; const issue = selected; const current = source; @@ -168,13 +204,15 @@ try { const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000); if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored."); - const state = await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number); + const state = nextState && current.provider === "azure-devops" + ? await setAzureIssueState(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number, nextState) + : await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number); const saved = cache.get(key); if (saved) cache.set(key, { ...saved, issues: saved.issues.map(item => item.id === issue.id ? { ...item, state } : item) }); - closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state); + if (!nextState) closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state); if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item); } catch (cause) { - if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Issue konnte nicht geschlossen werden. Bitte Schreibrechte prüfen." : "Could not close issue. Check write permissions."} ${String(cause)}`; + if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Status konnte nicht geändert werden. Bitte Schreibrechte und Übergangsregeln prüfen." : "Could not change state. Check write permissions and transition rules."} ${String(cause)}`; } finally { closingId = ""; } } @@ -244,7 +282,7 @@
{source?.label} · Issue
{#if selected.webUrl}{/if} - +
@@ -256,8 +294,15 @@ {#if source}{#key `${sourceKey}:${selected.id}`}{/key}{/if}
- {#if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state} - + {#if source?.provider === "azure-devops"} +
+

{de ? "Status ändern" : "Change state"}

+ ({ value, label: value }))} disabled={statesLoading || !!closingId || loading || !azureStates.length} placeholder={statesLoading ? (de ? "Status werden geladen …" : "Loading states …") : selected.state} ariaLabel={de ? "Neuer Status" : "New state"} onChange={value => { targetState = value; }} /> + + {#if statesError}{/if} +
+ {:else if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state} + {/if} {#if actionError}{/if} {#if selected.webUrl}{/if} diff --git a/src/lib/components/LinePatchDialog.svelte b/src/lib/components/LinePatchDialog.svelte index e39c640..6ec92ca 100644 --- a/src/lib/components/LinePatchDialog.svelte +++ b/src/lib/components/LinePatchDialog.svelte @@ -302,7 +302,7 @@
- +
diff --git a/src/lib/components/RepositoryDashboard.svelte b/src/lib/components/RepositoryDashboard.svelte index ff23c1b..957cd5b 100644 --- a/src/lib/components/RepositoryDashboard.svelte +++ b/src/lib/components/RepositoryDashboard.svelte @@ -247,7 +247,7 @@ -

{de ? "Workspace anlegen" : "Create workspace"}

+

{de ? "Workspace anlegen" : "Create workspace"}

{#if workspaceError}{/if} diff --git a/src/lib/components/ResolveDialog.svelte b/src/lib/components/ResolveDialog.svelte index 07542c7..8f18850 100644 --- a/src/lib/components/ResolveDialog.svelte +++ b/src/lib/components/ResolveDialog.svelte @@ -285,7 +285,7 @@

{t("Konflikte lösen", "Resolve conflicts")}

{#if conflictTarget}{/if} - +
{#if conflictedFiles.length === 0} diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index 5f23bf2..7b3172a 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -494,7 +494,7 @@