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:
Shaun Arman 2026-04-03 15:43:37 -05:00
parent 1e8ef41e64
commit 4172616c8b
4 changed files with 1461 additions and 84 deletions

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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,
}

View File

@ -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");
}
}