Compare commits
3 Commits
1e8ef41e64
...
6759c38e2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6759c38e2a | ||
|
|
9d8bdd383c | ||
|
|
4172616c8b |
489
GenAI API User Guide.md
Normal file
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
312
HANDOFF-MSI-GENAI.md
Normal 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.
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use super::{ConnectionResult, TicketResult};
|
||||
pub struct AzureDevOpsConfig {
|
||||
pub organization_url: String,
|
||||
pub project: String,
|
||||
pub pat: String,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -15,46 +15,545 @@ pub struct WorkItem {
|
||||
pub title: String,
|
||||
pub work_item_type: String,
|
||||
pub state: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn test_connection(_config: &AzureDevOpsConfig) -> Result<ConnectionResult, String> {
|
||||
Err(
|
||||
"Azure DevOps integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
/// Test connection to Azure DevOps by querying project info
|
||||
pub async fn test_connection(config: &AzureDevOpsConfig) -> Result<ConnectionResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/_apis/projects/{}?api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(ConnectionResult {
|
||||
success: true,
|
||||
message: "Successfully connected to Azure DevOps".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(ConnectionResult {
|
||||
success: false,
|
||||
message: format!("Connection failed with status: {}", resp.status()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for work items using WIQL query
|
||||
pub async fn search_work_items(
|
||||
config: &AzureDevOpsConfig,
|
||||
query: &str,
|
||||
) -> Result<Vec<WorkItem>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let wiql_url = format!(
|
||||
"{}/{}/_apis/wit/wiql?api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project
|
||||
);
|
||||
|
||||
// Build WIQL query
|
||||
let wiql = format!(
|
||||
"SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State] FROM WorkItems WHERE [System.Title] CONTAINS '{}' ORDER BY [System.CreatedDate] DESC",
|
||||
query
|
||||
);
|
||||
|
||||
let body = serde_json::json!({ "query": wiql });
|
||||
|
||||
let resp = client
|
||||
.post(&wiql_url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WIQL query failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"WIQL query failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let wiql_result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse WIQL response: {}", e))?;
|
||||
|
||||
let work_item_refs = wiql_result["workItems"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|w| w["id"].as_i64())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if work_item_refs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Fetch full work item details
|
||||
let ids = work_item_refs
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let detail_url = format!(
|
||||
"{}/{}/_apis/wit/workitems?ids={}&api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project,
|
||||
ids
|
||||
);
|
||||
|
||||
let detail_resp = client
|
||||
.get(&detail_url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch work item details: {}", e))?;
|
||||
|
||||
if !detail_resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch work item details: {}",
|
||||
detail_resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let details: serde_json::Value = detail_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse work item details: {}", e))?;
|
||||
|
||||
let work_items = details["value"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
Some(WorkItem {
|
||||
id: w["id"].as_i64()?,
|
||||
title: w["fields"]["System.Title"].as_str()?.to_string(),
|
||||
work_item_type: w["fields"]["System.WorkItemType"].as_str()?.to_string(),
|
||||
state: w["fields"]["System.State"].as_str()?.to_string(),
|
||||
description: w["fields"]["System.Description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(work_items)
|
||||
}
|
||||
|
||||
/// Create a new work item in Azure DevOps
|
||||
pub async fn create_work_item(
|
||||
_config: &AzureDevOpsConfig,
|
||||
_title: &str,
|
||||
_description: &str,
|
||||
_work_item_type: &str,
|
||||
_severity: &str,
|
||||
config: &AzureDevOpsConfig,
|
||||
title: &str,
|
||||
description: &str,
|
||||
work_item_type: &str,
|
||||
severity: &str,
|
||||
) -> Result<TicketResult, String> {
|
||||
Err(
|
||||
"Azure DevOps integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/{}/_apis/wit/workitems/${}?api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project,
|
||||
work_item_type
|
||||
);
|
||||
|
||||
let mut operations = vec![
|
||||
serde_json::json!({
|
||||
"op": "add",
|
||||
"path": "/fields/System.Title",
|
||||
"value": title
|
||||
}),
|
||||
serde_json::json!({
|
||||
"op": "add",
|
||||
"path": "/fields/System.Description",
|
||||
"value": description
|
||||
}),
|
||||
];
|
||||
|
||||
// Add severity/priority if provided
|
||||
if work_item_type == "Bug" && !severity.is_empty() {
|
||||
operations.push(serde_json::json!({
|
||||
"op": "add",
|
||||
"path": "/fields/Microsoft.VSTS.Common.Severity",
|
||||
"value": severity
|
||||
}));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.header("Content-Type", "application/json-patch+json")
|
||||
.json(&operations)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create work item: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to create work item: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let work_item_id = result["id"].as_i64().unwrap_or(0);
|
||||
let work_item_url = format!(
|
||||
"{}/_workitems/edit/{}",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
work_item_id
|
||||
);
|
||||
|
||||
Ok(TicketResult {
|
||||
id: work_item_id.to_string(),
|
||||
ticket_number: format!("#{}", work_item_id),
|
||||
url: work_item_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a work item by ID
|
||||
pub async fn get_work_item(
|
||||
_config: &AzureDevOpsConfig,
|
||||
_work_item_id: i64,
|
||||
config: &AzureDevOpsConfig,
|
||||
work_item_id: i64,
|
||||
) -> Result<WorkItem, String> {
|
||||
Err(
|
||||
"Azure DevOps integration available in v0.2. Please update to the latest version."
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/{}/_apis/wit/workitems/{}?api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project,
|
||||
work_item_id
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get work item: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to get work item: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(WorkItem {
|
||||
id: result["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| "Missing id".to_string())?,
|
||||
title: result["fields"]["System.Title"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing title".to_string())?
|
||||
.to_string(),
|
||||
)
|
||||
work_item_type: result["fields"]["System.WorkItemType"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing work item type".to_string())?
|
||||
.to_string(),
|
||||
state: result["fields"]["System.State"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing state".to_string())?
|
||||
.to_string(),
|
||||
description: result["fields"]["System.Description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Update an existing work item
|
||||
pub async fn update_work_item(
|
||||
_config: &AzureDevOpsConfig,
|
||||
_work_item_id: i64,
|
||||
_updates: serde_json::Value,
|
||||
config: &AzureDevOpsConfig,
|
||||
work_item_id: i64,
|
||||
updates: serde_json::Value,
|
||||
) -> Result<TicketResult, String> {
|
||||
Err(
|
||||
"Azure DevOps integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/{}/_apis/wit/workitems/{}?api-version=7.0",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
config.project,
|
||||
work_item_id
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.patch(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.header("Content-Type", "application/json-patch+json")
|
||||
.json(&updates)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update work item: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to update work item: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let updated_work_item_id = result["id"].as_i64().unwrap_or(work_item_id);
|
||||
let work_item_url = format!(
|
||||
"{}/_workitems/edit/{}",
|
||||
config.organization_url.trim_end_matches('/'),
|
||||
updated_work_item_id
|
||||
);
|
||||
|
||||
Ok(TicketResult {
|
||||
id: updated_work_item_id.to_string(),
|
||||
ticket_number: format!("#{}", updated_work_item_id),
|
||||
url: work_item_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_apis/projects/TestProject")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"name":"TestProject","id":"abc123"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(conn.success);
|
||||
assert!(conn.message.contains("Successfully connected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_apis/projects/TestProject")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(401)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "invalid_token".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(!conn.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_work_items() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let wiql_mock = server
|
||||
.mock("POST", "/TestProject/_apis/wit/wiql")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"workItems":[{"id":123}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let detail_mock = server
|
||||
.mock("GET", "/TestProject/_apis/wit/workitems")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
mockito::Matcher::UrlEncoded("ids".into(), "123".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"value": [{
|
||||
"id": 123,
|
||||
"fields": {
|
||||
"System.Title": "Bug: Login fails",
|
||||
"System.WorkItemType": "Bug",
|
||||
"System.State": "Active",
|
||||
"System.Description": "Users cannot login"
|
||||
}
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = search_work_items(&config, "login").await;
|
||||
wiql_mock.assert_async().await;
|
||||
detail_mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let items = result.unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, 123);
|
||||
assert_eq!(items[0].title, "Bug: Login fails");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_work_item() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/TestProject/_apis/wit/workitems/$Bug")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_header("content-type", "application/json-patch+json")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"id":456}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = create_work_item(&config, "Test bug", "Description", "Bug", "3").await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ticket = result.unwrap();
|
||||
assert_eq!(ticket.id, "456");
|
||||
assert_eq!(ticket.ticket_number, "#456");
|
||||
assert!(ticket.url.contains("456"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_work_item() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/TestProject/_apis/wit/workitems/123")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"id": 123,
|
||||
"fields": {
|
||||
"System.Title": "Test item",
|
||||
"System.WorkItemType": "Task",
|
||||
"System.State": "Active",
|
||||
"System.Description": "Test description"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = get_work_item(&config, 123).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let item = result.unwrap();
|
||||
assert_eq!(item.id, 123);
|
||||
assert_eq!(item.title, "Test item");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_work_item() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("PATCH", "/TestProject/_apis/wit/workitems/123")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_header("content-type", "application/json-patch+json")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("api-version".into(), "7.0".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"id":123}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = AzureDevOpsConfig {
|
||||
organization_url: server.url(),
|
||||
project: "TestProject".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let updates = serde_json::json!([
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/fields/System.State",
|
||||
"value": "Resolved"
|
||||
}
|
||||
]);
|
||||
|
||||
let result = update_work_item(&config, 123, updates).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ticket = result.unwrap();
|
||||
assert_eq!(ticket.id, "123");
|
||||
assert_eq!(ticket.ticket_number, "#123");
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,7 @@ use super::{ConnectionResult, PublishResult};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfluenceConfig {
|
||||
pub base_url: String,
|
||||
pub username: String,
|
||||
pub api_token: String,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -23,42 +22,425 @@ pub struct Page {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub async fn test_connection(_config: &ConfluenceConfig) -> Result<ConnectionResult, String> {
|
||||
Err(
|
||||
"Confluence integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
/// Test connection to Confluence by fetching current user info
|
||||
pub async fn test_connection(config: &ConfluenceConfig) -> Result<ConnectionResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/rest/api/user/current", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(ConnectionResult {
|
||||
success: true,
|
||||
message: "Successfully connected to Confluence".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(ConnectionResult {
|
||||
success: false,
|
||||
message: format!("Connection failed with status: {}", resp.status()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_spaces(_config: &ConfluenceConfig) -> Result<Vec<Space>, String> {
|
||||
Err(
|
||||
"Confluence integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
/// List all spaces accessible with the current token
|
||||
pub async fn list_spaces(config: &ConfluenceConfig) -> Result<Vec<Space>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/rest/api/space", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.query(&[("limit", "100")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list spaces: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to list spaces: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let spaces = body["results"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
Some(Space {
|
||||
key: s["key"].as_str()?.to_string(),
|
||||
name: s["name"].as_str()?.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(spaces)
|
||||
}
|
||||
|
||||
/// Search for pages by title or content
|
||||
pub async fn search_pages(
|
||||
config: &ConfluenceConfig,
|
||||
query: &str,
|
||||
space_key: Option<&str>,
|
||||
) -> Result<Vec<Page>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/rest/api/content/search",
|
||||
config.base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let mut cql = format!("text ~ \"{}\"", query);
|
||||
if let Some(space) = space_key {
|
||||
cql = format!("{} AND space = {}", cql, space);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.query(&[("cql", &cql), ("limit", &"50".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Search failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let pages = body["results"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let base_url = config.base_url.trim_end_matches('/');
|
||||
let page_id = p["id"].as_str()?;
|
||||
Some(Page {
|
||||
id: page_id.to_string(),
|
||||
title: p["title"].as_str()?.to_string(),
|
||||
space_key: p["space"]["key"].as_str()?.to_string(),
|
||||
url: format!("{}/pages/viewpage.action?pageId={}", base_url, page_id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
/// Publish a new page to Confluence
|
||||
pub async fn publish_page(
|
||||
_config: &ConfluenceConfig,
|
||||
_space_key: &str,
|
||||
_title: &str,
|
||||
_content_html: &str,
|
||||
_parent_page_id: Option<&str>,
|
||||
config: &ConfluenceConfig,
|
||||
space_key: &str,
|
||||
title: &str,
|
||||
content_html: &str,
|
||||
parent_page_id: Option<&str>,
|
||||
) -> Result<PublishResult, String> {
|
||||
Err(
|
||||
"Confluence integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/rest/api/content", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"type": "page",
|
||||
"title": title,
|
||||
"space": { "key": space_key },
|
||||
"body": {
|
||||
"storage": {
|
||||
"value": content_html,
|
||||
"representation": "storage"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(parent_id) = parent_page_id {
|
||||
body["ancestors"] = serde_json::json!([{ "id": parent_id }]);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to publish page: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to publish page: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let page_id = result["id"].as_str().unwrap_or("");
|
||||
let page_url = format!(
|
||||
"{}/pages/viewpage.action?pageId={}",
|
||||
config.base_url.trim_end_matches('/'),
|
||||
page_id
|
||||
);
|
||||
|
||||
Ok(PublishResult {
|
||||
id: page_id.to_string(),
|
||||
url: page_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update an existing page in Confluence
|
||||
pub async fn update_page(
|
||||
_config: &ConfluenceConfig,
|
||||
_page_id: &str,
|
||||
_title: &str,
|
||||
_content_html: &str,
|
||||
_version: i32,
|
||||
config: &ConfluenceConfig,
|
||||
page_id: &str,
|
||||
title: &str,
|
||||
content_html: &str,
|
||||
version: i32,
|
||||
) -> Result<PublishResult, String> {
|
||||
Err(
|
||||
"Confluence integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/rest/api/content/{}",
|
||||
config.base_url.trim_end_matches('/'),
|
||||
page_id
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"id": page_id,
|
||||
"type": "page",
|
||||
"title": title,
|
||||
"version": { "number": version + 1 },
|
||||
"body": {
|
||||
"storage": {
|
||||
"value": content_html,
|
||||
"representation": "storage"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.bearer_auth(&config.access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update page: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to update page: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let updated_page_id = result["id"].as_str().unwrap_or(page_id);
|
||||
let page_url = format!(
|
||||
"{}/pages/viewpage.action?pageId={}",
|
||||
config.base_url.trim_end_matches('/'),
|
||||
updated_page_id
|
||||
);
|
||||
|
||||
Ok(PublishResult {
|
||||
id: updated_page_id.to_string(),
|
||||
url: page_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/rest/api/user/current")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"username":"test_user"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(conn.success);
|
||||
assert!(conn.message.contains("Successfully connected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/rest/api/user/current")
|
||||
.with_status(401)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "invalid_token".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(!conn.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_spaces() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/rest/api/space")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"results": [
|
||||
{"key": "DEV", "name": "Development"},
|
||||
{"key": "OPS", "name": "Operations"}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = list_spaces(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let spaces = result.unwrap();
|
||||
assert_eq!(spaces.len(), 2);
|
||||
assert_eq!(spaces[0].key, "DEV");
|
||||
assert_eq!(spaces[1].name, "Operations");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_pages() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/rest/api/content/search")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("cql".into(), "text ~ \"kubernetes\"".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"results": [
|
||||
{
|
||||
"id": "123",
|
||||
"title": "Kubernetes Guide",
|
||||
"space": {"key": "DEV"}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = search_pages(&config, "kubernetes", None).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let pages = result.unwrap();
|
||||
assert_eq!(pages.len(), 1);
|
||||
assert_eq!(pages[0].title, "Kubernetes Guide");
|
||||
assert_eq!(pages[0].space_key, "DEV");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_publish_page() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/rest/api/content")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"id":"456","title":"New Page"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = publish_page(&config, "DEV", "New Page", "<p>Content</p>", None).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let publish = result.unwrap();
|
||||
assert_eq!(publish.id, "456");
|
||||
assert!(publish.url.contains("pageId=456"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_page() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("PUT", "/rest/api/content/789")
|
||||
.match_header("authorization", "Bearer test_token")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"id":"789","title":"Updated Page"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ConfluenceConfig {
|
||||
base_url: server.url(),
|
||||
access_token: "test_token".to_string(),
|
||||
};
|
||||
|
||||
let result = update_page(&config, "789", "Updated Page", "<p>New content</p>", 1).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let publish = result.unwrap();
|
||||
assert_eq!(publish.id, "789");
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ pub struct PublishResult {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TicketResult {
|
||||
pub id: String,
|
||||
pub ticket_number: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ pub struct ServiceNowConfig {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Incident {
|
||||
pub sys_id: String,
|
||||
pub number: String,
|
||||
pub short_description: String,
|
||||
pub description: String,
|
||||
@ -19,43 +20,537 @@ pub struct Incident {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn test_connection(_config: &ServiceNowConfig) -> Result<ConnectionResult, String> {
|
||||
Err(
|
||||
"ServiceNow integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
/// Test connection to ServiceNow by querying a single incident
|
||||
pub async fn test_connection(config: &ServiceNowConfig) -> Result<ConnectionResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/now/table/incident",
|
||||
config.instance_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.basic_auth(&config.username, Some(&config.password))
|
||||
.query(&[("sysparm_limit", "1")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(ConnectionResult {
|
||||
success: true,
|
||||
message: "Successfully connected to ServiceNow".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(ConnectionResult {
|
||||
success: false,
|
||||
message: format!("Connection failed with status: {}", resp.status()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for incidents by description or number
|
||||
pub async fn search_incidents(
|
||||
config: &ServiceNowConfig,
|
||||
query: &str,
|
||||
) -> Result<Vec<Incident>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/now/table/incident",
|
||||
config.instance_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let sysparm_query = format!("short_descriptionLIKE{}", query);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.basic_auth(&config.username, Some(&config.password))
|
||||
.query(&[("sysparm_query", &sysparm_query), ("sysparm_limit", &"10".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Search failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let incidents = body["result"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
Some(Incident {
|
||||
sys_id: i["sys_id"].as_str()?.to_string(),
|
||||
number: i["number"].as_str()?.to_string(),
|
||||
short_description: i["short_description"].as_str()?.to_string(),
|
||||
description: i["description"].as_str().unwrap_or("").to_string(),
|
||||
urgency: i["urgency"].as_str().unwrap_or("3").to_string(),
|
||||
impact: i["impact"].as_str().unwrap_or("3").to_string(),
|
||||
state: i["state"].as_str().unwrap_or("1").to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(incidents)
|
||||
}
|
||||
|
||||
/// Create a new incident in ServiceNow
|
||||
pub async fn create_incident(
|
||||
_config: &ServiceNowConfig,
|
||||
_short_description: &str,
|
||||
_description: &str,
|
||||
_urgency: &str,
|
||||
_impact: &str,
|
||||
config: &ServiceNowConfig,
|
||||
short_description: &str,
|
||||
description: &str,
|
||||
urgency: &str,
|
||||
impact: &str,
|
||||
) -> Result<TicketResult, String> {
|
||||
Err(
|
||||
"ServiceNow integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/now/table/incident",
|
||||
config.instance_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"short_description": short_description,
|
||||
"description": description,
|
||||
"urgency": urgency,
|
||||
"impact": impact,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.basic_auth(&config.username, Some(&config.password))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create incident: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to create incident: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let incident_number = result["result"]["number"].as_str().unwrap_or("");
|
||||
let sys_id = result["result"]["sys_id"].as_str().unwrap_or("");
|
||||
let incident_url = format!(
|
||||
"{}/nav_to.do?uri=incident.do?sys_id={}",
|
||||
config.instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
|
||||
Ok(TicketResult {
|
||||
id: sys_id.to_string(),
|
||||
ticket_number: incident_number.to_string(),
|
||||
url: incident_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get an incident by sys_id or number
|
||||
pub async fn get_incident(
|
||||
_config: &ServiceNowConfig,
|
||||
_incident_number: &str,
|
||||
config: &ServiceNowConfig,
|
||||
incident_id: &str,
|
||||
) -> Result<Incident, String> {
|
||||
Err(
|
||||
"ServiceNow integration available in v0.2. Please update to the latest version."
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Determine if incident_id is a sys_id or incident number
|
||||
let (url, use_query) = if incident_id.starts_with("INC") {
|
||||
// It's an incident number, use query parameter
|
||||
(
|
||||
format!(
|
||||
"{}/api/now/table/incident",
|
||||
config.instance_url.trim_end_matches('/')
|
||||
),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
// It's a sys_id, use direct path
|
||||
(
|
||||
format!(
|
||||
"{}/api/now/table/incident/{}",
|
||||
config.instance_url.trim_end_matches('/'),
|
||||
incident_id
|
||||
),
|
||||
false,
|
||||
)
|
||||
};
|
||||
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.basic_auth(&config.username, Some(&config.password));
|
||||
|
||||
if use_query {
|
||||
request = request.query(&[("sysparm_query", &format!("number={}", incident_id))]);
|
||||
}
|
||||
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get incident: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to get incident: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let incident_data = if use_query {
|
||||
// Query response has "result" array
|
||||
body["result"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.ok_or_else(|| "Incident not found".to_string())?
|
||||
} else {
|
||||
// Direct sys_id response has "result" object
|
||||
&body["result"]
|
||||
};
|
||||
|
||||
Ok(Incident {
|
||||
sys_id: incident_data["sys_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing sys_id".to_string())?
|
||||
.to_string(),
|
||||
)
|
||||
number: incident_data["number"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing number".to_string())?
|
||||
.to_string(),
|
||||
short_description: incident_data["short_description"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "Missing short_description".to_string())?
|
||||
.to_string(),
|
||||
description: incident_data["description"].as_str().unwrap_or("").to_string(),
|
||||
urgency: incident_data["urgency"].as_str().unwrap_or("3").to_string(),
|
||||
impact: incident_data["impact"].as_str().unwrap_or("3").to_string(),
|
||||
state: incident_data["state"].as_str().unwrap_or("1").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Update an existing incident
|
||||
pub async fn update_incident(
|
||||
_config: &ServiceNowConfig,
|
||||
_incident_number: &str,
|
||||
_updates: serde_json::Value,
|
||||
config: &ServiceNowConfig,
|
||||
sys_id: &str,
|
||||
updates: serde_json::Value,
|
||||
) -> Result<TicketResult, String> {
|
||||
Err(
|
||||
"ServiceNow integration available in v0.2. Please update to the latest version."
|
||||
.to_string(),
|
||||
)
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/now/table/incident/{}",
|
||||
config.instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.patch(&url)
|
||||
.basic_auth(&config.username, Some(&config.password))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&updates)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update incident: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to update incident: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let incident_number = result["result"]["number"].as_str().unwrap_or("");
|
||||
let updated_sys_id = result["result"]["sys_id"].as_str().unwrap_or(sys_id);
|
||||
let incident_url = format!(
|
||||
"{}/nav_to.do?uri=incident.do?sys_id={}",
|
||||
config.instance_url.trim_end_matches('/'),
|
||||
updated_sys_id
|
||||
);
|
||||
|
||||
Ok(TicketResult {
|
||||
id: updated_sys_id.to_string(),
|
||||
ticket_number: incident_number.to_string(),
|
||||
url: incident_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/now/table/incident")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"result":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(conn.success);
|
||||
assert!(conn.message.contains("Successfully connected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/now/table/incident")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("sysparm_limit".into(), "1".into()),
|
||||
]))
|
||||
.with_status(401)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "wrong_password".to_string(),
|
||||
};
|
||||
|
||||
let result = test_connection(&config).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let conn = result.unwrap();
|
||||
assert!(!conn.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_incidents() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/now/table/incident")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("sysparm_query".into(), "short_descriptionLIKElogin".into()),
|
||||
mockito::Matcher::UrlEncoded("sysparm_limit".into(), "10".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"result": [
|
||||
{
|
||||
"sys_id": "abc123",
|
||||
"number": "INC0010001",
|
||||
"short_description": "Login issue",
|
||||
"description": "Users cannot login",
|
||||
"urgency": "2",
|
||||
"impact": "2",
|
||||
"state": "2"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = search_incidents(&config, "login").await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let incidents = result.unwrap();
|
||||
assert_eq!(incidents.len(), 1);
|
||||
assert_eq!(incidents[0].number, "INC0010001");
|
||||
assert_eq!(incidents[0].short_description, "Login issue");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_incident() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/api/now/table/incident")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.match_header("content-type", "application/json")
|
||||
.with_status(201)
|
||||
.with_body(
|
||||
r#"{
|
||||
"result": {
|
||||
"sys_id": "def456",
|
||||
"number": "INC0010002"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = create_incident(&config, "Test issue", "Description", "3", "3").await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ticket = result.unwrap();
|
||||
assert_eq!(ticket.ticket_number, "INC0010002");
|
||||
assert_eq!(ticket.id, "def456");
|
||||
assert!(ticket.url.contains("def456"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_incident_by_sys_id() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/now/table/incident/abc123")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"result": {
|
||||
"sys_id": "abc123",
|
||||
"number": "INC0010001",
|
||||
"short_description": "Login issue",
|
||||
"description": "Users cannot login",
|
||||
"urgency": "2",
|
||||
"impact": "2",
|
||||
"state": "2"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = get_incident(&config, "abc123").await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let incident = result.unwrap();
|
||||
assert_eq!(incident.sys_id, "abc123");
|
||||
assert_eq!(incident.number, "INC0010001");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_incident_by_number() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/now/table/incident")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("sysparm_query".into(), "number=INC0010001".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"result": [{
|
||||
"sys_id": "abc123",
|
||||
"number": "INC0010001",
|
||||
"short_description": "Login issue",
|
||||
"description": "Users cannot login",
|
||||
"urgency": "2",
|
||||
"impact": "2",
|
||||
"state": "2"
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = get_incident(&config, "INC0010001").await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let incident = result.unwrap();
|
||||
assert_eq!(incident.sys_id, "abc123");
|
||||
assert_eq!(incident.number, "INC0010001");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_incident() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("PATCH", "/api/now/table/incident/abc123")
|
||||
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
|
||||
.match_header("content-type", "application/json")
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{
|
||||
"result": {
|
||||
"sys_id": "abc123",
|
||||
"number": "INC0010001"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let config = ServiceNowConfig {
|
||||
instance_url: server.url(),
|
||||
username: "admin".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let updates = serde_json::json!({
|
||||
"state": "6",
|
||||
"close_notes": "Issue resolved"
|
||||
});
|
||||
|
||||
let result = update_incident(&config, "abc123", updates).await;
|
||||
mock.assert_async().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let ticket = result.unwrap();
|
||||
assert_eq!(ticket.id, "abc123");
|
||||
assert_eq!(ticket.ticket_number, "INC0010001");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user