//! 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, pub priority: String, pub reasoning: String, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueTriageResponse { pub suggestions: Option, 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 { 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 { 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 { 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 = 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, }) } }