Compare commits

..

No commits in common. "6759c38e2ab432b8a3cfa1efa80053781df96b99" and "1e8ef41e648be3259a6e97974c1264564da5108b" have entirely different histories.

10 changed files with 86 additions and 2490 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,312 +0,0 @@
# MSI GenAI Custom Provider Integration - Handoff Document
**Date**: 2026-04-03
**Status**: In Progress - Backend schema updated, frontend and provider logic pending
---
## Context
User needs to integrate MSI GenAI API (https://genai-service.stage.commandcentral.com/app-gateway/api/v2/chat) into the application's AI Providers system.
**Problem**: The existing "Custom" provider type assumes OpenAI-compatible APIs (expects `/chat/completions` endpoint, OpenAI request/response format, `Authorization: Bearer` header). MSI GenAI has a completely different API contract:
| Aspect | OpenAI Format | MSI GenAI Format |
|--------|---------------|------------------|
| **Endpoint** | `/chat/completions` | `/api/v2/chat` (no suffix) |
| **Request** | `{"messages": [...], "model": "..."}` | `{"prompt": "...", "model": "...", "sessionId": "..."}` |
| **Response** | `{"choices": [{"message": {"content": "..."}}]}` | `{"msg": "...", "sessionId": "..."}` |
| **Auth Header** | `Authorization: Bearer <token>` | `x-msi-genai-api-key: <token>` |
| **History** | Client sends full message array | Server-side via `sessionId` |
---
## Work Completed
### 1. Updated `src-tauri/src/state.rs` - ProviderConfig Schema
Added optional fields to support custom API formats without breaking existing OpenAI-compatible providers:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
#[serde(default)]
pub provider_type: String,
pub api_url: String,
pub api_key: String,
pub model: String,
// NEW FIELDS:
/// Optional: Custom endpoint path (e.g., "" for no path, "/v1/chat" for custom path)
/// If None, defaults to "/chat/completions" for OpenAI compatibility
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_endpoint_path: Option<String>,
/// Optional: Custom auth header name (e.g., "x-msi-genai-api-key")
/// If None, defaults to "Authorization"
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_auth_header: Option<String>,
/// Optional: Custom auth value prefix (e.g., "" for no prefix, "Bearer " for OpenAI)
/// If None, defaults to "Bearer "
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_auth_prefix: Option<String>,
/// Optional: API format ("openai" or "msi_genai")
/// If None, defaults to "openai"
#[serde(skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Optional: Session ID for stateful APIs like MSI GenAI
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
```
**Design philosophy**: Existing providers remain unchanged (all fields default to OpenAI-compatible behavior). Only when `api_format` is set to `"msi_genai"` do the custom fields take effect.
---
## Work Remaining
### 2. Update `src-tauri/src/ai/openai.rs` - Support Custom Formats
The `OpenAiProvider::chat()` method needs to conditionally handle MSI GenAI format:
**Changes needed**:
- Check `config.api_format` — if `Some("msi_genai")`, use MSI GenAI request/response logic
- Use `config.custom_endpoint_path.unwrap_or("/chat/completions")` for endpoint
- Use `config.custom_auth_header.unwrap_or("Authorization")` for header name
- Use `config.custom_auth_prefix.unwrap_or("Bearer ")` for auth prefix
**MSI GenAI request format**:
```json
{
"model": "VertexGemini",
"prompt": "<last user message>",
"system": "<optional system message>",
"sessionId": "<uuid or null for first message>",
"userId": "user@motorolasolutions.com"
}
```
**MSI GenAI response format**:
```json
{
"status": true,
"sessionId": "uuid",
"msg": "AI response text",
"initialPrompt": true/false
}
```
**Implementation notes**:
- For MSI GenAI, convert `Vec<Message>` to a single `prompt` (concatenate or use last user message)
- Extract system message from messages array if present (role == "system")
- Store returned `sessionId` back to `config.session_id` for subsequent requests
- Extract response content from `json["msg"]` instead of `json["choices"][0]["message"]["content"]`
### 3. Update `src/lib/tauriCommands.ts` - TypeScript Types
Add new optional fields to `ProviderConfig` interface:
```typescript
export interface ProviderConfig {
provider_type?: string;
max_tokens?: number;
temperature?: number;
name: string;
api_url: string;
api_key: string;
model: string;
// NEW FIELDS:
custom_endpoint_path?: string;
custom_auth_header?: string;
custom_auth_prefix?: string;
api_format?: string;
session_id?: string;
}
```
### 4. Update `src/pages/Settings/AIProviders.tsx` - UI Fields
**When `provider_type === "custom"`, show additional form fields**:
```tsx
{form.provider_type === "custom" && (
<>
<div className="space-y-2">
<Label>API Format</Label>
<Select
value={form.api_format ?? "openai"}
onValueChange={(v) => {
const format = v;
const defaults = format === "msi_genai"
? {
custom_endpoint_path: "",
custom_auth_header: "x-msi-genai-api-key",
custom_auth_prefix: "",
}
: {
custom_endpoint_path: "/chat/completions",
custom_auth_header: "Authorization",
custom_auth_prefix: "Bearer ",
};
setForm({ ...form, api_format: format, ...defaults });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI Compatible</SelectItem>
<SelectItem value="msi_genai">MSI GenAI</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Endpoint Path</Label>
<Input
value={form.custom_endpoint_path ?? ""}
onChange={(e) => setForm({ ...form, custom_endpoint_path: e.target.value })}
placeholder="/chat/completions"
/>
</div>
<div className="space-y-2">
<Label>Auth Header Name</Label>
<Input
value={form.custom_auth_header ?? ""}
onChange={(e) => setForm({ ...form, custom_auth_header: e.target.value })}
placeholder="Authorization"
/>
</div>
</div>
<div className="space-y-2">
<Label>Auth Prefix</Label>
<Input
value={form.custom_auth_prefix ?? ""}
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
placeholder="Bearer "
/>
<p className="text-xs text-muted-foreground">
Prefix added before API key (e.g., "Bearer " for OpenAI, "" for MSI GenAI)
</p>
</div>
</>
)}
```
**Update `emptyProvider` initial state**:
```typescript
const emptyProvider: ProviderConfig = {
name: "",
provider_type: "openai",
api_url: "",
api_key: "",
model: "",
custom_endpoint_path: undefined,
custom_auth_header: undefined,
custom_auth_prefix: undefined,
api_format: undefined,
session_id: undefined,
};
```
---
## Testing Configuration
**For MSI GenAI**:
- **Type**: Custom
- **API Format**: MSI GenAI
- **API URL**: `https://genai-service.stage.commandcentral.com/app-gateway`
- **Model**: `VertexGemini` (or `Claude-Sonnet-4`, `ChatGPT4o`)
- **API Key**: (user's MSI GenAI API key from portal)
- **Endpoint Path**: `` (empty - URL already includes `/api/v2/chat`)
- **Auth Header**: `x-msi-genai-api-key`
- **Auth Prefix**: `` (empty - no "Bearer " prefix)
**Test command flow**:
1. Create provider with above settings
2. Test connection (should receive AI response)
3. Verify `sessionId` is returned and stored
4. Send second message (should reuse `sessionId` for conversation history)
---
## Known Issues from User's Original Error
User initially tried:
- **API URL**: `https://genai-service.stage.commandcentral.com/app-gateway/api/v2/chat`
- **Type**: Custom (no format specified)
**Result**: `Cannot POST /api/v2/chat/chat/completions` (404)
**Root cause**: OpenAI provider appends `/chat/completions` to base URL. With the new `custom_endpoint_path` field, this is now configurable.
---
## Integration with Existing Session Management
MSI GenAI uses server-side session management. Current triage flow sends full message history on every request (OpenAI style). For MSI GenAI:
- **First message**: Send `sessionId: null` or omit field
- **Store response**: Save `response.sessionId` to `config.session_id`
- **Subsequent messages**: Include `sessionId` in requests (server maintains history)
Consider storing `session_id` per conversation in the database (link to `ai_conversations.id`) rather than globally in `ProviderConfig`.
---
## Commit Strategy
**Current git state**:
- Modified by other session: `src-tauri/src/integrations/*.rs` (ADO/Confluence/ServiceNow work)
- Modified by me: `src-tauri/src/state.rs` (MSI GenAI schema)
- Untracked: `GenAI API User Guide.md`
**Recommended approach**:
1. **Other session commits first**: Commit integration changes to main
2. **Then complete MSI GenAI work**: Finish items 2-4 above, test, commit separately
**Alternative**: Create feature branch `feature/msi-genai-custom-provider`, cherry-pick only MSI GenAI changes, complete work there, merge when ready.
---
## Reference: MSI GenAI API Spec
**Documentation**: `GenAI API User Guide.md` (in project root)
**Key endpoints**:
- `POST /api/v2/chat` - Send prompt, get response
- `POST /api/v2/upload/<SESSION-ID>` - Upload files (requires session)
- `GET /api/v2/getSessionMessages/<SESSION-ID>` - Retrieve history
- `DELETE /api/v2/entry/<MSG-ID>` - Delete message
**Available models** (from guide):
- `Claude-Sonnet-4` (Public)
- `ChatGPT4o` (Public)
- `VertexGemini` (Private) - Gemini 2.0 Flash
- `ChatGPT-5_2-Chat` (Public)
- Many others (see guide section 4.1)
**Rate limits**: $50/user/month (enforced server-side)
---
## Questions for User
1. Should `session_id` be stored globally in `ProviderConfig` or per-conversation in DB?
2. Do we need to support file uploads via `/api/v2/upload/<SESSION-ID>`?
3. Should we expose model config options (temperature, max_tokens) for MSI GenAI?
---
## Contact
This handoff doc was generated for the other Claude Code session working on integration files. Once that work is committed, this MSI GenAI work can be completed as a separate commit or feature branch.

View File

@ -28,33 +28,9 @@ impl Provider for OpenAiProvider {
&self,
messages: Vec<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
// Check if using MSI GenAI format
let api_format = config.api_format.as_deref().unwrap_or("openai");
if api_format == "msi_genai" {
self.chat_msi_genai(messages, config).await
} else {
self.chat_openai(messages, config).await
}
}
}
impl OpenAiProvider {
/// OpenAI-compatible API format (default)
async fn chat_openai(
&self,
messages: Vec<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
let client = reqwest::Client::new();
// Use custom endpoint path if provided, otherwise default to /chat/completions
let endpoint_path = config
.custom_endpoint_path
.as_deref()
.unwrap_or("/chat/completions");
let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path);
let url = format!("{}/chat/completions", config.api_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": config.model,
@ -62,14 +38,9 @@ impl OpenAiProvider {
"max_tokens": 4096,
});
// Use custom auth header and prefix if provided
let auth_header = config.custom_auth_header.as_deref().unwrap_or("Authorization");
let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or("Bearer ");
let auth_value = format!("{}{}", auth_prefix, config.api_key);
let resp = client
.post(&url)
.header(auth_header, auth_value)
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
@ -101,89 +72,4 @@ impl OpenAiProvider {
usage,
})
}
/// MSI GenAI custom format
async fn chat_msi_genai(
&self,
messages: Vec<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
let client = reqwest::Client::new();
// Use custom endpoint path, default to empty (API URL already includes /api/v2/chat)
let endpoint_path = config.custom_endpoint_path.as_deref().unwrap_or("");
let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path);
// Extract system message if present
let system_message = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.clone());
// Get last user message as prompt
let prompt = messages
.iter()
.rev()
.find(|m| m.role == "user")
.map(|m| m.content.clone())
.ok_or_else(|| anyhow::anyhow!("No user message found"))?;
// Build request body
let mut body = serde_json::json!({
"model": config.model,
"prompt": prompt,
"userId": "user@motorolasolutions.com", // Default user ID
});
// Add optional system message
if let Some(system) = system_message {
body["system"] = serde_json::Value::String(system);
}
// Add session ID if available (for conversation continuity)
if let Some(session_id) = &config.session_id {
body["sessionId"] = serde_json::Value::String(session_id.clone());
}
// Use custom auth header and prefix (no prefix for MSI GenAI)
let auth_header = config
.custom_auth_header
.as_deref()
.unwrap_or("x-msi-genai-api-key");
let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or("");
let auth_value = format!("{}{}", auth_prefix, config.api_key);
let resp = client
.post(&url)
.header(auth_header, auth_value)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await?;
anyhow::bail!("MSI GenAI API error {status}: {text}");
}
let json: serde_json::Value = resp.json().await?;
// Extract response content from "msg" field
let content = json["msg"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No 'msg' field in response"))?
.to_string();
// Note: sessionId from response should be stored back to config.session_id
// This would require making config mutable or returning it as part of ChatResponse
// For now, the caller can extract it from the response if needed
// TODO: Consider adding session_id to ChatResponse struct
Ok(ChatResponse {
content,
model: config.model.clone(),
usage: None, // MSI GenAI doesn't provide token usage in response
})
}
}

