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