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