gitdataai/libs/service/agent/issue_triage.rs

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,
})
}
}