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;
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/// Parse full URL format: <https://github.com/owner/repo/issues/123>
81fn parse_url_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
82    // Remove trailing fragments and query params
83    let clean_url = input.split('#').next().unwrap_or(input);
84    let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
85
86    // Parse the URL path
87    let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
88
89    // Expected: ["https:", "", "github.com", "owner", "repo", "issues/pull", "123"]
90    if parts.len() < 7 {
91        anyhow::bail!(
92            "Invalid GitHub {} URL format.\n\
93             Expected: https://github.com/owner/repo/{}/123\n\
94             Got: {input}",
95            kind.display_name(),
96            kind.url_segment()
97        );
98    }
99
100    // Verify it's a github.com URL
101    if !parts[2].contains("github.com") {
102        anyhow::bail!(
103            "URL must be a GitHub {} URL.\n\
104             Expected: https://github.com/owner/repo/{}/123\n\
105             Got: {input}",
106            kind.display_name(),
107            kind.url_segment()
108        );
109    }
110
111    // Verify it's the correct path segment
112    if parts[5] != kind.url_segment() {
113        anyhow::bail!(
114            "URL must point to a GitHub {}.\n\
115             Expected: https://github.com/owner/repo/{}/123\n\
116             Got: {input}",
117            kind.display_name(),
118            kind.url_segment()
119        );
120    }
121
122    let owner = parts[3].to_string();
123    let repo = parts[4].to_string();
124    let number: u64 = parts[6].parse().with_context(|| {
125        format!(
126            "Invalid {} number '{}' in URL.\n\
127             Expected a numeric {} number.",
128            kind.display_name(),
129            parts[6],
130            kind.display_name()
131        )
132    })?;
133
134    debug!(owner = %owner, repo = %repo, number = number, "Parsed {} URL", kind.display_name());
135    Ok((owner, repo, number))
136}
137
138/// Parse short form: owner/repo#123
139fn parse_short_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
140    if let Some(hash_pos) = input.find('#') {
141        let owner_repo_part = &input[..hash_pos];
142        let number_part = &input[hash_pos + 1..];
143
144        let (owner, repo) = parse_owner_repo(owner_repo_part)?;
145        let number: u64 = number_part.parse().with_context(|| {
146            format!(
147                "Invalid {} number '{number_part}' in short form.\n\
148                 Expected: owner/repo#123\n\
149                 Got: {input}",
150                kind.display_name()
151            )
152        })?;
153
154        debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form {} reference", kind.display_name());
155        return Ok((owner, repo, number));
156    }
157    anyhow::bail!("Not a short form reference")
158}
159
160/// Parse bare number: `123` (requires `repo_context`)
161fn parse_bare_ref(
162    kind: ReferenceKind,
163    input: &str,
164    repo_context: Option<&str>,
165) -> Result<(String, String, u64)> {
166    if let Ok(number) = input.parse::<u64>() {
167        let repo_context = repo_context.ok_or_else(|| {
168            anyhow::anyhow!(
169                "Bare {} number requires repository context.\n\
170                 Use one of:\n\
171                 - Full URL: https://github.com/owner/repo/{}/123\n\
172                 - Short form: owner/repo#123\n\
173                 - Bare number with --repo flag: 123 --repo owner/repo\n\
174                 Got: {input}",
175                kind.display_name(),
176                kind.url_segment()
177            )
178        })?;
179
180        let (owner, repo) = parse_owner_repo(repo_context)?;
181        debug!(owner = %owner, repo = %repo, number = number, "Parsed bare {} number", kind.display_name());
182        return Ok((owner, repo, number));
183    }
184    anyhow::bail!("Not a bare number reference")
185}
186
187/// Parses a GitHub reference (issue or PR) in multiple formats.
188///
189/// Supports:
190/// - Full URL: `https://github.com/owner/repo/issues/123` or `https://github.com/owner/repo/pull/123`
191/// - Short form: `owner/repo#123`
192/// - Bare number: `123` (requires `repo_context`)
193///
194/// # Arguments
195///
196/// * `kind` - The type of reference (Issue or Pull)
197/// * `input` - The reference to parse
198/// * `repo_context` - Optional repository context for bare numbers (e.g., "owner/repo")
199///
200/// # Errors
201///
202/// Returns an error if the format is invalid or bare number is used without context.
203pub fn parse_github_reference(
204    kind: ReferenceKind,
205    input: &str,
206    repo_context: Option<&str>,
207) -> Result<(String, String, u64)> {
208    let input = input.trim();
209
210    // Try full URL first
211    if input.starts_with("https://") || input.starts_with("http://") {
212        return parse_url_ref(kind, input);
213    }
214
215    // Try short form: owner/repo#123
216    if input.contains('#') {
217        return parse_short_ref(kind, input);
218    }
219
220    // Try bare number: 123 (requires repo_context)
221    if input.parse::<u64>().is_ok() {
222        return parse_bare_ref(kind, input, repo_context);
223    }
224
225    // If we get here, it's an invalid format
226    anyhow::bail!(
227        "Invalid {} reference format.\n\
228         Expected one of:\n\
229         - Full URL: https://github.com/owner/repo/{}/123\n\
230         - Short form: owner/repo#123\n\
231         - Bare number with --repo flag: 123 --repo owner/repo\n\
232         Got: {input}",
233        kind.display_name(),
234        kind.url_segment()
235    );
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_parse_owner_repo_valid() {
244        let (owner, repo) = parse_owner_repo("octocat/Hello-World").unwrap();
245        assert_eq!(owner, "octocat");
246        assert_eq!(repo, "Hello-World");
247    }
248
249    #[test]
250    fn test_parse_owner_repo_invalid_no_slash() {
251        assert!(parse_owner_repo("octocat").is_err());
252    }
253
254    #[test]
255    fn test_parse_owner_repo_invalid_empty_owner() {
256        assert!(parse_owner_repo("/repo").is_err());
257    }
258
259    #[test]
260    fn test_parse_owner_repo_invalid_empty_repo() {
261        assert!(parse_owner_repo("owner/").is_err());
262    }
263
264    #[test]
265    fn test_parse_github_reference_issue_full_url() {
266        let (owner, repo, number) = parse_github_reference(
267            ReferenceKind::Issue,
268            "https://github.com/octocat/Hello-World/issues/123",
269            None,
270        )
271        .unwrap();
272        assert_eq!(owner, "octocat");
273        assert_eq!(repo, "Hello-World");
274        assert_eq!(number, 123);
275    }
276
277    #[test]
278    fn test_parse_github_reference_issue_full_url_with_query() {
279        let (owner, repo, number) = parse_github_reference(
280            ReferenceKind::Issue,
281            "https://github.com/octocat/Hello-World/issues/123?foo=bar",
282            None,
283        )
284        .unwrap();
285        assert_eq!(owner, "octocat");
286        assert_eq!(repo, "Hello-World");
287        assert_eq!(number, 123);
288    }
289
290    #[test]
291    fn test_parse_github_reference_issue_full_url_with_fragment() {
292        let (owner, repo, number) = parse_github_reference(
293            ReferenceKind::Issue,
294            "https://github.com/octocat/Hello-World/issues/123#comment-456",
295            None,
296        )
297        .unwrap();
298        assert_eq!(owner, "octocat");
299        assert_eq!(repo, "Hello-World");
300        assert_eq!(number, 123);
301    }
302
303    #[test]
304    fn test_parse_github_reference_issue_short_form() {
305        let (owner, repo, number) =
306            parse_github_reference(ReferenceKind::Issue, "octocat/Hello-World#123", None).unwrap();
307        assert_eq!(owner, "octocat");
308        assert_eq!(repo, "Hello-World");
309        assert_eq!(number, 123);
310    }
311
312    #[test]
313    fn test_parse_github_reference_issue_bare_number() {
314        let (owner, repo, number) =
315            parse_github_reference(ReferenceKind::Issue, "123", Some("octocat/Hello-World"))
316                .unwrap();
317        assert_eq!(owner, "octocat");
318        assert_eq!(repo, "Hello-World");
319        assert_eq!(number, 123);
320    }
321
322    #[test]
323    fn test_parse_github_reference_issue_bare_number_no_context() {
324        assert!(parse_github_reference(ReferenceKind::Issue, "123", None).is_err());
325    }
326
327    #[test]
328    fn test_parse_github_reference_pull_full_url() {
329        let (owner, repo, number) = parse_github_reference(
330            ReferenceKind::Pull,
331            "https://github.com/octocat/Hello-World/pull/456",
332            None,
333        )
334        .unwrap();
335        assert_eq!(owner, "octocat");
336        assert_eq!(repo, "Hello-World");
337        assert_eq!(number, 456);
338    }
339
340    #[test]
341    fn test_parse_github_reference_pull_short_form() {
342        let (owner, repo, number) =
343            parse_github_reference(ReferenceKind::Pull, "octocat/Hello-World#456", None).unwrap();
344        assert_eq!(owner, "octocat");
345        assert_eq!(repo, "Hello-World");
346        assert_eq!(number, 456);
347    }
348
349    #[test]
350    fn test_parse_github_reference_pull_bare_number() {
351        let (owner, repo, number) =
352            parse_github_reference(ReferenceKind::Pull, "456", Some("octocat/Hello-World"))
353                .unwrap();
354        assert_eq!(owner, "octocat");
355        assert_eq!(repo, "Hello-World");
356        assert_eq!(number, 456);
357    }
358
359    #[test]
360    fn test_parse_github_reference_issue_wrong_kind_url() {
361        // Try to parse a PR URL as an issue
362        assert!(
363            parse_github_reference(
364                ReferenceKind::Issue,
365                "https://github.com/octocat/Hello-World/pull/123",
366                None
367            )
368            .is_err()
369        );
370    }
371
372    #[test]
373    fn test_parse_github_reference_pull_wrong_kind_url() {
374        // Try to parse an issue URL as a PR
375        assert!(
376            parse_github_reference(
377                ReferenceKind::Pull,
378                "https://github.com/octocat/Hello-World/issues/123",
379                None
380            )
381            .is_err()
382        );
383    }
384
385    #[test]
386    fn test_parse_github_reference_invalid_url() {
387        assert!(
388            parse_github_reference(
389                ReferenceKind::Issue,
390                "https://github.com/octocat/Hello-World/invalid/123",
391                None
392            )
393            .is_err()
394        );
395    }
396
397    #[test]
398    fn test_parse_github_reference_not_github_url() {
399        assert!(
400            parse_github_reference(
401                ReferenceKind::Issue,
402                "https://gitlab.com/octocat/Hello-World/issues/123",
403                None
404            )
405            .is_err()
406        );
407    }
408}