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}";
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 #[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 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 if !issue.labels.is_empty() {
592 let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
593 }
594
595 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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 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 assert!(prompt.contains("file1.rs"));
1344 assert!(prompt.contains("file2.rs"));
1345 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}