Skip to main content

aptu_core/github/
pulls.rs

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