View File

@ -6,7 +6,7 @@ use super::{ConnectionResult, TicketResult};
pub struct AzureDevOpsConfig {
pub organization_url: String,
pub project: String,
pub access_token: String,
pub pat: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -15,545 +15,46 @@ pub struct WorkItem {
pub title: String,
pub work_item_type: String,
pub state: String,
pub description: String,
pub url: 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()),
})
}
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(),
)
}
/// 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> {
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,
})
Err(
"Azure DevOps integration available in v0.2. Please update to the latest version."
.to_string(),
)
}
/// 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> {
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())?
Err(
"Azure DevOps integration available in v0.2. Please update to the latest version."
.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> {
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");
}
Err(
"Azure DevOps integration available in v0.2. Please update to the latest version."
.to_string(),
)
}

View File

@ -5,7 +5,8 @@ use super::{ConnectionResult, PublishResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfluenceConfig {
pub base_url: String,
pub access_token: String,
pub username: String,
pub api_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -22,425 +23,42 @@ pub struct Page {
pub url: 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 test_connection(_config: &ConfluenceConfig) -> Result<ConnectionResult, 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)
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(),
)
}
/// 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> {
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,
})
Err(
"Confluence integration available in v0.2. Please update to the latest version."
.to_string(),
)
}
/// 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> {
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");
}
Err(
"Confluence integration available in v0.2. Please update to the latest version."
.to_string(),
)
}

