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 const OAUTH_CLIENT_ID: &str = "Ov23lifiYQrh6Ga7Hpyr";
22
23#[cfg(feature = "keyring")]
25pub const KEYRING_SERVICE: &str = "aptu";
26
27#[cfg(feature = "keyring")]
29pub const KEYRING_USER: &str = "github_token";
30
31#[derive(Debug, Clone, Copy)]
33pub enum ReferenceKind {
34 Issue,
36 Pull,
38}
39
40impl ReferenceKind {
41 #[must_use]
43 pub fn display_name(&self) -> &'static str {
44 match self {
45 ReferenceKind::Issue => "issue",
46 ReferenceKind::Pull => "pull request",
47 }
48 }
49
50 #[must_use]
52 pub fn url_segment(&self) -> &'static str {
53 match self {
54 ReferenceKind::Issue => "issues",
55 ReferenceKind::Pull => "pull",
56 }
57 }
58}
59
60pub fn parse_owner_repo(s: &str) -> Result<(String, String)> {
68 let parts: Vec<&str> = s.split('/').collect();
69 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
70 anyhow::bail!(
71 "Invalid owner/repo format.\n\
72 Expected: owner/repo\n\
73 Got: {s}"
74 );
75 }
76 Ok((parts[0].to_string(), parts[1].to_string()))
77}
78
79fn parse_url_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
81 let clean_url = input.split('#').next().unwrap_or(input);
83 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
84
85 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
87
88 if parts.len() < 7 {
90 anyhow::bail!(
91 "Invalid GitHub {} URL format.\n\
92 Expected: https://github.com/owner/repo/{}/123\n\
93 Got: {input}",
94 kind.display_name(),
95 kind.url_segment()
96 );
97 }
98
99 if !parts[2].contains("github.com") {
101 anyhow::bail!(
102 "URL must be a GitHub {} URL.\n\
103 Expected: https://github.com/owner/repo/{}/123\n\
104 Got: {input}",
105 kind.display_name(),
106 kind.url_segment()
107 );
108 }
109
110 if parts[5] != kind.url_segment() {
112 anyhow::bail!(
113 "URL must point to a GitHub {}.\n\
114 Expected: https://github.com/owner/repo/{}/123\n\
115 Got: {input}",
116 kind.display_name(),
117 kind.url_segment()
118 );
119 }
120
121 let owner = parts[3].to_string();
122 let repo = parts[4].to_string();
123 let number: u64 = parts[6].parse().with_context(|| {
124 format!(
125 "Invalid {} number '{}' in URL.\n\
126 Expected a numeric {} number.",
127 kind.display_name(),
128 parts[6],
129 kind.display_name()
130 )
131 })?;
132
133 debug!(owner = %owner, repo = %repo, number = number, "Parsed {} URL", kind.display_name());
134 Ok((owner, repo, number))
135}
136
137fn parse_short_ref(kind: ReferenceKind, input: &str) -> Result<(String, String, u64)> {
139 if let Some(hash_pos) = input.find('#') {
140 let owner_repo_part = &input[..hash_pos];
141 let number_part = &input[hash_pos + 1..];
142
143 let (owner, repo) = parse_owner_repo(owner_repo_part)?;
144 let number: u64 = number_part.parse().with_context(|| {
145 format!(
146 "Invalid {} number '{number_part}' in short form.\n\
147 Expected: owner/repo#123\n\
148 Got: {input}",
149 kind.display_name()
150 )
151 })?;
152
153 debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form {} reference", kind.display_name());
154 return Ok((owner, repo, number));
155 }
156 anyhow::bail!("Not a short form reference")
157}
158
159fn parse_bare_ref(
161 kind: ReferenceKind,
162 input: &str,
163 repo_context: Option<&str>,
164) -> Result<(String, String, u64)> {
165 if let Ok(number) = input.parse::<u64>() {
166 let repo_context = repo_context.ok_or_else(|| {
167 anyhow::anyhow!(
168 "Bare {} number requires repository context.\n\
169 Use one of:\n\
170 - Full URL: https://github.com/owner/repo/{}/123\n\
171 - Short form: owner/repo#123\n\
172 - Bare number with --repo flag: 123 --repo owner/repo\n\
173 Got: {input}",
174 kind.display_name(),
175 kind.url_segment()
176 )
177 })?;
178
179 let (owner, repo) = parse_owner_repo(repo_context)?;
180 debug!(owner = %owner, repo = %repo, number = number, "Parsed bare {} number", kind.display_name());
181 return Ok((owner, repo, number));
182 }
183 anyhow::bail!("Not a bare number reference")
184}
185
186pub fn parse_github_reference(
203 kind: ReferenceKind,
204 input: &str,
205 repo_context: Option<&str>,
206) -> Result<(String, String, u64)> {
207 let input = input.trim();
208
209 if input.starts_with("https://") || input.starts_with("http://") {
211 return parse_url_ref(kind, input);
212 }
213
214 if input.contains('#') {
216 return parse_short_ref(kind, input);
217 }
218
219 if input.parse::<u64>().is_ok() {
221 return parse_bare_ref(kind, input, repo_context);
222 }
223
224 anyhow::bail!(
226 "Invalid {} reference format.\n\
227 Expected one of:\n\
228 - Full URL: https://github.com/owner/repo/{}/123\n\
229 - Short form: owner/repo#123\n\
230 - Bare number with --repo flag: 123 --repo owner/repo\n\
231 Got: {input}",
232 kind.display_name(),
233 kind.url_segment()
234 );
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_parse_owner_repo_valid() {
243 let (owner, repo) = parse_owner_repo("octocat/Hello-World").unwrap();
244 assert_eq!(owner, "octocat");
245 assert_eq!(repo, "Hello-World");
246 }
247
248 #[test]
249 fn test_parse_owner_repo_invalid_no_slash() {
250 assert!(parse_owner_repo("octocat").is_err());
251 }
252
253 #[test]
254 fn test_parse_owner_repo_invalid_empty_owner() {
255 assert!(parse_owner_repo("/repo").is_err());
256 }
257
258 #[test]
259 fn test_parse_owner_repo_invalid_empty_repo() {
260 assert!(parse_owner_repo("owner/").is_err());
261 }
262
263 #[test]
264 fn test_parse_github_reference_issue_full_url() {
265 let (owner, repo, number) = parse_github_reference(
266 ReferenceKind::Issue,
267 "https://github.com/octocat/Hello-World/issues/123",
268 None,
269 )
270 .unwrap();
271 assert_eq!(owner, "octocat");
272 assert_eq!(repo, "Hello-World");
273 assert_eq!(number, 123);
274 }
275
276 #[test]
277 fn test_parse_github_reference_issue_full_url_with_query() {
278 let (owner, repo, number) = parse_github_reference(
279 ReferenceKind::Issue,
280 "https://github.com/octocat/Hello-World/issues/123?foo=bar",
281 None,
282 )
283 .unwrap();
284 assert_eq!(owner, "octocat");
285 assert_eq!(repo, "Hello-World");
286 assert_eq!(number, 123);
287 }
288
289 #[test]
290 fn test_parse_github_reference_issue_full_url_with_fragment() {
291 let (owner, repo, number) = parse_github_reference(
292 ReferenceKind::Issue,
293 "https://github.com/octocat/Hello-World/issues/123#comment-456",
294 None,
295 )
296 .unwrap();
297 assert_eq!(owner, "octocat");
298 assert_eq!(repo, "Hello-World");
299 assert_eq!(number, 123);
300 }
301
302 #[test]
303 fn test_parse_github_reference_issue_short_form() {
304 let (owner, repo, number) =
305 parse_github_reference(ReferenceKind::Issue, "octocat/Hello-World#123", None).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_bare_number() {
313 let (owner, repo, number) =
314 parse_github_reference(ReferenceKind::Issue, "123", Some("octocat/Hello-World"))
315 .unwrap();
316 assert_eq!(owner, "octocat");
317 assert_eq!(repo, "Hello-World");
318 assert_eq!(number, 123);
319 }
320
321 #[test]
322 fn test_parse_github_reference_issue_bare_number_no_context() {
323 assert!(parse_github_reference(ReferenceKind::Issue, "123", None).is_err());
324 }
325
326 #[test]
327 fn test_parse_github_reference_pull_full_url() {
328 let (owner, repo, number) = parse_github_reference(
329 ReferenceKind::Pull,
330 "https://github.com/octocat/Hello-World/pull/456",
331 None,
332 )
333 .unwrap();
334 assert_eq!(owner, "octocat");
335 assert_eq!(repo, "Hello-World");
336 assert_eq!(number, 456);
337 }
338
339 #[test]
340 fn test_parse_github_reference_pull_short_form() {
341 let (owner, repo, number) =
342 parse_github_reference(ReferenceKind::Pull, "octocat/Hello-World#456", None).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_bare_number() {
350 let (owner, repo, number) =
351 parse_github_reference(ReferenceKind::Pull, "456", Some("octocat/Hello-World"))
352 .unwrap();
353 assert_eq!(owner, "octocat");
354 assert_eq!(repo, "Hello-World");
355 assert_eq!(number, 456);
356 }
357
358 #[test]
359 fn test_parse_github_reference_issue_wrong_kind_url() {
360 assert!(
362 parse_github_reference(
363 ReferenceKind::Issue,
364 "https://github.com/octocat/Hello-World/pull/123",
365 None
366 )
367 .is_err()
368 );
369 }
370
371 #[test]
372 fn test_parse_github_reference_pull_wrong_kind_url() {
373 assert!(
375 parse_github_reference(
376 ReferenceKind::Pull,
377 "https://github.com/octocat/Hello-World/issues/123",
378 None
379 )
380 .is_err()
381 );
382 }
383
384 #[test]
385 fn test_parse_github_reference_invalid_url() {
386 assert!(
387 parse_github_reference(
388 ReferenceKind::Issue,
389 "https://github.com/octocat/Hello-World/invalid/123",
390 None
391 )
392 .is_err()
393 );
394 }
395
396 #[test]
397 fn test_parse_github_reference_not_github_url() {
398 assert!(
399 parse_github_reference(
400 ReferenceKind::Issue,
401 "https://gitlab.com/octocat/Hello-World/issues/123",
402 None
403 )
404 .is_err()
405 );
406 }
407}