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    let details = PrDetails {
269        owner: owner.to_string(),
270        repo: repo.to_string(),
271        number,
272        title: pr.title.clone().unwrap_or_default(),
273        body: pr.body.clone().unwrap_or_default(),
274        base_branch: pr.base.ref_field.clone(),
275        head_branch: pr.head.ref_field.clone(),
276        head_sha: pr.head.sha.as_str().to_string(),
277        files: pr_files,
278        url: pr
279            .html_url
280            .as_ref()
281            .map(std::string::ToString::to_string)
282            .unwrap_or_default(),
283        labels,
284        review_comments: Vec::new(),
285        instructions: None,
286        dep_enrichments: Vec::new(),
287    };
288
289    debug!(
290        file_count = details.files.len(),
291        "PR details fetched successfully"
292    );
293
294    Ok(details)
295}
296
297/// Detects if a patch is truncated mid-hunk by GitHub API.
298///
299/// A patch is considered truncated if the last non-empty line starts with '+' or '-',
300/// indicating an incomplete hunk.
301fn is_patch_truncated(patch: &str) -> bool {
302    let lines: Vec<&str> = patch.lines().collect();
303
304    // Rule 1: Check if last non-empty line starts with '+' or '-' (mid-hunk cutoff)
305    if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
306        && (last_line.starts_with('+') || last_line.starts_with('-'))
307    {
308        return true;
309    }
310
311    // Rule 2: Check if declared hunk size matches actual lines delivered
312    // Parse the last @@ -a,b +c,d @@ header and verify line count
313    if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
314        // Extract the +c,d part from the hunk header
315        if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
316            // Extract the number after '+' and before the next space or @@
317            if let Some(size_str) = plus_part.split_whitespace().next() {
318                // Parse "c,d" format
319                if let Some(count_str) = size_str.split(',').nth(1)
320                    && let Ok(declared_count) = count_str.parse::<usize>()
321                {
322                    // Count actual lines after this hunk header (context + added lines)
323                    // Find the index of this hunk header
324                    if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
325                    {
326                        let lines_after_hunk = &lines[hunk_idx + 1..];
327                        // Count lines that are context (' '), additions ('+'), or deletions ('-')
328                        // Stop counting if we hit another hunk header
329                        let mut actual_count = 0;
330                        for line in lines_after_hunk {
331                            if line.starts_with("@@") {
332                                break;
333                            }
334                            if line.starts_with(' ')
335                                || line.starts_with('+')
336                                || line.starts_with('-')
337                            {
338                                actual_count += 1;
339                            }
340                        }
341                        // If actual count is less than declared, the hunk is truncated
342                        if actual_count < declared_count {
343                            return true;
344                        }
345                    }
346                }
347            }
348        }
349    }
350
351    false
352}
353
354/// Fetches a single file's content from GitHub Contents API as a fallback for truncated patches.
355///
356/// Returns the file content truncated to `max_chars`, or `None` if the file cannot be fetched.
357/// Non-fatal errors (404, rate limits) are logged as warnings.
358#[cfg(not(target_arch = "wasm32"))]
359async fn fetch_file_contents_single(
360    client: &Octocrab,
361    owner: &str,
362    repo: &str,
363    filename: &str,
364    head_sha: &str,
365    max_chars: usize,
366) -> Result<Option<String>> {
367    match client
368        .repos(owner, repo)
369        .get_content()
370        .path(filename)
371        .r#ref(head_sha)
372        .send()
373        .await
374    {
375        Ok(content) => {
376            // Try to decode the first item (should be the file, not a directory listing)
377            if let Some(item) = content.items.first() {
378                if let Some(decoded) = item.decoded_content() {
379                    let truncated = if decoded.chars().count() > max_chars {
380                        truncate_at_line_boundary(&decoded, max_chars)
381                    } else {
382                        decoded
383                    };
384                    Ok(Some(truncated))
385                } else {
386                    tracing::warn!(
387                        "Failed to decode content for {}/{}/{} at {}",
388                        owner,
389                        repo,
390                        filename,
391                        head_sha
392                    );
393                    Ok(None)
394                }
395            } else {
396                tracing::warn!(
397                    "File content response was empty for {}/{}/{} at {}",
398                    owner,
399                    repo,
400                    filename,
401                    head_sha
402                );
403                Ok(None)
404            }
405        }
406        Err(e) => {
407            tracing::warn!(
408                "Failed to fetch content for {}/{}/{} at {}: {}",
409                owner,
410                repo,
411                filename,
412                head_sha,
413                e
414            );
415            Ok(None)
416        }
417    }
418}
419
420/// Fetches full file contents for PR files from GitHub Contents API.
421///
422/// Fetches content for eligible files up to a specified limit and truncates each to a character limit.
423/// Skips deleted files and files with empty patches. Per-file errors are non-fatal: they produce
424/// `None` entries and log warnings.
425///
426/// # Arguments
427///
428/// * `client` - Authenticated Octocrab client
429/// * `owner` - Repository owner
430/// * `repo` - Repository name
431/// * `files` - Slice of PR files to fetch
432/// * `head_sha` - PR head commit SHA to fetch from
433/// * `max_files` - Maximum number of files to fetch content for
434/// * `max_chars_per_file` - Truncate each file's content at this character limit
435///
436/// # Returns
437///
438/// Vector of `Option<String>` with one entry per input file (in order):
439/// - `Some(content)` if fetch succeeded
440/// - `None` if fetch failed, file was skipped, or file index exceeded `max_files`
441#[cfg(not(target_arch = "wasm32"))]
442#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
443async fn fetch_file_contents(
444    client: &Octocrab,
445    owner: &str,
446    repo: &str,
447    files: &[PrFile],
448    head_sha: &str,
449    max_files: usize,
450    max_chars_per_file: usize,
451) -> Vec<Option<String>> {
452    let mut results = Vec::with_capacity(files.len());
453    let mut fetched_count = 0usize;
454
455    for file in files {
456        if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
457            results.push(None);
458            continue;
459        }
460
461        // Skip if beyond max_files cap (count only successfully-fetched files)
462        if fetched_count >= max_files {
463            debug!(
464                file = %file.filename,
465                fetched_count = fetched_count,
466                max_files = max_files,
467                "Fetched file count exceeds max_files cap"
468            );
469            results.push(None);
470            continue;
471        }
472
473        // Attempt to fetch file content
474        match client
475            .repos(owner, repo)
476            .get_content()
477            .path(&file.filename)
478            .r#ref(head_sha)
479            .send()
480            .await
481        {
482            Ok(content) => {
483                // Try to decode the first item (should be the file, not a directory listing)
484                if let Some(item) = content.items.first() {
485                    if let Some(decoded) = item.decoded_content() {
486                        let truncated = if decoded.chars().count() > max_chars_per_file {
487                            truncate_at_line_boundary(&decoded, max_chars_per_file)
488                        } else {
489                            decoded
490                        };
491                        debug!(
492                            file = %file.filename,
493                            content_len = truncated.len(),
494                            "File content fetched and truncated"
495                        );
496                        results.push(Some(truncated));
497                        fetched_count += 1;
498                    } else {
499                        tracing::warn!(
500                            file = %file.filename,
501                            "Failed to decode file content; skipping"
502                        );
503                        results.push(None);
504                    }
505                } else {
506                    tracing::warn!(
507                        file = %file.filename,
508                        "File content response was empty; skipping"
509                    );
510                    results.push(None);
511                }
512            }
513            Err(e) => {
514                tracing::warn!(
515                    file = %file.filename,
516                    err = %e,
517                    "Failed to fetch file content; skipping"
518                );
519                results.push(None);
520            }
521        }
522    }
523
524    results
525}
526
527/// Posts a PR review to GitHub.
528///
529/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
530/// Requires write access to the repository.
531///
532/// # Arguments
533///
534/// * `client` - Authenticated Octocrab client
535/// * `owner` - Repository owner
536/// * `repo` - Repository name
537/// * `number` - PR number
538/// * `body` - Review comment text
539/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
540/// * `comments` - Inline review comments to attach; entries with `line = None` are silently skipped
541/// * `commit_id` - Head commit SHA to associate with the review; omitted from payload if empty
542///
543/// # Returns
544///
545/// Review ID on success.
546///
547/// # Errors
548///
549/// Returns an error if the API call fails, user lacks write access, or PR is not found.
550#[cfg(not(target_arch = "wasm32"))]
551#[allow(clippy::too_many_arguments)]
552#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
553pub async fn post_pr_review(
554    client: &Octocrab,
555    owner: &str,
556    repo: &str,
557    number: u64,
558    body: &str,
559    event: ReviewEvent,
560    comments: &[PrReviewComment],
561    commit_id: &str,
562) -> Result<u64> {
563    debug!("Posting PR review");
564
565    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
566
567    // Build inline comments array; skip entries without a line number.
568    let inline_comments: Vec<serde_json::Value> = comments
569        .iter()
570        // Comments without a line number cannot be anchored to the diff; skip silently.
571        .filter_map(|c| {
572            c.line.map(|line| {
573                serde_json::json!({
574                    "path": c.file,
575                    "line": line,
576                    // RIGHT = new version of the file (added/changed lines).
577                    // Use line (file line number) rather than the deprecated
578                    // position (diff hunk offset) so no hunk parsing is needed.
579                    "side": "RIGHT",
580                    "body": render_pr_review_comment_body(c),
581                })
582            })
583        })
584        .collect();
585
586    let mut payload = serde_json::json!({
587        "body": body,
588        "event": event.to_string(),
589        "comments": inline_comments,
590    });
591
592    // commit_id is optional; include only when non-empty.
593    if !commit_id.is_empty() {
594        payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
595    }
596
597    #[derive(serde::Deserialize)]
598    struct ReviewResponse {
599        id: u64,
600    }
601
602    let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
603        format!(
604            "Failed to post review to PR #{number} in {owner}/{repo}. \
605                 Check that you have write access to the repository."
606        )
607    })?;
608
609    debug!(review_id = response.id, "PR review posted successfully");
610
611    Ok(response.id)
612}
613
614/// Deletes a PR review comment.
615///
616/// # Errors
617///
618/// Returns an error if the API request fails. 404 errors (comment not found)
619/// are treated as success (idempotent).
620#[cfg(not(target_arch = "wasm32"))]
621#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
622pub async fn delete_pr_review_comment(
623    client: &Octocrab,
624    owner: &str,
625    repo: &str,
626    comment_id: u64,
627) -> Result<()> {
628    debug!("Deleting PR review comment");
629
630    let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
631
632    // Use generic delete method; needs explicit empty object body type
633    let empty_body = serde_json::json!({});
634    let result: std::result::Result<serde_json::Value, _> =
635        client.delete(&route, Some(&empty_body)).await;
636
637    match result {
638        Ok(_) => {
639            debug!("PR review comment deleted successfully");
640            Ok(())
641        }
642        Err(e)
643            if let octocrab::Error::GitHub { source, .. } = &e
644                && source.status_code.as_u16() == 404 =>
645        {
646            debug!("PR review comment already deleted (404); treating as success");
647            Ok(())
648        }
649        Err(e) => {
650            Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
651        }
652    }
653}
654
655/// Extract labels from PR metadata (title and file paths).
656///
657/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
658/// Returns a vector of label names to apply to the PR.
659///
660/// # Arguments
661/// * `title` - PR title (may contain conventional commit prefix)
662/// * `file_paths` - List of file paths changed in the PR
663///
664/// # Returns
665/// Vector of label names to apply
666#[must_use]
667pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
668    let mut labels = std::collections::HashSet::new();
669
670    // Extract conventional commit prefix from title
671    // Handle both "feat: ..." and "feat(scope): ..." formats
672    let prefix = title
673        .split(':')
674        .next()
675        .unwrap_or("")
676        .split('(')
677        .next()
678        .unwrap_or("")
679        .trim();
680
681    // Map conventional commit type to label
682    let type_label = match prefix {
683        "feat" | "perf" => Some("enhancement"),
684        "fix" => Some("bug"),
685        "docs" => Some("documentation"),
686        "refactor" => Some("refactor"),
687        _ => None,
688    };
689
690    if let Some(label) = type_label {
691        labels.insert(label.to_string());
692    }
693
694    // Map file paths to scope labels
695    for path in file_paths {
696        let scope = if path.starts_with("crates/aptu-cli/") {
697            Some("cli")
698        } else if path.starts_with("docs/") {
699            Some("documentation")
700        } else {
701            None
702        };
703
704        if let Some(label) = scope {
705            labels.insert(label.to_string());
706        }
707    }
708
709    labels.into_iter().collect()
710}
711
712/// Creates a pull request on GitHub.
713///
714/// # Arguments
715///
716/// * `client` - Authenticated Octocrab client
717/// * `owner` - Repository owner
718/// * `repo` - Repository name
719/// * `title` - PR title
720/// * `head_branch` - Head branch (the branch with changes)
721/// * `base_branch` - Base branch (the branch to merge into)
722/// * `body` - Optional PR body text
723///
724/// # Returns
725///
726/// `PrCreateResult` with PR metadata.
727///
728/// # Errors
729///
730/// Returns an error if the API call fails or the user lacks write access.
731#[cfg(not(target_arch = "wasm32"))]
732#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
733#[allow(clippy::too_many_arguments)]
734pub async fn create_pull_request(
735    client: &Octocrab,
736    owner: &str,
737    repo: &str,
738    title: &str,
739    head_branch: &str,
740    base_branch: &str,
741    body: Option<&str>,
742    draft: bool,
743) -> anyhow::Result<PrCreateResult> {
744    debug!("Creating pull request");
745
746    let pr = client
747        .pulls(owner, repo)
748        .create(title, head_branch, base_branch)
749        .body(body.unwrap_or_default())
750        .draft(draft)
751        .send()
752        .await
753        .with_context(|| {
754            format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
755        })?;
756
757    let result = PrCreateResult {
758        pr_number: pr.number,
759        url: pr
760            .html_url
761            .as_ref()
762            .map(std::string::ToString::to_string)
763            .unwrap_or_default(),
764        branch: pr.head.ref_field.clone(),
765        base: pr.base.ref_field.clone(),
766        title: pr.title.clone().unwrap_or_default(),
767        draft: pr.draft.unwrap_or(false),
768        files_changed: u32::try_from(pr.changed_files.unwrap_or(0)).unwrap_or(u32::MAX),
769        additions: pr.additions.unwrap_or(0),
770        deletions: pr.deletions.unwrap_or(0),
771    };
772
773    debug!(
774        pr_number = result.pr_number,
775        "Pull request created successfully"
776    );
777
778    Ok(result)
779}
780
781/// Determines whether a file should be skipped during fetch based on status and patch.
782/// Emits a debug log with the skip reason. Returns true if the file should be skipped
783/// (removed status or no patch), false otherwise.
784fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
785    if status.to_lowercase().contains("removed") {
786        debug!(file = %filename, "Skipping removed file");
787        return true;
788    }
789    if patch.is_none_or(String::is_empty) {
790        debug!(file = %filename, "Skipping file with empty patch");
791        return true;
792    }
793    false
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::ai::types::CommentSeverity;
800
801    fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
802        use base64::Engine;
803        let engine = base64::engine::general_purpose::STANDARD;
804        let decoded_bytes = engine.decode(encoded).ok()?;
805        let decoded_str = String::from_utf8(decoded_bytes).ok()?;
806
807        if decoded_str.len() <= max_chars {
808            Some(decoded_str)
809        } else {
810            Some(decoded_str.chars().take(max_chars).collect::<String>())
811        }
812    }
813
814    #[test]
815    fn test_pr_create_result_fields() {
816        // Arrange / Act: construct directly (no network call needed)
817        let result = PrCreateResult {
818            pr_number: 42,
819            url: "https://github.com/owner/repo/pull/42".to_string(),
820            branch: "feat/my-feature".to_string(),
821            base: "main".to_string(),
822            title: "feat: add feature".to_string(),
823            draft: false,
824            files_changed: 3,
825            additions: 100,
826            deletions: 10,
827        };
828
829        // Assert
830        assert_eq!(result.pr_number, 42);
831        assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
832        assert_eq!(result.branch, "feat/my-feature");
833        assert_eq!(result.base, "main");
834        assert_eq!(result.title, "feat: add feature");
835        assert!(!result.draft);
836        assert_eq!(result.files_changed, 3);
837        assert_eq!(result.additions, 100);
838        assert_eq!(result.deletions, 10);
839    }
840
841    // ---------------------------------------------------------------------------
842    // post_pr_review payload construction
843    // ---------------------------------------------------------------------------
844
845    /// Helper: build the inline comments JSON array using the same logic as
846    /// `post_pr_review`, without making a live HTTP call.
847    fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
848        comments
849            .iter()
850            .filter_map(|c| {
851                c.line.map(|line| {
852                    serde_json::json!({
853                        "path": c.file,
854                        "line": line,
855                        "side": "RIGHT",
856                        "body": render_pr_review_comment_body(c),
857                    })
858                })
859            })
860            .collect()
861    }
862
863    #[test]
864    fn test_post_pr_review_payload_with_comments() {
865        // Arrange
866        let comments = vec![PrReviewComment {
867            file: "src/main.rs".to_string(),
868            line: Some(42),
869            comment: "Consider using a match here.".to_string(),
870            severity: CommentSeverity::Suggestion,
871            suggested_code: None,
872        }];
873
874        // Act
875        let inline = build_inline_comments(&comments);
876
877        // Assert
878        assert_eq!(inline.len(), 1);
879        assert_eq!(inline[0]["path"], "src/main.rs");
880        assert_eq!(inline[0]["line"], 42);
881        assert_eq!(inline[0]["side"], "RIGHT");
882        assert_eq!(inline[0]["body"], "Consider using a match here.");
883    }
884
885    #[test]
886    fn test_post_pr_review_skips_none_line_comments() {
887        // Arrange: one comment with a line, one without.
888        let comments = vec![
889            PrReviewComment {
890                file: "src/lib.rs".to_string(),
891                line: None,
892                comment: "General file comment.".to_string(),
893                severity: CommentSeverity::Info,
894                suggested_code: None,
895            },
896            PrReviewComment {
897                file: "src/lib.rs".to_string(),
898                line: Some(10),
899                comment: "Inline comment.".to_string(),
900                severity: CommentSeverity::Warning,
901                suggested_code: None,
902            },
903        ];
904
905        // Act
906        let inline = build_inline_comments(&comments);
907
908        // Assert: only the comment with a line is included.
909        assert_eq!(inline.len(), 1);
910        assert_eq!(inline[0]["line"], 10);
911    }
912
913    #[test]
914    fn test_post_pr_review_empty_comments() {
915        // Arrange
916        let comments: Vec<PrReviewComment> = vec![];
917
918        // Act
919        let inline = build_inline_comments(&comments);
920
921        // Assert: empty slice produces empty array, which serializes as [].
922        assert!(inline.is_empty());
923        let serialized = serde_json::to_string(&inline).unwrap();
924        assert_eq!(serialized, "[]");
925    }
926
927    // ---------------------------------------------------------------------------
928    // Existing tests
929    // ---------------------------------------------------------------------------
930
931    // Smoke test to verify parse_pr_reference delegates correctly.
932    // Comprehensive parsing tests are in github/mod.rs.
933    #[test]
934    fn test_parse_pr_reference_delegates_to_shared() {
935        let (owner, repo, number) =
936            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
937        assert_eq!(owner, "block");
938        assert_eq!(repo, "goose");
939        assert_eq!(number, 123);
940    }
941
942    #[test]
943    fn test_title_prefix_to_label_mapping() {
944        let cases = vec![
945            (
946                "feat: add new feature",
947                vec!["enhancement"],
948                "feat should map to enhancement",
949            ),
950            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
951            (
952                "docs: update readme",
953                vec!["documentation"],
954                "docs should map to documentation",
955            ),
956            (
957                "refactor: improve code",
958                vec!["refactor"],
959                "refactor should map to refactor",
960            ),
961            (
962                "perf: optimize",
963                vec!["enhancement"],
964                "perf should map to enhancement",
965            ),
966            (
967                "chore: update deps",
968                vec![],
969                "chore should produce no labels",
970            ),
971        ];
972
973        for (title, expected_labels, msg) in cases {
974            let labels = labels_from_pr_metadata(title, &[]);
975            for expected in &expected_labels {
976                assert!(
977                    labels.contains(&expected.to_string()),
978                    "{msg}: expected '{expected}' in {labels:?}",
979                );
980            }
981            if expected_labels.is_empty() {
982                assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
983            }
984        }
985    }
986
987    #[test]
988    fn test_file_path_to_scope_mapping() {
989        let cases = vec![
990            (
991                "feat: cli",
992                vec!["crates/aptu-cli/src/main.rs"],
993                vec!["enhancement", "cli"],
994                "cli path should map to cli scope",
995            ),
996            (
997                "feat: docs",
998                vec!["docs/GITHUB_ACTION.md"],
999                vec!["enhancement", "documentation"],
1000                "docs path should map to documentation scope",
1001            ),
1002            (
1003                "feat: workflow",
1004                vec![".github/workflows/test.yml"],
1005                vec!["enhancement"],
1006                "workflow path should be ignored",
1007            ),
1008        ];
1009
1010        for (title, paths, expected_labels, msg) in cases {
1011            let labels = labels_from_pr_metadata(
1012                title,
1013                &paths
1014                    .iter()
1015                    .map(std::string::ToString::to_string)
1016                    .collect::<Vec<_>>(),
1017            );
1018            for expected in expected_labels {
1019                assert!(
1020                    labels.contains(&expected.to_string()),
1021                    "{msg}: expected '{expected}' in {labels:?}",
1022                );
1023            }
1024        }
1025    }
1026
1027    #[test]
1028    fn test_combined_title_and_paths() {
1029        let labels = labels_from_pr_metadata(
1030            "feat: multi",
1031            &[
1032                "crates/aptu-cli/src/main.rs".to_string(),
1033                "docs/README.md".to_string(),
1034            ],
1035        );
1036        assert!(
1037            labels.contains(&"enhancement".to_string()),
1038            "should include enhancement from feat prefix"
1039        );
1040        assert!(
1041            labels.contains(&"cli".to_string()),
1042            "should include cli from path"
1043        );
1044        assert!(
1045            labels.contains(&"documentation".to_string()),
1046            "should include documentation from path"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_no_match_returns_empty() {
1052        let cases = vec![
1053            (
1054                "Random title",
1055                vec![],
1056                "unrecognized prefix should return empty",
1057            ),
1058            (
1059                "chore: update",
1060                vec![],
1061                "ignored prefix should return empty",
1062            ),
1063        ];
1064
1065        for (title, paths, msg) in cases {
1066            let labels = labels_from_pr_metadata(title, &paths);
1067            assert!(labels.is_empty(), "{msg}: got {labels:?}");
1068        }
1069    }
1070
1071    #[test]
1072    fn test_scoped_prefix_extracts_type() {
1073        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
1074        assert!(
1075            labels.contains(&"enhancement".to_string()),
1076            "scoped prefix should extract type from feat(cli)"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_duplicate_labels_deduplicated() {
1082        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
1083        assert_eq!(
1084            labels.len(),
1085            1,
1086            "should have exactly one label when title and path both map to documentation"
1087        );
1088        assert!(
1089            labels.contains(&"documentation".to_string()),
1090            "should contain documentation label"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_should_skip_file_respects_fetched_count_cap() {
1096        // Test that should_skip_file correctly identifies files to skip.
1097        // Files with removed status or no patch should be skipped.
1098        let removed_file = PrFile {
1099            filename: "removed.rs".to_string(),
1100            status: "removed".to_string(),
1101            additions: 0,
1102            deletions: 5,
1103            patch: None,
1104            patch_truncated: false,
1105            full_content: None,
1106        };
1107        let modified_file = PrFile {
1108            filename: "file_0.rs".to_string(),
1109            status: "modified".to_string(),
1110            additions: 1,
1111            deletions: 0,
1112            patch: Some("+ new code".to_string()),
1113            patch_truncated: false,
1114            full_content: None,
1115        };
1116        let no_patch_file = PrFile {
1117            filename: "file_1.rs".to_string(),
1118            status: "modified".to_string(),
1119            additions: 1,
1120            deletions: 0,
1121            patch: None,
1122            patch_truncated: false,
1123            full_content: None,
1124        };
1125
1126        // Assert: removed files are skipped
1127        assert!(
1128            should_skip_file(
1129                &removed_file.filename,
1130                &removed_file.status,
1131                removed_file.patch.as_ref()
1132            ),
1133            "removed files should be skipped"
1134        );
1135
1136        // Assert: modified files with patch are not skipped
1137        assert!(
1138            !should_skip_file(
1139                &modified_file.filename,
1140                &modified_file.status,
1141                modified_file.patch.as_ref()
1142            ),
1143            "modified files with patch should not be skipped"
1144        );
1145
1146        // Assert: files without patch are skipped
1147        assert!(
1148            should_skip_file(
1149                &no_patch_file.filename,
1150                &no_patch_file.status,
1151                no_patch_file.patch.as_ref()
1152            ),
1153            "files without patch should be skipped"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_decode_content_valid_base64() {
1159        // Arrange: valid base64-encoded string
1160        use base64::Engine;
1161        let engine = base64::engine::general_purpose::STANDARD;
1162        let original = "Hello, World!";
1163        let encoded = engine.encode(original);
1164
1165        // Act: decode with sufficient max_chars
1166        let result = decode_content(&encoded, 1000);
1167
1168        // Assert: decoding succeeds and matches original
1169        assert_eq!(
1170            result,
1171            Some(original.to_string()),
1172            "valid base64 should decode successfully"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_decode_content_invalid_base64() {
1178        // Arrange: invalid base64 string
1179        let invalid_base64 = "!!!invalid!!!";
1180
1181        // Act: attempt to decode
1182        let result = decode_content(invalid_base64, 1000);
1183
1184        // Assert: decoding fails gracefully
1185        assert_eq!(result, None, "invalid base64 should return None");
1186    }
1187
1188    #[test]
1189    fn test_decode_content_truncates_at_max_chars() {
1190        // Arrange: multi-byte UTF-8 string (Japanese characters)
1191        use base64::Engine;
1192        let engine = base64::engine::general_purpose::STANDARD;
1193        let original = "こんにちは".repeat(10); // 50 characters total
1194        let encoded = engine.encode(&original);
1195        let max_chars = 10;
1196
1197        // Act: decode with max_chars limit
1198        let result = decode_content(&encoded, max_chars);
1199
1200        // Assert: result is truncated to max_chars on character boundary
1201        assert!(result.is_some(), "decoding should succeed");
1202        let decoded = result.unwrap();
1203        assert_eq!(
1204            decoded.chars().count(),
1205            max_chars,
1206            "output should be truncated to max_chars on character boundary"
1207        );
1208        assert!(
1209            decoded.is_char_boundary(decoded.len()),
1210            "output should be valid UTF-8 (truncated on char boundary)"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_list_files_pagination_collects_all_pages() {
1216        // Arrange: simulate pagination with two pages
1217        // Page 1: 100 items with next_link set
1218        let mut page1_items = Vec::new();
1219        for i in 0..100 {
1220            page1_items.push(PrFile {
1221                filename: format!("file{}.rs", i),
1222                status: "modified".to_string(),
1223                additions: 1,
1224                deletions: 0,
1225                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1226                patch_truncated: false,
1227                full_content: None,
1228            });
1229        }
1230
1231        // Page 2: 50 items with no next_link
1232        let mut page2_items = Vec::new();
1233        for i in 100..150 {
1234            page2_items.push(PrFile {
1235                filename: format!("file{}.rs", i),
1236                status: "modified".to_string(),
1237                additions: 1,
1238                deletions: 0,
1239                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1240                patch_truncated: false,
1241                full_content: None,
1242            });
1243        }
1244
1245        // Act: collect all items (simulating pagination loop)
1246        let mut all_files = Vec::new();
1247        all_files.extend(page1_items);
1248        all_files.extend(page2_items);
1249
1250        // Assert: total collected == 150
1251        assert_eq!(
1252            all_files.len(),
1253            150,
1254            "pagination should collect all items from both pages"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_list_files_pagination_respects_300_file_cap() {
1260        // Arrange: build a Vec of 301 PrFile items
1261        let mut files = Vec::new();
1262        for i in 0..301 {
1263            files.push(PrFile {
1264                filename: format!("file{}.rs", i),
1265                status: "modified".to_string(),
1266                additions: 1,
1267                deletions: 0,
1268                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
1269                patch_truncated: false,
1270                full_content: None,
1271            });
1272        }
1273
1274        // Act: apply the 300-file cap (simulating the truncate logic)
1275        if files.len() >= 300 {
1276            files.truncate(300);
1277        }
1278
1279        // Assert: result.len() == 300
1280        assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
1281    }
1282
1283    #[test]
1284    fn test_is_patch_truncated_detects_mid_hunk_plus() {
1285        // Test: patch ending with '+' (mid-hunk truncation)
1286        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
1287        assert!(
1288            is_patch_truncated(truncated_patch),
1289            "patch ending with + should be detected as truncated"
1290        );
1291    }
1292
1293    #[test]
1294    fn test_is_patch_truncated_detects_mid_hunk_minus() {
1295        // Test: patch ending with '-' (mid-hunk truncation)
1296        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
1297        assert!(
1298            is_patch_truncated(truncated_patch),
1299            "patch ending with - should be detected as truncated"
1300        );
1301    }
1302
1303    #[test]
1304    fn test_is_patch_truncated_clean_patch_context_line() {
1305        // Test: patch ending with ' ' (context line, not truncated)
1306        let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1307        assert!(
1308            !is_patch_truncated(clean_patch),
1309            "patch ending with context line should not be detected as truncated"
1310        );
1311    }
1312
1313    #[test]
1314    fn test_is_patch_truncated_correct_hunk_line_count() {
1315        // Test: patch with correct hunk line count (declared 3, actual 3)
1316        let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
1317        assert!(
1318            !is_patch_truncated(clean_patch),
1319            "patch with correct hunk line count should not be detected as truncated"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
1325        // Test: patch with declared hunk size larger than delivered lines
1326        // Declared: +1,4 (4 lines in new file), Actual: only 2 lines delivered
1327        let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
1328        assert!(
1329            is_patch_truncated(truncated_patch),
1330            "patch with declared hunk size larger than delivered should be detected as truncated"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
1336        // Test: patch with no @@ header but last line is '+'
1337        let truncated_patch = "line1\nline2\n+";
1338        assert!(
1339            is_patch_truncated(truncated_patch),
1340            "patch with no @@ header but ending with + should be detected as truncated"
1341        );
1342    }
1343
1344    #[test]
1345    fn test_is_patch_truncated_empty_patch() {
1346        // Test: empty patch
1347        let empty_patch = "";
1348        assert!(
1349            !is_patch_truncated(empty_patch),
1350            "empty patch should not be detected as truncated"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
1356        // Test: multiple hunks where the last hunk is truncated
1357        let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
1358        assert!(
1359            is_patch_truncated(truncated_patch),
1360            "patch with last hunk truncated should be detected as truncated"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_pr_file_status_case_insensitive_added() {
1366        // Test: Added file status is matched case-insensitively
1367        let file = PrFile {
1368            filename: "new.rs".to_string(),
1369            status: "Added".to_string(), // Debug repr from Octocrab
1370            additions: 50,
1371            deletions: 0,
1372            patch: Some("new code".to_string()),
1373            patch_truncated: false,
1374            full_content: None,
1375        };
1376
1377        let is_added_renamed_copied = matches!(
1378            file.status.to_lowercase().as_str(),
1379            "added" | "renamed" | "copied"
1380        );
1381        assert!(is_added_renamed_copied, "Added status should be recognized");
1382    }
1383
1384    #[test]
1385    fn test_pr_file_status_case_insensitive_modified() {
1386        // Test: Modified file status is NOT matched (edge case)
1387        let file = PrFile {
1388            filename: "existing.rs".to_string(),
1389            status: "Modified".to_string(),
1390            additions: 10,
1391            deletions: 5,
1392            patch: Some("modified code".to_string()),
1393            patch_truncated: false,
1394            full_content: None,
1395        };
1396
1397        let is_added_renamed_copied = matches!(
1398            file.status.to_lowercase().as_str(),
1399            "added" | "renamed" | "copied"
1400        );
1401        assert!(
1402            !is_added_renamed_copied,
1403            "Modified status should NOT be recognized as added/renamed/copied"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_pr_file_oversized_patch_detection() {
1409        // Test: Patch size is compared against max_patch_chars_per_file.
1410        // Derive the limit from ReviewConfig::default() -- single source of truth.
1411        let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1412        let patch = "a".repeat(max_patch_chars + 5_000); // Clearly exceeds limit
1413
1414        let patch_too_large = patch.len() > max_patch_chars;
1415        assert!(
1416            patch_too_large,
1417            "patch exceeding the default limit should be detected as oversized"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_pr_file_dedup_guard_full_content_present() {
1423        // Test: File with full_content already populated should skip Contents API call
1424        let file = PrFile {
1425            filename: "new.rs".to_string(),
1426            status: "Added".to_string(),
1427            additions: 50,
1428            deletions: 0,
1429            patch: Some("new code".to_string()),
1430            patch_truncated: false,
1431            full_content: Some("full content from Contents API".to_string()),
1432        };
1433
1434        let should_fetch = file.full_content.is_none();
1435        assert!(
1436            !should_fetch,
1437            "File with full_content should not be fetched again (dedup guard)"
1438        );
1439    }
1440
1441    #[test]
1442    fn test_pr_file_contents_api_fallback_flow() {
1443        // Test: Verify the three conditions for Contents API fallback:
1444        // 1. status is Added/Renamed/Copied
1445        // 2. patch size exceeds max_patch_chars_per_file
1446        // 3. full_content is None
1447        // Derive the limit from ReviewConfig::default() -- single source of truth.
1448        let max_patch_chars = crate::config::ReviewConfig::default().max_patch_chars_per_file;
1449
1450        let file = PrFile {
1451            filename: "new.rs".to_string(),
1452            status: "Added".to_string(),
1453            additions: 50,
1454            deletions: 0,
1455            patch: Some("a".repeat(max_patch_chars + 5_000)), // Clearly exceeds limit
1456            patch_truncated: false,
1457            full_content: None, // Not yet fetched
1458        };
1459
1460        let is_added_renamed_copied = matches!(
1461            file.status.to_lowercase().as_str(),
1462            "added" | "renamed" | "copied"
1463        );
1464        let patch_too_large = file.patch.as_deref().map_or(0, str::len) > max_patch_chars;
1465        let should_attempt_contents_api =
1466            is_added_renamed_copied && patch_too_large && file.full_content.is_none();
1467
1468        assert!(
1469            should_attempt_contents_api,
1470            "Added file with 30k patch and no full_content should attempt Contents API"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_merge_preserves_existing_full_content() {
1476        // Arrange: create a PrFile with full_content = Some("fallback content"),
1477        // pair it with content = None (simulating fetch_file_contents returning None beyond the cap)
1478        let mut file = PrFile {
1479            filename: "test.rs".to_string(),
1480            status: "modified".to_string(),
1481            additions: 5,
1482            deletions: 2,
1483            patch: Some("@@ -1,1 +1,1 @@".to_string()),
1484            patch_truncated: false,
1485            full_content: Some("fallback content".to_string()),
1486        };
1487        let content = None;
1488
1489        // Act: apply the fixed merge logic
1490        if file.full_content.is_none() {
1491            file.full_content = content;
1492        }
1493
1494        // Assert: file.full_content == Some("fallback content")
1495        assert_eq!(file.full_content, Some("fallback content".to_string()));
1496    }
1497
1498    #[test]
1499    fn test_merge_sets_full_content_when_none() {
1500        // Arrange: PrFile with full_content = None, content = Some("fetched content")
1501        let mut file = PrFile {
1502            filename: "test.rs".to_string(),
1503            status: "modified".to_string(),
1504            additions: 5,
1505            deletions: 2,
1506            patch: Some("@@ -1,1 +1,1 @@".to_string()),
1507            patch_truncated: false,
1508            full_content: None,
1509        };
1510        let content = Some("fetched content".to_string());
1511
1512        // Act: apply merge logic
1513        if file.full_content.is_none() {
1514            file.full_content = content;
1515        }
1516
1517        // Assert: file.full_content == Some("fetched content")
1518        assert_eq!(file.full_content, Some("fetched content".to_string()));
1519    }
1520
1521    #[test]
1522    fn test_fetch_file_contents_fallback_on_truncated_patch() {
1523        // Note: The Contents API network call cannot be unit-tested without a mock.
1524        // The fallback is exercised in integration tests via the full fetch_pr_details flow.
1525        // Unit tests for is_patch_truncated are above.
1526        // New unit tests for the added/renamed/copied Contents API fallback:
1527        // - test_pr_file_status_case_insensitive_added
1528        // - test_pr_file_status_case_insensitive_modified
1529        // - test_pr_file_oversized_patch_detection
1530        // - test_pr_file_dedup_guard_full_content_present
1531        // - test_pr_file_contents_api_fallback_flow
1532    }
1533}