245 lines
7.5 KiB
Rust
245 lines
7.5 KiB
Rust
//! AI-powered issue triage service.
|
|
//!
|
|
//! Analyzes newly created issues and suggests labels and priority.
|
|
|
|
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use chrono::Utc;
|
|
use config::AppConfig;
|
|
use models::agents::ModelStatus;
|
|
use models::agents::model::{Column as MColumn, Entity as MEntity};
|
|
use models::issues::{issue, issue_comment};
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct IssueTriageSuggestion {
|
|
pub suggested_labels: Vec<String>,
|
|
pub priority: String,
|
|
pub reasoning: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct IssueTriageResponse {
|
|
pub suggestions: Option<IssueTriageSuggestion>,
|
|
pub comment_posted: bool,
|
|
}
|
|
|
|
fn build_triage_prompt(title: &str, body: Option<&str>, existing_labels: &[String]) -> String {
|
|
let body_text = body.unwrap_or("(no description)");
|
|
let labels_text = if existing_labels.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
existing_labels.join(", ")
|
|
};
|
|
|
|
format!(
|
|
r#"You are an expert software project manager. Analyze the following GitHub issue and suggest how to triage it.
|
|
|
|
Issue Title: {}
|
|
Issue Body:
|
|
{}
|
|
Existing Labels: {}
|
|
|
|
Based on the issue, suggest:
|
|
1. Additional labels from this standard set: bug, enhancement, documentation, question, help wanted, good first issue, priority:high, priority:medium, priority:low, kind:backend, kind:frontend, kind:dx, kind:security, kind:performance
|
|
2. A priority level: high, medium, or low
|
|
3. A brief reasoning for your assessment
|
|
|
|
Respond in JSON format like:
|
|
{{
|
|
"suggested_labels": ["bug", "priority:high"],
|
|
"priority": "high",
|
|
"reasoning": "This is a critical security vulnerability in the auth module..."
|
|
}}
|
|
|
|
Only suggest labels not already in the existing list. Be concise."#,
|
|
title, body_text, labels_text
|
|
)
|
|
}
|
|
|
|
fn parse_triage_response(content: &str) -> Option<IssueTriageSuggestion> {
|
|
let content = content.trim();
|
|
let json_str = if content.starts_with("```json") {
|
|
content
|
|
.strip_prefix("```json")?
|
|
.strip_prefix('\n')
|
|
.unwrap_or(content)
|
|
.trim_end_matches("```")
|
|
.trim()
|
|
} else if content.starts_with("```") {
|
|
content
|
|
.strip_prefix("```")?
|
|
.strip_prefix('\n')
|
|
.unwrap_or(content)
|
|
.trim_end_matches("```")
|
|
.trim()
|
|
} else {
|
|
content
|
|
};
|
|
|
|
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;
|
|
Some(IssueTriageSuggestion {
|
|
suggested_labels: parsed
|
|
.get("suggested_labels")?
|
|
.as_array()?
|
|
.iter()
|
|
.filter_map(|v| v.as_str().map(String::from))
|
|
.collect(),
|
|
priority: parsed.get("priority")?.as_str()?.to_string(),
|
|
reasoning: parsed.get("reasoning")?.as_str()?.to_string(),
|
|
})
|
|
}
|
|
|
|
async fn call_ai_for_triage(
|
|
model_name: &str,
|
|
prompt: &str,
|
|
app_config: &AppConfig,
|
|
) -> Result<agent::AiCallResponse, AppError> {
|
|
let api_key = app_config
|
|
.ai_api_key()
|
|
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
|
|
|
|
let base_url = app_config
|
|
.ai_basic_url()
|
|
.unwrap_or_else(|_| "https://api.openai.com".into());
|
|
|
|
let client_config = ::agent::AiClientConfig::new(api_key).with_base_url(base_url);
|
|
|
|
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
|
|
|
|
let response = ::agent::call_with_params(
|
|
&messages,
|
|
model_name,
|
|
&client_config,
|
|
0.3,
|
|
1024,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("AI triage call failed: {}", e)))?;
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
impl AppService {
|
|
/// Run AI triage on a newly created issue and post a suggestion comment.
|
|
/// Called asynchronously after issue creation.
|
|
pub async fn triage_issue(
|
|
&self,
|
|
project_name: String,
|
|
issue_number: i64,
|
|
) -> Result<IssueTriageResponse, AppError> {
|
|
let project = self
|
|
.utils_find_project_by_name(project_name.clone())
|
|
.await?;
|
|
|
|
let issue_model = issue::Entity::find()
|
|
.filter(issue::Column::Project.eq(project.id))
|
|
.filter(issue::Column::Number.eq(issue_number))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Issue not found".to_string()))?;
|
|
|
|
let existing_labels: Vec<String> = Vec::new();
|
|
|
|
let model = match MEntity::find()
|
|
.filter(MColumn::Status.eq(ModelStatus::Active.to_string()))
|
|
.order_by_asc(MColumn::Name)
|
|
.one(&self.db)
|
|
.await?
|
|
{
|
|
Some(m) => m,
|
|
None => {
|
|
tracing::debug!(
|
|
project = %project_name,
|
|
issue = issue_number,
|
|
"No active AI model for triage — skipping"
|
|
);
|
|
return Ok(IssueTriageResponse {
|
|
suggestions: None,
|
|
comment_posted: false,
|
|
});
|
|
}
|
|
};
|
|
|
|
let prompt = build_triage_prompt(
|
|
&issue_model.title,
|
|
issue_model.body.as_deref(),
|
|
&existing_labels,
|
|
);
|
|
let ai_response = match call_ai_for_triage(&model.name, &prompt, &self.config).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
project = %project_name,
|
|
issue = issue_number,
|
|
error = ?e,
|
|
"AI triage failed"
|
|
);
|
|
return Ok(IssueTriageResponse {
|
|
suggestions: None,
|
|
comment_posted: false,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Record billing (non-fatal)
|
|
let _ = self
|
|
.record_ai_usage(
|
|
project.id,
|
|
model.id,
|
|
ai_response.input_tokens,
|
|
ai_response.output_tokens,
|
|
)
|
|
.await
|
|
.inspect_err(|e| {
|
|
tracing::warn!(
|
|
project = %project.id,
|
|
error = ?e,
|
|
"failed to record AI billing for issue triage"
|
|
);
|
|
})
|
|
.ok();
|
|
|
|
let suggestions = parse_triage_response(&ai_response.content);
|
|
let mut comment_posted = false;
|
|
|
|
if let Some(ref s) = suggestions {
|
|
let comment_body = format!(
|
|
"## AI Triage Suggestions\n\n**Priority:** *{}*\n\n{}\n\n**Suggested Labels:** \
|
|
{}\n\n_This analysis was generated automatically by the AI collaborator._",
|
|
s.priority.to_uppercase(),
|
|
s.reasoning,
|
|
if s.suggested_labels.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
s.suggested_labels.join(", ")
|
|
}
|
|
);
|
|
|
|
let now = Utc::now();
|
|
let active = issue_comment::ActiveModel {
|
|
issue: Set(issue_model.id),
|
|
author: Set(Uuid::nil()),
|
|
body: Set(comment_body),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
..Default::default()
|
|
};
|
|
if active.insert(&self.db).await.is_ok() {
|
|
comment_posted = true;
|
|
}
|
|
}
|
|
|
|
Ok(IssueTriageResponse {
|
|
suggestions,
|
|
comment_posted,
|
|
})
|
|
}
|
|
}
|