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(); } } }