@mbe24/99problems 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +49 -9
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +107 -85
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +618 -118
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +225 -138
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +65 -7
  71. package/tests/integration.rs +404 -33
  72. package/src/source/github_issues.rs +0 -227
@@ -1,27 +1,105 @@
1
- /// Integration tests — require a live GitHub API token.
2
- /// Run with: GITHUB_TOKEN=ghp_... cargo test -- --include-ignored
3
- ///
4
- /// These tests use schemaorg/schemaorg#1842 as a known stable fixture.
5
-
1
+ /// Integration tests — live network tests for GitHub + GitLab + Jira.
2
+ /// Run with: cargo test -- --include-ignored
3
+ /// Optional env vars for higher-rate/authenticated calls:
4
+ /// - `GITHUB_TOKEN`=...
5
+ /// - `GITLAB_TOKEN`=...
6
+ /// - `JIRA_TOKEN`=...
7
+ /// - `BITBUCKET_TOKEN`=...
8
+ /// - `BITBUCKET_REPO`=`workspace/repo_slug`
9
+ /// - `BITBUCKET_PR_ID`=numeric pull request id
6
10
  #[cfg(test)]
7
11
  mod tests {
8
- use problems99::source::{Query, Source, github_issues::GitHubIssues};
12
+ use problems99::source::{
13
+ ContentKind, FetchRequest, FetchTarget, Source, bitbucket::BitbucketSource,
14
+ github::GitHubSource, gitlab::GitLabSource, jira::JiraSource,
15
+ };
9
16
 
10
- fn token() -> Option<String> {
11
- // Prefer env var, fall back to dotfile config (same resolution as the binary)
17
+ fn github_token() -> Option<String> {
12
18
  std::env::var("GITHUB_TOKEN").ok().or_else(|| {
13
- problems99::config::Config::load()
14
- .ok()
15
- .and_then(|c| c.token)
19
+ problems99::config::Config::load_with_options(problems99::config::ResolveOptions {
20
+ instance: Some("github"),
21
+ ..problems99::config::ResolveOptions::default()
22
+ })
23
+ .ok()
24
+ .and_then(|c| c.token)
16
25
  })
17
26
  }
18
27
 
28
+ fn gitlab_token() -> Option<String> {
29
+ std::env::var("GITLAB_TOKEN").ok()
30
+ }
31
+
32
+ fn jira_token() -> Option<String> {
33
+ std::env::var("JIRA_TOKEN").ok()
34
+ }
35
+
36
+ fn bitbucket_token() -> Option<String> {
37
+ std::env::var("BITBUCKET_TOKEN").ok()
38
+ }
39
+
40
+ fn required_env(var: &str) -> String {
41
+ std::env::var(var).unwrap_or_else(|_| panic!("missing required env var: {var}"))
42
+ }
43
+
44
+ fn is_public_jira_login_wall(err: &str) -> bool {
45
+ err.contains("non-JSON content-type 'text/html'")
46
+ && (err.contains("auth/login page")
47
+ || err.contains("login.jsp?permissionViolation")
48
+ || err.contains("id-frontend.prod-east.frontend.public.atl-paas.net"))
49
+ }
50
+
51
+ fn fail_public_jira_login_wall(test_name: &str, msg: &str) -> ! {
52
+ panic!(
53
+ "{test_name}: public Jira endpoint returned a login/auth wall instead of JSON. \
54
+ This indicates external endpoint/auth drift (not an adapter parsing bug). \
55
+ Response details: {msg}"
56
+ )
57
+ }
58
+
59
+ fn req_id(repo: &str, id: &str, include_review_comments: bool) -> FetchRequest {
60
+ FetchRequest {
61
+ target: FetchTarget::Id {
62
+ repo: repo.to_string(),
63
+ id: id.to_string(),
64
+ kind: ContentKind::Issue,
65
+ allow_fallback_to_pr: true,
66
+ },
67
+ per_page: 100,
68
+ token: github_token(),
69
+ account_email: None,
70
+ include_comments: true,
71
+ include_review_comments,
72
+ }
73
+ }
74
+
75
+ fn req_id_with_kind(
76
+ repo: &str,
77
+ id: &str,
78
+ kind: ContentKind,
79
+ allow_fallback_to_pr: bool,
80
+ ) -> FetchRequest {
81
+ FetchRequest {
82
+ target: FetchTarget::Id {
83
+ repo: repo.to_string(),
84
+ id: id.to_string(),
85
+ kind,
86
+ allow_fallback_to_pr,
87
+ },
88
+ per_page: 100,
89
+ token: github_token(),
90
+ account_email: None,
91
+ include_comments: true,
92
+ include_review_comments: false,
93
+ }
94
+ }
95
+
19
96
  #[test]
20
97
  #[ignore = "requires GITHUB_TOKEN and live network"]
21
- fn fetch_known_issue_1842() {
22
- let source = GitHubIssues::new().unwrap();
23
- let conv = source.fetch_one("schemaorg/schemaorg", 1842).unwrap();
24
- assert_eq!(conv.id, 1842);
98
+ fn github_fetch_known_issue_1842() {
99
+ let source = GitHubSource::new().unwrap();
100
+ let req = req_id("schemaorg/schemaorg", "1842", false);
101
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
102
+ assert_eq!(conv.id, "1842");
25
103
  assert_eq!(conv.title, "Online-only events");
26
104
  assert_eq!(conv.state, "closed");
27
105
  assert!(conv.body.is_some());
@@ -30,21 +108,19 @@ mod tests {
30
108
 
31
109
  #[test]
32
110
  #[ignore = "requires GITHUB_TOKEN and live network"]
33
- fn search_returns_results() {
34
- let source = GitHubIssues::new().unwrap();
35
- let query = Query::build(
36
- Some("is:issue state:closed EventSeries repo:schemaorg/schemaorg".into()),
37
- "issue",
38
- None,
39
- None,
40
- None,
41
- None,
42
- None,
43
- None,
44
- 10,
45
- token(),
46
- );
47
- let results = source.fetch(&query).unwrap();
111
+ fn github_search_returns_results() {
112
+ let source = GitHubSource::new().unwrap();
113
+ let req = FetchRequest {
114
+ target: FetchTarget::Search {
115
+ raw_query: "is:issue state:closed EventSeries repo:schemaorg/schemaorg".into(),
116
+ },
117
+ per_page: 10,
118
+ token: github_token(),
119
+ account_email: None,
120
+ include_comments: true,
121
+ include_review_comments: false,
122
+ };
123
+ let results = source.fetch(&req).unwrap();
48
124
  assert!(!results.is_empty());
49
125
  for conv in &results {
50
126
  assert!(!conv.title.is_empty());
@@ -54,9 +130,10 @@ mod tests {
54
130
 
55
131
  #[test]
56
132
  #[ignore = "requires GITHUB_TOKEN and live network"]
57
- fn fetch_one_comment_has_author_and_body() {
58
- let source = GitHubIssues::new().unwrap();
59
- let conv = source.fetch_one("schemaorg/schemaorg", 1842).unwrap();
133
+ fn github_fetch_one_comment_has_author_and_body() {
134
+ let source = GitHubSource::new().unwrap();
135
+ let req = req_id("schemaorg/schemaorg", "1842", false);
136
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
60
137
  let first = conv
61
138
  .comments
62
139
  .first()
@@ -64,4 +141,298 @@ mod tests {
64
141
  assert!(first.author.is_some());
65
142
  assert!(!first.created_at.is_empty());
66
143
  }
144
+
145
+ #[test]
146
+ #[ignore = "requires GITHUB_TOKEN and live network"]
147
+ fn github_fetch_pr_2402_default_issue_comments_only() {
148
+ let source = GitHubSource::new().unwrap();
149
+ let req = req_id("github/gitignore", "2402", false);
150
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
151
+
152
+ assert_eq!(conv.id, "2402");
153
+ assert!(!conv.title.is_empty());
154
+ assert!(!conv.state.is_empty());
155
+ assert!(!conv.comments.is_empty());
156
+ assert!(!conv.comments.iter().any(|c| {
157
+ c.kind.as_deref() == Some("review_comment")
158
+ || c.review_path.is_some()
159
+ || c.review_line.is_some()
160
+ || c.review_side.is_some()
161
+ }));
162
+ }
163
+
164
+ #[test]
165
+ #[ignore = "requires GITHUB_TOKEN and live network"]
166
+ fn github_fetch_pr_2402_with_review_comments() {
167
+ let source = GitHubSource::new().unwrap();
168
+ let req = req_id("github/gitignore", "2402", true);
169
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
170
+
171
+ assert_eq!(conv.id, "2402");
172
+ assert!(
173
+ conv.comments
174
+ .iter()
175
+ .any(|c| c.kind.as_deref() == Some("review_comment"))
176
+ );
177
+ assert!(conv.comments.iter().any(|c| c.review_path.is_some()));
178
+ }
179
+
180
+ #[test]
181
+ #[ignore = "requires GITHUB_TOKEN and live network"]
182
+ fn github_search_pr_query_includes_2402() {
183
+ let source = GitHubSource::new().unwrap();
184
+ let req = FetchRequest {
185
+ target: FetchTarget::Search {
186
+ raw_query: "repo:github/gitignore is:pr 2402".into(),
187
+ },
188
+ per_page: 10,
189
+ token: github_token(),
190
+ account_email: None,
191
+ include_comments: true,
192
+ include_review_comments: false,
193
+ };
194
+ let results = source.fetch(&req).unwrap();
195
+ assert!(!results.is_empty());
196
+ assert!(results.iter().any(|c| c.id == "2402"));
197
+ }
198
+
199
+ #[test]
200
+ #[ignore = "requires GITHUB_TOKEN and live network"]
201
+ fn github_fetch_issue_as_pr_errors_when_kind_is_explicit() {
202
+ let source = GitHubSource::new().unwrap();
203
+ let req = req_id_with_kind("schemaorg/schemaorg", "1842", ContentKind::Pr, false);
204
+ let err = source.fetch(&req).unwrap_err().to_string();
205
+ assert!(err.contains("not a pull request"));
206
+ }
207
+
208
+ #[test]
209
+ #[ignore = "requires GITHUB_TOKEN and live network"]
210
+ fn github_fetch_pr_as_issue_errors_when_fallback_is_disabled() {
211
+ let source = GitHubSource::new().unwrap();
212
+ let req = req_id_with_kind("github/gitignore", "2402", ContentKind::Issue, false);
213
+ let err = source.fetch(&req).unwrap_err().to_string();
214
+ assert!(err.contains("is a pull request"));
215
+ }
216
+
217
+ #[test]
218
+ #[ignore = "requires live network (GITLAB_TOKEN recommended for comments)"]
219
+ fn gitlab_fetch_issue_6() {
220
+ let source = GitLabSource::new(None).unwrap();
221
+ let req = FetchRequest {
222
+ target: FetchTarget::Id {
223
+ repo: "veloren/veloren".into(),
224
+ id: "6".into(),
225
+ kind: ContentKind::Issue,
226
+ allow_fallback_to_pr: true,
227
+ },
228
+ per_page: 50,
229
+ token: gitlab_token(),
230
+ account_email: None,
231
+ include_comments: true,
232
+ include_review_comments: false,
233
+ };
234
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
235
+ assert_eq!(conv.id, "6");
236
+ assert!(!conv.title.is_empty());
237
+ }
238
+
239
+ #[test]
240
+ #[ignore = "requires live network (GITLAB_TOKEN recommended for comments)"]
241
+ fn gitlab_fetch_mr_6() {
242
+ let source = GitLabSource::new(None).unwrap();
243
+ let req = FetchRequest {
244
+ target: FetchTarget::Id {
245
+ repo: "veloren/veloren".into(),
246
+ id: "6".into(),
247
+ kind: ContentKind::Pr,
248
+ allow_fallback_to_pr: false,
249
+ },
250
+ per_page: 50,
251
+ token: gitlab_token(),
252
+ account_email: None,
253
+ include_comments: true,
254
+ include_review_comments: true,
255
+ };
256
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
257
+ assert_eq!(conv.id, "6");
258
+ assert!(!conv.title.is_empty());
259
+ }
260
+
261
+ #[test]
262
+ #[ignore = "requires live network (GITLAB_TOKEN recommended for comments)"]
263
+ fn gitlab_search_issue_results() {
264
+ let source = GitLabSource::new(None).unwrap();
265
+ let req = FetchRequest {
266
+ target: FetchTarget::Search {
267
+ raw_query: "repo:veloren/veloren is:issue state:closed terrain".into(),
268
+ },
269
+ per_page: 10,
270
+ token: gitlab_token(),
271
+ account_email: None,
272
+ include_comments: true,
273
+ include_review_comments: false,
274
+ };
275
+ let results = source.fetch(&req).unwrap();
276
+ assert!(!results.is_empty());
277
+ }
278
+
279
+ #[test]
280
+ #[ignore = "requires live network (GITLAB_TOKEN recommended for comments)"]
281
+ fn gitlab_search_mr_results() {
282
+ let source = GitLabSource::new(None).unwrap();
283
+ let req = FetchRequest {
284
+ target: FetchTarget::Search {
285
+ raw_query: "repo:veloren/veloren is:pr state:closed netcode".into(),
286
+ },
287
+ per_page: 10,
288
+ token: gitlab_token(),
289
+ account_email: None,
290
+ include_comments: true,
291
+ include_review_comments: true,
292
+ };
293
+ let results = source.fetch(&req).unwrap();
294
+ assert!(!results.is_empty());
295
+ }
296
+
297
+ #[test]
298
+ #[ignore = "requires live network (public Jira endpoint)"]
299
+ fn jira_fetch_public_issue_cloud_12817() {
300
+ let source = JiraSource::new(Some("https://jira.atlassian.com".into())).unwrap();
301
+ let req = FetchRequest {
302
+ target: FetchTarget::Id {
303
+ repo: String::new(),
304
+ id: "CLOUD-12817".into(),
305
+ kind: ContentKind::Issue,
306
+ allow_fallback_to_pr: false,
307
+ },
308
+ per_page: 50,
309
+ token: jira_token(),
310
+ account_email: None,
311
+ include_comments: true,
312
+ include_review_comments: false,
313
+ };
314
+ let conv = match source.fetch(&req) {
315
+ Ok(results) => results.into_iter().next().unwrap(),
316
+ Err(err) => {
317
+ let msg = err.to_string();
318
+ if is_public_jira_login_wall(&msg) {
319
+ fail_public_jira_login_wall("jira_fetch_public_issue_cloud_12817", &msg);
320
+ }
321
+ panic!("unexpected Jira issue fetch error: {msg}");
322
+ }
323
+ };
324
+ assert_eq!(conv.id, "CLOUD-12817");
325
+ assert!(!conv.title.is_empty());
326
+ }
327
+
328
+ #[test]
329
+ #[ignore = "requires live network (public Jira endpoint)"]
330
+ fn jira_search_public_project() {
331
+ let source = JiraSource::new(Some("https://jira.atlassian.com".into())).unwrap();
332
+ let req = FetchRequest {
333
+ target: FetchTarget::Search {
334
+ raw_query: "repo:CLOUD state:closed CLOUD-12817".into(),
335
+ },
336
+ per_page: 5,
337
+ token: jira_token(),
338
+ account_email: None,
339
+ include_comments: true,
340
+ include_review_comments: false,
341
+ };
342
+ let results = match source.fetch(&req) {
343
+ Ok(results) => results,
344
+ Err(err) => {
345
+ let msg = err.to_string();
346
+ if is_public_jira_login_wall(&msg) {
347
+ fail_public_jira_login_wall("jira_search_public_project", &msg);
348
+ }
349
+ panic!("unexpected Jira search error: {msg}");
350
+ }
351
+ };
352
+ assert!(!results.is_empty());
353
+ assert!(results.iter().any(|c| c.id == "CLOUD-12817"));
354
+ }
355
+
356
+ #[test]
357
+ fn jira_rejects_pr_kind() {
358
+ let source = JiraSource::new(Some("https://jira.atlassian.com".into())).unwrap();
359
+ let req = FetchRequest {
360
+ target: FetchTarget::Id {
361
+ repo: String::new(),
362
+ id: "CLOUD-12817".into(),
363
+ kind: ContentKind::Pr,
364
+ allow_fallback_to_pr: false,
365
+ },
366
+ per_page: 5,
367
+ token: None,
368
+ account_email: None,
369
+ include_comments: true,
370
+ include_review_comments: false,
371
+ };
372
+ let err = source.fetch(&req).unwrap_err().to_string();
373
+ assert!(err.contains("does not support pull requests"));
374
+ }
375
+
376
+ #[test]
377
+ #[ignore = "requires live network and BITBUCKET_REPO/BITBUCKET_PR_ID env vars"]
378
+ fn bitbucket_cloud_fetch_pr_by_id() {
379
+ let source = BitbucketSource::new(None, Some("cloud".into())).unwrap();
380
+ let repo = required_env("BITBUCKET_REPO");
381
+ let pr_id = required_env("BITBUCKET_PR_ID");
382
+ let req = FetchRequest {
383
+ target: FetchTarget::Id {
384
+ repo,
385
+ id: pr_id.clone(),
386
+ kind: ContentKind::Pr,
387
+ allow_fallback_to_pr: false,
388
+ },
389
+ per_page: 50,
390
+ token: bitbucket_token(),
391
+ account_email: None,
392
+ include_comments: true,
393
+ include_review_comments: true,
394
+ };
395
+ let conv = source.fetch(&req).unwrap().into_iter().next().unwrap();
396
+ assert_eq!(conv.id, pr_id);
397
+ assert!(!conv.title.is_empty());
398
+ }
399
+
400
+ #[test]
401
+ #[ignore = "requires live network and BITBUCKET_REPO env var"]
402
+ fn bitbucket_cloud_search_pr_results() {
403
+ let source = BitbucketSource::new(None, Some("cloud".into())).unwrap();
404
+ let repo = required_env("BITBUCKET_REPO");
405
+ let req = FetchRequest {
406
+ target: FetchTarget::Search {
407
+ raw_query: format!("repo:{repo} is:pr state:all"),
408
+ },
409
+ per_page: 10,
410
+ token: bitbucket_token(),
411
+ account_email: None,
412
+ include_comments: false,
413
+ include_review_comments: false,
414
+ };
415
+ let results = source.fetch(&req).unwrap();
416
+ assert!(!results.is_empty());
417
+ }
418
+
419
+ #[test]
420
+ fn bitbucket_cloud_rejects_issue_kind() {
421
+ let source = BitbucketSource::new(None, Some("cloud".into())).unwrap();
422
+ let req = FetchRequest {
423
+ target: FetchTarget::Id {
424
+ repo: "workspace/repo".into(),
425
+ id: "1".into(),
426
+ kind: ContentKind::Issue,
427
+ allow_fallback_to_pr: false,
428
+ },
429
+ per_page: 10,
430
+ token: None,
431
+ account_email: None,
432
+ include_comments: false,
433
+ include_review_comments: false,
434
+ };
435
+ let err = source.fetch(&req).unwrap_err().to_string();
436
+ assert!(err.contains("supports pull requests only"));
437
+ }
67
438
  }
@@ -1,227 +0,0 @@
1
- use anyhow::{Result, anyhow};
2
- use reqwest::blocking::{Client, RequestBuilder};
3
- use serde::Deserialize;
4
-
5
- use super::{Query, Source};
6
- use crate::model::{Comment, Conversation};
7
-
8
- const GITHUB_API_BASE: &str = "https://api.github.com";
9
- const GITHUB_API_VERSION: &str = "2022-11-28";
10
- const PAGE_SIZE: u32 = 100;
11
-
12
- pub struct GitHubIssues {
13
- client: Client,
14
- }
15
-
16
- impl GitHubIssues {
17
- pub fn new() -> Result<Self> {
18
- let client = Client::builder()
19
- .user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
20
- .build()?;
21
- Ok(Self { client })
22
- }
23
-
24
- /// Adds Authorization + API version headers when a token is present.
25
- fn apply_auth(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
26
- match token.as_ref() {
27
- Some(t) => req
28
- .header("Authorization", format!("Bearer {t}"))
29
- .header("X-GitHub-Api-Version", GITHUB_API_VERSION),
30
- None => req,
31
- }
32
- }
33
-
34
- fn get_pages<T: for<'de> Deserialize<'de>>(
35
- &self,
36
- url: &str,
37
- token: &Option<String>,
38
- per_page: u32,
39
- ) -> Result<Vec<T>> {
40
- let mut results = vec![];
41
- let mut page = 1u32;
42
-
43
- loop {
44
- let req = self.client.get(url).query(&[
45
- ("per_page", per_page.to_string()),
46
- ("page", page.to_string()),
47
- ]);
48
- let req = Self::apply_auth(req, token);
49
- let resp = req.send()?;
50
-
51
- if !resp.status().is_success() {
52
- return Err(anyhow!(
53
- "GitHub API error {}: {}",
54
- resp.status(),
55
- resp.text()?
56
- ));
57
- }
58
-
59
- let has_next = resp
60
- .headers()
61
- .get("link")
62
- .and_then(|v| v.to_str().ok())
63
- .map(|l| l.contains(r#"rel="next""#))
64
- .unwrap_or(false);
65
-
66
- let items: Vec<T> = resp.json()?;
67
- let done = items.is_empty() || !has_next;
68
- results.extend(items);
69
- if done {
70
- break;
71
- }
72
- page += 1;
73
- }
74
-
75
- Ok(results)
76
- }
77
- }
78
-
79
- // --- GitHub API response shapes ---
80
-
81
- #[derive(Deserialize)]
82
- struct SearchResponse {
83
- items: Vec<IssueItem>,
84
- }
85
-
86
- #[derive(Deserialize)]
87
- struct IssueItem {
88
- number: u64,
89
- title: String,
90
- state: String,
91
- body: Option<String>,
92
- }
93
-
94
- #[derive(Deserialize)]
95
- struct CommentItem {
96
- user: Option<UserItem>,
97
- created_at: String,
98
- body: Option<String>,
99
- }
100
-
101
- #[derive(Deserialize)]
102
- struct UserItem {
103
- login: String,
104
- }
105
-
106
- impl Source for GitHubIssues {
107
- fn fetch(&self, query: &Query) -> Result<Vec<Conversation>> {
108
- let search_url = format!("{GITHUB_API_BASE}/search/issues");
109
- let mut page = 1u32;
110
- let mut all_issues: Vec<IssueItem> = vec![];
111
-
112
- loop {
113
- let req = self.client.get(&search_url).query(&[
114
- ("q", query.raw.as_str()),
115
- ("per_page", "100"),
116
- ("page", &page.to_string()),
117
- ]);
118
- let req = Self::apply_auth(req, &query.token);
119
- let resp = req.send()?;
120
-
121
- if !resp.status().is_success() {
122
- return Err(anyhow!(
123
- "GitHub search error {}: {}",
124
- resp.status(),
125
- resp.text()?
126
- ));
127
- }
128
-
129
- let search: SearchResponse = resp.json()?;
130
- let done = search.items.len() < PAGE_SIZE as usize;
131
- all_issues.extend(search.items);
132
- if done {
133
- break;
134
- }
135
- page += 1;
136
- }
137
-
138
- // Determine repo from query for comment fetching
139
- let repo = extract_repo(&query.raw).ok_or_else(|| {
140
- anyhow!("No repo: found in query. Use --repo or include 'repo:owner/name' in -q")
141
- })?;
142
-
143
- let mut conversations = vec![];
144
- for issue in all_issues {
145
- let comments_url = format!(
146
- "{GITHUB_API_BASE}/repos/{repo}/issues/{}/comments",
147
- issue.number
148
- );
149
- let raw_comments: Vec<CommentItem> =
150
- self.get_pages(&comments_url, &query.token, PAGE_SIZE)?;
151
-
152
- conversations.push(Conversation {
153
- id: issue.number,
154
- title: issue.title,
155
- state: issue.state,
156
- body: issue.body,
157
- comments: raw_comments
158
- .into_iter()
159
- .map(|c| Comment {
160
- author: c.user.map(|u| u.login),
161
- created_at: c.created_at,
162
- body: c.body,
163
- })
164
- .collect(),
165
- });
166
- }
167
-
168
- Ok(conversations)
169
- }
170
-
171
- fn fetch_one(&self, repo: &str, issue_id: u64) -> Result<Conversation> {
172
- let issue_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{issue_id}");
173
- let resp = self.client.get(&issue_url).send()?;
174
- if !resp.status().is_success() {
175
- return Err(anyhow!(
176
- "GitHub issue error {}: {}",
177
- resp.status(),
178
- resp.text()?
179
- ));
180
- }
181
- let issue: IssueItem = resp.json()?;
182
-
183
- let comments_url = format!("{GITHUB_API_BASE}/repos/{repo}/issues/{issue_id}/comments");
184
- let raw_comments: Vec<CommentItem> = self.get_pages(&comments_url, &None, PAGE_SIZE)?;
185
-
186
- Ok(Conversation {
187
- id: issue.number,
188
- title: issue.title,
189
- state: issue.state,
190
- body: issue.body,
191
- comments: raw_comments
192
- .into_iter()
193
- .map(|c| Comment {
194
- author: c.user.map(|u| u.login),
195
- created_at: c.created_at,
196
- body: c.body,
197
- })
198
- .collect(),
199
- })
200
- }
201
- }
202
-
203
- /// Extract `owner/repo` from a query string containing `repo:owner/repo`.
204
- pub fn extract_repo(query: &str) -> Option<String> {
205
- query
206
- .split_whitespace()
207
- .find(|t| t.starts_with("repo:"))
208
- .map(|t| t.trim_start_matches("repo:").to_string())
209
- }
210
-
211
- #[cfg(test)]
212
- mod tests {
213
- use super::*;
214
-
215
- #[test]
216
- fn extract_repo_finds_token() {
217
- assert_eq!(
218
- extract_repo("is:issue state:closed repo:owner/repo Event"),
219
- Some("owner/repo".into())
220
- );
221
- }
222
-
223
- #[test]
224
- fn extract_repo_returns_none_when_absent() {
225
- assert_eq!(extract_repo("is:issue state:closed Event"), None);
226
- }
227
- }