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            head_sha: String::new(),
1294        };
1295
1296        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1297        assert!(prompt.contains("files omitted due to size limits"));
1298        assert!(prompt.contains("MAX_FILES=20"));
1299    }
1300
1301    #[test]
1302    fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1303        use super::super::types::{PrDetails, PrFile};
1304
1305        // Create patches that will exceed the limit when combined
1306        // Each patch is ~30KB, so two will exceed 50KB limit
1307        let patch1 = "x".repeat(30_000);
1308        let patch2 = "y".repeat(30_000);
1309
1310        let files = vec![
1311            PrFile {
1312                filename: "file1.rs".to_string(),
1313                status: "modified".to_string(),
1314                additions: 100,
1315                deletions: 50,
1316                patch: Some(patch1),
1317            },
1318            PrFile {
1319                filename: "file2.rs".to_string(),
1320                status: "modified".to_string(),
1321                additions: 100,
1322                deletions: 50,
1323                patch: Some(patch2),
1324            },
1325        ];
1326
1327        let pr = PrDetails {
1328            owner: "test".to_string(),
1329            repo: "repo".to_string(),
1330            number: 1,
1331            title: "Test PR".to_string(),
1332            body: "Description".to_string(),
1333            head_branch: "feature".to_string(),
1334            base_branch: "main".to_string(),
1335            url: "https://github.com/test/repo/pull/1".to_string(),
1336            files,
1337            labels: vec![],
1338            head_sha: String::new(),
1339        };
1340
1341        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1342        // Both files should be listed
1343        assert!(prompt.contains("file1.rs"));
1344        assert!(prompt.contains("file2.rs"));
1345        // The second patch should be limited - verify the prompt doesn't contain both full patches
1346        // by checking that the total size is less than what two full 30KB patches would be
1347        assert!(prompt.len() < 65_000);
1348    }
1349
1350    #[test]
1351    fn test_build_pr_review_user_prompt_with_no_patches() {
1352        use super::super::types::{PrDetails, PrFile};
1353
1354        let files = vec![PrFile {
1355            filename: "file1.rs".to_string(),
1356            status: "added".to_string(),
1357            additions: 10,
1358            deletions: 0,
1359            patch: None,
1360        }];
1361
1362        let pr = PrDetails {
1363            owner: "test".to_string(),
1364            repo: "repo".to_string(),
1365            number: 1,
1366            title: "Test PR".to_string(),
1367            body: "Description".to_string(),
1368            head_branch: "feature".to_string(),
1369            base_branch: "main".to_string(),
1370            url: "https://github.com/test/repo/pull/1".to_string(),
1371            files,
1372            labels: vec![],
1373            head_sha: String::new(),
1374        };
1375
1376        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1377        assert!(prompt.contains("file1.rs"));
1378        assert!(prompt.contains("added"));
1379        assert!(!prompt.contains("files omitted"));
1380    }
1381
1382    #[test]
1383    fn test_build_pr_label_system_prompt_contains_json_schema() {
1384        let prompt = TestProvider::build_pr_label_system_prompt(None);
1385        assert!(prompt.contains("suggested_labels"));
1386        assert!(prompt.contains("json_object"));
1387        assert!(prompt.contains("bug"));
1388        assert!(prompt.contains("enhancement"));
1389    }
1390
1391    #[test]
1392    fn test_build_pr_label_user_prompt_with_title_and_body() {
1393        let title = "feat: add new feature";
1394        let body = "This PR adds a new feature";
1395        let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1396
1397        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1398        assert!(prompt.starts_with("<pull_request>"));
1399        assert!(prompt.ends_with("</pull_request>"));
1400        assert!(prompt.contains("feat: add new feature"));
1401        assert!(prompt.contains("This PR adds a new feature"));
1402        assert!(prompt.contains("src/main.rs"));
1403        assert!(prompt.contains("tests/test.rs"));
1404    }
1405
1406    #[test]
1407    fn test_build_pr_label_user_prompt_empty_body() {
1408        let title = "fix: bug fix";
1409        let body = "";
1410        let files = vec!["src/lib.rs".to_string()];
1411
1412        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1413        assert!(prompt.contains("[No description provided]"));
1414        assert!(prompt.contains("src/lib.rs"));
1415    }
1416
1417    #[test]
1418    fn test_build_pr_label_user_prompt_truncates_long_body() {
1419        let title = "test";
1420        let long_body = "x".repeat(5000);
1421        let files = vec![];
1422
1423        let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1424        assert!(prompt.contains("[Description truncated"));
1425        assert!(prompt.contains("5000 chars"));
1426    }
1427
1428    #[test]
1429    fn test_build_pr_label_user_prompt_respects_file_limit() {
1430        let title = "test";
1431        let body = "test";
1432        let mut files = Vec::new();
1433        for i in 0..25 {
1434            files.push(format!("file{i}.rs"));
1435        }
1436
1437        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1438        assert!(prompt.contains("file0.rs"));
1439        assert!(prompt.contains("file19.rs"));
1440        assert!(!prompt.contains("file20.rs"));
1441        assert!(prompt.contains("... and 5 more files"));
1442    }
1443
1444    #[test]
1445    fn test_build_pr_label_user_prompt_empty_files() {
1446        let title = "test";
1447        let body = "test";
1448        let files: Vec<String> = vec![];
1449
1450        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1451        assert!(prompt.contains("Title: test"));
1452        assert!(prompt.contains("Description:\ntest"));
1453        assert!(!prompt.contains("Files Changed:"));
1454    }
1455
1456    #[test]
1457    fn test_parse_ai_json_with_valid_json() {
1458        #[derive(serde::Deserialize)]
1459        struct TestResponse {
1460            message: String,
1461        }
1462
1463        let json = r#"{"message": "hello"}"#;
1464        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1465        assert!(result.is_ok());
1466        let response = result.unwrap();
1467        assert_eq!(response.message, "hello");
1468    }
1469
1470    #[test]
1471    fn test_parse_ai_json_with_truncated_json() {
1472        #[derive(Debug, serde::Deserialize)]
1473        #[allow(dead_code)]
1474        struct TestResponse {
1475            message: String,
1476        }
1477
1478        let json = r#"{"message": "hello"#;
1479        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1480        assert!(result.is_err());
1481        let err = result.unwrap_err();
1482        assert!(
1483            err.to_string()
1484                .contains("Truncated response from test-provider")
1485        );
1486    }
1487
1488    #[test]
1489    fn test_parse_ai_json_with_malformed_json() {
1490        #[derive(Debug, serde::Deserialize)]
1491        #[allow(dead_code)]
1492        struct TestResponse {
1493            message: String,
1494        }
1495
1496        let json = r#"{"message": invalid}"#;
1497        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1498        assert!(result.is_err());
1499        let err = result.unwrap_err();
1500        assert!(err.to_string().contains("Invalid JSON response from AI"));
1501    }
1502
1503    #[test]
1504    fn test_build_system_prompt_has_senior_persona() {
1505        let prompt = TestProvider::build_system_prompt(None);
1506        assert!(
1507            prompt.contains("You are a senior"),
1508            "prompt should have senior persona"
1509        );
1510        assert!(
1511            prompt.contains("Your mission is"),
1512            "prompt should have mission statement"
1513        );
1514    }
1515
1516    #[test]
1517    fn test_build_system_prompt_has_cot_directive() {
1518        let prompt = TestProvider::build_system_prompt(None);
1519        assert!(prompt.contains("Reason through each step before producing output."));
1520    }
1521
1522    #[test]
1523    fn test_build_system_prompt_has_examples_section() {
1524        let prompt = TestProvider::build_system_prompt(None);
1525        assert!(prompt.contains("## Examples"));
1526    }
1527
1528    #[test]
1529    fn test_build_create_system_prompt_has_senior_persona() {
1530        let prompt = TestProvider::build_create_system_prompt(None);
1531        assert!(
1532            prompt.contains("You are a senior"),
1533            "prompt should have senior persona"
1534        );
1535        assert!(
1536            prompt.contains("Your mission is"),
1537            "prompt should have mission statement"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_build_pr_review_system_prompt_has_senior_persona() {
1543        let prompt = TestProvider::build_pr_review_system_prompt(None);
1544        assert!(
1545            prompt.contains("You are a senior"),
1546            "prompt should have senior persona"
1547        );
1548        assert!(
1549            prompt.contains("Your mission is"),
1550            "prompt should have mission statement"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_build_pr_label_system_prompt_has_senior_persona() {
1556        let prompt = TestProvider::build_pr_label_system_prompt(None);
1557        assert!(
1558            prompt.contains("You are a senior"),
1559            "prompt should have senior persona"
1560        );
1561        assert!(
1562            prompt.contains("Your mission is"),
1563            "prompt should have mission statement"
1564        );
1565    }
1566
1567    #[tokio::test]
1568    async fn test_load_system_prompt_override_returns_none_when_absent() {
1569        let result =
1570            super::super::context::load_system_prompt_override("__nonexistent_test_override__")
1571                .await;
1572        assert!(result.is_none());
1573    }
1574
1575    #[tokio::test]
1576    async fn test_load_system_prompt_override_returns_content_when_present() {
1577        use std::io::Write;
1578        let dir = tempfile::tempdir().expect("create tempdir");
1579        let file_path = dir.path().join("test_override.md");
1580        let mut f = std::fs::File::create(&file_path).expect("create file");
1581        writeln!(f, "Custom override content").expect("write file");
1582        drop(f);
1583
1584        let content = tokio::fs::read_to_string(&file_path).await.ok();
1585        assert_eq!(content.as_deref(), Some("Custom override content\n"));
1586    }
1587}