aptu_core/github/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GitHub integration module.
4//!
5//! Provides authentication and API client functionality for GitHub.
6
7use anyhow::{Context, Result};
8use tracing::debug;
9
10pub mod auth;
11pub mod graphql;
12pub mod issues;
13pub mod pulls;
14pub mod ratelimit;
15pub mod releases;
16
17/// OAuth Client ID for Aptu CLI (safe to embed per RFC 8252).
18///
19/// This is a public client ID for native/CLI applications. Per OAuth 2.0 for
20/// Native Apps (RFC 8252), client credentials in native apps cannot be kept
21/// confidential and are safe to embed in source code.
22pub const OAUTH_CLIENT_ID: &str = "Ov23lifiYQrh6Ga7Hpyr";
23
24/// Keyring service name for storing credentials.
25#[cfg(feature = "keyring")]
26pub const KEYRING_SERVICE: &str = "aptu";
27
28/// Keyring username for the GitHub token.
29#[cfg(feature = "keyring")]
30pub const KEYRING_USER: &str = "github_token";
31
32/// Discriminator for GitHub reference type (issue or pull request).
33#[derive(Debug, Clone, Copy)]
34pub enum ReferenceKind {
35    /// Issue reference with display name and URL path segment.
36    Issue,
37    /// Pull request reference with display name and URL path segment.
38    Pull,
39}
40
41impl ReferenceKind {
42    /// Returns the display name for this reference kind.
43    #[must_use]
44    pub fn display_name(&self) -> &'static str {
45        match self {
46            ReferenceKind::Issue => "issue",
47            ReferenceKind::Pull => "pull request",
48        }
49    }
50
51    /// Returns the URL path segment for this reference kind.
52    #[must_use]
53    pub fn url_segment(&self) -> &'static str {
54        match self {
55            ReferenceKind::Issue => "issues",
56            ReferenceKind::Pull => "pull",
57        }
58    }
59}
60
61/// Parses an owner/repo string to extract owner and repo.
62///
63/// Validates format: exactly one `/`, non-empty parts.
64///
65/// # Errors
66///
67/// Returns an error if the format is invalid.
68pub fn parse_owner_repo(s: &str) -> Result<(String, String)> {
69    let parts: Vec<&str> = s.split('/').collect();
70    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
71        anyhow::bail!(
72            "Invalid owner/repo format.\n\
73             Expected: owner/repo\n\
74             Got: {s}"
75        );
76    }
77    Ok((parts[0].to_string(), parts[1].to_string()))
78}
79
80/// Parses a GitHub reference (issue or PR) in multiple formats.
81///
82/// Supports:
83/// - Full URL: `https://github.com/owner/repo/issues/123` or `https://github.com/owner/repo/pull/123`
84/// - Short form: `owner/repo#123`
85/// - Bare number: `123` (requires `repo_context`)
86///
87/// # Arguments
88///
89/// * `kind` - The type of reference (Issue or Pull)
90/// * `input` - The reference to parse
91/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
92///
93/// # Errors
94///
95/// Returns an error if the format is invalid or bare number is used without context.
96pub fn parse_github_reference(
97    kind: ReferenceKind,
98    input: &str,
99    repo_context: Option<&str>,
100) -> Result<(String, String, u64)> {
101    let input = input.trim();
102
103    // Try full URL first
104    if input.starts_with("https://") || input.starts_with("http://") {
105        // Remove trailing fragments and query params
106        let clean_url = input.split('#').next().unwrap_or(input);
107        let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
108
109        // Parse the URL path
110        let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
111
112        // Expected: ["https:", "", "github.com", "owner", "repo", "issues/pull", "123"]
113        if parts.len() < 7 {
114            anyhow::bail!(
115                "Invalid GitHub {} URL format.\n\
116                 Expected: https://github.com/owner/repo/{}/123\n\
117                 Got: {input}",
118                kind.display_name(),
119                kind.url_segment()
120            );
121        }
122
123        // Verify it's a github.com URL
124        if !parts[2].contains("github.com") {
125            anyhow::bail!(
126                "URL must be a GitHub {} URL.\n\
127                 Expected: https://github.com/owner/repo/{}/123\n\
128                 Got: {input}",
129                kind.display_name(),
130                kind.url_segment()
131            );
132        }
133
134        // Verify it's the correct path segment
135        if parts[5] != kind.url_segment() {
136            anyhow::bail!(
137                "URL must point to a GitHub {}.\n\
138                 Expected: https://github.com/owner/repo/{}/123\n\
139                 Got: {input}",
140                kind.display_name(),
141                kind.url_segment()
142            );
143        }
144
145        let owner = parts[3].to_string();
146        let repo = parts[4].to_string();
147        let number: u64 = parts[6].parse().with_context(|| {
148            format!(
149                "Invalid {} number '{}' in URL.\n\
150                 Expected a numeric {} number.",
151                kind.display_name(),
152                parts[6],
153                kind.display_name()
154            )
155        })?;
156
157        debug!(owner = %owner, repo = %repo, number = number, "Parsed {} URL", kind.display_name());
158        return Ok((owner, repo, number));
159    }
160
161    // Try short form: owner/repo#123
162    if let Some(hash_pos) = input.find('#') {
163        let owner_repo_part = &input[..hash_pos];
164        let number_part = &input[hash_pos + 1..];
165
166        let (owner, repo) = parse_owner_repo(owner_repo_part)?;
167        let number: u64 = number_part.parse().with_context(|| {
168            format!(
169                "Invalid {} number '{number_part}' in short form.\n\
170                 Expected: owner/repo#123\n\
171                 Got: {input}",
172                kind.display_name()
173            )
174        })?;
175
176        debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form {} reference", kind.display_name());
177        return Ok((owner, repo, number));
178    }
179
180    // Try bare number: 123 (requires repo_context)
181    if let Ok(number) = input.parse::<u64>() {
182        let repo_context = repo_context.ok_or_else(|| {
183            anyhow::anyhow!(
184                "Bare {} number requires repository context.\n\
185                 Use one of:\n\
186                 - Full URL: https://github.com/owner/repo/{}/123\n\
187                 - Short form: owner/repo#123\n\
188                 - Bare number with --repo flag: 123 --repo owner/repo\n\
189                 Got: {input}",
190                kind.display_name(),
191                kind.url_segment()
192            )
193        })?;
194
195        let (owner, repo) = parse_owner_repo(repo_context)?;
196        debug!(owner = %owner, repo = %repo, number = number, "Parsed bare {} number", kind.display_name());
197        return Ok((owner, repo, number));
198    }
199
200    // If we get here, it's an invalid format
201    anyhow::bail!(
202        "Invalid {} reference format.\n\
203         Expected one of:\n\
204         - Full URL: https://github.com/owner/repo/{}/123\n\
205         - Short form: owner/repo#123\n\
206         - Bare number with --repo flag: 123 --repo owner/repo\n\
207         Got: {input}",
208        kind.display_name(),
209        kind.url_segment()
210    );
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_parse_owner_repo_valid() {
219        let (owner, repo) = parse_owner_repo("octocat/Hello-World").unwrap();
220        assert_eq!(owner, "octocat");
221        assert_eq!(repo, "Hello-World");
222    }
223
224    #[test]
225    fn test_parse_owner_repo_invalid_no_slash() {
226        assert!(parse_owner_repo("octocat").is_err());
227    }
228
229    #[test]
230    fn test_parse_owner_repo_invalid_empty_owner() {
231        assert!(parse_owner_repo("/repo").is_err());
232    }
233
234    #[test]
235    fn test_parse_owner_repo_invalid_empty_repo() {
236        assert!(parse_owner_repo("owner/").is_err());
237    }
238
239    #[test]
240    fn test_parse_github_reference_issue_full_url() {
241        let (owner, repo, number) = parse_github_reference(
242            ReferenceKind::Issue,
243            "https://github.com/octocat/Hello-World/issues/123",
244            None,
245        )
246        .unwrap();
247        assert_eq!(owner, "octocat");
248        assert_eq!(repo, "Hello-World");
249        assert_eq!(number, 123);
250    }
251
252    #[test]
253    fn test_parse_github_reference_issue_full_url_with_query() {
254        let (owner, repo, number) = parse_github_reference(
255            ReferenceKind::Issue,
256            "https://github.com/octocat/Hello-World/issues/123?foo=bar",
257            None,
258        )
259        .unwrap();
260        assert_eq!(owner, "octocat");
261        assert_eq!(repo, "Hello-World");
262        assert_eq!(number, 123);
263    }
264
265    #[test]
266    fn test_parse_github_reference_issue_full_url_with_fragment() {
267        let (owner, repo, number) = parse_github_reference(
268            ReferenceKind::Issue,
269            "https://github.com/octocat/Hello-World/issues/123#comment-456",
270            None,
271        )
272        .unwrap();
273        assert_eq!(owner, "octocat");
274        assert_eq!(repo, "Hello-World");
275        assert_eq!(number, 123);
276    }
277
278    #[test]
279    fn test_parse_github_reference_issue_short_form() {
280        let (owner, repo, number) =
281            parse_github_reference(ReferenceKind::Issue, "octocat/Hello-World#123", None).unwrap();
282        assert_eq!(owner, "octocat");
283        assert_eq!(repo, "Hello-World");
284        assert_eq!(number, 123);
285    }
286
287    #[test]
288    fn test_parse_github_reference_issue_bare_number() {
289        let (owner, repo, number) =
290            parse_github_reference(ReferenceKind::Issue, "123", Some("octocat/Hello-World"))
291                .unwrap();
292        assert_eq!(owner, "octocat");
293        assert_eq!(repo, "Hello-World");
294        assert_eq!(number, 123);
295    }
296
297    #[test]
298    fn test_parse_github_reference_issue_bare_number_no_context() {
299        assert!(parse_github_reference(ReferenceKind::Issue, "123", None).is_err());
300    }
301
302    #[test]
303    fn test_parse_github_reference_pull_full_url() {
304        let (owner, repo, number) = parse_github_reference(
305            ReferenceKind::Pull,
306            "https://github.com/octocat/Hello-World/pull/456",
307            None,
308        )
309        .unwrap();
310        assert_eq!(owner, "octocat");
311        assert_eq!(repo, "Hello-World");
312        assert_eq!(number, 456);
313    }
314
315    #[test]
316    fn test_parse_github_reference_pull_short_form() {
317        let (owner, repo, number) =
318            parse_github_reference(ReferenceKind::Pull, "octocat/Hello-World#456", None).unwrap();
319        assert_eq!(owner, "octocat");
320        assert_eq!(repo, "Hello-World");
321        assert_eq!(number, 456);
322    }
323
324    #[test]
325    fn test_parse_github_reference_pull_bare_number() {
326        let (owner, repo, number) =
327            parse_github_reference(ReferenceKind::Pull, "456", Some("octocat/Hello-World"))
328                .unwrap();
329        assert_eq!(owner, "octocat");
330        assert_eq!(repo, "Hello-World");
331        assert_eq!(number, 456);
332    }
333
334    #[test]
335    fn test_parse_github_reference_issue_wrong_kind_url() {
336        // Try to parse a PR URL as an issue
337        assert!(
338            parse_github_reference(
339                ReferenceKind::Issue,
340                "https://github.com/octocat/Hello-World/pull/123",
341                None
342            )
343            .is_err()
344        );
345    }
346
347    #[test]
348    fn test_parse_github_reference_pull_wrong_kind_url() {
349        // Try to parse an issue URL as a PR
350        assert!(
351            parse_github_reference(
352                ReferenceKind::Pull,
353                "https://github.com/octocat/Hello-World/issues/123",
354                None
355            )
356            .is_err()
357        );
358    }
359
360    #[test]
361    fn test_parse_github_reference_invalid_url() {
362        assert!(
363            parse_github_reference(
364                ReferenceKind::Issue,
365                "https://github.com/octocat/Hello-World/invalid/123",
366                None
367            )
368            .is_err()
369        );
370    }
371
372    #[test]
373    fn test_parse_github_reference_not_github_url() {
374        assert!(
375            parse_github_reference(
376                ReferenceKind::Issue,
377                "https://gitlab.com/octocat/Hello-World/issues/123",
378                None
379            )
380            .is_err()
381        );
382    }
383}