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))]
85pub async fn fetch_pr_details(
86    client: &Octocrab,
87    owner: &str,
88    repo: &str,
89    number: u64,
90) -> Result<PrDetails> {
91    debug!("Fetching PR details");
92
93    // Fetch PR metadata
94    let pr = match client.pulls(owner, repo).get(number).await {
95        Ok(pr) => pr,
96        Err(e) => {
97            // Check if this is a 404 error and if an issue exists instead
98            if let octocrab::Error::GitHub { source, .. } = &e
99                && source.status_code == 404
100            {
101                // Try to fetch as an issue to provide a better error message
102                if (client.issues(owner, repo).get(number).await).is_ok() {
103                    return Err(AptuError::TypeMismatch {
104                        number,
105                        expected: ResourceType::PullRequest,
106                        actual: ResourceType::Issue,
107                    }
108                    .into());
109                }
110                // Issue check failed, fall back to original error
111            }
112            return Err(e)
113                .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
114        }
115    };
116
117    // Fetch PR files (diffs)
118    let files = client
119        .pulls(owner, repo)
120        .list_files(number)
121        .await
122        .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
123
124    // Convert to our types
125    let pr_files: Vec<PrFile> = files
126        .items
127        .into_iter()
128        .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        })
135        .collect();
136
137    let labels: Vec<String> = pr
138        .labels
139        .iter()
140        .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
141        .collect();
142
143    let details = PrDetails {
144        owner: owner.to_string(),
145        repo: repo.to_string(),
146        number,
147        title: pr.title.unwrap_or_default(),
148        body: pr.body.unwrap_or_default(),
149        base_branch: pr.base.ref_field,
150        head_branch: pr.head.ref_field,
151        head_sha: pr.head.sha,
152        files: pr_files,
153        url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
154        labels,
155    };
156
157    debug!(
158        file_count = details.files.len(),
159        "PR details fetched successfully"
160    );
161
162    Ok(details)
163}
164
165/// Posts a PR review to GitHub.
166///
167/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
168/// Requires write access to the repository.
169///
170/// # Arguments
171///
172/// * `client` - Authenticated Octocrab client
173/// * `owner` - Repository owner
174/// * `repo` - Repository name
175/// * `number` - PR number
176/// * `body` - Review comment text
177/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
178/// * `comments` - Inline review comments to attach; entries with `line = None` are silently skipped
179/// * `commit_id` - Head commit SHA to associate with the review; omitted from payload if empty
180///
181/// # Returns
182///
183/// Review ID on success.
184///
185/// # Errors
186///
187/// Returns an error if the API call fails, user lacks write access, or PR is not found.
188#[allow(clippy::too_many_arguments)]
189#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
190pub async fn post_pr_review(
191    client: &Octocrab,
192    owner: &str,
193    repo: &str,
194    number: u64,
195    body: &str,
196    event: ReviewEvent,
197    comments: &[PrReviewComment],
198    commit_id: &str,
199) -> Result<u64> {
200    debug!("Posting PR review");
201
202    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
203
204    // Build inline comments array; skip entries without a line number.
205    let inline_comments: Vec<serde_json::Value> = comments
206        .iter()
207        // Comments without a line number cannot be anchored to the diff; skip silently.
208        .filter_map(|c| {
209            c.line.map(|line| {
210                serde_json::json!({
211                    "path": c.file,
212                    "line": line,
213                    // RIGHT = new version of the file (added/changed lines).
214                    // Use line (file line number) rather than the deprecated
215                    // position (diff hunk offset) so no hunk parsing is needed.
216                    "side": "RIGHT",
217                    "body": render_pr_review_comment_body(c),
218                })
219            })
220        })
221        .collect();
222
223    let mut payload = serde_json::json!({
224        "body": body,
225        "event": event.to_string(),
226        "comments": inline_comments,
227    });
228
229    // commit_id is optional; include only when non-empty.
230    if !commit_id.is_empty() {
231        payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
232    }
233
234    #[derive(serde::Deserialize)]
235    struct ReviewResponse {
236        id: u64,
237    }
238
239    let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
240        format!(
241            "Failed to post review to PR #{number} in {owner}/{repo}. \
242                 Check that you have write access to the repository."
243        )
244    })?;
245
246    debug!(review_id = response.id, "PR review posted successfully");
247
248    Ok(response.id)
249}
250
251/// Extract labels from PR metadata (title and file paths).
252///
253/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
254/// Returns a vector of label names to apply to the PR.
255///
256/// # Arguments
257/// * `title` - PR title (may contain conventional commit prefix)
258/// * `file_paths` - List of file paths changed in the PR
259///
260/// # Returns
261/// Vector of label names to apply
262#[must_use]
263pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
264    let mut labels = std::collections::HashSet::new();
265
266    // Extract conventional commit prefix from title
267    // Handle both "feat: ..." and "feat(scope): ..." formats
268    let prefix = title
269        .split(':')
270        .next()
271        .unwrap_or("")
272        .split('(')
273        .next()
274        .unwrap_or("")
275        .trim();
276
277    // Map conventional commit type to label
278    let type_label = match prefix {
279        "feat" | "perf" => Some("enhancement"),
280        "fix" => Some("bug"),
281        "docs" => Some("documentation"),
282        "refactor" => Some("refactor"),
283        _ => None,
284    };
285
286    if let Some(label) = type_label {
287        labels.insert(label.to_string());
288    }
289
290    // Map file paths to scope labels
291    for path in file_paths {
292        let scope = if path.starts_with("crates/aptu-cli/") {
293            Some("cli")
294        } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
295            Some("ios")
296        } else if path.starts_with("docs/") {
297            Some("documentation")
298        } else if path.starts_with("snap/") {
299            Some("distribution")
300        } else {
301            None
302        };
303
304        if let Some(label) = scope {
305            labels.insert(label.to_string());
306        }
307    }
308
309    labels.into_iter().collect()
310}
311
312/// Creates a pull request on GitHub.
313///
314/// # Arguments
315///
316/// * `client` - Authenticated Octocrab client
317/// * `owner` - Repository owner
318/// * `repo` - Repository name
319/// * `title` - PR title
320/// * `head_branch` - Head branch (the branch with changes)
321/// * `base_branch` - Base branch (the branch to merge into)
322/// * `body` - Optional PR body text
323///
324/// # Returns
325///
326/// `PrCreateResult` with PR metadata.
327///
328/// # Errors
329///
330/// Returns an error if the API call fails or the user lacks write access.
331#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
332pub async fn create_pull_request(
333    client: &Octocrab,
334    owner: &str,
335    repo: &str,
336    title: &str,
337    head_branch: &str,
338    base_branch: &str,
339    body: Option<&str>,
340) -> anyhow::Result<PrCreateResult> {
341    debug!("Creating pull request");
342
343    let pr = client
344        .pulls(owner, repo)
345        .create(title, head_branch, base_branch)
346        .body(body.unwrap_or_default())
347        .draft(false)
348        .send()
349        .await
350        .with_context(|| {
351            format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
352        })?;
353
354    let result = PrCreateResult {
355        pr_number: pr.number,
356        url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
357        branch: pr.head.ref_field,
358        base: pr.base.ref_field,
359        title: pr.title.unwrap_or_default(),
360        draft: pr.draft.unwrap_or(false),
361        files_changed: u32::try_from(pr.changed_files.unwrap_or_default()).unwrap_or(u32::MAX),
362        additions: pr.additions.unwrap_or_default(),
363        deletions: pr.deletions.unwrap_or_default(),
364    };
365
366    debug!(
367        pr_number = result.pr_number,
368        "Pull request created successfully"
369    );
370
371    Ok(result)
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::ai::types::CommentSeverity;
378
379    #[test]
380    fn test_pr_create_result_fields() {
381        // Arrange / Act: construct directly (no network call needed)
382        let result = PrCreateResult {
383            pr_number: 42,
384            url: "https://github.com/owner/repo/pull/42".to_string(),
385            branch: "feat/my-feature".to_string(),
386            base: "main".to_string(),
387            title: "feat: add feature".to_string(),
388            draft: false,
389            files_changed: 3,
390            additions: 100,
391            deletions: 10,
392        };
393
394        // Assert
395        assert_eq!(result.pr_number, 42);
396        assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
397        assert_eq!(result.branch, "feat/my-feature");
398        assert_eq!(result.base, "main");
399        assert_eq!(result.title, "feat: add feature");
400        assert!(!result.draft);
401        assert_eq!(result.files_changed, 3);
402        assert_eq!(result.additions, 100);
403        assert_eq!(result.deletions, 10);
404    }
405
406    // ---------------------------------------------------------------------------
407    // post_pr_review payload construction
408    // ---------------------------------------------------------------------------
409
410    /// Helper: build the inline comments JSON array using the same logic as
411    /// `post_pr_review`, without making a live HTTP call.
412    fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
413        comments
414            .iter()
415            .filter_map(|c| {
416                c.line.map(|line| {
417                    serde_json::json!({
418                        "path": c.file,
419                        "line": line,
420                        "side": "RIGHT",
421                        "body": render_pr_review_comment_body(c),
422                    })
423                })
424            })
425            .collect()
426    }
427
428    #[test]
429    fn test_post_pr_review_payload_with_comments() {
430        // Arrange
431        let comments = vec![PrReviewComment {
432            file: "src/main.rs".to_string(),
433            line: Some(42),
434            comment: "Consider using a match here.".to_string(),
435            severity: CommentSeverity::Suggestion,
436            suggested_code: None,
437        }];
438
439        // Act
440        let inline = build_inline_comments(&comments);
441
442        // Assert
443        assert_eq!(inline.len(), 1);
444        assert_eq!(inline[0]["path"], "src/main.rs");
445        assert_eq!(inline[0]["line"], 42);
446        assert_eq!(inline[0]["side"], "RIGHT");
447        assert_eq!(inline[0]["body"], "Consider using a match here.");
448    }
449
450    #[test]
451    fn test_post_pr_review_skips_none_line_comments() {
452        // Arrange: one comment with a line, one without.
453        let comments = vec![
454            PrReviewComment {
455                file: "src/lib.rs".to_string(),
456                line: None,
457                comment: "General file comment.".to_string(),
458                severity: CommentSeverity::Info,
459                suggested_code: None,
460            },
461            PrReviewComment {
462                file: "src/lib.rs".to_string(),
463                line: Some(10),
464                comment: "Inline comment.".to_string(),
465                severity: CommentSeverity::Warning,
466                suggested_code: None,
467            },
468        ];
469
470        // Act
471        let inline = build_inline_comments(&comments);
472
473        // Assert: only the comment with a line is included.
474        assert_eq!(inline.len(), 1);
475        assert_eq!(inline[0]["line"], 10);
476    }
477
478    #[test]
479    fn test_post_pr_review_empty_comments() {
480        // Arrange
481        let comments: Vec<PrReviewComment> = vec![];
482
483        // Act
484        let inline = build_inline_comments(&comments);
485
486        // Assert: empty slice produces empty array, which serializes as [].
487        assert!(inline.is_empty());
488        let serialized = serde_json::to_string(&inline).unwrap();
489        assert_eq!(serialized, "[]");
490    }
491
492    // ---------------------------------------------------------------------------
493    // Existing tests
494    // ---------------------------------------------------------------------------
495
496    // Smoke test to verify parse_pr_reference delegates correctly.
497    // Comprehensive parsing tests are in github/mod.rs.
498    #[test]
499    fn test_parse_pr_reference_delegates_to_shared() {
500        let (owner, repo, number) =
501            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
502        assert_eq!(owner, "block");
503        assert_eq!(repo, "goose");
504        assert_eq!(number, 123);
505    }
506
507    #[test]
508    fn test_title_prefix_to_label_mapping() {
509        let cases = vec![
510            (
511                "feat: add new feature",
512                vec!["enhancement"],
513                "feat should map to enhancement",
514            ),
515            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
516            (
517                "docs: update readme",
518                vec!["documentation"],
519                "docs should map to documentation",
520            ),
521            (
522                "refactor: improve code",
523                vec!["refactor"],
524                "refactor should map to refactor",
525            ),
526            (
527                "perf: optimize",
528                vec!["enhancement"],
529                "perf should map to enhancement",
530            ),
531            (
532                "chore: update deps",
533                vec![],
534                "chore should produce no labels",
535            ),
536        ];
537
538        for (title, expected_labels, msg) in cases {
539            let labels = labels_from_pr_metadata(title, &[]);
540            for expected in &expected_labels {
541                assert!(
542                    labels.contains(&expected.to_string()),
543                    "{msg}: expected '{expected}' in {labels:?}",
544                );
545            }
546            if expected_labels.is_empty() {
547                assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
548            }
549        }
550    }
551
552    #[test]
553    fn test_file_path_to_scope_mapping() {
554        let cases = vec![
555            (
556                "feat: cli",
557                vec!["crates/aptu-cli/src/main.rs"],
558                vec!["enhancement", "cli"],
559                "cli path should map to cli scope",
560            ),
561            (
562                "feat: ios",
563                vec!["crates/aptu-ffi/src/lib.rs"],
564                vec!["enhancement", "ios"],
565                "ffi path should map to ios scope",
566            ),
567            (
568                "feat: ios",
569                vec!["AptuApp/ContentView.swift"],
570                vec!["enhancement", "ios"],
571                "app path should map to ios scope",
572            ),
573            (
574                "feat: docs",
575                vec!["docs/GITHUB_ACTION.md"],
576                vec!["enhancement", "documentation"],
577                "docs path should map to documentation scope",
578            ),
579            (
580                "feat: snap",
581                vec!["snap/snapcraft.yaml"],
582                vec!["enhancement", "distribution"],
583                "snap path should map to distribution scope",
584            ),
585            (
586                "feat: workflow",
587                vec![".github/workflows/test.yml"],
588                vec!["enhancement"],
589                "workflow path should be ignored",
590            ),
591        ];
592
593        for (title, paths, expected_labels, msg) in cases {
594            let labels = labels_from_pr_metadata(
595                title,
596                &paths
597                    .iter()
598                    .map(std::string::ToString::to_string)
599                    .collect::<Vec<_>>(),
600            );
601            for expected in expected_labels {
602                assert!(
603                    labels.contains(&expected.to_string()),
604                    "{msg}: expected '{expected}' in {labels:?}",
605                );
606            }
607        }
608    }
609
610    #[test]
611    fn test_combined_title_and_paths() {
612        let labels = labels_from_pr_metadata(
613            "feat: multi",
614            &[
615                "crates/aptu-cli/src/main.rs".to_string(),
616                "docs/README.md".to_string(),
617            ],
618        );
619        assert!(
620            labels.contains(&"enhancement".to_string()),
621            "should include enhancement from feat prefix"
622        );
623        assert!(
624            labels.contains(&"cli".to_string()),
625            "should include cli from path"
626        );
627        assert!(
628            labels.contains(&"documentation".to_string()),
629            "should include documentation from path"
630        );
631    }
632
633    #[test]
634    fn test_no_match_returns_empty() {
635        let cases = vec![
636            (
637                "Random title",
638                vec![],
639                "unrecognized prefix should return empty",
640            ),
641            (
642                "chore: update",
643                vec![],
644                "ignored prefix should return empty",
645            ),
646        ];
647
648        for (title, paths, msg) in cases {
649            let labels = labels_from_pr_metadata(title, &paths);
650            assert!(labels.is_empty(), "{msg}: got {labels:?}");
651        }
652    }
653
654    #[test]
655    fn test_scoped_prefix_extracts_type() {
656        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
657        assert!(
658            labels.contains(&"enhancement".to_string()),
659            "scoped prefix should extract type from feat(cli)"
660        );
661    }
662
663    #[test]
664    fn test_duplicate_labels_deduplicated() {
665        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
666        assert_eq!(
667            labels.len(),
668            1,
669            "should have exactly one label when title and path both map to documentation"
670        );
671        assert!(
672            labels.contains(&"documentation".to_string()),
673            "should contain documentation label"
674        );
675    }
676}