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
80pub fn parse_github_reference(
97 kind: ReferenceKind,
98 input: &str,
99 repo_context: Option<&str>,
100) -> Result<(String, String, u64)> {
101 let input = input.trim();
102
103 if input.starts_with("https://") || input.starts_with("http://") {
105 let clean_url = input.split('#').next().unwrap_or(input);
107 let clean_url = clean_url.split('?').next().unwrap_or(clean_url);
108
109 let parts: Vec<&str> = clean_url.trim_end_matches('/').split('/').collect();
111
112 if parts.len() < 7 {
114 anyhow::bail!(
115 "Invalid GitHub {} URL format.\n\
116 Expected: https://github.com/owner/repo/{}/123\n\
117 Got: {input}",
118 kind.display_name(),
119 kind.url_segment()
120 );
121 }
122
123 if !parts[2].contains("github.com") {
125 anyhow::bail!(
126 "URL must be a GitHub {} URL.\n\
127 Expected: https://github.com/owner/repo/{}/123\n\
128 Got: {input}",
129 kind.display_name(),
130 kind.url_segment()
131 );
132 }
133
134 if parts[5] != kind.url_segment() {
136 anyhow::bail!(
137 "URL must point to a GitHub {}.\n\
138 Expected: https://github.com/owner/repo/{}/123\n\
139 Got: {input}",
140 kind.display_name(),
141 kind.url_segment()
142 );
143 }
144
145 let owner = parts[3].to_string();
146 let repo = parts[4].to_string();
147 let number: u64 = parts[6].parse().with_context(|| {
148 format!(
149 "Invalid {} number '{}' in URL.\n\
150 Expected a numeric {} number.",
151 kind.display_name(),
152 parts[6],
153 kind.display_name()
154 )
155 })?;
156
157 debug!(owner = %owner, repo = %repo, number = number, "Parsed {} URL", kind.display_name());
158 return Ok((owner, repo, number));
159 }
160
161 if let Some(hash_pos) = input.find('#') {
163 let owner_repo_part = &input[..hash_pos];
164 let number_part = &input[hash_pos + 1..];
165
166 let (owner, repo) = parse_owner_repo(owner_repo_part)?;
167 let number: u64 = number_part.parse().with_context(|| {
168 format!(
169 "Invalid {} number '{number_part}' in short form.\n\
170 Expected: owner/repo#123\n\
171 Got: {input}",
172 kind.display_name()
173 )
174 })?;
175
176 debug!(owner = %owner, repo = %repo, number = number, "Parsed short-form {} reference", kind.display_name());
177 return Ok((owner, repo, number));
178 }
179
180 if let Ok(number) = input.parse::<u64>() {
182 let repo_context = repo_context.ok_or_else(|| {
183 anyhow::anyhow!(
184 "Bare {} number requires repository context.\n\
185 Use one of:\n\
186 - Full URL: https://github.com/owner/repo/{}/123\n\
187 - Short form: owner/repo#123\n\
188 - Bare number with --repo flag: 123 --repo owner/repo\n\
189 Got: {input}",
190 kind.display_name(),
191 kind.url_segment()
192 )
193 })?;
194
195 let (owner, repo) = parse_owner_repo(repo_context)?;
196 debug!(owner = %owner, repo = %repo, number = number, "Parsed bare {} number", kind.display_name());
197 return Ok((owner, repo, number));
198 }
199
200 anyhow::bail!(
202 "Invalid {} reference format.\n\
203 Expected one of:\n\
204 - Full URL: https://github.com/owner/repo/{}/123\n\
205 - Short form: owner/repo#123\n\
206 - Bare number with --repo flag: 123 --repo owner/repo\n\
207 Got: {input}",
208 kind.display_name(),
209 kind.url_segment()
210 );
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_parse_owner_repo_valid() {
219 let (owner, repo) = parse_owner_repo("octocat/Hello-World").unwrap();
220 assert_eq!(owner, "octocat");
221 assert_eq!(repo, "Hello-World");
222 }
223
224 #[test]
225 fn test_parse_owner_repo_invalid_no_slash() {
226 assert!(parse_owner_repo("octocat").is_err());
227 }
228
229 #[test]
230 fn test_parse_owner_repo_invalid_empty_owner() {
231 assert!(parse_owner_repo("/repo").is_err());
232 }
233
234 #[test]
235 fn test_parse_owner_repo_invalid_empty_repo() {
236 assert!(parse_owner_repo("owner/").is_err());
237 }
238
239 #[test]
240 fn test_parse_github_reference_issue_full_url() {
241 let (owner, repo, number) = parse_github_reference(
242 ReferenceKind::Issue,
243 "https://github.com/octocat/Hello-World/issues/123",
244 None,
245 )
246 .unwrap();
247 assert_eq!(owner, "octocat");
248 assert_eq!(repo, "Hello-World");
249 assert_eq!(number, 123);
250 }
251
252 #[test]
253 fn test_parse_github_reference_issue_full_url_with_query() {
254 let (owner, repo, number) = parse_github_reference(
255 ReferenceKind::Issue,
256 "https://github.com/octocat/Hello-World/issues/123?foo=bar",
257 None,
258 )
259 .unwrap();
260 assert_eq!(owner, "octocat");
261 assert_eq!(repo, "Hello-World");
262 assert_eq!(number, 123);
263 }
264
265 #[test]
266 fn test_parse_github_reference_issue_full_url_with_fragment() {
267 let (owner, repo, number) = parse_github_reference(
268 ReferenceKind::Issue,
269 "https://github.com/octocat/Hello-World/issues/123#comment-456",
270 None,
271 )
272 .unwrap();
273 assert_eq!(owner, "octocat");
274 assert_eq!(repo, "Hello-World");
275 assert_eq!(number, 123);
276 }
277
278 #[test]
279 fn test_parse_github_reference_issue_short_form() {
280 let (owner, repo, number) =
281 parse_github_reference(ReferenceKind::Issue, "octocat/Hello-World#123", None).unwrap();
282 assert_eq!(owner, "octocat");
283 assert_eq!(repo, "Hello-World");
284 assert_eq!(number, 123);
285 }
286
287 #[test]
288 fn test_parse_github_reference_issue_bare_number() {
289 let (owner, repo, number) =
290 parse_github_reference(ReferenceKind::Issue, "123", Some("octocat/Hello-World"))
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_bare_number_no_context() {
299 assert!(parse_github_reference(ReferenceKind::Issue, "123", None).is_err());
300 }
301
302 #[test]
303 fn test_parse_github_reference_pull_full_url() {
304 let (owner, repo, number) = parse_github_reference(
305 ReferenceKind::Pull,
306 "https://github.com/octocat/Hello-World/pull/456",
307 None,
308 )
309 .unwrap();
310 assert_eq!(owner, "octocat");
311 assert_eq!(repo, "Hello-World");
312 assert_eq!(number, 456);
313 }
314
315 #[test]
316 fn test_parse_github_reference_pull_short_form() {
317 let (owner, repo, number) =
318 parse_github_reference(ReferenceKind::Pull, "octocat/Hello-World#456", None).unwrap();
319 assert_eq!(owner, "octocat");
320 assert_eq!(repo, "Hello-World");
321 assert_eq!(number, 456);
322 }
323
324 #[test]
325 fn test_parse_github_reference_pull_bare_number() {
326 let (owner, repo, number) =
327 parse_github_reference(ReferenceKind::Pull, "456", Some("octocat/Hello-World"))
328 .unwrap();
329 assert_eq!(owner, "octocat");
330 assert_eq!(repo, "Hello-World");
331 assert_eq!(number, 456);
332 }
333
334 #[test]
335 fn test_parse_github_reference_issue_wrong_kind_url() {
336 assert!(
338 parse_github_reference(
339 ReferenceKind::Issue,
340 "https://github.com/octocat/Hello-World/pull/123",
341 None
342 )
343 .is_err()
344 );
345 }
346
347 #[test]
348 fn test_parse_github_reference_pull_wrong_kind_url() {
349 assert!(
351 parse_github_reference(
352 ReferenceKind::Pull,
353 "https://github.com/octocat/Hello-World/issues/123",
354 None
355 )
356 .is_err()
357 );
358 }
359
360 #[test]
361 fn test_parse_github_reference_invalid_url() {
362 assert!(
363 parse_github_reference(
364 ReferenceKind::Issue,
365 "https://github.com/octocat/Hello-World/invalid/123",
366 None
367 )
368 .is_err()
369 );
370 }
371
372 #[test]
373 fn test_parse_github_reference_not_github_url() {
374 assert!(
375 parse_github_reference(
376 ReferenceKind::Issue,
377 "https://gitlab.com/octocat/Hello-World/issues/123",
378 None
379 )
380 .is_err()
381 );
382 }
383}