@mbe24/99problems 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +7 -7
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +109 -72
  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 +641 -42
  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 +230 -91
  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 +179 -24
  71. package/tests/integration.rs +406 -26
  72. package/src/source/github_issues.rs +0 -232
@@ -0,0 +1,153 @@
1
+ use anyhow::Result;
2
+
3
+ use crate::error::AppError;
4
+ use crate::source::ContentKind;
5
+
6
+ #[derive(Debug)]
7
+ pub(super) struct JiraFilters {
8
+ pub(super) repo: Option<String>,
9
+ pub(super) kind: ContentKind,
10
+ pub(super) state: Option<String>,
11
+ pub(super) labels: Vec<String>,
12
+ pub(super) author: Option<String>,
13
+ pub(super) since: Option<String>,
14
+ pub(super) milestone: Option<String>,
15
+ pub(super) search_terms: Vec<String>,
16
+ }
17
+
18
+ impl Default for JiraFilters {
19
+ fn default() -> Self {
20
+ Self {
21
+ repo: None,
22
+ kind: ContentKind::Issue,
23
+ state: None,
24
+ labels: vec![],
25
+ author: None,
26
+ since: None,
27
+ milestone: None,
28
+ search_terms: vec![],
29
+ }
30
+ }
31
+ }
32
+
33
+ pub(super) fn parse_jira_query(raw_query: &str) -> JiraFilters {
34
+ let mut filters = JiraFilters::default();
35
+
36
+ for token in raw_query.split_whitespace() {
37
+ if token == "is:issue" {
38
+ filters.kind = ContentKind::Issue;
39
+ continue;
40
+ }
41
+ if token == "is:pr" {
42
+ filters.kind = ContentKind::Pr;
43
+ continue;
44
+ }
45
+ if let Some(kind) = token.strip_prefix("type:") {
46
+ if kind == "issue" {
47
+ filters.kind = ContentKind::Issue;
48
+ continue;
49
+ }
50
+ if kind == "pr" {
51
+ filters.kind = ContentKind::Pr;
52
+ continue;
53
+ }
54
+ }
55
+ if let Some(repo) = token.strip_prefix("repo:") {
56
+ filters.repo = Some(repo.to_string());
57
+ continue;
58
+ }
59
+ if let Some(state) = token.strip_prefix("state:") {
60
+ filters.state = Some(state.to_string());
61
+ continue;
62
+ }
63
+ if let Some(label) = token.strip_prefix("label:") {
64
+ filters.labels.push(label.to_string());
65
+ continue;
66
+ }
67
+ if let Some(author) = token.strip_prefix("author:") {
68
+ filters.author = Some(author.to_string());
69
+ continue;
70
+ }
71
+ if let Some(since) = token.strip_prefix("created:>=") {
72
+ filters.since = Some(since.to_string());
73
+ continue;
74
+ }
75
+ if let Some(milestone) = token.strip_prefix("milestone:") {
76
+ filters.milestone = Some(milestone.to_string());
77
+ continue;
78
+ }
79
+
80
+ filters.search_terms.push(token.to_string());
81
+ }
82
+
83
+ filters
84
+ }
85
+
86
+ pub(super) fn build_jql(filters: &JiraFilters) -> Result<String> {
87
+ let project = filters.repo.as_deref().ok_or_else(|| {
88
+ AppError::usage("No repo: found in query. Use --repo with Jira project key.")
89
+ })?;
90
+
91
+ let mut clauses = vec![format!("project = {}", quote_jql(project))];
92
+ if let Some(state) = filters.state.as_deref() {
93
+ match state.to_ascii_lowercase().as_str() {
94
+ "open" | "opened" => clauses.push("statusCategory != Done".into()),
95
+ "closed" => clauses.push("statusCategory = Done".into()),
96
+ "all" => {}
97
+ _ => clauses.push(format!("status = {}", quote_jql(state))),
98
+ }
99
+ }
100
+
101
+ for label in &filters.labels {
102
+ clauses.push(format!("labels = {}", quote_jql(label)));
103
+ }
104
+ if let Some(author) = &filters.author {
105
+ clauses.push(format!("reporter = {}", quote_jql(author)));
106
+ }
107
+ if let Some(since) = &filters.since {
108
+ clauses.push(format!("created >= {}", quote_jql(since)));
109
+ }
110
+ if let Some(milestone) = &filters.milestone {
111
+ clauses.push(format!("fixVersion = {}", quote_jql(milestone)));
112
+ }
113
+ if !filters.search_terms.is_empty() {
114
+ clauses.push(format!(
115
+ "text ~ {}",
116
+ quote_jql(&filters.search_terms.join(" "))
117
+ ));
118
+ }
119
+
120
+ Ok(clauses.join(" AND "))
121
+ }
122
+
123
+ fn quote_jql(value: &str) -> String {
124
+ format!("\"{}\"", value.replace('"', "\\\""))
125
+ }
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ use super::*;
130
+
131
+ #[test]
132
+ fn parse_jira_query_extracts_filters() {
133
+ let q = parse_jira_query(
134
+ "repo:CLOUD state:closed label:api author:alice created:>=2024-01-01 milestone:v1 text",
135
+ );
136
+ assert_eq!(q.repo.as_deref(), Some("CLOUD"));
137
+ assert!(matches!(q.kind, ContentKind::Issue));
138
+ assert_eq!(q.state.as_deref(), Some("closed"));
139
+ assert_eq!(q.labels, vec!["api"]);
140
+ assert_eq!(q.author.as_deref(), Some("alice"));
141
+ assert_eq!(q.since.as_deref(), Some("2024-01-01"));
142
+ assert_eq!(q.milestone.as_deref(), Some("v1"));
143
+ assert_eq!(q.search_terms, vec!["text"]);
144
+ }
145
+
146
+ #[test]
147
+ fn build_jql_maps_closed_to_done_category() {
148
+ let q = parse_jira_query("repo:CLOUD state:closed");
149
+ let jql = build_jql(&q).unwrap();
150
+ assert!(jql.contains("project = \"CLOUD\""));
151
+ assert!(jql.contains("statusCategory = Done"));
152
+ }
153
+ }
package/src/source/mod.rs CHANGED
@@ -1,19 +1,76 @@
1
1
  use crate::model::Conversation;
