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};
15
16/// Parses a PR reference into (owner, repo, number).
17///
18/// Supports multiple formats:
19/// - Full URL: `https://github.com/owner/repo/pull/123`
20/// - Short form: `owner/repo#123`
21/// - Bare number: `123` (requires `repo_context`)
22///
23/// # Arguments
24///
25/// * `reference` - PR reference string
26/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
27///
28/// # Returns
29///
30/// Tuple of (owner, repo, number)
31///
32/// # Errors
33///
34/// Returns an error if the reference format is invalid or `repo_context` is missing for bare numbers.
35pub fn parse_pr_reference(
36    reference: &str,
37    repo_context: Option<&str>,
38) -> Result<(String, String, u64)> {
39    parse_github_reference(ReferenceKind::Pull, reference, repo_context)
40}
41
42/// Fetches PR details including file diffs from GitHub.
43///
44/// Uses Octocrab to fetch PR metadata and file changes.
45///
46/// # Arguments
47///
48/// * `client` - Authenticated Octocrab client
49/// * `owner` - Repository owner
50/// * `repo` - Repository name
51/// * `number` - PR number
52///
53/// # Returns
54///
55/// `PrDetails` struct with PR metadata and file diffs.
56///
57/// # Errors
58///
59/// Returns an error if the API call fails or PR is not found.
60#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
61pub async fn fetch_pr_details(
62    client: &Octocrab,
63    owner: &str,
64    repo: &str,
65    number: u64,
66) -> Result<PrDetails> {
67    debug!("Fetching PR details");
68
69    // Fetch PR metadata
70    let pr = match client.pulls(owner, repo).get(number).await {
71        Ok(pr) => pr,
72        Err(e) => {
73            // Check if this is a 404 error and if an issue exists instead
74            if let octocrab::Error::GitHub { source, .. } = &e
75                && source.status_code == 404
76            {
77                // Try to fetch as an issue to provide a better error message
78                if (client.issues(owner, repo).get(number).await).is_ok() {
79                    return Err(AptuError::TypeMismatch {
80                        number,
81                        expected: ResourceType::PullRequest,
82                        actual: ResourceType::Issue,
83                    }
84                    .into());
85                }
86                // Issue check failed, fall back to original error
87            }
88            return Err(e)
89                .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
90        }
91    };
92
93    // Fetch PR files (diffs)
94    let files = client
95        .pulls(owner, repo)
96        .list_files(number)
97        .await
98        .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
99
100    // Convert to our types
101    let pr_files: Vec<PrFile> = files
102        .items
103        .into_iter()
104        .map(|f| PrFile {
105            filename: f.filename,
106            status: format!("{:?}", f.status),
107            additions: f.additions,
108            deletions: f.deletions,
109            patch: f.patch,
110        })
111        .collect();
112
113    let labels: Vec<String> = pr
114        .labels
115        .iter()
116        .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
117        .collect();
118
119    let details = PrDetails {
120        owner: owner.to_string(),
121        repo: repo.to_string(),
122        number,
123        title: pr.title.unwrap_or_default(),
124        body: pr.body.unwrap_or_default(),
125        base_branch: pr.base.ref_field,
126        head_branch: pr.head.ref_field,
127        head_sha: pr.head.sha,
128        files: pr_files,
129        url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
130        labels,
131    };
132
133    debug!(
134        file_count = details.files.len(),
135        "PR details fetched successfully"
136    );
137
138    Ok(details)
139}
140
141/// Posts a PR review to GitHub.
142///
143/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
144/// Requires write access to the repository.
145///
146/// # Arguments
147///
148/// * `client` - Authenticated Octocrab client
149/// * `owner` - Repository owner
150/// * `repo` - Repository name
151/// * `number` - PR number
152/// * `body` - Review comment text
153/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
154/// * `comments` - Inline review comments to attach; entries with `line = None` are silently skipped
155/// * `commit_id` - Head commit SHA to associate with the review; omitted from payload if empty
156///
157/// # Returns
158///
159/// Review ID on success.
160///
161/// # Errors
162///
163/// Returns an error if the API call fails, user lacks write access, or PR is not found.
164#[allow(clippy::too_many_arguments)]
165#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
166pub async fn post_pr_review(
167    client: &Octocrab,
168    owner: &str,
169    repo: &str,
170    number: u64,
171    body: &str,
172    event: ReviewEvent,
173    comments: &[PrReviewComment],
174    commit_id: &str,
175) -> Result<u64> {
176    debug!("Posting PR review");
177
178    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
179
180    // Build inline comments array; skip entries without a line number.
181    let inline_comments: Vec<serde_json::Value> = comments
182        .iter()
183        // Comments without a line number cannot be anchored to the diff; skip silently.
184        .filter_map(|c| {
185            c.line.map(|line| {
186                serde_json::json!({
187                    "path": c.file,
188                    "line": line,
189                    // RIGHT = new version of the file (added/changed lines).
190                    // Use line (file line number) rather than the deprecated
191                    // position (diff hunk offset) so no hunk parsing is needed.
192                    "side": "RIGHT",
193                    "body": c.comment,
194                })
195            })
196        })
197        .collect();
198
199    let mut payload = serde_json::json!({
200        "body": body,
201        "event": event.to_string(),
202        "comments": inline_comments,
203    });
204
205    // commit_id is optional; include only when non-empty.
206    if !commit_id.is_empty() {
207        payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
208    }
209
210    #[derive(serde::Deserialize)]
211    struct ReviewResponse {
212        id: u64,
213    }
214
215    let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
216        format!(
217            "Failed to post review to PR #{number} in {owner}/{repo}. \
218                 Check that you have write access to the repository."
219        )
220    })?;
221
222    debug!(review_id = response.id, "PR review posted successfully");
223
224    Ok(response.id)
225}
226
227/// Extract labels from PR metadata (title and file paths).
228///
229/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
230/// Returns a vector of label names to apply to the PR.
231///
232/// # Arguments
233/// * `title` - PR title (may contain conventional commit prefix)
234/// * `file_paths` - List of file paths changed in the PR
235///
236/// # Returns
237/// Vector of label names to apply
238#[must_use]
239pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
240    let mut labels = std::collections::HashSet::new();
241
242    // Extract conventional commit prefix from title
243    // Handle both "feat: ..." and "feat(scope): ..." formats
244    let prefix = title
245        .split(':')
246        .next()
247        .unwrap_or("")
248        .split('(')
249        .next()
250        .unwrap_or("")
251        .trim();
252
253    // Map conventional commit type to label
254    let type_label = match prefix {
255        "feat" | "perf" => Some("enhancement"),
256        "fix" => Some("bug"),
257        "docs" => Some("documentation"),
258        "refactor" => Some("refactor"),
259        _ => None,
260    };
261
262    if let Some(label) = type_label {
263        labels.insert(label.to_string());
264    }
265
266    // Map file paths to scope labels
267    for path in file_paths {
268        let scope = if path.starts_with("crates/aptu-cli/") {
269            Some("cli")
270        } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
271            Some("ios")
272        } else if path.starts_with("docs/") {
273            Some("documentation")
274        } else if path.starts_with("snap/") {
275            Some("distribution")
276        } else {
277            None
278        };
279
280        if let Some(label) = scope {
281            labels.insert(label.to_string());
282        }
283    }
284
285    labels.into_iter().collect()
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::ai::types::CommentSeverity;
292
293    // ---------------------------------------------------------------------------
294    // post_pr_review payload construction
295    // ---------------------------------------------------------------------------
296
297    /// Helper: build the inline comments JSON array using the same logic as
298    /// `post_pr_review`, without making a live HTTP call.
299    fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
300        comments
301            .iter()
302            .filter_map(|c| {
303                c.line.map(|line| {
304                    serde_json::json!({
305                        "path": c.file,
306                        "line": line,
307                        "side": "RIGHT",
308                        "body": c.comment,
309                    })
310                })
311            })
312            .collect()
313    }
314
315    #[test]
316    fn test_post_pr_review_payload_with_comments() {
317        // Arrange
318        let comments = vec![PrReviewComment {
319            file: "src/main.rs".to_string(),
320            line: Some(42),
321            comment: "Consider using a match here.".to_string(),
322            severity: CommentSeverity::Suggestion,
323        }];
324
325        // Act
326        let inline = build_inline_comments(&comments);
327
328        // Assert
329        assert_eq!(inline.len(), 1);
330        assert_eq!(inline[0]["path"], "src/main.rs");
331        assert_eq!(inline[0]["line"], 42);
332        assert_eq!(inline[0]["side"], "RIGHT");
333        assert_eq!(inline[0]["body"], "Consider using a match here.");
334    }
335
336    #[test]
337    fn test_post_pr_review_skips_none_line_comments() {
338        // Arrange: one comment with a line, one without.
339        let comments = vec![
340            PrReviewComment {
341                file: "src/lib.rs".to_string(),
342                line: None,
343                comment: "General file comment.".to_string(),
344                severity: CommentSeverity::Info,
345            },
346            PrReviewComment {
347                file: "src/lib.rs".to_string(),
348                line: Some(10),
349                comment: "Inline comment.".to_string(),
350                severity: CommentSeverity::Warning,
351            },
352        ];
353
354        // Act
355        let inline = build_inline_comments(&comments);
356
357        // Assert: only the comment with a line is included.
358        assert_eq!(inline.len(), 1);
359        assert_eq!(inline[0]["line"], 10);
360    }
361
362    #[test]
363    fn test_post_pr_review_empty_comments() {
364        // Arrange
365        let comments: Vec<PrReviewComment> = vec![];
366
367        // Act
368        let inline = build_inline_comments(&comments);
369
370        // Assert: empty slice produces empty array, which serializes as [].
371        assert!(inline.is_empty());
372        let serialized = serde_json::to_string(&inline).unwrap();
373        assert_eq!(serialized, "[]");
374    }
375
376    // ---------------------------------------------------------------------------
377    // Existing tests
378    // ---------------------------------------------------------------------------
379
380    // Smoke test to verify parse_pr_reference delegates correctly.
381    // Comprehensive parsing tests are in github/mod.rs.
382    #[test]
383    fn test_parse_pr_reference_delegates_to_shared() {
384        let (owner, repo, number) =
385            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
386        assert_eq!(owner, "block");
387        assert_eq!(repo, "goose");
388        assert_eq!(number, 123);
389    }
390
391    #[test]
392    fn test_title_prefix_to_label_mapping() {
393        let cases = vec![
394            (
395                "feat: add new feature",
396                vec!["enhancement"],
397                "feat should map to enhancement",
398            ),
399            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
400            (
401                "docs: update readme",
402                vec!["documentation"],
403                "docs should map to documentation",
404            ),
405            (
406                "refactor: improve code",
407                vec!["refactor"],
408                "refactor should map to refactor",
409            ),
410            (
411                "perf: optimize",
412                vec!["enhancement"],
413                "perf should map to enhancement",
414            ),
415            (
416                "chore: update deps",
417                vec![],
418                "chore should produce no labels",
419            ),
420        ];
421
422        for (title, expected_labels, msg) in cases {
423            let labels = labels_from_pr_metadata(title, &[]);
424            for expected in &expected_labels {
425                assert!(
426                    labels.contains(&expected.to_string()),
427                    "{msg}: expected '{expected}' in {labels:?}",
428                );
429            }
430            if expected_labels.is_empty() {
431                assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
432            }
433        }
434    }
435
436    #[test]
437    fn test_file_path_to_scope_mapping() {
438        let cases = vec![
439            (
440                "feat: cli",
441                vec!["crates/aptu-cli/src/main.rs"],
442                vec!["enhancement", "cli"],
443                "cli path should map to cli scope",
444            ),
445            (
446                "feat: ios",
447                vec!["crates/aptu-ffi/src/lib.rs"],
448                vec!["enhancement", "ios"],
449                "ffi path should map to ios scope",
450            ),
451            (
452                "feat: ios",
453                vec!["AptuApp/ContentView.swift"],
454                vec!["enhancement", "ios"],
455                "app path should map to ios scope",
456            ),
457            (
458                "feat: docs",
459                vec!["docs/GITHUB_ACTION.md"],
460                vec!["enhancement", "documentation"],
461                "docs path should map to documentation scope",
462            ),
463            (
464                "feat: snap",
465                vec!["snap/snapcraft.yaml"],
466                vec!["enhancement", "distribution"],
467                "snap path should map to distribution scope",
468            ),
469            (
470                "feat: workflow",
471                vec![".github/workflows/test.yml"],
472                vec!["enhancement"],
473                "workflow path should be ignored",
474            ),
475        ];
476
477        for (title, paths, expected_labels, msg) in cases {
478            let labels = labels_from_pr_metadata(
479                title,
480                &paths
481                    .iter()
482                    .map(std::string::ToString::to_string)
483                    .collect::<Vec<_>>(),
484            );
485            for expected in expected_labels {
486                assert!(
487                    labels.contains(&expected.to_string()),
488                    "{msg}: expected '{expected}' in {labels:?}",
489                );
490            }
491        }
492    }
493
494    #[test]
495    fn test_combined_title_and_paths() {
496        let labels = labels_from_pr_metadata(
497            "feat: multi",
498            &[
499                "crates/aptu-cli/src/main.rs".to_string(),
500                "docs/README.md".to_string(),
501            ],
502        );
503        assert!(
504            labels.contains(&"enhancement".to_string()),
505            "should include enhancement from feat prefix"
506        );
507        assert!(
508            labels.contains(&"cli".to_string()),
509            "should include cli from path"
510        );
511        assert!(
512            labels.contains(&"documentation".to_string()),
513            "should include documentation from path"
514        );
515    }
516
517    #[test]
518    fn test_no_match_returns_empty() {
519        let cases = vec![
520            (
521                "Random title",
522                vec![],
523                "unrecognized prefix should return empty",
524            ),
525            (
526                "chore: update",
527                vec![],
528                "ignored prefix should return empty",
529            ),
530        ];
531
532        for (title, paths, msg) in cases {
533            let labels = labels_from_pr_metadata(title, &paths);
534            assert!(labels.is_empty(), "{msg}: got {labels:?}");
535        }
536    }
537
538    #[test]
539    fn test_scoped_prefix_extracts_type() {
540        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
541        assert!(
542            labels.contains(&"enhancement".to_string()),
543            "scoped prefix should extract type from feat(cli)"
544        );
545    }
546
547    #[test]
548    fn test_duplicate_labels_deduplicated() {
549        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
550        assert_eq!(
551            labels.len(),
552            1,
553            "should have exactly one label when title and path both map to documentation"
554        );
555        assert!(
556            labels.contains(&"documentation".to_string()),
557            "should contain documentation label"
558        );
559    }
560}