1use anyhow::{Context, Result};
8use tracing::debug;
9
10pub mod auth;
11pub mod graphql;
12pub mod issues;
13pub mod pulls;
14pub mod ratelimit;
15
16pub use auth::create_client;
18
19#[cfg(feature = "keyring")]
21pub use auth::{keyring_deinit, keyring_init};
22
23pub const OAUTH_CLIENT_ID: &str = "Ov23lifiYQrh6Ga7Hpyr";
29
30#[cfg(feature = "keyring")]
32pub const KEYRING_SERVICE: &str = "aptu";
33
34#[cfg(feature = "keyring")]
36pub const KEYRING_USER: &str = "github_token";
37
38#[derive(Debug, Clone, Copy)]
40pub enum ReferenceKind {
41 Issue,
43 Pull,
45}
46
47impl ReferenceKind {
48 #[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 #[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
67pub 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
86fn parse_url_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
88 let clean_url = input.split('#').next().unwrap_or(input);
90 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
91
92 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
94
95 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 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 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
144fn 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
166fn 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
193pub 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 if input.starts_with("https://") || input.starts_with("http://") {
218 return parse_url_ref(kind, input);
219 }
220
221 if input.contains('#') {
223 return parse_short_ref(kind, input);
224 }
225
226 if input.parse::<u64>().is_ok() {
228 return parse_bare_ref(kind, input, repo_context);
229 }
230
231 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 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 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}