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::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
15use crate::error::{AptuError, ResourceType};
16use crate::triage::render_pr_review_comment_body;
17
18#[derive(Debug, serde::Serialize)]
20pub struct PrCreateResult {
21 pub pr_number: u64,
23 pub url: String,
25 pub branch: String,
27 pub base: String,
29 pub title: String,
31 pub draft: bool,
33 pub files_changed: u32,
35 pub additions: u64,
37 pub deletions: u64,
39}
40
41pub fn parse_pr_reference(
61 reference: &str,
62 repo_context: Option<&str>,
63) -> Result<(String, String, u64)> {
64 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
65}
66
67#[cfg(not(target_arch = "wasm32"))]
86#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
87#[allow(clippy::too_many_lines)]
88pub async fn fetch_pr_details(
89 client: &Octocrab,
90 owner: &str,
91 repo: &str,
92 number: u64,
93 review_config: &crate::config::ReviewConfig,
94) -> Result<PrDetails> {
95 debug!("Fetching PR details");
96
97 let pr = match client.pulls(owner, repo).get(number).await {
99 Ok(pr) => pr,
100 Err(e) => {
101 if let octocrab::Error::GitHub { source, .. } = &e
103 && source.status_code == 404
104 {
105 if (client.issues(owner, repo).get(number).await).is_ok() {
107 return Err(AptuError::TypeMismatch {
108 number,
109 expected: ResourceType::PullRequest,
110 actual: ResourceType::Issue,
111 }
112 .into());
113 }
114 }
116 return Err(e)
117 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
118 }
119 };
120
121 let mut pr_files: Vec<PrFile> = Vec::new();
123 let mut page = client
124 .pulls(owner, repo)
125 .list_files(number)
126 .await
127 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
128
129 loop {
130 pr_files.extend(page.items.into_iter().map(|f| PrFile {
131 filename: f.filename,
132 status: format!("{:?}", f.status),
133 additions: f.additions,
134 deletions: f.deletions,
135 patch: f.patch,
136 patch_truncated: false,
137 full_content: None,
138 }));
139
140 if pr_files.len() >= 300 {
141 tracing::warn!(
142 "PR #{} has reached 300-file cap; stopping pagination",
143 number
144 );
145 pr_files.truncate(300);
146 break;
147 }
148
149 match client
150 .get_page::<octocrab::models::repos::DiffEntry>(&page.next)
151 .await
152 {
153 Ok(Some(next_page)) => page = next_page,
154 Ok(None) => break,
155 Err(e) => {
156 tracing::warn!("Error fetching next page of files: {}", e);
157 break;
158 }
159 }
160 }
161
162 let head_sha = pr.head.sha.as_str();
163
164 for file in &mut pr_files {
166 #[allow(clippy::collapsible_if)]
167 if let Some(patch) = &file.patch {
168 if is_patch_truncated(patch) {
169 file.patch_truncated = true;
170 if let Ok(Some(content)) = fetch_file_contents_single(
172 client,
173 owner,
174 repo,
175 &file.filename,
176 head_sha,
177 review_config.max_chars_per_file,
178 )
179 .await
180 {
181 file.patch = Some(content);
182 }
183 }
184 }
185 }
186
187 for file in &mut pr_files {
191 let is_added_renamed_copied = matches!(
193 file.status.to_lowercase().as_str(),
194 "added" | "renamed" | "copied"
195 );
196 let patch_too_large =
197 file.patch.as_deref().map_or(0, str::len) > review_config.max_patch_chars_per_file;
198 if is_added_renamed_copied && patch_too_large && file.full_content.is_none() {
199 match fetch_file_contents_single(
200 client,
201 owner,
202 repo,
203 &file.filename,
204 head_sha,
205 review_config.max_chars_per_file,
206 )
207 .await
208 {
209 Ok(Some(content)) => {
210 file.full_content = Some(content);
211 }
212 Ok(None) => {
213 tracing::warn!(
214 "Contents API returned empty content for added file {} in PR #{}",
215 file.filename,
216 number
217 );
218 }
219 Err(e) => {
220 tracing::warn!(
221 "Failed to fetch contents for added file {} in PR #{}: {}",
222 file.filename,
223 number,
224 e
225 );
226 }
227 }
228 }
229 }
230
231 let file_contents = fetch_file_contents(
233 client,
234 owner,
235 repo,
236 &pr_files,
237 pr.head.sha.as_str(),
238 review_config.max_full_content_files,
239 review_config.max_chars_per_file,
240 )
241 .await;
242
243 debug_assert_eq!(
245 pr_files.len(),
246 file_contents.len(),
247 "fetch_file_contents must return one entry per file"
248 );
249 let pr_files: Vec<PrFile> = pr_files
250 .into_iter()
251 .zip(file_contents)
252 .map(|(mut file, content)| {
253 if file.full_content.is_none() {
254 file.full_content = content;
255 }
256 file
257 })
258 .collect();
259
260 let labels: Vec<String> = pr
261 .labels
262 .iter()
263 .flat_map(|v| v.iter())
264 .map(|l| l.name.clone())
265 .collect();
266
267 let details = PrDetails {
268 owner: owner.to_string(),
269 repo: repo.to_string(),
270 number,
271 title: pr.title.clone().unwrap_or_default(),
272 body: pr.body.clone().unwrap_or_default(),
273 base_branch: pr.base.ref_field.clone(),
274 head_branch: pr.head.ref_field.clone(),
275 head_sha: pr.head.sha.as_str().to_string(),
276 files: pr_files,
277 url: pr
278 .html_url
279 .as_ref()
280 .map(std::string::ToString::to_string)
281 .unwrap_or_default(),
282 labels,
283 review_comments: Vec::new(),
284 instructions: None,
285 dep_enrichments: Vec::new(),
286 };
287
288 debug!(
289 file_count = details.files.len(),
290 "PR details fetched successfully"
291 );
292
293 Ok(details)
294}
295
296fn is_patch_truncated(patch: &str) -> bool {
301 let lines: Vec<&str> = patch.lines().collect();
302
303 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
305 && (last_line.starts_with('+') || last_line.starts_with('-'))
306 {
307 return true;
308 }
309
310 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
313 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
315 if let Some(size_str) = plus_part.split_whitespace().next() {
317 if let Some(count_str) = size_str.split(',').nth(1)
319 && let Ok(declared_count) = count_str.parse::<usize>()
320 {
321 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
324 {
325 let lines_after_hunk = &lines[hunk_idx + 1..];
326 let mut actual_count = 0;
329 for line in lines_after_hunk {
330 if line.starts_with("@@") {
331 break;
332 }
333 if line.starts_with(' ')
334 || line.starts_with('+')
335 || line.starts_with('-')
336 {
337 actual_count += 1;
338 }
339 }
340 if actual_count < declared_count {
342 return true;
343 }
344 }
345 }
346 }
347 }
348 }
349
350 false
351}
352
353#[cfg(not(target_arch = "wasm32"))]
358async fn fetch_file_contents_single(
359 client: &Octocrab,
360 owner: &str,
361 repo: &str,
362 filename: &str,
363 head_sha: &str,
364 max_chars: usize,
365) -> Result<Option<String>> {
366 match client
367 .repos(owner, repo)
368 .get_content()
369 .path(filename)
370 .r#ref(head_sha)
371 .send()
372 .await
373 {
374 Ok(content) => {
375 if let Some(item) = content.items.first() {
377 if let Some(decoded) = item.decoded_content() {
378 let truncated = if decoded.len() > max_chars {
379 decoded.chars().take(max_chars).collect::<String>()
380 } else {
381 decoded
382 };
383 Ok(Some(truncated))
384 } else {
385 tracing::warn!(
386 "Failed to decode content for {}/{}/{} at {}",
387 owner,
388 repo,
389 filename,
390 head_sha
391 );
392 Ok(None)
393 }
394 } else {
395 tracing::warn!(
396 "File content response was empty for {}/{}/{} at {}",
397 owner,
398 repo,
399 filename,
400 head_sha
401 );
402 Ok(None)
403 }
404 }
405 Err(e) => {
406 tracing::warn!(
407 "Failed to fetch content for {}/{}/{} at {}: {}",
408 owner,
409 repo,
410 filename,
411 head_sha,
412 e
413 );
414 Ok(None)
415 }
416 }
417}
418
419#[cfg(not(target_arch = "wasm32"))]
441#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
442async fn fetch_file_contents(
443 client: &Octocrab,
444 owner: &str,
445 repo: &str,
446 files: &[PrFile],
447 head_sha: &str,
448 max_files: usize,
449 max_chars_per_file: usize,
450) -> Vec<Option<String>> {
451 let mut results = Vec::with_capacity(files.len());
452 let mut fetched_count = 0usize;
453
454 for file in files {
455 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
456 results.push(None);
457 continue;
458 }
459
460 if fetched_count >= max_files {
462 debug!(
463 file = %file.filename,
464 fetched_count = fetched_count,
465 max_files = max_files,
466 "Fetched file count exceeds max_files cap"
467 );
468 results.push(None);
469 continue;
470 }
471
472 match client
474 .repos(owner, repo)
475 .get_content()
476 .path(&file.filename)
477 .r#ref(head_sha)
478 .send()
479 .await
480 {
481 Ok(content) => {
482 if let Some(item) = content.items.first() {
484 if let Some(decoded) = item.decoded_content() {
485 let truncated = if decoded.len() > max_chars_per_file {
486 decoded.chars().take(max_chars_per_file).collect::<String>()
487 } else {
488 decoded
489 };
490 debug!(
491 file = %file.filename,
492 content_len = truncated.len(),
493 "File content fetched and truncated"
494 );
495 results.push(Some(truncated));
496 fetched_count += 1;
497 } else {
498 tracing::warn!(
499 file = %file.filename,
500 "Failed to decode file content; skipping"
501 );
502 results.push(None);
503 }
504 } else {
505 tracing::warn!(
506 file = %file.filename,
507 "File content response was empty; skipping"
508 );
509 results.push(None);
510 }
511 }
512 Err(e) => {
513 tracing::warn!(
514 file = %file.filename,
515 err = %e,
516 "Failed to fetch file content; skipping"
517 );
518 results.push(None);
519 }
520 }
521 }
522
523 results
524}
525
526#[cfg(not(target_arch = "wasm32"))]
550#[allow(clippy::too_many_arguments)]
551#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
552pub async fn post_pr_review(
553 client: &Octocrab,
554 owner: &str,
555 repo: &str,
556 number: u64,
557 body: &str,
558 event: ReviewEvent,
559 comments: &[PrReviewComment],
560 commit_id: &str,
561) -> Result<u64> {
562 debug!("Posting PR review");
563
564 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
565
566 let inline_comments: Vec<serde_json::Value> = comments
568 .iter()
569 .filter_map(|c| {
571 c.line.map(|line| {
572 serde_json::json!({
573 "path": c.file,
574 "line": line,
575 "side": "RIGHT",
579 "body": render_pr_review_comment_body(c),
580 })
581 })
582 })
583 .collect();
584
585 let mut payload = serde_json::json!({
586 "body": body,
587 "event": event.to_string(),
588 "comments": inline_comments,
589 });
590
591 if !commit_id.is_empty() {
593 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
594 }
595
596 #[derive(serde::Deserialize)]
597 struct ReviewResponse {
598 id: u64,
599 }
600
601 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
602 format!(
603 "Failed to post review to PR #{number} in {owner}/{repo}. \
604 Check that you have write access to the repository."
605 )
606 })?;
607
608 debug!(review_id = response.id, "PR review posted successfully");
609
610 Ok(response.id)
611}
612
613#[cfg(not(target_arch = "wasm32"))]
620#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
621pub async fn delete_pr_review_comment(
622 client: &Octocrab,
623 owner: &str,
624 repo: &str,
625 comment_id: u64,
626) -> Result<()> {
627 debug!("Deleting PR review comment");
628
629 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
630
631 let empty_body = serde_json::json!({});
633 let result: std::result::Result<serde_json::Value, _> =
634 client.delete(&route, Some(&empty_body)).await;
635
636 match result {
637 Ok(_) => {
638 debug!("PR review comment deleted successfully");
639 Ok(())
640 }
641 Err(e)
642 if let octocrab::Error::GitHub { source, .. } = &e
643 && source.status_code.as_u16() == 404 =>
644 {
645 debug!("PR review comment already deleted (404); treating as success");
646 Ok(())
647 }
648 Err(e) => {
649 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
650 }
651 }
652}
653
654#[must_use]
666pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
667 let mut labels = std::collections::HashSet::new();
668
669 let prefix = title
672 .split(':')
673 .next()
674 .unwrap_or("")
675 .split('(')
676 .next()
677 .unwrap_or("")
678 .trim();
679
680 let type_label = match prefix {
682 "feat" | "perf" => Some("enhancement"),
683 "fix" => Some("bug"),
684 "docs" => Some("documentation"),
685 "refactor" => Some("refactor"),
686 _ => None,
687 };
688
689 if let Some(label) = type_label {
690 labels.insert(label.to_string());
691 }
692
693 for path in file_paths {
695 let scope = if path.starts_with("crates/aptu-cli/") {
696 Some("cli")
697 } else if path.starts_with("docs/") {
698 Some("documentation")
699 } else {
700 None
701 };
702
703 if let Some(label) = scope {
704 labels.insert(label.to_string());
705 }
706 }
707
708 labels.into_iter().collect()
709}
710
711#[cfg(not(target_arch = "wasm32"))]
731#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
732#[allow(clippy::too_many_arguments)]
733pub async fn create_pull_request(
734 client: &Octocrab,
735 owner: &str,
736 repo: &str,
737 title: &str,
738 head_branch: &str,
739 base_branch: &str,
740 body: Option<&str>,
741 draft: bool,
742) -> anyhow::Result<PrCreateResult> {
743 debug!("Creating pull request");
744
745 let pr = client
746 .pulls(owner, repo)
747 .create(title, head_branch, base_branch)
748 .body(body.unwrap_or_default())
749 .draft(draft)
750 .send()
751 .await
752 .with_context(|| {
753 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
754 })?;
755
756 let result = PrCreateResult {
757 pr_number: pr.number,
758 url: pr
759 .html_url
760 .as_ref()
761 .map(std::string::ToString::to_string)
762 .unwrap_or_default(),
763 branch: pr.head.ref_field.clone(),
764 base: pr.base.ref_field.clone(),
765 title: pr.title.clone().unwrap_or_default(),
766 draft: pr.draft.unwrap_or(false),
767 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
768 additions: pr.additions.unwrap_or(0),
769 deletions: pr.deletions.unwrap_or(0),
770 };
771
772 debug!(
773 pr_number = result.pr_number,
774 "Pull request created successfully"
775 );
776
777 Ok(result)
778}
779
780fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
784 if status.to_lowercase().contains("removed") {
785 debug!(file = %filename, "Skipping removed file");
786 return true;
787 }
788 if patch.is_none_or(String::is_empty) {
789 debug!(file = %filename, "Skipping file with empty patch");
790 return true;
791 }
792 false
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::ai::types::CommentSeverity;
799
800 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
801 use base64::Engine;
802 let engine = base64::engine::general_purpose::STANDARD;
803 let decoded_bytes = engine.decode(encoded).ok()?;
804 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
805
806 if decoded_str.len() <= max_chars {
807 Some(decoded_str)
808 } else {
809 Some(decoded_str.chars().take(max_chars).collect::<String>())
810 }
811 }
812
813 #[test]
814 fn test_pr_create_result_fields() {
815 let result = PrCreateResult {
817 pr_number: 42,
818 url: "https://github.com/owner/repo/pull/42".to_string(),
819 branch: "feat/my-feature".to_string(),
820 base: "main".to_string(),
821 title: "feat: add feature".to_string(),
822 draft: false,
823 files_changed: 3,
824 additions: 100,
825 deletions: 10,
826 };
827
828 assert_eq!(result.pr_number, 42);
830 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
831 assert_eq!(result.branch, "feat/my-feature");
832 assert_eq!(result.base, "main");
833 assert_eq!(result.title, "feat: add feature");
834 assert!(!result.draft);
835 assert_eq!(result.files_changed, 3);
836 assert_eq!(result.additions, 100);
837 assert_eq!(result.deletions, 10);
838 }
839
840 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
847 comments
848 .iter()
849 .filter_map(|c| {
850 c.line.map(|line| {
851 serde_json::json!({
852 "path": c.file,
853 "line": line,
854 "side": "RIGHT",
855 "body": render_pr_review_comment_body(c),
856 })
857 })
858 })
859 .collect()
860 }
861
862 #[test]
863 fn test_post_pr_review_payload_with_comments() {
864 let comments = vec![PrReviewComment {
866 file: "src/main.rs".to_string(),
867 line: Some(42),
868 comment: "Consider using a match here.".to_string(),
869 severity: CommentSeverity::Suggestion,
870 suggested_code: None,
871 }];
872
873 let inline = build_inline_comments(&comments);
875
876 assert_eq!(inline.len(), 1);
878 assert_eq!(inline[0]["path"], "src/main.rs");
879 assert_eq!(inline[0]["line"], 42);
880 assert_eq!(inline[0]["side"], "RIGHT");
881 assert_eq!(inline[0]["body"], "Consider using a match here.");
882 }
883
884 #[test]
885 fn test_post_pr_review_skips_none_line_comments() {
886 let comments = vec![
888 PrReviewComment {
889 file: "src/lib.rs".to_string(),
890 line: None,
891 comment: "General file comment.".to_string(),
892 severity: CommentSeverity::Info,
893 suggested_code: None,
894 },
895 PrReviewComment {
896 file: "src/lib.rs".to_string(),
897 line: Some(10),
898 comment: "Inline comment.".to_string(),
899 severity: CommentSeverity::Warning,
900 suggested_code: None,
901 },
902 ];
903
904 let inline = build_inline_comments(&comments);
906
907 assert_eq!(inline.len(), 1);
909 assert_eq!(inline[0]["line"], 10);
910 }
911
912 #[test]
913 fn test_post_pr_review_empty_comments() {
914 let comments: Vec<PrReviewComment> = vec![];
916
917 let inline = build_inline_comments(&comments);
919
920 assert!(inline.is_empty());
922 let serialized = serde_json::to_string(&inline).unwrap();
923 assert_eq!(serialized, "[]");
924 }
925
926 #[test]
933 fn test_parse_pr_reference_delegates_to_shared() {
934 let (owner, repo, number) =
935 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
936 assert_eq!(owner, "block");
937 assert_eq!(repo, "goose");
938 assert_eq!(number, 123);
939 }
940
941 #[test]
942 fn test_title_prefix_to_label_mapping() {
943 let cases = vec![
944 (
945 "feat: add new feature",
946 vec!["enhancement"],
947 "feat should map to enhancement",
948 ),
949 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
950 (
951 "docs: update readme",
952 vec!["documentation"],
953 "docs should map to documentation",
954 ),
955 (
956 "refactor: improve code",
957 vec!["refactor"],
958 "refactor should map to refactor",
959 ),
960 (
961 "perf: optimize",
962 vec!["enhancement"],
963 "perf should map to enhancement",
964 ),
965 (
966 "chore: update deps",
967 vec![],
968 "chore should produce no labels",
969 ),
970 ];
971
972 for (title, expected_labels, msg) in cases {
973 let labels = labels_from_pr_metadata(title, &[]);
974 for expected in &expected_labels {
975 assert!(
976 labels.contains(&expected.to_string()),
977 "{msg}: expected '{expected}' in {labels:?}",
978 );
979 }
980 if expected_labels.is_empty() {
981 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
982 }
983 }
984 }
985
986 #[test]
987 fn test_file_path_to_scope_mapping() {
988 let cases = vec![
989 (
990 "feat: cli",
991 vec!["crates/aptu-cli/src/main.rs"],
992 vec!["enhancement", "cli"],
993 "cli path should map to cli scope",
994 ),
995 (
996 "feat: docs",
997 vec!["docs/GITHUB_ACTION.md"],
998 vec!["enhancement", "documentation"],
999 "docs path should map to documentation scope",
1000 ),
1001 (
1002 "feat: workflow",
1003 vec![".github/workflows/test.yml"],
1004 vec!["enhancement"],
1005 "workflow path should be ignored",
1006 ),
1007 ];
1008
1009 for (title, paths, expected_labels, msg) in cases {
1010 let labels = labels_from_pr_metadata(
1011 title,
1012 &paths
1013 .iter()
1014 .map(std::string::ToString::to_string)
1015 .collect::<Vec<_>>(),
1016 );
1017 for expected in expected_labels {
1018 assert!(
1019 labels.contains(&expected.to_string()),
1020 "{msg}: expected '{expected}' in {labels:?}",
1021 );
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn test_combined_title_and_paths() {
1028 let labels = labels_from_pr_metadata(
1029 "feat: multi",
1030 &[
1031 "crates/aptu-cli/src/main.rs".to_string(),
1032 "docs/README.md".to_string(),
1033 ],
1034 );
1035 assert!(
1036 labels.contains(&"enhancement".to_string()),
1037 "should include enhancement from feat prefix"
1038 );
1039 assert!(
1040 labels.contains(&"cli".to_string()),
1041 "should include cli from path"
1042 );
1043 assert!(
1044 labels.contains(&"documentation".to_string()),
1045 "should include documentation from path"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_no_match_returns_empty() {
1051 let cases = vec![
1052 (
1053 "Random title",
1054 vec![],
1055 "unrecognized prefix should return empty",
1056 ),
1057 (
1058 "chore: update",
1059 vec![],
1060 "ignored prefix should return empty",
1061 ),
1062 ];
1063
1064 for (title, paths, msg) in cases {
1065 let labels = labels_from_pr_metadata(title, &paths);
1066 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1067 }
1068 }
1069
1070 #[test]
1071 fn test_scoped_prefix_extracts_type() {
1072 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1073 assert!(
1074 labels.contains(&"enhancement".to_string()),
1075 "scoped prefix should extract type from feat(cli)"
1076 );
1077 }
1078
1079 #[test]
1080 fn test_duplicate_labels_deduplicated() {
1081 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1082 assert_eq!(
1083 labels.len(),
1084 1,
1085 "should have exactly one label when title and path both map to documentation"
1086 );
1087 assert!(
1088 labels.contains(&"documentation".to_string()),
1089 "should contain documentation label"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_should_skip_file_respects_fetched_count_cap() {
1095 let removed_file = PrFile {
1098 filename: "removed.rs".to_string(),
1099 status: "removed".to_string(),
1100 additions: 0,
1101 deletions: 5,
1102 patch: None,
1103 patch_truncated: false,
1104 full_content: None,
1105 };
1106 let modified_file = PrFile {
1107 filename: "file_0.rs".to_string(),
1108 status: "modified".to_string(),
1109 additions: 1,
1110 deletions: 0,
1111 patch: Some("+ new code".to_string()),
1112 patch_truncated: false,
1113 full_content: None,
1114 };
1115 let no_patch_file = PrFile {
1116 filename: "file_1.rs".to_string(),
1117 status: "modified".to_string(),
1118 additions: 1,
1119 deletions: 0,
1120 patch: None,
1121 patch_truncated: false,
1122 full_content: None,
1123 };
1124
1125 assert!(
1127 should_skip_file(
1128 &removed_file.filename,
1129 &removed_file.status,
1130 removed_file.patch.as_ref()
1131 ),
1132 "removed files should be skipped"
1133 );
1134
1135 assert!(
1137 !should_skip_file(
1138 &modified_file.filename,
1139 &modified_file.status,
1140 modified_file.patch.as_ref()
1141 ),
1142 "modified files with patch should not be skipped"
1143 );
1144
1145 assert!(
1147 should_skip_file(
1148 &no_patch_file.filename,
1149 &no_patch_file.status,
1150 no_patch_file.patch.as_ref()
1151 ),
1152 "files without patch should be skipped"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_decode_content_valid_base64() {
1158 use base64::Engine;
1160 let engine = base64::engine::general_purpose::STANDARD;
1161 let original = "Hello, World!";
1162 let encoded = engine.encode(original);
1163
1164 let result = decode_content(&encoded, 1000);
1166
1167 assert_eq!(
1169 result,
1170 Some(original.to_string()),
1171 "valid base64 should decode successfully"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_decode_content_invalid_base64() {
1177 let invalid_base64 = "!!!invalid!!!";
1179
1180 let result = decode_content(invalid_base64, 1000);
1182
1183 assert_eq!(result, None, "invalid base64 should return None");
1185 }
1186
1187 #[test]
1188 fn test_decode_content_truncates_at_max_chars() {
1189 use base64::Engine;
1191 let engine = base64::engine::general_purpose::STANDARD;
1192 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1194 let max_chars = 10;
1195
1196 let result = decode_content(&encoded, max_chars);
1198
1199 assert!(result.is_some(), "decoding should succeed");
1201 let decoded = result.unwrap();
1202 assert_eq!(
1203 decoded.chars().count(),
1204 max_chars,
1205 "output should be truncated to max_chars on character boundary"
1206 );
1207 assert!(
1208 decoded.is_char_boundary(decoded.len()),
1209 "output should be valid UTF-8 (truncated on char boundary)"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_list_files_pagination_collects_all_pages() {
1215 let mut page1_items = Vec::new();
1218 for i in 0..100 {
1219 page1_items.push(PrFile {
1220 filename: format!("file{}.rs", i),
1221 status: "modified".to_string(),
1222 additions: 1,
1223 deletions: 0,
1224 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1225 patch_truncated: false,
1226 full_content: None,
1227 });
1228 }
1229
1230 let mut page2_items = Vec::new();
1232 for i in 100..150 {
1233 page2_items.push(PrFile {
1234 filename: format!("file{}.rs", i),
1235 status: "modified".to_string(),
1236 additions: 1,
1237 deletions: 0,
1238 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1239 patch_truncated: false,
1240 full_content: None,
1241 });
1242 }
1243
1244 let mut all_files = Vec::new();
1246 all_files.extend(page1_items);
1247 all_files.extend(page2_items);
1248
1249 assert_eq!(
1251 all_files.len(),
1252 150,
1253 "pagination should collect all items from both pages"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_list_files_pagination_respects_300_file_cap() {
1259 let mut files = Vec::new();
1261 for i in 0..301 {
1262 files.push(PrFile {
1263 filename: format!("file{}.rs", i),
1264 status: "modified".to_string(),
1265 additions: 1,
1266 deletions: 0,
1267 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1268 patch_truncated: false,
1269 full_content: None,
1270 });
1271 }
1272
1273 if files.len() >= 300 {
1275 files.truncate(300);
1276 }
1277
1278 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1280 }
1281
1282 #[test]
1283 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1284 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1286 assert!(
1287 is_patch_truncated(truncated_patch),
1288 "patch ending with + should be detected as truncated"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1294 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1296 assert!(
1297 is_patch_truncated(truncated_patch),
1298 "patch ending with - should be detected as truncated"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_is_patch_truncated_clean_patch_context_line() {
1304 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1306 assert!(
1307 !is_patch_truncated(clean_patch),
1308 "patch ending with context line should not be detected as truncated"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_is_patch_truncated_correct_hunk_line_count() {
1314 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1316 assert!(
1317 !is_patch_truncated(clean_patch),
1318 "patch with correct hunk line count should not be detected as truncated"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1324 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1327 assert!(
1328 is_patch_truncated(truncated_patch),
1329 "patch with declared hunk size larger than delivered should be detected as truncated"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1335 let truncated_patch = "line1\nline2\n+";
1337 assert!(
1338 is_patch_truncated(truncated_patch),
1339 "patch with no @@ header but ending with + should be detected as truncated"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_is_patch_truncated_empty_patch() {
1345 let empty_patch = "";
1347 assert!(
1348 !is_patch_truncated(empty_patch),
1349 "empty patch should not be detected as truncated"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1355 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1357 assert!(
1358 is_patch_truncated(truncated_patch),
1359 "patch with last hunk truncated should be detected as truncated"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_pr_file_status_case_insensitive_added() {
1365 let file = PrFile {
1367 filename: "new.rs".to_string(),
1368 status: "Added".to_string(), additions: 50,
1370 deletions: 0,
1371 patch: Some("new code".to_string()),
1372 patch_truncated: false,
1373 full_content: None,
1374 };
1375
1376 let is_added_renamed_copied = matches!(
1377 file.status.to_lowercase().as_str(),
1378 "added" | "renamed" | "copied"
1379 );
1380 assert!(is_added_renamed_copied, "Added status should be recognized");
1381 }
1382
1383 #[test]
1384 fn test_pr_file_status_case_insensitive_modified() {
1385 let file = PrFile {
1387 filename: "existing.rs".to_string(),
1388 status: "Modified".to_string(),
1389 additions: 10,
1390 deletions: 5,
1391 patch: Some("modified code".to_string()),
1392 patch_truncated: false,
1393 full_content: None,
1394 };
1395
1396 let is_added_renamed_copied = matches!(
1397 file.status.to_lowercase().as_str(),
1398 "added" | "renamed" | "copied"
1399 );
1400 assert!(
1401 !is_added_renamed_copied,
1402 "Modified status should NOT be recognized as added/renamed/copied"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_pr_file_oversized_patch_detection() {
1408 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1411 let patch = "a".repeat(max_patch_chars + 5_000); let patch_too_large = patch.len() > max_patch_chars;
1414 assert!(
1415 patch_too_large,
1416 "patch exceeding the default limit should be detected as oversized"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_pr_file_dedup_guard_full_content_present() {
1422 let file = PrFile {
1424 filename: "new.rs".to_string(),
1425 status: "Added".to_string(),
1426 additions: 50,
1427 deletions: 0,
1428 patch: Some("new code".to_string()),
1429 patch_truncated: false,
1430 full_content: Some("full content from Contents API".to_string()),
1431 };
1432
1433 let should_fetch = file.full_content.is_none();
1434 assert!(
1435 !should_fetch,
1436 "File with full_content should not be fetched again (dedup guard)"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_pr_file_contents_api_fallback_flow() {
1442 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1448
1449 let file = PrFile {
1450 filename: "new.rs".to_string(),
1451 status: "Added".to_string(),
1452 additions: 50,
1453 deletions: 0,
1454 patch: Some("a".repeat(max_patch_chars + 5_000)), patch_truncated: false,
1456 full_content: None, };
1458
1459 let is_added_renamed_copied = matches!(
1460 file.status.to_lowercase().as_str(),
1461 "added" | "renamed" | "copied"
1462 );
1463 let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1464 let should_attempt_contents_api =
1465 is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1466
1467 assert!(
1468 should_attempt_contents_api,
1469 "Added file with 30k patch and no full_content should attempt Contents API"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_merge_preserves_existing_full_content() {
1475 let mut file = PrFile {
1478 filename: "test.rs".to_string(),
1479 status: "modified".to_string(),
1480 additions: 5,
1481 deletions: 2,
1482 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1483 patch_truncated: false,
1484 full_content: Some("fallback content".to_string()),
1485 };
1486 let content = None;
1487
1488 if file.full_content.is_none() {
1490 file.full_content = content;
1491 }
1492
1493 assert_eq!(file.full_content, Some("fallback content".to_string()));
1495 }
1496
1497 #[test]
1498 fn test_merge_sets_full_content_when_none() {
1499 let mut file = PrFile {
1501 filename: "test.rs".to_string(),
1502 status: "modified".to_string(),
1503 additions: 5,
1504 deletions: 2,
1505 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1506 patch_truncated: false,
1507 full_content: None,
1508 };
1509 let content = Some("fetched content".to_string());
1510
1511 if file.full_content.is_none() {
1513 file.full_content = content;
1514 }
1515
1516 assert_eq!(file.full_content, Some("fetched content".to_string()));
1518 }
1519
1520 #[test]
1521 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1522 }
1532}