1use anyhow::{Context, Result};
9#[cfg(not(target_arch = "wasm32"))]
10use octocrab::Octocrab;
11use tracing::{debug, instrument};
12
13use super::{ReferenceKind, parse_github_reference};
14use crate::ai::review_context::truncate_at_line_boundary;
15use crate::ai::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
16use crate::error::{AptuError, ResourceType};
17use crate::triage::render_pr_review_comment_body;
18
19#[derive(Debug, serde::Serialize)]
21pub struct PrCreateResult {
22 pub pr_number: u64,
24 pub url: String,
26 pub branch: String,
28 pub base: String,
30 pub title: String,
32 pub draft: bool,
34 pub files_changed: u32,
36 pub additions: u64,
38 pub deletions: u64,
40}
41
42pub fn parse_pr_reference(
62 reference: &str,
63 repo_context: Option<&str>,
64) -> Result<(String, String, u64)> {
65 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
66}
67
68#[cfg(not(target_arch = "wasm32"))]
87#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
88#[allow(clippy::too_many_lines)]
89pub async fn fetch_pr_details(
90 client: &Octocrab,
91 owner: &str,
92 repo: &str,
93 number: u64,
94 review_config: &crate::config::ReviewConfig,
95) -> Result<PrDetails> {
96 debug!("Fetching PR details");
97
98 let pr = match client.pulls(owner, repo).get(number).await {
100 Ok(pr) => pr,
101 Err(e) => {
102 if let octocrab::Error::GitHub { source, .. } = &e
104 && source.status_code == 404
105 {
106 if (client.issues(owner, repo).get(number).await).is_ok() {
108 return Err(AptuError::TypeMismatch {
109 number,
110 expected: ResourceType::PullRequest,
111 actual: ResourceType::Issue,
112 }
113 .into());
114 }
115 }
117 return Err(e)
118 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
119 }
120 };
121
122 let mut pr_files: Vec<PrFile> = Vec::new();
124 let mut page = client
125 .pulls(owner, repo)
126 .list_files(number)
127 .await
128 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
129
130 loop {
131 pr_files.extend(page.items.into_iter().map(|f| PrFile {
132 filename: f.filename,
133 status: format!("{:?}", f.status),
134 additions: f.additions,
135 deletions: f.deletions,
136 patch: f.patch,
137 patch_truncated: false,
138 full_content: None,
139 }));
140
141 if pr_files.len() >= 300 {
142 tracing::warn!(
143 "PR #{} has reached 300-file cap; stopping pagination",
144 number
145 );
146 pr_files.truncate(300);
147 break;
148 }
149
150 match client
151 .get_page::<octocrab::models::repos::DiffEntry>(&page.next)
152 .await
153 {
154 Ok(Some(next_page)) => page = next_page,
155 Ok(None) => break,
156 Err(e) => {
157 tracing::warn!("Error fetching next page of files: {}", e);
158 break;
159 }
160 }
161 }
162
163 let head_sha = pr.head.sha.as_str();
164
165 for file in &mut pr_files {
167 #[allow(clippy::collapsible_if)]
168 if let Some(patch) = &file.patch {
169 if is_patch_truncated(patch) {
170 file.patch_truncated = true;
171 if let Ok(Some(content)) = fetch_file_contents_single(
173 client,
174 owner,
175 repo,
176 &file.filename,
177 head_sha,
178 review_config.max_chars_per_file,
179 )
180 .await
181 {
182 file.patch = Some(content);
183 }
184 }
185 }
186 }
187
188 for file in &mut pr_files {
192 let is_added_renamed_copied = matches!(
194 file.status.to_lowercase().as_str(),
195 "added" | "renamed" | "copied"
196 );
197 let patch_too_large =
198 file.patch.as_deref().map_or(0, str::len) > review_config.max_patch_chars_per_file;
199 if is_added_renamed_copied && patch_too_large && file.full_content.is_none() {
200 match fetch_file_contents_single(
201 client,
202 owner,
203 repo,
204 &file.filename,
205 head_sha,
206 review_config.max_chars_per_file,
207 )
208 .await
209 {
210 Ok(Some(content)) => {
211 file.full_content = Some(content);
212 }
213 Ok(None) => {
214 tracing::warn!(
215 "Contents API returned empty content for added file {} in PR #{}",
216 file.filename,
217 number
218 );
219 }
220 Err(e) => {
221 tracing::warn!(
222 "Failed to fetch contents for added file {} in PR #{}: {}",
223 file.filename,
224 number,
225 e
226 );
227 }
228 }
229 }
230 }
231
232 let file_contents = fetch_file_contents(
234 client,
235 owner,
236 repo,
237 &pr_files,
238 pr.head.sha.as_str(),
239 review_config.max_full_content_files,
240 review_config.max_chars_per_file,
241 )
242 .await;
243
244 debug_assert_eq!(
246 pr_files.len(),
247 file_contents.len(),
248 "fetch_file_contents must return one entry per file"
249 );
250 let pr_files: Vec<PrFile> = pr_files
251 .into_iter()
252 .zip(file_contents)
253 .map(|(mut file, content)| {
254 if file.full_content.is_none() {
255 file.full_content = content;
256 }
257 file
258 })
259 .collect();
260
261 let labels: Vec<String> = pr
262 .labels
263 .iter()
264 .flat_map(|v| v.iter())
265 .map(|l| l.name.clone())
266 .collect();
267
268 let mut review_comments: Vec<crate::ai::types::PrReviewCommentDetails> = Vec::new();
271 let bot_login = match client.current().user().await {
272 Ok(user) => user.login,
273 Err(e) => {
274 tracing::warn!("Failed to resolve bot login; skipping review comment fetch: {e}");
275 String::new()
276 }
277 };
278 if !bot_login.is_empty() {
279 let mut page = client
280 .pulls(owner, repo)
281 .list_comments(Some(number))
282 .per_page(100)
283 .send()
284 .await
285 .with_context(|| format!("Failed to fetch review comments for PR #{number}"))?;
286
287 loop {
288 review_comments.extend(page.items.into_iter().filter_map(|c| {
289 let author = c.user.as_ref().map(|u| u.login.clone()).unwrap_or_default();
290 if author != bot_login {
291 return None;
292 }
293 Some(crate::ai::types::PrReviewCommentDetails {
294 id: c.id.0,
295 author,
296 body: c.body.clone(),
297 path: c.path,
298 line: c.line,
299 side: c.side,
300 commit_id: c.commit_id,
301 })
302 }));
303
304 if review_comments.len() >= 300 {
307 tracing::warn!(
308 "PR #{} has reached 300-comment cap; stopping pagination",
309 number
310 );
311 review_comments.truncate(300);
312 break;
313 }
314
315 match client
316 .get_page::<octocrab::models::pulls::Comment>(&page.next)
317 .await
318 {
319 Ok(Some(next_page)) => page = next_page,
320 Ok(None) => break,
321 Err(e) => {
322 tracing::warn!("Error fetching next page of review comments: {}", e);
323 break;
324 }
325 }
326 }
327 }
328 debug!(
329 review_comments = review_comments.len(),
330 "Existing review comments fetched"
331 );
332
333 let details = PrDetails {
334 owner: owner.to_string(),
335 repo: repo.to_string(),
336 number,
337 title: pr.title.clone().unwrap_or_default(),
338 body: pr.body.clone().unwrap_or_default(),
339 base_branch: pr.base.ref_field.clone(),
340 head_branch: pr.head.ref_field.clone(),
341 head_sha: pr.head.sha.as_str().to_string(),
342 files: pr_files,
343 url: pr
344 .html_url
345 .as_ref()
346 .map(std::string::ToString::to_string)
347 .unwrap_or_default(),
348 labels,
349 review_comments,
350 instructions: None,
351 dep_enrichments: Vec::new(),
352 };
353
354 debug!(
355 file_count = details.files.len(),
356 "PR details fetched successfully"
357 );
358
359 Ok(details)
360}
361
362fn is_patch_truncated(patch: &str) -> bool {
367 let lines: Vec<&str> = patch.lines().collect();
368
369 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
371 && (last_line.starts_with('+') || last_line.starts_with('-'))
372 {
373 return true;
374 }
375
376 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
379 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
381 if let Some(size_str) = plus_part.split_whitespace().next() {
383 if let Some(count_str) = size_str.split(',').nth(1)
385 && let Ok(declared_count) = count_str.parse::<usize>()
386 {
387 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
390 {
391 let lines_after_hunk = &lines[hunk_idx + 1..];
392 let mut actual_count = 0;
395 for line in lines_after_hunk {
396 if line.starts_with("@@") {
397 break;
398 }
399 if line.starts_with(' ')
400 || line.starts_with('+')
401 || line.starts_with('-')
402 {
403 actual_count += 1;
404 }
405 }
406 if actual_count < declared_count {
408 return true;
409 }
410 }
411 }
412 }
413 }
414 }
415
416 false
417}
418
419#[cfg(not(target_arch = "wasm32"))]
424async fn fetch_file_contents_single(
425 client: &Octocrab,
426 owner: &str,
427 repo: &str,
428 filename: &str,
429 head_sha: &str,
430 max_chars: usize,
431) -> Result<Option<String>> {
432 match client
433 .repos(owner, repo)
434 .get_content()
435 .path(filename)
436 .r#ref(head_sha)
437 .send()
438 .await
439 {
440 Ok(content) => {
441 if let Some(item) = content.items.first() {
443 if let Some(decoded) = item.decoded_content() {
444 let truncated = if decoded.chars().count() > max_chars {
445 truncate_at_line_boundary(&decoded, max_chars)
446 } else {
447 decoded
448 };
449 Ok(Some(truncated))
450 } else {
451 tracing::warn!(
452 "Failed to decode content for {}/{}/{} at {}",
453 owner,
454 repo,
455 filename,
456 head_sha
457 );
458 Ok(None)
459 }
460 } else {
461 tracing::warn!(
462 "File content response was empty for {}/{}/{} at {}",
463 owner,
464 repo,
465 filename,
466 head_sha
467 );
468 Ok(None)
469 }
470 }
471 Err(e) => {
472 tracing::warn!(
473 "Failed to fetch content for {}/{}/{} at {}: {}",
474 owner,
475 repo,
476 filename,
477 head_sha,
478 e
479 );
480 Ok(None)
481 }
482 }
483}
484
485#[cfg(not(target_arch = "wasm32"))]
507#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
508async fn fetch_file_contents(
509 client: &Octocrab,
510 owner: &str,
511 repo: &str,
512 files: &[PrFile],
513 head_sha: &str,
514 max_files: usize,
515 max_chars_per_file: usize,
516) -> Vec<Option<String>> {
517 let mut results = Vec::with_capacity(files.len());
518 let mut fetched_count = 0usize;
519
520 for file in files {
521 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
522 results.push(None);
523 continue;
524 }
525
526 if fetched_count >= max_files {
528 debug!(
529 file = %file.filename,
530 fetched_count = fetched_count,
531 max_files = max_files,
532 "Fetched file count exceeds max_files cap"
533 );
534 results.push(None);
535 continue;
536 }
537
538 match client
540 .repos(owner, repo)
541 .get_content()
542 .path(&file.filename)
543 .r#ref(head_sha)
544 .send()
545 .await
546 {
547 Ok(content) => {
548 if let Some(item) = content.items.first() {
550 if let Some(decoded) = item.decoded_content() {
551 let truncated = if decoded.chars().count() > max_chars_per_file {
552 truncate_at_line_boundary(&decoded, max_chars_per_file)
553 } else {
554 decoded
555 };
556 debug!(
557 file = %file.filename,
558 content_len = truncated.len(),
559 "File content fetched and truncated"
560 );
561 results.push(Some(truncated));
562 fetched_count += 1;
563 } else {
564 tracing::warn!(
565 file = %file.filename,
566 "Failed to decode file content; skipping"
567 );
568 results.push(None);
569 }
570 } else {
571 tracing::warn!(
572 file = %file.filename,
573 "File content response was empty; skipping"
574 );
575 results.push(None);
576 }
577 }
578 Err(e) => {
579 tracing::warn!(
580 file = %file.filename,
581 err = %e,
582 "Failed to fetch file content; skipping"
583 );
584 results.push(None);
585 }
586 }
587 }
588
589 results
590}
591
592#[cfg(not(target_arch = "wasm32"))]
616#[allow(clippy::too_many_arguments)]
617#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
618pub async fn post_pr_review(
619 client: &Octocrab,
620 owner: &str,
621 repo: &str,
622 number: u64,
623 body: &str,
624 event: ReviewEvent,
625 comments: &[PrReviewComment],
626 commit_id: &str,
627) -> Result<u64> {
628 debug!("Posting PR review");
629
630 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
631
632 let inline_comments: Vec<serde_json::Value> = comments
634 .iter()
635 .filter_map(|c| {
637 c.line.map(|line| {
638 serde_json::json!({
639 "path": c.file,
640 "line": line,
641 "side": "RIGHT",
645 "body": render_pr_review_comment_body(c),
646 })
647 })
648 })
649 .collect();
650
651 let mut payload = serde_json::json!({
652 "body": body,
653 "event": event.to_string(),
654 "comments": inline_comments,
655 });
656
657 if !commit_id.is_empty() {
659 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
660 }
661
662 #[derive(serde::Deserialize)]
663 struct ReviewResponse {
664 id: u64,
665 }
666
667 match client.post::<_, ReviewResponse>(route, Some(&payload)).await {
668 Ok(response) => {
669 debug!(review_id = response.id, "PR review posted successfully");
670 Ok(response.id)
671 }
672 Err(octocrab::Error::GitHub { source, .. }) => {
673 tracing::warn!(
674 status = source.status_code.as_u16(),
675 github_message = %source.message,
676 "Failed to post review to PR"
677 );
678 Err(anyhow::anyhow!(
679 "Failed to post review to PR #{number} in {owner}/{repo}. \
680 GitHub API returned HTTP {}: {}. \
681 Check that you have write access to the repository.",
682 source.status_code.as_u16(),
683 source.message,
684 ))
685 }
686 Err(e) => {
687 Err(e).with_context(|| {
688 format!(
689 "Failed to post review to PR #{number} in {owner}/{repo}. Check that you have write access to the repository."
690 )
691 })
692 }
693 }
694}
695
696#[cfg(not(target_arch = "wasm32"))]
703#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
704pub async fn delete_pr_review_comment(
705 client: &Octocrab,
706 owner: &str,
707 repo: &str,
708 comment_id: u64,
709) -> Result<()> {
710 debug!("Deleting PR review comment");
711
712 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
713
714 let empty_body = serde_json::json!({});
716 let result: std::result::Result<serde_json::Value, _> =
717 client.delete(&route, Some(&empty_body)).await;
718
719 match result {
720 Ok(_) => {
721 debug!("PR review comment deleted successfully");
722 Ok(())
723 }
724 Err(e)
725 if let octocrab::Error::GitHub { source, .. } = &e
726 && source.status_code.as_u16() == 404 =>
727 {
728 debug!("PR review comment already deleted (404); treating as success");
729 Ok(())
730 }
731 Err(e) => {
732 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
733 }
734 }
735}
736
737#[cfg(not(target_arch = "wasm32"))]
748#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
749pub async fn update_pr_review_comment(
750 client: &Octocrab,
751 owner: &str,
752 repo: &str,
753 comment_id: u64,
754 body: &str,
755) -> Result<()> {
756 debug!("Updating PR review comment");
757
758 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
759 let payload = serde_json::json!({ "body": body });
760 let result: std::result::Result<serde_json::Value, _> =
761 client.patch(&route, Some(&payload)).await;
762
763 match result {
764 Ok(_) => {
765 debug!("PR review comment updated successfully");
766 Ok(())
767 }
768 Err(e)
769 if let octocrab::Error::GitHub { source, .. } = &e
770 && source.status_code.as_u16() == 404 =>
771 {
772 debug!("PR review comment not found (404); treating as success");
773 Ok(())
774 }
775 Err(e) => {
776 Err(e).with_context(|| format!("Failed to update PR review comment #{comment_id}"))
777 }
778 }
779}
780
781#[must_use]
793pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
794 let mut labels = std::collections::HashSet::new();
795
796 let prefix = title
799 .split(':')
800 .next()
801 .unwrap_or("")
802 .split('(')
803 .next()
804 .unwrap_or("")
805 .trim();
806
807 let type_label = match prefix {
809 "feat" | "perf" => Some("enhancement"),
810 "fix" => Some("bug"),
811 "docs" => Some("documentation"),
812 "refactor" => Some("refactor"),
813 _ => None,
814 };
815
816 if let Some(label) = type_label {
817 labels.insert(label.to_string());
818 }
819
820 for path in file_paths {
822 let scope = if path.starts_with("crates/aptu-cli/") {
823 Some("cli")
824 } else if path.starts_with("docs/") {
825 Some("documentation")
826 } else {
827 None
828 };
829
830 if let Some(label) = scope {
831 labels.insert(label.to_string());
832 }
833 }
834
835 labels.into_iter().collect()
836}
837
838#[cfg(not(target_arch = "wasm32"))]
858#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
859#[allow(clippy::too_many_arguments)]
860pub async fn create_pull_request(
861 client: &Octocrab,
862 owner: &str,
863 repo: &str,
864 title: &str,
865 head_branch: &str,
866 base_branch: &str,
867 body: Option<&str>,
868 draft: bool,
869) -> anyhow::Result<PrCreateResult> {
870 debug!("Creating pull request");
871
872 let pr = client
873 .pulls(owner, repo)
874 .create(title, head_branch, base_branch)
875 .body(body.unwrap_or_default())
876 .draft(draft)
877 .send()
878 .await
879 .with_context(|| {
880 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
881 })?;
882
883 let result = PrCreateResult {
884 pr_number: pr.number,
885 url: pr
886 .html_url
887 .as_ref()
888 .map(std::string::ToString::to_string)
889 .unwrap_or_default(),
890 branch: pr.head.ref_field.clone(),
891 base: pr.base.ref_field.clone(),
892 title: pr.title.clone().unwrap_or_default(),
893 draft: pr.draft.unwrap_or(false),
894 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
895 additions: pr.additions.unwrap_or(0),
896 deletions: pr.deletions.unwrap_or(0),
897 };
898
899 debug!(
900 pr_number = result.pr_number,
901 "Pull request created successfully"
902 );
903
904 Ok(result)
905}
906
907fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
911 if status.to_lowercase().contains("removed") {
912 debug!(file = %filename, "Skipping removed file");
913 return true;
914 }
915 if patch.is_none_or(String::is_empty) {
916 debug!(file = %filename, "Skipping file with empty patch");
917 return true;
918 }
919 false
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::ai::types::CommentSeverity;
926
927 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
928 use base64::Engine;
929 let engine = base64::engine::general_purpose::STANDARD;
930 let decoded_bytes = engine.decode(encoded).ok()?;
931 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
932
933 if decoded_str.len() <= max_chars {
934 Some(decoded_str)
935 } else {
936 Some(decoded_str.chars().take(max_chars).collect::<String>())
937 }
938 }
939
940 #[test]
941 fn test_pr_create_result_fields() {
942 let result = PrCreateResult {
944 pr_number: 42,
945 url: "https://github.com/owner/repo/pull/42".to_string(),
946 branch: "feat/my-feature".to_string(),
947 base: "main".to_string(),
948 title: "feat: add feature".to_string(),
949 draft: false,
950 files_changed: 3,
951 additions: 100,
952 deletions: 10,
953 };
954
955 assert_eq!(result.pr_number, 42);
957 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
958 assert_eq!(result.branch, "feat/my-feature");
959 assert_eq!(result.base, "main");
960 assert_eq!(result.title, "feat: add feature");
961 assert!(!result.draft);
962 assert_eq!(result.files_changed, 3);
963 assert_eq!(result.additions, 100);
964 assert_eq!(result.deletions, 10);
965 }
966
967 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
974 comments
975 .iter()
976 .filter_map(|c| {
977 c.line.map(|line| {
978 serde_json::json!({
979 "path": c.file,
980 "line": line,
981 "side": "RIGHT",
982 "body": render_pr_review_comment_body(c),
983 })
984 })
985 })
986 .collect()
987 }
988
989 #[test]
990 fn test_post_pr_review_payload_with_comments() {
991 let comments = vec![PrReviewComment {
993 file: "src/main.rs".to_string(),
994 line: Some(42),
995 comment: "Consider using a match here.".to_string(),
996 severity: CommentSeverity::Suggestion,
997 suggested_code: None,
998 }];
999
1000 let inline = build_inline_comments(&comments);
1002
1003 assert_eq!(inline.len(), 1);
1005 assert_eq!(inline[0]["path"], "src/main.rs");
1006 assert_eq!(inline[0]["line"], 42);
1007 assert_eq!(inline[0]["side"], "RIGHT");
1008 assert_eq!(inline[0]["body"], "Consider using a match here.");
1009 }
1010
1011 #[test]
1012 fn test_post_pr_review_skips_none_line_comments() {
1013 let comments = vec![
1015 PrReviewComment {
1016 file: "src/lib.rs".to_string(),
1017 line: None,
1018 comment: "General file comment.".to_string(),
1019 severity: CommentSeverity::Info,
1020 suggested_code: None,
1021 },
1022 PrReviewComment {
1023 file: "src/lib.rs".to_string(),
1024 line: Some(10),
1025 comment: "Inline comment.".to_string(),
1026 severity: CommentSeverity::Warning,
1027 suggested_code: None,
1028 },
1029 ];
1030
1031 let inline = build_inline_comments(&comments);
1033
1034 assert_eq!(inline.len(), 1);
1036 assert_eq!(inline[0]["line"], 10);
1037 }
1038
1039 #[test]
1040 fn test_post_pr_review_empty_comments() {
1041 let comments: Vec<PrReviewComment> = vec![];
1043
1044 let inline = build_inline_comments(&comments);
1046
1047 assert!(inline.is_empty());
1049 let serialized = serde_json::to_string(&inline).unwrap();
1050 assert_eq!(serialized, "[]");
1051 }
1052
1053 #[test]
1060 fn test_parse_pr_reference_delegates_to_shared() {
1061 let (owner, repo, number) =
1062 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
1063 assert_eq!(owner, "block");
1064 assert_eq!(repo, "goose");
1065 assert_eq!(number, 123);
1066 }
1067
1068 #[test]
1069 fn test_title_prefix_to_label_mapping() {
1070 let cases = vec![
1071 (
1072 "feat: add new feature",
1073 vec!["enhancement"],
1074 "feat should map to enhancement",
1075 ),
1076 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
1077 (
1078 "docs: update readme",
1079 vec!["documentation"],
1080 "docs should map to documentation",
1081 ),
1082 (
1083 "refactor: improve code",
1084 vec!["refactor"],
1085 "refactor should map to refactor",
1086 ),
1087 (
1088 "perf: optimize",
1089 vec!["enhancement"],
1090 "perf should map to enhancement",
1091 ),
1092 (
1093 "chore: update deps",
1094 vec![],
1095 "chore should produce no labels",
1096 ),
1097 ];
1098
1099 for (title, expected_labels, msg) in cases {
1100 let labels = labels_from_pr_metadata(title, &[]);
1101 for expected in &expected_labels {
1102 assert!(
1103 labels.contains(&expected.to_string()),
1104 "{msg}: expected '{expected}' in {labels:?}",
1105 );
1106 }
1107 if expected_labels.is_empty() {
1108 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
1109 }
1110 }
1111 }
1112
1113 #[test]
1114 fn test_file_path_to_scope_mapping() {
1115 let cases = vec![
1116 (
1117 "feat: cli",
1118 vec!["crates/aptu-cli/src/main.rs"],
1119 vec!["enhancement", "cli"],
1120 "cli path should map to cli scope",
1121 ),
1122 (
1123 "feat: docs",
1124 vec!["docs/GITHUB_ACTION.md"],
1125 vec!["enhancement", "documentation"],
1126 "docs path should map to documentation scope",
1127 ),
1128 (
1129 "feat: workflow",
1130 vec![".github/workflows/test.yml"],
1131 vec!["enhancement"],
1132 "workflow path should be ignored",
1133 ),
1134 ];
1135
1136 for (title, paths, expected_labels, msg) in cases {
1137 let labels = labels_from_pr_metadata(
1138 title,
1139 &paths
1140 .iter()
1141 .map(std::string::ToString::to_string)
1142 .collect::<Vec<_>>(),
1143 );
1144 for expected in expected_labels {
1145 assert!(
1146 labels.contains(&expected.to_string()),
1147 "{msg}: expected '{expected}' in {labels:?}",
1148 );
1149 }
1150 }
1151 }
1152
1153 #[test]
1154 fn test_combined_title_and_paths() {
1155 let labels = labels_from_pr_metadata(
1156 "feat: multi",
1157 &[
1158 "crates/aptu-cli/src/main.rs".to_string(),
1159 "docs/README.md".to_string(),
1160 ],
1161 );
1162 assert!(
1163 labels.contains(&"enhancement".to_string()),
1164 "should include enhancement from feat prefix"
1165 );
1166 assert!(
1167 labels.contains(&"cli".to_string()),
1168 "should include cli from path"
1169 );
1170 assert!(
1171 labels.contains(&"documentation".to_string()),
1172 "should include documentation from path"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_no_match_returns_empty() {
1178 let cases = vec![
1179 (
1180 "Random title",
1181 vec![],
1182 "unrecognized prefix should return empty",
1183 ),
1184 (
1185 "chore: update",
1186 vec![],
1187 "ignored prefix should return empty",
1188 ),
1189 ];
1190
1191 for (title, paths, msg) in cases {
1192 let labels = labels_from_pr_metadata(title, &paths);
1193 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1194 }
1195 }
1196
1197 #[test]
1198 fn test_scoped_prefix_extracts_type() {
1199 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1200 assert!(
1201 labels.contains(&"enhancement".to_string()),
1202 "scoped prefix should extract type from feat(cli)"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_duplicate_labels_deduplicated() {
1208 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1209 assert_eq!(
1210 labels.len(),
1211 1,
1212 "should have exactly one label when title and path both map to documentation"
1213 );
1214 assert!(
1215 labels.contains(&"documentation".to_string()),
1216 "should contain documentation label"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_should_skip_file_respects_fetched_count_cap() {
1222 let removed_file = PrFile {
1225 filename: "removed.rs".to_string(),
1226 status: "removed".to_string(),
1227 additions: 0,
1228 deletions: 5,
1229 patch: None,
1230 patch_truncated: false,
1231 full_content: None,
1232 };
1233 let modified_file = PrFile {
1234 filename: "file_0.rs".to_string(),
1235 status: "modified".to_string(),
1236 additions: 1,
1237 deletions: 0,
1238 patch: Some("+ new code".to_string()),
1239 patch_truncated: false,
1240 full_content: None,
1241 };
1242 let no_patch_file = PrFile {
1243 filename: "file_1.rs".to_string(),
1244 status: "modified".to_string(),
1245 additions: 1,
1246 deletions: 0,
1247 patch: None,
1248 patch_truncated: false,
1249 full_content: None,
1250 };
1251
1252 assert!(
1254 should_skip_file(
1255 &removed_file.filename,
1256 &removed_file.status,
1257 removed_file.patch.as_ref()
1258 ),
1259 "removed files should be skipped"
1260 );
1261
1262 assert!(
1264 !should_skip_file(
1265 &modified_file.filename,
1266 &modified_file.status,
1267 modified_file.patch.as_ref()
1268 ),
1269 "modified files with patch should not be skipped"
1270 );
1271
1272 assert!(
1274 should_skip_file(
1275 &no_patch_file.filename,
1276 &no_patch_file.status,
1277 no_patch_file.patch.as_ref()
1278 ),
1279 "files without patch should be skipped"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_decode_content_valid_base64() {
1285 use base64::Engine;
1287 let engine = base64::engine::general_purpose::STANDARD;
1288 let original = "Hello, World!";
1289 let encoded = engine.encode(original);
1290
1291 let result = decode_content(&encoded, 1000);
1293
1294 assert_eq!(
1296 result,
1297 Some(original.to_string()),
1298 "valid base64 should decode successfully"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_decode_content_invalid_base64() {
1304 let invalid_base64 = "!!!invalid!!!";
1306
1307 let result = decode_content(invalid_base64, 1000);
1309
1310 assert_eq!(result, None, "invalid base64 should return None");
1312 }
1313
1314 #[test]
1315 fn test_decode_content_truncates_at_max_chars() {
1316 use base64::Engine;
1318 let engine = base64::engine::general_purpose::STANDARD;
1319 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1321 let max_chars = 10;
1322
1323 let result = decode_content(&encoded, max_chars);
1325
1326 assert!(result.is_some(), "decoding should succeed");
1328 let decoded = result.unwrap();
1329 assert_eq!(
1330 decoded.chars().count(),
1331 max_chars,
1332 "output should be truncated to max_chars on character boundary"
1333 );
1334 assert!(
1335 decoded.is_char_boundary(decoded.len()),
1336 "output should be valid UTF-8 (truncated on char boundary)"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_list_files_pagination_collects_all_pages() {
1342 let mut page1_items = Vec::new();
1345 for i in 0..100 {
1346 page1_items.push(PrFile {
1347 filename: format!("file{}.rs", i),
1348 status: "modified".to_string(),
1349 additions: 1,
1350 deletions: 0,
1351 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1352 patch_truncated: false,
1353 full_content: None,
1354 });
1355 }
1356
1357 let mut page2_items = Vec::new();
1359 for i in 100..150 {
1360 page2_items.push(PrFile {
1361 filename: format!("file{}.rs", i),
1362 status: "modified".to_string(),
1363 additions: 1,
1364 deletions: 0,
1365 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1366 patch_truncated: false,
1367 full_content: None,
1368 });
1369 }
1370
1371 let mut all_files = Vec::new();
1373 all_files.extend(page1_items);
1374 all_files.extend(page2_items);
1375
1376 assert_eq!(
1378 all_files.len(),
1379 150,
1380 "pagination should collect all items from both pages"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_list_files_pagination_respects_300_file_cap() {
1386 let mut files = Vec::new();
1388 for i in 0..301 {
1389 files.push(PrFile {
1390 filename: format!("file{}.rs", i),
1391 status: "modified".to_string(),
1392 additions: 1,
1393 deletions: 0,
1394 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1395 patch_truncated: false,
1396 full_content: None,
1397 });
1398 }
1399
1400 if files.len() >= 300 {
1402 files.truncate(300);
1403 }
1404
1405 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1407 }
1408
1409 #[test]
1410 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1411 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1413 assert!(
1414 is_patch_truncated(truncated_patch),
1415 "patch ending with + should be detected as truncated"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1421 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1423 assert!(
1424 is_patch_truncated(truncated_patch),
1425 "patch ending with - should be detected as truncated"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_is_patch_truncated_clean_patch_context_line() {
1431 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1433 assert!(
1434 !is_patch_truncated(clean_patch),
1435 "patch ending with context line should not be detected as truncated"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_is_patch_truncated_correct_hunk_line_count() {
1441 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1443 assert!(
1444 !is_patch_truncated(clean_patch),
1445 "patch with correct hunk line count should not be detected as truncated"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1451 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1454 assert!(
1455 is_patch_truncated(truncated_patch),
1456 "patch with declared hunk size larger than delivered should be detected as truncated"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1462 let truncated_patch = "line1\nline2\n+";
1464 assert!(
1465 is_patch_truncated(truncated_patch),
1466 "patch with no @@ header but ending with + should be detected as truncated"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_is_patch_truncated_empty_patch() {
1472 let empty_patch = "";
1474 assert!(
1475 !is_patch_truncated(empty_patch),
1476 "empty patch should not be detected as truncated"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1482 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1484 assert!(
1485 is_patch_truncated(truncated_patch),
1486 "patch with last hunk truncated should be detected as truncated"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_pr_file_status_case_insensitive_added() {
1492 let file = PrFile {
1494 filename: "new.rs".to_string(),
1495 status: "Added".to_string(), additions: 50,
1497 deletions: 0,
1498 patch: Some("new code".to_string()),
1499 patch_truncated: false,
1500 full_content: None,
1501 };
1502
1503 let is_added_renamed_copied = matches!(
1504 file.status.to_lowercase().as_str(),
1505 "added" | "renamed" | "copied"
1506 );
1507 assert!(is_added_renamed_copied, "Added status should be recognized");
1508 }
1509
1510 #[test]
1511 fn test_pr_file_status_case_insensitive_modified() {
1512 let file = PrFile {
1514 filename: "existing.rs".to_string(),
1515 status: "Modified".to_string(),
1516 additions: 10,
1517 deletions: 5,
1518 patch: Some("modified code".to_string()),
1519 patch_truncated: false,
1520 full_content: None,
1521 };
1522
1523 let is_added_renamed_copied = matches!(
1524 file.status.to_lowercase().as_str(),
1525 "added" | "renamed" | "copied"
1526 );
1527 assert!(
1528 !is_added_renamed_copied,
1529 "Modified status should NOT be recognized as added/renamed/copied"
1530 );
1531 }
1532
1533 #[test]
1534 fn test_pr_file_oversized_patch_detection() {
1535 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1538 let patch = "a".repeat(max_patch_chars + 5_000); let patch_too_large = patch.len() > max_patch_chars;
1541 assert!(
1542 patch_too_large,
1543 "patch exceeding the default limit should be detected as oversized"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_pr_file_dedup_guard_full_content_present() {
1549 let file = PrFile {
1551 filename: "new.rs".to_string(),
1552 status: "Added".to_string(),
1553 additions: 50,
1554 deletions: 0,
1555 patch: Some("new code".to_string()),
1556 patch_truncated: false,
1557 full_content: Some("full content from Contents API".to_string()),
1558 };
1559
1560 let should_fetch = file.full_content.is_none();
1561 assert!(
1562 !should_fetch,
1563 "File with full_content should not be fetched again (dedup guard)"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_pr_file_contents_api_fallback_flow() {
1569 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1575
1576 let file = PrFile {
1577 filename: "new.rs".to_string(),
1578 status: "Added".to_string(),
1579 additions: 50,
1580 deletions: 0,
1581 patch: Some("a".repeat(max_patch_chars + 5_000)), patch_truncated: false,
1583 full_content: None, };
1585
1586 let is_added_renamed_copied = matches!(
1587 file.status.to_lowercase().as_str(),
1588 "added" | "renamed" | "copied"
1589 );
1590 let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1591 let should_attempt_contents_api =
1592 is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1593
1594 assert!(
1595 should_attempt_contents_api,
1596 "Added file with 30k patch and no full_content should attempt Contents API"
1597 );
1598 }
1599
1600 #[test]
1601 fn test_merge_preserves_existing_full_content() {
1602 let mut file = PrFile {
1605 filename: "test.rs".to_string(),
1606 status: "modified".to_string(),
1607 additions: 5,
1608 deletions: 2,
1609 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1610 patch_truncated: false,
1611 full_content: Some("fallback content".to_string()),
1612 };
1613 let content = None;
1614
1615 if file.full_content.is_none() {
1617 file.full_content = content;
1618 }
1619
1620 assert_eq!(file.full_content, Some("fallback content".to_string()));
1622 }
1623
1624 #[test]
1625 fn test_merge_sets_full_content_when_none() {
1626 let mut file = PrFile {
1628 filename: "test.rs".to_string(),
1629 status: "modified".to_string(),
1630 additions: 5,
1631 deletions: 2,
1632 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1633 patch_truncated: false,
1634 full_content: None,
1635 };
1636 let content = Some("fetched content".to_string());
1637
1638 if file.full_content.is_none() {
1640 file.full_content = content;
1641 }
1642
1643 assert_eq!(file.full_content, Some("fetched content".to_string()));
1645 }
1646
1647 #[test]
1648 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1649 }
1659
1660 #[test]
1661 fn test_review_comments_maps_fields_correctly() {
1662 use crate::ai::types::PrReviewCommentDetails;
1663
1664 let bot = PrReviewCommentDetails {
1665 id: 42,
1666 author: "aptu[bot]".to_string(),
1667 body: "suggestion".to_string(),
1668 path: "src/lib.rs".to_string(),
1669 line: Some(15),
1670 side: Some("RIGHT".to_string()),
1671 commit_id: "abc123".to_string(),
1672 };
1673 let human = PrReviewCommentDetails {
1674 id: 99,
1675 author: "human-user".to_string(),
1676 body: "looks good".to_string(),
1677 path: "src/main.rs".to_string(),
1678 line: Some(30),
1679 side: Some("LEFT".to_string()),
1680 commit_id: "def456".to_string(),
1681 };
1682
1683 let kept: Vec<_> = vec![bot, human]
1684 .into_iter()
1685 .filter(|c| c.author == "aptu[bot]")
1686 .collect();
1687 assert_eq!(kept.len(), 1);
1688 assert_eq!(kept[0].path, "src/lib.rs");
1689 assert_eq!(kept[0].line, Some(15));
1690 assert_eq!(kept[0].side, Some("RIGHT".to_string()));
1691 assert_eq!(kept[0].commit_id, "abc123");
1692 }
1693}