@mbe24/99problems 0.1.1 → 0.3.0

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 +7 -7
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +109 -72
  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 +641 -42
  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 +230 -91
  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 +179 -24
  71. package/tests/integration.rs +406 -26
  72. package/src/source/github_issues.rs +0 -232
@@ -1,22 +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
+ };
16
+
17
+ fn github_token() -> Option<String> {
18
+ std::env::var("GITHUB_TOKEN").ok().or_else(|| {
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)
25
+ })
26
+ }
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
+ }
9
58
 
10
- fn token() -> Option<String> {
11
- std::env::var("GITHUB_TOKEN").ok()
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
+ }
12
94
  }
13
95
 
14
96
  #[test]
15
97
  #[ignore = "requires GITHUB_TOKEN and live network"]
16
- fn fetch_known_issue_1842() {
17
- let source = GitHubIssues::new().unwrap();
18
- let conv = source.fetch_one("schemaorg/schemaorg", 1842).unwrap();
19
- 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");
20
103
  assert_eq!(conv.title, "Online-only events");
21
104
  assert_eq!(conv.state, "closed");
22
105
  assert!(conv.body.is_some());
@@ -25,17 +108,19 @@ mod tests {
25
108
 
26
109
  #[test]
27
110
  #[ignore = "requires GITHUB_TOKEN and live network"]
28
- fn search_returns_results() {
29
- let source = GitHubIssues::new().unwrap();
30
- let query = Query::build(
31
- Some("is:issue state:closed EventSeries repo:schemaorg/schemaorg".into()),
32
- None,
33
- None,
34
- None,
35
- 10,
36
- token(),
37
- );
38
- 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();
39
124
  assert!(!results.is_empty());
40
125
  for conv in &results {
41
126
  assert!(!conv.title.is_empty());
@@ -45,9 +130,10 @@ mod tests {
45
130
 
46
131
  #[test]
47
132
  #[ignore = "requires GITHUB_TOKEN and live network"]
48
- fn fetch_one_comment_has_author_and_body() {
49
- let source = GitHubIssues::new().unwrap();
50
- 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();
51
137
  let first = conv
52
138
  .comments
53
139
  .first()
@@ -55,4 +141,298 @@ mod tests {
55
141
  assert!(first.author.is_some());
56
142
  assert!(!first.created_at.is_empty());
57
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
+ }
58
438
  }
@@ -1,232 +0,0 @@
1
- use anyhow::{Result, anyhow};
2
- use reqwest::blocking::Client;
3
- use serde::Deserialize;
4
-
5
- use super::{Query, Source};
6
- use crate::model::{Comment, Conversation};
7
-
8
- pub struct GitHubIssues {
9
- client: Client,
10
- }
11
-
12
- impl GitHubIssues {
13
- pub fn new() -> Result<Self> {
14
- let client = Client::builder()
15
- .user_agent("99problems-cli/0.1.0")
16
- .build()?;
17
- Ok(Self { client })
18
- }
19
-
20
- fn auth_header(token: &Option<String>) -> Option<String> {
21
- token.as_ref().map(|t| format!("Bearer {t}"))
22
- }
23
-
24
- fn get_pages<T: for<'de> Deserialize<'de>>(
25
- &self,
26
- url: &str,
27
- token: &Option<String>,
28
- per_page: u32,
29
- ) -> Result<Vec<T>> {
30
- let mut results = vec![];
31
- let mut page = 1u32;
32
-
33
- loop {
34
- let mut req = self.client.get(url).query(&[
35
- ("per_page", per_page.to_string()),
36
- ("page", page.to_string()),
37
- ]);
38
-
39
- if let Some(auth) = Self::auth_header(token) {
40
- req = req
41
- .header("Authorization", auth)
42
- .header("X-GitHub-Api-Version", "2022-11-28");
43
- }
44
-
45
- let resp = req.send()?;
46
-
47
- if !resp.status().is_success() {
48
- return Err(anyhow!(
49
- "GitHub API error {}: {}",
50
- resp.status(),
51
- resp.text()?
52
- ));
53
- }
54
-
55
- let has_next = resp
56
- .headers()
57
- .get("link")
58
- .and_then(|v| v.to_str().ok())
59
- .map(|l| l.contains(r#"rel="next""#))
60
- .unwrap_or(false);
61
-
62
- let items: Vec<T> = resp.json()?;
63
- let done = items.is_empty() || !has_next;
64
- results.extend(items);
65
- if done {
66
- break;
67
- }
68
- page += 1;
69
- }
70
-
71
- Ok(results)
72
- }
73
- }
74
-
75
- // --- GitHub API response shapes ---
76
-
77
- #[derive(Deserialize)]
78
- struct SearchResponse {
79
- items: Vec<IssueItem>,
80
- }
81
-
82
- #[derive(Deserialize)]
83
- struct IssueItem {
84
- number: u64,
85
- title: String,
86
- state: String,
87
- body: Option<String>,
88
- }
89
-
90
- #[derive(Deserialize)]
91
- struct CommentItem {
92
- user: Option<UserItem>,
93
- created_at: String,
94
- body: Option<String>,
95
- }
96
-
97
- #[derive(Deserialize)]
98
- struct UserItem {
99
- login: String,
100
- }
101
-
102
- impl Source for GitHubIssues {
103
- fn fetch(&self, query: &Query) -> Result<Vec<Conversation>> {
104
- let search_url = "https://api.github.com/search/issues";
105
- let mut page = 1u32;
106
- let mut all_issues: Vec<IssueItem> = vec![];
107
-
108
- loop {
109
- let mut req = self.client.get(search_url).query(&[
110
- ("q", query.raw.as_str()),
111
- ("per_page", "100"),
112
- ("page", &page.to_string()),
113
- ]);
114
-
115
- if let Some(auth) = Self::auth_header(&query.token) {
116
- req = req
117
- .header("Authorization", auth)
118
- .header("X-GitHub-Api-Version", "2022-11-28");
119
- }
120
-
121
- let resp = req.send()?;
122
- if !resp.status().is_success() {
123
- return Err(anyhow!(
124
- "GitHub search error {}: {}",
125
- resp.status(),
126
- resp.text()?
127
- ));
128
- }
129
-
130
- let search: SearchResponse = resp.json()?;
131
- let done = search.items.len() < 100;
132
- all_issues.extend(search.items);
133
- if done {
134
- break;
135
- }
136
- page += 1;
137
- }
138
-
139
- // Determine repo from query for comment fetching
140
- let repo = extract_repo(&query.raw).ok_or_else(|| {
141
- anyhow!("No repo: found in query. Use --repo or include 'repo:owner/name' in -q")
142
- })?;
143
-
144
- let mut conversations = vec![];
145
- for issue in all_issues {
146
- let comments_url = format!(
147
- "https://api.github.com/repos/{repo}/issues/{}/comments",
148
- issue.number
149
- );
150
- let raw_comments: Vec<CommentItem> =
151
- self.get_pages(&comments_url, &query.token, 100)?;
152
-
153
- conversations.push(Conversation {
154
- id: issue.number,
155
- title: issue.title,
156
- state: issue.state,
157
- body: issue.body,
158
- comments: raw_comments
159
- .into_iter()
160
- .map(|c| Comment {
161
- author: c.user.map(|u| u.login),
162
- created_at: c.created_at,
163
- body: c.body,
164
- })
165
- .collect(),
166
- });
167
- }
168
-
169
- Ok(conversations)
170
- }
171
-
172
- fn fetch_one(&self, repo: &str, issue_id: u64) -> Result<Conversation> {
173
- let issue_url = format!("https://api.github.com/repos/{repo}/issues/{issue_id}");
174
- let req = self.client.get(&issue_url);
175
- // token is not stored on struct, so we pass None here — callers set it via Query
176
- // For fetch_one we skip auth (public repos). Callers who need auth should use fetch().
177
- let resp = req.send()?;
178
- if !resp.status().is_success() {
179
- return Err(anyhow!(
180
- "GitHub issue error {}: {}",
181
- resp.status(),
182
- resp.text()?
183
- ));
184
- }
185
- let issue: IssueItem = resp.json()?;
186
-
187
- let comments_url =
188
- format!("https://api.github.com/repos/{repo}/issues/{issue_id}/comments");
189
- let raw_comments: Vec<CommentItem> = self.get_pages(&comments_url, &None, 100)?;
190
-
191
- Ok(Conversation {
192
- id: issue.number,
193
- title: issue.title,
194
- state: issue.state,
195
- body: issue.body,
196
- comments: raw_comments
197
- .into_iter()
198
- .map(|c| Comment {
199
- author: c.user.map(|u| u.login),
200
- created_at: c.created_at,
201
- body: c.body,
202
- })
203
- .collect(),
204
- })
205
- }
206
- }
207
-
208
- /// Extract `owner/repo` from a query string containing `repo:owner/repo`.
209
- pub fn extract_repo(query: &str) -> Option<String> {
210
- query
211
- .split_whitespace()
212
- .find(|t| t.starts_with("repo:"))
213
- .map(|t| t.trim_start_matches("repo:").to_string())
214
- }
215
-
216
- #[cfg(test)]
217
- mod tests {
218
- use super::*;
219
-
220
- #[test]
221
- fn extract_repo_finds_token() {
222
- assert_eq!(
223
- extract_repo("is:issue state:closed repo:owner/repo Event"),
224
- Some("owner/repo".into())
225
- );
226
- }
227
-
228
- #[test]
229
- fn extract_repo_returns_none_when_absent() {
230
- assert_eq!(extract_repo("is:issue state:closed Event"), None);
231
- }
232
- }