@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.
- package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
- package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
- package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/dependabot.yml +20 -0
- package/.github/scripts/publish.js +1 -1
- package/.github/workflows/ci.yml +32 -6
- package/.github/workflows/man-drift.yml +26 -0
- package/.github/workflows/release.yml +7 -7
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +151 -108
- package/Cargo.toml +8 -3
- package/README.md +109 -72
- package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
- package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
- package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
- package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
- package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
- package/docs/man/99problems-completions.1 +31 -0
- package/docs/man/99problems-config.1 +50 -0
- package/docs/man/99problems-get.1 +114 -0
- package/docs/man/99problems-help.1 +25 -0
- package/docs/man/99problems-man.1 +32 -0
- package/docs/man/99problems.1 +52 -0
- package/npm/install.js +90 -3
- package/package.json +7 -7
- package/rust-toolchain.toml +4 -0
- package/src/cmd/config/key.rs +126 -0
- package/src/cmd/config/mod.rs +218 -0
- package/src/cmd/config/render.rs +33 -0
- package/src/cmd/config/store.rs +318 -0
- package/src/cmd/config/write.rs +173 -0
- package/src/cmd/get.rs +658 -0
- package/src/cmd/man.rs +117 -0
- package/src/cmd/mod.rs +3 -0
- package/src/config.rs +641 -42
- package/src/error.rs +254 -0
- package/src/format/json.rs +59 -18
- package/src/format/jsonl.rs +52 -0
- package/src/format/mod.rs +25 -3
- package/src/format/text.rs +73 -0
- package/src/format/yaml.rs +64 -15
- package/src/lib.rs +1 -0
- package/src/logging.rs +54 -0
- package/src/main.rs +230 -91
- package/src/model.rs +9 -1
- package/src/source/bitbucket/cloud/api.rs +67 -0
- package/src/source/bitbucket/cloud/mod.rs +178 -0
- package/src/source/bitbucket/cloud/model.rs +211 -0
- package/src/source/bitbucket/datacenter/api.rs +74 -0
- package/src/source/bitbucket/datacenter/mod.rs +181 -0
- package/src/source/bitbucket/datacenter/model.rs +327 -0
- package/src/source/bitbucket/mod.rs +90 -0
- package/src/source/bitbucket/query.rs +169 -0
- package/src/source/bitbucket/shared/auth.rs +54 -0
- package/src/source/bitbucket/shared/http.rs +59 -0
- package/src/source/bitbucket/shared/mod.rs +5 -0
- package/src/source/github/api.rs +128 -0
- package/src/source/github/mod.rs +191 -0
- package/src/source/github/model.rs +84 -0
- package/src/source/github/query.rs +50 -0
- package/src/source/gitlab/api.rs +282 -0
- package/src/source/gitlab/mod.rs +225 -0
- package/src/source/gitlab/model.rs +102 -0
- package/src/source/gitlab/query.rs +177 -0
- package/src/source/jira/api.rs +222 -0
- package/src/source/jira/mod.rs +161 -0
- package/src/source/jira/model.rs +99 -0
- package/src/source/jira/query.rs +153 -0
- package/src/source/mod.rs +179 -24
- package/tests/integration.rs +406 -26
- package/src/source/github_issues.rs +0 -232
package/tests/integration.rs
CHANGED
|
@@ -1,22 +1,105 @@
|
|
|
1
|
-
/// Integration tests —
|
|
2
|
-
/// Run with:
|
|
3
|
-
///
|
|
4
|
-
///
|
|
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::{
|
|
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
|
|
11
|
-
|
|
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
|
|
17
|
-
let source =
|
|
18
|
-
let
|
|
19
|
-
|
|
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
|
|
29
|
-
let source =
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
49
|
-
let source =
|
|
50
|
-
let
|
|
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
|
-
}
|