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
17#[cfg(not(target_arch = "wasm32"))]
19pub use auth::create_client;
20
21#[cfg(feature = "keyring")]
23pub use auth::{keyring_deinit, keyring_init};
24
25pub const OAUTH_CLIENT_ID: &str = "Ov23lifiYQrh6Ga7Hpyr";
31
32#[cfg(feature = "keyring")]
34pub const KEYRING_SERVICE: &str = "aptu";
35
36#[cfg(feature = "keyring")]
38pub const KEYRING_USER: &str = "github_token";
39
40#[derive(Debug, Clone, Copy)]
42pub enum ReferenceKind {
43 Issue,
45 Pull,
47}
48
49impl ReferenceKind {
50 #[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 #[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
69pub 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
88fn parse_url_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
90 let clean_url = input.split('#').next().unwrap_or(input);
92 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
93
94 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
96
97 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 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 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
146fn 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
168fn 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
195pub 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 if input.starts_with("https://") || input.starts_with("http://") {
220 return parse_url_ref(kind, input);
221 }
222
223 if input.contains('#') {
225 return parse_short_ref(kind, input);
226 }
227
228 if input.parse::<u64>().is_ok() {
230 return parse_bare_ref(kind, input, repo_context);
231 }
232
233 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 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 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}