Compare commits

...

3 Commits

Author SHA1 Message Date
Shaun Arman
6759c38e2a docs: add MSI GenAI API reference and handoff documentation
Some checks failed
Auto Tag / auto-tag (push) Successful in 9s
Test / rust-fmt-check (push) Failing after 2m14s
Release / build-macos-arm64 (push) Successful in 9m48s
Test / rust-clippy (push) Failing after 18m4s
Release / build-linux-arm64 (push) Failing after 22m29s
Test / rust-tests (push) Successful in 12m57s
Test / frontend-typecheck (push) Successful in 1m35s
Test / frontend-tests (push) Successful in 1m29s
Release / build-windows-amd64 (push) Has been cancelled
Release / build-linux-amd64 (push) Has been cancelled
- Added GenAI API User Guide.md with complete API specification
- Added HANDOFF-MSI-GENAI.md documenting custom provider implementation
- Includes API endpoints, request/response formats, available models, and rate limits
2026-04-03 15:45:52 -05:00
Shaun Arman
9d8bdd383c feat: add MSI GenAI custom provider support
- Extended ProviderConfig with optional custom fields for non-OpenAI APIs
- Added custom_endpoint_path, custom_auth_header, custom_auth_prefix fields
- Added api_format field to distinguish between OpenAI and MSI GenAI formats
- Added session_id field for stateful conversation APIs
- Implemented chat_msi_genai() method in OpenAI provider
- MSI GenAI uses different request format (prompt+sessionId) and response (msg field)
- Updated TypeScript types to match Rust schema
- Added UI controls in Settings/AIProviders for custom provider configuration
- API format selector auto-populates appropriate defaults (OpenAI vs MSI GenAI)
- Backward compatible: existing providers default to OpenAI format
2026-04-03 15:45:42 -05:00
Shaun Arman
4172616c8b 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
2026-04-03 15:43:37 -05:00
10 changed files with 2490 additions and 86 deletions

489
GenAI API User Guide.md Normal file

File diff suppressed because one or more lines are too long

312
HANDOFF-MSI-GENAI.md Normal file
View File

@ -0,0 +1,312 @@
# 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,9 +28,33 @@ 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();
let url = format!("{}/chat/completions", config.api_url.trim_end_matches('/'));
// 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 body = serde_json::json!({
"model": config.model,
@ -38,9 +62,14 @@ impl Provider for 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("Authorization", format!("Bearer {}", config.api_key))
.header(auth_header, auth_value)
.header("Content-Type", "application/json")
.json(&body)
.send()
@ -72,4 +101,89 @@ impl Provider for 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 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");
}
}

View File

@ -10,6 +10,25 @@ 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,6 +10,11 @@ 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,6 +27,11 @@ 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() {
@ -267,6 +272,89 @@ 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