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};
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#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_parse_pr_reference_full_url() {
174        let (owner, repo, number) =
175            parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
176        assert_eq!(owner, "block");
177        assert_eq!(repo, "goose");
178        assert_eq!(number, 123);
179    }
180
181    #[test]
182    fn test_parse_pr_reference_short_form() {
183        let (owner, repo, number) = parse_pr_reference("block/goose#456", None).unwrap();
184        assert_eq!(owner, "block");
185        assert_eq!(repo, "goose");
186        assert_eq!(number, 456);
187    }
188
189    #[test]
190    fn test_parse_pr_reference_bare_number_with_context() {
191        let (owner, repo, number) = parse_pr_reference("789", Some("block/goose")).unwrap();
192        assert_eq!(owner, "block");
193        assert_eq!(repo, "goose");
194        assert_eq!(number, 789);
195    }
196
197    #[test]
198    fn test_parse_pr_reference_bare_number_without_context() {
199        let result = parse_pr_reference("123", None);
200        assert!(result.is_err());
201        assert!(
202            result
203                .unwrap_err()
204                .to_string()
205                .contains("requires --repo flag")
206        );
207    }
208
209    #[test]
210    fn test_parse_pr_reference_hash_with_context() {
211        let (owner, repo, number) = parse_pr_reference("#42", Some("owner/repo")).unwrap();
212        assert_eq!(owner, "owner");
213        assert_eq!(repo, "repo");
214        assert_eq!(number, 42);
215    }
216
217    #[test]
218    fn test_parse_pr_reference_invalid_url() {
219        let result = parse_pr_reference("https://github.com/invalid", None);
220        assert!(result.is_err());
221    }
222
223    #[test]
224    fn test_parse_pr_reference_invalid_number() {
225        let result = parse_pr_reference("block/goose#abc", None);
226        assert!(result.is_err());
227    }
228}