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