diff --git a/src-tauri/src/integrations/azuredevops.rs b/src-tauri/src/integrations/azuredevops.rs index e25333b5..67ed770f 100644 --- a/src-tauri/src/integrations/azuredevops.rs +++ b/src-tauri/src/integrations/azuredevops.rs @@ -6,7 +6,7 @@ use super::{ConnectionResult, TicketResult}; pub struct AzureDevOpsConfig { pub organization_url: String, pub project: String, - pub pat: String, + pub access_token: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -15,46 +15,545 @@ pub struct WorkItem { pub title: String, pub work_item_type: String, pub state: String, - pub url: String, + pub description: String, } -pub async fn test_connection(_config: &AzureDevOpsConfig) -> Result { - Err( - "Azure DevOps integration available in v0.2. Please update to the latest version." - .to_string(), - ) +/// Test connection to Azure DevOps by querying project info +pub async fn test_connection(config: &AzureDevOpsConfig) -> Result { + let client = reqwest::Client::new(); + let url = format!( + "{}/_apis/projects/{}?api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project + ); + + let resp = client + .get(&url) + .bearer_auth(&config.access_token) + .send() + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + if resp.status().is_success() { + Ok(ConnectionResult { + success: true, + message: "Successfully connected to Azure DevOps".to_string(), + }) + } else { + Ok(ConnectionResult { + success: false, + message: format!("Connection failed with status: {}", resp.status()), + }) + } } +/// Search for work items using WIQL query +pub async fn search_work_items( + config: &AzureDevOpsConfig, + query: &str, +) -> Result, String> { + let client = reqwest::Client::new(); + let wiql_url = format!( + "{}/{}/_apis/wit/wiql?api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project + ); + + // Build WIQL query + let wiql = format!( + "SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State] FROM WorkItems WHERE [System.Title] CONTAINS '{}' ORDER BY [System.CreatedDate] DESC", + query + ); + + let body = serde_json::json!({ "query": wiql }); + + let resp = client + .post(&wiql_url) + .bearer_auth(&config.access_token) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("WIQL query failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "WIQL query failed: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let wiql_result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse WIQL response: {}", e))?; + + let work_item_refs = wiql_result["workItems"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|w| w["id"].as_i64()) + .collect::>(); + + if work_item_refs.is_empty() { + return Ok(vec![]); + } + + // Fetch full work item details + let ids = work_item_refs + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + + let detail_url = format!( + "{}/{}/_apis/wit/workitems?ids={}&api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project, + ids + ); + + let detail_resp = client + .get(&detail_url) + .bearer_auth(&config.access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch work item details: {}", e))?; + + if !detail_resp.status().is_success() { + return Err(format!( + "Failed to fetch work item details: {}", + detail_resp.status() + )); + } + + let details: serde_json::Value = detail_resp + .json() + .await + .map_err(|e| format!("Failed to parse work item details: {}", e))?; + + let work_items = details["value"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|w| { + Some(WorkItem { + id: w["id"].as_i64()?, + title: w["fields"]["System.Title"].as_str()?.to_string(), + work_item_type: w["fields"]["System.WorkItemType"].as_str()?.to_string(), + state: w["fields"]["System.State"].as_str()?.to_string(), + description: w["fields"]["System.Description"] + .as_str() + .unwrap_or("") + .to_string(), + }) + }) + .collect(); + + Ok(work_items) +} + +/// Create a new work item in Azure DevOps pub async fn create_work_item( - _config: &AzureDevOpsConfig, - _title: &str, - _description: &str, - _work_item_type: &str, - _severity: &str, + config: &AzureDevOpsConfig, + title: &str, + description: &str, + work_item_type: &str, + severity: &str, ) -> Result { - Err( - "Azure DevOps integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!( + "{}/{}/_apis/wit/workitems/${}?api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project, + work_item_type + ); + + let mut operations = vec![ + serde_json::json!({ + "op": "add", + "path": "/fields/System.Title", + "value": title + }), + serde_json::json!({ + "op": "add", + "path": "/fields/System.Description", + "value": description + }), + ]; + + // Add severity/priority if provided + if work_item_type == "Bug" && !severity.is_empty() { + operations.push(serde_json::json!({ + "op": "add", + "path": "/fields/Microsoft.VSTS.Common.Severity", + "value": severity + })); + } + + let resp = client + .post(&url) + .bearer_auth(&config.access_token) + .header("Content-Type", "application/json-patch+json") + .json(&operations) + .send() + .await + .map_err(|e| format!("Failed to create work item: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to create work item: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let work_item_id = result["id"].as_i64().unwrap_or(0); + let work_item_url = format!( + "{}/_workitems/edit/{}", + config.organization_url.trim_end_matches('/'), + work_item_id + ); + + Ok(TicketResult { + id: work_item_id.to_string(), + ticket_number: format!("#{}", work_item_id), + url: work_item_url, + }) } +/// Get a work item by ID pub async fn get_work_item( - _config: &AzureDevOpsConfig, - _work_item_id: i64, + config: &AzureDevOpsConfig, + work_item_id: i64, ) -> Result { - Err( - "Azure DevOps integration available in v0.2. Please update to the latest version." + let client = reqwest::Client::new(); + let url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project, + work_item_id + ); + + let resp = client + .get(&url) + .bearer_auth(&config.access_token) + .send() + .await + .map_err(|e| format!("Failed to get work item: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to get work item: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(WorkItem { + id: result["id"] + .as_i64() + .ok_or_else(|| "Missing id".to_string())?, + title: result["fields"]["System.Title"] + .as_str() + .ok_or_else(|| "Missing title".to_string())? .to_string(), - ) + work_item_type: result["fields"]["System.WorkItemType"] + .as_str() + .ok_or_else(|| "Missing work item type".to_string())? + .to_string(), + state: result["fields"]["System.State"] + .as_str() + .ok_or_else(|| "Missing state".to_string())? + .to_string(), + description: result["fields"]["System.Description"] + .as_str() + .unwrap_or("") + .to_string(), + }) } +/// Update an existing work item pub async fn update_work_item( - _config: &AzureDevOpsConfig, - _work_item_id: i64, - _updates: serde_json::Value, + config: &AzureDevOpsConfig, + work_item_id: i64, + updates: serde_json::Value, ) -> Result { - Err( - "Azure DevOps integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!( + "{}/{}/_apis/wit/workitems/{}?api-version=7.0", + config.organization_url.trim_end_matches('/'), + config.project, + work_item_id + ); + + let resp = client + .patch(&url) + .bearer_auth(&config.access_token) + .header("Content-Type", "application/json-patch+json") + .json(&updates) + .send() + .await + .map_err(|e| format!("Failed to update work item: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to update work item: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let updated_work_item_id = result["id"].as_i64().unwrap_or(work_item_id); + let work_item_url = format!( + "{}/_workitems/edit/{}", + config.organization_url.trim_end_matches('/'), + updated_work_item_id + ); + + Ok(TicketResult { + id: updated_work_item_id.to_string(), + ticket_number: format!("#{}", updated_work_item_id), + url: work_item_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_connection_success() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/_apis/projects/TestProject") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(200) + .with_body(r#"{"name":"TestProject","id":"abc123"}"#) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "test_token".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(conn.success); + assert!(conn.message.contains("Successfully connected")); + } + + #[tokio::test] + async fn test_connection_failure() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/_apis/projects/TestProject") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(401) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "invalid_token".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(!conn.success); + } + + #[tokio::test] + async fn test_search_work_items() { + let mut server = mockito::Server::new_async().await; + + let wiql_mock = server + .mock("POST", "/TestProject/_apis/wit/wiql") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(200) + .with_body(r#"{"workItems":[{"id":123}]}"#) + .create_async() + .await; + + let detail_mock = server + .mock("GET", "/TestProject/_apis/wit/workitems") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + mockito::Matcher::UrlEncoded("ids".into(), "123".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "value": [{ + "id": 123, + "fields": { + "System.Title": "Bug: Login fails", + "System.WorkItemType": "Bug", + "System.State": "Active", + "System.Description": "Users cannot login" + } + }] + }"#, + ) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "test_token".to_string(), + }; + + let result = search_work_items(&config, "login").await; + wiql_mock.assert_async().await; + detail_mock.assert_async().await; + + assert!(result.is_ok()); + let items = result.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, 123); + assert_eq!(items[0].title, "Bug: Login fails"); + } + + #[tokio::test] + async fn test_create_work_item() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/TestProject/_apis/wit/workitems/$Bug") + .match_header("authorization", "Bearer test_token") + .match_header("content-type", "application/json-patch+json") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(200) + .with_body(r#"{"id":456}"#) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "test_token".to_string(), + }; + + let result = create_work_item(&config, "Test bug", "Description", "Bug", "3").await; + mock.assert_async().await; + + assert!(result.is_ok()); + let ticket = result.unwrap(); + assert_eq!(ticket.id, "456"); + assert_eq!(ticket.ticket_number, "#456"); + assert!(ticket.url.contains("456")); + } + + #[tokio::test] + async fn test_get_work_item() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/TestProject/_apis/wit/workitems/123") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "id": 123, + "fields": { + "System.Title": "Test item", + "System.WorkItemType": "Task", + "System.State": "Active", + "System.Description": "Test description" + } + }"#, + ) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "test_token".to_string(), + }; + + let result = get_work_item(&config, 123).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let item = result.unwrap(); + assert_eq!(item.id, 123); + assert_eq!(item.title, "Test item"); + } + + #[tokio::test] + async fn test_update_work_item() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("PATCH", "/TestProject/_apis/wit/workitems/123") + .match_header("authorization", "Bearer test_token") + .match_header("content-type", "application/json-patch+json") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()), + ])) + .with_status(200) + .with_body(r#"{"id":123}"#) + .create_async() + .await; + + let config = AzureDevOpsConfig { + organization_url: server.url(), + project: "TestProject".to_string(), + access_token: "test_token".to_string(), + }; + + let updates = serde_json::json!([ + { + "op": "add", + "path": "/fields/System.State", + "value": "Resolved" + } + ]); + + let result = update_work_item(&config, 123, updates).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let ticket = result.unwrap(); + assert_eq!(ticket.id, "123"); + assert_eq!(ticket.ticket_number, "#123"); + } } diff --git a/src-tauri/src/integrations/confluence.rs b/src-tauri/src/integrations/confluence.rs index 9df38feb..957590dc 100644 --- a/src-tauri/src/integrations/confluence.rs +++ b/src-tauri/src/integrations/confluence.rs @@ -5,8 +5,7 @@ use super::{ConnectionResult, PublishResult}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfluenceConfig { pub base_url: String, - pub username: String, - pub api_token: String, + pub access_token: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -23,42 +22,425 @@ pub struct Page { pub url: String, } -pub async fn test_connection(_config: &ConfluenceConfig) -> Result { - Err( - "Confluence integration available in v0.2. Please update to the latest version." - .to_string(), - ) +/// Test connection to Confluence by fetching current user info +pub async fn test_connection(config: &ConfluenceConfig) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/rest/api/user/current", config.base_url.trim_end_matches('/')); + + let resp = client + .get(&url) + .bearer_auth(&config.access_token) + .send() + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + if resp.status().is_success() { + Ok(ConnectionResult { + success: true, + message: "Successfully connected to Confluence".to_string(), + }) + } else { + Ok(ConnectionResult { + success: false, + message: format!("Connection failed with status: {}", resp.status()), + }) + } } -pub async fn list_spaces(_config: &ConfluenceConfig) -> Result, String> { - Err( - "Confluence integration available in v0.2. Please update to the latest version." - .to_string(), - ) +/// List all spaces accessible with the current token +pub async fn list_spaces(config: &ConfluenceConfig) -> Result, String> { + let client = reqwest::Client::new(); + let url = format!("{}/rest/api/space", config.base_url.trim_end_matches('/')); + + let resp = client + .get(&url) + .bearer_auth(&config.access_token) + .query(&[("limit", "100")]) + .send() + .await + .map_err(|e| format!("Failed to list spaces: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to list spaces: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let spaces = body["results"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|s| { + Some(Space { + key: s["key"].as_str()?.to_string(), + name: s["name"].as_str()?.to_string(), + }) + }) + .collect(); + + Ok(spaces) } +/// Search for pages by title or content +pub async fn search_pages( + config: &ConfluenceConfig, + query: &str, + space_key: Option<&str>, +) -> Result, String> { + let client = reqwest::Client::new(); + let url = format!( + "{}/rest/api/content/search", + config.base_url.trim_end_matches('/') + ); + + let mut cql = format!("text ~ \"{}\"", query); + if let Some(space) = space_key { + cql = format!("{} AND space = {}", cql, space); + } + + let resp = client + .get(&url) + .bearer_auth(&config.access_token) + .query(&[("cql", &cql), ("limit", &"50".to_string())]) + .send() + .await + .map_err(|e| format!("Search failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Search failed: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let pages = body["results"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|p| { + let base_url = config.base_url.trim_end_matches('/'); + let page_id = p["id"].as_str()?; + Some(Page { + id: page_id.to_string(), + title: p["title"].as_str()?.to_string(), + space_key: p["space"]["key"].as_str()?.to_string(), + url: format!("{}/pages/viewpage.action?pageId={}", base_url, page_id), + }) + }) + .collect(); + + Ok(pages) +} + +/// Publish a new page to Confluence pub async fn publish_page( - _config: &ConfluenceConfig, - _space_key: &str, - _title: &str, - _content_html: &str, - _parent_page_id: Option<&str>, + config: &ConfluenceConfig, + space_key: &str, + title: &str, + content_html: &str, + parent_page_id: Option<&str>, ) -> Result { - Err( - "Confluence integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!("{}/rest/api/content", config.base_url.trim_end_matches('/')); + + let mut body = serde_json::json!({ + "type": "page", + "title": title, + "space": { "key": space_key }, + "body": { + "storage": { + "value": content_html, + "representation": "storage" + } + } + }); + + if let Some(parent_id) = parent_page_id { + body["ancestors"] = serde_json::json!([{ "id": parent_id }]); + } + + let resp = client + .post(&url) + .bearer_auth(&config.access_token) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to publish page: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to publish page: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let page_id = result["id"].as_str().unwrap_or(""); + let page_url = format!( + "{}/pages/viewpage.action?pageId={}", + config.base_url.trim_end_matches('/'), + page_id + ); + + Ok(PublishResult { + id: page_id.to_string(), + url: page_url, + }) } +/// Update an existing page in Confluence pub async fn update_page( - _config: &ConfluenceConfig, - _page_id: &str, - _title: &str, - _content_html: &str, - _version: i32, + config: &ConfluenceConfig, + page_id: &str, + title: &str, + content_html: &str, + version: i32, ) -> Result { - Err( - "Confluence integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!( + "{}/rest/api/content/{}", + config.base_url.trim_end_matches('/'), + page_id + ); + + let body = serde_json::json!({ + "id": page_id, + "type": "page", + "title": title, + "version": { "number": version + 1 }, + "body": { + "storage": { + "value": content_html, + "representation": "storage" + } + } + }); + + let resp = client + .put(&url) + .bearer_auth(&config.access_token) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to update page: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to update page: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let updated_page_id = result["id"].as_str().unwrap_or(page_id); + let page_url = format!( + "{}/pages/viewpage.action?pageId={}", + config.base_url.trim_end_matches('/'), + updated_page_id + ); + + Ok(PublishResult { + id: updated_page_id.to_string(), + url: page_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_connection_success() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/rest/api/user/current") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_body(r#"{"username":"test_user"}"#) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "test_token".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(conn.success); + assert!(conn.message.contains("Successfully connected")); + } + + #[tokio::test] + async fn test_connection_failure() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/rest/api/user/current") + .with_status(401) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "invalid_token".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(!conn.success); + } + + #[tokio::test] + async fn test_list_spaces() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/rest/api/space") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("limit".into(), "100".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "results": [ + {"key": "DEV", "name": "Development"}, + {"key": "OPS", "name": "Operations"} + ] + }"#, + ) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "test_token".to_string(), + }; + + let result = list_spaces(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let spaces = result.unwrap(); + assert_eq!(spaces.len(), 2); + assert_eq!(spaces[0].key, "DEV"); + assert_eq!(spaces[1].name, "Operations"); + } + + #[tokio::test] + async fn test_search_pages() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/rest/api/content/search") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("cql".into(), "text ~ \"kubernetes\"".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "results": [ + { + "id": "123", + "title": "Kubernetes Guide", + "space": {"key": "DEV"} + } + ] + }"#, + ) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "test_token".to_string(), + }; + + let result = search_pages(&config, "kubernetes", None).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let pages = result.unwrap(); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0].title, "Kubernetes Guide"); + assert_eq!(pages[0].space_key, "DEV"); + } + + #[tokio::test] + async fn test_publish_page() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/rest/api/content") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_body(r#"{"id":"456","title":"New Page"}"#) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "test_token".to_string(), + }; + + let result = publish_page(&config, "DEV", "New Page", "

Content

", None).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let publish = result.unwrap(); + assert_eq!(publish.id, "456"); + assert!(publish.url.contains("pageId=456")); + } + + #[tokio::test] + async fn test_update_page() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("PUT", "/rest/api/content/789") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_body(r#"{"id":"789","title":"Updated Page"}"#) + .create_async() + .await; + + let config = ConfluenceConfig { + base_url: server.url(), + access_token: "test_token".to_string(), + }; + + let result = update_page(&config, "789", "Updated Page", "

New content

", 1).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let publish = result.unwrap(); + assert_eq!(publish.id, "789"); + } } diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs index ea44d4c7..91fce5a2 100644 --- a/src-tauri/src/integrations/mod.rs +++ b/src-tauri/src/integrations/mod.rs @@ -20,6 +20,7 @@ pub struct PublishResult { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TicketResult { + pub id: String, pub ticket_number: String, pub url: String, } diff --git a/src-tauri/src/integrations/servicenow.rs b/src-tauri/src/integrations/servicenow.rs index d1d6ba64..0577ff5f 100644 --- a/src-tauri/src/integrations/servicenow.rs +++ b/src-tauri/src/integrations/servicenow.rs @@ -11,6 +11,7 @@ pub struct ServiceNowConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Incident { + pub sys_id: String, pub number: String, pub short_description: String, pub description: String, @@ -19,43 +20,537 @@ pub struct Incident { pub state: String, } -pub async fn test_connection(_config: &ServiceNowConfig) -> Result { - Err( - "ServiceNow integration available in v0.2. Please update to the latest version." - .to_string(), - ) +/// Test connection to ServiceNow by querying a single incident +pub async fn test_connection(config: &ServiceNowConfig) -> Result { + let client = reqwest::Client::new(); + let url = format!( + "{}/api/now/table/incident", + config.instance_url.trim_end_matches('/') + ); + + let resp = client + .get(&url) + .basic_auth(&config.username, Some(&config.password)) + .query(&[("sysparm_limit", "1")]) + .send() + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + if resp.status().is_success() { + Ok(ConnectionResult { + success: true, + message: "Successfully connected to ServiceNow".to_string(), + }) + } else { + Ok(ConnectionResult { + success: false, + message: format!("Connection failed with status: {}", resp.status()), + }) + } } +/// Search for incidents by description or number +pub async fn search_incidents( + config: &ServiceNowConfig, + query: &str, +) -> Result, String> { + let client = reqwest::Client::new(); + let url = format!( + "{}/api/now/table/incident", + config.instance_url.trim_end_matches('/') + ); + + let sysparm_query = format!("short_descriptionLIKE{}", query); + + let resp = client + .get(&url) + .basic_auth(&config.username, Some(&config.password)) + .query(&[("sysparm_query", &sysparm_query), ("sysparm_limit", &"10".to_string())]) + .send() + .await + .map_err(|e| format!("Search failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Search failed: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let incidents = body["result"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|i| { + Some(Incident { + sys_id: i["sys_id"].as_str()?.to_string(), + number: i["number"].as_str()?.to_string(), + short_description: i["short_description"].as_str()?.to_string(), + description: i["description"].as_str().unwrap_or("").to_string(), + urgency: i["urgency"].as_str().unwrap_or("3").to_string(), + impact: i["impact"].as_str().unwrap_or("3").to_string(), + state: i["state"].as_str().unwrap_or("1").to_string(), + }) + }) + .collect(); + + Ok(incidents) +} + +/// Create a new incident in ServiceNow pub async fn create_incident( - _config: &ServiceNowConfig, - _short_description: &str, - _description: &str, - _urgency: &str, - _impact: &str, + config: &ServiceNowConfig, + short_description: &str, + description: &str, + urgency: &str, + impact: &str, ) -> Result { - Err( - "ServiceNow integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!( + "{}/api/now/table/incident", + config.instance_url.trim_end_matches('/') + ); + + let body = serde_json::json!({ + "short_description": short_description, + "description": description, + "urgency": urgency, + "impact": impact, + }); + + let resp = client + .post(&url) + .basic_auth(&config.username, Some(&config.password)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to create incident: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to create incident: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let incident_number = result["result"]["number"].as_str().unwrap_or(""); + let sys_id = result["result"]["sys_id"].as_str().unwrap_or(""); + let incident_url = format!( + "{}/nav_to.do?uri=incident.do?sys_id={}", + config.instance_url.trim_end_matches('/'), + sys_id + ); + + Ok(TicketResult { + id: sys_id.to_string(), + ticket_number: incident_number.to_string(), + url: incident_url, + }) } +/// Get an incident by sys_id or number pub async fn get_incident( - _config: &ServiceNowConfig, - _incident_number: &str, + config: &ServiceNowConfig, + incident_id: &str, ) -> Result { - Err( - "ServiceNow integration available in v0.2. Please update to the latest version." + let client = reqwest::Client::new(); + + // Determine if incident_id is a sys_id or incident number + let (url, use_query) = if incident_id.starts_with("INC") { + // It's an incident number, use query parameter + ( + format!( + "{}/api/now/table/incident", + config.instance_url.trim_end_matches('/') + ), + true, + ) + } else { + // It's a sys_id, use direct path + ( + format!( + "{}/api/now/table/incident/{}", + config.instance_url.trim_end_matches('/'), + incident_id + ), + false, + ) + }; + + let mut request = client + .get(&url) + .basic_auth(&config.username, Some(&config.password)); + + if use_query { + request = request.query(&[("sysparm_query", &format!("number={}", incident_id))]); + } + + let resp = request + .send() + .await + .map_err(|e| format!("Failed to get incident: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to get incident: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let incident_data = if use_query { + // Query response has "result" array + body["result"] + .as_array() + .and_then(|arr| arr.first()) + .ok_or_else(|| "Incident not found".to_string())? + } else { + // Direct sys_id response has "result" object + &body["result"] + }; + + Ok(Incident { + sys_id: incident_data["sys_id"] + .as_str() + .ok_or_else(|| "Missing sys_id".to_string())? .to_string(), - ) + number: incident_data["number"] + .as_str() + .ok_or_else(|| "Missing number".to_string())? + .to_string(), + short_description: incident_data["short_description"] + .as_str() + .ok_or_else(|| "Missing short_description".to_string())? + .to_string(), + description: incident_data["description"].as_str().unwrap_or("").to_string(), + urgency: incident_data["urgency"].as_str().unwrap_or("3").to_string(), + impact: incident_data["impact"].as_str().unwrap_or("3").to_string(), + state: incident_data["state"].as_str().unwrap_or("1").to_string(), + }) } +/// Update an existing incident pub async fn update_incident( - _config: &ServiceNowConfig, - _incident_number: &str, - _updates: serde_json::Value, + config: &ServiceNowConfig, + sys_id: &str, + updates: serde_json::Value, ) -> Result { - Err( - "ServiceNow integration available in v0.2. Please update to the latest version." - .to_string(), - ) + let client = reqwest::Client::new(); + let url = format!( + "{}/api/now/table/incident/{}", + config.instance_url.trim_end_matches('/'), + sys_id + ); + + let resp = client + .patch(&url) + .basic_auth(&config.username, Some(&config.password)) + .header("Content-Type", "application/json") + .json(&updates) + .send() + .await + .map_err(|e| format!("Failed to update incident: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Failed to update incident: {} - {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let incident_number = result["result"]["number"].as_str().unwrap_or(""); + let updated_sys_id = result["result"]["sys_id"].as_str().unwrap_or(sys_id); + let incident_url = format!( + "{}/nav_to.do?uri=incident.do?sys_id={}", + config.instance_url.trim_end_matches('/'), + updated_sys_id + ); + + Ok(TicketResult { + id: updated_sys_id.to_string(), + ticket_number: incident_number.to_string(), + url: incident_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_connection_success() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/now/table/incident") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()), + ])) + .with_status(200) + .with_body(r#"{"result":[]}"#) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(conn.success); + assert!(conn.message.contains("Successfully connected")); + } + + #[tokio::test] + async fn test_connection_failure() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/now/table/incident") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()), + ])) + .with_status(401) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "wrong_password".to_string(), + }; + + let result = test_connection(&config).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let conn = result.unwrap(); + assert!(!conn.success); + } + + #[tokio::test] + async fn test_search_incidents() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/now/table/incident") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("sysparm_query".into(), "short_descriptionLIKElogin".into()), + mockito::Matcher::UrlEncoded("sysparm_limit".into(), "10".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "result": [ + { + "sys_id": "abc123", + "number": "INC0010001", + "short_description": "Login issue", + "description": "Users cannot login", + "urgency": "2", + "impact": "2", + "state": "2" + } + ] + }"#, + ) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let result = search_incidents(&config, "login").await; + mock.assert_async().await; + + assert!(result.is_ok()); + let incidents = result.unwrap(); + assert_eq!(incidents.len(), 1); + assert_eq!(incidents[0].number, "INC0010001"); + assert_eq!(incidents[0].short_description, "Login issue"); + } + + #[tokio::test] + async fn test_create_incident() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/api/now/table/incident") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .match_header("content-type", "application/json") + .with_status(201) + .with_body( + r#"{ + "result": { + "sys_id": "def456", + "number": "INC0010002" + } + }"#, + ) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let result = create_incident(&config, "Test issue", "Description", "3", "3").await; + mock.assert_async().await; + + assert!(result.is_ok()); + let ticket = result.unwrap(); + assert_eq!(ticket.ticket_number, "INC0010002"); + assert_eq!(ticket.id, "def456"); + assert!(ticket.url.contains("def456")); + } + + #[tokio::test] + async fn test_get_incident_by_sys_id() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/now/table/incident/abc123") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .with_status(200) + .with_body( + r#"{ + "result": { + "sys_id": "abc123", + "number": "INC0010001", + "short_description": "Login issue", + "description": "Users cannot login", + "urgency": "2", + "impact": "2", + "state": "2" + } + }"#, + ) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let result = get_incident(&config, "abc123").await; + mock.assert_async().await; + + assert!(result.is_ok()); + let incident = result.unwrap(); + assert_eq!(incident.sys_id, "abc123"); + assert_eq!(incident.number, "INC0010001"); + } + + #[tokio::test] + async fn test_get_incident_by_number() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/now/table/incident") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("sysparm_query".into(), "number=INC0010001".into()), + ])) + .with_status(200) + .with_body( + r#"{ + "result": [{ + "sys_id": "abc123", + "number": "INC0010001", + "short_description": "Login issue", + "description": "Users cannot login", + "urgency": "2", + "impact": "2", + "state": "2" + }] + }"#, + ) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let result = get_incident(&config, "INC0010001").await; + mock.assert_async().await; + + assert!(result.is_ok()); + let incident = result.unwrap(); + assert_eq!(incident.sys_id, "abc123"); + assert_eq!(incident.number, "INC0010001"); + } + + #[tokio::test] + async fn test_update_incident() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("PATCH", "/api/now/table/incident/abc123") + .match_header("authorization", mockito::Matcher::Regex("Basic .+".into())) + .match_header("content-type", "application/json") + .with_status(200) + .with_body( + r#"{ + "result": { + "sys_id": "abc123", + "number": "INC0010001" + } + }"#, + ) + .create_async() + .await; + + let config = ServiceNowConfig { + instance_url: server.url(), + username: "admin".to_string(), + password: "password".to_string(), + }; + + let updates = serde_json::json!({ + "state": "6", + "close_notes": "Issue resolved" + }); + + let result = update_incident(&config, "abc123", updates).await; + mock.assert_async().await; + + assert!(result.is_ok()); + let ticket = result.unwrap(); + assert_eq!(ticket.id, "abc123"); + assert_eq!(ticket.ticket_number, "INC0010001"); + } }