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 for file in &mut pr_files {
164 #[allow(clippy::collapsible_if)]
165 if let Some(patch) = &file.patch {
166 if is_patch_truncated(patch) {
167 file.patch_truncated = true;
168 if let Ok(Some(content)) = fetch_file_contents_single(
170 client,
171 owner,
172 repo,
173 &file.filename,
174 pr.head
175 .as_deref()
176 .map(|h| h.sha.as_str())
177 .unwrap_or_default(),
178 review_config.max_chars_per_file,
179 )
180 .await
181 {
182 file.patch = Some(content);
183 }
184 }
185 }
186 }
187
188 let file_contents = fetch_file_contents(
190 client,
191 owner,
192 repo,
193 &pr_files,
194 pr.head
195 .as_deref()
196 .map(|h| h.sha.as_str())
197 .unwrap_or_default(),
198 review_config.max_full_content_files,
199 review_config.max_chars_per_file,
200 )
201 .await;
202
203 debug_assert_eq!(
205 pr_files.len(),
206 file_contents.len(),
207 "fetch_file_contents must return one entry per file"
208 );
209 let pr_files: Vec<PrFile> = pr_files
210 .into_iter()
211 .zip(file_contents)
212 .map(|(mut file, content)| {
213 file.full_content = content;
214 file
215 })
216 .collect();
217
218 let labels: Vec<String> = pr
219 .labels
220 .iter()
221 .flat_map(|v| v.iter())
222 .map(|l| l.name.clone())
223 .collect();
224
225 let details = PrDetails {
226 owner: owner.to_string(),
227 repo: repo.to_string(),
228 number,
229 title: pr.title.clone().unwrap_or_default(),
230 body: pr.body.clone().unwrap_or_default(),
231 base_branch: pr
232 .base
233 .as_deref()
234 .map(|b| b.ref_field.clone())
235 .unwrap_or_default(),
236 head_branch: pr
237 .head
238 .as_deref()
239 .map(|h| h.ref_field.clone())
240 .unwrap_or_default(),
241 head_sha: pr
242 .head
243 .as_deref()
244 .map(|h| h.sha.as_str())
245 .unwrap_or_default()
246 .to_string(),
247 files: pr_files,
248 url: pr
249 .html_url
250 .as_ref()
251 .map(std::string::ToString::to_string)
252 .unwrap_or_default(),
253 labels,
254 review_comments: Vec::new(),
255 instructions: None,
256 dep_enrichments: Vec::new(),
257 };
258
259 debug!(
260 file_count = details.files.len(),
261 "PR details fetched successfully"
262 );
263
264 Ok(details)
265}
266
267fn is_patch_truncated(patch: &str) -> bool {
272 let lines: Vec<&str> = patch.lines().collect();
273
274 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
276 && (last_line.starts_with('+') || last_line.starts_with('-'))
277 {
278 return true;
279 }
280
281 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
284 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
286 if let Some(size_str) = plus_part.split_whitespace().next() {
288 if let Some(count_str) = size_str.split(',').nth(1)
290 && let Ok(declared_count) = count_str.parse::<usize>()
291 {
292 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
295 {
296 let lines_after_hunk = &lines[hunk_idx + 1..];
297 let mut actual_count = 0;
300 for line in lines_after_hunk {
301 if line.starts_with("@@") {
302 break;
303 }
304 if line.starts_with(' ')
305 || line.starts_with('+')
306 || line.starts_with('-')
307 {
308 actual_count += 1;
309 }
310 }
311 if actual_count < declared_count {
313 return true;
314 }
315 }
316 }
317 }
318 }
319 }
320
321 false
322}
323
324#[cfg(not(target_arch = "wasm32"))]
329async fn fetch_file_contents_single(
330 client: &Octocrab,
331 owner: &str,
332 repo: &str,
333 filename: &str,
334 head_sha: &str,
335 max_chars: usize,
336) -> Result<Option<String>> {
337 match client
338 .repos(owner, repo)
339 .get_content()
340 .path(filename)
341 .r#ref(head_sha)
342 .send()
343 .await
344 {
345 Ok(content) => {
346 if let Some(item) = content.items.first() {
348 if let Some(decoded) = item.decoded_content() {
349 let truncated = if decoded.len() > max_chars {
350 decoded.chars().take(max_chars).collect::<String>()
351 } else {
352 decoded
353 };
354 Ok(Some(truncated))
355 } else {
356 tracing::warn!(
357 "Failed to decode content for {}/{}/{} at {}",
358 owner,
359 repo,
360 filename,
361 head_sha
362 );
363 Ok(None)
364 }
365 } else {
366 tracing::warn!(
367 "File content response was empty for {}/{}/{} at {}",
368 owner,
369 repo,
370 filename,
371 head_sha
372 );
373 Ok(None)
374 }
375 }
376 Err(e) => {
377 tracing::warn!(
378 "Failed to fetch content for {}/{}/{} at {}: {}",
379 owner,
380 repo,
381 filename,
382 head_sha,
383 e
384 );
385 Ok(None)
386 }
387 }
388}
389
390#[cfg(not(target_arch = "wasm32"))]
412#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
413async fn fetch_file_contents(
414 client: &Octocrab,
415 owner: &str,
416 repo: &str,
417 files: &[PrFile],
418 head_sha: &str,
419 max_files: usize,
420 max_chars_per_file: usize,
421) -> Vec<Option<String>> {
422 let mut results = Vec::with_capacity(files.len());
423 let mut fetched_count = 0usize;
424
425 for file in files {
426 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
427 results.push(None);
428 continue;
429 }
430
431 if fetched_count >= max_files {
433 debug!(
434 file = %file.filename,
435 fetched_count = fetched_count,
436 max_files = max_files,
437 "Fetched file count exceeds max_files cap"
438 );
439 results.push(None);
440 continue;
441 }
442
443 match client
445 .repos(owner, repo)
446 .get_content()
447 .path(&file.filename)
448 .r#ref(head_sha)
449 .send()
450 .await
451 {
452 Ok(content) => {
453 if let Some(item) = content.items.first() {
455 if let Some(decoded) = item.decoded_content() {
456 let truncated = if decoded.len() > max_chars_per_file {
457 decoded.chars().take(max_chars_per_file).collect::<String>()
458 } else {
459 decoded
460 };
461 debug!(
462 file = %file.filename,
463 content_len = truncated.len(),
464 "File content fetched and truncated"
465 );
466 results.push(Some(truncated));
467 fetched_count += 1;
468 } else {
469 tracing::warn!(
470 file = %file.filename,
471 "Failed to decode file content; skipping"
472 );
473 results.push(None);
474 }
475 } else {
476 tracing::warn!(
477 file = %file.filename,
478 "File content response was empty; skipping"
479 );
480 results.push(None);
481 }
482 }
483 Err(e) => {
484 tracing::warn!(
485 file = %file.filename,
486 err = %e,
487 "Failed to fetch file content; skipping"
488 );
489 results.push(None);
490 }
491 }
492 }
493
494 results
495}
496
497#[cfg(not(target_arch = "wasm32"))]
521#[allow(clippy::too_many_arguments)]
522#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
523pub async fn post_pr_review(
524 client: &Octocrab,
525 owner: &str,
526 repo: &str,
527 number: u64,
528 body: &str,
529 event: ReviewEvent,
530 comments: &[PrReviewComment],
531 commit_id: &str,
532) -> Result<u64> {
533 debug!("Posting PR review");
534
535 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
536
537 let inline_comments: Vec<serde_json::Value> = comments
539 .iter()
540 .filter_map(|c| {
542 c.line.map(|line| {
543 serde_json::json!({
544 "path": c.file,
545 "line": line,
546 "side": "RIGHT",
550 "body": render_pr_review_comment_body(c),
551 })
552 })
553 })
554 .collect();
555
556 let mut payload = serde_json::json!({
557 "body": body,
558 "event": event.to_string(),
559 "comments": inline_comments,
560 });
561
562 if !commit_id.is_empty() {
564 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
565 }
566
567 #[derive(serde::Deserialize)]
568 struct ReviewResponse {
569 id: u64,
570 }
571
572 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
573 format!(
574 "Failed to post review to PR #{number} in {owner}/{repo}. \
575 Check that you have write access to the repository."
576 )
577 })?;
578
579 debug!(review_id = response.id, "PR review posted successfully");
580
581 Ok(response.id)
582}
583
584#[cfg(not(target_arch = "wasm32"))]
591#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
592pub async fn delete_pr_review_comment(
593 client: &Octocrab,
594 owner: &str,
595 repo: &str,
596 comment_id: u64,
597) -> Result<()> {
598 debug!("Deleting PR review comment");
599
600 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
601
602 let empty_body = serde_json::json!({});
604 let result: std::result::Result<serde_json::Value, _> =
605 client.delete(&route, Some(&empty_body)).await;
606
607 match result {
608 Ok(_) => {
609 debug!("PR review comment deleted successfully");
610 Ok(())
611 }
612 Err(e)
613 if let octocrab::Error::GitHub { source, .. } = &e
614 && source.status_code.as_u16() == 404 =>
615 {
616 debug!("PR review comment already deleted (404); treating as success");
617 Ok(())
618 }
619 Err(e) => {
620 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
621 }
622 }
623}
624
625#[must_use]
637pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
638 let mut labels = std::collections::HashSet::new();
639
640 let prefix = title
643 .split(':')
644 .next()
645 .unwrap_or("")
646 .split('(')
647 .next()
648 .unwrap_or("")
649 .trim();
650
651 let type_label = match prefix {
653 "feat" | "perf" => Some("enhancement"),
654 "fix" => Some("bug"),
655 "docs" => Some("documentation"),
656 "refactor" => Some("refactor"),
657 _ => None,
658 };
659
660 if let Some(label) = type_label {
661 labels.insert(label.to_string());
662 }
663
664 for path in file_paths {
666 let scope = if path.starts_with("crates/aptu-cli/") {
667 Some("cli")
668 } else if path.starts_with("docs/") {
669 Some("documentation")
670 } else {
671 None
672 };
673
674 if let Some(label) = scope {
675 labels.insert(label.to_string());
676 }
677 }
678
679 labels.into_iter().collect()
680}
681
682#[cfg(not(target_arch = "wasm32"))]
702#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
703#[allow(clippy::too_many_arguments)]
704pub async fn create_pull_request(
705 client: &Octocrab,
706 owner: &str,
707 repo: &str,
708 title: &str,
709 head_branch: &str,
710 base_branch: &str,
711 body: Option<&str>,
712 draft: bool,
713) -> anyhow::Result<PrCreateResult> {
714 debug!("Creating pull request");
715
716 let pr = client
717 .pulls(owner, repo)
718 .create(title, head_branch, base_branch)
719 .body(body.unwrap_or_default())
720 .draft(draft)
721 .send()
722 .await
723 .with_context(|| {
724 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
725 })?;
726
727 let result = PrCreateResult {
728 pr_number: pr.number.unwrap_or(0),
729 url: pr
730 .html_url
731 .as_ref()
732 .map(std::string::ToString::to_string)
733 .unwrap_or_default(),
734 branch: pr
735 .head
736 .as_deref()
737 .map(|h| h.ref_field.clone())
738 .unwrap_or_default(),
739 base: pr
740 .base
741 .as_deref()
742 .map(|b| b.ref_field.clone())
743 .unwrap_or_default(),
744 title: pr.title.clone().unwrap_or_default(),
745 draft: pr.draft.unwrap_or(false),
746 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
747 additions: pr.additions.unwrap_or(0),
748 deletions: pr.deletions.unwrap_or(0),
749 };
750
751 debug!(
752 pr_number = result.pr_number,
753 "Pull request created successfully"
754 );
755
756 Ok(result)
757}
758
759fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
763 if status.to_lowercase().contains("removed") {
764 debug!(file = %filename, "Skipping removed file");
765 return true;
766 }
767 if patch.is_none_or(String::is_empty) {
768 debug!(file = %filename, "Skipping file with empty patch");
769 return true;
770 }
771 false
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use crate::ai::types::CommentSeverity;
778
779 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
780 use base64::Engine;
781 let engine = base64::engine::general_purpose::STANDARD;
782 let decoded_bytes = engine.decode(encoded).ok()?;
783 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
784
785 if decoded_str.len() <= max_chars {
786 Some(decoded_str)
787 } else {
788 Some(decoded_str.chars().take(max_chars).collect::<String>())
789 }
790 }
791
792 #[test]
793 fn test_pr_create_result_fields() {
794 let result = PrCreateResult {
796 pr_number: 42,
797 url: "https://github.com/owner/repo/pull/42".to_string(),
798 branch: "feat/my-feature".to_string(),
799 base: "main".to_string(),
800 title: "feat: add feature".to_string(),
801 draft: false,
802 files_changed: 3,
803 additions: 100,
804 deletions: 10,
805 };
806
807 assert_eq!(result.pr_number, 42);
809 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
810 assert_eq!(result.branch, "feat/my-feature");
811 assert_eq!(result.base, "main");
812 assert_eq!(result.title, "feat: add feature");
813 assert!(!result.draft);
814 assert_eq!(result.files_changed, 3);
815 assert_eq!(result.additions, 100);
816 assert_eq!(result.deletions, 10);
817 }
818
819 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
826 comments
827 .iter()
828 .filter_map(|c| {
829 c.line.map(|line| {
830 serde_json::json!({
831 "path": c.file,
832 "line": line,
833 "side": "RIGHT",
834 "body": render_pr_review_comment_body(c),
835 })
836 })
837 })
838 .collect()
839 }
840
841 #[test]
842 fn test_post_pr_review_payload_with_comments() {
843 let comments = vec![PrReviewComment {
845 file: "src/main.rs".to_string(),
846 line: Some(42),
847 comment: "Consider using a match here.".to_string(),
848 severity: CommentSeverity::Suggestion,
849 suggested_code: None,
850 }];
851
852 let inline = build_inline_comments(&comments);
854
855 assert_eq!(inline.len(), 1);
857 assert_eq!(inline[0]["path"], "src/main.rs");
858 assert_eq!(inline[0]["line"], 42);
859 assert_eq!(inline[0]["side"], "RIGHT");
860 assert_eq!(inline[0]["body"], "Consider using a match here.");
861 }
862
863 #[test]
864 fn test_post_pr_review_skips_none_line_comments() {
865 let comments = vec![
867 PrReviewComment {
868 file: "src/lib.rs".to_string(),
869 line: None,
870 comment: "General file comment.".to_string(),
871 severity: CommentSeverity::Info,
872 suggested_code: None,
873 },
874 PrReviewComment {
875 file: "src/lib.rs".to_string(),
876 line: Some(10),
877 comment: "Inline comment.".to_string(),
878 severity: CommentSeverity::Warning,
879 suggested_code: None,
880 },
881 ];
882
883 let inline = build_inline_comments(&comments);
885
886 assert_eq!(inline.len(), 1);
888 assert_eq!(inline[0]["line"], 10);
889 }
890
891 #[test]
892 fn test_post_pr_review_empty_comments() {
893 let comments: Vec<PrReviewComment> = vec![];
895
896 let inline = build_inline_comments(&comments);
898
899 assert!(inline.is_empty());
901 let serialized = serde_json::to_string(&inline).unwrap();
902 assert_eq!(serialized, "[]");
903 }
904
905 #[test]
912 fn test_parse_pr_reference_delegates_to_shared() {
913 let (owner, repo, number) =
914 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
915 assert_eq!(owner, "block");
916 assert_eq!(repo, "goose");
917 assert_eq!(number, 123);
918 }
919
920 #[test]
921 fn test_title_prefix_to_label_mapping() {
922 let cases = vec![
923 (
924 "feat: add new feature",
925 vec!["enhancement"],
926 "feat should map to enhancement",
927 ),
928 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
929 (
930 "docs: update readme",
931 vec!["documentation"],
932 "docs should map to documentation",
933 ),
934 (
935 "refactor: improve code",
936 vec!["refactor"],
937 "refactor should map to refactor",
938 ),
939 (
940 "perf: optimize",
941 vec!["enhancement"],
942 "perf should map to enhancement",
943 ),
944 (
945 "chore: update deps",
946 vec![],
947 "chore should produce no labels",
948 ),
949 ];
950
951 for (title, expected_labels, msg) in cases {
952 let labels = labels_from_pr_metadata(title, &[]);
953 for expected in &expected_labels {
954 assert!(
955 labels.contains(&expected.to_string()),
956 "{msg}: expected '{expected}' in {labels:?}",
957 );
958 }
959 if expected_labels.is_empty() {
960 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
961 }
962 }
963 }
964
965 #[test]
966 fn test_file_path_to_scope_mapping() {
967 let cases = vec![
968 (
969 "feat: cli",
970 vec!["crates/aptu-cli/src/main.rs"],
971 vec!["enhancement", "cli"],
972 "cli path should map to cli scope",
973 ),
974 (
975 "feat: docs",
976 vec!["docs/GITHUB_ACTION.md"],
977 vec!["enhancement", "documentation"],
978 "docs path should map to documentation scope",
979 ),
980 (
981 "feat: workflow",
982 vec![".github/workflows/test.yml"],
983 vec!["enhancement"],
984 "workflow path should be ignored",
985 ),
986 ];
987
988 for (title, paths, expected_labels, msg) in cases {
989 let labels = labels_from_pr_metadata(
990 title,
991 &paths
992 .iter()
993 .map(std::string::ToString::to_string)
994 .collect::<Vec<_>>(),
995 );
996 for expected in expected_labels {
997 assert!(
998 labels.contains(&expected.to_string()),
999 "{msg}: expected '{expected}' in {labels:?}",
1000 );
1001 }
1002 }
1003 }
1004
1005 #[test]
1006 fn test_combined_title_and_paths() {
1007 let labels = labels_from_pr_metadata(
1008 "feat: multi",
1009 &[
1010 "crates/aptu-cli/src/main.rs".to_string(),
1011 "docs/README.md".to_string(),
1012 ],
1013 );
1014 assert!(
1015 labels.contains(&"enhancement".to_string()),
1016 "should include enhancement from feat prefix"
1017 );
1018 assert!(
1019 labels.contains(&"cli".to_string()),
1020 "should include cli from path"
1021 );
1022 assert!(
1023 labels.contains(&"documentation".to_string()),
1024 "should include documentation from path"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_no_match_returns_empty() {
1030 let cases = vec![
1031 (
1032 "Random title",
1033 vec![],
1034 "unrecognized prefix should return empty",
1035 ),
1036 (
1037 "chore: update",
1038 vec![],
1039 "ignored prefix should return empty",
1040 ),
1041 ];
1042
1043 for (title, paths, msg) in cases {
1044 let labels = labels_from_pr_metadata(title, &paths);
1045 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1046 }
1047 }
1048
1049 #[test]
1050 fn test_scoped_prefix_extracts_type() {
1051 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1052 assert!(
1053 labels.contains(&"enhancement".to_string()),
1054 "scoped prefix should extract type from feat(cli)"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_duplicate_labels_deduplicated() {
1060 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1061 assert_eq!(
1062 labels.len(),
1063 1,
1064 "should have exactly one label when title and path both map to documentation"
1065 );
1066 assert!(
1067 labels.contains(&"documentation".to_string()),
1068 "should contain documentation label"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_should_skip_file_respects_fetched_count_cap() {
1074 let removed_file = PrFile {
1077 filename: "removed.rs".to_string(),
1078 status: "removed".to_string(),
1079 additions: 0,
1080 deletions: 5,
1081 patch: None,
1082 patch_truncated: false,
1083 full_content: None,
1084 };
1085 let modified_file = PrFile {
1086 filename: "file_0.rs".to_string(),
1087 status: "modified".to_string(),
1088 additions: 1,
1089 deletions: 0,
1090 patch: Some("+ new code".to_string()),
1091 patch_truncated: false,
1092 full_content: None,
1093 };
1094 let no_patch_file = PrFile {
1095 filename: "file_1.rs".to_string(),
1096 status: "modified".to_string(),
1097 additions: 1,
1098 deletions: 0,
1099 patch: None,
1100 patch_truncated: false,
1101 full_content: None,
1102 };
1103
1104 assert!(
1106 should_skip_file(
1107 &removed_file.filename,
1108 &removed_file.status,
1109 removed_file.patch.as_ref()
1110 ),
1111 "removed files should be skipped"
1112 );
1113
1114 assert!(
1116 !should_skip_file(
1117 &modified_file.filename,
1118 &modified_file.status,
1119 modified_file.patch.as_ref()
1120 ),
1121 "modified files with patch should not be skipped"
1122 );
1123
1124 assert!(
1126 should_skip_file(
1127 &no_patch_file.filename,
1128 &no_patch_file.status,
1129 no_patch_file.patch.as_ref()
1130 ),
1131 "files without patch should be skipped"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_decode_content_valid_base64() {
1137 use base64::Engine;
1139 let engine = base64::engine::general_purpose::STANDARD;
1140 let original = "Hello, World!";
1141 let encoded = engine.encode(original);
1142
1143 let result = decode_content(&encoded, 1000);
1145
1146 assert_eq!(
1148 result,
1149 Some(original.to_string()),
1150 "valid base64 should decode successfully"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_decode_content_invalid_base64() {
1156 let invalid_base64 = "!!!invalid!!!";
1158
1159 let result = decode_content(invalid_base64, 1000);
1161
1162 assert_eq!(result, None, "invalid base64 should return None");
1164 }
1165
1166 #[test]
1167 fn test_decode_content_truncates_at_max_chars() {
1168 use base64::Engine;
1170 let engine = base64::engine::general_purpose::STANDARD;
1171 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1173 let max_chars = 10;
1174
1175 let result = decode_content(&encoded, max_chars);
1177
1178 assert!(result.is_some(), "decoding should succeed");
1180 let decoded = result.unwrap();
1181 assert_eq!(
1182 decoded.chars().count(),
1183 max_chars,
1184 "output should be truncated to max_chars on character boundary"
1185 );
1186 assert!(
1187 decoded.is_char_boundary(decoded.len()),
1188 "output should be valid UTF-8 (truncated on char boundary)"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_list_files_pagination_collects_all_pages() {
1194 let mut page1_items = Vec::new();
1197 for i in 0..100 {
1198 page1_items.push(PrFile {
1199 filename: format!("file{}.rs", i),
1200 status: "modified".to_string(),
1201 additions: 1,
1202 deletions: 0,
1203 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1204 patch_truncated: false,
1205 full_content: None,
1206 });
1207 }
1208
1209 let mut page2_items = Vec::new();
1211 for i in 100..150 {
1212 page2_items.push(PrFile {
1213 filename: format!("file{}.rs", i),
1214 status: "modified".to_string(),
1215 additions: 1,
1216 deletions: 0,
1217 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1218 patch_truncated: false,
1219 full_content: None,
1220 });
1221 }
1222
1223 let mut all_files = Vec::new();
1225 all_files.extend(page1_items);
1226 all_files.extend(page2_items);
1227
1228 assert_eq!(
1230 all_files.len(),
1231 150,
1232 "pagination should collect all items from both pages"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_list_files_pagination_respects_300_file_cap() {
1238 let mut files = Vec::new();
1240 for i in 0..301 {
1241 files.push(PrFile {
1242 filename: format!("file{}.rs", i),
1243 status: "modified".to_string(),
1244 additions: 1,
1245 deletions: 0,
1246 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1247 patch_truncated: false,
1248 full_content: None,
1249 });
1250 }
1251
1252 if files.len() >= 300 {
1254 files.truncate(300);
1255 }
1256
1257 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1259 }
1260
1261 #[test]
1262 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1263 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1265 assert!(
1266 is_patch_truncated(truncated_patch),
1267 "patch ending with + should be detected as truncated"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1273 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1275 assert!(
1276 is_patch_truncated(truncated_patch),
1277 "patch ending with - should be detected as truncated"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_is_patch_truncated_clean_patch_context_line() {
1283 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1285 assert!(
1286 !is_patch_truncated(clean_patch),
1287 "patch ending with context line should not be detected as truncated"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_is_patch_truncated_correct_hunk_line_count() {
1293 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1295 assert!(
1296 !is_patch_truncated(clean_patch),
1297 "patch with correct hunk line count should not be detected as truncated"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1303 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1306 assert!(
1307 is_patch_truncated(truncated_patch),
1308 "patch with declared hunk size larger than delivered should be detected as truncated"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1314 let truncated_patch = "line1\nline2\n+";
1316 assert!(
1317 is_patch_truncated(truncated_patch),
1318 "patch with no @@ header but ending with + should be detected as truncated"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_is_patch_truncated_empty_patch() {
1324 let empty_patch = "";
1326 assert!(
1327 !is_patch_truncated(empty_patch),
1328 "empty patch should not be detected as truncated"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1334 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1336 assert!(
1337 is_patch_truncated(truncated_patch),
1338 "patch with last hunk truncated should be detected as truncated"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1344 }
1348}