@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,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,12 +1,69 @@
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.
@@ -22,6 +79,7 @@ pub struct Query {
22
79
  impl Query {
23
80
  /// Build a query by merging a raw string with convenience shorthands.
24
81
  /// Shorthands are only appended if their qualifier isn't already present in the raw string.
82
+ #[must_use]
25
83
  #[allow(clippy::too_many_arguments)]
26
84
  pub fn build(
27
85
  raw: Option<String>,
@@ -100,10 +158,10 @@ mod tests {
100
158
 
101
159
  fn build(raw: Option<&str>, kind: &str, repo: Option<&str>, state: Option<&str>) -> Query {
102
160
  Query::build(
103
- raw.map(|s| s.into()),
161
+ raw.map(std::convert::Into::into),
104
162
  kind,
105
- repo.map(|s| s.into()),
106
- state.map(|s| s.into()),
163
+ repo.map(std::convert::Into::into),
164
+ state.map(std::convert::Into::into),
107
165
  None,
108
166
  None,
109
167
  None,