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 details = PrDetails {
269 owner: owner.to_string(),
270 repo: repo.to_string(),
271 number,
272 title: pr.title.clone().unwrap_or_default(),
273 body: pr.body.clone().unwrap_or_default(),
274 base_branch: pr.base.ref_field.clone(),
275 head_branch: pr.head.ref_field.clone(),
276 head_sha: pr.head.sha.as_str().to_string(),
277 files: pr_files,
278 url: pr
279 .html_url
280 .as_ref()
281 .map(std::string::ToString::to_string)
282 .unwrap_or_default(),
283 labels,
284 review_comments: Vec::new(),
285 instructions: None,
286 dep_enrichments: Vec::new(),
287 };
288
289 debug!(
290 file_count = details.files.len(),
291 "PR details fetched successfully"
292 );
293
294 Ok(details)
295}
296
297fn is_patch_truncated(patch: &str) -> bool {
302 let lines: Vec<&str> = patch.lines().collect();
303
304 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
306 && (last_line.starts_with('+') || last_line.starts_with('-'))
307 {
308 return true;
309 }
310
311 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
314 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
316 if let Some(size_str) = plus_part.split_whitespace().next() {
318 if let Some(count_str) = size_str.split(',').nth(1)
320 && let Ok(declared_count) = count_str.parse::<usize>()
321 {
322 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
325 {
326 let lines_after_hunk = &lines[hunk_idx + 1..];
327 let mut actual_count = 0;
330 for line in lines_after_hunk {
331 if line.starts_with("@@") {
332 break;
333 }
334 if line.starts_with(' ')
335 || line.starts_with('+')
336 || line.starts_with('-')
337 {
338 actual_count += 1;
339 }
340 }
341 if actual_count < declared_count {
343 return true;
344 }
345 }
346 }
347 }
348 }
349 }
350
351 false
352}
353
354#[cfg(not(target_arch = "wasm32"))]
359async fn fetch_file_contents_single(
360 client: &Octocrab,
361 owner: &str,
362 repo: &str,
363 filename: &str,
364 head_sha: &str,
365 max_chars: usize,
366) -> Result<Option<String>> {
367 match client
368 .repos(owner, repo)
369 .get_content()
370 .path(filename)
371 .r#ref(head_sha)
372 .send()
373 .await
374 {
375 Ok(content) => {
376 if let Some(item) = content.items.first() {
378 if let Some(decoded) = item.decoded_content() {
379 let truncated = if decoded.chars().count() > max_chars {
380 truncate_at_line_boundary(&decoded, max_chars)
381 } else {
382 decoded
383 };
384 Ok(Some(truncated))
385 } else {
386 tracing::warn!(
387 "Failed to decode content for {}/{}/{} at {}",
388 owner,
389 repo,
390 filename,
391 head_sha
392 );
393 Ok(None)
394 }
395 } else {
396 tracing::warn!(
397 "File content response was empty for {}/{}/{} at {}",
398 owner,
399 repo,
400 filename,
401 head_sha
402 );
403 Ok(None)
404 }
405 }
406 Err(e) => {
407 tracing::warn!(
408 "Failed to fetch content for {}/{}/{} at {}: {}",
409 owner,
410 repo,
411 filename,
412 head_sha,
413 e
414 );
415 Ok(None)
416 }
417 }
418}
419
420#[cfg(not(target_arch = "wasm32"))]
442#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
443async fn fetch_file_contents(
444 client: &Octocrab,
445 owner: &str,
446 repo: &str,
447 files: &[PrFile],
448 head_sha: &str,
449 max_files: usize,
450 max_chars_per_file: usize,
451) -> Vec<Option<String>> {
452 let mut results = Vec::with_capacity(files.len());
453 let mut fetched_count = 0usize;
454
455 for file in files {
456 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
457 results.push(None);
458 continue;
459 }
460
461 if fetched_count >= max_files {
463 debug!(
464 file = %file.filename,
465 fetched_count = fetched_count,
466 max_files = max_files,
467 "Fetched file count exceeds max_files cap"
468 );
469 results.push(None);
470 continue;
471 }
472
473 match client
475 .repos(owner, repo)
476 .get_content()
477 .path(&file.filename)
478 .r#ref(head_sha)
479 .send()
480 .await
481 {
482 Ok(content) => {
483 if let Some(item) = content.items.first() {
485 if let Some(decoded) = item.decoded_content() {
486 let truncated = if decoded.chars().count() > max_chars_per_file {
487 truncate_at_line_boundary(&decoded, max_chars_per_file)
488 } else {
489 decoded
490 };
491 debug!(
492 file = %file.filename,
493 content_len = truncated.len(),
494 "File content fetched and truncated"
495 );
496 results.push(Some(truncated));
497 fetched_count += 1;
498 } else {
499 tracing::warn!(
500 file = %file.filename,
501 "Failed to decode file content; skipping"
502 );
503 results.push(None);
504 }
505 } else {
506 tracing::warn!(
507 file = %file.filename,
508 "File content response was empty; skipping"
509 );
510 results.push(None);
511 }
512 }
513 Err(e) => {
514 tracing::warn!(
515 file = %file.filename,
516 err = %e,
517 "Failed to fetch file content; skipping"
518 );
519 results.push(None);
520 }
521 }
522 }
523
524 results
525}
526
527#[cfg(not(target_arch = "wasm32"))]
551#[allow(clippy::too_many_arguments)]
552#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
553pub async fn post_pr_review(
554 client: &Octocrab,
555 owner: &str,
556 repo: &str,
557 number: u64,
558 body: &str,
559 event: ReviewEvent,
560 comments: &[PrReviewComment],
561 commit_id: &str,
562) -> Result<u64> {
563 debug!("Posting PR review");
564
565 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
566
567 let inline_comments: Vec<serde_json::Value> = comments
569 .iter()
570 .filter_map(|c| {
572 c.line.map(|line| {
573 serde_json::json!({
574 "path": c.file,
575 "line": line,
576 "side": "RIGHT",
580 "body": render_pr_review_comment_body(c),
581 })
582 })
583 })
584 .collect();
585
586 let mut payload = serde_json::json!({
587 "body": body,
588 "event": event.to_string(),
589 "comments": inline_comments,
590 });
591
592 if !commit_id.is_empty() {
594 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
595 }
596
597 #[derive(serde::Deserialize)]
598 struct ReviewResponse {
599 id: u64,
600 }
601
602 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
603 format!(
604 "Failed to post review to PR #{number} in {owner}/{repo}. \
605 Check that you have write access to the repository."
606 )
607 })?;
608
609 debug!(review_id = response.id, "PR review posted successfully");
610
611 Ok(response.id)
612}
613
614#[cfg(not(target_arch = "wasm32"))]
621#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
622pub async fn delete_pr_review_comment(
623 client: &Octocrab,
624 owner: &str,
625 repo: &str,
626 comment_id: u64,
627) -> Result<()> {
628 debug!("Deleting PR review comment");
629
630 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
631
632 let empty_body = serde_json::json!({});
634 let result: std::result::Result<serde_json::Value, _> =
635 client.delete(&route, Some(&empty_body)).await;
636
637 match result {
638 Ok(_) => {
639 debug!("PR review comment deleted successfully");
640 Ok(())
641 }
642 Err(e)
643 if let octocrab::Error::GitHub { source, .. } = &e
644 && source.status_code.as_u16() == 404 =>
645 {
646 debug!("PR review comment already deleted (404); treating as success");
647 Ok(())
648 }
649 Err(e) => {
650 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
651 }
652 }
653}
654
655#[must_use]
667pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
668 let mut labels = std::collections::HashSet::new();
669
670 let prefix = title
673 .split(':')
674 .next()
675 .unwrap_or("")
676 .split('(')
677 .next()
678 .unwrap_or("")
679 .trim();
680
681 let type_label = match prefix {
683 "feat" | "perf" => Some("enhancement"),
684 "fix" => Some("bug"),
685 "docs" => Some("documentation"),
686 "refactor" => Some("refactor"),
687 _ => None,
688 };
689
690 if let Some(label) = type_label {
691 labels.insert(label.to_string());
692 }
693
694 for path in file_paths {
696 let scope = if path.starts_with("crates/aptu-cli/") {
697 Some("cli")
698 } else if path.starts_with("docs/") {
699 Some("documentation")
700 } else {
701 None
702 };
703
704 if let Some(label) = scope {
705 labels.insert(label.to_string());
706 }
707 }
708
709 labels.into_iter().collect()
710}
711
712#[cfg(not(target_arch = "wasm32"))]
732#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
733#[allow(clippy::too_many_arguments)]
734pub async fn create_pull_request(
735 client: &Octocrab,
736 owner: &str,
737 repo: &str,
738 title: &str,
739 head_branch: &str,
740 base_branch: &str,
741 body: Option<&str>,
742 draft: bool,
743) -> anyhow::Result<PrCreateResult> {
744 debug!("Creating pull request");
745
746 let pr = client
747 .pulls(owner, repo)
748 .create(title, head_branch, base_branch)
749 .body(body.unwrap_or_default())
750 .draft(draft)
751 .send()
752 .await
753 .with_context(|| {
754 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
755 })?;
756
757 let result = PrCreateResult {
758 pr_number: pr.number,
759 url: pr
760 .html_url
761 .as_ref()
762 .map(std::string::ToString::to_string)
763 .unwrap_or_default(),
764 branch: pr.head.ref_field.clone(),
765 base: pr.base.ref_field.clone(),
766 title: pr.title.clone().unwrap_or_default(),
767 draft: pr.draft.unwrap_or(false),
768 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
769 additions: pr.additions.unwrap_or(0),
770 deletions: pr.deletions.unwrap_or(0),
771 };
772
773 debug!(
774 pr_number = result.pr_number,
775 "Pull request created successfully"
776 );
777
778 Ok(result)
779}
780
781fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
785 if status.to_lowercase().contains("removed") {
786 debug!(file = %filename, "Skipping removed file");
787 return true;
788 }
789 if patch.is_none_or(String::is_empty) {
790 debug!(file = %filename, "Skipping file with empty patch");
791 return true;
792 }
793 false
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use crate::ai::types::CommentSeverity;
800
801 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
802 use base64::Engine;
803 let engine = base64::engine::general_purpose::STANDARD;
804 let decoded_bytes = engine.decode(encoded).ok()?;
805 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
806
807 if decoded_str.len() <= max_chars {
808 Some(decoded_str)
809 } else {
810 Some(decoded_str.chars().take(max_chars).collect::<String>())
811 }
812 }
813
814 #[test]
815 fn test_pr_create_result_fields() {
816 let result = PrCreateResult {
818 pr_number: 42,
819 url: "https://github.com/owner/repo/pull/42".to_string(),
820 branch: "feat/my-feature".to_string(),
821 base: "main".to_string(),
822 title: "feat: add feature".to_string(),
823 draft: false,
824 files_changed: 3,
825 additions: 100,
826 deletions: 10,
827 };
828
829 assert_eq!(result.pr_number, 42);
831 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
832 assert_eq!(result.branch, "feat/my-feature");
833 assert_eq!(result.base, "main");
834 assert_eq!(result.title, "feat: add feature");
835 assert!(!result.draft);
836 assert_eq!(result.files_changed, 3);
837 assert_eq!(result.additions, 100);
838 assert_eq!(result.deletions, 10);
839 }
840
841 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
848 comments
849 .iter()
850 .filter_map(|c| {
851 c.line.map(|line| {
852 serde_json::json!({
853 "path": c.file,
854 "line": line,
855 "side": "RIGHT",
856 "body": render_pr_review_comment_body(c),
857 })
858 })
859 })
860 .collect()
861 }
862
863 #[test]
864 fn test_post_pr_review_payload_with_comments() {
865 let comments = vec![PrReviewComment {
867 file: "src/main.rs".to_string(),
868 line: Some(42),
869 comment: "Consider using a match here.".to_string(),
870 severity: CommentSeverity::Suggestion,
871 suggested_code: None,
872 }];
873
874 let inline = build_inline_comments(&comments);
876
877 assert_eq!(inline.len(), 1);
879 assert_eq!(inline[0]["path"], "src/main.rs");
880 assert_eq!(inline[0]["line"], 42);
881 assert_eq!(inline[0]["side"], "RIGHT");
882 assert_eq!(inline[0]["body"], "Consider using a match here.");
883 }
884
885 #[test]
886 fn test_post_pr_review_skips_none_line_comments() {
887 let comments = vec![
889 PrReviewComment {
890 file: "src/lib.rs".to_string(),
891 line: None,
892 comment: "General file comment.".to_string(),
893 severity: CommentSeverity::Info,
894 suggested_code: None,
895 },
896 PrReviewComment {
897 file: "src/lib.rs".to_string(),
898 line: Some(10),
899 comment: "Inline comment.".to_string(),
900 severity: CommentSeverity::Warning,
901 suggested_code: None,
902 },
903 ];
904
905 let inline = build_inline_comments(&comments);
907
908 assert_eq!(inline.len(), 1);
910 assert_eq!(inline[0]["line"], 10);
911 }
912
913 #[test]
914 fn test_post_pr_review_empty_comments() {
915 let comments: Vec<PrReviewComment> = vec![];
917
918 let inline = build_inline_comments(&comments);
920
921 assert!(inline.is_empty());
923 let serialized = serde_json::to_string(&inline).unwrap();
924 assert_eq!(serialized, "[]");
925 }
926
927 #[test]
934 fn test_parse_pr_reference_delegates_to_shared() {
935 let (owner, repo, number) =
936 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
937 assert_eq!(owner, "block");
938 assert_eq!(repo, "goose");
939 assert_eq!(number, 123);
940 }
941
942 #[test]
943 fn test_title_prefix_to_label_mapping() {
944 let cases = vec![
945 (
946 "feat: add new feature",
947 vec!["enhancement"],
948 "feat should map to enhancement",
949 ),
950 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
951 (
952 "docs: update readme",
953 vec!["documentation"],
954 "docs should map to documentation",
955 ),
956 (
957 "refactor: improve code",
958 vec!["refactor"],
959 "refactor should map to refactor",
960 ),
961 (
962 "perf: optimize",
963 vec!["enhancement"],
964 "perf should map to enhancement",
965 ),
966 (
967 "chore: update deps",
968 vec![],
969 "chore should produce no labels",
970 ),
971 ];
972
973 for (title, expected_labels, msg) in cases {
974 let labels = labels_from_pr_metadata(title, &[]);
975 for expected in &expected_labels {
976 assert!(
977 labels.contains(&expected.to_string()),
978 "{msg}: expected '{expected}' in {labels:?}",
979 );
980 }
981 if expected_labels.is_empty() {
982 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
983 }
984 }
985 }
986
987 #[test]
988 fn test_file_path_to_scope_mapping() {
989 let cases = vec![
990 (
991 "feat: cli",
992 vec!["crates/aptu-cli/src/main.rs"],
993 vec!["enhancement", "cli"],
994 "cli path should map to cli scope",
995 ),
996 (
997 "feat: docs",
998 vec!["docs/GITHUB_ACTION.md"],
999 vec!["enhancement", "documentation"],
1000 "docs path should map to documentation scope",
1001 ),
1002 (
1003 "feat: workflow",
1004 vec![".github/workflows/test.yml"],
1005 vec!["enhancement"],
1006 "workflow path should be ignored",
1007 ),
1008 ];
1009
1010 for (title, paths, expected_labels, msg) in cases {
1011 let labels = labels_from_pr_metadata(
1012 title,
1013 &paths
1014 .iter()
1015 .map(std::string::ToString::to_string)
1016 .collect::<Vec<_>>(),
1017 );
1018 for expected in expected_labels {
1019 assert!(
1020 labels.contains(&expected.to_string()),
1021 "{msg}: expected '{expected}' in {labels:?}",
1022 );
1023 }
1024 }
1025 }
1026
1027 #[test]
1028 fn test_combined_title_and_paths() {
1029 let labels = labels_from_pr_metadata(
1030 "feat: multi",
1031 &[
1032 "crates/aptu-cli/src/main.rs".to_string(),
1033 "docs/README.md".to_string(),
1034 ],
1035 );
1036 assert!(
1037 labels.contains(&"enhancement".to_string()),
1038 "should include enhancement from feat prefix"
1039 );
1040 assert!(
1041 labels.contains(&"cli".to_string()),
1042 "should include cli from path"
1043 );
1044 assert!(
1045 labels.contains(&"documentation".to_string()),
1046 "should include documentation from path"
1047 );
1048 }
1049
1050 #[test]
1051 fn test_no_match_returns_empty() {
1052 let cases = vec![
1053 (
1054 "Random title",
1055 vec![],
1056 "unrecognized prefix should return empty",
1057 ),
1058 (
1059 "chore: update",
1060 vec![],
1061 "ignored prefix should return empty",
1062 ),
1063 ];
1064
1065 for (title, paths, msg) in cases {
1066 let labels = labels_from_pr_metadata(title, &paths);
1067 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1068 }
1069 }
1070
1071 #[test]
1072 fn test_scoped_prefix_extracts_type() {
1073 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1074 assert!(
1075 labels.contains(&"enhancement".to_string()),
1076 "scoped prefix should extract type from feat(cli)"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_duplicate_labels_deduplicated() {
1082 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1083 assert_eq!(
1084 labels.len(),
1085 1,
1086 "should have exactly one label when title and path both map to documentation"
1087 );
1088 assert!(
1089 labels.contains(&"documentation".to_string()),
1090 "should contain documentation label"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_should_skip_file_respects_fetched_count_cap() {
1096 let removed_file = PrFile {
1099 filename: "removed.rs".to_string(),
1100 status: "removed".to_string(),
1101 additions: 0,
1102 deletions: 5,
1103 patch: None,
1104 patch_truncated: false,
1105 full_content: None,
1106 };
1107 let modified_file = PrFile {
1108 filename: "file_0.rs".to_string(),
1109 status: "modified".to_string(),
1110 additions: 1,
1111 deletions: 0,
1112 patch: Some("+ new code".to_string()),
1113 patch_truncated: false,
1114 full_content: None,
1115 };
1116 let no_patch_file = PrFile {
1117 filename: "file_1.rs".to_string(),
1118 status: "modified".to_string(),
1119 additions: 1,
1120 deletions: 0,
1121 patch: None,
1122 patch_truncated: false,
1123 full_content: None,
1124 };
1125
1126 assert!(
1128 should_skip_file(
1129 &removed_file.filename,
1130 &removed_file.status,
1131 removed_file.patch.as_ref()
1132 ),
1133 "removed files should be skipped"
1134 );
1135
1136 assert!(
1138 !should_skip_file(
1139 &modified_file.filename,
1140 &modified_file.status,
1141 modified_file.patch.as_ref()
1142 ),
1143 "modified files with patch should not be skipped"
1144 );
1145
1146 assert!(
1148 should_skip_file(
1149 &no_patch_file.filename,
1150 &no_patch_file.status,
1151 no_patch_file.patch.as_ref()
1152 ),
1153 "files without patch should be skipped"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_decode_content_valid_base64() {
1159 use base64::Engine;
1161 let engine = base64::engine::general_purpose::STANDARD;
1162 let original = "Hello, World!";
1163 let encoded = engine.encode(original);
1164
1165 let result = decode_content(&encoded, 1000);
1167
1168 assert_eq!(
1170 result,
1171 Some(original.to_string()),
1172 "valid base64 should decode successfully"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_decode_content_invalid_base64() {
1178 let invalid_base64 = "!!!invalid!!!";
1180
1181 let result = decode_content(invalid_base64, 1000);
1183
1184 assert_eq!(result, None, "invalid base64 should return None");
1186 }
1187
1188 #[test]
1189 fn test_decode_content_truncates_at_max_chars() {
1190 use base64::Engine;
1192 let engine = base64::engine::general_purpose::STANDARD;
1193 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1195 let max_chars = 10;
1196
1197 let result = decode_content(&encoded, max_chars);
1199
1200 assert!(result.is_some(), "decoding should succeed");
1202 let decoded = result.unwrap();
1203 assert_eq!(
1204 decoded.chars().count(),
1205 max_chars,
1206 "output should be truncated to max_chars on character boundary"
1207 );
1208 assert!(
1209 decoded.is_char_boundary(decoded.len()),
1210 "output should be valid UTF-8 (truncated on char boundary)"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_list_files_pagination_collects_all_pages() {
1216 let mut page1_items = Vec::new();
1219 for i in 0..100 {
1220 page1_items.push(PrFile {
1221 filename: format!("file{}.rs", i),
1222 status: "modified".to_string(),
1223 additions: 1,
1224 deletions: 0,
1225 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1226 patch_truncated: false,
1227 full_content: None,
1228 });
1229 }
1230
1231 let mut page2_items = Vec::new();
1233 for i in 100..150 {
1234 page2_items.push(PrFile {
1235 filename: format!("file{}.rs", i),
1236 status: "modified".to_string(),
1237 additions: 1,
1238 deletions: 0,
1239 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1240 patch_truncated: false,
1241 full_content: None,
1242 });
1243 }
1244
1245 let mut all_files = Vec::new();
1247 all_files.extend(page1_items);
1248 all_files.extend(page2_items);
1249
1250 assert_eq!(
1252 all_files.len(),
1253 150,
1254 "pagination should collect all items from both pages"
1255 );
1256 }
1257
1258 #[test]
1259 fn test_list_files_pagination_respects_300_file_cap() {
1260 let mut files = Vec::new();
1262 for i in 0..301 {
1263 files.push(PrFile {
1264 filename: format!("file{}.rs", i),
1265 status: "modified".to_string(),
1266 additions: 1,
1267 deletions: 0,
1268 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1269 patch_truncated: false,
1270 full_content: None,
1271 });
1272 }
1273
1274 if files.len() >= 300 {
1276 files.truncate(300);
1277 }
1278
1279 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1281 }
1282
1283 #[test]
1284 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1285 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1287 assert!(
1288 is_patch_truncated(truncated_patch),
1289 "patch ending with + should be detected as truncated"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1295 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1297 assert!(
1298 is_patch_truncated(truncated_patch),
1299 "patch ending with - should be detected as truncated"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_is_patch_truncated_clean_patch_context_line() {
1305 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1307 assert!(
1308 !is_patch_truncated(clean_patch),
1309 "patch ending with context line should not be detected as truncated"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_is_patch_truncated_correct_hunk_line_count() {
1315 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1317 assert!(
1318 !is_patch_truncated(clean_patch),
1319 "patch with correct hunk line count should not be detected as truncated"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1325 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1328 assert!(
1329 is_patch_truncated(truncated_patch),
1330 "patch with declared hunk size larger than delivered should be detected as truncated"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1336 let truncated_patch = "line1\nline2\n+";
1338 assert!(
1339 is_patch_truncated(truncated_patch),
1340 "patch with no @@ header but ending with + should be detected as truncated"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_is_patch_truncated_empty_patch() {
1346 let empty_patch = "";
1348 assert!(
1349 !is_patch_truncated(empty_patch),
1350 "empty patch should not be detected as truncated"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1356 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1358 assert!(
1359 is_patch_truncated(truncated_patch),
1360 "patch with last hunk truncated should be detected as truncated"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_pr_file_status_case_insensitive_added() {
1366 let file = PrFile {
1368 filename: "new.rs".to_string(),
1369 status: "Added".to_string(), additions: 50,
1371 deletions: 0,
1372 patch: Some("new code".to_string()),
1373 patch_truncated: false,
1374 full_content: None,
1375 };
1376
1377 let is_added_renamed_copied = matches!(
1378 file.status.to_lowercase().as_str(),
1379 "added" | "renamed" | "copied"
1380 );
1381 assert!(is_added_renamed_copied, "Added status should be recognized");
1382 }
1383
1384 #[test]
1385 fn test_pr_file_status_case_insensitive_modified() {
1386 let file = PrFile {
1388 filename: "existing.rs".to_string(),
1389 status: "Modified".to_string(),
1390 additions: 10,
1391 deletions: 5,
1392 patch: Some("modified code".to_string()),
1393 patch_truncated: false,
1394 full_content: None,
1395 };
1396
1397 let is_added_renamed_copied = matches!(
1398 file.status.to_lowercase().as_str(),
1399 "added" | "renamed" | "copied"
1400 );
1401 assert!(
1402 !is_added_renamed_copied,
1403 "Modified status should NOT be recognized as added/renamed/copied"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_pr_file_oversized_patch_detection() {
1409 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1412 let patch = "a".repeat(max_patch_chars + 5_000); let patch_too_large = patch.len() > max_patch_chars;
1415 assert!(
1416 patch_too_large,
1417 "patch exceeding the default limit should be detected as oversized"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_pr_file_dedup_guard_full_content_present() {
1423 let file = PrFile {
1425 filename: "new.rs".to_string(),
1426 status: "Added".to_string(),
1427 additions: 50,
1428 deletions: 0,
1429 patch: Some("new code".to_string()),
1430 patch_truncated: false,
1431 full_content: Some("full content from Contents API".to_string()),
1432 };
1433
1434 let should_fetch = file.full_content.is_none();
1435 assert!(
1436 !should_fetch,
1437 "File with full_content should not be fetched again (dedup guard)"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_pr_file_contents_api_fallback_flow() {
1443 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1449
1450 let file = PrFile {
1451 filename: "new.rs".to_string(),
1452 status: "Added".to_string(),
1453 additions: 50,
1454 deletions: 0,
1455 patch: Some("a".repeat(max_patch_chars + 5_000)), patch_truncated: false,
1457 full_content: None, };
1459
1460 let is_added_renamed_copied = matches!(
1461 file.status.to_lowercase().as_str(),
1462 "added" | "renamed" | "copied"
1463 );
1464 let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1465 let should_attempt_contents_api =
1466 is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1467
1468 assert!(
1469 should_attempt_contents_api,
1470 "Added file with 30k patch and no full_content should attempt Contents API"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_merge_preserves_existing_full_content() {
1476 let mut file = PrFile {
1479 filename: "test.rs".to_string(),
1480 status: "modified".to_string(),
1481 additions: 5,
1482 deletions: 2,
1483 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1484 patch_truncated: false,
1485 full_content: Some("fallback content".to_string()),
1486 };
1487 let content = None;
1488
1489 if file.full_content.is_none() {
1491 file.full_content = content;
1492 }
1493
1494 assert_eq!(file.full_content, Some("fallback content".to_string()));
1496 }
1497
1498 #[test]
1499 fn test_merge_sets_full_content_when_none() {
1500 let mut file = PrFile {
1502 filename: "test.rs".to_string(),
1503 status: "modified".to_string(),
1504 additions: 5,
1505 deletions: 2,
1506 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1507 patch_truncated: false,
1508 full_content: None,
1509 };
1510 let content = Some("fetched content".to_string());
1511
1512 if file.full_content.is_none() {
1514 file.full_content = content;
1515 }
1516
1517 assert_eq!(file.full_content, Some("fetched content".to_string()));
1519 }
1520
1521 #[test]
1522 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1523 }
1533}