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