From aec0d431f926be90dcb2fbc5b8f3edbc896628dd Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Sep 2026 11:28:33 +0200 Subject: [PATCH] feat(integrations): add labels integration and expose shared IntegrationApi Add a new labels integration (src-tauri/src/integrations/labels.rs) that implements listing, reading and updating issue labels for multiple providers (GitHub, Gitea, GitLab and Azure DevOps). The new module provides Tauri commands: - list_integration_labels - get_integration_issue_labels - set_integration_issue_labels Refactor assignees integration to make core HTTP helpers reusable: - Rename AssignmentApi -> IntegrationApi and make its fields/methods pub(super). - Make helper functions api_url, repository_parts and target_url pub(super) so labels.rs can construct and call provider endpoints. - Adjust wording of a few error messages (e.g. "Assignment request..." -> "Integration request...") and genericize some page/result error text. Export the new labels module from integrations.rs and relax test helper visibility (pub(crate)) so the labels tests can reuse the existing fixture utilities. This commit introduces provider-specific parsing and payload logic for labels and reuses the shared IntegrationApi to perform authenticated requests. --- src-tauri/src/integrations.rs | 2 + src-tauri/src/integrations/assignees.rs | 41 +- src-tauri/src/integrations/labels.rs | 544 ++++++++++++++++++++ src-tauri/src/main.rs | 4 + src/lib/components/CreateIssueDialog.svelte | 23 +- src/lib/components/IssueCenter.svelte | 14 +- src/lib/components/IssueLabelEditor.svelte | 102 ++++ src/lib/git.ts | 10 + src/lib/types.ts | 7 + 9 files changed, 720 insertions(+), 27 deletions(-) create mode 100644 src-tauri/src/integrations/labels.rs create mode 100644 src/lib/components/IssueLabelEditor.svelte diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 3e939f1..7a3241b 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,5 @@ +mod labels; +pub use labels::*; mod assignees; pub use assignees::*; mod cleanup; diff --git a/src-tauri/src/integrations/assignees.rs b/src-tauri/src/integrations/assignees.rs index 8f874cd..6baf586 100644 --- a/src-tauri/src/integrations/assignees.rs +++ b/src-tauri/src/integrations/assignees.rs @@ -21,7 +21,7 @@ pub struct AssignmentTarget { pub kind: String, } -fn api_url(provider: &str, base: &str, segments: &[&str]) -> Result { +pub(super) fn api_url(provider: &str, base: &str, segments: &[&str]) -> Result { let base = match provider { "github" => github_api_base_url(base)?, "gitea" | "gitlab" | "gitlab-self-hosted" | "azure-devops" => normalized_base_url(base)?, @@ -50,7 +50,7 @@ fn project(target: &AssignmentTarget) -> Result<&str, String> { Ok(name) } -fn repository_parts(repository: &str) -> Result<(&str, &str), String> { +pub(super) fn repository_parts(repository: &str) -> Result<(&str, &str), String> { repository .split_once('/') .filter(|(owner, repo)| { @@ -63,7 +63,7 @@ fn repository_parts(repository: &str) -> Result<(&str, &str), String> { .ok_or("Invalid repository name.".into()) } -fn target_url(provider: &str, base: &str, target: &AssignmentTarget) -> Result { +pub(super) fn target_url(provider: &str, base: &str, target: &AssignmentTarget) -> Result { if target.number == 0 || !["issue", "review"].contains(&target.kind.as_str()) { return Err("Invalid assignment target.".into()); } @@ -199,14 +199,14 @@ fn assigned(value: &Value, provider: &str, kind: &str) -> Result { - client: Client, - provider: &'a str, - username: &'a str, - token: &'a str, +pub(super) struct IntegrationApi<'a> { + pub(super) client: Client, + pub(super) provider: &'a str, + pub(super) username: &'a str, + pub(super) token: &'a str, } -impl AssignmentApi<'_> { - fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result { +impl IntegrationApi<'_> { + pub(super) fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result { let mut req = request( &self.client, method, @@ -223,7 +223,7 @@ impl AssignmentApi<'_> { } let response = req .send() - .map_err(|e| format!("Assignment request could not be confirmed: {e}"))?; + .map_err(|e| format!("Integration request could not be confirmed: {e}"))?; if !response.status().is_success() { return Err(response_error(response, self.provider)); } @@ -232,7 +232,7 @@ impl AssignmentApi<'_> { } response.json().map_err(|e| e.to_string()) } - fn pages(&self, url: Url) -> Result, String> { + pub(super) fn pages(&self, url: Url) -> Result, String> { let mut result = Vec::new(); let mut previous = None; for page in 1..=1000 { @@ -268,17 +268,18 @@ impl AssignmentApi<'_> { .and_then(|h| h.to_str().ok()) .map(|s| s.contains("rel=\"next\"")); let value: Value = response.json().map_err(|e| e.to_string())?; + if self.provider == "gitea" && value.is_null() { return Ok(result); } let entries = if self.provider == "azure-devops" { value["value"].as_array() } else { value.as_array() } - .ok_or("Invalid user list.")?; + .ok_or("Invalid entry list.")?; if entries.is_empty() { return Ok(result); } if previous.as_ref() == Some(&value) { - return Err("The provider repeated a user page.".into()); + return Err("The provider repeated a result page.".into()); } result.extend(entries.iter().cloned()); if !next.unwrap_or(entries.len() == 100) { @@ -286,7 +287,7 @@ impl AssignmentApi<'_> { } previous = Some(value); } - Err("Too many user pages returned by the provider.".into()) + Err("Too many result pages returned by the provider.".into()) } } @@ -299,7 +300,7 @@ pub async fn list_integration_assignees( target: AssignmentTarget, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - let api = AssignmentApi { + let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, @@ -380,7 +381,7 @@ pub async fn get_integration_assignees( target: AssignmentTarget, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - let api = AssignmentApi { + let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, @@ -455,7 +456,7 @@ pub async fn set_integration_assignees( users: Vec, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - let api = AssignmentApi { client: comment_client()?, provider: &provider, username: &username, token: &token }; + let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, token: &token }; let url = target_url(&provider, &base_url, &target)?; if users.iter().any(|u| u.id.trim().is_empty()) { return Err("Invalid assigned user.".into()); } let value = if provider == "azure-devops" && target.kind == "review" { @@ -485,7 +486,7 @@ pub async fn set_integration_assignees( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn target(kind: &str) -> AssignmentTarget { AssignmentTarget { @@ -657,7 +658,7 @@ mod tests { } // Exercise the actual authenticated HTTP path against a local provider fixture. - fn fixture( + pub(crate) fn fixture( responses: Vec<(String, u16, String, String)>, ) -> (String, std::thread::JoinHandle>) { use std::io::{Read, Write}; diff --git a/src-tauri/src/integrations/labels.rs b/src-tauri/src/integrations/labels.rs new file mode 100644 index 0000000..5fd8c0d --- /dev/null +++ b/src-tauri/src/integrations/labels.rs @@ -0,0 +1,544 @@ +use super::assignees::{IntegrationApi, api_url, repository_parts, target_url}; +use super::issue_comments::comment_client; +use super::*; +use reqwest::{Method, Url}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IntegrationLabel { + pub id: String, + pub name: String, + #[serde(default)] + pub color: String, + #[serde(default)] + pub description: String, +} + +fn catalog_url(provider: &str, base: &str, repository: &str) -> Result { + if repository.trim().is_empty() { + return Err("Select a repository or project.".into()); + } + match provider { + "github" | "gitea" => { + let (owner, repo) = repository_parts(repository)?; + let mut parts = vec!["repos", owner, repo, "labels"]; + if provider == "gitea" { + parts.splice(0..0, ["api", "v1"]); + } + api_url(provider, base, &parts) + } + "gitlab" | "gitlab-self-hosted" => { + let mut url = api_url( + provider, + base, + &["api", "v4", "projects", repository, "labels"], + )?; + url.query_pairs_mut() + .append_pair("include_ancestor_groups", "true"); + Ok(url) + } + "azure-devops" => api_url(provider, base, &[repository, "_apis", "wit", "tags"]), + _ => Err("Unsupported label provider.".into()), + } +} + +fn parse_label(value: &Value, provider: &str) -> Result { + let name = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value_string(value, &["name"])); + if name.trim().is_empty() { + return Err("The provider returned an invalid label name.".into()); + } + let id = if provider == "gitea" { + json_id(value) + } else { + name.clone() + }; + if id.is_empty() { + return Err("Gitea returned no label ID.".into()); + } + let color = value_string(value, &["color"]); + let color = color.trim_start_matches('#'); + Ok(IntegrationLabel { + id, + name, + color: if color.len() == 6 && color.bytes().all(|b| b.is_ascii_hexdigit()) { + format!("#{color}") + } else { + String::new() + }, + description: value_string(value, &["description"]), + }) +} + +fn parse_labels(value: &Value, provider: &str) -> Result, String> { + if provider == "gitea" && value.is_null() { + return Ok(vec![]); + } + value + .as_array() + .ok_or("The provider returned no label list.")? + .iter() + .map(|label| parse_label(label, provider)) + .collect() +} + +fn issue_labels(value: &Value, provider: &str) -> Result, String> { + if provider == "azure-devops" { + let fields = value["fields"] + .as_object() + .ok_or("Azure returned no work item fields.")?; + let tags = match fields.get("System.Tags") { + None | Some(Value::Null) => "", + Some(Value::String(tags)) => tags, + _ => return Err("Azure returned an invalid tag list.".into()), + }; + return tags + .split(';') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(|name| parse_label(&json!(name), provider)) + .collect(); + } + parse_labels( + value + .get("labels") + .ok_or("The provider returned no issue labels.")?, + provider, + ) +} + +fn label_payload( + provider: &str, + labels: &[IntegrationLabel], + current: &Value, +) -> Result { + if labels.iter().any(|label| label.name.trim().is_empty()) { + return Err("Labels must have a name.".into()); + } + match provider { + "gitea" => { + let ids = labels + .iter() + .map(|label| { + label + .id + .parse::() + .ok() + .filter(|id| *id > 0) + .ok_or("Invalid Gitea label ID.") + }) + .collect::, _>>()?; + Ok(json!({"labels":ids})) + } + "github" => { + Ok(json!({"labels":labels.iter().map(|label| &label.name).collect::>()})) + } + "gitlab" | "gitlab-self-hosted" => { + if labels.iter().any(|label| label.name.contains(',')) { + return Err("GitLab label names cannot contain commas for this operation.".into()); + } + Ok( + json!({"labels":labels.iter().map(|label| label.name.as_str()).collect::>().join(",")}), + ) + } + "azure-devops" => { + if labels.iter().any(|label| label.name.contains(';')) { + return Err("Azure tag names cannot contain semicolons.".into()); + } + let rev = current["rev"] + .as_u64() + .ok_or("Azure returned no work item revision.")?; + Ok( + json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.Tags","value":labels.iter().map(|label| label.name.as_str()).collect::>().join("; ")}]), + ) + } + _ => Err("Unsupported label provider.".into()), + } +} + +fn names(labels: &[IntegrationLabel]) -> BTreeSet { + labels.iter().map(|label| label.name.clone()).collect() +} + +fn desired_labels( + current: &[IntegrationLabel], + requested: Vec, + expected: Option>, +) -> Result, String> { + if let Some(expected) = expected { + if names(current) != expected.into_iter().collect() { + return Err( + "The issue labels changed. Reload the current labels before saving.".into(), + ); + } + Ok(requested) + } else { + // Creation adds to provider defaults. Retrying is idempotent after a lost response. + let mut result = current.to_vec(); + for label in requested { + if !result.iter().any(|existing| existing.name == label.name) { + result.push(label); + } + } + Ok(result) + } +} + +#[tauri::command] +pub async fn list_integration_labels( + provider: String, + base_url: String, + username: String, + token: String, + repository: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let api = IntegrationApi { + client: comment_client()?, + provider: &provider, + username: &username, + token: &token, + }; + let url = catalog_url(&provider, &base_url, &repository)?; + let mut labels = if provider == "azure-devops" { + let value = api.send(Method::GET, url, None)?; + // Azure services return the collection envelope; also accept the documented array form. + parse_labels(value.get("value").unwrap_or(&value), &provider)? + } else { + api.pages(url)? + .iter() + .filter(|label| label["archived_at"].is_null()) + .map(|label| parse_label(label, &provider)) + .collect::, _>>()? + }; + let mut seen = BTreeSet::new(); + labels.retain(|label| seen.insert(label.name.clone())); + labels.sort_by_key(|label| label.name.to_lowercase()); + Ok(labels) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn get_integration_issue_labels( + provider: String, + base_url: String, + username: String, + token: String, + repository: String, + number: u64, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let api = IntegrationApi { + client: comment_client()?, + provider: &provider, + username: &username, + token: &token, + }; + let target = AssignmentTarget { + repository, + repository_id: String::new(), + number, + kind: "issue".into(), + }; + issue_labels( + &api.send( + Method::GET, + target_url(&provider, &base_url, &target)?, + None, + )?, + &provider, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn set_integration_issue_labels( + provider: String, + base_url: String, + username: String, + token: String, + repository: String, + number: u64, + labels: Vec, + expected: Option>, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, token: &token }; + let target = AssignmentTarget { repository, repository_id: String::new(), number, kind: "issue".into() }; + let mut url = target_url(&provider, &base_url, &target)?; + let current = api.send(Method::GET, url.clone(), None)?; + let current_labels = issue_labels(¤t, &provider)?; + let labels = desired_labels(¤t_labels, labels, expected)?; + if names(¤t_labels) == names(&labels) { return Ok(current_labels); } + let body = label_payload(&provider, &labels, ¤t)?; + let array_response = provider == "github" || provider == "gitea"; + if array_response { url.path_segments_mut().map_err(|_| "Invalid labels URL.")?.push("labels"); } + let response = api.send(if provider == "azure-devops" { Method::PATCH } else { Method::PUT }, url, Some(&body))?; + let actual = if array_response { parse_labels(&response, &provider)? } else { issue_labels(&response, &provider)? }; + if names(&actual) != names(&labels) { return Err("The provider did not confirm the labels. Reload the issue and check your permissions.".into()); } + Ok(actual) + }).await.map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + fn label(name: &str) -> IntegrationLabel { + IntegrationLabel { + id: "7".into(), + name: name.into(), + color: String::new(), + description: String::new(), + } + } + #[test] + fn provider_catalog_routes_preserve_subpaths() { + assert_eq!( + catalog_url("gitea", "https://git.test/sub", "team/repo") + .unwrap() + .path(), + "/sub/api/v1/repos/team/repo/labels" + ); + assert_eq!( + catalog_url("github", "https://github.com", "team/repo") + .unwrap() + .host_str(), + Some("api.github.com") + ); + let gl = catalog_url( + "gitlab-self-hosted", + "https://git.test/sub", + "team/nested/repo", + ) + .unwrap(); + assert_eq!( + gl.path(), + "/sub/api/v4/projects/team%2Fnested%2Frepo/labels" + ); + assert_eq!(gl.query(), Some("include_ancestor_groups=true")); + assert_eq!( + catalog_url("azure-devops", "https://dev.azure.com/org", "My Project") + .unwrap() + .path(), + "/org/My%20Project/_apis/wit/tags" + ); + } + #[test] + fn provider_payloads_add_and_clear_labels() { + assert_eq!( + label_payload("gitea", &[label("bug")], &Value::Null).unwrap(), + json!({"labels":[7]}) + ); + assert_eq!( + label_payload("github", &[label("bug")], &Value::Null).unwrap(), + json!({"labels":["bug"]}) + ); + assert_eq!( + label_payload("gitlab", &[label("bug"), label("urgent")], &Value::Null).unwrap(), + json!({"labels":"bug,urgent"}) + ); + assert_eq!( + label_payload("gitlab", &[], &Value::Null).unwrap(), + json!({"labels":""}) + ); + assert_eq!( + label_payload("gitea", &[], &Value::Null).unwrap(), + json!({"labels":[]}) + ); + let patch = label_payload("azure-devops", &[label("bug")], &json!({"rev":8})).unwrap(); + assert_eq!(patch[0], json!({"op":"test","path":"/rev","value":8})); + assert_eq!(patch[1]["value"], "bug"); + assert_eq!( + label_payload("azure-devops", &[], &json!({"rev":8})).unwrap()[1]["value"], + "" + ); + assert!(label_payload("gitlab", &[label("comma,name")], &Value::Null).is_err()); + assert!(label_payload("azure-devops", &[label("bad;tag")], &json!({"rev":8})).is_err()); + assert!(label_payload("azure-devops", &[], &Value::Null).is_err()); + } + #[test] + fn reads_gitea_null_gitlab_strings_and_azure_tags() { + assert!( + issue_labels(&json!({"labels":null}), "gitea") + .unwrap() + .is_empty() + ); + let gitea = issue_labels( + &json!({"labels":[{"id":7,"name":"bug","color":"ff0000"}]}), + "gitea", + ) + .unwrap(); + assert_eq!(gitea[0].id, "7"); + assert_eq!(gitea[0].color, "#ff0000"); + assert_eq!( + issue_labels(&json!({"labels":["bug"]}), "gitlab").unwrap()[0].name, + "bug" + ); + assert_eq!( + issue_labels( + &json!({"fields":{"System.Tags":"bug; urgent; "}}), + "azure-devops" + ) + .unwrap() + .len(), + 2 + ); + assert!( + issue_labels(&json!({"fields":{}}), "azure-devops") + .unwrap() + .is_empty() + ); + assert!(issue_labels(&json!({}), "gitea").is_err()); + assert!(issue_labels(&json!({}), "azure-devops").is_err()); + } + #[test] + fn creation_preserves_defaults_and_edit_detects_stale_labels() { + let current = vec![label("default")]; + let next = desired_labels(¤t, vec![label("bug")], None).unwrap(); + assert_eq!(next.len(), 2); + assert_eq!( + desired_labels(&next, vec![label("bug")], None) + .unwrap() + .len(), + 2 + ); + assert!(desired_labels(¤t, vec![label("bug")], Some(vec![])).is_err()); + assert!( + desired_labels(¤t, vec![], Some(vec!["default".into()])) + .unwrap() + .is_empty() + ); + } + #[tokio::test] + async fn gitea_writes_numeric_label_ids_and_reads_null_current_labels() { + let (base, worker) = super::super::assignees::tests::fixture(vec![ + ( + "GET /api/v1/repos/team/repo/issues/12 ".into(), + 200, + String::new(), + json!({"labels":null}).to_string(), + ), + ( + "PUT /api/v1/repos/team/repo/issues/12/labels ".into(), + 200, + String::new(), + json!([{"id":7,"name":"bug","color":"ff0000"}]).to_string(), + ), + ]); + let result = set_integration_issue_labels( + "gitea".into(), + base, + "qa".into(), + "fixture-token".into(), + "team/repo".into(), + 12, + vec![label("bug")], + Some(vec![]), + ) + .await + .unwrap(); + assert_eq!(result[0].name, "bug"); + let requests = worker.join().unwrap(); + let body: Value = + serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body, json!({"labels":[7]})); + } + + #[tokio::test] + async fn azure_tag_updates_guard_revision_and_preserve_other_fields() { + let (base, worker) = super::super::assignees::tests::fixture(vec![ + ( + "GET /Project/_apis/wit/workitems/12?".into(), + 200, + String::new(), + json!({"rev":4,"fields":{"System.Tags":"default"}}).to_string(), + ), + ( + "PATCH /Project/_apis/wit/workitems/12?".into(), + 200, + String::new(), + json!({"rev":5,"fields":{"System.Tags":"default; bug"}}).to_string(), + ), + ]); + let result = set_integration_issue_labels( + "azure-devops".into(), + base, + "qa".into(), + "fixture-token".into(), + "Project".into(), + 12, + vec![label("bug")], + None, + ) + .await + .unwrap(); + assert_eq!(result.len(), 2); + let requests = worker.join().unwrap(); + assert!(requests[1].contains("application/json-patch+json")); + let body: Value = + serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!( + body, + json!([{"op":"test","path":"/rev","value":4},{"op":"add","path":"/fields/System.Tags","value":"default; bug"}]) + ); + } + + #[tokio::test] + async fn stale_labels_stop_before_any_write() { + let (base, worker) = super::super::assignees::tests::fixture(vec![( + "GET /api/v1/repos/team/repo/issues/12 ".into(), + 200, + String::new(), + json!({"labels":[{"id":9,"name":"new"}]}).to_string(), + )]); + let result = set_integration_issue_labels( + "gitea".into(), + base, + "qa".into(), + "fixture-token".into(), + "team/repo".into(), + 12, + vec![label("bug")], + Some(vec![]), + ) + .await; + assert!(result.unwrap_err().contains("labels changed")); + assert_eq!(worker.join().unwrap().len(), 1); + } + + #[tokio::test] + async fn empty_gitea_catalog_is_valid_but_permission_errors_are_not_empty_lists() { + for (status, response, valid) in [ + (200, "null", true), + (403, "{\"message\":\"Forbidden\"}", false), + ] { + let (base, worker) = super::super::assignees::tests::fixture(vec![( + "GET /api/v1/repos/team/repo/labels?".into(), + status, + String::new(), + response.into(), + )]); + let result = list_integration_labels( + "gitea".into(), + base, + "qa".into(), + "fixture-token".into(), + "team/repo".into(), + ) + .await; + if valid { + assert!(result.unwrap().is_empty()); + } else { + assert!(result.is_err()); + } + worker.join().unwrap(); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2152be5..4a578d9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -38,6 +38,7 @@ 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, list_integration_assignees, get_integration_assignees, set_integration_assignees, + list_integration_labels, get_integration_issue_labels, set_integration_issue_labels, create_integration_issue, list_azure_issue_projects, list_azure_issue_types, run_integration_review_action, get_integration_review_merge_options, 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, }; @@ -457,6 +458,9 @@ async fn main() { move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, + list_integration_labels, + get_integration_issue_labels, + set_integration_issue_labels, list_integration_assignees, get_integration_assignees, set_integration_assignees, diff --git a/src/lib/components/CreateIssueDialog.svelte b/src/lib/components/CreateIssueDialog.svelte index 4388930..8d22690 100644 --- a/src/lib/components/CreateIssueDialog.svelte +++ b/src/lib/components/CreateIssueDialog.svelte @@ -1,9 +1,10 @@ + +
+ {heading} + {#if value.length}
    + {#each value as label (label.name)} +
  • {label.name}
  • + {/each} +
{/if} + + {#snippet optionIcon(option)} label.id === option.value))}>{/snippet} + + {#if !loading && repository && !catalog.length && !catalogError}{de ? `Keine ${heading} im Repository/Projekt vorhanden.` : `No ${heading.toLowerCase()} available in this repository/project.`}{/if} + {#if catalogError || error}

{catalogError || error}

{/if} + {#if number && loaded && dirty}
{/if} +
+ + diff --git a/src/lib/git.ts b/src/lib/git.ts index 7c3c87e..6a7a076 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -767,3 +767,13 @@ export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUr export function setIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget, users: import("./types").IntegrationAssignee[]): Promise { return invoke("set_integration_assignees", { provider, baseUrl, username, token, target, users }); } + +export function listIntegrationLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string): Promise { + return invoke("list_integration_labels", { provider, baseUrl, username, token, repository }); +} +export function getIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise { + return invoke("get_integration_issue_labels", { provider, baseUrl, username, token, repository, number }); +} +export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise { + return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 742cfa6..44c9225 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -524,3 +524,10 @@ export interface AssignmentTarget { number: number; kind: "issue" | "review"; } + +export interface IntegrationLabel { + id: string; + name: string; + color: string; + description: string; +}