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