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