Skip to main content

aptu_core/github/
pulls.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pull request fetching via Octocrab.
4//!
5//! Provides functions to parse PR references and fetch PR details
6//! including file diffs for AI review.
7
8use 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/// Result from creating a pull request.
19#[derive(Debug, serde::Serialize)]
20pub struct PrCreateResult {
21    /// PR number.
22    pub pr_number: u64,
23    /// PR URL.
24    pub url: String,
25    /// Head branch.
26    pub branch: String,
27    /// Base branch.
28    pub base: String,
29    /// PR title.
30    pub title: String,
31    /// Whether the PR is a draft.
32    pub draft: bool,
33    /// Number of files changed.
34    pub files_changed: u32,
35    /// Number of additions.
36    pub additions: u64,
37    /// Number of deletions.
38    pub deletions: u64,
39}
40
41/// Parses a PR reference into (owner, repo, number).
42///
43/// Supports multiple formats:
44/// - Full URL: `https://github.com/owner/repo/pull/123`
45/// - Short form: `owner/repo#123`
46/// - Bare number: `123` (requires `repo_context`)
47///
48/// # Arguments
49///
50/// * `reference` - PR reference string
51/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
52///
53/// # Returns
54///
55/// Tuple of (owner, repo, number)
56///
57/// # Errors
58///
59/// Returns an error if the reference format is invalid or `repo_context` is missing for bare numbers.
60pub 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/// Fetches PR details including file diffs from GitHub.
68///
69/// Uses Octocrab to fetch PR metadata and file changes.
70///
71/// # Arguments
72///
73/// * `client` - Authenticated Octocrab client
74/// * `owner` - Repository owner
75/// * `repo` - Repository name
76/// * `number` - PR number
77///
78/// # Returns
79///
80/// `PrDetails` struct with PR metadata and file diffs.
81///
82/// # Errors
83///
84/// Returns an error if the API call fails or PR is not found.
85#[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    // Fetch PR metadata
98    let pr = match client.pulls(owner, repo).get(number).await {
99        Ok(pr) => pr,
100        Err(e) => {
101            // Check if this is a 404 error and if an issue exists instead
102            if let octocrab::Error::GitHub { source, .. } = &e
103                && source.status_code == 404
104            {
105                // Try to fetch as an issue to provide a better error message
106                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                // Issue check failed, fall back to original error
115            }
116            return Err(e)
117                .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
118        }
119    };
120
121    // Fetch PR files (diffs) with pagination (per_page=100, max 300 files)
122    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    // Detect truncated patches and attempt Contents API fallback
169    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                // Attempt Contents API fallback
175                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    // Contents API fallback for Added/Renamed/Copied files with oversized patches.
192    // Fetch full content from Contents API so the AI can review the full file, even though
193    // the patch exceeds the character budget.
194    for file in &mut pr_files {
195        // status is produced via format!("{:?}", f.status) which yields mixed-case values (e.g. "Added", "Renamed")
196        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    // Fetch full file contents for eligible files (default: up to 10 files, max 4000 chars each)
236    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    // Merge file contents back into pr_files
251    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
316/// Detects if a patch is truncated mid-hunk by GitHub API.
317///
318/// A patch is considered truncated if the last non-empty line starts with '+' or '-',
319/// indicating an incomplete hunk.
320fn is_patch_truncated(patch: &str) -> bool {
321    let lines: Vec<&str> = patch.lines().collect();
322
323    // Rule 1: Check if last non-empty line starts with '+' or '-' (mid-hunk cutoff)
324    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    // Rule 2: Check if declared hunk size matches actual lines delivered
331    // Parse the last @@ -a,b +c,d @@ header and verify line count
332    if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
333        // Extract the +c,d part from the hunk header
334        if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
335            // Extract the number after '+' and before the next space or @@
336            if let Some(size_str) = plus_part.split_whitespace().next() {
337                // Parse "c,d" format
338                if let Some(count_str) = size_str.split(',').nth(1)
339                    && let Ok(declared_count) = count_str.parse::<usize>()
340                {
341                    // Count actual lines after this hunk header (context + added lines)
342                    // Find the index of this hunk header
343                    if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
344                    {
345                        let lines_after_hunk = &lines[hunk_idx + 1..];
346                        // Count lines that are context (' '), additions ('+'), or deletions ('-')
347                        // Stop counting if we hit another hunk header
348                        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 is less than declared, the hunk is truncated
361                        if actual_count < declared_count {
362                            return true;
363                        }
364                    }
365                }
366            }
367        }
368    }
369
370    false
371}
372
373/// Fetches a single file's content from GitHub Contents API as a fallback for truncated patches.
374///
375/// Returns the file content truncated to `max_chars`, or `None` if the file cannot be fetched.
376/// Non-fatal errors (404, rate limits) are logged as warnings.
377#[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            // Try to decode the first item (should be the file, not a directory listing)
396            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/// Fetches full file contents for PR files from GitHub Contents API.
440///
441/// Fetches content for eligible files up to a specified limit and truncates each to a character limit.
442/// Skips deleted files and files with empty patches. Per-file errors are non-fatal: they produce
443/// `None` entries and log warnings.
444///
445/// # Arguments
446///
447/// * `client` - Authenticated Octocrab client
448/// * `owner` - Repository owner
449/// * `repo` - Repository name
450/// * `files` - Slice of PR files to fetch
451/// * `head_sha` - PR head commit SHA to fetch from
452/// * `max_files` - Maximum number of files to fetch content for
453/// * `max_chars_per_file` - Truncate each file's content at this character limit
454///
455/// # Returns
456///
457/// Vector of `Option<String>` with one entry per input file (in order):
458/// - `Some(content)` if fetch succeeded
459/// - `None` if fetch failed, file was skipped, or file index exceeded `max_files`
460#[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        // Skip if beyond max_files cap (count only successfully-fetched files)
481        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        // Attempt to fetch file content
493        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                // Try to decode the first item (should be the file, not a directory listing)
503                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/// Posts a PR review to GitHub.
547///
548/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
549/// Requires write access to the repository.
550///
551/// # Arguments
552///
553/// * `client` - Authenticated Octocrab client
554/// * `owner` - Repository owner
555/// * `repo` - Repository name
556/// * `number` - PR number
557/// * `body` - Review comment text
558/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
559/// * `comments` - Inline review comments to attach; entries with `line = None` are silently skipped
560/// * `commit_id` - Head commit SHA to associate with the review; omitted from payload if empty
561///
562/// # Returns
563///
564/// Review ID on success.
565///
566/// # Errors
567///
568/// Returns an error if the API call fails, user lacks write access, or PR is not found.
569#[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    // Build inline comments array; skip entries without a line number.
587    let inline_comments: Vec<serde_json::Value> = comments
588        .iter()
589        // Comments without a line number cannot be anchored to the diff; skip silently.
590        .filter_map(|c| {
591            c.line.map(|line| {
592                serde_json::json!({
593                    "path": c.file,
594                    "line": line,
595                    // RIGHT = new version of the file (added/changed lines).
596                    // Use line (file line number) rather than the deprecated
597                    // position (diff hunk offset) so no hunk parsing is needed.
598                    "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    // commit_id is optional; include only when non-empty.
612    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/// Deletes a PR review comment.
634///
635/// # Errors
636///
637/// Returns an error if the API request fails. 404 errors (comment not found)
638/// are treated as success (idempotent).
639#[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    // Use generic delete method; needs explicit empty object body type
652    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/// Extract labels from PR metadata (title and file paths).
675///
676/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
677/// Returns a vector of label names to apply to the PR.
678///
679/// # Arguments
680/// * `title` - PR title (may contain conventional commit prefix)
681/// * `file_paths` - List of file paths changed in the PR
682///
683/// # Returns
684/// Vector of label names to apply
685#[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    // Extract conventional commit prefix from title
690    // Handle both "feat: ..." and "feat(scope): ..." formats
691    let prefix = title
692        .split(':')
693        .next()
694        .unwrap_or("")
695        .split('(')
696        .next()
697        .unwrap_or("")
698        .trim();
699
700    // Map conventional commit type to label
701    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    // Map file paths to scope labels
714    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/// Creates a pull request on GitHub.
732///
733/// # Arguments
734///
735/// * `client` - Authenticated Octocrab client
736/// * `owner` - Repository owner
737/// * `repo` - Repository name
738/// * `title` - PR title
739/// * `head_branch` - Head branch (the branch with changes)
740/// * `base_branch` - Base branch (the branch to merge into)
741/// * `body` - Optional PR body text
742///
743/// # Returns
744///
745/// `PrCreateResult` with PR metadata.
746///
747/// # Errors
748///
749/// Returns an error if the API call fails or the user lacks write access.
750#[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
808/// Determines whether a file should be skipped during fetch based on status and patch.
809/// Emits a debug log with the skip reason. Returns true if the file should be skipped
810/// (removed status or no patch), false otherwise.
811fn 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        // Arrange / Act: construct directly (no network call needed)
844        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
857        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    // ---------------------------------------------------------------------------
869    // post_pr_review payload construction
870    // ---------------------------------------------------------------------------
871
872    /// Helper: build the inline comments JSON array using the same logic as
873    /// `post_pr_review`, without making a live HTTP call.
874    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        // Arrange
893        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        // Act
902        let inline = build_inline_comments(&comments);
903
904        // Assert
905        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        // Arrange: one comment with a line, one without.
915        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        // Act
933        let inline = build_inline_comments(&comments);
934
935        // Assert: only the comment with a line is included.
936        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        // Arrange
943        let comments: Vec<PrReviewComment> = vec![];
944
945        // Act
946        let inline = build_inline_comments(&comments);
947
948        // Assert: empty slice produces empty array, which serializes as [].
949        assert!(inline.is_empty());
950        let serialized = serde_json::to_string(&inline).unwrap();
951        assert_eq!(serialized, "[]");
952    }
953
954    // ---------------------------------------------------------------------------
955    // Existing tests
956    // ---------------------------------------------------------------------------
957
958    // Smoke test to verify parse_pr_reference delegates correctly.
959    // Comprehensive parsing tests are in github/mod.rs.
960    #[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        // Test that should_skip_file correctly identifies files to skip.
1124        // Files with removed status or no patch should be skipped.
1125        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: removed files are skipped
1154        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: modified files with patch are not skipped
1164        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: files without patch are skipped
1174        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        // Arrange: valid base64-encoded string
1187        use base64::Engine;
1188        let engine = base64::engine::general_purpose::STANDARD;
1189        let original = "Hello, World!";
1190        let encoded = engine.encode(original);
1191
1192        // Act: decode with sufficient max_chars
1193        let result = decode_content(&encoded, 1000);
1194
1195        // Assert: decoding succeeds and matches original
1196        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        // Arrange: invalid base64 string
1206        let invalid_base64 = "!!!invalid!!!";
1207
1208        // Act: attempt to decode
1209        let result = decode_content(invalid_base64, 1000);
1210
1211        // Assert: decoding fails gracefully
1212        assert_eq!(result, None, "invalid base64 should return None");
1213    }
1214
1215    #[test]
1216    fn test_decode_content_truncates_at_max_chars() {
1217        // Arrange: multi-byte UTF-8 string (Japanese characters)
1218        use base64::Engine;
1219        let engine = base64::engine::general_purpose::STANDARD;
1220        let original = "こんにちは".repeat(10); // 50 characters total
1221        let encoded = engine.encode(&original);
1222        let max_chars = 10;
1223
1224        // Act: decode with max_chars limit
1225        let result = decode_content(&encoded, max_chars);
1226
1227        // Assert: result is truncated to max_chars on character boundary
1228        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        // Arrange: simulate pagination with two pages
1244        // Page 1: 100 items with next_link set
1245        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        // Page 2: 50 items with no next_link
1259        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        // Act: collect all items (simulating pagination loop)
1273        let mut all_files = Vec::new();
1274        all_files.extend(page1_items);
1275        all_files.extend(page2_items);
1276
1277        // Assert: total collected == 150
1278        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        // Arrange: build a Vec of 301 PrFile items
1288        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        // Act: apply the 300-file cap (simulating the truncate logic)
1302        if files.len() >= 300 {
1303            files.truncate(300);
1304        }
1305
1306        // Assert: result.len() == 300
1307        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        // Test: patch ending with '+' (mid-hunk truncation)
1313        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        // Test: patch ending with '-' (mid-hunk truncation)
1323        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        // Test: patch ending with ' ' (context line, not truncated)
1333        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        // Test: patch with correct hunk line count (declared 3, actual 3)
1343        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        // Test: patch with declared hunk size larger than delivered lines
1353        // Declared: +1,4 (4 lines in new file), Actual: only 2 lines delivered
1354        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        // Test: patch with no @@ header but last line is '+'
1364        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        // Test: empty patch
1374        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        // Test: multiple hunks where the last hunk is truncated
1384        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        // Test: Added file status is matched case-insensitively
1394        let file = PrFile {
1395            filename: "new.rs".to_string(),
1396            status: "Added".to_string(), // Debug repr from Octocrab
1397            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        // Test: Modified file status is NOT matched (edge case)
1414        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        // Test: Patch size is compared against max_patch_chars_per_file.
1437        // Derive the limit from ReviewConfig::default() -- single source of truth.
1438        let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1439        let patch = "a".repeat(max_patch_chars + 5_000); // Clearly exceeds limit
1440
1441        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        // Test: File with full_content already populated should skip Contents API call
1451        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        // Test: Verify the three conditions for Contents API fallback:
1471        // 1. status is Added/Renamed/Copied
1472        // 2. patch size exceeds max_patch_chars_per_file
1473        // 3. full_content is None
1474        // Derive the limit from ReviewConfig::default() -- single source of truth.
1475        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)), // Clearly exceeds limit
1483            patch_truncated: false,
1484            full_content: None, // Not yet fetched
1485        };
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        // Arrange: create a PrFile with full_content = Some("fallback content"),
1504        // pair it with content = None (simulating fetch_file_contents returning None beyond the cap)
1505        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        // Act: apply the fixed merge logic
1517        if file.full_content.is_none() {
1518            file.full_content = content;
1519        }
1520
1521        // Assert: file.full_content == Some("fallback content")
1522        assert_eq!(file.full_content, Some("fallback content".to_string()));
1523    }
1524
1525    #[test]
1526    fn test_merge_sets_full_content_when_none() {
1527        // Arrange: PrFile with full_content = None, content = Some("fetched content")
1528        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        // Act: apply merge logic
1540        if file.full_content.is_none() {
1541            file.full_content = content;
1542        }
1543
1544        // Assert: file.full_content == Some("fetched content")
1545        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        // Note: The Contents API network call cannot be unit-tested without a mock.
1551        // The fallback is exercised in integration tests via the full fetch_pr_details flow.
1552        // Unit tests for is_patch_truncated are above.
1553        // New unit tests for the added/renamed/copied Contents API fallback:
1554        // - test_pr_file_status_case_insensitive_added
1555        // - test_pr_file_status_case_insensitive_modified
1556        // - test_pr_file_oversized_patch_detection
1557        // - test_pr_file_dedup_guard_full_content_present
1558        // - test_pr_file_contents_api_fallback_flow
1559    }
1560}