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::review_context::truncate_at_line_boundary;
15use crate::ai::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
16use crate::error::{AptuError, ResourceType};
17use crate::triage::render_pr_review_comment_body;
18
19/// Result from creating a pull request.
20#[derive(Debug, serde::Serialize)]
21pub struct PrCreateResult {
22    /// PR number.
23    pub pr_number: u64,
24    /// PR URL.
25    pub url: String,
26    /// Head branch.
27    pub branch: String,
28    /// Base branch.
29    pub base: String,
30    /// PR title.
31    pub title: String,
32    /// Whether the PR is a draft.
33    pub draft: bool,
34    /// Number of files changed.
35    pub files_changed: u32,
36    /// Number of additions.
37    pub additions: u64,
38    /// Number of deletions.
39    pub deletions: u64,
40}
41
42/// Parses a PR reference into (owner, repo, number).
43///
44/// Supports multiple formats:
45/// - Full URL: `https://github.com/owner/repo/pull/123`
46/// - Short form: `owner/repo#123`
47/// - Bare number: `123` (requires `repo_context`)
48///
49/// # Arguments
50///
51/// * `reference` - PR reference string
52/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
53///
54/// # Returns
55///
56/// Tuple of (owner, repo, number)
57///
58/// # Errors
59///
60/// Returns an error if the reference format is invalid or `repo_context` is missing for bare numbers.
61pub fn parse_pr_reference(
62    reference: &str,
63    repo_context: Option<&str>,
64) -> Result<(String, String, u64)> {
65    parse_github_reference(ReferenceKind::Pull, reference, repo_context)
66}
67
68/// Fetches PR details including file diffs from GitHub.
69///
70/// Uses Octocrab to fetch PR metadata and file changes.
71///
72/// # Arguments
73///
74/// * `client` - Authenticated Octocrab client
75/// * `owner` - Repository owner
76/// * `repo` - Repository name
77/// * `number` - PR number
78///
79/// # Returns
80///
81/// `PrDetails` struct with PR metadata and file diffs.
82///
83/// # Errors
84///
85/// Returns an error if the API call fails or PR is not found.
86#[cfg(not(target_arch = "wasm32"))]
87#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
88#[allow(clippy::too_many_lines)]
89pub async fn fetch_pr_details(
90    client: &Octocrab,
91    owner: &str,
92    repo: &str,
93    number: u64,
94    review_config: &crate::config::ReviewConfig,
95) -> Result<PrDetails> {
96    debug!("Fetching PR details");
97
98    // Fetch PR metadata
99    let pr = match client.pulls(owner, repo).get(number).await {
100        Ok(pr) => pr,
101        Err(e) => {
102            // Check if this is a 404 error and if an issue exists instead
103            if let octocrab::Error::GitHub { source, .. } = &e
104                && source.status_code == 404
105            {
106                // Try to fetch as an issue to provide a better error message
107                if (client.issues(owner, repo).get(number).await).is_ok() {
108                    return Err(AptuError::TypeMismatch {
109                        number,
110                        expected: ResourceType::PullRequest,
111                        actual: ResourceType::Issue,
112                    }
113                    .into());
114                }
115                // Issue check failed, fall back to original error
116            }
117            return Err(e)
118                .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
119        }
120    };
121
122    // Fetch PR files (diffs) with pagination (per_page=100, max 300 files)
123    let mut pr_files: Vec<PrFile> = Vec::new();
124    let mut page = client
125        .pulls(owner, repo)
126        .list_files(number)
127        .await
128        .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
129
130    loop {
131        pr_files.extend(page.items.into_iter().map(|f| PrFile {
132            filename: f.filename,
133            status: format!("{:?}", f.status),
134            additions: f.additions,
135            deletions: f.deletions,
136            patch: f.patch,
137            patch_truncated: false,
138            full_content: None,
139        }));
140
141        if pr_files.len() >= 300 {
142            tracing::warn!(
143                "PR #{} has reached 300-file cap; stopping pagination",
144                number
145            );
146            pr_files.truncate(300);
147            break;
148        }
149
150        match client
151            .get_page::<octocrab::models::repos::DiffEntry>(&page.next)
152            .await
153        {
154            Ok(Some(next_page)) => page = next_page,
155            Ok(None) => break,
156            Err(e) => {
157                tracing::warn!("Error fetching next page of files: {}", e);
158                break;
159            }
160        }
161    }
162
163    let head_sha = pr.head.sha.as_str();
164
165    // Detect truncated patches and attempt Contents API fallback
166    for file in &mut pr_files {
167        #[allow(clippy::collapsible_if)]
168        if let Some(patch) = &file.patch {
169            if is_patch_truncated(patch) {
170                file.patch_truncated = true;
171                // Attempt Contents API fallback
172                if let Ok(Some(content)) = fetch_file_contents_single(
173                    client,
174                    owner,
175                    repo,
176                    &file.filename,
177                    head_sha,
178                    review_config.max_chars_per_file,
179                )
180                .await
181                {
182                    file.patch = Some(content);
183                }
184            }
185        }
186    }
187
188    // Contents API fallback for Added/Renamed/Copied files with oversized patches.
189    // Fetch full content from Contents API so the AI can review the full file, even though
190    // the patch exceeds the character budget.
191    for file in &mut pr_files {
192        // status is produced via format!("{:?}", f.status) which yields mixed-case values (e.g. "Added", "Renamed")
193        let is_added_renamed_copied = matches!(
194            file.status.to_lowercase().as_str(),
195            "added" | "renamed" | "copied"
196        );
197        let patch_too_large =
198            file.patch.as_deref().map_or(0, str::len) > review_config.max_patch_chars_per_file;
199        if is_added_renamed_copied && patch_too_large && file.full_content.is_none() {
200            match fetch_file_contents_single(
201                client,
202                owner,
203                repo,
204                &file.filename,
205                head_sha,
206                review_config.max_chars_per_file,
207            )
208            .await
209            {
210                Ok(Some(content)) => {
211                    file.full_content = Some(content);
212                }
213                Ok(None) => {
214                    tracing::warn!(
215                        "Contents API returned empty content for added file {} in PR #{}",
216                        file.filename,
217                        number
218                    );
219                }
220                Err(e) => {
221                    tracing::warn!(
222                        "Failed to fetch contents for added file {} in PR #{}: {}",
223                        file.filename,
224                        number,
225                        e
226                    );
227                }
228            }
229        }
230    }
231
232    // Fetch full file contents for eligible files (default: up to 10 files, max 4000 chars each)
233    let file_contents = fetch_file_contents(
234        client,
235        owner,
236        repo,
237        &pr_files,
238        pr.head.sha.as_str(),
239        review_config.max_full_content_files,
240        review_config.max_chars_per_file,
241    )
242    .await;
243
244    // Merge file contents back into pr_files
245    debug_assert_eq!(
246        pr_files.len(),
247        file_contents.len(),
248        "fetch_file_contents must return one entry per file"
249    );
250    let pr_files: Vec<PrFile> = pr_files
251        .into_iter()
252        .zip(file_contents)
253        .map(|(mut file, content)| {
254            if file.full_content.is_none() {
255                file.full_content = content;
256            }
257            file
258        })
259        .collect();
260
261    let labels: Vec<String> = pr
262        .labels
263        .iter()
264        .flat_map(|v| v.iter())
265        .map(|l| l.name.clone())
266        .collect();
267
268    // Fetch existing review comments (with pagination, per_page=100, max 300 items)
269    // so the AI prompt can avoid restating feedback the bot already posted.
270    let mut review_comments: Vec<crate::ai::types::PrReviewCommentDetails> = Vec::new();
271    let bot_login = match client.current().user().await {
272        Ok(user) => user.login,
273        Err(e) => {
274            tracing::warn!("Failed to resolve bot login; skipping review comment fetch: {e}");
275            String::new()
276        }
277    };
278    if !bot_login.is_empty() {
279        let mut page = client
280            .pulls(owner, repo)
281            .list_comments(Some(number))
282            .per_page(100)
283            .send()
284            .await
285            .with_context(|| format!("Failed to fetch review comments for PR #{number}"))?;
286
287        loop {
288            review_comments.extend(page.items.into_iter().filter_map(|c| {
289                let author = c.user.as_ref().map(|u| u.login.clone()).unwrap_or_default();
290                if author != bot_login {
291                    return None;
292                }
293                Some(crate::ai::types::PrReviewCommentDetails {
294                    id: c.id.0,
295                    author,
296                    body: c.body.clone(),
297                    path: c.path,
298                    line: c.line,
299                    side: c.side,
300                    commit_id: c.commit_id,
301                })
302            }));
303
304            // Cap at 300 to mirror the list_files limit; PRs with more existing comments
305            // are uncommon and the prompt budget would discard most entries anyway.
306            if review_comments.len() >= 300 {
307                tracing::warn!(
308                    "PR #{} has reached 300-comment cap; stopping pagination",
309                    number
310                );
311                review_comments.truncate(300);
312                break;
313            }
314
315            match client
316                .get_page::<octocrab::models::pulls::Comment>(&page.next)
317                .await
318            {
319                Ok(Some(next_page)) => page = next_page,
320                Ok(None) => break,
321                Err(e) => {
322                    tracing::warn!("Error fetching next page of review comments: {}", e);
323                    break;
324                }
325            }
326        }
327    }
328    debug!(
329        review_comments = review_comments.len(),
330        "Existing review comments fetched"
331    );
332
333    let details = PrDetails {
334        owner: owner.to_string(),
335        repo: repo.to_string(),
336        number,
337        title: pr.title.clone().unwrap_or_default(),
338        body: pr.body.clone().unwrap_or_default(),
339        base_branch: pr.base.ref_field.clone(),
340        head_branch: pr.head.ref_field.clone(),
341        head_sha: pr.head.sha.as_str().to_string(),
342        files: pr_files,
343        url: pr
344            .html_url
345            .as_ref()
346            .map(std::string::ToString::to_string)
347            .unwrap_or_default(),
348        labels,
349        review_comments,
350        instructions: None,
351        dep_enrichments: Vec::new(),
352    };
353
354    debug!(
355        file_count = details.files.len(),
356        "PR details fetched successfully"
357    );
358
359    Ok(details)
360}
361
362/// Detects if a patch is truncated mid-hunk by GitHub API.
363///
364/// A patch is considered truncated if the last non-empty line starts with '+' or '-',
365/// indicating an incomplete hunk.
366fn is_patch_truncated(patch: &str) -> bool {
367    let lines: Vec<&str> = patch.lines().collect();
368
369    // Rule 1: Check if last non-empty line starts with '+' or '-' (mid-hunk cutoff)
370    if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
371        && (last_line.starts_with('+') || last_line.starts_with('-'))
372    {
373        return true;
374    }
375
376    // Rule 2: Check if declared hunk size matches actual lines delivered
377    // Parse the last @@ -a,b +c,d @@ header and verify line count
378    if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
379        // Extract the +c,d part from the hunk header
380        if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
381            // Extract the number after '+' and before the next space or @@
382            if let Some(size_str) = plus_part.split_whitespace().next() {
383                // Parse "c,d" format
384                if let Some(count_str) = size_str.split(',').nth(1)
385                    && let Ok(declared_count) = count_str.parse::<usize>()
386                {
387                    // Count actual lines after this hunk header (context + added lines)
388                    // Find the index of this hunk header
389                    if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
390                    {
391                        let lines_after_hunk = &lines[hunk_idx + 1..];
392                        // Count lines that are context (' '), additions ('+'), or deletions ('-')
393                        // Stop counting if we hit another hunk header
394                        let mut actual_count = 0;
395                        for line in lines_after_hunk {
396                            if line.starts_with("@@") {
397                                break;
398                            }
399                            if line.starts_with(' ')
400                                || line.starts_with('+')
401                                || line.starts_with('-')
402                            {
403                                actual_count += 1;
404                            }
405                        }
406                        // If actual count is less than declared, the hunk is truncated
407                        if actual_count < declared_count {
408                            return true;
409                        }
410                    }
411                }
412            }
413        }
414    }
415
416    false
417}
418
419/// Fetches a single file's content from GitHub Contents API as a fallback for truncated patches.
420///
421/// Returns the file content truncated to `max_chars`, or `None` if the file cannot be fetched.
422/// Non-fatal errors (404, rate limits) are logged as warnings.
423#[cfg(not(target_arch = "wasm32"))]
424async fn fetch_file_contents_single(
425    client: &Octocrab,
426    owner: &str,
427    repo: &str,
428    filename: &str,
429    head_sha: &str,
430    max_chars: usize,
431) -> Result<Option<String>> {
432    match client
433        .repos(owner, repo)
434        .get_content()
435        .path(filename)
436        .r#ref(head_sha)
437        .send()
438        .await
439    {
440        Ok(content) => {
441            // Try to decode the first item (should be the file, not a directory listing)
442            if let Some(item) = content.items.first() {
443                if let Some(decoded) = item.decoded_content() {
444                    let truncated = if decoded.chars().count() > max_chars {
445                        truncate_at_line_boundary(&decoded, max_chars)
446                    } else {
447                        decoded
448                    };
449                    Ok(Some(truncated))
450                } else {
451                    tracing::warn!(
452                        "Failed to decode content for {}/{}/{} at {}",
453                        owner,
454                        repo,
455                        filename,
456                        head_sha
457                    );
458                    Ok(None)
459                }
460            } else {
461                tracing::warn!(
462                    "File content response was empty for {}/{}/{} at {}",
463                    owner,
464                    repo,
465                    filename,
466                    head_sha
467                );
468                Ok(None)
469            }
470        }
471        Err(e) => {
472            tracing::warn!(
473                "Failed to fetch content for {}/{}/{} at {}: {}",
474                owner,
475                repo,
476                filename,
477                head_sha,
478                e
479            );
480            Ok(None)
481        }
482    }
483}
484
485/// Fetches full file contents for PR files from GitHub Contents API.
486///
487/// Fetches content for eligible files up to a specified limit and truncates each to a character limit.
488/// Skips deleted files and files with empty patches. Per-file errors are non-fatal: they produce
489/// `None` entries and log warnings.
490///
491/// # Arguments
492///
493/// * `client` - Authenticated Octocrab client
494/// * `owner` - Repository owner
495/// * `repo` - Repository name
496/// * `files` - Slice of PR files to fetch
497/// * `head_sha` - PR head commit SHA to fetch from
498/// * `max_files` - Maximum number of files to fetch content for
499/// * `max_chars_per_file` - Truncate each file's content at this character limit
500///
501/// # Returns
502///
503/// Vector of `Option<String>` with one entry per input file (in order):
504/// - `Some(content)` if fetch succeeded
505/// - `None` if fetch failed, file was skipped, or file index exceeded `max_files`
506#[cfg(not(target_arch = "wasm32"))]
507#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
508async fn fetch_file_contents(
509    client: &Octocrab,
510    owner: &str,
511    repo: &str,
512    files: &[PrFile],
513    head_sha: &str,
514    max_files: usize,
515    max_chars_per_file: usize,
516) -> Vec<Option<String>> {
517    let mut results = Vec::with_capacity(files.len());
518    let mut fetched_count = 0usize;
519
520    for file in files {
521        if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
522            results.push(None);
523            continue;
524        }
525
526        // Skip if beyond max_files cap (count only successfully-fetched files)
527        if fetched_count >= max_files {
528            debug!(
529                file = %file.filename,
530                fetched_count = fetched_count,
531                max_files = max_files,
532                "Fetched file count exceeds max_files cap"
533            );
534            results.push(None);
535            continue;
536        }
537
538        // Attempt to fetch file content
539        match client
540            .repos(owner, repo)
541            .get_content()
542            .path(&file.filename)
543            .r#ref(head_sha)
544            .send()
545            .await
546        {
547            Ok(content) => {
548                // Try to decode the first item (should be the file, not a directory listing)
549                if let Some(item) = content.items.first() {
550                    if let Some(decoded) = item.decoded_content() {
551                        let truncated = if decoded.chars().count() > max_chars_per_file {
552                            truncate_at_line_boundary(&decoded, max_chars_per_file)
553                        } else {
554                            decoded
555                        };
556                        debug!(
557                            file = %file.filename,
558                            content_len = truncated.len(),
559                            "File content fetched and truncated"
560                        );
561                        results.push(Some(truncated));
562                        fetched_count += 1;
563                    } else {
564                        tracing::warn!(
565                            file = %file.filename,
566                            "Failed to decode file content; skipping"
567                        );
568                        results.push(None);
569                    }
570                } else {
571                    tracing::warn!(
572                        file = %file.filename,
573                        "File content response was empty; skipping"
574                    );
575                    results.push(None);
576                }
577            }
578            Err(e) => {
579                tracing::warn!(
580                    file = %file.filename,
581                    err = %e,
582                    "Failed to fetch file content; skipping"
583                );
584                results.push(None);
585            }
586        }
587    }
588
589    results
590}
591
592/// Posts a PR review to GitHub.
593///
594/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
595/// Requires write access to the repository.
596///
597/// # Arguments
598///
599/// * `client` - Authenticated Octocrab client
600/// * `owner` - Repository owner
601/// * `repo` - Repository name
602/// * `number` - PR number
603/// * `body` - Review comment text
604/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
605/// * `comments` - Inline review comments to attach; entries with `line = None` are silently skipped
606/// * `commit_id` - Head commit SHA to associate with the review; omitted from payload if empty
607///
608/// # Returns
609///
610/// Review ID on success.
611///
612/// # Errors
613///
614/// Returns an error if the API call fails, user lacks write access, or PR is not found.
615#[cfg(not(target_arch = "wasm32"))]
616#[allow(clippy::too_many_arguments)]
617#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
618pub async fn post_pr_review(
619    client: &Octocrab,
620    owner: &str,
621    repo: &str,
622    number: u64,
623    body: &str,
624    event: ReviewEvent,
625    comments: &[PrReviewComment],
626    commit_id: &str,
627) -> Result<u64> {
628    debug!("Posting PR review");
629
630    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
631
632    // Build inline comments array; skip entries without a line number.
633    let inline_comments: Vec<serde_json::Value> = comments
634        .iter()
635        // Comments without a line number cannot be anchored to the diff; skip silently.
636        .filter_map(|c| {
637            c.line.map(|line| {
638                serde_json::json!({
639                    "path": c.file,
640                    "line": line,
641                    // RIGHT = new version of the file (added/changed lines).
642                    // Use line (file line number) rather than the deprecated
643                    // position (diff hunk offset) so no hunk parsing is needed.
644                    "side": "RIGHT",
645                    "body": render_pr_review_comment_body(c),
646                })
647            })
648        })
649        .collect();
650
651    let mut payload = serde_json::json!({
652        "body": body,
653        "event": event.to_string(),
654        "comments": inline_comments,
655    });
656
657    // commit_id is optional; include only when non-empty.
658    if !commit_id.is_empty() {
659        payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
660    }
661
662    #[derive(serde::Deserialize)]
663    struct ReviewResponse {
664        id: u64,
665    }
666
667    match client.post::<_, ReviewResponse>(route, Some(&payload)).await {
668        Ok(response) => {
669            debug!(review_id = response.id, "PR review posted successfully");
670            Ok(response.id)
671        }
672        Err(octocrab::Error::GitHub { source, .. }) => {
673            tracing::warn!(
674                status = source.status_code.as_u16(),
675                github_message = %source.message,
676                "Failed to post review to PR"
677            );
678            Err(anyhow::anyhow!(
679                "Failed to post review to PR #{number} in {owner}/{repo}. \
680                 GitHub API returned HTTP {}: {}. \
681                 Check that you have write access to the repository.",
682                source.status_code.as_u16(),
683                source.message,
684            ))
685        }
686        Err(e) => {
687            Err(e).with_context(|| {
688                format!(
689                    "Failed to post review to PR #{number} in {owner}/{repo}. Check that you have write access to the repository."
690                )
691            })
692        }
693    }
694}
695
696/// Deletes a PR review comment.
697///
698/// # Errors
699///
700/// Returns an error if the API request fails. 404 errors (comment not found)
701/// are treated as success (idempotent).
702#[cfg(not(target_arch = "wasm32"))]
703#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
704pub async fn delete_pr_review_comment(
705    client: &Octocrab,
706    owner: &str,
707    repo: &str,
708    comment_id: u64,
709) -> Result<()> {
710    debug!("Deleting PR review comment");
711
712    let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
713
714    // Use generic delete method; needs explicit empty object body type
715    let empty_body = serde_json::json!({});
716    let result: std::result::Result<serde_json::Value, _> =
717        client.delete(&route, Some(&empty_body)).await;
718
719    match result {
720        Ok(_) => {
721            debug!("PR review comment deleted successfully");
722            Ok(())
723        }
724        Err(e)
725            if let octocrab::Error::GitHub { source, .. } = &e
726                && source.status_code.as_u16() == 404 =>
727        {
728            debug!("PR review comment already deleted (404); treating as success");
729            Ok(())
730        }
731        Err(e) => {
732            Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
733        }
734    }
735}
736
737/// Updates the body of an existing PR review comment.
738///
739/// Used when a duplicate comment is detected but its body differs from the
740/// previously posted version; the existing comment is `PATCH`ed in place rather
741/// than posting a new one.
742///
743/// # Errors
744///
745/// Returns an error if the API request fails. 404 errors (comment not found)
746/// are treated as success (idempotent).
747#[cfg(not(target_arch = "wasm32"))]
748#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
749pub async fn update_pr_review_comment(
750    client: &Octocrab,
751    owner: &str,
752    repo: &str,
753    comment_id: u64,
754    body: &str,
755) -> Result<()> {
756    debug!("Updating PR review comment");
757
758    let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
759    let payload = serde_json::json!({ "body": body });
760    let result: std::result::Result<serde_json::Value, _> =
761        client.patch(&route, Some(&payload)).await;
762
763    match result {
764        Ok(_) => {
765            debug!("PR review comment updated successfully");
766            Ok(())
767        }
768        Err(e)
769            if let octocrab::Error::GitHub { source, .. } = &e
770                && source.status_code.as_u16() == 404 =>
771        {
772            debug!("PR review comment not found (404); treating as success");
773            Ok(())
774        }
775        Err(e) => {
776            Err(e).with_context(|| format!("Failed to update PR review comment #{comment_id}"))
777        }
778    }
779}
780
781/// Extract labels from PR metadata (title and file paths).
782///
783/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
784/// Returns a vector of label names to apply to the PR.
785///
786/// # Arguments
787/// * `title` - PR title (may contain conventional commit prefix)
788/// * `file_paths` - List of file paths changed in the PR
789///
790/// # Returns
791/// Vector of label names to apply
792#[must_use]
793pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
794    let mut labels = std::collections::HashSet::new();
795
796    // Extract conventional commit prefix from title
797    // Handle both "feat: ..." and "feat(scope): ..." formats
798    let prefix = title
799        .split(':')
800        .next()
801        .unwrap_or("")
802        .split('(')
803        .next()
804        .unwrap_or("")
805        .trim();
806
807    // Map conventional commit type to label
808    let type_label = match prefix {
809        "feat" | "perf" => Some("enhancement"),
810        "fix" => Some("bug"),
811        "docs" => Some("documentation"),
812        "refactor" => Some("refactor"),
813        _ => None,
814    };
815
816    if let Some(label) = type_label {
817        labels.insert(label.to_string());
818    }
819
820    // Map file paths to scope labels
821    for path in file_paths {
822        let scope = if path.starts_with("crates/aptu-cli/") {
823            Some("cli")
824        } else if path.starts_with("docs/") {
825            Some("documentation")
826        } else {
827            None
828        };
829
830        if let Some(label) = scope {
831            labels.insert(label.to_string());
832        }
833    }
834
835    labels.into_iter().collect()
836}
837
838/// Creates a pull request on GitHub.
839///
840/// # Arguments
841///
842/// * `client` - Authenticated Octocrab client
843/// * `owner` - Repository owner
844/// * `repo` - Repository name
845/// * `title` - PR title
846/// * `head_branch` - Head branch (the branch with changes)
847/// * `base_branch` - Base branch (the branch to merge into)
848/// * `body` - Optional PR body text
849///
850/// # Returns
851///
852/// `PrCreateResult` with PR metadata.
853///
854/// # Errors
855///
856/// Returns an error if the API call fails or the user lacks write access.
857#[cfg(not(target_arch = "wasm32"))]
858#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
859#[allow(clippy::too_many_arguments)]
860pub async fn create_pull_request(
861    client: &Octocrab,
862    owner: &str,
863    repo: &str,
864    title: &str,
865    head_branch: &str,
866    base_branch: &str,
867    body: Option<&str>,
868    draft: bool,
869) -> anyhow::Result<PrCreateResult> {
870    debug!("Creating pull request");
871
872    let pr = client
873        .pulls(owner, repo)
874        .create(title, head_branch, base_branch)
875        .body(body.unwrap_or_default())
876        .draft(draft)
877        .send()
878        .await
879        .with_context(|| {
880            format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
881        })?;
882
883    let result = PrCreateResult {
884        pr_number: pr.number,
885        url: pr
886            .html_url
887            .as_ref()
888            .map(std::string::ToString::to_string)
889            .unwrap_or_default(),
890        branch: pr.head.ref_field.clone(),
891        base: pr.base.ref_field.clone(),
892        title: pr.title.clone().unwrap_or_default(),
893        draft: pr.draft.unwrap_or(false),
894        files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
895        additions: pr.additions.unwrap_or(0),
896        deletions: pr.deletions.unwrap_or(0),
897    };
898
899    debug!(
900        pr_number = result.pr_number,
901        "Pull request created successfully"
902    );
903
904    Ok(result)
905}
906
907/// Determines whether a file should be skipped during fetch based on status and patch.
908/// Emits a debug log with the skip reason. Returns true if the file should be skipped
909/// (removed status or no patch), false otherwise.
910fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
911    if status.to_lowercase().contains("removed") {
912        debug!(file = %filename, "Skipping removed file");
913        return true;
914    }
915    if patch.is_none_or(String::is_empty) {
916        debug!(file = %filename, "Skipping file with empty patch");
917        return true;
918    }
919    false
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::ai::types::CommentSeverity;
926
927    fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
928        use base64::Engine;
929        let engine = base64::engine::general_purpose::STANDARD;
930        let decoded_bytes = engine.decode(encoded).ok()?;
931        let decoded_str = String::from_utf8(decoded_bytes).ok()?;
932
933        if decoded_str.len() <= max_chars {
934            Some(decoded_str)
935        } else {
936            Some(decoded_str.chars().take(max_chars).collect::<String>())
937        }
938    }
939
940    #[test]
941    fn test_pr_create_result_fields() {
942        // Arrange / Act: construct directly (no network call needed)
943        let result = PrCreateResult {
944            pr_number: 42,
945            url: "https://github.com/owner/repo/pull/42".to_string(),
946            branch: "feat/my-feature".to_string(),
947            base: "main".to_string(),
948            title: "feat: add feature".to_string(),
949            draft: false,
950            files_changed: 3,
951            additions: 100,
952            deletions: 10,
953        };
954
955        // Assert
956        assert_eq!(result.pr_number, 42);
957        assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
958        assert_eq!(result.branch, "feat/my-feature");
959        assert_eq!(result.base, "main");
960        assert_eq!(result.title, "feat: add feature");
961        assert!(!result.draft);
962        assert_eq!(result.files_changed, 3);
963        assert_eq!(result.additions, 100);
964        assert_eq!(result.deletions, 10);
965    }
966
967    // ---------------------------------------------------------------------------
968    // post_pr_review payload construction
969    // ---------------------------------------------------------------------------
970
971    /// Helper: build the inline comments JSON array using the same logic as
972    /// `post_pr_review`, without making a live HTTP call.
973    fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
974        comments
975            .iter()
976            .filter_map(|c| {
977                c.line.map(|line| {
978                    serde_json::json!({
979                        "path": c.file,
980                        "line": line,
981                        "side": "RIGHT",
982                        "body": render_pr_review_comment_body(c),
983                    })
984                })
985            })
986            .collect()
987    }
988
989    #[test]
990    fn test_post_pr_review_payload_with_comments() {
991        // Arrange
992        let comments = vec![PrReviewComment {
993            file: "src/main.rs".to_string(),
994            line: Some(42),
995            comment: "Consider using a match here.".to_string(),
996            severity: CommentSeverity::Suggestion,
997            suggested_code: None,
998        }];
999
1000        // Act
1001        let inline = build_inline_comments(&comments);
1002
1003        // Assert
1004        assert_eq!(inline.len(), 1);
1005        assert_eq!(inline[0]["path"], "src/main.rs");
1006        assert_eq!(inline[0]["line"], 42);
1007        assert_eq!(inline[0]["side"], "RIGHT");
1008        assert_eq!(inline[0]["body"], "Consider using a match here.");
1009    }
1010
1011    #[test]
1012    fn test_post_pr_review_skips_none_line_comments() {
1013        // Arrange: one comment with a line, one without.
1014        let comments = vec![
1015            PrReviewComment {
1016                file: "src/lib.rs".to_string(),
1017                line: None,
1018                comment: "General file comment.".to_string(),
1019                severity: CommentSeverity::Info,
1020                suggested_code: None,
1021            },
1022            PrReviewComment {
1023                file: "src/lib.rs".to_string(),
1024                line: Some(10),
1025                comment: "Inline comment.".to_string(),
1026                severity: CommentSeverity::Warning,
1027                suggested_code: None,
1028            },
1029        ];
1030
1031        // Act
1032        let inline = build_inline_comments(&comments);
1033
1034        // Assert: only the comment with a line is included.
1035        assert_eq!(inline.len(), 1);
1036        assert_eq!(inline[0]["line"], 10);
1037    }
1038
1039    #[test]
1040    fn test_post_pr_review_empty_comments() {
1041        // Arrange
1042        let comments: Vec<PrReviewComment> = vec![];
1043
1044        // Act
1045        let inline = build_inline_comments(&comments);
1046
1047        // Assert: empty slice produces empty array, which serializes as [].
1048        assert!(inline.is_empty());
1049        let serialized = serde_json::to_string(&inline).unwrap();
1050        assert_eq!(serialized, "[]");
1051    }
1052
1053    // ---------------------------------------------------------------------------
1054    // Existing tests
1055    // ---------------------------------------------------------------------------
1056
1057    // Smoke test to verify parse_pr_reference delegates correctly.
1058    // Comprehensive parsing tests are in github/mod.rs.
1059    #[test]
1060    fn test_parse_pr_reference_delegates_to_shared() {
1061        let (owner, repo, number) =
1062            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
1063        assert_eq!(owner, "block");
1064        assert_eq!(repo, "goose");
1065        assert_eq!(number, 123);
1066    }
1067
1068    #[test]
1069    fn test_title_prefix_to_label_mapping() {
1070        let cases = vec![
1071            (
1072                "feat: add new feature",
1073                vec!["enhancement"],
1074                "feat should map to enhancement",
1075            ),
1076            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
1077            (
1078                "docs: update readme",
1079                vec!["documentation"],
1080                "docs should map to documentation",
1081            ),
1082            (
1083                "refactor: improve code",
1084                vec!["refactor"],
1085                "refactor should map to refactor",
1086            ),
1087            (
1088                "perf: optimize",
1089                vec!["enhancement"],
1090                "perf should map to enhancement",
1091            ),
1092            (
1093                "chore: update deps",
1094                vec![],
1095                "chore should produce no labels",
1096            ),
1097        ];
1098
1099        for (title, expected_labels, msg) in cases {
1100            let labels = labels_from_pr_metadata(title, &[]);
1101            for expected in &expected_labels {
1102                assert!(
1103                    labels.contains(&expected.to_string()),
1104                    "{msg}: expected '{expected}' in {labels:?}",
1105                );
1106            }
1107            if expected_labels.is_empty() {
1108                assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn test_file_path_to_scope_mapping() {
1115        let cases = vec![
1116            (
1117                "feat: cli",
1118                vec!["crates/aptu-cli/src/main.rs"],
1119                vec!["enhancement", "cli"],
1120                "cli path should map to cli scope",
1121            ),
1122            (
1123                "feat: docs",
1124                vec!["docs/GITHUB_ACTION.md"],
1125                vec!["enhancement", "documentation"],
1126                "docs path should map to documentation scope",
1127            ),
1128            (
1129                "feat: workflow",
1130                vec![".github/workflows/test.yml"],
1131                vec!["enhancement"],
1132                "workflow path should be ignored",
1133            ),
1134        ];
1135
1136        for (title, paths, expected_labels, msg) in cases {
1137            let labels = labels_from_pr_metadata(
1138                title,
1139                &paths
1140                    .iter()
1141                    .map(std::string::ToString::to_string)
1142                    .collect::<Vec<_>>(),
1143            );
1144            for expected in expected_labels {
1145                assert!(
1146                    labels.contains(&expected.to_string()),
1147                    "{msg}: expected '{expected}' in {labels:?}",
1148                );
1149            }
1150        }
1151    }
1152
1153    #[test]
1154    fn test_combined_title_and_paths() {
1155        let labels = labels_from_pr_metadata(
1156            "feat: multi",
1157            &[
1158                "crates/aptu-cli/src/main.rs".to_string(),
1159                "docs/README.md".to_string(),
1160            ],
1161        );
1162        assert!(
1163            labels.contains(&"enhancement".to_string()),
1164            "should include enhancement from feat prefix"
1165        );
1166        assert!(
1167            labels.contains(&"cli".to_string()),
1168            "should include cli from path"
1169        );
1170        assert!(
1171            labels.contains(&"documentation".to_string()),
1172            "should include documentation from path"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_no_match_returns_empty() {
1178        let cases = vec![
1179            (
1180                "Random title",
1181                vec![],
1182                "unrecognized prefix should return empty",
1183            ),
1184            (
1185                "chore: update",
1186                vec![],
1187                "ignored prefix should return empty",
1188            ),
1189        ];
1190
1191        for (title, paths, msg) in cases {
1192            let labels = labels_from_pr_metadata(title, &paths);
1193            assert!(labels.is_empty(), "{msg}: got {labels:?}");
1194        }
1195    }
1196
1197    #[test]
1198    fn test_scoped_prefix_extracts_type() {
1199        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1200        assert!(
1201            labels.contains(&"enhancement".to_string()),
1202            "scoped prefix should extract type from feat(cli)"
1203        );
1204    }
1205
1206    #[test]
1207    fn test_duplicate_labels_deduplicated() {
1208        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1209        assert_eq!(
1210            labels.len(),
1211            1,
1212            "should have exactly one label when title and path both map to documentation"
1213        );
1214        assert!(
1215            labels.contains(&"documentation".to_string()),
1216            "should contain documentation label"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_should_skip_file_respects_fetched_count_cap() {
1222        // Test that should_skip_file correctly identifies files to skip.
1223        // Files with removed status or no patch should be skipped.
1224        let removed_file = PrFile {
1225            filename: "removed.rs".to_string(),
1226            status: "removed".to_string(),
1227            additions: 0,
1228            deletions: 5,
1229            patch: None,
1230            patch_truncated: false,
1231            full_content: None,
1232        };
1233        let modified_file = PrFile {
1234            filename: "file_0.rs".to_string(),
1235            status: "modified".to_string(),
1236            additions: 1,
1237            deletions: 0,
1238            patch: Some("+ new code".to_string()),
1239            patch_truncated: false,
1240            full_content: None,
1241        };
1242        let no_patch_file = PrFile {
1243            filename: "file_1.rs".to_string(),
1244            status: "modified".to_string(),
1245            additions: 1,
1246            deletions: 0,
1247            patch: None,
1248            patch_truncated: false,
1249            full_content: None,
1250        };
1251
1252        // Assert: removed files are skipped
1253        assert!(
1254            should_skip_file(
1255                &removed_file.filename,
1256                &removed_file.status,
1257                removed_file.patch.as_ref()
1258            ),
1259            "removed files should be skipped"
1260        );
1261
1262        // Assert: modified files with patch are not skipped
1263        assert!(
1264            !should_skip_file(
1265                &modified_file.filename,
1266                &modified_file.status,
1267                modified_file.patch.as_ref()
1268            ),
1269            "modified files with patch should not be skipped"
1270        );
1271
1272        // Assert: files without patch are skipped
1273        assert!(
1274            should_skip_file(
1275                &no_patch_file.filename,
1276                &no_patch_file.status,
1277                no_patch_file.patch.as_ref()
1278            ),
1279            "files without patch should be skipped"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_decode_content_valid_base64() {
1285        // Arrange: valid base64-encoded string
1286        use base64::Engine;
1287        let engine = base64::engine::general_purpose::STANDARD;
1288        let original = "Hello, World!";
1289        let encoded = engine.encode(original);
1290
1291        // Act: decode with sufficient max_chars
1292        let result = decode_content(&encoded, 1000);
1293
1294        // Assert: decoding succeeds and matches original
1295        assert_eq!(
1296            result,
1297            Some(original.to_string()),
1298            "valid base64 should decode successfully"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_decode_content_invalid_base64() {
1304        // Arrange: invalid base64 string
1305        let invalid_base64 = "!!!invalid!!!";
1306
1307        // Act: attempt to decode
1308        let result = decode_content(invalid_base64, 1000);
1309
1310        // Assert: decoding fails gracefully
1311        assert_eq!(result, None, "invalid base64 should return None");
1312    }
1313
1314    #[test]
1315    fn test_decode_content_truncates_at_max_chars() {
1316        // Arrange: multi-byte UTF-8 string (Japanese characters)
1317        use base64::Engine;
1318        let engine = base64::engine::general_purpose::STANDARD;
1319        let original = "こんにちは".repeat(10); // 50 characters total
1320        let encoded = engine.encode(&original);
1321        let max_chars = 10;
1322
1323        // Act: decode with max_chars limit
1324        let result = decode_content(&encoded, max_chars);
1325
1326        // Assert: result is truncated to max_chars on character boundary
1327        assert!(result.is_some(), "decoding should succeed");
1328        let decoded = result.unwrap();
1329        assert_eq!(
1330            decoded.chars().count(),
1331            max_chars,
1332            "output should be truncated to max_chars on character boundary"
1333        );
1334        assert!(
1335            decoded.is_char_boundary(decoded.len()),
1336            "output should be valid UTF-8 (truncated on char boundary)"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_list_files_pagination_collects_all_pages() {
1342        // Arrange: simulate pagination with two pages
1343        // Page 1: 100 items with next_link set
1344        let mut page1_items = Vec::new();
1345        for i in 0..100 {
1346            page1_items.push(PrFile {
1347                filename: format!("file{}.rs", i),
1348                status: "modified".to_string(),
1349                additions: 1,
1350                deletions: 0,
1351                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1352                patch_truncated: false,
1353                full_content: None,
1354            });
1355        }
1356
1357        // Page 2: 50 items with no next_link
1358        let mut page2_items = Vec::new();
1359        for i in 100..150 {
1360            page2_items.push(PrFile {
1361                filename: format!("file{}.rs", i),
1362                status: "modified".to_string(),
1363                additions: 1,
1364                deletions: 0,
1365                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1366                patch_truncated: false,
1367                full_content: None,
1368            });
1369        }
1370
1371        // Act: collect all items (simulating pagination loop)
1372        let mut all_files = Vec::new();
1373        all_files.extend(page1_items);
1374        all_files.extend(page2_items);
1375
1376        // Assert: total collected == 150
1377        assert_eq!(
1378            all_files.len(),
1379            150,
1380            "pagination should collect all items from both pages"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_list_files_pagination_respects_300_file_cap() {
1386        // Arrange: build a Vec of 301 PrFile items
1387        let mut files = Vec::new();
1388        for i in 0..301 {
1389            files.push(PrFile {
1390                filename: format!("file{}.rs", i),
1391                status: "modified".to_string(),
1392                additions: 1,
1393                deletions: 0,
1394                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1395                patch_truncated: false,
1396                full_content: None,
1397            });
1398        }
1399
1400        // Act: apply the 300-file cap (simulating the truncate logic)
1401        if files.len() >= 300 {
1402            files.truncate(300);
1403        }
1404
1405        // Assert: result.len() == 300
1406        assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1407    }
1408
1409    #[test]
1410    fn test_is_patch_truncated_detects_mid_hunk_plus() {
1411        // Test: patch ending with '+' (mid-hunk truncation)
1412        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1413        assert!(
1414            is_patch_truncated(truncated_patch),
1415            "patch ending with + should be detected as truncated"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_is_patch_truncated_detects_mid_hunk_minus() {
1421        // Test: patch ending with '-' (mid-hunk truncation)
1422        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1423        assert!(
1424            is_patch_truncated(truncated_patch),
1425            "patch ending with - should be detected as truncated"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_is_patch_truncated_clean_patch_context_line() {
1431        // Test: patch ending with ' ' (context line, not truncated)
1432        let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1433        assert!(
1434            !is_patch_truncated(clean_patch),
1435            "patch ending with context line should not be detected as truncated"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_is_patch_truncated_correct_hunk_line_count() {
1441        // Test: patch with correct hunk line count (declared 3, actual 3)
1442        let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1443        assert!(
1444            !is_patch_truncated(clean_patch),
1445            "patch with correct hunk line count should not be detected as truncated"
1446        );
1447    }
1448
1449    #[test]
1450    fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1451        // Test: patch with declared hunk size larger than delivered lines
1452        // Declared: +1,4 (4 lines in new file), Actual: only 2 lines delivered
1453        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1454        assert!(
1455            is_patch_truncated(truncated_patch),
1456            "patch with declared hunk size larger than delivered should be detected as truncated"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1462        // Test: patch with no @@ header but last line is '+'
1463        let truncated_patch = "line1\nline2\n+";
1464        assert!(
1465            is_patch_truncated(truncated_patch),
1466            "patch with no @@ header but ending with + should be detected as truncated"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_is_patch_truncated_empty_patch() {
1472        // Test: empty patch
1473        let empty_patch = "";
1474        assert!(
1475            !is_patch_truncated(empty_patch),
1476            "empty patch should not be detected as truncated"
1477        );
1478    }
1479
1480    #[test]
1481    fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1482        // Test: multiple hunks where the last hunk is truncated
1483        let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1484        assert!(
1485            is_patch_truncated(truncated_patch),
1486            "patch with last hunk truncated should be detected as truncated"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_pr_file_status_case_insensitive_added() {
1492        // Test: Added file status is matched case-insensitively
1493        let file = PrFile {
1494            filename: "new.rs".to_string(),
1495            status: "Added".to_string(), // Debug repr from Octocrab
1496            additions: 50,
1497            deletions: 0,
1498            patch: Some("new code".to_string()),
1499            patch_truncated: false,
1500            full_content: None,
1501        };
1502
1503        let is_added_renamed_copied = matches!(
1504            file.status.to_lowercase().as_str(),
1505            "added" | "renamed" | "copied"
1506        );
1507        assert!(is_added_renamed_copied, "Added status should be recognized");
1508    }
1509
1510    #[test]
1511    fn test_pr_file_status_case_insensitive_modified() {
1512        // Test: Modified file status is NOT matched (edge case)
1513        let file = PrFile {
1514            filename: "existing.rs".to_string(),
1515            status: "Modified".to_string(),
1516            additions: 10,
1517            deletions: 5,
1518            patch: Some("modified code".to_string()),
1519            patch_truncated: false,
1520            full_content: None,
1521        };
1522
1523        let is_added_renamed_copied = matches!(
1524            file.status.to_lowercase().as_str(),
1525            "added" | "renamed" | "copied"
1526        );
1527        assert!(
1528            !is_added_renamed_copied,
1529            "Modified status should NOT be recognized as added/renamed/copied"
1530        );
1531    }
1532
1533    #[test]
1534    fn test_pr_file_oversized_patch_detection() {
1535        // Test: Patch size is compared against max_patch_chars_per_file.
1536        // Derive the limit from ReviewConfig::default() -- single source of truth.
1537        let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1538        let patch = "a".repeat(max_patch_chars + 5_000); // Clearly exceeds limit
1539
1540        let patch_too_large = patch.len() > max_patch_chars;
1541        assert!(
1542            patch_too_large,
1543            "patch exceeding the default limit should be detected as oversized"
1544        );
1545    }
1546
1547    #[test]
1548    fn test_pr_file_dedup_guard_full_content_present() {
1549        // Test: File with full_content already populated should skip Contents API call
1550        let file = PrFile {
1551            filename: "new.rs".to_string(),
1552            status: "Added".to_string(),
1553            additions: 50,
1554            deletions: 0,
1555            patch: Some("new code".to_string()),
1556            patch_truncated: false,
1557            full_content: Some("full content from Contents API".to_string()),
1558        };
1559
1560        let should_fetch = file.full_content.is_none();
1561        assert!(
1562            !should_fetch,
1563            "File with full_content should not be fetched again (dedup guard)"
1564        );
1565    }
1566
1567    #[test]
1568    fn test_pr_file_contents_api_fallback_flow() {
1569        // Test: Verify the three conditions for Contents API fallback:
1570        // 1. status is Added/Renamed/Copied
1571        // 2. patch size exceeds max_patch_chars_per_file
1572        // 3. full_content is None
1573        // Derive the limit from ReviewConfig::default() -- single source of truth.
1574        let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1575
1576        let file = PrFile {
1577            filename: "new.rs".to_string(),
1578            status: "Added".to_string(),
1579            additions: 50,
1580            deletions: 0,
1581            patch: Some("a".repeat(max_patch_chars + 5_000)), // Clearly exceeds limit
1582            patch_truncated: false,
1583            full_content: None, // Not yet fetched
1584        };
1585
1586        let is_added_renamed_copied = matches!(
1587            file.status.to_lowercase().as_str(),
1588            "added" | "renamed" | "copied"
1589        );
1590        let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1591        let should_attempt_contents_api =
1592            is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1593
1594        assert!(
1595            should_attempt_contents_api,
1596            "Added file with 30k patch and no full_content should attempt Contents API"
1597        );
1598    }
1599
1600    #[test]
1601    fn test_merge_preserves_existing_full_content() {
1602        // Arrange: create a PrFile with full_content = Some("fallback content"),
1603        // pair it with content = None (simulating fetch_file_contents returning None beyond the cap)
1604        let mut file = PrFile {
1605            filename: "test.rs".to_string(),
1606            status: "modified".to_string(),
1607            additions: 5,
1608            deletions: 2,
1609            patch: Some("@@ -1,1 +1,1 @@".to_string()),
1610            patch_truncated: false,
1611            full_content: Some("fallback content".to_string()),
1612        };
1613        let content = None;
1614
1615        // Act: apply the fixed merge logic
1616        if file.full_content.is_none() {
1617            file.full_content = content;
1618        }
1619
1620        // Assert: file.full_content == Some("fallback content")
1621        assert_eq!(file.full_content, Some("fallback content".to_string()));
1622    }
1623
1624    #[test]
1625    fn test_merge_sets_full_content_when_none() {
1626        // Arrange: PrFile with full_content = None, content = Some("fetched content")
1627        let mut file = PrFile {
1628            filename: "test.rs".to_string(),
1629            status: "modified".to_string(),
1630            additions: 5,
1631            deletions: 2,
1632            patch: Some("@@ -1,1 +1,1 @@".to_string()),
1633            patch_truncated: false,
1634            full_content: None,
1635        };
1636        let content = Some("fetched content".to_string());
1637
1638        // Act: apply merge logic
1639        if file.full_content.is_none() {
1640            file.full_content = content;
1641        }
1642
1643        // Assert: file.full_content == Some("fetched content")
1644        assert_eq!(file.full_content, Some("fetched content".to_string()));
1645    }
1646
1647    #[test]
1648    fn test_fetch_file_contents_fallback_on_truncated_patch() {
1649        // Note: The Contents API network call cannot be unit-tested without a mock.
1650        // The fallback is exercised in integration tests via the full fetch_pr_details flow.
1651        // Unit tests for is_patch_truncated are above.
1652        // New unit tests for the added/renamed/copied Contents API fallback:
1653        // - test_pr_file_status_case_insensitive_added
1654        // - test_pr_file_status_case_insensitive_modified
1655        // - test_pr_file_oversized_patch_detection
1656        // - test_pr_file_dedup_guard_full_content_present
1657        // - test_pr_file_contents_api_fallback_flow
1658    }
1659
1660    #[test]
1661    fn test_review_comments_maps_fields_correctly() {
1662        use crate::ai::types::PrReviewCommentDetails;
1663
1664        let bot = PrReviewCommentDetails {
1665            id: 42,
1666            author: "aptu[bot]".to_string(),
1667            body: "suggestion".to_string(),
1668            path: "src/lib.rs".to_string(),
1669            line: Some(15),
1670            side: Some(crate::facade::pr_review::DEFAULT_COMMENT_SIDE.to_string()),
1671            commit_id: "abc123".to_string(),
1672        };
1673        let human = PrReviewCommentDetails {
1674            id: 99,
1675            author: "human-user".to_string(),
1676            body: "looks good".to_string(),
1677            path: "src/main.rs".to_string(),
1678            line: Some(30),
1679            side: Some("LEFT".to_string()),
1680            commit_id: "def456".to_string(),
1681        };
1682
1683        let kept: Vec<_> = vec![bot, human]
1684            .into_iter()
1685            .filter(|c| c.author == "aptu[bot]")
1686            .collect();
1687        assert_eq!(kept.len(), 1);
1688        assert_eq!(kept[0].path, "src/lib.rs");
1689        assert_eq!(kept[0].line, Some(15));
1690        assert_eq!(
1691            kept[0].side,
1692            Some(crate::facade::pr_review::DEFAULT_COMMENT_SIDE.to_string())
1693        );
1694        assert_eq!(kept[0].commit_id, "abc123");
1695    }
1696}