2
2
  use anyhow::Result;
3
3
 
4
- pub mod github_issues;
4
+ pub mod bitbucket;
5
+ pub mod github;
6
+ pub mod gitlab;
7
+ pub mod jira;
5
8
 
6
- /// A pluggable data source that fetches issue conversations.
9
+ /// A pluggable data source that fetches issue/PR conversations.
7
10
  pub trait Source {
8
- fn fetch(&self, query: &Query) -> Result<Vec<Conversation>>;
9
- fn fetch_one(&self, repo: &str, issue_id: u64) -> Result<Conversation>;
11
+ /// Fetch issue or pull request conversations for a request target and emit
12
+ /// each conversation incrementally.
13
+ ///
14
+ /// # Errors
15
+ ///
16
+ /// Returns an error when request validation fails, authentication fails,
17
+ /// or the remote platform returns a non-success/invalid response.
18
+ fn fetch_stream(
19
+ &self,
20
+ req: &FetchRequest,
21
+ emit: &mut dyn FnMut(Conversation) -> Result<()>,
22
+ ) -> Result<usize>;
23
+
24
+ /// Fetch all conversations and collect them into memory.
25
+ ///
26
+ /// # Errors
27
+ ///
28
+ /// Returns an error when request validation fails, authentication fails,
29
+ /// remote calls fail, or emitted conversations cannot be collected.
30
+ fn fetch(&self, req: &FetchRequest) -> Result<Vec<Conversation>> {
31
+ let mut conversations = Vec::new();
32
+ self.fetch_stream(req, &mut |conversation| {
33
+ conversations.push(conversation);
34
+ Ok(())
35
+ })?;
36
+ Ok(conversations)
37
+ }
38
+ }
39
+
40
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41
+ pub enum ContentKind {
42
+ Issue,
43
+ Pr,
44
+ }
45
+
46
+ #[derive(Debug, Clone)]
47
+ pub enum FetchTarget {
48
+ Search {
49
+ raw_query: String,
50
+ },
51
+ Id {
52
+ repo: String,
53
+ id: String,
54
+ kind: ContentKind,
55
+ allow_fallback_to_pr: bool,
56
+ },
57
+ }
58
+
59
+ #[derive(Debug, Clone)]
60
+ pub struct FetchRequest {
61
+ pub target: FetchTarget,
62
+ pub per_page: u32,
63
+ pub token: Option<String>,
64
+ pub account_email: Option<String>,
65
+ pub include_comments: bool,
66
+ pub include_review_comments: bool,
10
67
  }
