feat: implement Confluence, ServiceNow, and Azure DevOps REST API clients
- Confluence: OAuth2 bearer auth, list_spaces, search_pages, publish_page, update_page - ServiceNow: Basic auth, search_incidents, create_incident, get_incident, update_incident - Azure DevOps: OAuth2 bearer auth, search_work_items, create_work_item, get_work_item, update_work_item - Added TicketResult.id field to support both sys_id and ticket_number - All implementations follow TDD with mockito HTTP mocking - 19 tests passing across all three integrations
This commit is contained in:
parent
1e8ef41e64
commit
4172616c8b
@ -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<ConnectionResult, String> {
|
||||
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<ConnectionResult, String> {
|
||||
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<Vec<WorkItem>, 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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<TicketResult, String> {
|
||||
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<WorkItem, String> {
|
||||
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<TicketResult, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ConnectionResult, String> {
|
||||
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<ConnectionResult, String> {
|
||||
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<Vec<Space>, 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<Vec<Space>, 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<Vec<Page>, 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<PublishResult, String> {
|
||||
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<PublishResult, String> {
|
||||
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", "<p>Content</p>", 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", "<p>New content</p>", 1).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let publish = result.unwrap();
|
||||
assert_eq!(publish.id, "789");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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<ConnectionResult, String> {
|
||||
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<ConnectionResult, String> {
|
||||
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<Vec<Incident>, 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<TicketResult, String> {
|
||||
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<Incident, String> {
|
||||
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<TicketResult, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user