aptu_core/ai/
provider.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! AI provider trait and shared implementations.
4//!
5//! Defines the `AiProvider` trait that all AI providers must implement,
6//! along with default implementations for shared logic like prompt building,
7//! request sending, and response parsing.
8
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use reqwest::Client;
12use secrecy::SecretString;
13use tracing::{debug, instrument};
14
15use super::AiResponse;
16use super::types::{
17    ChatCompletionRequest, ChatCompletionResponse, ChatMessage, IssueDetails, ResponseFormat,
18    TriageResponse,
19};
20use crate::history::AiStats;
21
22/// Parses JSON response from AI provider, detecting truncated responses.
23///
24/// If the JSON parsing fails with an EOF error (indicating the response was cut off),
25/// returns a `TruncatedResponse` error that can be retried. Other JSON errors are
26/// wrapped as `InvalidAIResponse`.
27///
28/// # Arguments
29///
30/// * `text` - The JSON text to parse
31/// * `provider` - The name of the AI provider (for error context)
32///
33/// # Returns
34///
35/// Parsed value of type T, or an error if parsing fails
36fn parse_ai_json<T: serde::de::DeserializeOwned>(text: &str, provider: &str) -> Result<T> {
37    match serde_json::from_str::<T>(text) {
38        Ok(value) => Ok(value),
39        Err(e) => {
40            // Check if this is an EOF error (truncated response)
41            if e.is_eof() {
42                Err(anyhow::anyhow!(
43                    crate::error::AptuError::TruncatedResponse {
44                        provider: provider.to_string(),
45                    }
46                ))
47            } else {
48                Err(anyhow::anyhow!(crate::error::AptuError::InvalidAIResponse(
49                    e
50                )))
51            }
52        }
53    }
54}
55
56/// Maximum length for issue body to stay within token limits.
57pub const MAX_BODY_LENGTH: usize = 4000;
58
59/// Maximum number of comments to include in the prompt.
60pub const MAX_COMMENTS: usize = 5;
61
62/// Maximum number of files to include in PR review prompt.
63pub const MAX_FILES: usize = 20;
64
65/// Maximum total diff size (in characters) for PR review prompt.
66pub const MAX_TOTAL_DIFF_SIZE: usize = 50_000;
67
68/// Maximum number of labels to include in the prompt.
69pub const MAX_LABELS: usize = 30;
70
71/// Maximum number of milestones to include in the prompt.
72pub const MAX_MILESTONES: usize = 10;
73
74/// AI provider trait for issue triage and creation.
75///
76/// Defines the interface that all AI providers must implement.
77/// Default implementations are provided for shared logic.
78#[async_trait]
79pub trait AiProvider: Send + Sync {
80    /// Returns the name of the provider (e.g., "gemini", "openrouter").
81    fn name(&self) -> &str;
82
83    /// Returns the API URL for this provider.
84    fn api_url(&self) -> &str;
85
86    /// Returns the environment variable name for the API key.
87    fn api_key_env(&self) -> &str;
88
89    /// Returns the HTTP client for making requests.
90    fn http_client(&self) -> &Client;
91
92    /// Returns the API key for authentication.
93    fn api_key(&self) -> &SecretString;
94
95    /// Returns the model name.
96    fn model(&self) -> &str;
97
98    /// Returns the maximum tokens for API responses.
99    fn max_tokens(&self) -> u32;
100
101    /// Returns the temperature for API requests.
102    fn temperature(&self) -> f32;
103
104    /// Returns the circuit breaker for this provider (optional).
105    ///
106    /// Default implementation returns None. Providers can override
107    /// to provide circuit breaker functionality.
108    fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
109        None
110    }
111
112    /// Builds HTTP headers for API requests.
113    ///
114    /// Default implementation includes Authorization and Content-Type headers.
115    /// Providers can override to add custom headers.
116    fn build_headers(&self) -> reqwest::header::HeaderMap {
117        let mut headers = reqwest::header::HeaderMap::new();
118        if let Ok(val) = "application/json".parse() {
119            headers.insert("Content-Type", val);
120        }
121        headers
122    }
123
124    /// Validates the model configuration.
125    ///
126    /// Default implementation does nothing. Providers can override
127    /// to enforce constraints (e.g., free tier validation).
128    fn validate_model(&self) -> Result<()> {
129        Ok(())
130    }
131
132    /// Sends a chat completion request to the provider's API (HTTP-only, no retry).
133    ///
134    /// Default implementation handles HTTP headers, error responses (401, 429).
135    /// Does not include retry logic - use `send_and_parse()` for retry behavior.
136    #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
137    async fn send_request_inner(
138        &self,
139        request: &ChatCompletionRequest,
140    ) -> Result<ChatCompletionResponse> {
141        use secrecy::ExposeSecret;
142        use tracing::warn;
143
144        use crate::error::AptuError;
145
146        let mut req = self.http_client().post(self.api_url());
147
148        // Add Authorization header
149        req = req.header(
150            "Authorization",
151            format!("Bearer {}", self.api_key().expose_secret()),
152        );
153
154        // Add custom headers from provider
155        for (key, value) in &self.build_headers() {
156            req = req.header(key.clone(), value.clone());
157        }
158
159        let response = req
160            .json(request)
161            .send()
162            .await
163            .context(format!("Failed to send request to {} API", self.name()))?;
164
165        // Check for HTTP errors
166        let status = response.status();
167        if !status.is_success() {
168            if status.as_u16() == 401 {
169                anyhow::bail!(
170                    "Invalid {} API key. Check your {} environment variable.",
171                    self.name(),
172                    self.api_key_env()
173                );
174            } else if status.as_u16() == 429 {
175                warn!("Rate limited by {} API", self.name());
176                // Parse Retry-After header (seconds), default to 0 if not present
177                let retry_after = response
178                    .headers()
179                    .get("Retry-After")
180                    .and_then(|h| h.to_str().ok())
181                    .and_then(|s| s.parse::<u64>().ok())
182                    .unwrap_or(0);
183                debug!(retry_after, "Parsed Retry-After header");
184                return Err(AptuError::RateLimited {
185                    provider: self.name().to_string(),
186                    retry_after,
187                }
188                .into());
189            }
190            let error_body = response.text().await.unwrap_or_default();
191            anyhow::bail!(
192                "{} API error (HTTP {}): {}",
193                self.name(),
194                status.as_u16(),
195                error_body
196            );
197        }
198
199        // Parse response
200        let completion: ChatCompletionResponse = response
201            .json()
202            .await
203            .context(format!("Failed to parse {} API response", self.name()))?;
204
205        Ok(completion)
206    }
207
208    /// Sends a chat completion request and parses the response with retry logic.
209    ///
210    /// This method wraps both HTTP request and JSON parsing in a single retry loop,
211    /// allowing truncated responses to be retried. Includes circuit breaker handling.
212    ///
213    /// # Arguments
214    ///
215    /// * `request` - The chat completion request to send
216    ///
217    /// # Returns
218    ///
219    /// A tuple of (parsed response, stats) extracted from the API response
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if:
224    /// - API request fails (network, timeout, rate limit)
225    /// - Response cannot be parsed as valid JSON (including truncated responses)
226    async fn send_and_parse<T: serde::de::DeserializeOwned>(
227        &self,
228        request: &ChatCompletionRequest,
229    ) -> Result<(T, AiStats)> {
230        use backon::Retryable;
231        use tracing::warn;
232
233        use crate::error::AptuError;
234        use crate::retry::{is_retryable_anyhow, retry_backoff};
235
236        // Check circuit breaker before attempting request
237        if let Some(cb) = self.circuit_breaker()
238            && cb.is_open()
239        {
240            return Err(AptuError::CircuitOpen.into());
241        }
242
243        // Start timing (outside retry loop to measure total time including retries)
244        let start = std::time::Instant::now();
245
246        let (parsed, completion): (T, ChatCompletionResponse) = (|| async {
247            // Send HTTP request
248            let completion = self.send_request_inner(request).await?;
249
250            // Extract message content
251            let content = completion
252                .choices
253                .first()
254                .map(|c| c.message.content.clone())
255                .context("No response from AI model")?;
256
257            debug!(response_length = content.len(), "Received AI response");
258
259            // Parse JSON response (inside retry loop, so truncated responses are retried)
260            let parsed: T = parse_ai_json(&content, self.name())?;
261
262            Ok((parsed, completion))
263        })
264        .retry(retry_backoff())
265        .when(is_retryable_anyhow)
266        .notify(|err, dur| warn!(error = %err, delay = ?dur, "Retrying after error"))
267        .await?;
268
269        // Record success in circuit breaker
270        if let Some(cb) = self.circuit_breaker() {
271            cb.record_success();
272        }
273
274        // Calculate duration (total time including any retries)
275        #[allow(clippy::cast_possible_truncation)]
276        let duration_ms = start.elapsed().as_millis() as u64;
277
278        // Build AI stats from usage info (trust API's cost field)
279        let (input_tokens, output_tokens, cost_usd) = if let Some(usage) = completion.usage {
280            (usage.prompt_tokens, usage.completion_tokens, usage.cost)
281        } else {
282            // If no usage info, default to 0
283            debug!("No usage information in API response");
284            (0, 0, None)
285        };
286
287        let ai_stats = AiStats {
288            model: self.model().to_string(),
289            input_tokens,
290            output_tokens,
291            duration_ms,
292            cost_usd,
293            fallback_provider: None,
294        };
295
296        Ok((parsed, ai_stats))
297    }
298
299    /// Analyzes a GitHub issue using the provider's API.
300    ///
301    /// Returns a structured triage response with summary, labels, questions, duplicates, and usage stats.
302    ///
303    /// # Arguments
304    ///
305    /// * `issue` - Issue details to analyze
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if:
310    /// - API request fails (network, timeout, rate limit)
311    /// - Response cannot be parsed as valid JSON
312    #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
313    async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
314        debug!(model = %self.model(), "Calling {} API", self.name());
315
316        // Build request
317        let request = ChatCompletionRequest {
318            model: self.model().to_string(),
319            messages: vec![
320                ChatMessage {
321                    role: "system".to_string(),
322                    content: Self::build_system_prompt(None),
323                },
324                ChatMessage {
325                    role: "user".to_string(),
326                    content: Self::build_user_prompt(issue),
327                },
328            ],
329            response_format: Some(ResponseFormat {
330                format_type: "json_object".to_string(),
331                json_schema: None,
332            }),
333            max_tokens: Some(self.max_tokens()),
334            temperature: Some(self.temperature()),
335        };
336
337        // Send request and parse JSON with retry logic
338        let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
339
340        debug!(
341            input_tokens = ai_stats.input_tokens,
342            output_tokens = ai_stats.output_tokens,
343            duration_ms = ai_stats.duration_ms,
344            cost_usd = ?ai_stats.cost_usd,
345            "AI analysis complete"
346        );
347
348        Ok(AiResponse {
349            triage,
350            stats: ai_stats,
351        })
352    }
353
354    /// Creates a formatted GitHub issue using the provider's API.
355    ///
356    /// Takes raw issue title and body, formats them using AI (conventional commit style,
357    /// structured body), and returns the formatted content with suggested labels.
358    ///
359    /// # Arguments
360    ///
361    /// * `title` - Raw issue title from user
362    /// * `body` - Raw issue body/description from user
363    /// * `repo` - Repository name for context (owner/repo format)
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if:
368    /// - API request fails (network, timeout, rate limit)
369    /// - Response cannot be parsed as valid JSON
370    #[instrument(skip(self), fields(repo = %repo))]
371    async fn create_issue(
372        &self,
373        title: &str,
374        body: &str,
375        repo: &str,
376    ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
377        debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
378
379        // Build request
380        let request = ChatCompletionRequest {
381            model: self.model().to_string(),
382            messages: vec![
383                ChatMessage {
384                    role: "system".to_string(),
385                    content: Self::build_create_system_prompt(None),
386                },
387                ChatMessage {
388                    role: "user".to_string(),
389                    content: Self::build_create_user_prompt(title, body, repo),
390                },
391            ],
392            response_format: Some(ResponseFormat {
393                format_type: "json_object".to_string(),
394                json_schema: None,
395            }),
396            max_tokens: Some(self.max_tokens()),
397            temperature: Some(self.temperature()),
398        };
399
400        // Send request and parse JSON with retry logic
401        let (create_response, ai_stats) = self
402            .send_and_parse::<super::types::CreateIssueResponse>(&request)
403            .await?;
404
405        debug!(
406            title_len = create_response.formatted_title.len(),
407            body_len = create_response.formatted_body.len(),
408            labels = create_response.suggested_labels.len(),
409            input_tokens = ai_stats.input_tokens,
410            output_tokens = ai_stats.output_tokens,
411            duration_ms = ai_stats.duration_ms,
412            "Issue formatting complete with stats"
413        );
414
415        Ok((create_response, ai_stats))
416    }
417
418    /// Builds the system prompt for issue triage.
419    #[must_use]
420    fn build_system_prompt(custom_guidance: Option<&str>) -> String {
421        let context = super::context::load_custom_guidance(custom_guidance);
422        let schema = "{\n  \"summary\": \"A 2-3 sentence summary of what the issue is about and its impact\",\n  \"suggested_labels\": [\"label1\", \"label2\"],\n  \"clarifying_questions\": [\"question1\", \"question2\"],\n  \"potential_duplicates\": [\"#123\", \"#456\"],\n  \"related_issues\": [\n    {\n      \"number\": 789,\n      \"title\": \"Related issue title\",\n      \"reason\": \"Brief explanation of why this is related\"\n    }\n  ],\n  \"status_note\": \"Optional note about issue status (e.g., claimed, in-progress)\",\n  \"contributor_guidance\": {\n    \"beginner_friendly\": true,\n    \"reasoning\": \"1-2 sentence explanation of beginner-friendliness assessment\"\n  },\n  \"implementation_approach\": \"Optional suggestions for implementation based on repository structure\",\n  \"suggested_milestone\": \"Optional milestone title for the issue\"\n}";
423        let guidelines = "Guidelines:\n\
424- summary: Concise explanation of the problem/request and why it matters\n\
425- suggested_labels: Prefer labels from the Available Labels list provided. Choose from: bug, enhancement, documentation, question, duplicate, invalid, wontfix. If a more specific label exists in the repository, use it instead of generic ones.\n\
426- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
427- potential_duplicates: Only include if you detect likely duplicates from the context. Leave empty array if none. A duplicate is an issue that describes the exact same problem.\n\
428- related_issues: Include issues from the search results that are contextually related but NOT duplicates. Provide brief reasoning for each. Leave empty array if none are relevant.\n\
429- status_note: Detect if someone has claimed the issue or is working on it. Look for patterns like \"I'd like to work on this\", \"I'll submit a PR\", \"working on this\", or \"@user I've assigned you\". If claimed, set status_note to a brief description (e.g., \"Issue claimed by @username\"). If not claimed, leave as null or empty string.\n\
430- contributor_guidance: Assess whether the issue is suitable for beginners. Consider: scope (small, well-defined), file count (few files to modify), required knowledge (no deep expertise needed), clarity (clear problem statement). Set beginner_friendly to true if all factors are favorable. Provide 1-2 sentence reasoning explaining the assessment.\n\
431- implementation_approach: Based on the repository structure provided, suggest specific files or modules to modify. Reference the file paths from the repository structure. Be concrete and actionable. Leave as null or empty string if no specific guidance can be provided.\n\
432- suggested_milestone: If applicable, suggest a milestone title from the Available Milestones list. Only include if a milestone is clearly relevant to the issue. Leave as null or empty string if no milestone is appropriate.\n\
433\n\
434Be helpful, concise, and actionable. Focus on what a maintainer needs to know.";
435        format!(
436            "You are an OSS issue triage assistant. Analyze the provided GitHub issue and \
437             provide structured triage information.\n\n{context}\n\nYour response MUST be valid \
438             JSON with this exact schema:\n{schema}\n\n{guidelines}"
439        )
440    }
441
442    /// Builds the user prompt containing the issue details.
443    #[must_use]
444    fn build_user_prompt(issue: &IssueDetails) -> String {
445        use std::fmt::Write;
446
447        let mut prompt = String::new();
448
449        prompt.push_str("<issue_content>\n");
450        let _ = writeln!(prompt, "Title: {}\n", issue.title);
451
452        // Truncate body if too long
453        let body = if issue.body.len() > MAX_BODY_LENGTH {
454            format!(
455                "{}...\n[Body truncated - original length: {} chars]",
456                &issue.body[..MAX_BODY_LENGTH],
457                issue.body.len()
458            )
459        } else if issue.body.is_empty() {
460            "[No description provided]".to_string()
461        } else {
462            issue.body.clone()
463        };
464        let _ = writeln!(prompt, "Body:\n{body}\n");
465
466        // Include existing labels
467        if !issue.labels.is_empty() {
468            let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
469        }
470
471        // Include recent comments (limited)
472        if !issue.comments.is_empty() {
473            prompt.push_str("Recent Comments:\n");
474            for comment in issue.comments.iter().take(MAX_COMMENTS) {
475                let comment_body = if comment.body.len() > 500 {
476                    format!("{}...", &comment.body[..500])
477                } else {
478                    comment.body.clone()
479                };
480                let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
481            }
482            prompt.push('\n');
483        }
484
485        // Include related issues from search (for context)
486        if !issue.repo_context.is_empty() {
487            prompt.push_str("Related Issues in Repository (for context):\n");
488            for related in issue.repo_context.iter().take(10) {
489                let _ = writeln!(
490                    prompt,
491                    "- #{} [{}] {}",
492                    related.number, related.state, related.title
493                );
494            }
495            prompt.push('\n');
496        }
497
498        // Include repository structure (source files)
499        if !issue.repo_tree.is_empty() {
500            prompt.push_str("Repository Structure (source files):\n");
501            for path in issue.repo_tree.iter().take(20) {
502                let _ = writeln!(prompt, "- {path}");
503            }
504            prompt.push('\n');
505        }
506
507        // Include available labels
508        if !issue.available_labels.is_empty() {
509            prompt.push_str("Available Labels:\n");
510            for label in issue.available_labels.iter().take(MAX_LABELS) {
511                let description = if label.description.is_empty() {
512                    String::new()
513                } else {
514                    format!(" - {}", label.description)
515                };
516                let _ = writeln!(
517                    prompt,
518                    "- {} (color: #{}){}",
519                    label.name, label.color, description
520                );
521            }
522            prompt.push('\n');
523        }
524
525        // Include available milestones
526        if !issue.available_milestones.is_empty() {
527            prompt.push_str("Available Milestones:\n");
528            for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
529                let description = if milestone.description.is_empty() {
530                    String::new()
531                } else {
532                    format!(" - {}", milestone.description)
533                };
534                let _ = writeln!(prompt, "- {}{}", milestone.title, description);
535            }
536            prompt.push('\n');
537        }
538
539        prompt.push_str("</issue_content>");
540
541        prompt
542    }
543
544    /// Builds the system prompt for issue creation/formatting.
545    #[must_use]
546    fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
547        let context = super::context::load_custom_guidance(custom_guidance);
548        format!(
549            r#"You are a GitHub issue formatting assistant. Your job is to take a raw issue title and body from a user and format them professionally for a GitHub repository.
550
551{context}
552
553Your response MUST be valid JSON with this exact schema:
554{{
555  "formatted_title": "Well-formatted issue title following conventional commit style",
556  "formatted_body": "Professionally formatted issue body with clear sections",
557  "suggested_labels": ["label1", "label2"]
558}}
559
560Guidelines:
561- formatted_title: Use conventional commit style (e.g., "feat: add search functionality", "fix: resolve memory leak in parser"). Keep it concise (under 72 characters). No period at the end.
562- formatted_body: Structure the body with clear sections:
563  * Start with a brief 1-2 sentence summary if not already present
564  * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
565  * Keep sentences clear and concise
566  * Use bullet points for lists
567  * Improve grammar and clarity
568  * Add relevant context if missing
569- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.
570
571Be professional but friendly. Maintain the user's intent while improving clarity and structure."#
572        )
573    }
574
575    /// Builds the user prompt for issue creation/formatting.
576    #[must_use]
577    fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
578        format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
579    }
580
581    /// Reviews a pull request using the provider's API.
582    ///
583    /// Analyzes PR metadata and file diffs to provide structured review feedback.
584    ///
585    /// # Arguments
586    ///
587    /// * `pr` - Pull request details including files and diffs
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if:
592    /// - API request fails (network, timeout, rate limit)
593    /// - Response cannot be parsed as valid JSON
594    #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
595    async fn review_pr(
596        &self,
597        pr: &super::types::PrDetails,
598    ) -> Result<(super::types::PrReviewResponse, AiStats)> {
599        debug!(model = %self.model(), "Calling {} API for PR review", self.name());
600
601        // Build request
602        let request = ChatCompletionRequest {
603            model: self.model().to_string(),
604            messages: vec![
605                ChatMessage {
606                    role: "system".to_string(),
607                    content: Self::build_pr_review_system_prompt(None),
608                },
609                ChatMessage {
610                    role: "user".to_string(),
611                    content: Self::build_pr_review_user_prompt(pr),
612                },
613            ],
614            response_format: Some(ResponseFormat {
615                format_type: "json_object".to_string(),
616                json_schema: None,
617            }),
618            max_tokens: Some(self.max_tokens()),
619            temperature: Some(self.temperature()),
620        };
621
622        // Send request and parse JSON with retry logic
623        let (review, ai_stats) = self
624            .send_and_parse::<super::types::PrReviewResponse>(&request)
625            .await?;
626
627        debug!(
628            verdict = %review.verdict,
629            input_tokens = ai_stats.input_tokens,
630            output_tokens = ai_stats.output_tokens,
631            duration_ms = ai_stats.duration_ms,
632            "PR review complete with stats"
633        );
634
635        Ok((review, ai_stats))
636    }
637
638    /// Suggests labels for a pull request using the provider's API.
639    ///
640    /// Analyzes PR title, body, and file paths to suggest relevant labels.
641    ///
642    /// # Arguments
643    ///
644    /// * `title` - Pull request title
645    /// * `body` - Pull request description
646    /// * `file_paths` - List of file paths changed in the PR
647    ///
648    /// # Errors
649    ///
650    /// Returns an error if:
651    /// - API request fails (network, timeout, rate limit)
652    /// - Response cannot be parsed as valid JSON
653    #[instrument(skip(self), fields(title = %title))]
654    async fn suggest_pr_labels(
655        &self,
656        title: &str,
657        body: &str,
658        file_paths: &[String],
659    ) -> Result<(Vec<String>, AiStats)> {
660        debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
661
662        // Build request
663        let request = ChatCompletionRequest {
664            model: self.model().to_string(),
665            messages: vec![
666                ChatMessage {
667                    role: "system".to_string(),
668                    content: Self::build_pr_label_system_prompt(None),
669                },
670                ChatMessage {
671                    role: "user".to_string(),
672                    content: Self::build_pr_label_user_prompt(title, body, file_paths),
673                },
674            ],
675            response_format: Some(ResponseFormat {
676                format_type: "json_object".to_string(),
677                json_schema: None,
678            }),
679            max_tokens: Some(self.max_tokens()),
680            temperature: Some(self.temperature()),
681        };
682
683        // Send request and parse JSON with retry logic
684        let (response, ai_stats) = self
685            .send_and_parse::<super::types::PrLabelResponse>(&request)
686            .await?;
687
688        debug!(
689            label_count = response.suggested_labels.len(),
690            input_tokens = ai_stats.input_tokens,
691            output_tokens = ai_stats.output_tokens,
692            duration_ms = ai_stats.duration_ms,
693            "PR label suggestion complete with stats"
694        );
695
696        Ok((response.suggested_labels, ai_stats))
697    }
698
699    /// Builds the system prompt for PR review.
700    #[must_use]
701    fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
702        let context = super::context::load_custom_guidance(custom_guidance);
703        format!(
704            r#"You are a code review assistant. Analyze the provided pull request and provide structured review feedback.
705
706{context}
707
708Your response MUST be valid JSON with this exact schema:
709{{
710  "summary": "A 2-3 sentence summary of what the PR does and its impact",
711  "verdict": "approve|request_changes|comment",
712  "strengths": ["strength1", "strength2"],
713  "concerns": ["concern1", "concern2"],
714  "comments": [
715    {{
716      "file": "path/to/file.rs",
717      "line": 42,
718      "comment": "Specific feedback about this line",
719      "severity": "info|suggestion|warning|issue"
720    }}
721  ],
722  "suggestions": ["suggestion1", "suggestion2"]
723}}
724
725Guidelines:
726- summary: Concise explanation of the changes and their purpose
727- verdict: Use "approve" for good PRs, "request_changes" for blocking issues, "comment" for feedback without blocking
728- strengths: What the PR does well (good patterns, clear code, etc.)
729- concerns: Potential issues or risks (bugs, performance, security, maintainability)
730- comments: Specific line-level feedback. Use severity:
731  - "info": Informational, no action needed
732  - "suggestion": Optional improvement
733  - "warning": Should consider changing
734  - "issue": Should be fixed before merge
735- suggestions: General improvements that are not blocking
736
737Focus on:
7381. Correctness: Does the code do what it claims?
7392. Security: Any potential vulnerabilities?
7403. Performance: Any obvious inefficiencies?
7414. Maintainability: Is the code clear and well-structured?
7425. Testing: Are changes adequately tested?
743
744Be constructive and specific. Explain why something is an issue and how to fix it."#
745        )
746    }
747
748    /// Builds the user prompt for PR review.
749    #[must_use]
750    fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
751        use std::fmt::Write;
752
753        let mut prompt = String::new();
754
755        prompt.push_str("<pull_request>\n");
756        let _ = writeln!(prompt, "Title: {}\n", pr.title);
757        let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
758
759        // PR description
760        let body = if pr.body.is_empty() {
761            "[No description provided]".to_string()
762        } else if pr.body.len() > MAX_BODY_LENGTH {
763            format!(
764                "{}...\n[Description truncated - original length: {} chars]",
765                &pr.body[..MAX_BODY_LENGTH],
766                pr.body.len()
767            )
768        } else {
769            pr.body.clone()
770        };
771        let _ = writeln!(prompt, "Description:\n{body}\n");
772
773        // File changes with limits
774        prompt.push_str("Files Changed:\n");
775        let mut total_diff_size = 0;
776        let mut files_included = 0;
777        let mut files_skipped = 0;
778
779        for file in &pr.files {
780            // Check file count limit
781            if files_included >= MAX_FILES {
782                files_skipped += 1;
783                continue;
784            }
785
786            let _ = writeln!(
787                prompt,
788                "- {} ({}) +{} -{}\n",
789                file.filename, file.status, file.additions, file.deletions
790            );
791
792            // Include patch if available (truncate large patches)
793            if let Some(patch) = &file.patch {
794                const MAX_PATCH_LENGTH: usize = 2000;
795                let patch_content = if patch.len() > MAX_PATCH_LENGTH {
796                    format!(
797                        "{}...\n[Patch truncated - original length: {} chars]",
798                        &patch[..MAX_PATCH_LENGTH],
799                        patch.len()
800                    )
801                } else {
802                    patch.clone()
803                };
804
805                // Check if adding this patch would exceed total diff size limit
806                let patch_size = patch_content.len();
807                if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
808                    let _ = writeln!(
809                        prompt,
810                        "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
811                    );
812                    files_skipped += 1;
813                    continue;
814                }
815
816                let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
817                total_diff_size += patch_size;
818            }
819
820            files_included += 1;
821        }
822
823        // Add truncation message if files were skipped
824        if files_skipped > 0 {
825            let _ = writeln!(
826                prompt,
827                "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
828            );
829        }
830
831        prompt.push_str("</pull_request>");
832
833        prompt
834    }
835
836    /// Builds the system prompt for PR label suggestion.
837    #[must_use]
838    fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
839        let context = super::context::load_custom_guidance(custom_guidance);
840        format!(
841            r#"You are a GitHub label suggestion assistant. Analyze the provided pull request and suggest relevant labels.
842
843{context}
844
845Your response MUST be valid JSON with this exact schema:
846{{
847  "suggested_labels": ["label1", "label2", "label3"]
848}}
849
850Response format: json_object
851
852Guidelines:
853- suggested_labels: Suggest 1-3 relevant GitHub labels based on the PR content. Common labels include: bug, enhancement, documentation, feature, refactor, performance, security, testing, ci, dependencies. Choose labels that best describe the type of change.
854- Focus on the PR title, description, and file paths to determine appropriate labels.
855- Prefer specific labels over generic ones when possible.
856- Only suggest labels that are commonly used in GitHub repositories.
857
858Be concise and practical."#
859        )
860    }
861
862    /// Builds the user prompt for PR label suggestion.
863    #[must_use]
864    fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
865        use std::fmt::Write;
866
867        let mut prompt = String::new();
868
869        prompt.push_str("<pull_request>\n");
870        let _ = writeln!(prompt, "Title: {title}\n");
871
872        // PR description
873        let body_content = if body.is_empty() {
874            "[No description provided]".to_string()
875        } else if body.len() > MAX_BODY_LENGTH {
876            format!(
877                "{}...\n[Description truncated - original length: {} chars]",
878                &body[..MAX_BODY_LENGTH],
879                body.len()
880            )
881        } else {
882            body.to_string()
883        };
884        let _ = writeln!(prompt, "Description:\n{body_content}\n");
885
886        // File paths
887        if !file_paths.is_empty() {
888            prompt.push_str("Files Changed:\n");
889            for path in file_paths.iter().take(20) {
890                let _ = writeln!(prompt, "- {path}");
891            }
892            if file_paths.len() > 20 {
893                let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
894            }
895            prompt.push('\n');
896        }
897
898        prompt.push_str("</pull_request>");
899
900        prompt
901    }
902
903    /// Generate release notes from PR summaries.
904    ///
905    /// # Arguments
906    ///
907    /// * `prs` - List of PR summaries to synthesize
908    /// * `version` - Version being released
909    ///
910    /// # Returns
911    ///
912    /// Structured release notes with theme, highlights, and categorized changes.
913    #[instrument(skip(self, prs))]
914    async fn generate_release_notes(
915        &self,
916        prs: Vec<super::types::PrSummary>,
917        version: &str,
918    ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
919        let prompt = Self::build_release_notes_prompt(&prs, version);
920        let request = ChatCompletionRequest {
921            model: self.model().to_string(),
922            messages: vec![ChatMessage {
923                role: "user".to_string(),
924                content: prompt,
925            }],
926            response_format: Some(ResponseFormat {
927                format_type: "json_object".to_string(),
928                json_schema: None,
929            }),
930            temperature: Some(0.7),
931            max_tokens: Some(self.max_tokens()),
932        };
933
934        let (parsed, ai_stats) = self
935            .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
936            .await?;
937
938        debug!(
939            input_tokens = ai_stats.input_tokens,
940            output_tokens = ai_stats.output_tokens,
941            duration_ms = ai_stats.duration_ms,
942            "Release notes generation complete with stats"
943        );
944
945        Ok((parsed, ai_stats))
946    }
947
948    /// Build the user prompt for release notes generation.
949    #[must_use]
950    fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
951        let pr_list = prs
952            .iter()
953            .map(|pr| {
954                format!(
955                    "- #{}: {} (by @{})\n  {}",
956                    pr.number,
957                    pr.title,
958                    pr.author,
959                    pr.body.lines().next().unwrap_or("")
960                )
961            })
962            .collect::<Vec<_>>()
963            .join("\n");
964
965        format!(
966            r#"Generate release notes for version {version} based on these merged PRs:
967
968{pr_list}
969
970Create a curated release notes document with:
9711. A theme/title that captures the essence of this release
9722. A 1-2 sentence narrative about the release
9733. 3-5 highlighted features
9744. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
9755. List of contributors
976
977Follow these conventions:
978- No emojis
979- Bold feature names with dash separator
980- Include PR numbers in parentheses
981- Group by user impact, not just commit type
982- Filter CI/deps under Maintenance
983
984Your response MUST be valid JSON with this exact schema:
985{{
986  "theme": "Release theme title",
987  "narrative": "1-2 sentence summary",
988  "highlights": ["highlight1", "highlight2"],
989  "features": ["feature1", "feature2"],
990  "fixes": ["fix1", "fix2"],
991  "improvements": ["improvement1"],
992  "documentation": ["doc change1"],
993  "maintenance": ["maintenance1"],
994  "contributors": ["@author1", "@author2"]
995}}"#
996        )
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    struct TestProvider;
1005
1006    impl AiProvider for TestProvider {
1007        fn name(&self) -> &'static str {
1008            "test"
1009        }
1010
1011        fn api_url(&self) -> &'static str {
1012            "https://test.example.com"
1013        }
1014
1015        fn api_key_env(&self) -> &'static str {
1016            "TEST_API_KEY"
1017        }
1018
1019        fn http_client(&self) -> &Client {
1020            unimplemented!()
1021        }
1022
1023        fn api_key(&self) -> &SecretString {
1024            unimplemented!()
1025        }
1026
1027        fn model(&self) -> &'static str {
1028            "test-model"
1029        }
1030
1031        fn max_tokens(&self) -> u32 {
1032            2048
1033        }
1034
1035        fn temperature(&self) -> f32 {
1036            0.3
1037        }
1038    }
1039
1040    #[test]
1041    fn test_build_system_prompt_contains_json_schema() {
1042        let prompt = TestProvider::build_system_prompt(None);
1043        assert!(prompt.contains("summary"));
1044        assert!(prompt.contains("suggested_labels"));
1045        assert!(prompt.contains("clarifying_questions"));
1046        assert!(prompt.contains("potential_duplicates"));
1047        assert!(prompt.contains("status_note"));
1048    }
1049
1050    #[test]
1051    fn test_build_user_prompt_with_delimiters() {
1052        let issue = IssueDetails::builder()
1053            .owner("test".to_string())
1054            .repo("repo".to_string())
1055            .number(1)
1056            .title("Test issue".to_string())
1057            .body("This is the body".to_string())
1058            .labels(vec!["bug".to_string()])
1059            .comments(vec![])
1060            .url("https://github.com/test/repo/issues/1".to_string())
1061            .build();
1062
1063        let prompt = TestProvider::build_user_prompt(&issue);
1064        assert!(prompt.starts_with("<issue_content>"));
1065        assert!(prompt.ends_with("</issue_content>"));
1066        assert!(prompt.contains("Title: Test issue"));
1067        assert!(prompt.contains("This is the body"));
1068        assert!(prompt.contains("Existing Labels: bug"));
1069    }
1070
1071    #[test]
1072    fn test_build_user_prompt_truncates_long_body() {
1073        let long_body = "x".repeat(5000);
1074        let issue = IssueDetails::builder()
1075            .owner("test".to_string())
1076            .repo("repo".to_string())
1077            .number(1)
1078            .title("Test".to_string())
1079            .body(long_body)
1080            .labels(vec![])
1081            .comments(vec![])
1082            .url("https://github.com/test/repo/issues/1".to_string())
1083            .build();
1084
1085        let prompt = TestProvider::build_user_prompt(&issue);
1086        assert!(prompt.contains("[Body truncated"));
1087        assert!(prompt.contains("5000 chars"));
1088    }
1089
1090    #[test]
1091    fn test_build_user_prompt_empty_body() {
1092        let issue = IssueDetails::builder()
1093            .owner("test".to_string())
1094            .repo("repo".to_string())
1095            .number(1)
1096            .title("Test".to_string())
1097            .body(String::new())
1098            .labels(vec![])
1099            .comments(vec![])
1100            .url("https://github.com/test/repo/issues/1".to_string())
1101            .build();
1102
1103        let prompt = TestProvider::build_user_prompt(&issue);
1104        assert!(prompt.contains("[No description provided]"));
1105    }
1106
1107    #[test]
1108    fn test_build_create_system_prompt_contains_json_schema() {
1109        let prompt = TestProvider::build_create_system_prompt(None);
1110        assert!(prompt.contains("formatted_title"));
1111        assert!(prompt.contains("formatted_body"));
1112        assert!(prompt.contains("suggested_labels"));
1113    }
1114
1115    #[test]
1116    fn test_build_pr_review_user_prompt_respects_file_limit() {
1117        use super::super::types::{PrDetails, PrFile};
1118
1119        let mut files = Vec::new();
1120        for i in 0..25 {
1121            files.push(PrFile {
1122                filename: format!("file{i}.rs"),
1123                status: "modified".to_string(),
1124                additions: 10,
1125                deletions: 5,
1126                patch: Some(format!("patch content {i}")),
1127            });
1128        }
1129
1130        let pr = PrDetails {
1131            owner: "test".to_string(),
1132            repo: "repo".to_string(),
1133            number: 1,
1134            title: "Test PR".to_string(),
1135            body: "Description".to_string(),
1136            head_branch: "feature".to_string(),
1137            base_branch: "main".to_string(),
1138            url: "https://github.com/test/repo/pull/1".to_string(),
1139            files,
1140            labels: vec![],
1141        };
1142
1143        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1144        assert!(prompt.contains("files omitted due to size limits"));
1145        assert!(prompt.contains("MAX_FILES=20"));
1146    }
1147
1148    #[test]
1149    fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1150        use super::super::types::{PrDetails, PrFile};
1151
1152        // Create patches that will exceed the limit when combined
1153        // Each patch is ~30KB, so two will exceed 50KB limit
1154        let patch1 = "x".repeat(30_000);
1155        let patch2 = "y".repeat(30_000);
1156
1157        let files = vec![
1158            PrFile {
1159                filename: "file1.rs".to_string(),
1160                status: "modified".to_string(),
1161                additions: 100,
1162                deletions: 50,
1163                patch: Some(patch1),
1164            },
1165            PrFile {
1166                filename: "file2.rs".to_string(),
1167                status: "modified".to_string(),
1168                additions: 100,
1169                deletions: 50,
1170                patch: Some(patch2),
1171            },
1172        ];
1173
1174        let pr = PrDetails {
1175            owner: "test".to_string(),
1176            repo: "repo".to_string(),
1177            number: 1,
1178            title: "Test PR".to_string(),
1179            body: "Description".to_string(),
1180            head_branch: "feature".to_string(),
1181            base_branch: "main".to_string(),
1182            url: "https://github.com/test/repo/pull/1".to_string(),
1183            files,
1184            labels: vec![],
1185        };
1186
1187        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1188        // Both files should be listed
1189        assert!(prompt.contains("file1.rs"));
1190        assert!(prompt.contains("file2.rs"));
1191        // The second patch should be limited - verify the prompt doesn't contain both full patches
1192        // by checking that the total size is less than what two full 30KB patches would be
1193        assert!(prompt.len() < 65_000);
1194    }
1195
1196    #[test]
1197    fn test_build_pr_review_user_prompt_with_no_patches() {
1198        use super::super::types::{PrDetails, PrFile};
1199
1200        let files = vec![PrFile {
1201            filename: "file1.rs".to_string(),
1202            status: "added".to_string(),
1203            additions: 10,
1204            deletions: 0,
1205            patch: None,
1206        }];
1207
1208        let pr = PrDetails {
1209            owner: "test".to_string(),
1210            repo: "repo".to_string(),
1211            number: 1,
1212            title: "Test PR".to_string(),
1213            body: "Description".to_string(),
1214            head_branch: "feature".to_string(),
1215            base_branch: "main".to_string(),
1216            url: "https://github.com/test/repo/pull/1".to_string(),
1217            files,
1218            labels: vec![],
1219        };
1220
1221        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1222        assert!(prompt.contains("file1.rs"));
1223        assert!(prompt.contains("added"));
1224        assert!(!prompt.contains("files omitted"));
1225    }
1226
1227    #[test]
1228    fn test_build_pr_label_system_prompt_contains_json_schema() {
1229        let prompt = TestProvider::build_pr_label_system_prompt(None);
1230        assert!(prompt.contains("suggested_labels"));
1231        assert!(prompt.contains("json_object"));
1232        assert!(prompt.contains("bug"));
1233        assert!(prompt.contains("enhancement"));
1234    }
1235
1236    #[test]
1237    fn test_build_pr_label_user_prompt_with_title_and_body() {
1238        let title = "feat: add new feature";
1239        let body = "This PR adds a new feature";
1240        let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1241
1242        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1243        assert!(prompt.starts_with("<pull_request>"));
1244        assert!(prompt.ends_with("</pull_request>"));
1245        assert!(prompt.contains("feat: add new feature"));
1246        assert!(prompt.contains("This PR adds a new feature"));
1247        assert!(prompt.contains("src/main.rs"));
1248        assert!(prompt.contains("tests/test.rs"));
1249    }
1250
1251    #[test]
1252    fn test_build_pr_label_user_prompt_empty_body() {
1253        let title = "fix: bug fix";
1254        let body = "";
1255        let files = vec!["src/lib.rs".to_string()];
1256
1257        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1258        assert!(prompt.contains("[No description provided]"));
1259        assert!(prompt.contains("src/lib.rs"));
1260    }
1261
1262    #[test]
1263    fn test_build_pr_label_user_prompt_truncates_long_body() {
1264        let title = "test";
1265        let long_body = "x".repeat(5000);
1266        let files = vec![];
1267
1268        let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1269        assert!(prompt.contains("[Description truncated"));
1270        assert!(prompt.contains("5000 chars"));
1271    }
1272
1273    #[test]
1274    fn test_build_pr_label_user_prompt_respects_file_limit() {
1275        let title = "test";
1276        let body = "test";
1277        let mut files = Vec::new();
1278        for i in 0..25 {
1279            files.push(format!("file{i}.rs"));
1280        }
1281
1282        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1283        assert!(prompt.contains("file0.rs"));
1284        assert!(prompt.contains("file19.rs"));
1285        assert!(!prompt.contains("file20.rs"));
1286        assert!(prompt.contains("... and 5 more files"));
1287    }
1288
1289    #[test]
1290    fn test_build_pr_label_user_prompt_empty_files() {
1291        let title = "test";
1292        let body = "test";
1293        let files: Vec<String> = vec![];
1294
1295        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1296        assert!(prompt.contains("Title: test"));
1297        assert!(prompt.contains("Description:\ntest"));
1298        assert!(!prompt.contains("Files Changed:"));
1299    }
1300
1301    #[test]
1302    fn test_parse_ai_json_with_valid_json() {
1303        #[derive(serde::Deserialize)]
1304        struct TestResponse {
1305            message: String,
1306        }
1307
1308        let json = r#"{"message": "hello"}"#;
1309        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1310        assert!(result.is_ok());
1311        let response = result.unwrap();
1312        assert_eq!(response.message, "hello");
1313    }
1314
1315    #[test]
1316    fn test_parse_ai_json_with_truncated_json() {
1317        #[derive(Debug, serde::Deserialize)]
1318        #[allow(dead_code)]
1319        struct TestResponse {
1320            message: String,
1321        }
1322
1323        let json = r#"{"message": "hello"#;
1324        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1325        assert!(result.is_err());
1326        let err = result.unwrap_err();
1327        assert!(
1328            err.to_string()
1329                .contains("Truncated response from test-provider")
1330        );
1331    }
1332
1333    #[test]
1334    fn test_parse_ai_json_with_malformed_json() {
1335        #[derive(Debug, serde::Deserialize)]
1336        #[allow(dead_code)]
1337        struct TestResponse {
1338            message: String,
1339        }
1340
1341        let json = r#"{"message": invalid}"#;
1342        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1343        assert!(result.is_err());
1344        let err = result.unwrap_err();
1345        assert!(err.to_string().contains("Invalid JSON response from AI"));
1346    }
1347}