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};
14
15/// Parses a PR reference into (owner, repo, number).
16///
17/// Supports multiple formats:
18/// - Full URL: `https://github.com/owner/repo/pull/123`
19/// - Short form: `owner/repo#123`
20/// - Bare number: `123` (requires `repo_context`)
21///
22/// # Arguments
23///
24/// * `reference` - PR reference string
25/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
26///
27/// # Returns
28///
29/// Tuple of (owner, repo, number)
30///
31/// # Errors
32///
33/// Returns an error if the reference format is invalid or `repo_context` is missing for bare numbers.
34pub fn parse_pr_reference(
35    reference: &str,
36    repo_context: Option<&str>,
37) -> Result<(String, String, u64)> {
38    parse_github_reference(ReferenceKind::Pull, reference, repo_context)
39}
40
41/// Fetches PR details including file diffs from GitHub.
42///
43/// Uses Octocrab to fetch PR metadata and file changes.
44///
45/// # Arguments
46///
47/// * `client` - Authenticated Octocrab client
48/// * `owner` - Repository owner
49/// * `repo` - Repository name
50/// * `number` - PR number
51///
52/// # Returns
53///
54/// `PrDetails` struct with PR metadata and file diffs.
55///
56/// # Errors
57///
58/// Returns an error if the API call fails or PR is not found.
59#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
60pub async fn fetch_pr_details(
61    client: &Octocrab,
62    owner: &str,
63    repo: &str,
64    number: u64,
65) -> Result<PrDetails> {
66    debug!("Fetching PR details");
67
68    // Fetch PR metadata
69    let pr = match client.pulls(owner, repo).get(number).await {
70        Ok(pr) => pr,
71        Err(e) => {
72            // Check if this is a 404 error and if an issue exists instead
73            if let octocrab::Error::GitHub { source, .. } = &e
74                && source.status_code == 404
75            {
76                // Try to fetch as an issue to provide a better error message
77                if (client.issues(owner, repo).get(number).await).is_ok() {
78                    return Err(anyhow::anyhow!("#{number} is an issue, not a pull request"));
79                }
80                // Issue check failed, fall back to original error
81            }
82            return Err(e)
83                .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
84        }
85    };
86
87    // Fetch PR files (diffs)
88    let files = client
89        .pulls(owner, repo)
90        .list_files(number)
91        .await
92        .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
93
94    // Convert to our types
95    let pr_files: Vec<PrFile> = files
96        .items
97        .into_iter()
98        .map(|f| PrFile {
99            filename: f.filename,
100            status: format!("{:?}", f.status),
101            additions: f.additions,
102            deletions: f.deletions,
103            patch: f.patch,
104        })
105        .collect();
106
107    let labels: Vec<String> = pr
108        .labels
109        .iter()
110        .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
111        .collect();
112
113    let details = PrDetails {
114        owner: owner.to_string(),
115        repo: repo.to_string(),
116        number,
117        title: pr.title.unwrap_or_default(),
118        body: pr.body.unwrap_or_default(),
119        base_branch: pr.base.ref_field,
120        head_branch: pr.head.ref_field,
121        files: pr_files,
122        url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
123        labels,
124    };
125
126    debug!(
127        file_count = details.files.len(),
128        "PR details fetched successfully"
129    );
130
131    Ok(details)
132}
133
134/// Posts a PR review to GitHub.
135///
136/// Uses Octocrab's custom HTTP POST to create a review with the specified event type.
137/// Requires write access to the repository.
138///
139/// # Arguments
140///
141/// * `client` - Authenticated Octocrab client
142/// * `owner` - Repository owner
143/// * `repo` - Repository name
144/// * `number` - PR number
145/// * `body` - Review comment text
146/// * `event` - Review event type (Comment, Approve, or `RequestChanges`)
147///
148/// # Returns
149///
150/// Review ID on success.
151///
152/// # Errors
153///
154/// Returns an error if the API call fails, user lacks write access, or PR is not found.
155#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
156pub async fn post_pr_review(
157    client: &Octocrab,
158    owner: &str,
159    repo: &str,
160    number: u64,
161    body: &str,
162    event: ReviewEvent,
163) -> Result<u64> {
164    debug!("Posting PR review");
165
166    let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
167
168    let payload = serde_json::json!({
169        "body": body,
170        "event": event.to_string(),
171    });
172
173    #[derive(serde::Deserialize)]
174    struct ReviewResponse {
175        id: u64,
176    }
177
178    let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
179        format!(
180            "Failed to post review to PR #{number} in {owner}/{repo}. \
181                 Check that you have write access to the repository."
182        )
183    })?;
184
185    debug!(review_id = response.id, "PR review posted successfully");
186
187    Ok(response.id)
188}
189
190/// Extract labels from PR metadata (title and file paths).
191///
192/// Parses conventional commit prefix from PR title and maps file paths to scope labels.
193/// Returns a vector of label names to apply to the PR.
194///
195/// # Arguments
196/// * `title` - PR title (may contain conventional commit prefix)
197/// * `file_paths` - List of file paths changed in the PR
198///
199/// # Returns
200/// Vector of label names to apply
201#[must_use]
202pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
203    let mut labels = std::collections::HashSet::new();
204
205    // Extract conventional commit prefix from title
206    // Handle both "feat: ..." and "feat(scope): ..." formats
207    let prefix = title
208        .split(':')
209        .next()
210        .unwrap_or("")
211        .split('(')
212        .next()
213        .unwrap_or("")
214        .trim();
215
216    // Map conventional commit type to label
217    let type_label = match prefix {
218        "feat" | "perf" => Some("enhancement"),
219        "fix" => Some("bug"),
220        "docs" => Some("documentation"),
221        "refactor" => Some("refactor"),
222        _ => None,
223    };
224
225    if let Some(label) = type_label {
226        labels.insert(label.to_string());
227    }
228
229    // Map file paths to scope labels
230    for path in file_paths {
231        let scope = if path.starts_with("crates/aptu-cli/") {
232            Some("cli")
233        } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
234            Some("ios")
235        } else if path.starts_with("docs/") {
236            Some("documentation")
237        } else if path.starts_with("snap/") {
238            Some("distribution")
239        } else {
240            None
241        };
242
243        if let Some(label) = scope {
244            labels.insert(label.to_string());
245        }
246    }
247
248    labels.into_iter().collect()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    // Smoke test to verify parse_pr_reference delegates correctly.
256    // Comprehensive parsing tests are in github/mod.rs.
257    #[test]
258    fn test_parse_pr_reference_delegates_to_shared() {
259        let (owner, repo, number) =
260            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
261        assert_eq!(owner, "block");
262        assert_eq!(repo, "goose");
263        assert_eq!(number, 123);
264    }
265
266    #[test]
267    fn test_title_prefix_to_label_mapping() {
268        let cases = vec![
269            (
270                "feat: add new feature",
271                vec!["enhancement"],
272                "feat should map to enhancement",
273            ),
274            ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
275            (
276                "docs: update readme",
277                vec!["documentation"],
278                "docs should map to documentation",
279            ),
280            (
281                "refactor: improve code",
282                vec!["refactor"],
283                "refactor should map to refactor",
284            ),
285            (
286                "perf: optimize",
287                vec!["enhancement"],
288                "perf should map to enhancement",
289            ),
290            (
291                "chore: update deps",
292                vec![],
293                "chore should produce no labels",
294            ),
295        ];
296
297        for (title, expected_labels, msg) in cases {
298            let labels = labels_from_pr_metadata(title, &[]);
299            for expected in &expected_labels {
300                assert!(
301                    labels.contains(&expected.to_string()),
302                    "{}: expected '{}' in {:?}",
303                    msg,
304                    expected,
305                    labels
306                );
307            }
308            if expected_labels.is_empty() {
309                assert!(
310                    labels.is_empty(),
311                    "{}: expected empty, got {:?}",
312                    msg,
313                    labels
314                );
315            }
316        }
317    }
318
319    #[test]
320    fn test_file_path_to_scope_mapping() {
321        let cases = vec![
322            (
323                "feat: cli",
324                vec!["crates/aptu-cli/src/main.rs"],
325                vec!["enhancement", "cli"],
326                "cli path should map to cli scope",
327            ),
328            (
329                "feat: ios",
330                vec!["crates/aptu-ffi/src/lib.rs"],
331                vec!["enhancement", "ios"],
332                "ffi path should map to ios scope",
333            ),
334            (
335                "feat: ios",
336                vec!["AptuApp/ContentView.swift"],
337                vec!["enhancement", "ios"],
338                "app path should map to ios scope",
339            ),
340            (
341                "feat: docs",
342                vec!["docs/GITHUB_ACTION.md"],
343                vec!["enhancement", "documentation"],
344                "docs path should map to documentation scope",
345            ),
346            (
347                "feat: snap",
348                vec!["snap/snapcraft.yaml"],
349                vec!["enhancement", "distribution"],
350                "snap path should map to distribution scope",
351            ),
352            (
353                "feat: workflow",
354                vec![".github/workflows/test.yml"],
355                vec!["enhancement"],
356                "workflow path should be ignored",
357            ),
358        ];
359
360        for (title, paths, expected_labels, msg) in cases {
361            let labels = labels_from_pr_metadata(
362                title,
363                &paths.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
364            );
365            for expected in expected_labels {
366                assert!(
367                    labels.contains(&expected.to_string()),
368                    "{}: expected '{}' in {:?}",
369                    msg,
370                    expected,
371                    labels
372                );
373            }
374        }
375    }
376
377    #[test]
378    fn test_combined_title_and_paths() {
379        let labels = labels_from_pr_metadata(
380            "feat: multi",
381            &[
382                "crates/aptu-cli/src/main.rs".to_string(),
383                "docs/README.md".to_string(),
384            ],
385        );
386        assert!(
387            labels.contains(&"enhancement".to_string()),
388            "should include enhancement from feat prefix"
389        );
390        assert!(
391            labels.contains(&"cli".to_string()),
392            "should include cli from path"
393        );
394        assert!(
395            labels.contains(&"documentation".to_string()),
396            "should include documentation from path"
397        );
398    }
399
400    #[test]
401    fn test_no_match_returns_empty() {
402        let cases = vec![
403            (
404                "Random title",
405                vec![],
406                "unrecognized prefix should return empty",
407            ),
408            (
409                "chore: update",
410                vec![],
411                "ignored prefix should return empty",
412            ),
413        ];
414
415        for (title, paths, msg) in cases {
416            let labels = labels_from_pr_metadata(title, &paths);
417            assert!(labels.is_empty(), "{}: got {:?}", msg, labels);
418        }
419    }
420
421    #[test]
422    fn test_scoped_prefix_extracts_type() {
423        let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
424        assert!(
425            labels.contains(&"enhancement".to_string()),
426            "scoped prefix should extract type from feat(cli)"
427        );
428    }
429
430    #[test]
431    fn test_duplicate_labels_deduplicated() {
432        let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
433        assert_eq!(
434            labels.len(),
435            1,
436            "should have exactly one label when title and path both map to documentation"
437        );
438        assert!(
439            labels.contains(&"documentation".to_string()),
440            "should contain documentation label"
441        );
442    }
443}