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