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 request = ChatCompletionRequest {
385 model: self.model().to_string(),
386 messages: vec![
387 ChatMessage {
388 role: "system".to_string(),
389 content: Self::build_system_prompt(None),
390 },
391 ChatMessage {
392 role: "user".to_string(),
393 content: Self::build_user_prompt(issue),
394 },
395 ],
396 response_format: Some(ResponseFormat {
397 format_type: "json_object".to_string(),
398 json_schema: None,
399 }),
400 max_tokens: Some(self.max_tokens()),
401 temperature: Some(self.temperature()),
402 };
403
404 let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
406
407 debug!(
408 input_tokens = ai_stats.input_tokens,
409 output_tokens = ai_stats.output_tokens,
410 duration_ms = ai_stats.duration_ms,
411 cost_usd = ?ai_stats.cost_usd,
412 "AI analysis complete"
413 );
414
415 Ok(AiResponse {
416 triage,
417 stats: ai_stats,
418 })
419 }
420
421 #[instrument(skip(self), fields(repo = %repo))]
438 async fn create_issue(
439 &self,
440 title: &str,
441 body: &str,
442 repo: &str,
443 ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
444 debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
445
446 let request = ChatCompletionRequest {
448 model: self.model().to_string(),
449 messages: vec![
450 ChatMessage {
451 role: "system".to_string(),
452 content: Self::build_create_system_prompt(None),
453 },
454 ChatMessage {
455 role: "user".to_string(),
456 content: Self::build_create_user_prompt(title, body, repo),
457 },
458 ],
459 response_format: Some(ResponseFormat {
460 format_type: "json_object".to_string(),
461 json_schema: None,
462 }),
463 max_tokens: Some(self.max_tokens()),
464 temperature: Some(self.temperature()),
465 };
466
467 let (create_response, ai_stats) = self
469 .send_and_parse::<super::types::CreateIssueResponse>(&request)
470 .await?;
471
472 debug!(
473 title_len = create_response.formatted_title.len(),
474 body_len = create_response.formatted_body.len(),
475 labels = create_response.suggested_labels.len(),
476 input_tokens = ai_stats.input_tokens,
477 output_tokens = ai_stats.output_tokens,
478 duration_ms = ai_stats.duration_ms,
479 "Issue formatting complete with stats"
480 );
481
482 Ok((create_response, ai_stats))
483 }
484
485 #[must_use]
487 fn build_system_prompt(custom_guidance: Option<&str>) -> String {
488 let context = super::context::load_custom_guidance(custom_guidance);
489 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}";
490 let guidelines = "Guidelines:\n\
491- summary: Concise explanation of the problem/request and why it matters\n\
492- 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\
493- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
494- 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\
495- 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\
496- 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\
497- 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\
498- 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\
499- 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\
500\n\
501Be helpful, concise, and actionable. Focus on what a maintainer needs to know.";
502 format!(
503 "You are an OSS issue triage assistant. Analyze the provided GitHub issue and \
504 provide structured triage information.\n\n{context}\n\nYour response MUST be valid \
505 JSON with this exact schema:\n{schema}\n\n{guidelines}"
506 )
507 }
508
509 #[must_use]
511 fn build_user_prompt(issue: &IssueDetails) -> String {
512 use std::fmt::Write;
513
514 let mut prompt = String::new();
515
516 prompt.push_str("<issue_content>\n");
517 let _ = writeln!(prompt, "Title: {}\n", issue.title);
518
519 let body = if issue.body.len() > MAX_BODY_LENGTH {
521 format!(
522 "{}...\n[Body truncated - original length: {} chars]",
523 &issue.body[..MAX_BODY_LENGTH],
524 issue.body.len()
525 )
526 } else if issue.body.is_empty() {
527 "[No description provided]".to_string()
528 } else {
529 issue.body.clone()
530 };
531 let _ = writeln!(prompt, "Body:\n{body}\n");
532
533 if !issue.labels.is_empty() {
535 let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
536 }
537
538 if !issue.comments.is_empty() {
540 prompt.push_str("Recent Comments:\n");
541 for comment in issue.comments.iter().take(MAX_COMMENTS) {
542 let comment_body = if comment.body.len() > 500 {
543 format!("{}...", &comment.body[..500])
544 } else {
545 comment.body.clone()
546 };
547 let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
548 }
549 prompt.push('\n');
550 }
551
552 if !issue.repo_context.is_empty() {
554 prompt.push_str("Related Issues in Repository (for context):\n");
555 for related in issue.repo_context.iter().take(10) {
556 let _ = writeln!(
557 prompt,
558 "- #{} [{}] {}",
559 related.number, related.state, related.title
560 );
561 }
562 prompt.push('\n');
563 }
564
565 if !issue.repo_tree.is_empty() {
567 prompt.push_str("Repository Structure (source files):\n");
568 for path in issue.repo_tree.iter().take(20) {
569 let _ = writeln!(prompt, "- {path}");
570 }
571 prompt.push('\n');
572 }
573
574 if !issue.available_labels.is_empty() {
576 prompt.push_str("Available Labels:\n");
577 for label in issue.available_labels.iter().take(MAX_LABELS) {
578 let description = if label.description.is_empty() {
579 String::new()
580 } else {
581 format!(" - {}", label.description)
582 };
583 let _ = writeln!(
584 prompt,
585 "- {} (color: #{}){}",
586 label.name, label.color, description
587 );
588 }
589 prompt.push('\n');
590 }
591
592 if !issue.available_milestones.is_empty() {
594 prompt.push_str("Available Milestones:\n");
595 for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
596 let description = if milestone.description.is_empty() {
597 String::new()
598 } else {
599 format!(" - {}", milestone.description)
600 };
601 let _ = writeln!(prompt, "- {}{}", milestone.title, description);
602 }
603 prompt.push('\n');
604 }
605
606 prompt.push_str("</issue_content>");
607
608 prompt
609 }
610
611 #[must_use]
613 fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
614 let context = super::context::load_custom_guidance(custom_guidance);
615 format!(
616 r#"You are a GitHub issue formatting assistant. Your job is to take a raw issue title and body from a user and format them professionally for a GitHub repository.
617
618{context}
619
620Your response MUST be valid JSON with this exact schema:
621{{
622 "formatted_title": "Well-formatted issue title following conventional commit style",
623 "formatted_body": "Professionally formatted issue body with clear sections",
624 "suggested_labels": ["label1", "label2"]
625}}
626
627Guidelines:
628- 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.
629- formatted_body: Structure the body with clear sections:
630 * Start with a brief 1-2 sentence summary if not already present
631 * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
632 * Keep sentences clear and concise
633 * Use bullet points for lists
634 * Improve grammar and clarity
635 * Add relevant context if missing
636- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.
637
638Be professional but friendly. Maintain the user's intent while improving clarity and structure."#
639 )
640 }
641
642 #[must_use]
644 fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
645 format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
646 }
647
648 #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
662 async fn review_pr(
663 &self,
664 pr: &super::types::PrDetails,
665 ) -> Result<(super::types::PrReviewResponse, AiStats)> {
666 debug!(model = %self.model(), "Calling {} API for PR review", self.name());
667
668 let request = ChatCompletionRequest {
670 model: self.model().to_string(),
671 messages: vec![
672 ChatMessage {
673 role: "system".to_string(),
674 content: Self::build_pr_review_system_prompt(None),
675 },
676 ChatMessage {
677 role: "user".to_string(),
678 content: Self::build_pr_review_user_prompt(pr),
679 },
680 ],
681 response_format: Some(ResponseFormat {
682 format_type: "json_object".to_string(),
683 json_schema: None,
684 }),
685 max_tokens: Some(self.max_tokens()),
686 temperature: Some(self.temperature()),
687 };
688
689 let (review, ai_stats) = self
691 .send_and_parse::<super::types::PrReviewResponse>(&request)
692 .await?;
693
694 debug!(
695 verdict = %review.verdict,
696 input_tokens = ai_stats.input_tokens,
697 output_tokens = ai_stats.output_tokens,
698 duration_ms = ai_stats.duration_ms,
699 "PR review complete with stats"
700 );
701
702 Ok((review, ai_stats))
703 }
704
705 #[instrument(skip(self), fields(title = %title))]
721 async fn suggest_pr_labels(
722 &self,
723 title: &str,
724 body: &str,
725 file_paths: &[String],
726 ) -> Result<(Vec<String>, AiStats)> {
727 debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
728
729 let request = ChatCompletionRequest {
731 model: self.model().to_string(),
732 messages: vec![
733 ChatMessage {
734 role: "system".to_string(),
735 content: Self::build_pr_label_system_prompt(None),
736 },
737 ChatMessage {
738 role: "user".to_string(),
739 content: Self::build_pr_label_user_prompt(title, body, file_paths),
740 },
741 ],
742 response_format: Some(ResponseFormat {
743 format_type: "json_object".to_string(),
744 json_schema: None,
745 }),
746 max_tokens: Some(self.max_tokens()),
747 temperature: Some(self.temperature()),
748 };
749
750 let (response, ai_stats) = self
752 .send_and_parse::<super::types::PrLabelResponse>(&request)
753 .await?;
754
755 debug!(
756 label_count = response.suggested_labels.len(),
757 input_tokens = ai_stats.input_tokens,
758 output_tokens = ai_stats.output_tokens,
759 duration_ms = ai_stats.duration_ms,
760 "PR label suggestion complete with stats"
761 );
762
763 Ok((response.suggested_labels, ai_stats))
764 }
765
766 #[must_use]
768 fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
769 let context = super::context::load_custom_guidance(custom_guidance);
770 format!(
771 r#"You are a code review assistant. Analyze the provided pull request and provide structured review feedback.
772
773{context}
774
775Your response MUST be valid JSON with this exact schema:
776{{
777 "summary": "A 2-3 sentence summary of what the PR does and its impact",
778 "verdict": "approve|request_changes|comment",
779 "strengths": ["strength1", "strength2"],
780 "concerns": ["concern1", "concern2"],
781 "comments": [
782 {{
783 "file": "path/to/file.rs",
784 "line": 42,
785 "comment": "Specific feedback about this line",
786 "severity": "info|suggestion|warning|issue"
787 }}
788 ],
789 "suggestions": ["suggestion1", "suggestion2"],
790 "disclaimer": null
791}}
792
793Guidelines:
794- summary: Concise explanation of the changes and their purpose
795- verdict: Use "approve" for good PRs, "request_changes" for blocking issues, "comment" for feedback without blocking
796- strengths: What the PR does well (good patterns, clear code, etc.)
797- concerns: Potential issues or risks (bugs, performance, security, maintainability)
798- comments: Specific line-level feedback. Use severity:
799 - "info": Informational, no action needed
800 - "suggestion": Optional improvement
801 - "warning": Should consider changing
802 - "issue": Should be fixed before merge
803- suggestions: General improvements that are not blocking
804- 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.
805
806IMPORTANT - Platform Version Exclusions:
807DO 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.
808
809Focus on:
8101. Correctness: Does the code do what it claims?
8112. Security: Any potential vulnerabilities?
8123. Performance: Any obvious inefficiencies?
8134. Maintainability: Is the code clear and well-structured?
8145. Testing: Are changes adequately tested?
815
816Be constructive and specific. Explain why something is an issue and how to fix it."#
817 )
818 }
819
820 #[must_use]
822 fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
823 use std::fmt::Write;
824
825 let mut prompt = String::new();
826
827 prompt.push_str("<pull_request>\n");
828 let _ = writeln!(prompt, "Title: {}\n", pr.title);
829 let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
830
831 let body = if pr.body.is_empty() {
833 "[No description provided]".to_string()
834 } else if pr.body.len() > MAX_BODY_LENGTH {
835 format!(
836 "{}...\n[Description truncated - original length: {} chars]",
837 &pr.body[..MAX_BODY_LENGTH],
838 pr.body.len()
839 )
840 } else {
841 pr.body.clone()
842 };
843 let _ = writeln!(prompt, "Description:\n{body}\n");
844
845 prompt.push_str("Files Changed:\n");
847 let mut total_diff_size = 0;
848 let mut files_included = 0;
849 let mut files_skipped = 0;
850
851 for file in &pr.files {
852 if files_included >= MAX_FILES {
854 files_skipped += 1;
855 continue;
856 }
857
858 let _ = writeln!(
859 prompt,
860 "- {} ({}) +{} -{}\n",
861 file.filename, file.status, file.additions, file.deletions
862 );
863
864 if let Some(patch) = &file.patch {
866 const MAX_PATCH_LENGTH: usize = 2000;
867 let patch_content = if patch.len() > MAX_PATCH_LENGTH {
868 format!(
869 "{}...\n[Patch truncated - original length: {} chars]",
870 &patch[..MAX_PATCH_LENGTH],
871 patch.len()
872 )
873 } else {
874 patch.clone()
875 };
876
877 let patch_size = patch_content.len();
879 if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
880 let _ = writeln!(
881 prompt,
882 "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
883 );
884 files_skipped += 1;
885 continue;
886 }
887
888 let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
889 total_diff_size += patch_size;
890 }
891
892 files_included += 1;
893 }
894
895 if files_skipped > 0 {
897 let _ = writeln!(
898 prompt,
899 "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
900 );
901 }
902
903 prompt.push_str("</pull_request>");
904
905 prompt
906 }
907
908 #[must_use]
910 fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
911 let context = super::context::load_custom_guidance(custom_guidance);
912 format!(
913 r#"You are a GitHub label suggestion assistant. Analyze the provided pull request and suggest relevant labels.
914
915{context}
916
917Your response MUST be valid JSON with this exact schema:
918{{
919 "suggested_labels": ["label1", "label2", "label3"]
920}}
921
922Response format: json_object
923
924Guidelines:
925- 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.
926- Focus on the PR title, description, and file paths to determine appropriate labels.
927- Prefer specific labels over generic ones when possible.
928- Only suggest labels that are commonly used in GitHub repositories.
929
930Be concise and practical."#
931 )
932 }
933
934 #[must_use]
936 fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
937 use std::fmt::Write;
938
939 let mut prompt = String::new();
940
941 prompt.push_str("<pull_request>\n");
942 let _ = writeln!(prompt, "Title: {title}\n");
943
944 let body_content = if body.is_empty() {
946 "[No description provided]".to_string()
947 } else if body.len() > MAX_BODY_LENGTH {
948 format!(
949 "{}...\n[Description truncated - original length: {} chars]",
950 &body[..MAX_BODY_LENGTH],
951 body.len()
952 )
953 } else {
954 body.to_string()
955 };
956 let _ = writeln!(prompt, "Description:\n{body_content}\n");
957
958 if !file_paths.is_empty() {
960 prompt.push_str("Files Changed:\n");
961 for path in file_paths.iter().take(20) {
962 let _ = writeln!(prompt, "- {path}");
963 }
964 if file_paths.len() > 20 {
965 let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
966 }
967 prompt.push('\n');
968 }
969
970 prompt.push_str("</pull_request>");
971
972 prompt
973 }
974
975 #[instrument(skip(self, prs))]
986 async fn generate_release_notes(
987 &self,
988 prs: Vec<super::types::PrSummary>,
989 version: &str,
990 ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
991 let prompt = Self::build_release_notes_prompt(&prs, version);
992 let request = ChatCompletionRequest {
993 model: self.model().to_string(),
994 messages: vec![ChatMessage {
995 role: "user".to_string(),
996 content: prompt,
997 }],
998 response_format: Some(ResponseFormat {
999 format_type: "json_object".to_string(),
1000 json_schema: None,
1001 }),
1002 temperature: Some(0.7),
1003 max_tokens: Some(self.max_tokens()),
1004 };
1005
1006 let (parsed, ai_stats) = self
1007 .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
1008 .await?;
1009
1010 debug!(
1011 input_tokens = ai_stats.input_tokens,
1012 output_tokens = ai_stats.output_tokens,
1013 duration_ms = ai_stats.duration_ms,
1014 "Release notes generation complete with stats"
1015 );
1016
1017 Ok((parsed, ai_stats))
1018 }
1019
1020 #[must_use]
1022 fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
1023 let pr_list = prs
1024 .iter()
1025 .map(|pr| {
1026 format!(
1027 "- #{}: {} (by @{})\n {}",
1028 pr.number,
1029 pr.title,
1030 pr.author,
1031 pr.body.lines().next().unwrap_or("")
1032 )
1033 })
1034 .collect::<Vec<_>>()
1035 .join("\n");
1036
1037 format!(
1038 r#"Generate release notes for version {version} based on these merged PRs:
1039
1040{pr_list}
1041
1042Create a curated release notes document with:
10431. A theme/title that captures the essence of this release
10442. A 1-2 sentence narrative about the release
10453. 3-5 highlighted features
10464. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
10475. List of contributors
1048
1049Follow these conventions:
1050- No emojis
1051- Bold feature names with dash separator
1052- Include PR numbers in parentheses
1053- Group by user impact, not just commit type
1054- Filter CI/deps under Maintenance
1055
1056Your response MUST be valid JSON with this exact schema:
1057{{
1058 "theme": "Release theme title",
1059 "narrative": "1-2 sentence summary",
1060 "highlights": ["highlight1", "highlight2"],
1061 "features": ["feature1", "feature2"],
1062 "fixes": ["fix1", "fix2"],
1063 "improvements": ["improvement1"],
1064 "documentation": ["doc change1"],
1065 "maintenance": ["maintenance1"],
1066 "contributors": ["@author1", "@author2"]
1067}}"#
1068 )
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 struct TestProvider;
1077
1078 impl AiProvider for TestProvider {
1079 fn name(&self) -> &'static str {
1080 "test"
1081 }
1082
1083 fn api_url(&self) -> &'static str {
1084 "https://test.example.com"
1085 }
1086
1087 fn api_key_env(&self) -> &'static str {
1088 "TEST_API_KEY"
1089 }
1090
1091 fn http_client(&self) -> &Client {
1092 unimplemented!()
1093 }
1094
1095 fn api_key(&self) -> &SecretString {
1096 unimplemented!()
1097 }
1098
1099 fn model(&self) -> &'static str {
1100 "test-model"
1101 }
1102
1103 fn max_tokens(&self) -> u32 {
1104 2048
1105 }
1106
1107 fn temperature(&self) -> f32 {
1108 0.3
1109 }
1110 }
1111
1112 #[test]
1113 fn test_build_system_prompt_contains_json_schema() {
1114 let prompt = TestProvider::build_system_prompt(None);
1115 assert!(prompt.contains("summary"));
1116 assert!(prompt.contains("suggested_labels"));
1117 assert!(prompt.contains("clarifying_questions"));
1118 assert!(prompt.contains("potential_duplicates"));
1119 assert!(prompt.contains("status_note"));
1120 }
1121
1122 #[test]
1123 fn test_build_user_prompt_with_delimiters() {
1124 let issue = IssueDetails::builder()
1125 .owner("test".to_string())
1126 .repo("repo".to_string())
1127 .number(1)
1128 .title("Test issue".to_string())
1129 .body("This is the body".to_string())
1130 .labels(vec!["bug".to_string()])
1131 .comments(vec![])
1132 .url("https://github.com/test/repo/issues/1".to_string())
1133 .build();
1134
1135 let prompt = TestProvider::build_user_prompt(&issue);
1136 assert!(prompt.starts_with("<issue_content>"));
1137 assert!(prompt.ends_with("</issue_content>"));
1138 assert!(prompt.contains("Title: Test issue"));
1139 assert!(prompt.contains("This is the body"));
1140 assert!(prompt.contains("Existing Labels: bug"));
1141 }
1142
1143 #[test]
1144 fn test_build_user_prompt_truncates_long_body() {
1145 let long_body = "x".repeat(5000);
1146 let issue = IssueDetails::builder()
1147 .owner("test".to_string())
1148 .repo("repo".to_string())
1149 .number(1)
1150 .title("Test".to_string())
1151 .body(long_body)
1152 .labels(vec![])
1153 .comments(vec![])
1154 .url("https://github.com/test/repo/issues/1".to_string())
1155 .build();
1156
1157 let prompt = TestProvider::build_user_prompt(&issue);
1158 assert!(prompt.contains("[Body truncated"));
1159 assert!(prompt.contains("5000 chars"));
1160 }
1161
1162 #[test]
1163 fn test_build_user_prompt_empty_body() {
1164 let issue = IssueDetails::builder()
1165 .owner("test".to_string())
1166 .repo("repo".to_string())
1167 .number(1)
1168 .title("Test".to_string())
1169 .body(String::new())
1170 .labels(vec![])
1171 .comments(vec![])
1172 .url("https://github.com/test/repo/issues/1".to_string())
1173 .build();
1174
1175 let prompt = TestProvider::build_user_prompt(&issue);
1176 assert!(prompt.contains("[No description provided]"));
1177 }
1178
1179 #[test]
1180 fn test_build_create_system_prompt_contains_json_schema() {
1181 let prompt = TestProvider::build_create_system_prompt(None);
1182 assert!(prompt.contains("formatted_title"));
1183 assert!(prompt.contains("formatted_body"));
1184 assert!(prompt.contains("suggested_labels"));
1185 }
1186
1187 #[test]
1188 fn test_build_pr_review_user_prompt_respects_file_limit() {
1189 use super::super::types::{PrDetails, PrFile};
1190
1191 let mut files = Vec::new();
1192 for i in 0..25 {
1193 files.push(PrFile {
1194 filename: format!("file{i}.rs"),
1195 status: "modified".to_string(),
1196 additions: 10,
1197 deletions: 5,
1198 patch: Some(format!("patch content {i}")),
1199 });
1200 }
1201
1202 let pr = PrDetails {
1203 owner: "test".to_string(),
1204 repo: "repo".to_string(),
1205 number: 1,
1206 title: "Test PR".to_string(),
1207 body: "Description".to_string(),
1208 head_branch: "feature".to_string(),
1209 base_branch: "main".to_string(),
1210 url: "https://github.com/test/repo/pull/1".to_string(),
1211 files,
1212 labels: vec![],
1213 };
1214
1215 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1216 assert!(prompt.contains("files omitted due to size limits"));
1217 assert!(prompt.contains("MAX_FILES=20"));
1218 }
1219
1220 #[test]
1221 fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1222 use super::super::types::{PrDetails, PrFile};
1223
1224 let patch1 = "x".repeat(30_000);
1227 let patch2 = "y".repeat(30_000);
1228
1229 let files = vec![
1230 PrFile {
1231 filename: "file1.rs".to_string(),
1232 status: "modified".to_string(),
1233 additions: 100,
1234 deletions: 50,
1235 patch: Some(patch1),
1236 },
1237 PrFile {
1238 filename: "file2.rs".to_string(),
1239 status: "modified".to_string(),
1240 additions: 100,
1241 deletions: 50,
1242 patch: Some(patch2),
1243 },
1244 ];
1245
1246 let pr = PrDetails {
1247 owner: "test".to_string(),
1248 repo: "repo".to_string(),
1249 number: 1,
1250 title: "Test PR".to_string(),
1251 body: "Description".to_string(),
1252 head_branch: "feature".to_string(),
1253 base_branch: "main".to_string(),
1254 url: "https://github.com/test/repo/pull/1".to_string(),
1255 files,
1256 labels: vec![],
1257 };
1258
1259 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1260 assert!(prompt.contains("file1.rs"));
1262 assert!(prompt.contains("file2.rs"));
1263 assert!(prompt.len() < 65_000);
1266 }
1267
1268 #[test]
1269 fn test_build_pr_review_user_prompt_with_no_patches() {
1270 use super::super::types::{PrDetails, PrFile};
1271
1272 let files = vec![PrFile {
1273 filename: "file1.rs".to_string(),
1274 status: "added".to_string(),
1275 additions: 10,
1276 deletions: 0,
1277 patch: None,
1278 }];
1279
1280 let pr = PrDetails {
1281 owner: "test".to_string(),
1282 repo: "repo".to_string(),
1283 number: 1,
1284 title: "Test PR".to_string(),
1285 body: "Description".to_string(),
1286 head_branch: "feature".to_string(),
1287 base_branch: "main".to_string(),
1288 url: "https://github.com/test/repo/pull/1".to_string(),
1289 files,
1290 labels: vec![],
1291 };
1292
1293 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1294 assert!(prompt.contains("file1.rs"));
1295 assert!(prompt.contains("added"));
1296 assert!(!prompt.contains("files omitted"));
1297 }
1298
1299 #[test]
1300 fn test_build_pr_label_system_prompt_contains_json_schema() {
1301 let prompt = TestProvider::build_pr_label_system_prompt(None);
1302 assert!(prompt.contains("suggested_labels"));
1303 assert!(prompt.contains("json_object"));
1304 assert!(prompt.contains("bug"));
1305 assert!(prompt.contains("enhancement"));
1306 }
1307
1308 #[test]
1309 fn test_build_pr_label_user_prompt_with_title_and_body() {
1310 let title = "feat: add new feature";
1311 let body = "This PR adds a new feature";
1312 let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1313
1314 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1315 assert!(prompt.starts_with("<pull_request>"));
1316 assert!(prompt.ends_with("</pull_request>"));
1317 assert!(prompt.contains("feat: add new feature"));
1318 assert!(prompt.contains("This PR adds a new feature"));
1319 assert!(prompt.contains("src/main.rs"));
1320 assert!(prompt.contains("tests/test.rs"));
1321 }
1322
1323 #[test]
1324 fn test_build_pr_label_user_prompt_empty_body() {
1325 let title = "fix: bug fix";
1326 let body = "";
1327 let files = vec!["src/lib.rs".to_string()];
1328
1329 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1330 assert!(prompt.contains("[No description provided]"));
1331 assert!(prompt.contains("src/lib.rs"));
1332 }
1333
1334 #[test]
1335 fn test_build_pr_label_user_prompt_truncates_long_body() {
1336 let title = "test";
1337 let long_body = "x".repeat(5000);
1338 let files = vec![];
1339
1340 let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1341 assert!(prompt.contains("[Description truncated"));
1342 assert!(prompt.contains("5000 chars"));
1343 }
1344
1345 #[test]
1346 fn test_build_pr_label_user_prompt_respects_file_limit() {
1347 let title = "test";
1348 let body = "test";
1349 let mut files = Vec::new();
1350 for i in 0..25 {
1351 files.push(format!("file{i}.rs"));
1352 }
1353
1354 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1355 assert!(prompt.contains("file0.rs"));
1356 assert!(prompt.contains("file19.rs"));
1357 assert!(!prompt.contains("file20.rs"));
1358 assert!(prompt.contains("... and 5 more files"));
1359 }
1360
1361 #[test]
1362 fn test_build_pr_label_user_prompt_empty_files() {
1363 let title = "test";
1364 let body = "test";
1365 let files: Vec<String> = vec![];
1366
1367 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1368 assert!(prompt.contains("Title: test"));
1369 assert!(prompt.contains("Description:\ntest"));
1370 assert!(!prompt.contains("Files Changed:"));
1371 }
1372
1373 #[test]
1374 fn test_parse_ai_json_with_valid_json() {
1375 #[derive(serde::Deserialize)]
1376 struct TestResponse {
1377 message: String,
1378 }
1379
1380 let json = r#"{"message": "hello"}"#;
1381 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1382 assert!(result.is_ok());
1383 let response = result.unwrap();
1384 assert_eq!(response.message, "hello");
1385 }
1386
1387 #[test]
1388 fn test_parse_ai_json_with_truncated_json() {
1389 #[derive(Debug, serde::Deserialize)]
1390 #[allow(dead_code)]
1391 struct TestResponse {
1392 message: String,
1393 }
1394
1395 let json = r#"{"message": "hello"#;
1396 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1397 assert!(result.is_err());
1398 let err = result.unwrap_err();
1399 assert!(
1400 err.to_string()
1401 .contains("Truncated response from test-provider")
1402 );
1403 }
1404
1405 #[test]
1406 fn test_parse_ai_json_with_malformed_json() {
1407 #[derive(Debug, serde::Deserialize)]
1408 #[allow(dead_code)]
1409 struct TestResponse {
1410 message: String,
1411 }
1412
1413 let json = r#"{"message": invalid}"#;
1414 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1415 assert!(result.is_err());
1416 let err = result.unwrap_err();
1417 assert!(err.to_string().contains("Invalid JSON response from AI"));
1418 }
1419}