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