View File

@ -20,7 +20,6 @@ pub struct PublishResult {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketResult {
pub id: String,
pub ticket_number: String,
pub url: String,
}

View File

@ -11,7 +11,6 @@ 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,
@ -20,537 +19,43 @@ pub struct Incident {
pub state: 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()),
})
}
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(),
)
}
/// 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> {
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,
})
Err(
"ServiceNow integration available in v0.2. Please update to the latest version."
.to_string(),
)
}
/// Get an incident by sys_id or number
pub async fn get_incident(
config: &ServiceNowConfig,
incident_id: &str,
_config: &ServiceNowConfig,
_incident_number: &str,
) -> Result<Incident, String> {
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())?
Err(
"ServiceNow integration available in v0.2. Please update to the latest version."
.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,
sys_id: &str,
updates: serde_json::Value,
_config: &ServiceNowConfig,
_incident_number: &str,
_updates: serde_json::Value,
) -> Result<TicketResult, 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");
}
Err(
"ServiceNow integration available in v0.2. Please update to the latest version."
.to_string(),
)
}

View File

@ -10,25 +10,6 @@ pub struct ProviderConfig {
pub api_url: String,
pub api_key: String,
pub model: String,
/// Optional: Custom endpoint path (e.g., "" for no path, "/v1/chat" for custom path)
/// If None, defaults to "/chat/completions" for OpenAI compatibility
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_endpoint_path: Option<String>,
/// Optional: Custom auth header name (e.g., "x-msi-genai-api-key")
/// If None, defaults to "Authorization"
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_auth_header: Option<String>,
/// Optional: Custom auth value prefix (e.g., "" for no prefix, "Bearer " for OpenAI)
/// If None, defaults to "Bearer "
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_auth_prefix: Option<String>,
/// Optional: API format ("openai" or "msi_genai")
/// If None, defaults to "openai"
#[serde(skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Optional: Session ID for stateful APIs like MSI GenAI
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -10,11 +10,6 @@ export interface ProviderConfig {
api_url: string;
api_key: string;
model: string;
custom_endpoint_path?: string;
custom_auth_header?: string;
custom_auth_prefix?: string;
api_format?: string;
session_id?: string;
}
export interface Message {

View File

@ -27,11 +27,6 @@ const emptyProvider: ProviderConfig = {
model: "",
max_tokens: 4096,
temperature: 0.7,
custom_endpoint_path: undefined,
custom_auth_header: undefined,
custom_auth_prefix: undefined,
api_format: undefined,
session_id: undefined,
};
export default function AIProviders() {
@ -272,89 +267,6 @@ export default function AIProviders() {
</div>
</div>
{/* Custom provider format options */}
{form.provider_type === "custom" && (
<>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label>API Format</Label>
<Select
value={form.api_format ?? "openai"}
onValueChange={(v) => {
const format = v;
const defaults =
format === "msi_genai"
? {
custom_endpoint_path: "",
custom_auth_header: "x-msi-genai-api-key",
custom_auth_prefix: "",
}
: {
custom_endpoint_path: "/chat/completions",
custom_auth_header: "Authorization",
custom_auth_prefix: "Bearer ",
};
setForm({ ...form, api_format: format, ...defaults });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI Compatible</SelectItem>
<SelectItem value="msi_genai">MSI GenAI</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select the API format. MSI GenAI uses a different request/response structure.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Endpoint Path</Label>
<Input
value={form.custom_endpoint_path ?? ""}
onChange={(e) =>
setForm({ ...form, custom_endpoint_path: e.target.value })
}
placeholder="/chat/completions"
/>
<p className="text-xs text-muted-foreground">
Path appended to API URL. Leave empty if URL includes full path.
</p>
</div>
<div className="space-y-2">
<Label>Auth Header Name</Label>
<Input
value={form.custom_auth_header ?? ""}
onChange={(e) =>
setForm({ ...form, custom_auth_header: e.target.value })
}
placeholder="Authorization"
/>
<p className="text-xs text-muted-foreground">
Header name for authentication (e.g., "Authorization" or "x-msi-genai-api-key")
</p>
</div>
</div>
<div className="space-y-2">
<Label>Auth Prefix</Label>
<Input
value={form.custom_auth_prefix ?? ""}
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
placeholder="Bearer "
/>
<p className="text-xs text-muted-foreground">
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for MSI GenAI)
</p>
</div>
</div>
</>
)}
{/* Test result */}
{testResult && (
<div