11
68
 
12
69
  /// Parsed search parameters passed to a Source.
13
70
  #[derive(Debug, Default, Clone)]
14
71
  #[allow(dead_code)]
15
72
  pub struct Query {
16
- /// Full raw query string (GitHub search syntax), e.g. "is:issue state:closed Event repo:owner/repo"
73
+ /// Full raw query string (platform search syntax), e.g. "state:closed Event repo:owner/repo"
17
74
  pub raw: String,
18
75
  pub per_page: u32,
19
76
  pub token: Option<String>,
@@ -21,12 +78,18 @@ pub struct Query {
21
78
 
22
79
  impl Query {
23
80
  /// Build a query by merging a raw string with convenience shorthands.
24
- /// Shorthands are only appended if their qualifier isn't already present.
81
+ /// Shorthands are only appended if their qualifier isn't already present in the raw string.
82
+ #[must_use]
83
+ #[allow(clippy::too_many_arguments)]
25
84
  pub fn build(
26
85
  raw: Option<String>,
86
+ kind: &str,
27
87
  repo: Option<String>,
28
88
  state: Option<String>,
29
89
  labels: Option<String>,
90
+ author: Option<String>,
91
+ since: Option<String>,
92
+ milestone: Option<String>,
30
93
  per_page: u32,
31
94
  token: Option<String>,
32
95
  ) -> Self {
@@ -36,6 +99,17 @@ impl Query {
36
99
  parts.push(r);
37
100
  }
38
101
 
102
+ // Inject type qualifier unless already present
103
+ if !parts
104
+ .iter()
105
+ .any(|p| p.contains("is:issue") || p.contains("is:pr") || p.contains("type:"))
106
+ {
107
+ match kind {
108
+ "pr" => parts.push("is:pr".into()),
109
+ _ => parts.push("is:issue".into()),
110
+ }
111
+ }
112
+
39
113
  if let Some(repo) = repo
40
114
  && !parts.iter().any(|p| p.contains("repo:"))
41
115
  {
@@ -54,6 +128,21 @@ impl Query {
54
128
  }
55
129
  }
56
130
  }
131
+ if let Some(author) = author
132
+ && !parts.iter().any(|p| p.contains("author:"))
133
+ {
134
+ parts.push(format!("author:{author}"));
135
+ }
136
+ if let Some(since) = since
137
+ && !parts.iter().any(|p| p.contains("created:"))
138
+ {
139
+ parts.push(format!("created:>={since}"));
140
+ }
141
+ if let Some(milestone) = milestone
142
+ && !parts.iter().any(|p| p.contains("milestone:"))
143
+ {
144
+ parts.push(format!("milestone:{milestone}"));
145
+ }
57
146
 
58
147
  Query {
59
148
  raw: parts.join(" "),
@@ -67,29 +156,36 @@ impl Query {
67
156
  mod tests {
68
157
  use super::*;
69
158
 
70
- #[test]
71
- fn build_query_appends_repo_and_state() {
72
- let q = Query::build(
73
- Some("is:issue Event".into()),
74
- Some("owner/repo".into()),
75
- Some("closed".into()),
159
+ fn build(raw: Option<&str>, kind: &str, repo: Option<&str>, state: Option<&str>) -> Query {
160
+ Query::build(
161
+ raw.map(std::convert::Into::into),
162
+ kind,
163
+ repo.map(std::convert::Into::into),
164
+ state.map(std::convert::Into::into),
165
+ None,
166
+ None,
167
+ None,
76
168
  None,
77
169
  100,
78
170
  None,
79
- );
171
+ )
172
+ }
173
+
174
+ #[test]
175
+ fn build_query_appends_repo_and_state() {
176
+ let q = build(Some("Event"), "issue", Some("owner/repo"), Some("closed"));
80
177
  assert!(q.raw.contains("repo:owner/repo"));
81
178
  assert!(q.raw.contains("state:closed"));
82
- assert!(q.raw.contains("is:issue Event"));
179
+ assert!(q.raw.contains("Event"));
180
+ assert!(q.raw.contains("is:issue"));
83
181
  }
84
182
 
85
183
  #[test]
86
184
  fn build_query_does_not_duplicate_repo() {
87
- let q = Query::build(
88
- Some("is:issue repo:owner/repo".into()),
89
- Some("other/repo".into()),
90
- None,
91
- None,
92
- 100,
185
+ let q = build(
186
+ Some("is:issue repo:owner/repo"),
187
+ "issue",
188
+ Some("other/repo"),
93
189
  None,
94
190
  );
95
191
  assert_eq!(q.raw.matches("repo:").count(), 1);
@@ -97,14 +193,73 @@ mod tests {
97
193
 
98
194
  #[test]
99
195
  fn build_query_handles_multiple_labels() {
100
- let q = Query::build(None, None, None, Some("bug,enhancement".into()), 100, None);
196
+ let q = Query::build(
197
+ None,
198
+ "issue",
199
+ None,
200
+ None,
201
+ Some("bug,enhancement".into()),
202
+ None,
203
+ None,
204
+ None,
205
+ 100,
206
+ None,
207
+ );
101
208
  assert!(q.raw.contains("label:bug"));
102
209
  assert!(q.raw.contains("label:enhancement"));
103
210
  }
104
211
 
105
212
  #[test]
106
- fn build_query_empty_produces_empty_raw() {
107
- let q = Query::build(None, None, None, None, 100, None);
108
- assert_eq!(q.raw.trim(), "");
213
+ fn build_query_empty_produces_type_qualifier() {
214
+ let q = build(None, "issue", None, None);
215
+ assert!(q.raw.contains("is:issue"));
216
+ }
217
+
218
+ #[test]
219
+ fn build_query_pr_type_injects_is_pr() {
220
+ let q = build(None, "pr", Some("owner/repo"), None);
221
+ assert!(q.raw.contains("is:pr"));
222
+ assert!(!q.raw.contains("is:issue"));
223
+ }
224
+
225
+ #[test]
226
+ fn build_query_does_not_duplicate_type() {
227
+ let q = build(Some("is:pr repo:owner/repo"), "pr", None, None);
228
+ assert_eq!(q.raw.matches("is:pr").count(), 1);
229
+ }
230
+
231
+ #[test]
232
+ fn build_query_author_and_since() {
233
+ let q = Query::build(
234
+ None,
235
+ "issue",
236
+ None,
237
+ None,
238
+ None,
239
+ Some("octocat".into()),
240
+ Some("2024-01-01".into()),
241
+ None,
242
+ 100,
243
+ None,
244
+ );
245
+ assert!(q.raw.contains("author:octocat"));
246
+ assert!(q.raw.contains("created:>=2024-01-01"));
247
+ }
248
+
249
+ #[test]
250
+ fn build_query_milestone() {
251
+ let q = Query::build(
252
+ None,
253
+ "issue",
254
+ None,
255
+ None,
256
+ None,
257
+ None,
258
+ None,
259
+ Some("v1.0".into()),
260
+ 100,
261
+ None,
262
+ );
263
+ assert!(q.raw.contains("milestone:v1.0"));
109
264
  }
110
265
  }