@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.
- 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 +49 -9
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +151 -108
- package/Cargo.toml +8 -3
- package/README.md +107 -85
- 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 +618 -118
- 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 +225 -138
- 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 +65 -7
- package/tests/integration.rs +404 -33
- package/src/source/github_issues.rs +0 -227
package/tests/integration.rs
CHANGED
|
@@ -1,27 +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
|
+
};
|
|
9
16
|
|
|
10
|
-
fn
|
|
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::
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
22
|
-
let source =
|
|
23
|
-
let
|
|
24
|
-
|
|
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
|
|
34
|
-
let source =
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
None,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
58
|
-
let source =
|
|
59
|
-
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();
|
|
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
|
-
}
|