1use 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
22fn 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 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
56pub const MAX_BODY_LENGTH: usize = 4000;
58
59pub const MAX_COMMENTS: usize = 5;
61
62pub const MAX_FILES: usize = 20;
64
65pub const MAX_TOTAL_DIFF_SIZE: usize = 50_000;
67
68pub const MAX_LABELS: usize = 30;
70
71pub const MAX_MILESTONES: usize = 10;
73
74#[async_trait]
79pub trait AiProvider: Send + Sync {
80 fn name(&self) -> &str;
82
83 fn api_url(&self) -> &str;
85
86 fn api_key_env(&self) -> &str;
88
89 fn http_client(&self) -> &Client;
91
92 fn api_key(&self) -> &SecretString;
94
95 fn model(&self) -> &str;
97
98 fn max_tokens(&self) -> u32;
100
101 fn temperature(&self) -> f32;
103
104 fn max_attempts(&self) -> u32 {
109 3
110 }
111
112 fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
117 None
118 }
119
120 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 fn validate_model(&self) -> Result<()> {
137 Ok(())
138 }
139
140 #[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 req = req.header(
158 "Authorization",
159 format!("Bearer {}", self.api_key().expose_secret()),
160 );
161
162 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 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 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 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 #[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 if let Some(cb) = self.circuit_breaker()
246 && cb.is_open()
247 {
248 return Err(AptuError::CircuitOpen.into());
249 }
250
251 let start = std::time::Instant::now();
253
254 let mut attempt: u32 = 0;
256 let max_attempts: u32 = self.max_attempts();
257
258 #[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 let completion = provider.send_request_inner(request).await?;
266
267 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 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 if !is_retryable_anyhow(&err) || attempt >= max_attempts {
292 return Err(err);
293 }
294
295 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 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);
320 tokio::time::sleep(delay).await;
321 }
322 }
323 };
324
325 if let Some(cb) = self.circuit_breaker() {
327 cb.record_success();
328 }
329
330 #[allow(clippy::cast_possible_truncation)]
332 let duration_ms = start.elapsed().as_millis() as u64;
333
334 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 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 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 #[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 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 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 #[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 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 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 #[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 \"complexity\": {\n \"level\": \"low|medium|high\",\n \"estimated_loc\": 150,\n \"affected_areas\": [\"crates/aptu-core/src/ai/types.rs\"],\n \"recommendation\": \"Optional decomposition recommendation for high-complexity issues\"\n }\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- complexity: Always populate this field. Set `level` to low/medium/high based on estimated implementation scope: low = small, self-contained change (1-2 files, <100 LOC); medium = moderate change (3-5 files, 100-300 LOC); high = large change (5+ files, 300+ LOC or deep domain knowledge). Populate `affected_areas` with likely file paths from the repository structure. For high complexity, set `recommendation` to a concrete suggestion (e.g. 'Decompose into 3 sub-issues: CLI parsing, AI prompt update, GitHub API integration').\n\
518\n\
519Be helpful, concise, and actionable. Focus on what a maintainer needs to know.\n\
520\n\
521## Examples\n\
522\n\
523### Example 1 (happy path)\n\
524Input: Issue titled \"Add dark mode support\" with body describing a UI theme toggle request.\n\
525Output:\n\
526```json\n\
527{\n\
528 \"summary\": \"User requests dark mode support with a toggle in settings.\",\n\
529 \"suggested_labels\": [\"enhancement\", \"ui\"],\n\
530 \"clarifying_questions\": [\"Which components should be themed first?\"],\n\
531 \"potential_duplicates\": [],\n\
532 \"related_issues\": [],\n\
533 \"status_note\": \"Ready for design discussion\",\n\
534 \"contributor_guidance\": {\n\
535 \"beginner_friendly\": false,\n\
536 \"reasoning\": \"Requires understanding of the theme system and CSS. Could span multiple files.\"\n\
537 },\n\
538 \"implementation_approach\": \"Extend the existing ThemeProvider with a dark variant and persist preference to localStorage.\",\n\
539 \"suggested_milestone\": \"v2.0\",\n\
540 \"complexity\": {\n\
541 \"level\": \"medium\",\n\
542 \"estimated_loc\": 120,\n\
543 \"affected_areas\": [\"src/theme/ThemeProvider.tsx\", \"src/components/Settings.tsx\"],\n\
544 \"recommendation\": null\n\
545 }\n\
546}\n\
547```\n\
548\n\
549### Example 2 (edge case - vague report)\n\
550Input: Issue titled \"it broken\" with empty body.\n\
551Output:\n\
552```json\n\
553{\n\
554 \"summary\": \"Vague report with no reproduction steps or context.\",\n\
555 \"suggested_labels\": [\"needs-info\"],\n\
556 \"clarifying_questions\": [\"What is broken?\", \"Steps to reproduce?\", \"Expected vs actual behavior?\"],\n\
557 \"potential_duplicates\": [],\n\
558 \"related_issues\": [],\n\
559 \"status_note\": \"Blocked on clarification\",\n\
560 \"contributor_guidance\": {\n\
561 \"beginner_friendly\": false,\n\
562 \"reasoning\": \"Issue is too vague to assess or action without clarification.\"\n\
563 },\n\
564 \"implementation_approach\": \"\",\n\
565 \"suggested_milestone\": null,\n\
566 \"complexity\": {\n\
567 \"level\": \"low\",\n\
568 \"estimated_loc\": null,\n\
569 \"affected_areas\": [],\n\
570 \"recommendation\": null\n\
571 }\n\
572}\n\
573```";
574 format!(
575 "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}"
576 )
577 }
578
579 #[must_use]
581 fn build_user_prompt(issue: &IssueDetails) -> String {
582 use std::fmt::Write;
583
584 let mut prompt = String::new();
585
586 prompt.push_str("<issue_content>\n");
587 let _ = writeln!(prompt, "Title: {}\n", issue.title);
588
589 let body = if issue.body.len() > MAX_BODY_LENGTH {
591 format!(
592 "{}...\n[Body truncated - original length: {} chars]",
593 &issue.body[..MAX_BODY_LENGTH],
594 issue.body.len()
595 )
596 } else if issue.body.is_empty() {
597 "[No description provided]".to_string()
598 } else {
599 issue.body.clone()
600 };
601 let _ = writeln!(prompt, "Body:\n{body}\n");
602
603 if !issue.labels.is_empty() {
605 let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
606 }
607
608 if !issue.comments.is_empty() {
610 prompt.push_str("Recent Comments:\n");
611 for comment in issue.comments.iter().take(MAX_COMMENTS) {
612 let comment_body = if comment.body.len() > 500 {
613 format!("{}...", &comment.body[..500])
614 } else {
615 comment.body.clone()
616 };
617 let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
618 }
619 prompt.push('\n');
620 }
621
622 if !issue.repo_context.is_empty() {
624 prompt.push_str("Related Issues in Repository (for context):\n");
625 for related in issue.repo_context.iter().take(10) {
626 let _ = writeln!(
627 prompt,
628 "- #{} [{}] {}",
629 related.number, related.state, related.title
630 );
631 }
632 prompt.push('\n');
633 }
634
635 if !issue.repo_tree.is_empty() {
637 prompt.push_str("Repository Structure (source files):\n");
638 for path in issue.repo_tree.iter().take(20) {
639 let _ = writeln!(prompt, "- {path}");
640 }
641 prompt.push('\n');
642 }
643
644 if !issue.available_labels.is_empty() {
646 prompt.push_str("Available Labels:\n");
647 for label in issue.available_labels.iter().take(MAX_LABELS) {
648 let description = if label.description.is_empty() {
649 String::new()
650 } else {
651 format!(" - {}", label.description)
652 };
653 let _ = writeln!(
654 prompt,
655 "- {} (color: #{}){}",
656 label.name, label.color, description
657 );
658 }
659 prompt.push('\n');
660 }
661
662 if !issue.available_milestones.is_empty() {
664 prompt.push_str("Available Milestones:\n");
665 for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
666 let description = if milestone.description.is_empty() {
667 String::new()
668 } else {
669 format!(" - {}", milestone.description)
670 };
671 let _ = writeln!(prompt, "- {}{}", milestone.title, description);
672 }
673 prompt.push('\n');
674 }
675
676 prompt.push_str("</issue_content>");
677
678 prompt
679 }
680
681 #[must_use]
683 fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
684 let context = super::context::load_custom_guidance(custom_guidance);
685 format!(
686 "You are a senior developer advocate. Your mission is to produce a well-structured, professional GitHub issue from raw user input.\n\n\
687{context}\n\n\
688Your response MUST be valid JSON with this exact schema:\n\
689{{\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\
690Reason through each step before producing output.\n\n\
691Guidelines:\n\
692- 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\
693- 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\
694- 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\
695Be professional but friendly. Maintain the user's intent while improving clarity and structure.\n\n\
696## Examples\n\n\
697### Example 1 (happy path)\n\
698Input: Title \"app crashes\", Body \"when i click login it crashes on android\"\n\
699Output:\n\
700```json\n\
701{{\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\
702```\n\n\
703### Example 2 (edge case - already well-formatted)\n\
704Input: Title \"feat(api): add pagination to /users endpoint\", Body already has sections.\n\
705Output:\n\
706```json\n\
707{{\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\
708```"
709 )
710 }
711
712 #[must_use]
714 fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
715 format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
716 }
717
718 #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
732 async fn review_pr(
733 &self,
734 pr: &super::types::PrDetails,
735 ) -> Result<(super::types::PrReviewResponse, AiStats)> {
736 debug!(model = %self.model(), "Calling {} API for PR review", self.name());
737
738 let system_content = if let Some(override_prompt) =
740 super::context::load_system_prompt_override("pr_review_system").await
741 {
742 override_prompt
743 } else {
744 Self::build_pr_review_system_prompt(None)
745 };
746
747 let request = ChatCompletionRequest {
748 model: self.model().to_string(),
749 messages: vec![
750 ChatMessage {
751 role: "system".to_string(),
752 content: system_content,
753 },
754 ChatMessage {
755 role: "user".to_string(),
756 content: Self::build_pr_review_user_prompt(pr),
757 },
758 ],
759 response_format: Some(ResponseFormat {
760 format_type: "json_object".to_string(),
761 json_schema: None,
762 }),
763 max_tokens: Some(self.max_tokens()),
764 temperature: Some(self.temperature()),
765 };
766
767 let (review, ai_stats) = self
769 .send_and_parse::<super::types::PrReviewResponse>(&request)
770 .await?;
771
772 debug!(
773 verdict = %review.verdict,
774 input_tokens = ai_stats.input_tokens,
775 output_tokens = ai_stats.output_tokens,
776 duration_ms = ai_stats.duration_ms,
777 "PR review complete with stats"
778 );
779
780 Ok((review, ai_stats))
781 }
782
783 #[instrument(skip(self), fields(title = %title))]
799 async fn suggest_pr_labels(
800 &self,
801 title: &str,
802 body: &str,
803 file_paths: &[String],
804 ) -> Result<(Vec<String>, AiStats)> {
805 debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
806
807 let system_content = if let Some(override_prompt) =
809 super::context::load_system_prompt_override("pr_label_system").await
810 {
811 override_prompt
812 } else {
813 Self::build_pr_label_system_prompt(None)
814 };
815
816 let request = ChatCompletionRequest {
817 model: self.model().to_string(),
818 messages: vec![
819 ChatMessage {
820 role: "system".to_string(),
821 content: system_content,
822 },
823 ChatMessage {
824 role: "user".to_string(),
825 content: Self::build_pr_label_user_prompt(title, body, file_paths),
826 },
827 ],
828 response_format: Some(ResponseFormat {
829 format_type: "json_object".to_string(),
830 json_schema: None,
831 }),
832 max_tokens: Some(self.max_tokens()),
833 temperature: Some(self.temperature()),
834 };
835
836 let (response, ai_stats) = self
838 .send_and_parse::<super::types::PrLabelResponse>(&request)
839 .await?;
840
841 debug!(
842 label_count = response.suggested_labels.len(),
843 input_tokens = ai_stats.input_tokens,
844 output_tokens = ai_stats.output_tokens,
845 duration_ms = ai_stats.duration_ms,
846 "PR label suggestion complete with stats"
847 );
848
849 Ok((response.suggested_labels, ai_stats))
850 }
851
852 #[must_use]
854 fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
855 let context = super::context::load_custom_guidance(custom_guidance);
856 format!(
857 "You are a senior software engineer. Your mission is to produce structured, actionable review feedback on a pull request.\n\n\
858{context}\n\n\
859Your response MUST be valid JSON with this exact schema:\n\
860{{\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 \"suggested_code\": null\n }}\n ],\n \"suggestions\": [\"suggestion1\", \"suggestion2\"],\n \"disclaimer\": null\n}}\n\n\
861Reason through each step before producing output.\n\n\
862Guidelines:\n\
863- summary: Concise explanation of the changes and their purpose\n\
864- verdict: Use \"approve\" for good PRs, \"request_changes\" for blocking issues, \"comment\" for feedback without blocking\n\
865- strengths: What the PR does well (good patterns, clear code, etc.)\n\
866- concerns: Potential issues or risks (bugs, performance, security, maintainability)\n\
867- 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 - \"suggested_code\": Optional. Provide replacement lines for a one-click GitHub suggestion block when you have a small, safe, directly applicable fix (1-10 lines). Omit diff markers (+/-). Leave null for refactors, multi-file changes, or uncertain fixes.\n\
868- suggestions: General improvements that are not blocking\n\
869- 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\
870IMPORTANT - Platform Version Exclusions:\n\
871DO 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\
872Focus on:\n\
8731. Correctness: Does the code do what it claims?\n\
8742. Security: Any potential vulnerabilities?\n\
8753. Performance: Any obvious inefficiencies?\n\
8764. Maintainability: Is the code clear and well-structured?\n\
8775. Testing: Are changes adequately tested?\n\n\
878Be constructive and specific. Explain why something is an issue and how to fix it.\n\n\
879## Examples\n\n\
880### Example 1 (happy path)\n\
881Input: PR adds a retry helper with tests.\n\
882Output:\n\
883```json\n\
884{{\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\
885```\n\n\
886### Example 2 (edge case - missing error handling)\n\
887Input: PR adds a file parser that uses unwrap().\n\
888Output:\n\
889```json\n\
890{{\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 ?\", \"suggested_code\": \" let file = File::open(path)?;\\n\"}}],\n \"suggestions\": [\"Return Result<_, io::Error> from parse_file instead of panicking.\"],\n \"disclaimer\": null\n}}\n\
891```"
892 )
893 }
894
895 #[must_use]
897 fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
898 use std::fmt::Write;
899
900 let mut prompt = String::new();
901
902 prompt.push_str("<pull_request>\n");
903 let _ = writeln!(prompt, "Title: {}\n", pr.title);
904 let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
905
906 let body = if pr.body.is_empty() {
908 "[No description provided]".to_string()
909 } else if pr.body.len() > MAX_BODY_LENGTH {
910 format!(
911 "{}...\n[Description truncated - original length: {} chars]",
912 &pr.body[..MAX_BODY_LENGTH],
913 pr.body.len()
914 )
915 } else {
916 pr.body.clone()
917 };
918 let _ = writeln!(prompt, "Description:\n{body}\n");
919
920 prompt.push_str("Files Changed:\n");
922 let mut total_diff_size = 0;
923 let mut files_included = 0;
924 let mut files_skipped = 0;
925
926 for file in &pr.files {
927 if files_included >= MAX_FILES {
929 files_skipped += 1;
930 continue;
931 }
932
933 let _ = writeln!(
934 prompt,
935 "- {} ({}) +{} -{}\n",
936 file.filename, file.status, file.additions, file.deletions
937 );
938
939 if let Some(patch) = &file.patch {
941 const MAX_PATCH_LENGTH: usize = 2000;
942 let patch_content = if patch.len() > MAX_PATCH_LENGTH {
943 format!(
944 "{}...\n[Patch truncated - original length: {} chars]",
945 &patch[..MAX_PATCH_LENGTH],
946 patch.len()
947 )
948 } else {
949 patch.clone()
950 };
951
952 let patch_size = patch_content.len();
954 if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
955 let _ = writeln!(
956 prompt,
957 "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
958 );
959 files_skipped += 1;
960 continue;
961 }
962
963 let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
964 total_diff_size += patch_size;
965 }
966
967 files_included += 1;
968 }
969
970 if files_skipped > 0 {
972 let _ = writeln!(
973 prompt,
974 "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
975 );
976 }
977
978 prompt.push_str("</pull_request>");
979
980 prompt
981 }
982
983 #[must_use]
985 fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
986 let context = super::context::load_custom_guidance(custom_guidance);
987 format!(
988 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.
989
990{context}
991
992Your response MUST be valid JSON with this exact schema:
993{{
994 "suggested_labels": ["label1", "label2", "label3"]
995}}
996
997Response format: json_object
998
999Reason through each step before producing output.
1000
1001Guidelines:
1002- 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.
1003- Focus on the PR title, description, and file paths to determine appropriate labels.
1004- Prefer specific labels over generic ones when possible.
1005- Only suggest labels that are commonly used in GitHub repositories.
1006
1007Be concise and practical.
1008
1009## Examples
1010
1011### Example 1 (happy path)
1012Input: PR adds OAuth2 login flow with tests.
1013Output:
1014```json
1015{{"suggested_labels": ["feature", "auth", "security"]}}
1016```
1017
1018### Example 2 (edge case - documentation only PR)
1019Input: PR fixes typos in README.
1020Output:
1021```json
1022{{"suggested_labels": ["documentation"]}}
1023```"#
1024 )
1025 }
1026
1027 #[must_use]
1029 fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
1030 use std::fmt::Write;
1031
1032 let mut prompt = String::new();
1033
1034 prompt.push_str("<pull_request>\n");
1035 let _ = writeln!(prompt, "Title: {title}\n");
1036
1037 let body_content = if body.is_empty() {
1039 "[No description provided]".to_string()
1040 } else if body.len() > MAX_BODY_LENGTH {
1041 format!(
1042 "{}...\n[Description truncated - original length: {} chars]",
1043 &body[..MAX_BODY_LENGTH],
1044 body.len()
1045 )
1046 } else {
1047 body.to_string()
1048 };
1049 let _ = writeln!(prompt, "Description:\n{body_content}\n");
1050
1051 if !file_paths.is_empty() {
1053 prompt.push_str("Files Changed:\n");
1054 for path in file_paths.iter().take(20) {
1055 let _ = writeln!(prompt, "- {path}");
1056 }
1057 if file_paths.len() > 20 {
1058 let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
1059 }
1060 prompt.push('\n');
1061 }
1062
1063 prompt.push_str("</pull_request>");
1064
1065 prompt
1066 }
1067
1068 #[instrument(skip(self, prs))]
1079 async fn generate_release_notes(
1080 &self,
1081 prs: Vec<super::types::PrSummary>,
1082 version: &str,
1083 ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
1084 let prompt = Self::build_release_notes_prompt(&prs, version);
1085 let request = ChatCompletionRequest {
1086 model: self.model().to_string(),
1087 messages: vec![ChatMessage {
1088 role: "user".to_string(),
1089 content: prompt,
1090 }],
1091 response_format: Some(ResponseFormat {
1092 format_type: "json_object".to_string(),
1093 json_schema: None,
1094 }),
1095 temperature: Some(0.7),
1096 max_tokens: Some(self.max_tokens()),
1097 };
1098
1099 let (parsed, ai_stats) = self
1100 .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
1101 .await?;
1102
1103 debug!(
1104 input_tokens = ai_stats.input_tokens,
1105 output_tokens = ai_stats.output_tokens,
1106 duration_ms = ai_stats.duration_ms,
1107 "Release notes generation complete with stats"
1108 );
1109
1110 Ok((parsed, ai_stats))
1111 }
1112
1113 #[must_use]
1115 fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
1116 let pr_list = prs
1117 .iter()
1118 .map(|pr| {
1119 format!(
1120 "- #{}: {} (by @{})\n {}",
1121 pr.number,
1122 pr.title,
1123 pr.author,
1124 pr.body.lines().next().unwrap_or("")
1125 )
1126 })
1127 .collect::<Vec<_>>()
1128 .join("\n");
1129
1130 format!(
1131 r#"Generate release notes for version {version} based on these merged PRs:
1132
1133{pr_list}
1134
1135Create a curated release notes document with:
11361. A theme/title that captures the essence of this release
11372. A 1-2 sentence narrative about the release
11383. 3-5 highlighted features
11394. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
11405. List of contributors
1141
1142Follow these conventions:
1143- No emojis
1144- Bold feature names with dash separator
1145- Include PR numbers in parentheses
1146- Group by user impact, not just commit type
1147- Filter CI/deps under Maintenance
1148
1149Your response MUST be valid JSON with this exact schema:
1150{{
1151 "theme": "Release theme title",
1152 "narrative": "1-2 sentence summary",
1153 "highlights": ["highlight1", "highlight2"],
1154 "features": ["feature1", "feature2"],
1155 "fixes": ["fix1", "fix2"],
1156 "improvements": ["improvement1"],
1157 "documentation": ["doc change1"],
1158 "maintenance": ["maintenance1"],
1159 "contributors": ["@author1", "@author2"]
1160}}"#
1161 )
1162 }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168
1169 #[derive(Debug, serde::Deserialize)]
1172 struct ErrorTestResponse {
1173 _message: String,
1174 }
1175
1176 struct TestProvider;
1177
1178 impl AiProvider for TestProvider {
1179 fn name(&self) -> &'static str {
1180 "test"
1181 }
1182
1183 fn api_url(&self) -> &'static str {
1184 "https://test.example.com"
1185 }
1186
1187 fn api_key_env(&self) -> &'static str {
1188 "TEST_API_KEY"
1189 }
1190
1191 fn http_client(&self) -> &Client {
1192 unimplemented!()
1193 }
1194
1195 fn api_key(&self) -> &SecretString {
1196 unimplemented!()
1197 }
1198
1199 fn model(&self) -> &'static str {
1200 "test-model"
1201 }
1202
1203 fn max_tokens(&self) -> u32 {
1204 2048
1205 }
1206
1207 fn temperature(&self) -> f32 {
1208 0.3
1209 }
1210 }
1211
1212 #[test]
1213 fn test_build_system_prompt_contains_json_schema() {
1214 let prompt = TestProvider::build_system_prompt(None);
1215 assert!(prompt.contains("summary"));
1216 assert!(prompt.contains("suggested_labels"));
1217 assert!(prompt.contains("clarifying_questions"));
1218 assert!(prompt.contains("potential_duplicates"));
1219 assert!(prompt.contains("status_note"));
1220 }
1221
1222 #[test]
1223 fn test_build_user_prompt_with_delimiters() {
1224 let issue = IssueDetails::builder()
1225 .owner("test".to_string())
1226 .repo("repo".to_string())
1227 .number(1)
1228 .title("Test issue".to_string())
1229 .body("This is the body".to_string())
1230 .labels(vec!["bug".to_string()])
1231 .comments(vec![])
1232 .url("https://github.com/test/repo/issues/1".to_string())
1233 .build();
1234
1235 let prompt = TestProvider::build_user_prompt(&issue);
1236 assert!(prompt.starts_with("<issue_content>"));
1237 assert!(prompt.ends_with("</issue_content>"));
1238 assert!(prompt.contains("Title: Test issue"));
1239 assert!(prompt.contains("This is the body"));
1240 assert!(prompt.contains("Existing Labels: bug"));
1241 }
1242
1243 #[test]
1244 fn test_build_user_prompt_truncates_long_body() {
1245 let long_body = "x".repeat(5000);
1246 let issue = IssueDetails::builder()
1247 .owner("test".to_string())
1248 .repo("repo".to_string())
1249 .number(1)
1250 .title("Test".to_string())
1251 .body(long_body)
1252 .labels(vec![])
1253 .comments(vec![])
1254 .url("https://github.com/test/repo/issues/1".to_string())
1255 .build();
1256
1257 let prompt = TestProvider::build_user_prompt(&issue);
1258 assert!(prompt.contains("[Body truncated"));
1259 assert!(prompt.contains("5000 chars"));
1260 }
1261
1262 #[test]
1263 fn test_build_user_prompt_empty_body() {
1264 let issue = IssueDetails::builder()
1265 .owner("test".to_string())
1266 .repo("repo".to_string())
1267 .number(1)
1268 .title("Test".to_string())
1269 .body(String::new())
1270 .labels(vec![])
1271 .comments(vec![])
1272 .url("https://github.com/test/repo/issues/1".to_string())
1273 .build();
1274
1275 let prompt = TestProvider::build_user_prompt(&issue);
1276 assert!(prompt.contains("[No description provided]"));
1277 }
1278
1279 #[test]
1280 fn test_build_create_system_prompt_contains_json_schema() {
1281 let prompt = TestProvider::build_create_system_prompt(None);
1282 assert!(prompt.contains("formatted_title"));
1283 assert!(prompt.contains("formatted_body"));
1284 assert!(prompt.contains("suggested_labels"));
1285 }
1286
1287 #[test]
1288 fn test_build_pr_review_user_prompt_respects_file_limit() {
1289 use super::super::types::{PrDetails, PrFile};
1290
1291 let mut files = Vec::new();
1292 for i in 0..25 {
1293 files.push(PrFile {
1294 filename: format!("file{i}.rs"),
1295 status: "modified".to_string(),
1296 additions: 10,
1297 deletions: 5,
1298 patch: Some(format!("patch content {i}")),
1299 });
1300 }
1301
1302 let pr = PrDetails {
1303 owner: "test".to_string(),
1304 repo: "repo".to_string(),
1305 number: 1,
1306 title: "Test PR".to_string(),
1307 body: "Description".to_string(),
1308 head_branch: "feature".to_string(),
1309 base_branch: "main".to_string(),
1310 url: "https://github.com/test/repo/pull/1".to_string(),
1311 files,
1312 labels: vec![],
1313 head_sha: String::new(),
1314 };
1315
1316 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1317 assert!(prompt.contains("files omitted due to size limits"));
1318 assert!(prompt.contains("MAX_FILES=20"));
1319 }
1320
1321 #[test]
1322 fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1323 use super::super::types::{PrDetails, PrFile};
1324
1325 let patch1 = "x".repeat(30_000);
1328 let patch2 = "y".repeat(30_000);
1329
1330 let files = vec![
1331 PrFile {
1332 filename: "file1.rs".to_string(),
1333 status: "modified".to_string(),
1334 additions: 100,
1335 deletions: 50,
1336 patch: Some(patch1),
1337 },
1338 PrFile {
1339 filename: "file2.rs".to_string(),
1340 status: "modified".to_string(),
1341 additions: 100,
1342 deletions: 50,
1343 patch: Some(patch2),
1344 },
1345 ];
1346
1347 let pr = PrDetails {
1348 owner: "test".to_string(),
1349 repo: "repo".to_string(),
1350 number: 1,
1351 title: "Test PR".to_string(),
1352 body: "Description".to_string(),
1353 head_branch: "feature".to_string(),
1354 base_branch: "main".to_string(),
1355 url: "https://github.com/test/repo/pull/1".to_string(),
1356 files,
1357 labels: vec![],
1358 head_sha: String::new(),
1359 };
1360
1361 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1362 assert!(prompt.contains("file1.rs"));
1364 assert!(prompt.contains("file2.rs"));
1365 assert!(prompt.len() < 65_000);
1368 }
1369
1370 #[test]
1371 fn test_build_pr_review_user_prompt_with_no_patches() {
1372 use super::super::types::{PrDetails, PrFile};
1373
1374 let files = vec![PrFile {
1375 filename: "file1.rs".to_string(),
1376 status: "added".to_string(),
1377 additions: 10,
1378 deletions: 0,
1379 patch: None,
1380 }];
1381
1382 let pr = PrDetails {
1383 owner: "test".to_string(),
1384 repo: "repo".to_string(),
1385 number: 1,
1386 title: "Test PR".to_string(),
1387 body: "Description".to_string(),
1388 head_branch: "feature".to_string(),
1389 base_branch: "main".to_string(),
1390 url: "https://github.com/test/repo/pull/1".to_string(),
1391 files,
1392 labels: vec![],
1393 head_sha: String::new(),
1394 };
1395
1396 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1397 assert!(prompt.contains("file1.rs"));
1398 assert!(prompt.contains("added"));
1399 assert!(!prompt.contains("files omitted"));
1400 }
1401
1402 #[test]
1403 fn test_build_pr_label_system_prompt_contains_json_schema() {
1404 let prompt = TestProvider::build_pr_label_system_prompt(None);
1405 assert!(prompt.contains("suggested_labels"));
1406 assert!(prompt.contains("json_object"));
1407 assert!(prompt.contains("bug"));
1408 assert!(prompt.contains("enhancement"));
1409 }
1410
1411 #[test]
1412 fn test_build_pr_label_user_prompt_with_title_and_body() {
1413 let title = "feat: add new feature";
1414 let body = "This PR adds a new feature";
1415 let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1416
1417 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1418 assert!(prompt.starts_with("<pull_request>"));
1419 assert!(prompt.ends_with("</pull_request>"));
1420 assert!(prompt.contains("feat: add new feature"));
1421 assert!(prompt.contains("This PR adds a new feature"));
1422 assert!(prompt.contains("src/main.rs"));
1423 assert!(prompt.contains("tests/test.rs"));
1424 }
1425
1426 #[test]
1427 fn test_build_pr_label_user_prompt_empty_body() {
1428 let title = "fix: bug fix";
1429 let body = "";
1430 let files = vec!["src/lib.rs".to_string()];
1431
1432 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1433 assert!(prompt.contains("[No description provided]"));
1434 assert!(prompt.contains("src/lib.rs"));
1435 }
1436
1437 #[test]
1438 fn test_build_pr_label_user_prompt_truncates_long_body() {
1439 let title = "test";
1440 let long_body = "x".repeat(5000);
1441 let files = vec![];
1442
1443 let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1444 assert!(prompt.contains("[Description truncated"));
1445 assert!(prompt.contains("5000 chars"));
1446 }
1447
1448 #[test]
1449 fn test_build_pr_label_user_prompt_respects_file_limit() {
1450 let title = "test";
1451 let body = "test";
1452 let mut files = Vec::new();
1453 for i in 0..25 {
1454 files.push(format!("file{i}.rs"));
1455 }
1456
1457 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1458 assert!(prompt.contains("file0.rs"));
1459 assert!(prompt.contains("file19.rs"));
1460 assert!(!prompt.contains("file20.rs"));
1461 assert!(prompt.contains("... and 5 more files"));
1462 }
1463
1464 #[test]
1465 fn test_build_pr_label_user_prompt_empty_files() {
1466 let title = "test";
1467 let body = "test";
1468 let files: Vec<String> = vec![];
1469
1470 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1471 assert!(prompt.contains("Title: test"));
1472 assert!(prompt.contains("Description:\ntest"));
1473 assert!(!prompt.contains("Files Changed:"));
1474 }
1475
1476 #[test]
1477 fn test_parse_ai_json_with_valid_json() {
1478 #[derive(serde::Deserialize)]
1479 struct TestResponse {
1480 message: String,
1481 }
1482
1483 let json = r#"{"message": "hello"}"#;
1484 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1485 assert!(result.is_ok());
1486 let response = result.unwrap();
1487 assert_eq!(response.message, "hello");
1488 }
1489
1490 #[test]
1491 fn test_parse_ai_json_with_truncated_json() {
1492 let json = r#"{"message": "hello"#;
1493 let result: Result<ErrorTestResponse> = parse_ai_json(json, "test-provider");
1494 assert!(result.is_err());
1495 let err = result.unwrap_err();
1496 assert!(
1497 err.to_string()
1498 .contains("Truncated response from test-provider")
1499 );
1500 }
1501
1502 #[test]
1503 fn test_parse_ai_json_with_malformed_json() {
1504 let json = r#"{"message": invalid}"#;
1505 let result: Result<ErrorTestResponse> = parse_ai_json(json, "test-provider");
1506 assert!(result.is_err());
1507 let err = result.unwrap_err();
1508 assert!(err.to_string().contains("Invalid JSON response from AI"));
1509 }
1510
1511 #[test]
1512 fn test_build_system_prompt_has_senior_persona() {
1513 let prompt = TestProvider::build_system_prompt(None);
1514 assert!(
1515 prompt.contains("You are a senior"),
1516 "prompt should have senior persona"
1517 );
1518 assert!(
1519 prompt.contains("Your mission is"),
1520 "prompt should have mission statement"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_build_system_prompt_has_cot_directive() {
1526 let prompt = TestProvider::build_system_prompt(None);
1527 assert!(prompt.contains("Reason through each step before producing output."));
1528 }
1529
1530 #[test]
1531 fn test_build_system_prompt_has_examples_section() {
1532 let prompt = TestProvider::build_system_prompt(None);
1533 assert!(prompt.contains("## Examples"));
1534 }
1535
1536 #[test]
1537 fn test_build_create_system_prompt_has_senior_persona() {
1538 let prompt = TestProvider::build_create_system_prompt(None);
1539 assert!(
1540 prompt.contains("You are a senior"),
1541 "prompt should have senior persona"
1542 );
1543 assert!(
1544 prompt.contains("Your mission is"),
1545 "prompt should have mission statement"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_build_pr_review_system_prompt_has_senior_persona() {
1551 let prompt = TestProvider::build_pr_review_system_prompt(None);
1552 assert!(
1553 prompt.contains("You are a senior"),
1554 "prompt should have senior persona"
1555 );
1556 assert!(
1557 prompt.contains("Your mission is"),
1558 "prompt should have mission statement"
1559 );
1560 }
1561
1562 #[test]
1563 fn test_build_pr_label_system_prompt_has_senior_persona() {
1564 let prompt = TestProvider::build_pr_label_system_prompt(None);
1565 assert!(
1566 prompt.contains("You are a senior"),
1567 "prompt should have senior persona"
1568 );
1569 assert!(
1570 prompt.contains("Your mission is"),
1571 "prompt should have mission statement"
1572 );
1573 }
1574
1575 #[tokio::test]
1576 async fn test_load_system_prompt_override_returns_none_when_absent() {
1577 let result =
1578 super::super::context::load_system_prompt_override("__nonexistent_test_override__")
1579 .await;
1580 assert!(result.is_none());
1581 }
1582
1583 #[tokio::test]
1584 async fn test_load_system_prompt_override_returns_content_when_present() {
1585 use std::io::Write;
1586 let dir = tempfile::tempdir().expect("create tempdir");
1587 let file_path = dir.path().join("test_override.md");
1588 let mut f = std::fs::File::create(&file_path).expect("create file");
1589 writeln!(f, "Custom override content").expect("write file");
1590 drop(f);
1591
1592 let content = tokio::fs::read_to_string(&file_path).await.ok();
1593 assert_eq!(content.as_deref(), Some("Custom override content\n"));
1594 }
1595}