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