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, 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        files: pr_files,
128        url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
129        labels,
130    };
131
132    debug!(
133        file_count = details.files.len(),
134        "PR details fetched successfully"
135    );
136
137    Ok(details)
138}
139
140/// Posts a PR review to GitHub.
141///
142/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
143/// Requires write access to the repository.
144///
145/// # Arguments
146///
147/// * `client` - Authenticated Octocrab client
148/// * `owner` - Repository owner
149/// * `repo` - Repository name
150/// * `number` - PR number
151/// * `body` - Review comment text
152/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
153///
154/// # Returns
155///
156/// Review ID on success.
157///
158/// # Errors
159///
160/// Returns an error if the API call fails, user lacks write access, or PR is not found.
161#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
162pub async fn post_pr_review(
163    client: &Octocrab,
164    owner: &str,
165    repo: &str,
166    number: u64,
167    body: &str,
168    event: ReviewEvent,
169) -> Result<u64> {
170    debug!("Posting PR review");
171
172    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
173
174    let payload = serde_json::json!({
175        "body": body,
176        "event": event.to_string(),
177    });
178
179    #[derive(serde::Deserialize)]
180    struct ReviewResponse {
181        id: u64,
182    }
183
184    let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
185        format!(
186            "Failed to post review to PR #{number} in {owner}/{repo}. \
187                 Check that you have write access to the repository."
188        )
189    })?;
190
191    debug!(review_id = response.id, "PR review posted successfully");
192
193    Ok(response.id)
194}
195
196/// Extract labels from PR metadata (title and file paths).
197///
198/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
199/// Returns a vector of label names to apply to the PR.
200///
201/// # Arguments
202/// * `title` - PR title (may contain conventional commit prefix)
203/// * `file_paths` - List of file paths changed in the PR
204///
205/// # Returns
206/// Vector of label names to apply
207#[must_use]
208pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
209    let mut labels = std::collections::HashSet::new();
210
211    // Extract conventional commit prefix from title
212    // Handle both "feat: ..." and "feat(scope): ..." formats
213    let prefix = title
214        .split(':')
215        .next()
216        .unwrap_or("")
217        .split('(')
218        .next()
219        .unwrap_or("")
220        .trim();
221
222    // Map conventional commit type to label
223    let type_label = match prefix {
224        "feat" | "perf" => Some("enhancement"),
225        "fix" => Some("bug"),
226        "docs" => Some("documentation"),
227        "refactor" => Some("refactor"),
228        _ => None,
229    };
230
231    if let Some(label) = type_label {
232        labels.insert(label.to_string());
233    }
234
235    // Map file paths to scope labels
236    for path in file_paths {
237        let scope = if path.starts_with("crates/aptu-cli/") {
238            Some("cli")
239        } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
240            Some("ios")
241        } else if path.starts_with("docs/") {
242            Some("documentation")
243        } else if path.starts_with("snap/") {
244            Some("distribution")
245        } else {
246            None
247        };
248
249        if let Some(label) = scope {
250            labels.insert(label.to_string());
251        }
252    }
253
254    labels.into_iter().collect()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // Smoke test to verify parse_pr_reference delegates correctly.
262    // Comprehensive parsing tests are in github/mod.rs.
263    #[test]
264    fn test_parse_pr_reference_delegates_to_shared() {
265        let (owner, repo, number) =
266            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
267        assert_eq!(owner, "block");
268        assert_eq!(repo, "goose");
269        assert_eq!(number, 123);
270    }
271
272    #[test]
273    fn test_title_prefix_to_label_mapping() {
274        let cases = vec![
275            (
276                "feat: add new feature",
277                vec!["enhancement"],
278                "feat should map to enhancement",
279            ),
280            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
281            (
282                "docs: update readme",
283                vec!["documentation"],
284                "docs should map to documentation",
285            ),
286            (
287                "refactor: improve code",
288                vec!["refactor"],
289                "refactor should map to refactor",
290            ),
291            (
292                "perf: optimize",
293                vec!["enhancement"],
294                "perf should map to enhancement",
295            ),
296            (
297                "chore: update deps",
298                vec![],
299                "chore should produce no labels",
300            ),
301        ];
302
303        for (title, expected_labels, msg) in cases {
304            let labels = labels_from_pr_metadata(title, &[]);
305            for expected in &expected_labels {
306                assert!(
307                    labels.contains(&expected.to_string()),
308                    "{msg}: expected '{expected}' in {labels:?}",
309                );
310            }
311            if expected_labels.is_empty() {
312                assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
313            }
314        }
315    }
316
317    #[test]
318    fn test_file_path_to_scope_mapping() {
319        let cases = vec![
320            (
321                "feat: cli",
322                vec!["crates/aptu-cli/src/main.rs"],
323                vec!["enhancement", "cli"],
324                "cli path should map to cli scope",
325            ),
326            (
327                "feat: ios",
328                vec!["crates/aptu-ffi/src/lib.rs"],
329                vec!["enhancement", "ios"],
330                "ffi path should map to ios scope",
331            ),
332            (
333                "feat: ios",
334                vec!["AptuApp/ContentView.swift"],
335                vec!["enhancement", "ios"],
336                "app path should map to ios scope",
337            ),
338            (
339                "feat: docs",
340                vec!["docs/GITHUB_ACTION.md"],
341                vec!["enhancement", "documentation"],
342                "docs path should map to documentation scope",
343            ),
344            (
345                "feat: snap",
346                vec!["snap/snapcraft.yaml"],
347                vec!["enhancement", "distribution"],
348                "snap path should map to distribution scope",
349            ),
350            (
351                "feat: workflow",
352                vec![".github/workflows/test.yml"],
353                vec!["enhancement"],
354                "workflow path should be ignored",
355            ),
356        ];
357
358        for (title, paths, expected_labels, msg) in cases {
359            let labels = labels_from_pr_metadata(
360                title,
361                &paths
362                    .iter()
363                    .map(std::string::ToString::to_string)
364                    .collect::<Vec<_>>(),
365            );
366            for expected in expected_labels {
367                assert!(
368                    labels.contains(&expected.to_string()),
369                    "{msg}: expected '{expected}' in {labels:?}",
370                );
371            }
372        }
373    }
374
375    #[test]
376    fn test_combined_title_and_paths() {
377        let labels = labels_from_pr_metadata(
378            "feat: multi",
379            &[
380                "crates/aptu-cli/src/main.rs".to_string(),
381                "docs/README.md".to_string(),
382            ],
383        );
384        assert!(
385            labels.contains(&"enhancement".to_string()),
386            "should include enhancement from feat prefix"
387        );
388        assert!(
389            labels.contains(&"cli".to_string()),
390            "should include cli from path"
391        );
392        assert!(
393            labels.contains(&"documentation".to_string()),
394            "should include documentation from path"
395        );
396    }
397
398    #[test]
399    fn test_no_match_returns_empty() {
400        let cases = vec![
401            (
402                "Random title",
403                vec![],
404                "unrecognized prefix should return empty",
405            ),
406            (
407                "chore: update",
408                vec![],
409                "ignored prefix should return empty",
410            ),
411        ];
412
413        for (title, paths, msg) in cases {
414            let labels = labels_from_pr_metadata(title, &paths);
415            assert!(labels.is_empty(), "{msg}: got {labels:?}");
416        }
417    }
418
419    #[test]
420    fn test_scoped_prefix_extracts_type() {
421        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
422        assert!(
423            labels.contains(&"enhancement".to_string()),
424            "scoped prefix should extract type from feat(cli)"
425        );
426    }
427
428    #[test]
429    fn test_duplicate_labels_deduplicated() {
430        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
431        assert_eq!(
432            labels.len(),
433            1,
434            "should have exactly one label when title and path both map to documentation"
435        );
436        assert!(
437            labels.contains(&"documentation".to_string()),
438            "should contain documentation label"
439        );
440    }
441}