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
163 .head
164 .as_deref()
165 .map(|h| h.sha.as_str())
166 .unwrap_or_default();
167
168 for file in &mut pr_files {
170 #[allow(clippy::collapsible_if)]
171 if let Some(patch) = &file.patch {
172 if is_patch_truncated(patch) {
173 file.patch_truncated = true;
174 if let Ok(Some(content)) = fetch_file_contents_single(
176 client,
177 owner,
178 repo,
179 &file.filename,
180 head_sha,
181 review_config.max_chars_per_file,
182 )
183 .await
184 {
185 file.patch = Some(content);
186 }
187 }
188 }
189 }
190
191 for file in &mut pr_files {
195 let is_added_renamed_copied = matches!(
197 file.status.to_lowercase().as_str(),
198 "added" | "renamed" | "copied"
199 );
200 let patch_too_large =
201 file.patch.as_deref().map_or(0, str::len) > review_config.max_patch_chars_per_file;
202 if is_added_renamed_copied && patch_too_large && file.full_content.is_none() {
203 match fetch_file_contents_single(
204 client,
205 owner,
206 repo,
207 &file.filename,
208 head_sha,
209 review_config.max_chars_per_file,
210 )
211 .await
212 {
213 Ok(Some(content)) => {
214 file.full_content = Some(content);
215 }
216 Ok(None) => {
217 tracing::warn!(
218 "Contents API returned empty content for added file {} in PR #{}",
219 file.filename,
220 number
221 );
222 }
223 Err(e) => {
224 tracing::warn!(
225 "Failed to fetch contents for added file {} in PR #{}: {}",
226 file.filename,
227 number,
228 e
229 );
230 }
231 }
232 }
233 }
234
235 let file_contents = fetch_file_contents(
237 client,
238 owner,
239 repo,
240 &pr_files,
241 pr.head
242 .as_deref()
243 .map(|h| h.sha.as_str())
244 .unwrap_or_default(),
245 review_config.max_full_content_files,
246 review_config.max_chars_per_file,
247 )
248 .await;
249
250 debug_assert_eq!(
252 pr_files.len(),
253 file_contents.len(),
254 "fetch_file_contents must return one entry per file"
255 );
256 let pr_files: Vec<PrFile> = pr_files
257 .into_iter()
258 .zip(file_contents)
259 .map(|(mut file, content)| {
260 if file.full_content.is_none() {
261 file.full_content = content;
262 }
263 file
264 })
265 .collect();
266
267 let labels: Vec<String> = pr
268 .labels
269 .iter()
270 .flat_map(|v| v.iter())
271 .map(|l| l.name.clone())
272 .collect();
273
274 let details = PrDetails {
275 owner: owner.to_string(),
276 repo: repo.to_string(),
277 number,
278 title: pr.title.clone().unwrap_or_default(),
279 body: pr.body.clone().unwrap_or_default(),
280 base_branch: pr
281 .base
282 .as_deref()
283 .map(|b| b.ref_field.clone())
284 .unwrap_or_default(),
285 head_branch: pr
286 .head
287 .as_deref()
288 .map(|h| h.ref_field.clone())
289 .unwrap_or_default(),
290 head_sha: pr
291 .head
292 .as_deref()
293 .map(|h| h.sha.as_str())
294 .unwrap_or_default()
295 .to_string(),
296 files: pr_files,
297 url: pr
298 .html_url
299 .as_ref()
300 .map(std::string::ToString::to_string)
301 .unwrap_or_default(),
302 labels,
303 review_comments: Vec::new(),
304 instructions: None,
305 dep_enrichments: Vec::new(),
306 };
307
308 debug!(
309 file_count = details.files.len(),
310 "PR details fetched successfully"
311 );
312
313 Ok(details)
314}
315
316fn is_patch_truncated(patch: &str) -> bool {
321 let lines: Vec<&str> = patch.lines().collect();
322
323 if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
325 && (last_line.starts_with('+') || last_line.starts_with('-'))
326 {
327 return true;
328 }
329
330 if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
333 if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
335 if let Some(size_str) = plus_part.split_whitespace().next() {
337 if let Some(count_str) = size_str.split(',').nth(1)
339 && let Ok(declared_count) = count_str.parse::<usize>()
340 {
341 if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
344 {
345 let lines_after_hunk = &lines[hunk_idx + 1..];
346 let mut actual_count = 0;
349 for line in lines_after_hunk {
350 if line.starts_with("@@") {
351 break;
352 }
353 if line.starts_with(' ')
354 || line.starts_with('+')
355 || line.starts_with('-')
356 {
357 actual_count += 1;
358 }
359 }
360 if actual_count < declared_count {
362 return true;
363 }
364 }
365 }
366 }
367 }
368 }
369
370 false
371}
372
373#[cfg(not(target_arch = "wasm32"))]
378async fn fetch_file_contents_single(
379 client: &Octocrab,
380 owner: &str,
381 repo: &str,
382 filename: &str,
383 head_sha: &str,
384 max_chars: usize,
385) -> Result<Option<String>> {
386 match client
387 .repos(owner, repo)
388 .get_content()
389 .path(filename)
390 .r#ref(head_sha)
391 .send()
392 .await
393 {
394 Ok(content) => {
395 if let Some(item) = content.items.first() {
397 if let Some(decoded) = item.decoded_content() {
398 let truncated = if decoded.len() > max_chars {
399 decoded.chars().take(max_chars).collect::<String>()
400 } else {
401 decoded
402 };
403 Ok(Some(truncated))
404 } else {
405 tracing::warn!(
406 "Failed to decode content for {}/{}/{} at {}",
407 owner,
408 repo,
409 filename,
410 head_sha
411 );
412 Ok(None)
413 }
414 } else {
415 tracing::warn!(
416 "File content response was empty for {}/{}/{} at {}",
417 owner,
418 repo,
419 filename,
420 head_sha
421 );
422 Ok(None)
423 }
424 }
425 Err(e) => {
426 tracing::warn!(
427 "Failed to fetch content for {}/{}/{} at {}: {}",
428 owner,
429 repo,
430 filename,
431 head_sha,
432 e
433 );
434 Ok(None)
435 }
436 }
437}
438
439#[cfg(not(target_arch = "wasm32"))]
461#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
462async fn fetch_file_contents(
463 client: &Octocrab,
464 owner: &str,
465 repo: &str,
466 files: &[PrFile],
467 head_sha: &str,
468 max_files: usize,
469 max_chars_per_file: usize,
470) -> Vec<Option<String>> {
471 let mut results = Vec::with_capacity(files.len());
472 let mut fetched_count = 0usize;
473
474 for file in files {
475 if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
476 results.push(None);
477 continue;
478 }
479
480 if fetched_count >= max_files {
482 debug!(
483 file = %file.filename,
484 fetched_count = fetched_count,
485 max_files = max_files,
486 "Fetched file count exceeds max_files cap"
487 );
488 results.push(None);
489 continue;
490 }
491
492 match client
494 .repos(owner, repo)
495 .get_content()
496 .path(&file.filename)
497 .r#ref(head_sha)
498 .send()
499 .await
500 {
501 Ok(content) => {
502 if let Some(item) = content.items.first() {
504 if let Some(decoded) = item.decoded_content() {
505 let truncated = if decoded.len() > max_chars_per_file {
506 decoded.chars().take(max_chars_per_file).collect::<String>()
507 } else {
508 decoded
509 };
510 debug!(
511 file = %file.filename,
512 content_len = truncated.len(),
513 "File content fetched and truncated"
514 );
515 results.push(Some(truncated));
516 fetched_count += 1;
517 } else {
518 tracing::warn!(
519 file = %file.filename,
520 "Failed to decode file content; skipping"
521 );
522 results.push(None);
523 }
524 } else {
525 tracing::warn!(
526 file = %file.filename,
527 "File content response was empty; skipping"
528 );
529 results.push(None);
530 }
531 }
532 Err(e) => {
533 tracing::warn!(
534 file = %file.filename,
535 err = %e,
536 "Failed to fetch file content; skipping"
537 );
538 results.push(None);
539 }
540 }
541 }
542
543 results
544}
545
546#[cfg(not(target_arch = "wasm32"))]
570#[allow(clippy::too_many_arguments)]
571#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
572pub async fn post_pr_review(
573 client: &Octocrab,
574 owner: &str,
575 repo: &str,
576 number: u64,
577 body: &str,
578 event: ReviewEvent,
579 comments: &[PrReviewComment],
580 commit_id: &str,
581) -> Result<u64> {
582 debug!("Posting PR review");
583
584 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
585
586 let inline_comments: Vec<serde_json::Value> = comments
588 .iter()
589 .filter_map(|c| {
591 c.line.map(|line| {
592 serde_json::json!({
593 "path": c.file,
594 "line": line,
595 "side": "RIGHT",
599 "body": render_pr_review_comment_body(c),
600 })
601 })
602 })
603 .collect();
604
605 let mut payload = serde_json::json!({
606 "body": body,
607 "event": event.to_string(),
608 "comments": inline_comments,
609 });
610
611 if !commit_id.is_empty() {
613 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
614 }
615
616 #[derive(serde::Deserialize)]
617 struct ReviewResponse {
618 id: u64,
619 }
620
621 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
622 format!(
623 "Failed to post review to PR #{number} in {owner}/{repo}. \
624 Check that you have write access to the repository."
625 )
626 })?;
627
628 debug!(review_id = response.id, "PR review posted successfully");
629
630 Ok(response.id)
631}
632
633#[cfg(not(target_arch = "wasm32"))]
640#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
641pub async fn delete_pr_review_comment(
642 client: &Octocrab,
643 owner: &str,
644 repo: &str,
645 comment_id: u64,
646) -> Result<()> {
647 debug!("Deleting PR review comment");
648
649 let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
650
651 let empty_body = serde_json::json!({});
653 let result: std::result::Result<serde_json::Value, _> =
654 client.delete(&route, Some(&empty_body)).await;
655
656 match result {
657 Ok(_) => {
658 debug!("PR review comment deleted successfully");
659 Ok(())
660 }
661 Err(e)
662 if let octocrab::Error::GitHub { source, .. } = &e
663 && source.status_code.as_u16() == 404 =>
664 {
665 debug!("PR review comment already deleted (404); treating as success");
666 Ok(())
667 }
668 Err(e) => {
669 Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
670 }
671 }
672}
673
674#[must_use]
686pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
687 let mut labels = std::collections::HashSet::new();
688
689 let prefix = title
692 .split(':')
693 .next()
694 .unwrap_or("")
695 .split('(')
696 .next()
697 .unwrap_or("")
698 .trim();
699
700 let type_label = match prefix {
702 "feat" | "perf" => Some("enhancement"),
703 "fix" => Some("bug"),
704 "docs" => Some("documentation"),
705 "refactor" => Some("refactor"),
706 _ => None,
707 };
708
709 if let Some(label) = type_label {
710 labels.insert(label.to_string());
711 }
712
713 for path in file_paths {
715 let scope = if path.starts_with("crates/aptu-cli/") {
716 Some("cli")
717 } else if path.starts_with("docs/") {
718 Some("documentation")
719 } else {
720 None
721 };
722
723 if let Some(label) = scope {
724 labels.insert(label.to_string());
725 }
726 }
727
728 labels.into_iter().collect()
729}
730
731#[cfg(not(target_arch = "wasm32"))]
751#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
752#[allow(clippy::too_many_arguments)]
753pub async fn create_pull_request(
754 client: &Octocrab,
755 owner: &str,
756 repo: &str,
757 title: &str,
758 head_branch: &str,
759 base_branch: &str,
760 body: Option<&str>,
761 draft: bool,
762) -> anyhow::Result<PrCreateResult> {
763 debug!("Creating pull request");
764
765 let pr = client
766 .pulls(owner, repo)
767 .create(title, head_branch, base_branch)
768 .body(body.unwrap_or_default())
769 .draft(draft)
770 .send()
771 .await
772 .with_context(|| {
773 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
774 })?;
775
776 let result = PrCreateResult {
777 pr_number: pr.number.unwrap_or(0),
778 url: pr
779 .html_url
780 .as_ref()
781 .map(std::string::ToString::to_string)
782 .unwrap_or_default(),
783 branch: pr
784 .head
785 .as_deref()
786 .map(|h| h.ref_field.clone())
787 .unwrap_or_default(),
788 base: pr
789 .base
790 .as_deref()
791 .map(|b| b.ref_field.clone())
792 .unwrap_or_default(),
793 title: pr.title.clone().unwrap_or_default(),
794 draft: pr.draft.unwrap_or(false),
795 files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
796 additions: pr.additions.unwrap_or(0),
797 deletions: pr.deletions.unwrap_or(0),
798 };
799
800 debug!(
801 pr_number = result.pr_number,
802 "Pull request created successfully"
803 );
804
805 Ok(result)
806}
807
808fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
812 if status.to_lowercase().contains("removed") {
813 debug!(file = %filename, "Skipping removed file");
814 return true;
815 }
816 if patch.is_none_or(String::is_empty) {
817 debug!(file = %filename, "Skipping file with empty patch");
818 return true;
819 }
820 false
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::ai::types::CommentSeverity;
827
828 fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
829 use base64::Engine;
830 let engine = base64::engine::general_purpose::STANDARD;
831 let decoded_bytes = engine.decode(encoded).ok()?;
832 let decoded_str = String::from_utf8(decoded_bytes).ok()?;
833
834 if decoded_str.len() <= max_chars {
835 Some(decoded_str)
836 } else {
837 Some(decoded_str.chars().take(max_chars).collect::<String>())
838 }
839 }
840
841 #[test]
842 fn test_pr_create_result_fields() {
843 let result = PrCreateResult {
845 pr_number: 42,
846 url: "https://github.com/owner/repo/pull/42".to_string(),
847 branch: "feat/my-feature".to_string(),
848 base: "main".to_string(),
849 title: "feat: add feature".to_string(),
850 draft: false,
851 files_changed: 3,
852 additions: 100,
853 deletions: 10,
854 };
855
856 assert_eq!(result.pr_number, 42);
858 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
859 assert_eq!(result.branch, "feat/my-feature");
860 assert_eq!(result.base, "main");
861 assert_eq!(result.title, "feat: add feature");
862 assert!(!result.draft);
863 assert_eq!(result.files_changed, 3);
864 assert_eq!(result.additions, 100);
865 assert_eq!(result.deletions, 10);
866 }
867
868 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
875 comments
876 .iter()
877 .filter_map(|c| {
878 c.line.map(|line| {
879 serde_json::json!({
880 "path": c.file,
881 "line": line,
882 "side": "RIGHT",
883 "body": render_pr_review_comment_body(c),
884 })
885 })
886 })
887 .collect()
888 }
889
890 #[test]
891 fn test_post_pr_review_payload_with_comments() {
892 let comments = vec![PrReviewComment {
894 file: "src/main.rs".to_string(),
895 line: Some(42),
896 comment: "Consider using a match here.".to_string(),
897 severity: CommentSeverity::Suggestion,
898 suggested_code: None,
899 }];
900
901 let inline = build_inline_comments(&comments);
903
904 assert_eq!(inline.len(), 1);
906 assert_eq!(inline[0]["path"], "src/main.rs");
907 assert_eq!(inline[0]["line"], 42);
908 assert_eq!(inline[0]["side"], "RIGHT");
909 assert_eq!(inline[0]["body"], "Consider using a match here.");
910 }
911
912 #[test]
913 fn test_post_pr_review_skips_none_line_comments() {
914 let comments = vec![
916 PrReviewComment {
917 file: "src/lib.rs".to_string(),
918 line: None,
919 comment: "General file comment.".to_string(),
920 severity: CommentSeverity::Info,
921 suggested_code: None,
922 },
923 PrReviewComment {
924 file: "src/lib.rs".to_string(),
925 line: Some(10),
926 comment: "Inline comment.".to_string(),
927 severity: CommentSeverity::Warning,
928 suggested_code: None,
929 },
930 ];
931
932 let inline = build_inline_comments(&comments);
934
935 assert_eq!(inline.len(), 1);
937 assert_eq!(inline[0]["line"], 10);
938 }
939
940 #[test]
941 fn test_post_pr_review_empty_comments() {
942 let comments: Vec<PrReviewComment> = vec![];
944
945 let inline = build_inline_comments(&comments);
947
948 assert!(inline.is_empty());
950 let serialized = serde_json::to_string(&inline).unwrap();
951 assert_eq!(serialized, "[]");
952 }
953
954 #[test]
961 fn test_parse_pr_reference_delegates_to_shared() {
962 let (owner, repo, number) =
963 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
964 assert_eq!(owner, "block");
965 assert_eq!(repo, "goose");
966 assert_eq!(number, 123);
967 }
968
969 #[test]
970 fn test_title_prefix_to_label_mapping() {
971 let cases = vec![
972 (
973 "feat: add new feature",
974 vec!["enhancement"],
975 "feat should map to enhancement",
976 ),
977 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
978 (
979 "docs: update readme",
980 vec!["documentation"],
981 "docs should map to documentation",
982 ),
983 (
984 "refactor: improve code",
985 vec!["refactor"],
986 "refactor should map to refactor",
987 ),
988 (
989 "perf: optimize",
990 vec!["enhancement"],
991 "perf should map to enhancement",
992 ),
993 (
994 "chore: update deps",
995 vec![],
996 "chore should produce no labels",
997 ),
998 ];
999
1000 for (title, expected_labels, msg) in cases {
1001 let labels = labels_from_pr_metadata(title, &[]);
1002 for expected in &expected_labels {
1003 assert!(
1004 labels.contains(&expected.to_string()),
1005 "{msg}: expected '{expected}' in {labels:?}",
1006 );
1007 }
1008 if expected_labels.is_empty() {
1009 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
1010 }
1011 }
1012 }
1013
1014 #[test]
1015 fn test_file_path_to_scope_mapping() {
1016 let cases = vec![
1017 (
1018 "feat: cli",
1019 vec!["crates/aptu-cli/src/main.rs"],
1020 vec!["enhancement", "cli"],
1021 "cli path should map to cli scope",
1022 ),
1023 (
1024 "feat: docs",
1025 vec!["docs/GITHUB_ACTION.md"],
1026 vec!["enhancement", "documentation"],
1027 "docs path should map to documentation scope",
1028 ),
1029 (
1030 "feat: workflow",
1031 vec![".github/workflows/test.yml"],
1032 vec!["enhancement"],
1033 "workflow path should be ignored",
1034 ),
1035 ];
1036
1037 for (title, paths, expected_labels, msg) in cases {
1038 let labels = labels_from_pr_metadata(
1039 title,
1040 &paths
1041 .iter()
1042 .map(std::string::ToString::to_string)
1043 .collect::<Vec<_>>(),
1044 );
1045 for expected in expected_labels {
1046 assert!(
1047 labels.contains(&expected.to_string()),
1048 "{msg}: expected '{expected}' in {labels:?}",
1049 );
1050 }
1051 }
1052 }
1053
1054 #[test]
1055 fn test_combined_title_and_paths() {
1056 let labels = labels_from_pr_metadata(
1057 "feat: multi",
1058 &[
1059 "crates/aptu-cli/src/main.rs".to_string(),
1060 "docs/README.md".to_string(),
1061 ],
1062 );
1063 assert!(
1064 labels.contains(&"enhancement".to_string()),
1065 "should include enhancement from feat prefix"
1066 );
1067 assert!(
1068 labels.contains(&"cli".to_string()),
1069 "should include cli from path"
1070 );
1071 assert!(
1072 labels.contains(&"documentation".to_string()),
1073 "should include documentation from path"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_no_match_returns_empty() {
1079 let cases = vec![
1080 (
1081 "Random title",
1082 vec![],
1083 "unrecognized prefix should return empty",
1084 ),
1085 (
1086 "chore: update",
1087 vec![],
1088 "ignored prefix should return empty",
1089 ),
1090 ];
1091
1092 for (title, paths, msg) in cases {
1093 let labels = labels_from_pr_metadata(title, &paths);
1094 assert!(labels.is_empty(), "{msg}: got {labels:?}");
1095 }
1096 }
1097
1098 #[test]
1099 fn test_scoped_prefix_extracts_type() {
1100 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1101 assert!(
1102 labels.contains(&"enhancement".to_string()),
1103 "scoped prefix should extract type from feat(cli)"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_duplicate_labels_deduplicated() {
1109 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1110 assert_eq!(
1111 labels.len(),
1112 1,
1113 "should have exactly one label when title and path both map to documentation"
1114 );
1115 assert!(
1116 labels.contains(&"documentation".to_string()),
1117 "should contain documentation label"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_should_skip_file_respects_fetched_count_cap() {
1123 let removed_file = PrFile {
1126 filename: "removed.rs".to_string(),
1127 status: "removed".to_string(),
1128 additions: 0,
1129 deletions: 5,
1130 patch: None,
1131 patch_truncated: false,
1132 full_content: None,
1133 };
1134 let modified_file = PrFile {
1135 filename: "file_0.rs".to_string(),
1136 status: "modified".to_string(),
1137 additions: 1,
1138 deletions: 0,
1139 patch: Some("+ new code".to_string()),
1140 patch_truncated: false,
1141 full_content: None,
1142 };
1143 let no_patch_file = PrFile {
1144 filename: "file_1.rs".to_string(),
1145 status: "modified".to_string(),
1146 additions: 1,
1147 deletions: 0,
1148 patch: None,
1149 patch_truncated: false,
1150 full_content: None,
1151 };
1152
1153 assert!(
1155 should_skip_file(
1156 &removed_file.filename,
1157 &removed_file.status,
1158 removed_file.patch.as_ref()
1159 ),
1160 "removed files should be skipped"
1161 );
1162
1163 assert!(
1165 !should_skip_file(
1166 &modified_file.filename,
1167 &modified_file.status,
1168 modified_file.patch.as_ref()
1169 ),
1170 "modified files with patch should not be skipped"
1171 );
1172
1173 assert!(
1175 should_skip_file(
1176 &no_patch_file.filename,
1177 &no_patch_file.status,
1178 no_patch_file.patch.as_ref()
1179 ),
1180 "files without patch should be skipped"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_decode_content_valid_base64() {
1186 use base64::Engine;
1188 let engine = base64::engine::general_purpose::STANDARD;
1189 let original = "Hello, World!";
1190 let encoded = engine.encode(original);
1191
1192 let result = decode_content(&encoded, 1000);
1194
1195 assert_eq!(
1197 result,
1198 Some(original.to_string()),
1199 "valid base64 should decode successfully"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_decode_content_invalid_base64() {
1205 let invalid_base64 = "!!!invalid!!!";
1207
1208 let result = decode_content(invalid_base64, 1000);
1210
1211 assert_eq!(result, None, "invalid base64 should return None");
1213 }
1214
1215 #[test]
1216 fn test_decode_content_truncates_at_max_chars() {
1217 use base64::Engine;
1219 let engine = base64::engine::general_purpose::STANDARD;
1220 let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
1222 let max_chars = 10;
1223
1224 let result = decode_content(&encoded, max_chars);
1226
1227 assert!(result.is_some(), "decoding should succeed");
1229 let decoded = result.unwrap();
1230 assert_eq!(
1231 decoded.chars().count(),
1232 max_chars,
1233 "output should be truncated to max_chars on character boundary"
1234 );
1235 assert!(
1236 decoded.is_char_boundary(decoded.len()),
1237 "output should be valid UTF-8 (truncated on char boundary)"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_list_files_pagination_collects_all_pages() {
1243 let mut page1_items = Vec::new();
1246 for i in 0..100 {
1247 page1_items.push(PrFile {
1248 filename: format!("file{}.rs", i),
1249 status: "modified".to_string(),
1250 additions: 1,
1251 deletions: 0,
1252 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1253 patch_truncated: false,
1254 full_content: None,
1255 });
1256 }
1257
1258 let mut page2_items = Vec::new();
1260 for i in 100..150 {
1261 page2_items.push(PrFile {
1262 filename: format!("file{}.rs", i),
1263 status: "modified".to_string(),
1264 additions: 1,
1265 deletions: 0,
1266 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1267 patch_truncated: false,
1268 full_content: None,
1269 });
1270 }
1271
1272 let mut all_files = Vec::new();
1274 all_files.extend(page1_items);
1275 all_files.extend(page2_items);
1276
1277 assert_eq!(
1279 all_files.len(),
1280 150,
1281 "pagination should collect all items from both pages"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_list_files_pagination_respects_300_file_cap() {
1287 let mut files = Vec::new();
1289 for i in 0..301 {
1290 files.push(PrFile {
1291 filename: format!("file{}.rs", i),
1292 status: "modified".to_string(),
1293 additions: 1,
1294 deletions: 0,
1295 patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1296 patch_truncated: false,
1297 full_content: None,
1298 });
1299 }
1300
1301 if files.len() >= 300 {
1303 files.truncate(300);
1304 }
1305
1306 assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1308 }
1309
1310 #[test]
1311 fn test_is_patch_truncated_detects_mid_hunk_plus() {
1312 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1314 assert!(
1315 is_patch_truncated(truncated_patch),
1316 "patch ending with + should be detected as truncated"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_is_patch_truncated_detects_mid_hunk_minus() {
1322 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1324 assert!(
1325 is_patch_truncated(truncated_patch),
1326 "patch ending with - should be detected as truncated"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_is_patch_truncated_clean_patch_context_line() {
1332 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1334 assert!(
1335 !is_patch_truncated(clean_patch),
1336 "patch ending with context line should not be detected as truncated"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_is_patch_truncated_correct_hunk_line_count() {
1342 let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1344 assert!(
1345 !is_patch_truncated(clean_patch),
1346 "patch with correct hunk line count should not be detected as truncated"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1352 let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1355 assert!(
1356 is_patch_truncated(truncated_patch),
1357 "patch with declared hunk size larger than delivered should be detected as truncated"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1363 let truncated_patch = "line1\nline2\n+";
1365 assert!(
1366 is_patch_truncated(truncated_patch),
1367 "patch with no @@ header but ending with + should be detected as truncated"
1368 );
1369 }
1370
1371 #[test]
1372 fn test_is_patch_truncated_empty_patch() {
1373 let empty_patch = "";
1375 assert!(
1376 !is_patch_truncated(empty_patch),
1377 "empty patch should not be detected as truncated"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1383 let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1385 assert!(
1386 is_patch_truncated(truncated_patch),
1387 "patch with last hunk truncated should be detected as truncated"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_pr_file_status_case_insensitive_added() {
1393 let file = PrFile {
1395 filename: "new.rs".to_string(),
1396 status: "Added".to_string(), additions: 50,
1398 deletions: 0,
1399 patch: Some("new code".to_string()),
1400 patch_truncated: false,
1401 full_content: None,
1402 };
1403
1404 let is_added_renamed_copied = matches!(
1405 file.status.to_lowercase().as_str(),
1406 "added" | "renamed" | "copied"
1407 );
1408 assert!(is_added_renamed_copied, "Added status should be recognized");
1409 }
1410
1411 #[test]
1412 fn test_pr_file_status_case_insensitive_modified() {
1413 let file = PrFile {
1415 filename: "existing.rs".to_string(),
1416 status: "Modified".to_string(),
1417 additions: 10,
1418 deletions: 5,
1419 patch: Some("modified code".to_string()),
1420 patch_truncated: false,
1421 full_content: None,
1422 };
1423
1424 let is_added_renamed_copied = matches!(
1425 file.status.to_lowercase().as_str(),
1426 "added" | "renamed" | "copied"
1427 );
1428 assert!(
1429 !is_added_renamed_copied,
1430 "Modified status should NOT be recognized as added/renamed/copied"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_pr_file_oversized_patch_detection() {
1436 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1439 let patch = "a".repeat(max_patch_chars + 5_000); let patch_too_large = patch.len() > max_patch_chars;
1442 assert!(
1443 patch_too_large,
1444 "patch exceeding the default limit should be detected as oversized"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_pr_file_dedup_guard_full_content_present() {
1450 let file = PrFile {
1452 filename: "new.rs".to_string(),
1453 status: "Added".to_string(),
1454 additions: 50,
1455 deletions: 0,
1456 patch: Some("new code".to_string()),
1457 patch_truncated: false,
1458 full_content: Some("full content from Contents API".to_string()),
1459 };
1460
1461 let should_fetch = file.full_content.is_none();
1462 assert!(
1463 !should_fetch,
1464 "File with full_content should not be fetched again (dedup guard)"
1465 );
1466 }
1467
1468 #[test]
1469 fn test_pr_file_contents_api_fallback_flow() {
1470 let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1476
1477 let file = PrFile {
1478 filename: "new.rs".to_string(),
1479 status: "Added".to_string(),
1480 additions: 50,
1481 deletions: 0,
1482 patch: Some("a".repeat(max_patch_chars + 5_000)), patch_truncated: false,
1484 full_content: None, };
1486
1487 let is_added_renamed_copied = matches!(
1488 file.status.to_lowercase().as_str(),
1489 "added" | "renamed" | "copied"
1490 );
1491 let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1492 let should_attempt_contents_api =
1493 is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1494
1495 assert!(
1496 should_attempt_contents_api,
1497 "Added file with 30k patch and no full_content should attempt Contents API"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_merge_preserves_existing_full_content() {
1503 let mut file = PrFile {
1506 filename: "test.rs".to_string(),
1507 status: "modified".to_string(),
1508 additions: 5,
1509 deletions: 2,
1510 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1511 patch_truncated: false,
1512 full_content: Some("fallback content".to_string()),
1513 };
1514 let content = None;
1515
1516 if file.full_content.is_none() {
1518 file.full_content = content;
1519 }
1520
1521 assert_eq!(file.full_content, Some("fallback content".to_string()));
1523 }
1524
1525 #[test]
1526 fn test_merge_sets_full_content_when_none() {
1527 let mut file = PrFile {
1529 filename: "test.rs".to_string(),
1530 status: "modified".to_string(),
1531 additions: 5,
1532 deletions: 2,
1533 patch: Some("@@ -1,1 +1,1 @@".to_string()),
1534 patch_truncated: false,
1535 full_content: None,
1536 };
1537 let content = Some("fetched content".to_string());
1538
1539 if file.full_content.is_none() {
1541 file.full_content = content;
1542 }
1543
1544 assert_eq!(file.full_content, Some("fetched content".to_string()));
1546 }
1547
1548 #[test]
1549 fn test_fetch_file_contents_fallback_on_truncated_patch() {
1550 }
1560}