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", "