1use 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
17pub use auth::create_client;
19
20#[cfg(feature = "keyring")]
22pub use auth::{keyring_deinit, keyring_init};
23
24pub const OAUTH_CLIENT_ID: &str = "Ov23lifiYQrh6Ga7Hpyr";
30
31#[cfg(feature = "keyring")]
33pub const KEYRING_SERVICE: &str = "aptu";
34
35#[cfg(feature = "keyring")]
37pub const KEYRING_USER: &str = "github_token";
38
39#[derive(Debug, Clone, Copy)]
41pub enum ReferenceKind {
42 Issue,
44 Pull,
46}
47
48impl ReferenceKind {
49 #[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 #[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
68pub 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
87fn parse_url_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
89 let clean_url = input.split('#').next().unwrap_or(input);
91 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
92
93 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
95
96 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 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 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
145fn 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
167fn 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
194pub 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 if input.starts_with("https://") || input.starts_with("http://") {
219 return parse_url_ref(kind, input);
220 }
221
222 if input.contains('#') {
224 return parse_short_ref(kind, input);
225 }
226
227 if input.parse::<u64>().is_ok() {
229 return parse_bare_ref(kind, input, repo_context);
230 }
231
232 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 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 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}