@mbe24/99problems 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +49 -9
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +107 -85
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +618 -118
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +225 -138
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +65 -7
  71. package/tests/integration.rs +404 -33
  72. package/src/source/github_issues.rs +0 -227
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ mod auth;
2
+ mod http;
3
+
4
+ pub(super) use auth::apply_auth;
5
+ pub(super) use http::{parse_bitbucket_json, send};