1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use tracing::{debug, instrument};
11
12use super::{ReferenceKind, parse_github_reference};
13use crate::ai::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
14use crate::error::{AptuError, ResourceType};
15use crate::triage::render_pr_review_comment_body;
16
17#[derive(Debug, serde::Serialize)]
19pub struct PrCreateResult {
20 pub pr_number: u64,
22 pub url: String,
24 pub branch: String,
26 pub base: String,
28 pub title: String,
30 pub draft: bool,
32 pub files_changed: u32,
34 pub additions: u64,
36 pub deletions: u64,
38}
39
40pub fn parse_pr_reference(
60 reference: &str,
61 repo_context: Option<&str>,
62) -> Result<(String, String, u64)> {
63 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
64}
65
66#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
85#[allow(clippy::too_many_lines)]
86pub async fn fetch_pr_details(
87 client: &Octocrab,
88 owner: &str,
89 repo: &str,
90 number: u64,
91 review_config: &crate::config::ReviewConfig,
92) -> Result<PrDetails> {
93 debug!("Fetching PR details");
94
95 let pr = match client.pulls(owner, repo).get(number).await {
97 Ok(pr) => pr,
98 Err(e) => {
99 if let octocrab::Error::GitHub { source, .. } = &e
101 && source.status_code == 404
102 {
103 if (client.issues(owner, repo).get(number).await).is_ok() {
105 return Err(AptuError::TypeMismatch {
106 number,
107 expected: ResourceType::PullRequest,
108 actual: ResourceType::Issue,
109 }
110 .into());
111 }
112 }
114 return Err(e)
115 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
116 }
117 };
118
119 let mut pr_files: Vec<PrFile> = Vec::new();
121 let mut page = client
122 .pulls(owner, repo)
123 .list_files(number)
124 .await
125 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
126
127 loop {
128 pr_files.extend(page.items.into_iter().map(|f| PrFile {
129 filename: f.filename,
130 status: format!("{:?}", f.status),
131 additions: f.additions,
132 deletions: f.deletions,
133 patch: f.patch,
134 patch_truncated: false,
135 full_content: None,
136 }));
137
138 if pr_files.len() >= 300 {
139 tracing::warn!(
140 "PR #{} has reached 300-file cap; stopping pagination",
141 number
142 );
143 pr_files.truncate(300);
144 break;
145 }
146
147 match client
148 .get_page::<octocrab::models::repos::DiffEntry>(&page.next)
149 .await
150 {
151 Ok(Some(next_page)) => page = next_page,
152 Ok(None) => break,
153 Err(e) => {
154 tracing::warn!("Error fetching next page of files: {}", e);
155 break;
156 }
157 }
158 }
159
160 for file in &mut pr_files {
162 #[allow(clippy::collapsible_if)]
163 if let Some(patch) = &file.patch {
164 if is_patch_truncated(patch) {
165 file.patch_truncated = true;
166 if let Ok(Some(content)) = fetch_file_contents_single(
168 client,
169 owner,
170 repo,
171 &file.filename,
172 pr.head
173 .as_deref()
174 .map(|h| h.sha.as_str())
175 .unwrap_or_default(),
176 review_config.max_chars_per_file,
177 )
178 .await
179 {
180 file.patch = Some(content);
181 }
182 }
183 }
184 }
185
186 let file_contents = fetch_file_contents(
188 client,
189 owner,
190 repo,
191 &pr_files,
192 pr.head
193 .as_deref()
194 .map(|h| h.sha.as_str())
195 .unwrap_or_default(),
196 review_config.max_full_content_files,
197 review_config.max_chars_per_file,
198 )
199 .await;
200
201 debug_assert_eq!(
203 pr_files.len(),
204 file_contents.len(),
205 "fetch_file_contents must return one entry per file"
206 );
207 let pr_files: Vec<PrFile> = pr_files
208 .into_iter()
209 .zip(file_contents)
210 .map(|(mut file, content)| {
211 file.full_content = content;
212 file
213 })
214 .collect();
215
216 let labels: Vec<String> = pr
217 .labels
218 .iter()
219 .flat_map(|v| v.iter())
220 .map(|l| l.name.clone())
221 .collect();
222
223 let details = PrDetails {
224 owner: owner.to_string(),
225 repo: repo.to_string(),
226 number,
227 title: pr.title.clone().unwrap_or_default(),
228 body: pr.body.clone().unwrap_or_default(),
229 base_branch: pr
230 .base
231 .as_deref()
232 .map(|b| b.ref_field.clone())
233 .unwrap_or_default(),
234 head_branch: pr
235 .head
236 .as_deref()
237 .map(|h| h.ref_field.clone())
238 .unwrap_or_default(),
239 head_sha: pr
240 .head
241 .as_deref()
242 .map(|h| h.sha.as_str())
243 .unwrap_or_default()
244 .to_string(),
245 files: pr_files,
246 url: pr
247 .html_url
248 .as_ref()
249 .map(std::string::ToString::to_string)
250 .unwrap_or_default(),
251 labels,
252 review_comments: Vec::new(),
253 instructions: None,
254 dep_enrichments: Vec::new(),
255 };
256
257 debug!(
258 file_count = details.files.len(),
259 "PR details fetched successfully"
260 );
261
262 Ok(details)
263}
264
265fn is_patch_truncated(patch: &str) -> bool {
270 let lines: Vec<&str> = patch.lines().collect();
271
272 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
274 && (last_line.starts_with('+') || last_line.starts_with('-'))
275 {
276 return true;
277 }
278
279 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
282 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
284 if let Some(size_str) = plus_part.split_whitespace().next() {
286 if let Some(count_str) = size_str.split(',').nth(1)
288 && let Ok(declared_count) = count_str.parse::<usize>()
289 {
290 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
293 {
294 let lines_after_hunk = &lines[hunk_idx + 1..];
295 let mut actual_count = 0;
298 for line in lines_after_hunk {
299 if line.starts_with("@@") {
300 break;
301 }
302 if line.starts_with(' ')
303 || line.starts_with('+')
304 || line.starts_with('-')
305 {
306 actual_count += 1;
307 }
308 }
309 if actual_count < declared_count {
311 return true;
312 }
313 }
314 }
315 }
316 }
317 }
318
319 false
320}
321
322async fn fetch_file_contents_single(
327 client: &Octocrab,
328 owner: &str,
329 repo: &str,
330 filename: &str,
331 head_sha: &str,
332 max_chars: usize,
333) -> Result<Option<String>> {
334 match client
335 .repos(owner, repo)
336 .get_content()
337 .path(filename)
338 .r#ref(head_sha)
339 .send()
340 .await
341 {
342 Ok(content) => {
343 if let Some(item) = content.items.first() {
345 if let Some(decoded) = item.decoded_content() {
346 let truncated = if decoded.len() > max_chars {
347 decoded.chars().take(max_chars).collect::<String>()
348 } else {
349 decoded
350 };
351 Ok(Some(truncated))
352 } else {
353 tracing::warn!(
354 "Failed to decode content for {}/{}/{} at {}",
355 owner,
356 repo,
357 filename,
358 head_sha
359 );
360 Ok(None)
361 }
362 } else {
363 tracing::warn!(
364 "File content response was empty for {}/{}/{} at {}",
365 owner,
366 repo,
367 filename,
368 head_sha
369 );
370 Ok(None)
371 }
372 }
373 Err(e) => {
374 tracing::warn!(
375 "Failed to fetch content for {}/{}/{} at {}: {}",
376 owner,
377 repo,
378 filename,
379 head_sha,
380 e
381 );
382 Ok(None)
383 }
384 }
385}
386
387#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
409async fn fetch_file_contents(
410 client: &Octocrab,
411 owner: &str,
412 repo: &str,
413 files: &[PrFile],
414 head_sha: &str,
415 max_files: usize,
416 max_chars_per_file: usize,
417) -> Vec<Option<String>> {
418 let mut results = Vec::with_capacity(files.len());
419 let mut fetched_count = 0usize;
420
421 for file in files {
422 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
423 results.push(None);
424 continue;
425 }
426
427 if fetched_count >= max_files {
429 debug!(
430 file = %file.filename,
431 fetched_count = fetched_count,
432 max_files = max_files,
433 "Fetched file count exceeds max_files cap"
434 );
435 results.push(None);
436 continue;
437 }
438
439 match client
441 .repos(owner, repo)
442 .get_content()
443 .path(&file.filename)
444 .r#ref(head_sha)
445 .send()
446 .await
447 {
448 Ok(content) => {
449 if let Some(item) = content.items.first() {
451 if let Some(decoded) = item.decoded_content() {
452 let truncated = if decoded.len() > max_chars_per_file {
453 decoded.chars().take(max_chars_per_file).collect::<String>()
454 } else {
455 decoded
456 };
457 debug!(
458 file = %file.filename,
459 content_len = truncated.len(),
460 "File content fetched and truncated"
461 );
462 results.push(Some(truncated));
463 fetched_count += 1;
464 } else {
465 tracing::warn!(
466 file = %file.filename,
467 "Failed to decode file content; skipping"
468 );
469 results.push(None);
470 }
471 } else {
472 tracing::warn!(
473 file = %file.filename,
474 "File content response was empty; skipping"
475 );
476 results.push(None);
477 }
478 }
479 Err(e) => {
480 tracing::warn!(
481 file = %file.filename,
482 err = %e,
483 "Failed to fetch file content; skipping"
484 );
485 results.push(None);
486 }
487 }
488 }
489
490 results
491}
492
493#[allow(clippy::too_many_arguments)]
517#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
518pub async fn post_pr_review(
519 client: &Octocrab,
520 owner: &str,
521 repo: &str,
522 number: u64,
523 body: &str,
524 event: ReviewEvent,
525 comments: &[PrReviewComment],
526 commit_id: &str,
527) -> Result<u64> {
528 debug!("Posting PR review");
529
530 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
531
532 let inline_comments: Vec<serde_json::Value> = comments
534 .iter()
535 .filter_map(|c| {
537 c.line.map(|line| {
538 serde_json::json!({
539 "path": c.file,
540 "line": line,
541 "side": "RIGHT",
545 "body": render_pr_review_comment_body(c),
546 })
547 })
548 })
549 .collect();
550
551 let mut payload = serde_json::json!({
552 "body": body,
553 "event": event.to_string(),
554 "comments": inline_comments,
555 });
556
557 if !commit_id.is_empty() {
559 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
560 }
561
562 #[derive(serde::Deserialize)]
563 struct ReviewResponse {
564 id: u64,
565 }
566
567 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
568 format!(
569 "Failed to post review to PR #{number} in {owner}/{repo}. \
570 Check that you have write access to the repository."
571 )
572 })?;
573
574 debug!(review_id = response.id, "PR review posted successfully");
575
576 Ok(response.id)
577}
578
579#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
586pub async fn delete_pr_review_comment(
587 client: &Octocrab,
588 owner: &str,
589 repo: &str,
590 comment_id: u64,
591) -> Result<()> {
592 debug!("Deleting PR review comment");
593
594 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
595
596 let empty_body = serde_json::json!({});
598 let result: std::result::Result<serde_json::Value, _> =
599 client.delete(&route, Some(&empty_body)).await;
600
601 match result {
602 Ok(_) => {
603 debug!("PR review comment deleted successfully");
604 Ok(())
605 }
606 Err(e)
607 if let octocrab::Error::GitHub { source, .. } = &e
608 && source.status_code.as_u16() == 404 =>
609 {
610 debug!("PR review comment already deleted (404); treating as success");
611 Ok(())
612 }
613 Err(e) => {
614 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
615 }
616 }
617}
618
619#[must_use]
631pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
632 let mut labels = std::collections::HashSet::new();
633
634 let prefix = title
637 .split(':')
638 .next()
639 .unwrap_or("")
640 .split('(')
641 .next()
642 .unwrap_or("")
643 .trim();
644
645 let type_label = match prefix {
647 "feat" | "perf" => Some("enhancement"),
648 "fix" => Some("bug"),
649 "docs" => Some("documentation"),
650 "refactor" => Some("refactor"),
651 _ => None,
652 };
653
654 if let Some(label) = type_label {
655 labels.insert(label.to_string());
656 }
657
658 for path in file_paths {
660 let scope = if path.starts_with("crates/aptu-cli/") {
661 Some("cli")
662 } else if path.starts_with("docs/") {
663 Some("documentation")
664 } else {
665 None
666 };
667
668 if let Some(label) = scope {
669 labels.insert(label.to_string());
670 }
671 }
672
673 labels.into_iter().collect()
674}
675
676#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
696#[allow(clippy::too_many_arguments)]
697pub async fn create_pull_request(
698 client: &Octocrab,
699 owner: &str,
700 repo: &str,
701 title: &str,
702 head_branch: &str,
703 base_branch: &str,
704 body: Option<&str>,
705 draft: bool,
706) -> anyhow::Result<PrCreateResult> {
707 debug!("Creating pull request");
708
709 let pr = client
710 .pulls(owner, repo)
711 .create(title, head_branch, base_branch)
712 .body(body.unwrap_or_default())
713 .draft(draft)
714 .send()
715 .await
716 .with_context(|| {
717 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
718 })?;
719
720 let result = PrCreateResult {
721 pr_number: pr.number.unwrap_or(0),
722 url: pr
723 .html_url
724 .as_ref()
725 .map(std::string::ToString::to_string)
726 .unwrap_or_default(),
727 branch: pr
728 .head
729 .as_deref()
730 .map(|h| h.ref_field.clone())
731 .unwrap_or_default(),
732 base: pr
733 .base
734 .as_deref()
735 .map(|b| b.ref_field.clone())
736 .unwrap_or_default(),
737 title: pr.title.clone().unwrap_or_default(),
738 draft: pr.draft.unwrap_or(false),
739 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
740 additions: pr.additions.unwrap_or(0),
741 deletions: pr.deletions.unwrap_or(0),
742 };
743
744 debug!(
745 pr_number = result.pr_number,
746 "Pull request created successfully"
747 );
748
749 Ok(result)
750}
751
752fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
756 if status.to_lowercase().contains("removed") {
757 debug!(file = %filename, "Skipping removed file");
758 return true;
759 }
760 if patch.is_none_or(String::is_empty) {
761 debug!(file = %filename, "Skipping file with empty patch");
762 return true;
763 }
764 false
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::ai::types::CommentSeverity;
771
772 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
773 use base64::Engine;
774 let engine = base64::engine::general_purpose::STANDARD;
775 let decoded_bytes = engine.decode(encoded).ok()?;
776 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
777
778 if decoded_str.len() <= max_chars {
779 Some(decoded_str)
780 } else {
781 Some(decoded_str.chars().take(max_chars).collect::<String>())
782 }
783 }
784
785 #[test]
786 fn test_pr_create_result_fields() {
787 let result = PrCreateResult {
789 pr_number: 42,
790 url: "https://github.com/owner/repo/pull/42".to_string(),
791 branch: "feat/my-feature".to_string(),
792 base: "main".to_string(),
793 title: "feat: add feature".to_string(),
794 draft: false,
795 files_changed: 3,
796 additions: 100,
797 deletions: 10,
798 };
799
800 assert_eq!(result.pr_number, 42);
802 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
803 assert_eq!(result.branch, "feat/my-feature");
804 assert_eq!(result.base, "main");
805 assert_eq!(result.title, "feat: add feature");
806 assert!(!result.draft);
807 assert_eq!(result.files_changed, 3);
808 assert_eq!(result.additions, 100);
809 assert_eq!(result.deletions, 10);
810 }
811
812 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
819 comments
820 .iter()
821 .filter_map(|c| {
822 c.line.map(|line| {
823 serde_json::json!({
824 "path": c.file,
825 "line": line,
826 "side": "RIGHT",
827 "body": render_pr_review_comment_body(c),
828 })
829 })
830 })
831 .collect()
832 }
833
834 #[test]
835 fn test_post_pr_review_payload_with_comments() {
836 let comments = vec![PrReviewComment {
838 file: "src/main.rs".to_string(),
839 line: Some(42),
840 comment: "Consider using a match here.".to_string(),
841 severity: CommentSeverity::Suggestion,
842 suggested_code: None,
843 }];
844
845 let inline = build_inline_comments(&comments);
847
848 assert_eq!(inline.len(), 1);
850 assert_eq!(inline[0]["path"], "src/main.rs");
851 assert_eq!(inline[0]["line"], 42);
852 assert_eq!(inline[0]["side"], "RIGHT");
853 assert_eq!(inline[0]["body"], "Consider using a match here.");
854 }
855
856 #[test]
857 fn test_post_pr_review_skips_none_line_comments() {
858 let comments = vec![
860 PrReviewComment {
861 file: "src/lib.rs".to_string(),
862 line: None,
863 comment: "General file comment.".to_string(),
864 severity: CommentSeverity::Info,
865 suggested_code: None,
866 },
867 PrReviewComment {
868 file: "src/lib.rs".to_string(),
869 line: Some(10),
870 comment: "Inline comment.".to_string(),
871 severity: CommentSeverity::Warning,
872 suggested_code: None,
873 },
874 ];
875
876 let inline = build_inline_comments(&comments);
878
879 assert_eq!(inline.len(), 1);
881 assert_eq!(inline[0]["line"], 10);
882 }
883
884 #[test]
885 fn test_post_pr_review_empty_comments() {
886 let comments: Vec<PrReviewComment> = vec![];
888
889 let inline = build_inline_comments(&comments);
891
892 assert!(inline.is_empty());
894 let serialized = serde_json::to_string(&inline).unwrap();
895 assert_eq!(serialized, "[]");
896 }
897
898 #[test]
905 fn test_parse_pr_reference_delegates_to_shared() {
906 let (owner, repo, number) =
907 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
908 assert_eq!(owner, "block");
909 assert_eq!(repo, "goose");
910 assert_eq!(number, 123);
911 }
912
913 #[test]
914 fn test_title_prefix_to_label_mapping() {
915 let cases = vec![
916 (
917 "feat: add new feature",
918 vec!["enhancement"],
919 "feat should map to enhancement",
920 ),
921 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
922 (
923 "docs: update readme",
924 vec!["documentation"],
925 "docs should map to documentation",
926 ),
927 (
928 "refactor: improve code",
929 vec!["refactor"],
930 "refactor should map to refactor",
931 ),
932 (
933 "perf: optimize",
934 vec!["enhancement"],
935 "perf should map to enhancement",
936 ),
937 (
938 "chore: update deps",
939 vec![],
940 "chore should produce no labels",
941 ),
942 ];
943
944 for (title, expected_labels, msg) in cases {
945 let labels = labels_from_pr_metadata(title, &[]);
946 for expected in &expected_labels {
947 assert!(
948 labels.contains(&expected.to_string()),
949 "{msg}: expected '{expected}' in {labels:?}",
950 );
951 }
952 if expected_labels.is_empty() {
953 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
954 }
955 }
956 }
957
958 #[test]
959 fn test_file_path_to_scope_mapping() {
960 let cases = vec![
961 (
962 "feat: cli",
963 vec!["crates/aptu-cli/src/main.rs"],
964 vec!["enhancement", "cli"],
965 "cli path should map to cli scope",
966 ),
967 (
968 "feat: docs",
969 vec!["docs/GITHUB_ACTION.md"],
970 vec!["enhancement", "documentation"],
971 "docs path should map to documentation scope",
972 ),
973 (
974 "feat: workflow",
975 vec![".github/workflows/test.yml"],
976 vec!["enhancement"],
977 "workflow path should be ignored",
978 ),
979 ];
980
981 for (title, paths, expected_labels, msg) in cases {
982 let labels = labels_from_pr_metadata(
983 title,
984 &paths
985 .iter()
986 .map(std::string::ToString::to_string)
987 .collect::<Vec<_>>(),
988 );
989 for expected in expected_labels {
990 assert!(
991 labels.contains(&expected.to_string()),
992 "{msg}: expected '{expected}' in {labels:?}",
993 );
994 }
995 }
996 }
997
998 #[test]
999 fn test_combined_title_and_paths() {
1000 let labels = labels_from_pr_metadata(
1001 "feat: multi",
1002 &[
1003 "crates/aptu-cli/src/main.rs".to_string(),
1004 "docs/README.md".to_string(),
1005 ],
1006 );
1007 assert!(
1008 labels.contains(&"enhancement".to_string()),
1009 "should include enhancement from feat prefix"
1010 );
1011 assert!(
1012 labels.contains(&"cli".to_string()),
1013 "should include cli from path"
1014 );
1015 assert!(
1016 labels.contains(&"documentation".to_string()),
1017 "should include documentation from path"
1018 );
1019 }
1020
1021 #[test]
1022 fn test_no_match_returns_empty() {
1023 let cases = vec![
1024 (
1025 "Random title",
1026 vec![],
1027 "unrecognized prefix should return empty",
1028 ),
1029 (
1030 "chore: update",
1031 vec![],
1032 "ignored prefix should return empty",
1033 ),
1034 ];
1035
1036 for (title, paths, msg) in cases {
1037 let labels = labels_from_pr_metadata(title, &paths);
1038 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1039 }
1040 }
1041
1042 #[test]
1043 fn test_scoped_prefix_extracts_type() {
1044 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1045 assert!(
1046 labels.contains(&"enhancement".to_string()),
1047 "scoped prefix should extract type from feat(cli)"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_duplicate_labels_deduplicated() {
1053 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1054 assert_eq!(
1055 labels.len(),
1056 1,
1057 "should have exactly one label when title and path both map to documentation"
1058 );
1059 assert!(
1060 labels.contains(&"documentation".to_string()),
1061 "should contain documentation label"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_should_skip_file_respects_fetched_count_cap() {
1067 let removed_file = PrFile {
1070 filename: "removed.rs".to_string(),
1071 status: "removed".to_string(),
1072 additions: 0,
1073 deletions: 5,
1074 patch: None,
1075 patch_truncated: false,
1076 full_content: None,
1077 };
1078 let modified_file = PrFile {
1079 filename: "file_0.rs".to_string(),
1080 status: "modified".to_string(),
1081 additions: 1,
1082 deletions: 0,
1083 patch: Some("+ new code".to_string()),
1084 patch_truncated: false,
1085 full_content: None,
1086 };
1087 let no_patch_file = PrFile {
1088 filename: "file_1.rs".to_string(),
1089 status: "modified".to_string(),
1090 additions: 1,
1091 deletions: 0,
1092 patch: None,
1093 patch_truncated: false,
1094 full_content: None,
1095 };
1096
1097 assert!(
1099 should_skip_file(
1100 &removed_file.filename,
1101 &removed_file.status,
1102 removed_file.patch.as_ref()
1103 ),
1104 "removed files should be skipped"
1105 );
1106
1107 assert!(
1109 !should_skip_file(
1110 &modified_file.filename,
1111 &modified_file.status,
1112 modified_file.patch.as_ref()
1113 ),
1114 "modified files with patch should not be skipped"
1115 );
1116
1117 assert!(
1119 should_skip_file(
1120 &no_patch_file.filename,
1121 &no_patch_file.status,
1122 no_patch_file.patch.as_ref()
1123 ),
1124 "files without patch should be skipped"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_decode_content_valid_base64() {
1130 use base64::Engine;
1132 let engine = base64::engine::general_purpose::STANDARD;
1133 let original = "Hello, World!";
1134 let encoded = engine.encode(original);
1135
1136 let result = decode_content(&encoded, 1000);
1138
1139 assert_eq!(
1141 result,
1142 Some(original.to_string()),
1143 "valid base64 should decode successfully"
1144 );
1145 }
1146
1147 #[test]
1148 fn test_decode_content_invalid_base64() {
1149 let invalid_base64 = "!!!invalid!!!";
1151
1152 let result = decode_content(invalid_base64, 1000);
1154
1155 assert_eq!(result, None, "invalid base64 should return None");
1157 }
1158
1159 #[test]
1160 fn test_decode_content_truncates_at_max_chars() {
1161 use base64::Engine;
1163 let engine = base64::engine::general_purpose::STANDARD;
1164 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1166 let max_chars = 10;
1167
1168 let result = decode_content(&encoded, max_chars);
1170
1171 assert!(result.is_some(), "decoding should succeed");
1173 let decoded = result.unwrap();
1174 assert_eq!(
1175 decoded.chars().count(),
1176 max_chars,
1177 "output should be truncated to max_chars on character boundary"
1178 );
1179 assert!(
1180 decoded.is_char_boundary(decoded.len()),
1181 "output should be valid UTF-8 (truncated on char boundary)"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_list_files_pagination_collects_all_pages() {
1187 let mut page1_items = Vec::new();
1190 for i in 0..100 {
1191 page1_items.push(PrFile {
1192 filename: format!("file{}.rs", i),
1193 status: "modified".to_string(),
1194 additions: 1,
1195 deletions: 0,
1196 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1197 patch_truncated: false,
1198 full_content: None,
1199 });
1200 }
1201
1202 let mut page2_items = Vec::new();
1204 for i in 100..150 {
1205 page2_items.push(PrFile {
1206 filename: format!("file{}.rs", i),
1207 status: "modified".to_string(),
1208 additions: 1,
1209 deletions: 0,
1210 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1211 patch_truncated: false,
1212 full_content: None,
1213 });
1214 }
1215
1216 let mut all_files = Vec::new();
1218 all_files.extend(page1_items);
1219 all_files.extend(page2_items);
1220
1221 assert_eq!(
1223 all_files.len(),
1224 150,
1225 "pagination should collect all items from both pages"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_list_files_pagination_respects_300_file_cap() {
1231 let mut files = Vec::new();
1233 for i in 0..301 {
1234 files.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 if files.len() >= 300 {
1247 files.truncate(300);
1248 }
1249
1250 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1252 }
1253
1254 #[test]
1255 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1256 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1258 assert!(
1259 is_patch_truncated(truncated_patch),
1260 "patch ending with + should be detected as truncated"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1266 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1268 assert!(
1269 is_patch_truncated(truncated_patch),
1270 "patch ending with - should be detected as truncated"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_is_patch_truncated_clean_patch_context_line() {
1276 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1278 assert!(
1279 !is_patch_truncated(clean_patch),
1280 "patch ending with context line should not be detected as truncated"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_is_patch_truncated_correct_hunk_line_count() {
1286 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1288 assert!(
1289 !is_patch_truncated(clean_patch),
1290 "patch with correct hunk line count should not be detected as truncated"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1296 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1299 assert!(
1300 is_patch_truncated(truncated_patch),
1301 "patch with declared hunk size larger than delivered should be detected as truncated"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1307 let truncated_patch = "line1\nline2\n+";
1309 assert!(
1310 is_patch_truncated(truncated_patch),
1311 "patch with no @@ header but ending with + should be detected as truncated"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_is_patch_truncated_empty_patch() {
1317 let empty_patch = "";
1319 assert!(
1320 !is_patch_truncated(empty_patch),
1321 "empty patch should not be detected as truncated"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1327 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1329 assert!(
1330 is_patch_truncated(truncated_patch),
1331 "patch with last hunk truncated should be detected as truncated"
1332 );
1333 }
1334
1335 #[test]
1336 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1337 }
1341}