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