@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,211 @@
1
+ use serde::Deserialize;
2
+
3
+ use super::super::query::BitbucketFilters;
4
+ use crate::model::Comment;
5
+
6
+ #[derive(Deserialize)]
7
+ pub(super) struct BitbucketPage<T> {
8
+ pub(super) values: Vec<T>,
9
+ pub(super) next: Option<String>,
10
+ }
11
+
12
+ #[derive(Deserialize)]
13
+ pub(super) struct BitbucketPullRequestItem {
14
+ pub(super) id: u64,
15
+ pub(super) title: String,
16
+ pub(super) state: String,
17
+ pub(super) description: Option<String>,
18
+ pub(super) summary: Option<BitbucketRichText>,
19
+ pub(super) author: Option<BitbucketUser>,
20
+ pub(super) created_on: Option<String>,
21
+ }
22
+
23
+ #[derive(Deserialize)]
24
+ pub(super) struct BitbucketCommentItem {
25
+ pub(super) user: Option<BitbucketUser>,
26
+ pub(super) created_on: Option<String>,
27
+ pub(super) content: Option<BitbucketRichText>,
28
+ pub(super) inline: Option<BitbucketInline>,
29
+ pub(super) deleted: Option<bool>,
30
+ }
31
+
32
+ #[derive(Deserialize)]
33
+ pub(super) struct BitbucketInline {
34
+ pub(super) path: Option<String>,
35
+ pub(super) from: Option<u64>,
36
+ pub(super) to: Option<u64>,
37
+ }
38
+
39
+ #[derive(Deserialize)]
40
+ pub(super) struct BitbucketRichText {
41
+ pub(super) raw: Option<String>,
42
+ }
43
+
44
+ #[derive(Deserialize)]
45
+ pub(super) struct BitbucketUser {
46
+ pub(super) display_name: Option<String>,
47
+ pub(super) nickname: Option<String>,
48
+ pub(super) username: Option<String>,
49
+ }
50
+
51
+ pub(super) fn matches_pr_filters(
52
+ item: &BitbucketPullRequestItem,
53
+ filters: &BitbucketFilters,
54
+ ) -> bool {
55
+ if !matches_pr_state(item.state.as_str(), filters.state.as_deref()) {
56
+ return false;
57
+ }
58
+ if let Some(author) = filters.author.as_deref()
59
+ && !user_matches(item.author.as_ref(), author)
60
+ {
61
+ return false;
62
+ }
63
+ if let Some(since) = filters.since.as_deref()
64
+ && let Some(created) = item.created_on.as_deref()
65
+ && created < since
66
+ {
67
+ return false;
68
+ }
69
+ matches_terms(
70
+ &[
71
+ item.title.as_str(),
72
+ item.description.as_deref().unwrap_or(""),
73
+ item.summary
74
+ .as_ref()
75
+ .and_then(|c| c.raw.as_deref())
76
+ .unwrap_or(""),
77
+ ],
78
+ filters,
79
+ )
80
+ }
81
+
82
+ fn matches_terms(haystack_parts: &[&str], filters: &BitbucketFilters) -> bool {
83
+ let mut terms = filters.search_terms.clone();
84
+ terms.extend(filters.labels.clone());
85
+ if let Some(milestone) = filters.milestone.as_deref() {
86
+ terms.push(milestone.to_string());
87
+ }
88
+ if terms.is_empty() {
89
+ return true;
90
+ }
91
+ let haystack = haystack_parts.join(" ").to_ascii_lowercase();
92
+ terms
93
+ .iter()
94
+ .all(|term| haystack.contains(&term.to_ascii_lowercase()))
95
+ }
96
+
97
+ fn matches_pr_state(state: &str, filter_state: Option<&str>) -> bool {
98
+ let state = state.to_ascii_lowercase();
99
+ let Some(filter) = filter_state.map(str::to_ascii_lowercase) else {
100
+ return true;
101
+ };
102
+ match filter.as_str() {
103
+ "open" | "opened" => state == "open",
104
+ "closed" => matches!(state.as_str(), "merged" | "declined" | "superseded"),
105
+ "merged" => state == "merged",
106
+ "declined" => state == "declined",
107
+ "all" => true,
108
+ other => state == other,
109
+ }
110
+ }
111
+
112
+ fn user_matches(user: Option<&BitbucketUser>, needle: &str) -> bool {
113
+ let Some(user) = user else {
114
+ return false;
115
+ };
116
+ let needle = needle.to_ascii_lowercase();
117
+ user.display_name
118
+ .as_deref()
119
+ .map(str::to_ascii_lowercase)
120
+ .is_some_and(|v| v == needle)
121
+ || user
122
+ .nickname
123
+ .as_deref()
124
+ .map(str::to_ascii_lowercase)
125
+ .is_some_and(|v| v == needle)
126
+ || user
127
+ .username
128
+ .as_deref()
129
+ .map(str::to_ascii_lowercase)
130
+ .is_some_and(|v| v == needle)
131
+ }
132
+
133
+ pub(super) fn map_pr_comment(
134
+ item: BitbucketCommentItem,
135
+ include_review_comments: bool,
136
+ ) -> Option<Comment> {
137
+ let (kind, review_path, review_line, review_side) = if let Some(inline) = item.inline {
138
+ if !include_review_comments {
139
+ return None;
140
+ }
141
+ let review_line = inline.to.or(inline.from);
142
+ let review_side = if inline.to.is_some() {
143
+ Some("RIGHT".to_string())
144
+ } else if inline.from.is_some() {
145
+ Some("LEFT".to_string())
146
+ } else {
147
+ None
148
+ };
149
+ (
150
+ "review_comment".to_string(),
151
+ inline.path,
152
+ review_line,
153
+ review_side,
154
+ )
155
+ } else {
156
+ ("issue_comment".to_string(), None, None, None)
157
+ };
158
+
159
+ Some(Comment {
160
+ author: item.user.and_then(select_author_name),
161
+ created_at: item.created_on.unwrap_or_default(),
162
+ body: item.content.and_then(|c| c.raw),
163
+ kind: Some(kind),
164
+ review_path,
165
+ review_line,
166
+ review_side,
167
+ })
168
+ }
169
+
170
+ fn select_author_name(user: BitbucketUser) -> Option<String> {
171
+ user.nickname.or(user.username).or(user.display_name)
172
+ }
173
+
174
+ #[cfg(test)]
175
+ mod tests {
176
+ use super::*;
177
+
178
+ #[test]
179
+ fn pr_state_filter_maps_open_closed_and_merged() {
180
+ assert!(matches_pr_state("OPEN", Some("open")));
181
+ assert!(matches_pr_state("DECLINED", Some("closed")));
182
+ assert!(matches_pr_state("MERGED", Some("merged")));
183
+ assert!(!matches_pr_state("OPEN", Some("merged")));
184
+ }
185
+
186
+ #[test]
187
+ fn map_pr_review_comment_sets_review_fields() {
188
+ let item = BitbucketCommentItem {
189
+ user: Some(BitbucketUser {
190
+ display_name: Some("Alice".into()),
191
+ nickname: Some("alice".into()),
192
+ username: None,
193
+ }),
194
+ created_on: Some("2026-01-01T00:00:00.000000+00:00".into()),
195
+ content: Some(BitbucketRichText {
196
+ raw: Some("Looks good".into()),
197
+ }),
198
+ inline: Some(BitbucketInline {
199
+ path: Some("src/lib.rs".into()),
200
+ from: None,
201
+ to: Some(42),
202
+ }),
203
+ deleted: Some(false),
204
+ };
205
+ let mapped = map_pr_comment(item, true).expect("expected comment");
206
+ assert_eq!(mapped.kind.as_deref(), Some("review_comment"));
207
+ assert_eq!(mapped.review_path.as_deref(), Some("src/lib.rs"));
208
+ assert_eq!(mapped.review_line, Some(42));
209
+ assert_eq!(mapped.review_side.as_deref(), Some("RIGHT"));
210
+ }
211
+ }
@@ -0,0 +1,74 @@
1
+ use anyhow::Result;
2
+ use reqwest::StatusCode;
3
+ use serde::de::DeserializeOwned;
4
+ use tracing::{debug, trace};
5
+
6
+ use super::super::BitbucketSource;
7
+ use super::super::shared::{apply_auth, parse_bitbucket_json, send};
8
+ use super::model::BitbucketDcPage;
9
+
10
+ impl BitbucketSource {
11
+ #[allow(clippy::too_many_arguments)]
12
+ pub(super) fn datacenter_get_pages_stream<T: DeserializeOwned>(
13
+ &self,
14
+ url: &str,
15
+ params: &[(String, String)],
16
+ token: Option<&str>,
17
+ per_page: u32,
18
+ emit: &mut dyn FnMut(T) -> Result<()>,
19
+ ) -> Result<usize> {
20
+ let per_page = per_page.clamp(1, 100);
21
+ let mut emitted = 0usize;
22
+ let mut start = 0u32;
23
+
24
+ loop {
25
+ debug!(url = %url, start, per_page, "fetching Bitbucket Data Center page");
26
+ let mut query_params = params.to_vec();
27
+ query_params.push(("start".to_string(), start.to_string()));
28
+ query_params.push(("limit".to_string(), per_page.to_string()));
29
+
30
+ let request = apply_auth(self.client.get(url), token)
31
+ .header("Accept", "application/json")
32
+ .query(&query_params);
33
+ let response = send(request, "page fetch")?;
34
+ let page: BitbucketDcPage<T> = parse_bitbucket_json(response, token, "page fetch")?;
35
+ trace!(
36
+ count = page.values.len(),
37
+ is_last_page = page.is_last_page,
38
+ "decoded Bitbucket Data Center page"
39
+ );
40
+
41
+ for item in page.values {
42
+ emit(item)?;
43
+ emitted += 1;
44
+ }
45
+
46
+ if page.is_last_page {
47
+ break;
48
+ }
49
+
50
+ match page.next_page_start {
51
+ Some(next) => start = next,
52
+ None => break,
53
+ }
54
+ }
55
+
56
+ Ok(emitted)
57
+ }
58
+
59
+ pub(super) fn datacenter_get_one<T: DeserializeOwned>(
60
+ &self,
61
+ url: &str,
62
+ token: Option<&str>,
63
+ operation: &str,
64
+ ) -> Result<Option<T>> {
65
+ let request = apply_auth(self.client.get(url), token).header("Accept", "application/json");
66
+ let response = send(request, operation)?;
67
+ if response.status() == StatusCode::NOT_FOUND {
68
+ return Ok(None);
69
+ }
70
+
71
+ let item = parse_bitbucket_json(response, token, operation)?;
72
+ Ok(Some(item))
73
+ }
74
+ }
@@ -0,0 +1,181 @@
1
+ use anyhow::Result;
2
+
3
+ use self::model::{
4
+ BitbucketDcActivityItem, BitbucketDcPullRequestItem, collect_comments_from_activity,
5
+ matches_pr_filters,
6
+ };
7
+ use super::BitbucketSource;
8
+ use super::query::{parse_bitbucket_query, parse_project_repo};
9
+ use crate::error::AppError;
10
+ use crate::model::{Comment, Conversation};
11
+ use crate::source::{ContentKind, FetchRequest, FetchTarget};
12
+
13
+ mod api;
14
+ mod model;
15
+
16
+ impl BitbucketSource {
17
+ pub(super) fn fetch_datacenter_stream(
18
+ &self,
19
+ req: &FetchRequest,
20
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
21
+ ) -> Result<usize> {
22
+ match &req.target {
23
+ FetchTarget::Search { raw_query } => {
24
+ self.search_datacenter_stream(req, raw_query, emit)
25
+ }
26
+ FetchTarget::Id {
27
+ repo,
28
+ id,
29
+ kind,
30
+ allow_fallback_to_pr: _,
31
+ } => self.fetch_datacenter_by_id_stream(req, repo, id, *kind, emit),
32
+ }
33
+ }
34
+
35
+ fn search_datacenter_stream(
36
+ &self,
37
+ req: &FetchRequest,
38
+ raw_query: &str,
39
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
40
+ ) -> Result<usize> {
41
+ let mut filters = parse_bitbucket_query(raw_query);
42
+ if !filters.kind_explicit {
43
+ filters.kind = ContentKind::Pr;
44
+ }
45
+ if matches!(filters.kind, ContentKind::Issue) {
46
+ return Err(AppError::usage(
47
+ "Bitbucket Data Center supports pull requests only. Use --type pr or omit --type.",
48
+ )
49
+ .into());
50
+ }
51
+
52
+ let (project, repo_slug) = parse_project_repo(filters.repo.as_deref())?;
53
+ let repo = format!("{project}/{repo_slug}");
54
+ self.search_datacenter_prs_stream(req, &repo, &filters, emit)
55
+ }
56
+
57
+ fn search_datacenter_prs_stream(
58
+ &self,
59
+ req: &FetchRequest,
60
+ repo: &str,
61
+ filters: &super::query::BitbucketFilters,
62
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
63
+ ) -> Result<usize> {
64
+ let (project, repo_slug) = parse_project_repo(Some(repo))?;
65
+ let url = format!(
66
+ "{}/rest/api/latest/projects/{project}/repos/{repo_slug}/pull-requests",
67
+ self.base_url
68
+ );
69
+ let params = vec![("state".to_string(), "ALL".to_string())];
70
+
71
+ let mut emitted = 0usize;
72
+ self.datacenter_get_pages_stream(
73
+ &url,
74
+ &params,
75
+ req.token.as_deref(),
76
+ req.per_page,
77
+ &mut |item: BitbucketDcPullRequestItem| {
78
+ if !matches_pr_filters(&item, filters) {
79
+ return Ok(());
80
+ }
81
+ emit(self.fetch_datacenter_pr_conversation(repo, item, req)?)?;
82
+ emitted += 1;
83
+ Ok(())
84
+ },
85
+ )?;
86
+
87
+ Ok(emitted)
88
+ }
89
+
90
+ fn fetch_datacenter_by_id_stream(
91
+ &self,
92
+ req: &FetchRequest,
93
+ repo: &str,
94
+ id: &str,
95
+ kind: ContentKind,
96
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
97
+ ) -> Result<usize> {
98
+ let id = id.parse::<u64>().map_err(|_| {
99
+ AppError::usage(format!(
100
+ "Bitbucket Data Center expects a numeric pull request id, got '{id}'."
101
+ ))
102
+ })?;
103
+
104
+ if matches!(kind, ContentKind::Issue) {
105
+ return Err(AppError::usage(
106
+ "Bitbucket Data Center supports pull requests only. Use --type pr or omit --type.",
107
+ )
108
+ .into());
109
+ }
110
+
111
+ if let Some(pr) = self.fetch_datacenter_pr_by_id(repo, id, req)? {
112
+ emit(self.fetch_datacenter_pr_conversation(repo, pr, req)?)?;
113
+ return Ok(1);
114
+ }
115
+
116
+ Err(AppError::not_found(format!("Pull request #{id} not found in repo {repo}.")).into())
117
+ }
118
+
119
+ fn fetch_datacenter_pr_by_id(
120
+ &self,
121
+ repo: &str,
122
+ id: u64,
123
+ req: &FetchRequest,
124
+ ) -> Result<Option<BitbucketDcPullRequestItem>> {
125
+ let (project, repo_slug) = parse_project_repo(Some(repo))?;
126
+ let url = format!(
127
+ "{}/rest/api/latest/projects/{project}/repos/{repo_slug}/pull-requests/{id}",
128
+ self.base_url
129
+ );
130
+ self.datacenter_get_one(&url, req.token.as_deref(), "pull request fetch")
131
+ }
132
+
133
+ fn fetch_datacenter_pr_conversation(
134
+ &self,
135
+ repo: &str,
136
+ item: BitbucketDcPullRequestItem,
137
+ req: &FetchRequest,
138
+ ) -> Result<Conversation> {
139
+ let comments = if req.include_comments {
140
+ self.fetch_datacenter_pr_comments(repo, item.id, req)?
141
+ } else {
142
+ Vec::new()
143
+ };
144
+
145
+ Ok(Conversation {
146
+ id: item.id.to_string(),
147
+ title: item.title,
148
+ state: item.state,
149
+ body: item.description.filter(|body| !body.is_empty()),
150
+ comments,
151
+ })
152
+ }
153
+
154
+ fn fetch_datacenter_pr_comments(
155
+ &self,
156
+ repo: &str,
157
+ id: u64,
158
+ req: &FetchRequest,
159
+ ) -> Result<Vec<Comment>> {
160
+ let (project, repo_slug) = parse_project_repo(Some(repo))?;
161
+ let url = format!(
162
+ "{}/rest/api/latest/projects/{project}/repos/{repo_slug}/pull-requests/{id}/activities",
163
+ self.base_url
164
+ );
165
+
166
+ let mut comments = Vec::new();
167
+ self.datacenter_get_pages_stream(
168
+ &url,
169
+ &[],
170
+ req.token.as_deref(),
171
+ req.per_page,
172
+ &mut |item: BitbucketDcActivityItem| {
173
+ collect_comments_from_activity(item, req.include_review_comments, &mut comments);
174
+ Ok(())
175
+ },
176
+ )?;
177
+
178
+ comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
179
+ Ok(comments)
180
+ }
181
+ }