Skip to main content

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 maximum retry attempts for rate-limited requests.
105    ///
106    /// Default implementation returns 3. Providers can override
107    /// to use a different retry limit.
108    fn max_attempts(&self) -> u32 {
109        3
110    }
111
112    /// Returns the circuit breaker for this provider (optional).
113    ///
114    /// Default implementation returns None. Providers can override
115    /// to provide circuit breaker functionality.
116    fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
117        None
118    }
119
120    /// Builds HTTP headers for API requests.
121    ///
122    /// Default implementation includes Authorization and Content-Type headers.
123    /// Providers can override to add custom headers.
124    fn build_headers(&self) -> reqwest::header::HeaderMap {
125        let mut headers = reqwest::header::HeaderMap::new();
126        if let Ok(val) = "application/json".parse() {
127            headers.insert("Content-Type", val);
128        }
129        headers
130    }
131
132    /// Validates the model configuration.
133    ///
134    /// Default implementation does nothing. Providers can override
135    /// to enforce constraints (e.g., free tier validation).
136    fn validate_model(&self) -> Result<()> {
137        Ok(())
138    }
139
140    /// Sends a chat completion request to the provider's API (HTTP-only, no retry).
141    ///
142    /// Default implementation handles HTTP headers, error responses (401, 429).
143    /// Does not include retry logic - use `send_and_parse()` for retry behavior.
144    #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
145    async fn send_request_inner(
146        &self,
147        request: &ChatCompletionRequest,
148    ) -> Result<ChatCompletionResponse> {
149        use secrecy::ExposeSecret;
150        use tracing::warn;
151
152        use crate::error::AptuError;
153
154        let mut req = self.http_client().post(self.api_url());
155
156        // Add Authorization header
157        req = req.header(
158            "Authorization",
159            format!("Bearer {}", self.api_key().expose_secret()),
160        );
161
162        // Add custom headers from provider
163        for (key, value) in &self.build_headers() {
164            req = req.header(key.clone(), value.clone());
165        }
166
167        let response = req
168            .json(request)
169            .send()
170            .await
171            .context(format!("Failed to send request to {} API", self.name()))?;
172
173        // Check for HTTP errors
174        let status = response.status();
175        if !status.is_success() {
176            if status.as_u16() == 401 {
177                anyhow::bail!(
178                    "Invalid {} API key. Check your {} environment variable.",
179                    self.name(),
180                    self.api_key_env()
181                );
182            } else if status.as_u16() == 429 {
183                warn!("Rate limited by {} API", self.name());
184                // Parse Retry-After header (seconds), default to 0 if not present
185                let retry_after = response
186                    .headers()
187                    .get("Retry-After")
188                    .and_then(|h| h.to_str().ok())
189                    .and_then(|s| s.parse::<u64>().ok())
190                    .unwrap_or(0);
191                debug!(retry_after, "Parsed Retry-After header");
192                return Err(AptuError::RateLimited {
193                    provider: self.name().to_string(),
194                    retry_after,
195                }
196                .into());
197            }
198            let error_body = response.text().await.unwrap_or_default();
199            anyhow::bail!(
200                "{} API error (HTTP {}): {}",
201                self.name(),
202                status.as_u16(),
203                error_body
204            );
205        }
206
207        // Parse response
208        let completion: ChatCompletionResponse = response
209            .json()
210            .await
211            .context(format!("Failed to parse {} API response", self.name()))?;
212
213        Ok(completion)
214    }
215
216    /// Sends a chat completion request and parses the response with retry logic.
217    ///
218    /// This method wraps both HTTP request and JSON parsing in a single retry loop,
219    /// allowing truncated responses to be retried. Includes circuit breaker handling.
220    ///
221    /// # Arguments
222    ///
223    /// * `request` - The chat completion request to send
224    ///
225    /// # Returns
226    ///
227    /// A tuple of (parsed response, stats) extracted from the API response
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - API request fails (network, timeout, rate limit)
233    /// - Response cannot be parsed as valid JSON (including truncated responses)
234    #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
235    async fn send_and_parse<T: serde::de::DeserializeOwned + Send>(
236        &self,
237        request: &ChatCompletionRequest,
238    ) -> Result<(T, AiStats)> {
239        use tracing::{info, warn};
240
241        use crate::error::AptuError;
242        use crate::retry::{extract_retry_after, is_retryable_anyhow};
243
244        // Check circuit breaker before attempting request
245        if let Some(cb) = self.circuit_breaker()
246            && cb.is_open()
247        {
248            return Err(AptuError::CircuitOpen.into());
249        }
250
251        // Start timing (outside retry loop to measure total time including retries)
252        let start = std::time::Instant::now();
253
254        // Custom retry loop that respects retry_after from RateLimited errors
255        let mut attempt: u32 = 0;
256        let max_attempts: u32 = self.max_attempts();
257
258        // Helper function to avoid closure-in-expression clippy warning
259        #[allow(clippy::items_after_statements)]
260        async fn try_request<T: serde::de::DeserializeOwned>(
261            provider: &(impl AiProvider + ?Sized),
262            request: &ChatCompletionRequest,
263        ) -> Result<(T, ChatCompletionResponse)> {
264            // Send HTTP request
265            let completion = provider.send_request_inner(request).await?;
266
267            // Extract message content
268            let content = completion
269                .choices
270                .first()
271                .map(|c| c.message.content.clone())
272                .context("No response from AI model")?;
273
274            debug!(response_length = content.len(), "Received AI response");
275
276            // Parse JSON response (inside retry loop, so truncated responses are retried)
277            let parsed: T = parse_ai_json(&content, provider.name())?;
278
279            Ok((parsed, completion))
280        }
281
282        let (parsed, completion): (T, ChatCompletionResponse) = loop {
283            attempt += 1;
284
285            let result = try_request(self, request).await;
286
287            match result {
288                Ok(success) => break success,
289                Err(err) => {
290                    // Check if error is retryable
291                    if !is_retryable_anyhow(&err) || attempt >= max_attempts {
292                        return Err(err);
293                    }
294
295                    // Extract retry_after if present, otherwise use exponential backoff
296                    let delay = if let Some(retry_after_duration) = extract_retry_after(&err) {
297                        debug!(
298                            retry_after_secs = retry_after_duration.as_secs(),
299                            "Using Retry-After value from rate limit error"
300                        );
301                        retry_after_duration
302                    } else {
303                        // Use exponential backoff with jitter: 1s, 2s, 4s + 0-500ms
304                        let backoff_secs = 2_u64.pow(attempt.saturating_sub(1));
305                        let jitter_ms = fastrand::u64(0..500);
306                        std::time::Duration::from_millis(backoff_secs * 1000 + jitter_ms)
307                    };
308
309                    let error_msg = err.to_string();
310                    warn!(
311                        error = %error_msg,
312                        delay_secs = delay.as_secs(),
313                        attempt,
314                        max_attempts,
315                        "Retrying after error"
316                    );
317
318                    // Drop err before await to avoid holding non-Send value across await
319                    drop(err);
320                    tokio::time::sleep(delay).await;
321                }
322            }
323        };
324
325        // Record success in circuit breaker
326        if let Some(cb) = self.circuit_breaker() {
327            cb.record_success();
328        }
329
330        // Calculate duration (total time including any retries)
331        #[allow(clippy::cast_possible_truncation)]
332        let duration_ms = start.elapsed().as_millis() as u64;
333
334        // Build AI stats from usage info (trust API's cost field)
335        let (input_tokens, output_tokens, cost_usd) = if let Some(usage) = completion.usage {
336            (usage.prompt_tokens, usage.completion_tokens, usage.cost)
337        } else {
338            // If no usage info, default to 0
339            debug!("No usage information in API response");
340            (0, 0, None)
341        };
342
343        let ai_stats = AiStats {
344            provider: self.name().to_string(),
345            model: self.model().to_string(),
346            input_tokens,
347            output_tokens,
348            duration_ms,
349            cost_usd,
350            fallback_provider: None,
351        };
352
353        // Emit structured metrics
354        info!(
355            duration_ms,
356            input_tokens,
357            output_tokens,
358            cost_usd = ?cost_usd,
359            model = %self.model(),
360            "AI request completed"
361        );
362
363        Ok((parsed, ai_stats))
364    }
365
366    /// Analyzes a GitHub issue using the provider's API.
367    ///
368    /// Returns a structured triage response with summary, labels, questions, duplicates, and usage stats.
369    ///
370    /// # Arguments
371    ///
372    /// * `issue` - Issue details to analyze
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if:
377    /// - API request fails (network, timeout, rate limit)
378    /// - Response cannot be parsed as valid JSON
379    #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
380    async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
381        debug!(model = %self.model(), "Calling {} API", self.name());
382
383        // Build request
384        let system_content = if let Some(override_prompt) =
385            super::context::load_system_prompt_override("triage_system").await
386        {
387            override_prompt
388        } else {
389            Self::build_system_prompt(None)
390        };
391
392        let request = ChatCompletionRequest {
393            model: self.model().to_string(),
394            messages: vec![
395                ChatMessage {
396                    role: "system".to_string(),
397                    content: system_content,
398                },
399                ChatMessage {
400                    role: "user".to_string(),
401                    content: Self::build_user_prompt(issue),
402                },
403            ],
404            response_format: Some(ResponseFormat {
405                format_type: "json_object".to_string(),
406                json_schema: None,
407            }),
408            max_tokens: Some(self.max_tokens()),
409            temperature: Some(self.temperature()),
410        };
411
412        // Send request and parse JSON with retry logic
413        let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
414
415        debug!(
416            input_tokens = ai_stats.input_tokens,
417            output_tokens = ai_stats.output_tokens,
418            duration_ms = ai_stats.duration_ms,
419            cost_usd = ?ai_stats.cost_usd,
420            "AI analysis complete"
421        );
422
423        Ok(AiResponse {
424            triage,
425            stats: ai_stats,
426        })
427    }
428
429    /// Creates a formatted GitHub issue using the provider's API.
430    ///
431    /// Takes raw issue title and body, formats them using AI (conventional commit style,
432    /// structured body), and returns the formatted content with suggested labels.
433    ///
434    /// # Arguments
435    ///
436    /// * `title` - Raw issue title from user
437    /// * `body` - Raw issue body/description from user
438    /// * `repo` - Repository name for context (owner/repo format)
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if:
443    /// - API request fails (network, timeout, rate limit)
444    /// - Response cannot be parsed as valid JSON
445    #[instrument(skip(self), fields(repo = %repo))]
446    async fn create_issue(
447        &self,
448        title: &str,
449        body: &str,
450        repo: &str,
451    ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
452        debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
453
454        // Build request
455        let system_content = if let Some(override_prompt) =
456            super::context::load_system_prompt_override("create_system").await
457        {
458            override_prompt
459        } else {
460            Self::build_create_system_prompt(None)
461        };
462
463        let request = ChatCompletionRequest {
464            model: self.model().to_string(),
465            messages: vec![
466                ChatMessage {
467                    role: "system".to_string(),
468                    content: system_content,
469                },
470                ChatMessage {
471                    role: "user".to_string(),
472                    content: Self::build_create_user_prompt(title, body, repo),
473                },
474            ],
475            response_format: Some(ResponseFormat {
476                format_type: "json_object".to_string(),
477                json_schema: None,
478            }),
479            max_tokens: Some(self.max_tokens()),
480            temperature: Some(self.temperature()),
481        };
482
483        // Send request and parse JSON with retry logic
484        let (create_response, ai_stats) = self
485            .send_and_parse::<super::types::CreateIssueResponse>(&request)
486            .await?;
487
488        debug!(
489            title_len = create_response.formatted_title.len(),
490            body_len = create_response.formatted_body.len(),
491            labels = create_response.suggested_labels.len(),
492            input_tokens = ai_stats.input_tokens,
493            output_tokens = ai_stats.output_tokens,
494            duration_ms = ai_stats.duration_ms,
495            "Issue formatting complete with stats"
496        );
497
498        Ok((create_response, ai_stats))
499    }
500
501    /// Builds the system prompt for issue triage.
502    #[must_use]
503    fn build_system_prompt(custom_guidance: Option<&str>) -> String {
504        let context = super::context::load_custom_guidance(custom_guidance);
505        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}";
506        let guidelines = "Reason through each step before producing output.\n\n\
507Guidelines:\n\
508- summary: Concise explanation of the problem/request and why it matters\n\
509- 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\
510- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
511- 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\
512- 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\
513- 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\
514- 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\
515- 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\
516- 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\
517\n\
518Be helpful, concise, and actionable. Focus on what a maintainer needs to know.\n\
519\n\
520## Examples\n\
521\n\
522### Example 1 (happy path)\n\
523Input: Issue titled \"Add dark mode support\" with body describing a UI theme toggle request.\n\
524Output:\n\
525```json\n\
526{\n\
527  \"summary\": \"User requests dark mode support with a toggle in settings.\",\n\
528  \"suggested_labels\": [\"enhancement\", \"ui\"],\n\
529  \"clarifying_questions\": [\"Which components should be themed first?\"],\n\
530  \"potential_duplicates\": [],\n\
531  \"related_issues\": [],\n\
532  \"status_note\": \"Ready for design discussion\",\n\
533  \"contributor_guidance\": {\n\
534    \"beginner_friendly\": false,\n\
535    \"reasoning\": \"Requires understanding of the theme system and CSS. Could span multiple files.\"\n\
536  },\n\
537  \"implementation_approach\": \"Extend the existing ThemeProvider with a dark variant and persist preference to localStorage.\",\n\
538  \"suggested_milestone\": \"v2.0\"\n\
539}\n\
540```\n\
541\n\
542### Example 2 (edge case - vague report)\n\
543Input: Issue titled \"it broken\" with empty body.\n\
544Output:\n\
545```json\n\
546{\n\
547  \"summary\": \"Vague report with no reproduction steps or context.\",\n\
548  \"suggested_labels\": [\"needs-info\"],\n\
549  \"clarifying_questions\": [\"What is broken?\", \"Steps to reproduce?\", \"Expected vs actual behavior?\"],\n\
550  \"potential_duplicates\": [],\n\
551  \"related_issues\": [],\n\
552  \"status_note\": \"Blocked on clarification\",\n\
553  \"contributor_guidance\": {\n\
554    \"beginner_friendly\": false,\n\
555    \"reasoning\": \"Issue is too vague to assess or action without clarification.\"\n\
556  },\n\
557  \"implementation_approach\": \"\",\n\
558  \"suggested_milestone\": null\n\
559}\n\
560```";
561        format!(
562            "You are a senior OSS maintainer. Your mission is to produce structured triage output that helps maintainers prioritize and route incoming issues.\n\n{context}\n\nYour response MUST be valid JSON with this exact schema:\n{schema}\n\n{guidelines}"
563        )
564    }
565
566    /// Builds the user prompt containing the issue details.
567    #[must_use]
568    fn build_user_prompt(issue: &IssueDetails) -> String {
569        use std::fmt::Write;
570
571        let mut prompt = String::new();
572
573        prompt.push_str("<issue_content>\n");
574        let _ = writeln!(prompt, "Title: {}\n", issue.title);
575
576        // Truncate body if too long
577        let body = if issue.body.len() > MAX_BODY_LENGTH {
578            format!(
579                "{}...\n[Body truncated - original length: {} chars]",
580                &issue.body[..MAX_BODY_LENGTH],
581                issue.body.len()
582            )
583        } else if issue.body.is_empty() {
584            "[No description provided]".to_string()
585        } else {
586            issue.body.clone()
587        };
588        let _ = writeln!(prompt, "Body:\n{body}\n");
589
590        // Include existing labels
591        if !issue.labels.is_empty() {
592            let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
593        }
594
595        // Include recent comments (limited)
596        if !issue.comments.is_empty() {
597            prompt.push_str("Recent Comments:\n");
598            for comment in issue.comments.iter().take(MAX_COMMENTS) {
599                let comment_body = if comment.body.len() > 500 {
600                    format!("{}...", &comment.body[..500])
601                } else {
602                    comment.body.clone()
603                };
604                let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
605            }
606            prompt.push('\n');
607        }
608
609        // Include related issues from search (for context)
610        if !issue.repo_context.is_empty() {
611            prompt.push_str("Related Issues in Repository (for context):\n");
612            for related in issue.repo_context.iter().take(10) {
613                let _ = writeln!(
614                    prompt,
615                    "- #{} [{}] {}",
616                    related.number, related.state, related.title
617                );
618            }
619            prompt.push('\n');
620        }
621
622        // Include repository structure (source files)
623        if !issue.repo_tree.is_empty() {
624            prompt.push_str("Repository Structure (source files):\n");
625            for path in issue.repo_tree.iter().take(20) {
626                let _ = writeln!(prompt, "- {path}");
627            }
628            prompt.push('\n');
629        }
630
631        // Include available labels
632        if !issue.available_labels.is_empty() {
633            prompt.push_str("Available Labels:\n");
634            for label in issue.available_labels.iter().take(MAX_LABELS) {
635                let description = if label.description.is_empty() {
636                    String::new()
637                } else {
638                    format!(" - {}", label.description)
639                };
640                let _ = writeln!(
641                    prompt,
642                    "- {} (color: #{}){}",
643                    label.name, label.color, description
644                );
645            }
646            prompt.push('\n');
647        }
648
649        // Include available milestones
650        if !issue.available_milestones.is_empty() {
651            prompt.push_str("Available Milestones:\n");
652            for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
653                let description = if milestone.description.is_empty() {
654                    String::new()
655                } else {
656                    format!(" - {}", milestone.description)
657                };
658                let _ = writeln!(prompt, "- {}{}", milestone.title, description);
659            }
660            prompt.push('\n');
661        }
662
663        prompt.push_str("</issue_content>");
664
665        prompt
666    }
667
668    /// Builds the system prompt for issue creation/formatting.
669    #[must_use]
670    fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
671        let context = super::context::load_custom_guidance(custom_guidance);
672        format!(
673            "You are a senior developer advocate. Your mission is to produce a well-structured, professional GitHub issue from raw user input.\n\n\
674{context}\n\n\
675Your response MUST be valid JSON with this exact schema:\n\
676{{\n  \"formatted_title\": \"Well-formatted issue title following conventional commit style\",\n  \"formatted_body\": \"Professionally formatted issue body with clear sections\",\n  \"suggested_labels\": [\"label1\", \"label2\"]\n}}\n\n\
677Reason through each step before producing output.\n\n\
678Guidelines:\n\
679- 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.\n\
680- formatted_body: Structure the body with clear sections:\n  * Start with a brief 1-2 sentence summary if not already present\n  * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)\n  * Keep sentences clear and concise\n  * Use bullet points for lists\n  * Improve grammar and clarity\n  * Add relevant context if missing\n\
681- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.\n\n\
682Be professional but friendly. Maintain the user's intent while improving clarity and structure.\n\n\
683## Examples\n\n\
684### Example 1 (happy path)\n\
685Input: Title \"app crashes\", Body \"when i click login it crashes on android\"\n\
686Output:\n\
687```json\n\
688{{\n  \"formatted_title\": \"fix(auth): app crashes on login on Android\",\n  \"formatted_body\": \"## Description\\nThe app crashes when tapping the login button on Android.\\n\\n## Steps to Reproduce\\n1. Open the app on Android\\n2. Tap the login button\\n\\n## Expected Behavior\\nUser is authenticated and redirected to the home screen.\\n\\n## Actual Behavior\\nApp crashes immediately.\",\n  \"suggested_labels\": [\"bug\", \"android\", \"auth\"]\n}}\n\
689```\n\n\
690### Example 2 (edge case - already well-formatted)\n\
691Input: Title \"feat(api): add pagination to /users endpoint\", Body already has sections.\n\
692Output:\n\
693```json\n\
694{{\n  \"formatted_title\": \"feat(api): add pagination to /users endpoint\",\n  \"formatted_body\": \"## Description\\nAdd cursor-based pagination to the /users endpoint to support large datasets.\\n\\n## Motivation\\nThe endpoint currently returns all users at once, causing timeouts for large datasets.\",\n  \"suggested_labels\": [\"enhancement\", \"api\"]\n}}\n\
695```"
696        )
697    }
698
699    /// Builds the user prompt for issue creation/formatting.
700    #[must_use]
701    fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
702        format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
703    }
704
705    /// Reviews a pull request using the provider's API.
706    ///
707    /// Analyzes PR metadata and file diffs to provide structured review feedback.
708    ///
709    /// # Arguments
710    ///
711    /// * `pr` - Pull request details including files and diffs
712    ///
713    /// # Errors
714    ///
715    /// Returns an error if:
716    /// - API request fails (network, timeout, rate limit)
717    /// - Response cannot be parsed as valid JSON
718    #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
719    async fn review_pr(
720        &self,
721        pr: &super::types::PrDetails,
722    ) -> Result<(super::types::PrReviewResponse, AiStats)> {
723        debug!(model = %self.model(), "Calling {} API for PR review", self.name());
724
725        // Build request
726        let system_content = if let Some(override_prompt) =
727            super::context::load_system_prompt_override("pr_review_system").await
728        {
729            override_prompt
730        } else {
731            Self::build_pr_review_system_prompt(None)
732        };
733
734        let request = ChatCompletionRequest {
735            model: self.model().to_string(),
736            messages: vec![
737                ChatMessage {
738                    role: "system".to_string(),
739                    content: system_content,
740                },
741                ChatMessage {
742                    role: "user".to_string(),
743                    content: Self::build_pr_review_user_prompt(pr),
744                },
745            ],
746            response_format: Some(ResponseFormat {
747                format_type: "json_object".to_string(),
748                json_schema: None,
749            }),
750            max_tokens: Some(self.max_tokens()),
751            temperature: Some(self.temperature()),
752        };
753
754        // Send request and parse JSON with retry logic
755        let (review, ai_stats) = self
756            .send_and_parse::<super::types::PrReviewResponse>(&request)
757            .await?;
758
759        debug!(
760            verdict = %review.verdict,
761            input_tokens = ai_stats.input_tokens,
762            output_tokens = ai_stats.output_tokens,
763            duration_ms = ai_stats.duration_ms,
764            "PR review complete with stats"
765        );
766
767        Ok((review, ai_stats))
768    }
769
770    /// Suggests labels for a pull request using the provider's API.
771    ///
772    /// Analyzes PR title, body, and file paths to suggest relevant labels.
773    ///
774    /// # Arguments
775    ///
776    /// * `title` - Pull request title
777    /// * `body` - Pull request description
778    /// * `file_paths` - List of file paths changed in the PR
779    ///
780    /// # Errors
781    ///
782    /// Returns an error if:
783    /// - API request fails (network, timeout, rate limit)
784    /// - Response cannot be parsed as valid JSON
785    #[instrument(skip(self), fields(title = %title))]
786    async fn suggest_pr_labels(
787        &self,
788        title: &str,
789        body: &str,
790        file_paths: &[String],
791    ) -> Result<(Vec<String>, AiStats)> {
792        debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
793
794        // Build request
795        let system_content = if let Some(override_prompt) =
796            super::context::load_system_prompt_override("pr_label_system").await
797        {
798            override_prompt
799        } else {
800            Self::build_pr_label_system_prompt(None)
801        };
802
803        let request = ChatCompletionRequest {
804            model: self.model().to_string(),
805            messages: vec![
806                ChatMessage {
807                    role: "system".to_string(),
808                    content: system_content,
809                },
810                ChatMessage {
811                    role: "user".to_string(),
812                    content: Self::build_pr_label_user_prompt(title, body, file_paths),
813                },
814            ],
815            response_format: Some(ResponseFormat {
816                format_type: "json_object".to_string(),
817                json_schema: None,
818            }),
819            max_tokens: Some(self.max_tokens()),
820            temperature: Some(self.temperature()),
821        };
822
823        // Send request and parse JSON with retry logic
824        let (response, ai_stats) = self
825            .send_and_parse::<super::types::PrLabelResponse>(&request)
826            .await?;
827
828        debug!(
829            label_count = response.suggested_labels.len(),
830            input_tokens = ai_stats.input_tokens,
831            output_tokens = ai_stats.output_tokens,
832            duration_ms = ai_stats.duration_ms,
833            "PR label suggestion complete with stats"
834        );
835
836        Ok((response.suggested_labels, ai_stats))
837    }
838
839    /// Builds the system prompt for PR review.
840    #[must_use]
841    fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
842        let context = super::context::load_custom_guidance(custom_guidance);
843        format!(
844            "You are a senior software engineer. Your mission is to produce structured, actionable review feedback on a pull request.\n\n\
845{context}\n\n\
846Your response MUST be valid JSON with this exact schema:\n\
847{{\n  \"summary\": \"A 2-3 sentence summary of what the PR does and its impact\",\n  \"verdict\": \"approve|request_changes|comment\",\n  \"strengths\": [\"strength1\", \"strength2\"],\n  \"concerns\": [\"concern1\", \"concern2\"],\n  \"comments\": [\n    {{\n      \"file\": \"path/to/file.rs\",\n      \"line\": 42,\n      \"comment\": \"Specific feedback about this line\",\n      \"severity\": \"info|suggestion|warning|issue\"\n    }}\n  ],\n  \"suggestions\": [\"suggestion1\", \"suggestion2\"],\n  \"disclaimer\": null\n}}\n\n\
848Reason through each step before producing output.\n\n\
849Guidelines:\n\
850- summary: Concise explanation of the changes and their purpose\n\
851- verdict: Use \"approve\" for good PRs, \"request_changes\" for blocking issues, \"comment\" for feedback without blocking\n\
852- strengths: What the PR does well (good patterns, clear code, etc.)\n\
853- concerns: Potential issues or risks (bugs, performance, security, maintainability)\n\
854- comments: Specific line-level feedback. Use severity:\n  - \"info\": Informational, no action needed\n  - \"suggestion\": Optional improvement\n  - \"warning\": Should consider changing\n  - \"issue\": Should be fixed before merge\n\
855- suggestions: General improvements that are not blocking\n\
856- disclaimer: Optional field. If the PR involves platform versions (iOS, Android, Node, Rust, Python, Java, etc.), include a disclaimer explaining that platform version validation may be inaccurate due to knowledge cutoffs. Otherwise, set to null.\n\n\
857IMPORTANT - Platform Version Exclusions:\n\
858DO NOT validate or flag platform versions (iOS, Android, Node, Rust, Python, Java, simulator availability, package versions, framework versions) as concerns or issues. These may be newer than your knowledge cutoff and flagging them creates false positives. If the PR involves platform versions, include a disclaimer field explaining that platform version validation was skipped due to knowledge cutoff limitations. Focus your review on code logic, patterns, and structure instead.\n\n\
859Focus on:\n\
8601. Correctness: Does the code do what it claims?\n\
8612. Security: Any potential vulnerabilities?\n\
8623. Performance: Any obvious inefficiencies?\n\
8634. Maintainability: Is the code clear and well-structured?\n\
8645. Testing: Are changes adequately tested?\n\n\
865Be constructive and specific. Explain why something is an issue and how to fix it.\n\n\
866## Examples\n\n\
867### Example 1 (happy path)\n\
868Input: PR adds a retry helper with tests.\n\
869Output:\n\
870```json\n\
871{{\n  \"summary\": \"Adds an exponential-backoff retry helper with unit tests.\",\n  \"verdict\": \"approve\",\n  \"strengths\": [\"Well-tested with happy and error paths\", \"Follows existing error handling patterns\"],\n  \"concerns\": [],\n  \"comments\": [],\n  \"suggestions\": [\"Consider adding a jitter parameter to reduce thundering-herd effects.\"],\n  \"disclaimer\": null\n}}\n\
872```\n\n\
873### Example 2 (edge case - missing error handling)\n\
874Input: PR adds a file parser that uses unwrap().\n\
875Output:\n\
876```json\n\
877{{\n  \"summary\": \"Adds a CSV parser but uses unwrap() on file reads.\",\n  \"verdict\": \"request_changes\",\n  \"strengths\": [\"Covers the happy path\"],\n  \"concerns\": [\"unwrap() on file open will panic on missing files\"],\n  \"comments\": [{{\"file\": \"src/parser.rs\", \"line\": 42, \"severity\": \"high\", \"comment\": \"Replace unwrap() with proper error propagation using ?\"}}],\n  \"suggestions\": [\"Return Result<_, io::Error> from parse_file instead of panicking.\"],\n  \"disclaimer\": null\n}}\n\
878```"
879        )
880    }
881
882    /// Builds the user prompt for PR review.
883    #[must_use]
884    fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
885        use std::fmt::Write;
886
887        let mut prompt = String::new();
888
889        prompt.push_str("<pull_request>\n");
890        let _ = writeln!(prompt, "Title: {}\n", pr.title);
891        let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
892
893        // PR description
894        let body = if pr.body.is_empty() {
895            "[No description provided]".to_string()
896        } else if pr.body.len() > MAX_BODY_LENGTH {
897            format!(
898                "{}...\n[Description truncated - original length: {} chars]",
899                &pr.body[..MAX_BODY_LENGTH],
900                pr.body.len()
901            )
902        } else {
903            pr.body.clone()
904        };
905        let _ = writeln!(prompt, "Description:\n{body}\n");
906
907        // File changes with limits
908        prompt.push_str("Files Changed:\n");
909        let mut total_diff_size = 0;
910        let mut files_included = 0;
911        let mut files_skipped = 0;
912
913        for file in &pr.files {
914            // Check file count limit
915            if files_included >= MAX_FILES {
916                files_skipped += 1;
917                continue;
918            }
919
920            let _ = writeln!(
921                prompt,
922                "- {} ({}) +{} -{}\n",
923                file.filename, file.status, file.additions, file.deletions
924            );
925
926            // Include patch if available (truncate large patches)
927            if let Some(patch) = &file.patch {
928                const MAX_PATCH_LENGTH: usize = 2000;
929                let patch_content = if patch.len() > MAX_PATCH_LENGTH {
930                    format!(
931                        "{}...\n[Patch truncated - original length: {} chars]",
932                        &patch[..MAX_PATCH_LENGTH],
933                        patch.len()
934                    )
935                } else {
936                    patch.clone()
937                };
938
939                // Check if adding this patch would exceed total diff size limit
940                let patch_size = patch_content.len();
941                if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
942                    let _ = writeln!(
943                        prompt,
944                        "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
945                    );
946                    files_skipped += 1;
947                    continue;
948                }
949
950                let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
951                total_diff_size += patch_size;
952            }
953
954            files_included += 1;
955        }
956
957        // Add truncation message if files were skipped
958        if files_skipped > 0 {
959            let _ = writeln!(
960                prompt,
961                "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
962            );
963        }
964
965        prompt.push_str("</pull_request>");
966
967        prompt
968    }
969
970    /// Builds the system prompt for PR label suggestion.
971    #[must_use]
972    fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
973        let context = super::context::load_custom_guidance(custom_guidance);
974        format!(
975            r#"You are a senior open-source maintainer. Your mission is to suggest the most relevant labels for a pull request based on its content.
976
977{context}
978
979Your response MUST be valid JSON with this exact schema:
980{{
981  "suggested_labels": ["label1", "label2", "label3"]
982}}
983
984Response format: json_object
985
986Reason through each step before producing output.
987
988Guidelines:
989- 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.
990- Focus on the PR title, description, and file paths to determine appropriate labels.
991- Prefer specific labels over generic ones when possible.
992- Only suggest labels that are commonly used in GitHub repositories.
993
994Be concise and practical.
995
996## Examples
997
998### Example 1 (happy path)
999Input: PR adds OAuth2 login flow with tests.
1000Output:
1001```json
1002{{"suggested_labels": ["feature", "auth", "security"]}}
1003```
1004
1005### Example 2 (edge case - documentation only PR)
1006Input: PR fixes typos in README.
1007Output:
1008```json
1009{{"suggested_labels": ["documentation"]}}
1010```"#
1011        )
1012    }
1013
1014    /// Builds the user prompt for PR label suggestion.
1015    #[must_use]
1016    fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
1017        use std::fmt::Write;
1018
1019        let mut prompt = String::new();
1020
1021        prompt.push_str("<pull_request>\n");
1022        let _ = writeln!(prompt, "Title: {title}\n");
1023
1024        // PR description
1025        let body_content = if body.is_empty() {
1026            "[No description provided]".to_string()
1027        } else if body.len() > MAX_BODY_LENGTH {
1028            format!(
1029                "{}...\n[Description truncated - original length: {} chars]",
1030                &body[..MAX_BODY_LENGTH],
1031                body.len()
1032            )
1033        } else {
1034            body.to_string()
1035        };
1036        let _ = writeln!(prompt, "Description:\n{body_content}\n");
1037
1038        // File paths
1039        if !file_paths.is_empty() {
1040            prompt.push_str("Files Changed:\n");
1041            for path in file_paths.iter().take(20) {
1042                let _ = writeln!(prompt, "- {path}");
1043            }
1044            if file_paths.len() > 20 {
1045                let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
1046            }
1047            prompt.push('\n');
1048        }
1049
1050        prompt.push_str("</pull_request>");
1051
1052        prompt
1053    }
1054
1055    /// Generate release notes from PR summaries.
1056    ///
1057    /// # Arguments
1058    ///
1059    /// * `prs` - List of PR summaries to synthesize
1060    /// * `version` - Version being released
1061    ///
1062    /// # Returns
1063    ///
1064    /// Structured release notes with theme, highlights, and categorized changes.
1065    #[instrument(skip(self, prs))]
1066    async fn generate_release_notes(
1067        &self,
1068        prs: Vec<super::types::PrSummary>,
1069        version: &str,
1070    ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
1071        let prompt = Self::build_release_notes_prompt(&prs, version);
1072        let request = ChatCompletionRequest {
1073            model: self.model().to_string(),
1074            messages: vec![ChatMessage {
1075                role: "user".to_string(),
1076                content: prompt,
1077            }],
1078            response_format: Some(ResponseFormat {
1079                format_type: "json_object".to_string(),
1080                json_schema: None,
1081            }),
1082            temperature: Some(0.7),
1083            max_tokens: Some(self.max_tokens()),
1084        };
1085
1086        let (parsed, ai_stats) = self
1087            .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
1088            .await?;
1089
1090        debug!(
1091            input_tokens = ai_stats.input_tokens,
1092            output_tokens = ai_stats.output_tokens,
1093            duration_ms = ai_stats.duration_ms,
1094            "Release notes generation complete with stats"
1095        );
1096
1097        Ok((parsed, ai_stats))
1098    }
1099
1100    /// Build the user prompt for release notes generation.
1101    #[must_use]
1102    fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
1103        let pr_list = prs
1104            .iter()
1105            .map(|pr| {
1106                format!(
1107                    "- #{}: {} (by @{})\n  {}",
1108                    pr.number,
1109                    pr.title,
1110                    pr.author,
1111                    pr.body.lines().next().unwrap_or("")
1112                )
1113            })
1114            .collect::<Vec<_>>()
1115            .join("\n");
1116
1117        format!(
1118            r#"Generate release notes for version {version} based on these merged PRs:
1119
1120{pr_list}
1121
1122Create a curated release notes document with:
11231. A theme/title that captures the essence of this release
11242. A 1-2 sentence narrative about the release
11253. 3-5 highlighted features
11264. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
11275. List of contributors
1128
1129Follow these conventions:
1130- No emojis
1131- Bold feature names with dash separator
1132- Include PR numbers in parentheses
1133- Group by user impact, not just commit type
1134- Filter CI/deps under Maintenance
1135
1136Your response MUST be valid JSON with this exact schema:
1137{{
1138  "theme": "Release theme title",
1139  "narrative": "1-2 sentence summary",
1140  "highlights": ["highlight1", "highlight2"],
1141  "features": ["feature1", "feature2"],
1142  "fixes": ["fix1", "fix2"],
1143  "improvements": ["improvement1"],
1144  "documentation": ["doc change1"],
1145  "maintenance": ["maintenance1"],
1146  "contributors": ["@author1", "@author2"]
1147}}"#
1148        )
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    struct TestProvider;
1157
1158    impl AiProvider for TestProvider {
1159        fn name(&self) -> &'static str {
1160            "test"
1161        }
1162
1163        fn api_url(&self) -> &'static str {
1164            "https://test.example.com"
1165        }
1166
1167        fn api_key_env(&self) -> &'static str {
1168            "TEST_API_KEY"
1169        }
1170
1171        fn http_client(&self) -> &Client {
1172            unimplemented!()
1173        }
1174
1175        fn api_key(&self) -> &SecretString {
1176            unimplemented!()
1177        }
1178
1179        fn model(&self) -> &'static str {
1180            "test-model"
1181        }
1182
1183        fn max_tokens(&self) -> u32 {
1184            2048
1185        }
1186
1187        fn temperature(&self) -> f32 {
1188            0.3
1189        }
1190    }
1191
1192    #[test]
1193    fn test_build_system_prompt_contains_json_schema() {
1194        let prompt = TestProvider::build_system_prompt(None);
1195        assert!(prompt.contains("summary"));
1196        assert!(prompt.contains("suggested_labels"));
1197        assert!(prompt.contains("clarifying_questions"));
1198        assert!(prompt.contains("potential_duplicates"));
1199        assert!(prompt.contains("status_note"));
1200    }
1201
1202    #[test]
1203    fn test_build_user_prompt_with_delimiters() {
1204        let issue = IssueDetails::builder()
1205            .owner("test".to_string())
1206            .repo("repo".to_string())
1207            .number(1)
1208            .title("Test issue".to_string())
1209            .body("This is the body".to_string())
1210            .labels(vec!["bug".to_string()])
1211            .comments(vec![])
1212            .url("https://github.com/test/repo/issues/1".to_string())
1213            .build();
1214
1215        let prompt = TestProvider::build_user_prompt(&issue);
1216        assert!(prompt.starts_with("<issue_content>"));
1217        assert!(prompt.ends_with("</issue_content>"));
1218        assert!(prompt.contains("Title: Test issue"));
1219        assert!(prompt.contains("This is the body"));
1220        assert!(prompt.contains("Existing Labels: bug"));
1221    }
1222
1223    #[test]
1224    fn test_build_user_prompt_truncates_long_body() {
1225        let long_body = "x".repeat(5000);
1226        let issue = IssueDetails::builder()
1227            .owner("test".to_string())
1228            .repo("repo".to_string())
1229            .number(1)
1230            .title("Test".to_string())
1231            .body(long_body)
1232            .labels(vec![])
1233            .comments(vec![])
1234            .url("https://github.com/test/repo/issues/1".to_string())
1235            .build();
1236
1237        let prompt = TestProvider::build_user_prompt(&issue);
1238        assert!(prompt.contains("[Body truncated"));
1239        assert!(prompt.contains("5000 chars"));
1240    }
1241
1242    #[test]
1243    fn test_build_user_prompt_empty_body() {
1244        let issue = IssueDetails::builder()
1245            .owner("test".to_string())
1246            .repo("repo".to_string())
1247            .number(1)
1248            .title("Test".to_string())
1249            .body(String::new())
1250            .labels(vec![])
1251            .comments(vec![])
1252            .url("https://github.com/test/repo/issues/1".to_string())
1253            .build();
1254
1255        let prompt = TestProvider::build_user_prompt(&issue);
1256        assert!(prompt.contains("[No description provided]"));
1257    }
1258
1259    #[test]
1260    fn test_build_create_system_prompt_contains_json_schema() {
1261        let prompt = TestProvider::build_create_system_prompt(None);
1262        assert!(prompt.contains("formatted_title"));
1263        assert!(prompt.contains("formatted_body"));
1264        assert!(prompt.contains("suggested_labels"));
1265    }
1266
1267    #[test]
1268    fn test_build_pr_review_user_prompt_respects_file_limit() {
1269        use super::super::types::{PrDetails, PrFile};
1270
1271        let mut files = Vec::new();
1272        for i in 0..25 {
1273            files.push(PrFile {
1274                filename: format!("file{i}.rs"),
1275                status: "modified".to_string(),
1276                additions: 10,
1277                deletions: 5,
1278                patch: Some(format!("patch content {i}")),
1279            });
1280        }
1281
1282        let pr = PrDetails {
1283            owner: "test".to_string(),
1284            repo: "repo".to_string(),
1285            number: 1,
1286            title: "Test PR".to_string(),
1287            body: "Description".to_string(),
1288            head_branch: "feature".to_string(),
1289            base_branch: "main".to_string(),
1290            url: "https://github.com/test/repo/pull/1".to_string(),
1291            files,
1292            labels: vec![],
1293        };
1294
1295        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1296        assert!(prompt.contains("files omitted due to size limits"));
1297        assert!(prompt.contains("MAX_FILES=20"));
1298    }
1299
1300    #[test]
1301    fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1302        use super::super::types::{PrDetails, PrFile};
1303
1304        // Create patches that will exceed the limit when combined
1305        // Each patch is ~30KB, so two will exceed 50KB limit
1306        let patch1 = "x".repeat(30_000);
1307        let patch2 = "y".repeat(30_000);
1308
1309        let files = vec![
1310            PrFile {
1311                filename: "file1.rs".to_string(),
1312                status: "modified".to_string(),
1313                additions: 100,
1314                deletions: 50,
1315                patch: Some(patch1),
1316            },
1317            PrFile {
1318                filename: "file2.rs".to_string(),
1319                status: "modified".to_string(),
1320                additions: 100,
1321                deletions: 50,
1322                patch: Some(patch2),
1323            },
1324        ];
1325
1326        let pr = PrDetails {
1327            owner: "test".to_string(),
1328            repo: "repo".to_string(),
1329            number: 1,
1330            title: "Test PR".to_string(),
1331            body: "Description".to_string(),
1332            head_branch: "feature".to_string(),
1333            base_branch: "main".to_string(),
1334            url: "https://github.com/test/repo/pull/1".to_string(),
1335            files,
1336            labels: vec![],
1337        };
1338
1339        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1340        // Both files should be listed
1341        assert!(prompt.contains("file1.rs"));
1342        assert!(prompt.contains("file2.rs"));
1343        // The second patch should be limited - verify the prompt doesn't contain both full patches
1344        // by checking that the total size is less than what two full 30KB patches would be
1345        assert!(prompt.len() < 65_000);
1346    }
1347
1348    #[test]
1349    fn test_build_pr_review_user_prompt_with_no_patches() {
1350        use super::super::types::{PrDetails, PrFile};
1351
1352        let files = vec![PrFile {
1353            filename: "file1.rs".to_string(),
1354            status: "added".to_string(),
1355            additions: 10,
1356            deletions: 0,
1357            patch: None,
1358        }];
1359
1360        let pr = PrDetails {
1361            owner: "test".to_string(),
1362            repo: "repo".to_string(),
1363            number: 1,
1364            title: "Test PR".to_string(),
1365            body: "Description".to_string(),
1366            head_branch: "feature".to_string(),
1367            base_branch: "main".to_string(),
1368            url: "https://github.com/test/repo/pull/1".to_string(),
1369            files,
1370            labels: vec![],
1371        };
1372
1373        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1374        assert!(prompt.contains("file1.rs"));
1375        assert!(prompt.contains("added"));
1376        assert!(!prompt.contains("files omitted"));
1377    }
1378
1379    #[test]
1380    fn test_build_pr_label_system_prompt_contains_json_schema() {
1381        let prompt = TestProvider::build_pr_label_system_prompt(None);
1382        assert!(prompt.contains("suggested_labels"));
1383        assert!(prompt.contains("json_object"));
1384        assert!(prompt.contains("bug"));
1385        assert!(prompt.contains("enhancement"));
1386    }
1387
1388    #[test]
1389    fn test_build_pr_label_user_prompt_with_title_and_body() {
1390        let title = "feat: add new feature";
1391        let body = "This PR adds a new feature";
1392        let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1393
1394        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1395        assert!(prompt.starts_with("<pull_request>"));
1396        assert!(prompt.ends_with("</pull_request>"));
1397        assert!(prompt.contains("feat: add new feature"));
1398        assert!(prompt.contains("This PR adds a new feature"));
1399        assert!(prompt.contains("src/main.rs"));
1400        assert!(prompt.contains("tests/test.rs"));
1401    }
1402
1403    #[test]
1404    fn test_build_pr_label_user_prompt_empty_body() {
1405        let title = "fix: bug fix";
1406        let body = "";
1407        let files = vec!["src/lib.rs".to_string()];
1408
1409        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1410        assert!(prompt.contains("[No description provided]"));
1411        assert!(prompt.contains("src/lib.rs"));
1412    }
1413
1414    #[test]
1415    fn test_build_pr_label_user_prompt_truncates_long_body() {
1416        let title = "test";
1417        let long_body = "x".repeat(5000);
1418        let files = vec![];
1419
1420        let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1421        assert!(prompt.contains("[Description truncated"));
1422        assert!(prompt.contains("5000 chars"));
1423    }
1424
1425    #[test]
1426    fn test_build_pr_label_user_prompt_respects_file_limit() {
1427        let title = "test";
1428        let body = "test";
1429        let mut files = Vec::new();
1430        for i in 0..25 {
1431            files.push(format!("file{i}.rs"));
1432        }
1433
1434        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1435        assert!(prompt.contains("file0.rs"));
1436        assert!(prompt.contains("file19.rs"));
1437        assert!(!prompt.contains("file20.rs"));
1438        assert!(prompt.contains("... and 5 more files"));
1439    }
1440
1441    #[test]
1442    fn test_build_pr_label_user_prompt_empty_files() {
1443        let title = "test";
1444        let body = "test";
1445        let files: Vec<String> = vec![];
1446
1447        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1448        assert!(prompt.contains("Title: test"));
1449        assert!(prompt.contains("Description:\ntest"));
1450        assert!(!prompt.contains("Files Changed:"));
1451    }
1452
1453    #[test]
1454    fn test_parse_ai_json_with_valid_json() {
1455        #[derive(serde::Deserialize)]
1456        struct TestResponse {
1457            message: String,
1458        }
1459
1460        let json = r#"{"message": "hello"}"#;
1461        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1462        assert!(result.is_ok());
1463        let response = result.unwrap();
1464        assert_eq!(response.message, "hello");
1465    }
1466
1467    #[test]
1468    fn test_parse_ai_json_with_truncated_json() {
1469        #[derive(Debug, serde::Deserialize)]
1470        #[allow(dead_code)]
1471        struct TestResponse {
1472            message: String,
1473        }
1474
1475        let json = r#"{"message": "hello"#;
1476        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1477        assert!(result.is_err());
1478        let err = result.unwrap_err();
1479        assert!(
1480            err.to_string()
1481                .contains("Truncated response from test-provider")
1482        );
1483    }
1484
1485    #[test]
1486    fn test_parse_ai_json_with_malformed_json() {
1487        #[derive(Debug, serde::Deserialize)]
1488        #[allow(dead_code)]
1489        struct TestResponse {
1490            message: String,
1491        }
1492
1493        let json = r#"{"message": invalid}"#;
1494        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1495        assert!(result.is_err());
1496        let err = result.unwrap_err();
1497        assert!(err.to_string().contains("Invalid JSON response from AI"));
1498    }
1499
1500    #[test]
1501    fn test_build_system_prompt_has_senior_persona() {
1502        let prompt = TestProvider::build_system_prompt(None);
1503        assert!(
1504            prompt.contains("You are a senior"),
1505            "prompt should have senior persona"
1506        );
1507        assert!(
1508            prompt.contains("Your mission is"),
1509            "prompt should have mission statement"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_build_system_prompt_has_cot_directive() {
1515        let prompt = TestProvider::build_system_prompt(None);
1516        assert!(prompt.contains("Reason through each step before producing output."));
1517    }
1518
1519    #[test]
1520    fn test_build_system_prompt_has_examples_section() {
1521        let prompt = TestProvider::build_system_prompt(None);
1522        assert!(prompt.contains("## Examples"));
1523    }
1524
1525    #[test]
1526    fn test_build_create_system_prompt_has_senior_persona() {
1527        let prompt = TestProvider::build_create_system_prompt(None);
1528        assert!(
1529            prompt.contains("You are a senior"),
1530            "prompt should have senior persona"
1531        );
1532        assert!(
1533            prompt.contains("Your mission is"),
1534            "prompt should have mission statement"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_build_pr_review_system_prompt_has_senior_persona() {
1540        let prompt = TestProvider::build_pr_review_system_prompt(None);
1541        assert!(
1542            prompt.contains("You are a senior"),
1543            "prompt should have senior persona"
1544        );
1545        assert!(
1546            prompt.contains("Your mission is"),
1547            "prompt should have mission statement"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_build_pr_label_system_prompt_has_senior_persona() {
1553        let prompt = TestProvider::build_pr_label_system_prompt(None);
1554        assert!(
1555            prompt.contains("You are a senior"),
1556            "prompt should have senior persona"
1557        );
1558        assert!(
1559            prompt.contains("Your mission is"),
1560            "prompt should have mission statement"
1561        );
1562    }
1563
1564    #[tokio::test]
1565    async fn test_load_system_prompt_override_returns_none_when_absent() {
1566        let result =
1567            super::super::context::load_system_prompt_override("__nonexistent_test_override__")
1568                .await;
1569        assert!(result.is_none());
1570    }
1571
1572    #[tokio::test]
1573    async fn test_load_system_prompt_override_returns_content_when_present() {
1574        use std::io::Write;
1575        let dir = tempfile::tempdir().expect("create tempdir");
1576        let file_path = dir.path().join("test_override.md");
1577        let mut f = std::fs::File::create(&file_path).expect("create file");
1578        writeln!(f, "Custom override content").expect("write file");
1579        drop(f);
1580
1581        let content = tokio::fs::read_to_string(&file_path).await.ok();
1582        assert_eq!(content.as_deref(), Some("Custom override content\n"));
1583    }
1584}