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 @@