@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
use serde::Deserialize;
|
|
2
|
+
|
|
3
|
+
use super::super::query::BitbucketFilters;
|
|
4
|
+
use crate::model::Comment;
|
|
5
|
+
|
|
6
|
+
#[derive(Deserialize)]
|
|
7
|
+
#[serde(rename_all = "camelCase")]
|
|
8
|
+
pub(super) struct BitbucketDcPage<T> {
|
|
9
|
+
pub(super) values: Vec<T>,
|
|
10
|
+
pub(super) is_last_page: bool,
|
|
11
|
+
pub(super) next_page_start: Option<u32>,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Deserialize)]
|
|
15
|
+
#[serde(rename_all = "camelCase")]
|
|
16
|
+
pub(super) struct BitbucketDcPullRequestItem {
|
|
17
|
+
pub(super) id: u64,
|
|
18
|
+
pub(super) title: String,
|
|
19
|
+
pub(super) state: String,
|
|
20
|
+
pub(super) description: Option<String>,
|
|
21
|
+
pub(super) author: Option<BitbucketDcParticipant>,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Deserialize)]
|
|
25
|
+
#[serde(rename_all = "camelCase")]
|
|
26
|
+
pub(super) struct BitbucketDcParticipant {
|
|
27
|
+
pub(super) user: Option<BitbucketDcUser>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Deserialize)]
|
|
31
|
+
#[serde(rename_all = "camelCase")]
|
|
32
|
+
pub(super) struct BitbucketDcCommentItem {
|
|
33
|
+
pub(super) text: Option<String>,
|
|
34
|
+
pub(super) author: Option<BitbucketDcUser>,
|
|
35
|
+
pub(super) created_date: Option<i64>,
|
|
36
|
+
pub(super) anchor: Option<BitbucketDcAnchor>,
|
|
37
|
+
#[serde(default)]
|
|
38
|
+
pub(super) comments: Vec<BitbucketDcCommentItem>,
|
|
39
|
+
pub(super) deleted: Option<bool>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Deserialize)]
|
|
43
|
+
#[serde(rename_all = "camelCase")]
|
|
44
|
+
pub(super) struct BitbucketDcActivityItem {
|
|
45
|
+
pub(super) action: Option<String>,
|
|
46
|
+
pub(super) comment: Option<BitbucketDcCommentItem>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Deserialize)]
|
|
50
|
+
#[serde(rename_all = "camelCase")]
|
|
51
|
+
pub(super) struct BitbucketDcAnchor {
|
|
52
|
+
pub(super) path: Option<String>,
|
|
53
|
+
pub(super) src_path: Option<String>,
|
|
54
|
+
pub(super) line: Option<u64>,
|
|
55
|
+
pub(super) line_type: Option<String>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Deserialize)]
|
|
59
|
+
#[serde(rename_all = "camelCase")]
|
|
60
|
+
pub(super) struct BitbucketDcUser {
|
|
61
|
+
pub(super) display_name: Option<String>,
|
|
62
|
+
pub(super) name: Option<String>,
|
|
63
|
+
pub(super) slug: Option<String>,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub(super) fn matches_pr_filters(
|
|
67
|
+
item: &BitbucketDcPullRequestItem,
|
|
68
|
+
filters: &BitbucketFilters,
|
|
69
|
+
) -> bool {
|
|
70
|
+
if !matches_pr_state(item.state.as_str(), filters.state.as_deref()) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if let Some(author) = filters.author.as_deref()
|
|
74
|
+
&& !participant_matches(item.author.as_ref(), author)
|
|
75
|
+
{
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let mut terms = filters.search_terms.clone();
|
|
80
|
+
terms.extend(filters.labels.clone());
|
|
81
|
+
if let Some(milestone) = filters.milestone.as_deref() {
|
|
82
|
+
terms.push(milestone.to_string());
|
|
83
|
+
}
|
|
84
|
+
if terms.is_empty() {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let haystack = [
|
|
89
|
+
item.title.as_str(),
|
|
90
|
+
item.description.as_deref().unwrap_or(""),
|
|
91
|
+
]
|
|
92
|
+
.join(" ")
|
|
93
|
+
.to_ascii_lowercase();
|
|
94
|
+
terms
|
|
95
|
+
.iter()
|
|
96
|
+
.all(|term| haystack.contains(&term.to_ascii_lowercase()))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn participant_matches(participant: Option<&BitbucketDcParticipant>, needle: &str) -> bool {
|
|
100
|
+
let Some(user) = participant.and_then(|p| p.user.as_ref()) else {
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
user_matches(user, needle)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fn user_matches(user: &BitbucketDcUser, needle: &str) -> bool {
|
|
107
|
+
let needle = needle.to_ascii_lowercase();
|
|
108
|
+
user.display_name
|
|
109
|
+
.as_deref()
|
|
110
|
+
.map(str::to_ascii_lowercase)
|
|
111
|
+
.is_some_and(|v| v == needle)
|
|
112
|
+
|| user
|
|
113
|
+
.name
|
|
114
|
+
.as_deref()
|
|
115
|
+
.map(str::to_ascii_lowercase)
|
|
116
|
+
.is_some_and(|v| v == needle)
|
|
117
|
+
|| user
|
|
118
|
+
.slug
|
|
119
|
+
.as_deref()
|
|
120
|
+
.map(str::to_ascii_lowercase)
|
|
121
|
+
.is_some_and(|v| v == needle)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn matches_pr_state(state: &str, filter_state: Option<&str>) -> bool {
|
|
125
|
+
let state = state.to_ascii_lowercase();
|
|
126
|
+
let Some(filter) = filter_state.map(str::to_ascii_lowercase) else {
|
|
127
|
+
return true;
|
|
128
|
+
};
|
|
129
|
+
match filter.as_str() {
|
|
130
|
+
"open" | "opened" => state == "open",
|
|
131
|
+
"closed" => matches!(state.as_str(), "merged" | "declined" | "superseded"),
|
|
132
|
+
"merged" => state == "merged",
|
|
133
|
+
"declined" => state == "declined",
|
|
134
|
+
"all" => true,
|
|
135
|
+
other => state == other,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub(super) fn collect_pr_comment(
|
|
140
|
+
item: BitbucketDcCommentItem,
|
|
141
|
+
include_review_comments: bool,
|
|
142
|
+
out: &mut Vec<Comment>,
|
|
143
|
+
) {
|
|
144
|
+
if item.deleted.unwrap_or(false) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if let Some(mapped) = map_pr_comment(&item, include_review_comments) {
|
|
149
|
+
out.push(mapped);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for reply in item.comments {
|
|
153
|
+
collect_pr_comment(reply, include_review_comments, out);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pub(super) fn collect_comments_from_activity(
|
|
158
|
+
activity: BitbucketDcActivityItem,
|
|
159
|
+
include_review_comments: bool,
|
|
160
|
+
out: &mut Vec<Comment>,
|
|
161
|
+
) {
|
|
162
|
+
if !activity
|
|
163
|
+
.action
|
|
164
|
+
.as_deref()
|
|
165
|
+
.is_some_and(|action| action.eq_ignore_ascii_case("COMMENTED"))
|
|
166
|
+
{
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if let Some(comment) = activity.comment {
|
|
170
|
+
collect_pr_comment(comment, include_review_comments, out);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn map_pr_comment(item: &BitbucketDcCommentItem, include_review_comments: bool) -> Option<Comment> {
|
|
175
|
+
let anchor = item.anchor.as_ref();
|
|
176
|
+
let is_review =
|
|
177
|
+
anchor.is_some_and(|a| a.line.is_some() || a.path.is_some() || a.src_path.is_some());
|
|
178
|
+
if is_review && !include_review_comments {
|
|
179
|
+
return None;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let kind = if is_review {
|
|
183
|
+
"review_comment"
|
|
184
|
+
} else {
|
|
185
|
+
"issue_comment"
|
|
186
|
+
}
|
|
187
|
+
.to_string();
|
|
188
|
+
|
|
189
|
+
let review_path = anchor.and_then(|a| a.path.clone().or_else(|| a.src_path.clone()));
|
|
190
|
+
let review_side = anchor.and_then(|a| match a.line_type.as_deref() {
|
|
191
|
+
Some("REMOVED") => Some("LEFT".to_string()),
|
|
192
|
+
Some("ADDED") => Some("RIGHT".to_string()),
|
|
193
|
+
_ => None,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
Some(Comment {
|
|
197
|
+
author: item.author.as_ref().and_then(select_author_name),
|
|
198
|
+
created_at: item
|
|
199
|
+
.created_date
|
|
200
|
+
.map(|value| value.to_string())
|
|
201
|
+
.unwrap_or_default(),
|
|
202
|
+
body: item.text.clone(),
|
|
203
|
+
kind: Some(kind),
|
|
204
|
+
review_path,
|
|
205
|
+
review_line: anchor.and_then(|a| a.line),
|
|
206
|
+
review_side,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn select_author_name(user: &BitbucketDcUser) -> Option<String> {
|
|
211
|
+
user.display_name
|
|
212
|
+
.clone()
|
|
213
|
+
.or_else(|| user.name.clone())
|
|
214
|
+
.or_else(|| user.slug.clone())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[cfg(test)]
|
|
218
|
+
mod tests {
|
|
219
|
+
use super::*;
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn collects_nested_review_comment_metadata() {
|
|
223
|
+
let mut out = Vec::new();
|
|
224
|
+
collect_pr_comment(
|
|
225
|
+
BitbucketDcCommentItem {
|
|
226
|
+
text: Some("root".into()),
|
|
227
|
+
author: Some(BitbucketDcUser {
|
|
228
|
+
display_name: Some("Alice".into()),
|
|
229
|
+
name: Some("alice".into()),
|
|
230
|
+
slug: None,
|
|
231
|
+
}),
|
|
232
|
+
created_date: Some(1),
|
|
233
|
+
anchor: Some(BitbucketDcAnchor {
|
|
234
|
+
path: Some("src/lib.rs".into()),
|
|
235
|
+
src_path: None,
|
|
236
|
+
line: Some(12),
|
|
237
|
+
line_type: Some("ADDED".into()),
|
|
238
|
+
}),
|
|
239
|
+
comments: vec![BitbucketDcCommentItem {
|
|
240
|
+
text: Some("reply".into()),
|
|
241
|
+
author: None,
|
|
242
|
+
created_date: Some(2),
|
|
243
|
+
anchor: None,
|
|
244
|
+
comments: vec![],
|
|
245
|
+
deleted: Some(false),
|
|
246
|
+
}],
|
|
247
|
+
deleted: Some(false),
|
|
248
|
+
},
|
|
249
|
+
true,
|
|
250
|
+
&mut out,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
assert_eq!(out.len(), 2);
|
|
254
|
+
assert_eq!(out[0].kind.as_deref(), Some("review_comment"));
|
|
255
|
+
assert_eq!(out[0].review_path.as_deref(), Some("src/lib.rs"));
|
|
256
|
+
assert_eq!(out[0].review_line, Some(12));
|
|
257
|
+
assert_eq!(out[0].review_side.as_deref(), Some("RIGHT"));
|
|
258
|
+
assert_eq!(out[1].kind.as_deref(), Some("issue_comment"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn skips_review_comments_when_disabled() {
|
|
263
|
+
let mut out = Vec::new();
|
|
264
|
+
collect_pr_comment(
|
|
265
|
+
BitbucketDcCommentItem {
|
|
266
|
+
text: Some("root".into()),
|
|
267
|
+
author: None,
|
|
268
|
+
created_date: Some(1),
|
|
269
|
+
anchor: Some(BitbucketDcAnchor {
|
|
270
|
+
path: Some("src/lib.rs".into()),
|
|
271
|
+
src_path: None,
|
|
272
|
+
line: Some(12),
|
|
273
|
+
line_type: Some("ADDED".into()),
|
|
274
|
+
}),
|
|
275
|
+
comments: vec![],
|
|
276
|
+
deleted: Some(false),
|
|
277
|
+
},
|
|
278
|
+
false,
|
|
279
|
+
&mut out,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
assert!(out.is_empty());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#[test]
|
|
286
|
+
fn activity_filter_ignores_non_comment_actions() {
|
|
287
|
+
let mut out = Vec::new();
|
|
288
|
+
collect_comments_from_activity(
|
|
289
|
+
BitbucketDcActivityItem {
|
|
290
|
+
action: Some("OPENED".into()),
|
|
291
|
+
comment: Some(BitbucketDcCommentItem {
|
|
292
|
+
text: Some("x".into()),
|
|
293
|
+
author: None,
|
|
294
|
+
created_date: Some(1),
|
|
295
|
+
anchor: None,
|
|
296
|
+
comments: vec![],
|
|
297
|
+
deleted: Some(false),
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
true,
|
|
301
|
+
&mut out,
|
|
302
|
+
);
|
|
303
|
+
assert!(out.is_empty());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#[test]
|
|
307
|
+
fn activity_filter_collects_commented_action() {
|
|
308
|
+
let mut out = Vec::new();
|
|
309
|
+
collect_comments_from_activity(
|
|
310
|
+
BitbucketDcActivityItem {
|
|
311
|
+
action: Some("COMMENTED".into()),
|
|
312
|
+
comment: Some(BitbucketDcCommentItem {
|
|
313
|
+
text: Some("hello".into()),
|
|
314
|
+
author: None,
|
|
315
|
+
created_date: Some(1),
|
|
316
|
+
anchor: None,
|
|
317
|
+
comments: vec![],
|
|
318
|
+
deleted: Some(false),
|
|
319
|
+
}),
|
|
320
|
+
},
|
|
321
|
+
true,
|
|
322
|
+
&mut out,
|
|
323
|
+
);
|
|
324
|
+
assert_eq!(out.len(), 1);
|
|
325
|
+
assert_eq!(out[0].kind.as_deref(), Some("issue_comment"));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::blocking::Client;
|
|
3
|
+
|
|
4
|
+
use super::{FetchRequest, Source};
|
|
5
|
+
use crate::error::AppError;
|
|
6
|
+
use crate::model::Conversation;
|
|
7
|
+
|
|
8
|
+
mod cloud;
|
|
9
|
+
mod datacenter;
|
|
10
|
+
mod query;
|
|
11
|
+
mod shared;
|
|
12
|
+
|
|
13
|
+
const BITBUCKET_CLOUD_API_BASE: &str = "https://api.bitbucket.org/2.0";
|
|
14
|
+
const PAGE_SIZE: u32 = 50;
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
17
|
+
pub(super) enum BitbucketDeployment {
|
|
18
|
+
Cloud,
|
|
19
|
+
Selfhosted,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl BitbucketDeployment {
|
|
23
|
+
fn parse(raw: &str) -> Result<Self> {
|
|
24
|
+
match raw {
|
|
25
|
+
"cloud" => Ok(Self::Cloud),
|
|
26
|
+
"selfhosted" => Ok(Self::Selfhosted),
|
|
27
|
+
other => Err(AppError::usage(format!(
|
|
28
|
+
"Invalid bitbucket deployment '{other}'. Supported: cloud, selfhosted."
|
|
29
|
+
))
|
|
30
|
+
.into()),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub struct BitbucketSource {
|
|
36
|
+
pub(super) client: Client,
|
|
37
|
+
deployment: BitbucketDeployment,
|
|
38
|
+
pub(super) base_url: String,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl BitbucketSource {
|
|
42
|
+
/// Create a Bitbucket source client.
|
|
43
|
+
///
|
|
44
|
+
/// # Errors
|
|
45
|
+
///
|
|
46
|
+
/// Returns an error if deployment is missing/invalid or the HTTP client
|
|
47
|
+
/// cannot be constructed.
|
|
48
|
+
pub fn new(platform_url: Option<String>, deployment: Option<String>) -> Result<Self> {
|
|
49
|
+
let deployment = deployment.ok_or_else(|| {
|
|
50
|
+
AppError::usage(
|
|
51
|
+
"Bitbucket deployment is required. Set [instances.<alias>].deployment or pass --deployment (cloud|selfhosted).",
|
|
52
|
+
)
|
|
53
|
+
})?;
|
|
54
|
+
let deployment = BitbucketDeployment::parse(&deployment)?;
|
|
55
|
+
let base_url = match deployment {
|
|
56
|
+
BitbucketDeployment::Cloud => BITBUCKET_CLOUD_API_BASE.to_string(),
|
|
57
|
+
BitbucketDeployment::Selfhosted => platform_url
|
|
58
|
+
.ok_or_else(|| {
|
|
59
|
+
AppError::usage(
|
|
60
|
+
"Bitbucket selfhosted deployment requires --url or [instances.<alias>].url.",
|
|
61
|
+
)
|
|
62
|
+
})?
|
|
63
|
+
.trim_end_matches('/')
|
|
64
|
+
.to_string(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let client = Client::builder()
|
|
68
|
+
.user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
|
|
69
|
+
.build()?;
|
|
70
|
+
|
|
71
|
+
Ok(Self {
|
|
72
|
+
client,
|
|
73
|
+
deployment,
|
|
74
|
+
base_url,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
impl Source for BitbucketSource {
|
|
80
|
+
fn fetch_stream(
|
|
81
|
+
&self,
|
|
82
|
+
req: &FetchRequest,
|
|
83
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
84
|
+
) -> Result<usize> {
|
|
85
|
+
match self.deployment {
|
|
86
|
+
BitbucketDeployment::Cloud => self.fetch_cloud_stream(req, emit),
|
|
87
|
+
BitbucketDeployment::Selfhosted => self.fetch_datacenter_stream(req, emit),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
|
|
3
|
+
use crate::error::AppError;
|
|
4
|
+
use crate::source::ContentKind;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone)]
|
|
7
|
+
pub(super) struct BitbucketFilters {
|
|
8
|
+
pub(super) repo: Option<String>,
|
|
9
|
+
pub(super) kind: ContentKind,
|
|
10
|
+
pub(super) kind_explicit: bool,
|
|
11
|
+
pub(super) state: Option<String>,
|
|
12
|
+
pub(super) labels: Vec<String>,
|
|
13
|
+
pub(super) author: Option<String>,
|
|
14
|
+
pub(super) since: Option<String>,
|
|
15
|
+
pub(super) milestone: Option<String>,
|
|
16
|
+
pub(super) search_terms: Vec<String>,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
impl Default for BitbucketFilters {
|
|
20
|
+
fn default() -> Self {
|
|
21
|
+
Self {
|
|
22
|
+
repo: None,
|
|
23
|
+
kind: ContentKind::Pr,
|
|
24
|
+
kind_explicit: false,
|
|
25
|
+
state: None,
|
|
26
|
+
labels: vec![],
|
|
27
|
+
author: None,
|
|
28
|
+
since: None,
|
|
29
|
+
milestone: None,
|
|
30
|
+
search_terms: vec![],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub(super) fn parse_bitbucket_query(raw_query: &str) -> BitbucketFilters {
|
|
36
|
+
let mut filters = BitbucketFilters::default();
|
|
37
|
+
|
|
38
|
+
for token in raw_query.split_whitespace() {
|
|
39
|
+
if token == "is:issue" {
|
|
40
|
+
filters.kind = ContentKind::Issue;
|
|
41
|
+
filters.kind_explicit = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if token == "is:pr" {
|
|
45
|
+
filters.kind = ContentKind::Pr;
|
|
46
|
+
filters.kind_explicit = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if let Some(kind) = token.strip_prefix("type:") {
|
|
50
|
+
if kind == "issue" {
|
|
51
|
+
filters.kind = ContentKind::Issue;
|
|
52
|
+
filters.kind_explicit = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if kind == "pr" {
|
|
56
|
+
filters.kind = ContentKind::Pr;
|
|
57
|
+
filters.kind_explicit = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if let Some(repo) = token.strip_prefix("repo:") {
|
|
62
|
+
filters.repo = Some(repo.to_string());
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if let Some(state) = token.strip_prefix("state:") {
|
|
66
|
+
filters.state = Some(state.to_string());
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if let Some(label) = token.strip_prefix("label:") {
|
|
70
|
+
filters.labels.push(label.to_string());
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if let Some(author) = token.strip_prefix("author:") {
|
|
74
|
+
filters.author = Some(author.to_string());
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if let Some(since) = token.strip_prefix("created:>=") {
|
|
78
|
+
filters.since = Some(since.to_string());
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if let Some(milestone) = token.strip_prefix("milestone:") {
|
|
82
|
+
filters.milestone = Some(milestone.to_string());
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
filters.search_terms.push(token.to_string());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
filters
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Parse `workspace/repo_slug` from `repo:` input.
|
|
93
|
+
///
|
|
94
|
+
/// # Errors
|
|
95
|
+
///
|
|
96
|
+
/// Returns an error when the repository path is missing or malformed.
|
|
97
|
+
pub(super) fn parse_workspace_repo(raw_repo: Option<&str>) -> Result<(String, String)> {
|
|
98
|
+
parse_repo_pair(raw_repo, "workspace/repo_slug")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Parse `project/repo_slug` from `repo:` input.
|
|
102
|
+
///
|
|
103
|
+
/// # Errors
|
|
104
|
+
///
|
|
105
|
+
/// Returns an error when the repository path is missing or malformed.
|
|
106
|
+
pub(super) fn parse_project_repo(raw_repo: Option<&str>) -> Result<(String, String)> {
|
|
107
|
+
parse_repo_pair(raw_repo, "project/repo_slug")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn parse_repo_pair(raw_repo: Option<&str>, expected_format: &str) -> Result<(String, String)> {
|
|
111
|
+
let repo = raw_repo.ok_or_else(|| {
|
|
112
|
+
AppError::usage(format!(
|
|
113
|
+
"No repo: found in query. Use --repo or include 'repo:{expected_format}' in -q"
|
|
114
|
+
))
|
|
115
|
+
})?;
|
|
116
|
+
let mut parts = repo.split('/');
|
|
117
|
+
let first = parts.next().unwrap_or_default().trim();
|
|
118
|
+
let second = parts.next().unwrap_or_default().trim();
|
|
119
|
+
let tail = parts.next();
|
|
120
|
+
if first.is_empty() || second.is_empty() || tail.is_some() {
|
|
121
|
+
return Err(AppError::usage(format!(
|
|
122
|
+
"Bitbucket repo must be '{expected_format}', got '{repo}'."
|
|
123
|
+
))
|
|
124
|
+
.into());
|
|
125
|
+
}
|
|
126
|
+
Ok((first.to_string(), second.to_string()))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[cfg(test)]
|
|
130
|
+
mod tests {
|
|
131
|
+
use super::*;
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn parse_bitbucket_query_extracts_filters() {
|
|
135
|
+
let q = parse_bitbucket_query(
|
|
136
|
+
"is:pr repo:workspace/repo state:closed label:bug author:alice created:>=2025-01-01 milestone:v1 text",
|
|
137
|
+
);
|
|
138
|
+
assert!(matches!(q.kind, ContentKind::Pr));
|
|
139
|
+
assert!(q.kind_explicit);
|
|
140
|
+
assert_eq!(q.repo.as_deref(), Some("workspace/repo"));
|
|
141
|
+
assert_eq!(q.state.as_deref(), Some("closed"));
|
|
142
|
+
assert_eq!(q.labels, vec!["bug"]);
|
|
143
|
+
assert_eq!(q.author.as_deref(), Some("alice"));
|
|
144
|
+
assert_eq!(q.since.as_deref(), Some("2025-01-01"));
|
|
145
|
+
assert_eq!(q.milestone.as_deref(), Some("v1"));
|
|
146
|
+
assert_eq!(q.search_terms, vec!["text"]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[test]
|
|
150
|
+
fn parse_query_defaults_to_pr_without_explicit_kind() {
|
|
151
|
+
let q = parse_bitbucket_query("repo:workspace/repo state:open");
|
|
152
|
+
assert!(matches!(q.kind, ContentKind::Pr));
|
|
153
|
+
assert!(!q.kind_explicit);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn parse_workspace_repo_requires_workspace_and_repo_slug() {
|
|
158
|
+
let err = parse_workspace_repo(Some("workspace"))
|
|
159
|
+
.unwrap_err()
|
|
160
|
+
.to_string();
|
|
161
|
+
assert!(err.contains("workspace/repo_slug"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn parse_project_repo_requires_project_and_repo_slug() {
|
|
166
|
+
let err = parse_project_repo(Some("PROJECT")).unwrap_err().to_string();
|
|
167
|
+
assert!(err.contains("project/repo_slug"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
use reqwest::blocking::RequestBuilder;
|
|
2
|
+
|
|
3
|
+
pub(in crate::source::bitbucket) fn apply_auth(
|
|
4
|
+
req: RequestBuilder,
|
|
5
|
+
token: Option<&str>,
|
|
6
|
+
) -> RequestBuilder {
|
|
7
|
+
match resolve_auth_mode(token) {
|
|
8
|
+
AuthMode::None => req,
|
|
9
|
+
AuthMode::Bearer(token) => req.bearer_auth(token),
|
|
10
|
+
AuthMode::Basic { user, secret } => req.basic_auth(user, Some(secret)),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fn resolve_auth_mode(token: Option<&str>) -> AuthMode<'_> {
|
|
15
|
+
match token {
|
|
16
|
+
Some(t) if t.contains(':') => {
|
|
17
|
+
let (user, secret) = t.split_once(':').unwrap_or_default();
|
|
18
|
+
AuthMode::Basic { user, secret }
|
|
19
|
+
}
|
|
20
|
+
Some(t) => AuthMode::Bearer(t),
|
|
21
|
+
None => AuthMode::None,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
26
|
+
enum AuthMode<'a> {
|
|
27
|
+
None,
|
|
28
|
+
Bearer(&'a str),
|
|
29
|
+
Basic { user: &'a str, secret: &'a str },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[cfg(test)]
|
|
33
|
+
mod tests {
|
|
34
|
+
use super::*;
|
|
35
|
+
|
|
36
|
+
#[test]
|
|
37
|
+
fn resolve_auth_mode_prefers_explicit_basic() {
|
|
38
|
+
assert_eq!(
|
|
39
|
+
resolve_auth_mode(Some("user:pass")),
|
|
40
|
+
AuthMode::Basic {
|
|
41
|
+
user: "user",
|
|
42
|
+
secret: "pass"
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn resolve_auth_mode_uses_bearer_for_plain_tokens() {
|
|
49
|
+
assert_eq!(
|
|
50
|
+
resolve_auth_mode(Some("ATxxxx")),
|
|
51
|
+
AuthMode::Bearer("ATxxxx")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::StatusCode;
|
|
3
|
+
use reqwest::blocking::{RequestBuilder, Response};
|
|
4
|
+
use serde::de::DeserializeOwned;
|
|
5
|
+
|
|
6
|
+
use crate::error::{AppError, app_error_from_decode, app_error_from_reqwest};
|
|
7
|
+
|
|
8
|
+
pub(in crate::source::bitbucket) fn send(req: RequestBuilder, operation: &str) -> Result<Response> {
|
|
9
|
+
req.send()
|
|
10
|
+
.map_err(|err| app_error_from_reqwest("Bitbucket", operation, &err).into())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
pub(in crate::source::bitbucket) fn parse_bitbucket_json<T: DeserializeOwned>(
|
|
14
|
+
resp: Response,
|
|
15
|
+
token: Option<&str>,
|
|
16
|
+
operation: &str,
|
|
17
|
+
) -> Result<T> {
|
|
18
|
+
let status = resp.status();
|
|
19
|
+
let body = resp.text()?;
|
|
20
|
+
if !status.is_success() {
|
|
21
|
+
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
|
22
|
+
return Err(AppError::auth(format!(
|
|
23
|
+
"Bitbucket API {operation} error {status}: {}. {}",
|
|
24
|
+
body_snippet(&body),
|
|
25
|
+
auth_hint(token)
|
|
26
|
+
))
|
|
27
|
+
.with_provider("bitbucket")
|
|
28
|
+
.with_http_status(status)
|
|
29
|
+
.into());
|
|
30
|
+
}
|
|
31
|
+
return Err(AppError::from_http("Bitbucket", operation, status, &body)
|
|
32
|
+
.with_provider("bitbucket")
|
|
33
|
+
.into());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
serde_json::from_str(&body).map_err(|err| {
|
|
37
|
+
app_error_from_decode(
|
|
38
|
+
"Bitbucket",
|
|
39
|
+
operation,
|
|
40
|
+
format!("{err} (body starts with: {})", body_snippet(&body)),
|
|
41
|
+
)
|
|
42
|
+
.into()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn auth_hint(token: Option<&str>) -> &'static str {
|
|
47
|
+
if token.is_some() {
|
|
48
|
+
"Check Bitbucket token credentials and scopes."
|
|
49
|
+
} else {
|
|
50
|
+
"No Bitbucket token detected. Set --token, BITBUCKET_TOKEN, or [instances.<alias>].token."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fn body_snippet(body: &str) -> String {
|
|
55
|
+
body.chars()
|
|
56
|
+
.take(200)
|
|
57
|
+
.collect::<String>()
|
|
58
|
+
.replace('\n', " ")
|
|
59
|
+
}
|