Skip to main content

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