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 circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
109 None
110 }
111
112 fn build_headers(&self) -> reqwest::header::HeaderMap {
117 let mut headers = reqwest::header::HeaderMap::new();
118 if let Ok(val) = "application/json".parse() {
119 headers.insert("Content-Type", val);
120 }
121 headers
122 }
123
124 fn validate_model(&self) -> Result<()> {
129 Ok(())
130 }
131
132 #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
137 async fn send_request_inner(
138 &self,
139 request: &ChatCompletionRequest,
140 ) -> Result<ChatCompletionResponse> {
141 use secrecy::ExposeSecret;
142 use tracing::warn;
143
144 use crate::error::AptuError;
145
146 let mut req = self.http_client().post(self.api_url());
147
148 req = req.header(
150 "Authorization",
151 format!("Bearer {}", self.api_key().expose_secret()),
152 );
153
154 for (key, value) in &self.build_headers() {
156 req = req.header(key.clone(), value.clone());
157 }
158
159 let response = req
160 .json(request)
161 .send()
162 .await
163 .context(format!("Failed to send request to {} API", self.name()))?;
164
165 let status = response.status();
167 if !status.is_success() {
168 if status.as_u16() == 401 {
169 anyhow::bail!(
170 "Invalid {} API key. Check your {} environment variable.",
171 self.name(),
172 self.api_key_env()
173 );
174 } else if status.as_u16() == 429 {
175 warn!("Rate limited by {} API", self.name());
176 let retry_after = response
178 .headers()
179 .get("Retry-After")
180 .and_then(|h| h.to_str().ok())
181 .and_then(|s| s.parse::<u64>().ok())
182 .unwrap_or(0);
183 debug!(retry_after, "Parsed Retry-After header");
184 return Err(AptuError::RateLimited {
185 provider: self.name().to_string(),
186 retry_after,
187 }
188 .into());
189 }
190 let error_body = response.text().await.unwrap_or_default();
191 anyhow::bail!(
192 "{} API error (HTTP {}): {}",
193 self.name(),
194 status.as_u16(),
195 error_body
196 );
197 }
198
199 let completion: ChatCompletionResponse = response
201 .json()
202 .await
203 .context(format!("Failed to parse {} API response", self.name()))?;
204
205 Ok(completion)
206 }
207
208 async fn send_and_parse<T: serde::de::DeserializeOwned>(
227 &self,
228 request: &ChatCompletionRequest,
229 ) -> Result<(T, AiStats)> {
230 use backon::Retryable;
231 use tracing::warn;
232
233 use crate::error::AptuError;
234 use crate::retry::{is_retryable_anyhow, retry_backoff};
235
236 if let Some(cb) = self.circuit_breaker()
238 && cb.is_open()
239 {
240 return Err(AptuError::CircuitOpen.into());
241 }
242
243 let start = std::time::Instant::now();
245
246 let (parsed, completion): (T, ChatCompletionResponse) = (|| async {
247 let completion = self.send_request_inner(request).await?;
249
250 let content = completion
252 .choices
253 .first()
254 .map(|c| c.message.content.clone())
255 .context("No response from AI model")?;
256
257 debug!(response_length = content.len(), "Received AI response");
258
259 let parsed: T = parse_ai_json(&content, self.name())?;
261
262 Ok((parsed, completion))
263 })
264 .retry(retry_backoff())
265 .when(is_retryable_anyhow)
266 .notify(|err, dur| warn!(error = %err, delay = ?dur, "Retrying after error"))
267 .await?;
268
269 if let Some(cb) = self.circuit_breaker() {
271 cb.record_success();
272 }
273
274 #[allow(clippy::cast_possible_truncation)]
276 let duration_ms = start.elapsed().as_millis() as u64;
277
278 let (input_tokens, output_tokens, cost_usd) = if let Some(usage) = completion.usage {
280 (usage.prompt_tokens, usage.completion_tokens, usage.cost)
281 } else {
282 debug!("No usage information in API response");
284 (0, 0, None)
285 };
286
287 let ai_stats = AiStats {
288 model: self.model().to_string(),
289 input_tokens,
290 output_tokens,
291 duration_ms,
292 cost_usd,
293 fallback_provider: None,
294 };
295
296 Ok((parsed, ai_stats))
297 }
298
299 #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
313 async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
314 debug!(model = %self.model(), "Calling {} API", self.name());
315
316 let request = ChatCompletionRequest {
318 model: self.model().to_string(),
319 messages: vec![
320 ChatMessage {
321 role: "system".to_string(),
322 content: Self::build_system_prompt(None),
323 },
324 ChatMessage {
325 role: "user".to_string(),
326 content: Self::build_user_prompt(issue),
327 },
328 ],
329 response_format: Some(ResponseFormat {
330 format_type: "json_object".to_string(),
331 json_schema: None,
332 }),
333 max_tokens: Some(self.max_tokens()),
334 temperature: Some(self.temperature()),
335 };
336
337 let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
339
340 debug!(
341 input_tokens = ai_stats.input_tokens,
342 output_tokens = ai_stats.output_tokens,
343 duration_ms = ai_stats.duration_ms,
344 cost_usd = ?ai_stats.cost_usd,
345 "AI analysis complete"
346 );
347
348 Ok(AiResponse {
349 triage,
350 stats: ai_stats,
351 })
352 }
353
354 #[instrument(skip(self), fields(repo = %repo))]
371 async fn create_issue(
372 &self,
373 title: &str,
374 body: &str,
375 repo: &str,
376 ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
377 debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
378
379 let request = ChatCompletionRequest {
381 model: self.model().to_string(),
382 messages: vec![
383 ChatMessage {
384 role: "system".to_string(),
385 content: Self::build_create_system_prompt(None),
386 },
387 ChatMessage {
388 role: "user".to_string(),
389 content: Self::build_create_user_prompt(title, body, repo),
390 },
391 ],
392 response_format: Some(ResponseFormat {
393 format_type: "json_object".to_string(),
394 json_schema: None,
395 }),
396 max_tokens: Some(self.max_tokens()),
397 temperature: Some(self.temperature()),
398 };
399
400 let (create_response, ai_stats) = self
402 .send_and_parse::<super::types::CreateIssueResponse>(&request)
403 .await?;
404
405 debug!(
406 title_len = create_response.formatted_title.len(),
407 body_len = create_response.formatted_body.len(),
408 labels = create_response.suggested_labels.len(),
409 input_tokens = ai_stats.input_tokens,
410 output_tokens = ai_stats.output_tokens,
411 duration_ms = ai_stats.duration_ms,
412 "Issue formatting complete with stats"
413 );
414
415 Ok((create_response, ai_stats))
416 }
417
418 #[must_use]
420 fn build_system_prompt(custom_guidance: Option<&str>) -> String {
421 let context = super::context::load_custom_guidance(custom_guidance);
422 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}";
423 let guidelines = "Guidelines:\n\
424- summary: Concise explanation of the problem/request and why it matters\n\
425- 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\
426- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
427- 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\
428- 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\
429- 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\
430- 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\
431- 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\
432- 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\
433\n\
434Be helpful, concise, and actionable. Focus on what a maintainer needs to know.";
435 format!(
436 "You are an OSS issue triage assistant. Analyze the provided GitHub issue and \
437 provide structured triage information.\n\n{context}\n\nYour response MUST be valid \
438 JSON with this exact schema:\n{schema}\n\n{guidelines}"
439 )
440 }
441
442 #[must_use]
444 fn build_user_prompt(issue: &IssueDetails) -> String {
445 use std::fmt::Write;
446
447 let mut prompt = String::new();
448
449 prompt.push_str("<issue_content>\n");
450 let _ = writeln!(prompt, "Title: {}\n", issue.title);
451
452 let body = if issue.body.len() > MAX_BODY_LENGTH {
454 format!(
455 "{}...\n[Body truncated - original length: {} chars]",
456 &issue.body[..MAX_BODY_LENGTH],
457 issue.body.len()
458 )
459 } else if issue.body.is_empty() {
460 "[No description provided]".to_string()
461 } else {
462 issue.body.clone()
463 };
464 let _ = writeln!(prompt, "Body:\n{body}\n");
465
466 if !issue.labels.is_empty() {
468 let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
469 }
470
471 if !issue.comments.is_empty() {
473 prompt.push_str("Recent Comments:\n");
474 for comment in issue.comments.iter().take(MAX_COMMENTS) {
475 let comment_body = if comment.body.len() > 500 {
476 format!("{}...", &comment.body[..500])
477 } else {
478 comment.body.clone()
479 };
480 let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
481 }
482 prompt.push('\n');
483 }
484
485 if !issue.repo_context.is_empty() {
487 prompt.push_str("Related Issues in Repository (for context):\n");
488 for related in issue.repo_context.iter().take(10) {
489 let _ = writeln!(
490 prompt,
491 "- #{} [{}] {}",
492 related.number, related.state, related.title
493 );
494 }
495 prompt.push('\n');
496 }
497
498 if !issue.repo_tree.is_empty() {
500 prompt.push_str("Repository Structure (source files):\n");
501 for path in issue.repo_tree.iter().take(20) {
502 let _ = writeln!(prompt, "- {path}");
503 }
504 prompt.push('\n');
505 }
506
507 if !issue.available_labels.is_empty() {
509 prompt.push_str("Available Labels:\n");
510 for label in issue.available_labels.iter().take(MAX_LABELS) {
511 let description = if label.description.is_empty() {
512 String::new()
513 } else {
514 format!(" - {}", label.description)
515 };
516 let _ = writeln!(
517 prompt,
518 "- {} (color: #{}){}",
519 label.name, label.color, description
520 );
521 }
522 prompt.push('\n');
523 }
524
525 if !issue.available_milestones.is_empty() {
527 prompt.push_str("Available Milestones:\n");
528 for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
529 let description = if milestone.description.is_empty() {
530 String::new()
531 } else {
532 format!(" - {}", milestone.description)
533 };
534 let _ = writeln!(prompt, "- {}{}", milestone.title, description);
535 }
536 prompt.push('\n');
537 }
538
539 prompt.push_str("</issue_content>");
540
541 prompt
542 }
543
544 #[must_use]
546 fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
547 let context = super::context::load_custom_guidance(custom_guidance);
548 format!(
549 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.
550
551{context}
552
553Your response MUST be valid JSON with this exact schema:
554{{
555 "formatted_title": "Well-formatted issue title following conventional commit style",
556 "formatted_body": "Professionally formatted issue body with clear sections",
557 "suggested_labels": ["label1", "label2"]
558}}
559
560Guidelines:
561- 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.
562- formatted_body: Structure the body with clear sections:
563 * Start with a brief 1-2 sentence summary if not already present
564 * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
565 * Keep sentences clear and concise
566 * Use bullet points for lists
567 * Improve grammar and clarity
568 * Add relevant context if missing
569- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.
570
571Be professional but friendly. Maintain the user's intent while improving clarity and structure."#
572 )
573 }
574
575 #[must_use]
577 fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
578 format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
579 }
580
581 #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
595 async fn review_pr(
596 &self,
597 pr: &super::types::PrDetails,
598 ) -> Result<(super::types::PrReviewResponse, AiStats)> {
599 debug!(model = %self.model(), "Calling {} API for PR review", self.name());
600
601 let request = ChatCompletionRequest {
603 model: self.model().to_string(),
604 messages: vec![
605 ChatMessage {
606 role: "system".to_string(),
607 content: Self::build_pr_review_system_prompt(None),
608 },
609 ChatMessage {
610 role: "user".to_string(),
611 content: Self::build_pr_review_user_prompt(pr),
612 },
613 ],
614 response_format: Some(ResponseFormat {
615 format_type: "json_object".to_string(),
616 json_schema: None,
617 }),
618 max_tokens: Some(self.max_tokens()),
619 temperature: Some(self.temperature()),
620 };
621
622 let (review, ai_stats) = self
624 .send_and_parse::<super::types::PrReviewResponse>(&request)
625 .await?;
626
627 debug!(
628 verdict = %review.verdict,
629 input_tokens = ai_stats.input_tokens,
630 output_tokens = ai_stats.output_tokens,
631 duration_ms = ai_stats.duration_ms,
632 "PR review complete with stats"
633 );
634
635 Ok((review, ai_stats))
636 }
637
638 #[instrument(skip(self), fields(title = %title))]
654 async fn suggest_pr_labels(
655 &self,
656 title: &str,
657 body: &str,
658 file_paths: &[String],
659 ) -> Result<(Vec<String>, AiStats)> {
660 debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
661
662 let request = ChatCompletionRequest {
664 model: self.model().to_string(),
665 messages: vec![
666 ChatMessage {
667 role: "system".to_string(),
668 content: Self::build_pr_label_system_prompt(None),
669 },
670 ChatMessage {
671 role: "user".to_string(),
672 content: Self::build_pr_label_user_prompt(title, body, file_paths),
673 },
674 ],
675 response_format: Some(ResponseFormat {
676 format_type: "json_object".to_string(),
677 json_schema: None,
678 }),
679 max_tokens: Some(self.max_tokens()),
680 temperature: Some(self.temperature()),
681 };
682
683 let (response, ai_stats) = self
685 .send_and_parse::<super::types::PrLabelResponse>(&request)
686 .await?;
687
688 debug!(
689 label_count = response.suggested_labels.len(),
690 input_tokens = ai_stats.input_tokens,
691 output_tokens = ai_stats.output_tokens,
692 duration_ms = ai_stats.duration_ms,
693 "PR label suggestion complete with stats"
694 );
695
696 Ok((response.suggested_labels, ai_stats))
697 }
698
699 #[must_use]
701 fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
702 let context = super::context::load_custom_guidance(custom_guidance);
703 format!(
704 r#"You are a code review assistant. Analyze the provided pull request and provide structured review feedback.
705
706{context}
707
708Your response MUST be valid JSON with this exact schema:
709{{
710 "summary": "A 2-3 sentence summary of what the PR does and its impact",
711 "verdict": "approve|request_changes|comment",
712 "strengths": ["strength1", "strength2"],
713 "concerns": ["concern1", "concern2"],
714 "comments": [
715 {{
716 "file": "path/to/file.rs",
717 "line": 42,
718 "comment": "Specific feedback about this line",
719 "severity": "info|suggestion|warning|issue"
720 }}
721 ],
722 "suggestions": ["suggestion1", "suggestion2"]
723}}
724
725Guidelines:
726- summary: Concise explanation of the changes and their purpose
727- verdict: Use "approve" for good PRs, "request_changes" for blocking issues, "comment" for feedback without blocking
728- strengths: What the PR does well (good patterns, clear code, etc.)
729- concerns: Potential issues or risks (bugs, performance, security, maintainability)
730- comments: Specific line-level feedback. Use severity:
731 - "info": Informational, no action needed
732 - "suggestion": Optional improvement
733 - "warning": Should consider changing
734 - "issue": Should be fixed before merge
735- suggestions: General improvements that are not blocking
736
737Focus on:
7381. Correctness: Does the code do what it claims?
7392. Security: Any potential vulnerabilities?
7403. Performance: Any obvious inefficiencies?
7414. Maintainability: Is the code clear and well-structured?
7425. Testing: Are changes adequately tested?
743
744Be constructive and specific. Explain why something is an issue and how to fix it."#
745 )
746 }
747
748 #[must_use]
750 fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
751 use std::fmt::Write;
752
753 let mut prompt = String::new();
754
755 prompt.push_str("<pull_request>\n");
756 let _ = writeln!(prompt, "Title: {}\n", pr.title);
757 let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
758
759 let body = if pr.body.is_empty() {
761 "[No description provided]".to_string()
762 } else if pr.body.len() > MAX_BODY_LENGTH {
763 format!(
764 "{}...\n[Description truncated - original length: {} chars]",
765 &pr.body[..MAX_BODY_LENGTH],
766 pr.body.len()
767 )
768 } else {
769 pr.body.clone()
770 };
771 let _ = writeln!(prompt, "Description:\n{body}\n");
772
773 prompt.push_str("Files Changed:\n");
775 let mut total_diff_size = 0;
776 let mut files_included = 0;
777 let mut files_skipped = 0;
778
779 for file in &pr.files {
780 if files_included >= MAX_FILES {
782 files_skipped += 1;
783 continue;
784 }
785
786 let _ = writeln!(
787 prompt,
788 "- {} ({}) +{} -{}\n",
789 file.filename, file.status, file.additions, file.deletions
790 );
791
792 if let Some(patch) = &file.patch {
794 const MAX_PATCH_LENGTH: usize = 2000;
795 let patch_content = if patch.len() > MAX_PATCH_LENGTH {
796 format!(
797 "{}...\n[Patch truncated - original length: {} chars]",
798 &patch[..MAX_PATCH_LENGTH],
799 patch.len()
800 )
801 } else {
802 patch.clone()
803 };
804
805 let patch_size = patch_content.len();
807 if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
808 let _ = writeln!(
809 prompt,
810 "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
811 );
812 files_skipped += 1;
813 continue;
814 }
815
816 let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
817 total_diff_size += patch_size;
818 }
819
820 files_included += 1;
821 }
822
823 if files_skipped > 0 {
825 let _ = writeln!(
826 prompt,
827 "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
828 );
829 }
830
831 prompt.push_str("</pull_request>");
832
833 prompt
834 }
835
836 #[must_use]
838 fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
839 let context = super::context::load_custom_guidance(custom_guidance);
840 format!(
841 r#"You are a GitHub label suggestion assistant. Analyze the provided pull request and suggest relevant labels.
842
843{context}
844
845Your response MUST be valid JSON with this exact schema:
846{{
847 "suggested_labels": ["label1", "label2", "label3"]
848}}
849
850Response format: json_object
851
852Guidelines:
853- 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.
854- Focus on the PR title, description, and file paths to determine appropriate labels.
855- Prefer specific labels over generic ones when possible.
856- Only suggest labels that are commonly used in GitHub repositories.
857
858Be concise and practical."#
859 )
860 }
861
862 #[must_use]
864 fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
865 use std::fmt::Write;
866
867 let mut prompt = String::new();
868
869 prompt.push_str("<pull_request>\n");
870 let _ = writeln!(prompt, "Title: {title}\n");
871
872 let body_content = if body.is_empty() {
874 "[No description provided]".to_string()
875 } else if body.len() > MAX_BODY_LENGTH {
876 format!(
877 "{}...\n[Description truncated - original length: {} chars]",
878 &body[..MAX_BODY_LENGTH],
879 body.len()
880 )
881 } else {
882 body.to_string()
883 };
884 let _ = writeln!(prompt, "Description:\n{body_content}\n");
885
886 if !file_paths.is_empty() {
888 prompt.push_str("Files Changed:\n");
889 for path in file_paths.iter().take(20) {
890 let _ = writeln!(prompt, "- {path}");
891 }
892 if file_paths.len() > 20 {
893 let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
894 }
895 prompt.push('\n');
896 }
897
898 prompt.push_str("</pull_request>");
899
900 prompt
901 }
902
903 #[instrument(skip(self, prs))]
914 async fn generate_release_notes(
915 &self,
916 prs: Vec<super::types::PrSummary>,
917 version: &str,
918 ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
919 let prompt = Self::build_release_notes_prompt(&prs, version);
920 let request = ChatCompletionRequest {
921 model: self.model().to_string(),
922 messages: vec![ChatMessage {
923 role: "user".to_string(),
924 content: prompt,
925 }],
926 response_format: Some(ResponseFormat {
927 format_type: "json_object".to_string(),
928 json_schema: None,
929 }),
930 temperature: Some(0.7),
931 max_tokens: Some(self.max_tokens()),
932 };
933
934 let (parsed, ai_stats) = self
935 .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
936 .await?;
937
938 debug!(
939 input_tokens = ai_stats.input_tokens,
940 output_tokens = ai_stats.output_tokens,
941 duration_ms = ai_stats.duration_ms,
942 "Release notes generation complete with stats"
943 );
944
945 Ok((parsed, ai_stats))
946 }
947
948 #[must_use]
950 fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
951 let pr_list = prs
952 .iter()
953 .map(|pr| {
954 format!(
955 "- #{}: {} (by @{})\n {}",
956 pr.number,
957 pr.title,
958 pr.author,
959 pr.body.lines().next().unwrap_or("")
960 )
961 })
962 .collect::<Vec<_>>()
963 .join("\n");
964
965 format!(
966 r#"Generate release notes for version {version} based on these merged PRs:
967
968{pr_list}
969
970Create a curated release notes document with:
9711. A theme/title that captures the essence of this release
9722. A 1-2 sentence narrative about the release
9733. 3-5 highlighted features
9744. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
9755. List of contributors
976
977Follow these conventions:
978- No emojis
979- Bold feature names with dash separator
980- Include PR numbers in parentheses
981- Group by user impact, not just commit type
982- Filter CI/deps under Maintenance
983
984Your response MUST be valid JSON with this exact schema:
985{{
986 "theme": "Release theme title",
987 "narrative": "1-2 sentence summary",
988 "highlights": ["highlight1", "highlight2"],
989 "features": ["feature1", "feature2"],
990 "fixes": ["fix1", "fix2"],
991 "improvements": ["improvement1"],
992 "documentation": ["doc change1"],
993 "maintenance": ["maintenance1"],
994 "contributors": ["@author1", "@author2"]
995}}"#
996 )
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 struct TestProvider;
1005
1006 impl AiProvider for TestProvider {
1007 fn name(&self) -> &'static str {
1008 "test"
1009 }
1010
1011 fn api_url(&self) -> &'static str {
1012 "https://test.example.com"
1013 }
1014
1015 fn api_key_env(&self) -> &'static str {
1016 "TEST_API_KEY"
1017 }
1018
1019 fn http_client(&self) -> &Client {
1020 unimplemented!()
1021 }
1022
1023 fn api_key(&self) -> &SecretString {
1024 unimplemented!()
1025 }
1026
1027 fn model(&self) -> &'static str {
1028 "test-model"
1029 }
1030
1031 fn max_tokens(&self) -> u32 {
1032 2048
1033 }
1034
1035 fn temperature(&self) -> f32 {
1036 0.3
1037 }
1038 }
1039
1040 #[test]
1041 fn test_build_system_prompt_contains_json_schema() {
1042 let prompt = TestProvider::build_system_prompt(None);
1043 assert!(prompt.contains("summary"));
1044 assert!(prompt.contains("suggested_labels"));
1045 assert!(prompt.contains("clarifying_questions"));
1046 assert!(prompt.contains("potential_duplicates"));
1047 assert!(prompt.contains("status_note"));
1048 }
1049
1050 #[test]
1051 fn test_build_user_prompt_with_delimiters() {
1052 let issue = IssueDetails::builder()
1053 .owner("test".to_string())
1054 .repo("repo".to_string())
1055 .number(1)
1056 .title("Test issue".to_string())
1057 .body("This is the body".to_string())
1058 .labels(vec!["bug".to_string()])
1059 .comments(vec![])
1060 .url("https://github.com/test/repo/issues/1".to_string())
1061 .build();
1062
1063 let prompt = TestProvider::build_user_prompt(&issue);
1064 assert!(prompt.starts_with("<issue_content>"));
1065 assert!(prompt.ends_with("</issue_content>"));
1066 assert!(prompt.contains("Title: Test issue"));
1067 assert!(prompt.contains("This is the body"));
1068 assert!(prompt.contains("Existing Labels: bug"));
1069 }
1070
1071 #[test]
1072 fn test_build_user_prompt_truncates_long_body() {
1073 let long_body = "x".repeat(5000);
1074 let issue = IssueDetails::builder()
1075 .owner("test".to_string())
1076 .repo("repo".to_string())
1077 .number(1)
1078 .title("Test".to_string())
1079 .body(long_body)
1080 .labels(vec![])
1081 .comments(vec![])
1082 .url("https://github.com/test/repo/issues/1".to_string())
1083 .build();
1084
1085 let prompt = TestProvider::build_user_prompt(&issue);
1086 assert!(prompt.contains("[Body truncated"));
1087 assert!(prompt.contains("5000 chars"));
1088 }
1089
1090 #[test]
1091 fn test_build_user_prompt_empty_body() {
1092 let issue = IssueDetails::builder()
1093 .owner("test".to_string())
1094 .repo("repo".to_string())
1095 .number(1)
1096 .title("Test".to_string())
1097 .body(String::new())
1098 .labels(vec![])
1099 .comments(vec![])
1100 .url("https://github.com/test/repo/issues/1".to_string())
1101 .build();
1102
1103 let prompt = TestProvider::build_user_prompt(&issue);
1104 assert!(prompt.contains("[No description provided]"));
1105 }
1106
1107 #[test]
1108 fn test_build_create_system_prompt_contains_json_schema() {
1109 let prompt = TestProvider::build_create_system_prompt(None);
1110 assert!(prompt.contains("formatted_title"));
1111 assert!(prompt.contains("formatted_body"));
1112 assert!(prompt.contains("suggested_labels"));
1113 }
1114
1115 #[test]
1116 fn test_build_pr_review_user_prompt_respects_file_limit() {
1117 use super::super::types::{PrDetails, PrFile};
1118
1119 let mut files = Vec::new();
1120 for i in 0..25 {
1121 files.push(PrFile {
1122 filename: format!("file{i}.rs"),
1123 status: "modified".to_string(),
1124 additions: 10,
1125 deletions: 5,
1126 patch: Some(format!("patch content {i}")),
1127 });
1128 }
1129
1130 let pr = PrDetails {
1131 owner: "test".to_string(),
1132 repo: "repo".to_string(),
1133 number: 1,
1134 title: "Test PR".to_string(),
1135 body: "Description".to_string(),
1136 head_branch: "feature".to_string(),
1137 base_branch: "main".to_string(),
1138 url: "https://github.com/test/repo/pull/1".to_string(),
1139 files,
1140 labels: vec![],
1141 };
1142
1143 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1144 assert!(prompt.contains("files omitted due to size limits"));
1145 assert!(prompt.contains("MAX_FILES=20"));
1146 }
1147
1148 #[test]
1149 fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1150 use super::super::types::{PrDetails, PrFile};
1151
1152 let patch1 = "x".repeat(30_000);
1155 let patch2 = "y".repeat(30_000);
1156
1157 let files = vec![
1158 PrFile {
1159 filename: "file1.rs".to_string(),
1160 status: "modified".to_string(),
1161 additions: 100,
1162 deletions: 50,
1163 patch: Some(patch1),
1164 },
1165 PrFile {
1166 filename: "file2.rs".to_string(),
1167 status: "modified".to_string(),
1168 additions: 100,
1169 deletions: 50,
1170 patch: Some(patch2),
1171 },
1172 ];
1173
1174 let pr = PrDetails {
1175 owner: "test".to_string(),
1176 repo: "repo".to_string(),
1177 number: 1,
1178 title: "Test PR".to_string(),
1179 body: "Description".to_string(),
1180 head_branch: "feature".to_string(),
1181 base_branch: "main".to_string(),
1182 url: "https://github.com/test/repo/pull/1".to_string(),
1183 files,
1184 labels: vec![],
1185 };
1186
1187 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1188 assert!(prompt.contains("file1.rs"));
1190 assert!(prompt.contains("file2.rs"));
1191 assert!(prompt.len() < 65_000);
1194 }
1195
1196 #[test]
1197 fn test_build_pr_review_user_prompt_with_no_patches() {
1198 use super::super::types::{PrDetails, PrFile};
1199
1200 let files = vec![PrFile {
1201 filename: "file1.rs".to_string(),
1202 status: "added".to_string(),
1203 additions: 10,
1204 deletions: 0,
1205 patch: None,
1206 }];
1207
1208 let pr = PrDetails {
1209 owner: "test".to_string(),
1210 repo: "repo".to_string(),
1211 number: 1,
1212 title: "Test PR".to_string(),
1213 body: "Description".to_string(),
1214 head_branch: "feature".to_string(),
1215 base_branch: "main".to_string(),
1216 url: "https://github.com/test/repo/pull/1".to_string(),
1217 files,
1218 labels: vec![],
1219 };
1220
1221 let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1222 assert!(prompt.contains("file1.rs"));
1223 assert!(prompt.contains("added"));
1224 assert!(!prompt.contains("files omitted"));
1225 }
1226
1227 #[test]
1228 fn test_build_pr_label_system_prompt_contains_json_schema() {
1229 let prompt = TestProvider::build_pr_label_system_prompt(None);
1230 assert!(prompt.contains("suggested_labels"));
1231 assert!(prompt.contains("json_object"));
1232 assert!(prompt.contains("bug"));
1233 assert!(prompt.contains("enhancement"));
1234 }
1235
1236 #[test]
1237 fn test_build_pr_label_user_prompt_with_title_and_body() {
1238 let title = "feat: add new feature";
1239 let body = "This PR adds a new feature";
1240 let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1241
1242 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1243 assert!(prompt.starts_with("<pull_request>"));
1244 assert!(prompt.ends_with("</pull_request>"));
1245 assert!(prompt.contains("feat: add new feature"));
1246 assert!(prompt.contains("This PR adds a new feature"));
1247 assert!(prompt.contains("src/main.rs"));
1248 assert!(prompt.contains("tests/test.rs"));
1249 }
1250
1251 #[test]
1252 fn test_build_pr_label_user_prompt_empty_body() {
1253 let title = "fix: bug fix";
1254 let body = "";
1255 let files = vec!["src/lib.rs".to_string()];
1256
1257 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1258 assert!(prompt.contains("[No description provided]"));
1259 assert!(prompt.contains("src/lib.rs"));
1260 }
1261
1262 #[test]
1263 fn test_build_pr_label_user_prompt_truncates_long_body() {
1264 let title = "test";
1265 let long_body = "x".repeat(5000);
1266 let files = vec![];
1267
1268 let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1269 assert!(prompt.contains("[Description truncated"));
1270 assert!(prompt.contains("5000 chars"));
1271 }
1272
1273 #[test]
1274 fn test_build_pr_label_user_prompt_respects_file_limit() {
1275 let title = "test";
1276 let body = "test";
1277 let mut files = Vec::new();
1278 for i in 0..25 {
1279 files.push(format!("file{i}.rs"));
1280 }
1281
1282 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1283 assert!(prompt.contains("file0.rs"));
1284 assert!(prompt.contains("file19.rs"));
1285 assert!(!prompt.contains("file20.rs"));
1286 assert!(prompt.contains("... and 5 more files"));
1287 }
1288
1289 #[test]
1290 fn test_build_pr_label_user_prompt_empty_files() {
1291 let title = "test";
1292 let body = "test";
1293 let files: Vec<String> = vec![];
1294
1295 let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1296 assert!(prompt.contains("Title: test"));
1297 assert!(prompt.contains("Description:\ntest"));
1298 assert!(!prompt.contains("Files Changed:"));
1299 }
1300
1301 #[test]
1302 fn test_parse_ai_json_with_valid_json() {
1303 #[derive(serde::Deserialize)]
1304 struct TestResponse {
1305 message: String,
1306 }
1307
1308 let json = r#"{"message": "hello"}"#;
1309 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1310 assert!(result.is_ok());
1311 let response = result.unwrap();
1312 assert_eq!(response.message, "hello");
1313 }
1314
1315 #[test]
1316 fn test_parse_ai_json_with_truncated_json() {
1317 #[derive(Debug, serde::Deserialize)]
1318 #[allow(dead_code)]
1319 struct TestResponse {
1320 message: String,
1321 }
1322
1323 let json = r#"{"message": "hello"#;
1324 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1325 assert!(result.is_err());
1326 let err = result.unwrap_err();
1327 assert!(
1328 err.to_string()
1329 .contains("Truncated response from test-provider")
1330 );
1331 }
1332
1333 #[test]
1334 fn test_parse_ai_json_with_malformed_json() {
1335 #[derive(Debug, serde::Deserialize)]
1336 #[allow(dead_code)]
1337 struct TestResponse {
1338 message: String,
1339 }
1340
1341 let json = r#"{"message": invalid}"#;
1342 let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1343 assert!(result.is_err());
1344 let err = result.unwrap_err();
1345 assert!(err.to_string().contains("Invalid JSON response from AI"));
1346 }
1347}