@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,177 @@
|
|
|
1
|
+
use std::fmt::Write as _;
|
|
2
|
+
|
|
3
|
+
use crate::source::ContentKind;
|
|
4
|
+
|
|
5
|
+
#[derive(Debug)]
|
|
6
|
+
pub(super) struct GitLabFilters {
|
|
7
|
+
pub(super) repo: Option<String>,
|
|
8
|
+
pub(super) kind: ContentKind,
|
|
9
|
+
pub(super) state: Option<String>,
|
|
10
|
+
pub(super) labels: Vec<String>,
|
|
11
|
+
pub(super) author: Option<String>,
|
|
12
|
+
pub(super) since: Option<String>,
|
|
13
|
+
pub(super) milestone: Option<String>,
|
|
14
|
+
pub(super) search_terms: Vec<String>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
impl Default for GitLabFilters {
|
|
18
|
+
fn default() -> Self {
|
|
19
|
+
Self {
|
|
20
|
+
repo: None,
|
|
21
|
+
kind: ContentKind::Issue,
|
|
22
|
+
state: None,
|
|
23
|
+
labels: vec![],
|
|
24
|
+
author: None,
|
|
25
|
+
since: None,
|
|
26
|
+
milestone: None,
|
|
27
|
+
search_terms: vec![],
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub(super) fn parse_gitlab_query(raw_query: &str) -> GitLabFilters {
|
|
33
|
+
let mut filters = GitLabFilters::default();
|
|
34
|
+
|
|
35
|
+
for token in raw_query.split_whitespace() {
|
|
36
|
+
if token == "is:issue" {
|
|
37
|
+
filters.kind = ContentKind::Issue;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if token == "is:pr" {
|
|
41
|
+
filters.kind = ContentKind::Pr;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if let Some(kind) = token.strip_prefix("type:") {
|
|
45
|
+
if kind == "pr" {
|
|
46
|
+
filters.kind = ContentKind::Pr;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if kind == "issue" {
|
|
50
|
+
filters.kind = ContentKind::Issue;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if let Some(repo) = token.strip_prefix("repo:") {
|
|
55
|
+
filters.repo = Some(repo.to_string());
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if let Some(state) = token.strip_prefix("state:") {
|
|
59
|
+
filters.state = Some(state.to_string());
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if let Some(label) = token.strip_prefix("label:") {
|
|
63
|
+
filters.labels.push(label.to_string());
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if let Some(author) = token.strip_prefix("author:") {
|
|
67
|
+
filters.author = Some(author.to_string());
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if let Some(since) = token.strip_prefix("created:>=") {
|
|
71
|
+
filters.since = Some(since.to_string());
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if let Some(milestone) = token.strip_prefix("milestone:") {
|
|
75
|
+
filters.milestone = Some(milestone.to_string());
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
filters.search_terms.push(token.to_string());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
filters
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pub(super) fn build_search_params(filters: &GitLabFilters) -> Vec<(String, String)> {
|
|
86
|
+
let mut params = Vec::new();
|
|
87
|
+
|
|
88
|
+
if let Some(state) = normalize_state(filters.kind, filters.state.as_deref()) {
|
|
89
|
+
params.push(("state".into(), state.to_string()));
|
|
90
|
+
}
|
|
91
|
+
if !filters.labels.is_empty() {
|
|
92
|
+
params.push(("labels".into(), filters.labels.join(",")));
|
|
93
|
+
}
|
|
94
|
+
if let Some(author) = &filters.author {
|
|
95
|
+
params.push(("author_username".into(), author.clone()));
|
|
96
|
+
}
|
|
97
|
+
if let Some(since) = &filters.since {
|
|
98
|
+
params.push(("created_after".into(), since.clone()));
|
|
99
|
+
}
|
|
100
|
+
if let Some(milestone) = &filters.milestone {
|
|
101
|
+
params.push(("milestone".into(), milestone.clone()));
|
|
102
|
+
}
|
|
103
|
+
if !filters.search_terms.is_empty() {
|
|
104
|
+
params.push(("search".into(), filters.search_terms.join(" ")));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
params
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn normalize_state(kind: ContentKind, state: Option<&str>) -> Option<&'static str> {
|
|
111
|
+
let s = state?.to_ascii_lowercase();
|
|
112
|
+
match kind {
|
|
113
|
+
ContentKind::Issue => match s.as_str() {
|
|
114
|
+
"open" | "opened" => Some("opened"),
|
|
115
|
+
"closed" => Some("closed"),
|
|
116
|
+
"all" => Some("all"),
|
|
117
|
+
_ => None,
|
|
118
|
+
},
|
|
119
|
+
ContentKind::Pr => match s.as_str() {
|
|
120
|
+
"open" | "opened" => Some("opened"),
|
|
121
|
+
"closed" => Some("closed"),
|
|
122
|
+
"merged" => Some("merged"),
|
|
123
|
+
"locked" => Some("locked"),
|
|
124
|
+
"all" => Some("all"),
|
|
125
|
+
_ => None,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pub(super) fn encode_project_path(path: &str) -> String {
|
|
131
|
+
let mut out = String::new();
|
|
132
|
+
for b in path.as_bytes() {
|
|
133
|
+
if b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'.' | b'_' | b'~') {
|
|
134
|
+
out.push(*b as char);
|
|
135
|
+
} else {
|
|
136
|
+
write!(out, "%{b:02X}").expect("writing to String should never fail");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[cfg(test)]
|
|
143
|
+
mod tests {
|
|
144
|
+
use super::*;
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn parse_gitlab_query_extracts_filters() {
|
|
148
|
+
let q = parse_gitlab_query(
|
|
149
|
+
"is:pr repo:group/project state:closed label:bug author:alice created:>=2024-01-01 milestone:v1 text",
|
|
150
|
+
);
|
|
151
|
+
assert!(matches!(q.kind, ContentKind::Pr));
|
|
152
|
+
assert_eq!(q.repo.as_deref(), Some("group/project"));
|
|
153
|
+
assert_eq!(q.state.as_deref(), Some("closed"));
|
|
154
|
+
assert_eq!(q.labels, vec!["bug"]);
|
|
155
|
+
assert_eq!(q.author.as_deref(), Some("alice"));
|
|
156
|
+
assert_eq!(q.since.as_deref(), Some("2024-01-01"));
|
|
157
|
+
assert_eq!(q.milestone.as_deref(), Some("v1"));
|
|
158
|
+
assert_eq!(q.search_terms, vec!["text"]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn normalize_state_maps_open_to_opened() {
|
|
163
|
+
assert_eq!(
|
|
164
|
+
normalize_state(ContentKind::Issue, Some("open")),
|
|
165
|
+
Some("opened")
|
|
166
|
+
);
|
|
167
|
+
assert_eq!(
|
|
168
|
+
normalize_state(ContentKind::Pr, Some("open")),
|
|
169
|
+
Some("opened")
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn encode_project_path_encodes_slash() {
|
|
175
|
+
assert_eq!(encode_project_path("group/project"), "group%2Fproject");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::StatusCode;
|
|
3
|
+
use reqwest::blocking::{RequestBuilder, Response};
|
|
4
|
+
use reqwest::header::CONTENT_TYPE;
|
|
5
|
+
use serde::Deserialize;
|
|
6
|
+
use tracing::{debug, trace};
|
|
7
|
+
|
|
8
|
+
use super::model::{JiraCommentsPage, JiraIssueItem, extract_adf_text};
|
|
9
|
+
use super::{JiraSource, PAGE_SIZE};
|
|
10
|
+
use crate::error::{AppError, app_error_from_decode, app_error_from_reqwest};
|
|
11
|
+
use crate::model::{Comment, Conversation};
|
|
12
|
+
use crate::source::FetchRequest;
|
|
13
|
+
|
|
14
|
+
impl JiraSource {
|
|
15
|
+
pub(super) fn apply_auth(
|
|
16
|
+
req: RequestBuilder,
|
|
17
|
+
token: Option<&str>,
|
|
18
|
+
account_email: Option<&str>,
|
|
19
|
+
) -> RequestBuilder {
|
|
20
|
+
let req = req.header("Accept", "application/json");
|
|
21
|
+
match token {
|
|
22
|
+
Some(t) if t.contains(':') => {
|
|
23
|
+
let (user, api_token) = t.split_once(':').unwrap_or_default();
|
|
24
|
+
req.basic_auth(user, Some(api_token))
|
|
25
|
+
}
|
|
26
|
+
Some(t) => match account_email {
|
|
27
|
+
Some(email) => req.basic_auth(email, Some(t)),
|
|
28
|
+
None => req.bearer_auth(t),
|
|
29
|
+
},
|
|
30
|
+
None => req,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub(super) fn bounded_per_page(per_page: u32) -> u32 {
|
|
35
|
+
per_page.clamp(1, PAGE_SIZE)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(super) fn send(req: RequestBuilder, operation: &str) -> Result<Response> {
|
|
39
|
+
req.send()
|
|
40
|
+
.map_err(|err| app_error_from_reqwest("Jira", operation, &err).into())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub(super) fn fetch_issue(&self, id_or_key: &str, req: &FetchRequest) -> Result<Conversation> {
|
|
44
|
+
let fields = "summary,description,status";
|
|
45
|
+
let url = format!("{}/rest/api/3/issue/{}", self.base_url, id_or_key);
|
|
46
|
+
let http = Self::apply_auth(
|
|
47
|
+
self.client.get(&url),
|
|
48
|
+
req.token.as_deref(),
|
|
49
|
+
req.account_email.as_deref(),
|
|
50
|
+
)
|
|
51
|
+
.query(&[("fields", fields)]);
|
|
52
|
+
let resp = Self::send(http, "issue fetch")?;
|
|
53
|
+
if resp.status() == StatusCode::NOT_FOUND {
|
|
54
|
+
let body = resp.text().unwrap_or_default();
|
|
55
|
+
let auth_hint = if req.token.is_some() {
|
|
56
|
+
if req.account_email.is_none() {
|
|
57
|
+
" Check Jira permissions or configure account_email for API-token auth."
|
|
58
|
+
} else {
|
|
59
|
+
" Check Jira permissions for this issue."
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
" Jira often returns 404 for unauthorized issues. Set --token, JIRA_TOKEN, or [instances.<alias>].token."
|
|
63
|
+
};
|
|
64
|
+
return Err(AppError::not_found(format!(
|
|
65
|
+
"Jira issue '{}' was not found or is not accessible.{} Response: {}",
|
|
66
|
+
id_or_key,
|
|
67
|
+
auth_hint,
|
|
68
|
+
body_snippet(&body)
|
|
69
|
+
))
|
|
70
|
+
.with_provider("jira")
|
|
71
|
+
.with_http_status(StatusCode::NOT_FOUND)
|
|
72
|
+
.into());
|
|
73
|
+
}
|
|
74
|
+
let issue: JiraIssueItem = Self::parse_jira_json(
|
|
75
|
+
resp,
|
|
76
|
+
req.token.as_deref(),
|
|
77
|
+
req.account_email.as_deref(),
|
|
78
|
+
"issue fetch",
|
|
79
|
+
)?;
|
|
80
|
+
let comments = if req.include_comments {
|
|
81
|
+
self.fetch_comments(&issue.key, req)?
|
|
82
|
+
} else {
|
|
83
|
+
vec![]
|
|
84
|
+
};
|
|
85
|
+
Ok(Conversation {
|
|
86
|
+
id: issue.key,
|
|
87
|
+
title: issue.fields.summary,
|
|
88
|
+
state: issue.fields.status.name,
|
|
89
|
+
body: issue
|
|
90
|
+
.fields
|
|
91
|
+
.description
|
|
92
|
+
.as_ref()
|
|
93
|
+
.map(extract_adf_text)
|
|
94
|
+
.filter(|s| !s.is_empty()),
|
|
95
|
+
comments,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pub(super) fn fetch_comments(
|
|
100
|
+
&self,
|
|
101
|
+
issue_key: &str,
|
|
102
|
+
req: &FetchRequest,
|
|
103
|
+
) -> Result<Vec<Comment>> {
|
|
104
|
+
let mut start_at = 0u32;
|
|
105
|
+
let per_page = Self::bounded_per_page(req.per_page);
|
|
106
|
+
let mut out = vec![];
|
|
107
|
+
|
|
108
|
+
loop {
|
|
109
|
+
let url = format!("{}/rest/api/3/issue/{issue_key}/comment", self.base_url);
|
|
110
|
+
debug!(issue_key, start_at, per_page, "fetching Jira comment page");
|
|
111
|
+
let http = Self::apply_auth(
|
|
112
|
+
self.client.get(&url),
|
|
113
|
+
req.token.as_deref(),
|
|
114
|
+
req.account_email.as_deref(),
|
|
115
|
+
)
|
|
116
|
+
.query(&[
|
|
117
|
+
("startAt", start_at.to_string()),
|
|
118
|
+
("maxResults", per_page.to_string()),
|
|
119
|
+
]);
|
|
120
|
+
let resp = Self::send(http, "comment fetch")?;
|
|
121
|
+
let page: JiraCommentsPage = Self::parse_jira_json(
|
|
122
|
+
resp,
|
|
123
|
+
req.token.as_deref(),
|
|
124
|
+
req.account_email.as_deref(),
|
|
125
|
+
"comment fetch",
|
|
126
|
+
)?;
|
|
127
|
+
trace!(
|
|
128
|
+
count = page.comments.len(),
|
|
129
|
+
start_at = page.start_at,
|
|
130
|
+
"decoded Jira comments page"
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
for c in page.comments {
|
|
134
|
+
let body = extract_adf_text(&c.body);
|
|
135
|
+
out.push(Comment {
|
|
136
|
+
author: c.author.map(|a| a.display_name),
|
|
137
|
+
created_at: c.created,
|
|
138
|
+
body: if body.is_empty() { None } else { Some(body) },
|
|
139
|
+
kind: Some("issue_comment".into()),
|
|
140
|
+
review_path: None,
|
|
141
|
+
review_line: None,
|
|
142
|
+
review_side: None,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let next = page.start_at + page.max_results;
|
|
147
|
+
if next >= page.total {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
start_at = next;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
Ok(out)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pub(super) fn parse_jira_json<T: for<'de> Deserialize<'de>>(
|
|
157
|
+
resp: Response,
|
|
158
|
+
token: Option<&str>,
|
|
159
|
+
account_email: Option<&str>,
|
|
160
|
+
operation: &str,
|
|
161
|
+
) -> Result<T> {
|
|
162
|
+
let status = resp.status();
|
|
163
|
+
let content_type = resp
|
|
164
|
+
.headers()
|
|
165
|
+
.get(CONTENT_TYPE)
|
|
166
|
+
.and_then(|v| v.to_str().ok())
|
|
167
|
+
.unwrap_or("")
|
|
168
|
+
.to_string();
|
|
169
|
+
let body = resp.text()?;
|
|
170
|
+
|
|
171
|
+
if !status.is_success() {
|
|
172
|
+
let auth_hint = if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN
|
|
173
|
+
{
|
|
174
|
+
if token.is_some() {
|
|
175
|
+
if account_email.is_some() {
|
|
176
|
+
" Jira auth failed. Check token format/scope (email:api_token for Atlassian Cloud)."
|
|
177
|
+
} else {
|
|
178
|
+
" Jira auth failed. If this is an Atlassian API token, also set account email (--account-email, JIRA_ACCOUNT_EMAIL, or [instances.<alias>].account_email), or pass --token as email:api_token."
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
" No Jira token detected. Set --token, JIRA_TOKEN, or [instances.<alias>].token."
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
""
|
|
185
|
+
};
|
|
186
|
+
let mut err =
|
|
187
|
+
AppError::from_http("Jira", operation, status, &body).with_provider("jira");
|
|
188
|
+
if !auth_hint.is_empty() {
|
|
189
|
+
err = err.with_hint(auth_hint.trim());
|
|
190
|
+
}
|
|
191
|
+
return Err(err.into());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if !content_type.contains("application/json") {
|
|
195
|
+
return Err(AppError::provider(format!(
|
|
196
|
+
"Jira API {} returned non-JSON content-type '{}' (body starts with: {}). This often means an auth/login page.",
|
|
197
|
+
operation,
|
|
198
|
+
content_type,
|
|
199
|
+
body_snippet(&body)
|
|
200
|
+
))
|
|
201
|
+
.with_provider("jira")
|
|
202
|
+
.with_http_status(status)
|
|
203
|
+
.into());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
serde_json::from_str(&body).map_err(|e| {
|
|
207
|
+
app_error_from_decode(
|
|
208
|
+
"Jira",
|
|
209
|
+
operation,
|
|
210
|
+
format!("{e} (body starts with: {})", body_snippet(&body)),
|
|
211
|
+
)
|
|
212
|
+
.into()
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fn body_snippet(body: &str) -> String {
|
|
218
|
+
body.chars()
|
|
219
|
+
.take(200)
|
|
220
|
+
.collect::<String>()
|
|
221
|
+
.replace('\n', " ")
|
|
222
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::blocking::Client;
|
|
3
|
+
use tracing::{debug, trace};
|
|
4
|
+
|
|
5
|
+
use super::{ContentKind, FetchRequest, FetchTarget, Source};
|
|
6
|
+
use crate::error::AppError;
|
|
7
|
+
use crate::model::Conversation;
|
|
8
|
+
|
|
9
|
+
mod api;
|
|
10
|
+
mod model;
|
|
11
|
+
mod query;
|
|
12
|
+
|
|
13
|
+
use model::{JiraSearchResponse, extract_adf_text};
|
|
14
|
+
use query::{build_jql, parse_jira_query};
|
|
15
|
+
|
|
16
|
+
pub(super) const JIRA_DEFAULT_BASE_URL: &str = "https://jira.atlassian.com";
|
|
17
|
+
pub(super) const PAGE_SIZE: u32 = 100;
|
|
18
|
+
|
|
19
|
+
pub struct JiraSource {
|
|
20
|
+
client: Client,
|
|
21
|
+
base_url: String,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl JiraSource {
|
|
25
|
+
/// Create a Jira source client.
|
|
26
|
+
///
|
|
27
|
+
/// # Errors
|
|
28
|
+
///
|
|
29
|
+
/// Returns an error if the HTTP client cannot be constructed.
|
|
30
|
+
pub fn new(base_url: Option<String>) -> Result<Self> {
|
|
31
|
+
let client = Client::builder()
|
|
32
|
+
.user_agent(concat!("99problems-cli/", env!("CARGO_PKG_VERSION")))
|
|
33
|
+
.build()?;
|
|
34
|
+
let base_url = base_url
|
|
35
|
+
.unwrap_or_else(|| JIRA_DEFAULT_BASE_URL.to_string())
|
|
36
|
+
.trim_end_matches('/')
|
|
37
|
+
.to_string();
|
|
38
|
+
Ok(Self { client, base_url })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fn search_stream(
|
|
42
|
+
&self,
|
|
43
|
+
req: &FetchRequest,
|
|
44
|
+
raw_query: &str,
|
|
45
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
46
|
+
) -> Result<usize> {
|
|
47
|
+
let filters = parse_jira_query(raw_query);
|
|
48
|
+
if matches!(filters.kind, ContentKind::Pr) {
|
|
49
|
+
return Err(AppError::usage(
|
|
50
|
+
"Platform 'jira' does not support pull requests. Use --type issue.",
|
|
51
|
+
)
|
|
52
|
+
.into());
|
|
53
|
+
}
|
|
54
|
+
let jql = build_jql(&filters)?;
|
|
55
|
+
let per_page = Self::bounded_per_page(req.per_page);
|
|
56
|
+
let mut start_at = 0u32;
|
|
57
|
+
let mut next_page_token: Option<String> = None;
|
|
58
|
+
let mut emitted = 0usize;
|
|
59
|
+
|
|
60
|
+
loop {
|
|
61
|
+
let url = format!("{}/rest/api/3/search/jql", self.base_url);
|
|
62
|
+
debug!(
|
|
63
|
+
start_at,
|
|
64
|
+
per_page,
|
|
65
|
+
has_next_page_token = next_page_token.is_some(),
|
|
66
|
+
"fetching Jira search page"
|
|
67
|
+
);
|
|
68
|
+
let mut query_params: Vec<(String, String)> = vec![
|
|
69
|
+
("jql".into(), jql.clone()),
|
|
70
|
+
("maxResults".into(), per_page.to_string()),
|
|
71
|
+
("fields".into(), "summary,description,status".into()),
|
|
72
|
+
];
|
|
73
|
+
if let Some(token) = &next_page_token {
|
|
74
|
+
query_params.push(("nextPageToken".into(), token.clone()));
|
|
75
|
+
} else {
|
|
76
|
+
query_params.push(("startAt".into(), start_at.to_string()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let http = Self::apply_auth(
|
|
80
|
+
self.client.get(&url),
|
|
81
|
+
req.token.as_deref(),
|
|
82
|
+
req.account_email.as_deref(),
|
|
83
|
+
)
|
|
84
|
+
.query(&query_params);
|
|
85
|
+
let resp = Self::send(http, "search")?;
|
|
86
|
+
let page: JiraSearchResponse = Self::parse_jira_json(
|
|
87
|
+
resp,
|
|
88
|
+
req.token.as_deref(),
|
|
89
|
+
req.account_email.as_deref(),
|
|
90
|
+
"search",
|
|
91
|
+
)?;
|
|
92
|
+
trace!(count = page.issues.len(), "decoded Jira search page");
|
|
93
|
+
for issue in page.issues {
|
|
94
|
+
let comments = if req.include_comments {
|
|
95
|
+
self.fetch_comments(&issue.key, req)?
|
|
96
|
+
} else {
|
|
97
|
+
vec![]
|
|
98
|
+
};
|
|
99
|
+
emit(Conversation {
|
|
100
|
+
id: issue.key,
|
|
101
|
+
title: issue.fields.summary,
|
|
102
|
+
state: issue.fields.status.name,
|
|
103
|
+
body: issue
|
|
104
|
+
.fields
|
|
105
|
+
.description
|
|
106
|
+
.as_ref()
|
|
107
|
+
.map(extract_adf_text)
|
|
108
|
+
.filter(|s| !s.is_empty()),
|
|
109
|
+
comments,
|
|
110
|
+
})?;
|
|
111
|
+
emitted += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if let Some(token) = page.next_page_token {
|
|
115
|
+
next_page_token = Some(token);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if let (Some(s), Some(m), Some(t)) = (page.start_at, page.max_results, page.total) {
|
|
119
|
+
let next = s + m;
|
|
120
|
+
if next < t {
|
|
121
|
+
start_at = next;
|
|
122
|
+
next_page_token = None;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if page.is_last == Some(false) {
|
|
128
|
+
return Err(AppError::provider(
|
|
129
|
+
"Jira API search response indicated more pages but provided no pagination cursor.",
|
|
130
|
+
)
|
|
131
|
+
.with_provider("jira")
|
|
132
|
+
.into());
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Ok(emitted)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
impl Source for JiraSource {
|
|
142
|
+
fn fetch_stream(
|
|
143
|
+
&self,
|
|
144
|
+
req: &FetchRequest,
|
|
145
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
146
|
+
) -> Result<usize> {
|
|
147
|
+
match &req.target {
|
|
148
|
+
FetchTarget::Search { raw_query } => self.search_stream(req, raw_query, emit),
|
|
149
|
+
FetchTarget::Id { id, kind, .. } => {
|
|
150
|
+
if matches!(kind, ContentKind::Pr) {
|
|
151
|
+
return Err(AppError::usage(
|
|
152
|
+
"Platform 'jira' does not support pull requests. Use --type issue.",
|
|
153
|
+
)
|
|
154
|
+
.into());
|
|
155
|
+
}
|
|
156
|
+
emit(self.fetch_issue(id, req)?)?;
|
|
157
|
+
Ok(1)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
use serde::Deserialize;
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
#[derive(Deserialize)]
|
|
5
|
+
pub(super) struct JiraSearchResponse {
|
|
6
|
+
#[serde(rename = "startAt")]
|
|
7
|
+
pub(super) start_at: Option<u32>,
|
|
8
|
+
#[serde(rename = "maxResults")]
|
|
9
|
+
pub(super) max_results: Option<u32>,
|
|
10
|
+
pub(super) total: Option<u32>,
|
|
11
|
+
#[serde(rename = "isLast")]
|
|
12
|
+
pub(super) is_last: Option<bool>,
|
|
13
|
+
#[serde(rename = "nextPageToken")]
|
|
14
|
+
pub(super) next_page_token: Option<String>,
|
|
15
|
+
pub(super) issues: Vec<JiraIssueItem>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Deserialize)]
|
|
19
|
+
pub(super) struct JiraIssueItem {
|
|
20
|
+
pub(super) key: String,
|
|
21
|
+
pub(super) fields: JiraIssueFields,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Deserialize)]
|
|
25
|
+
pub(super) struct JiraIssueFields {
|
|
26
|
+
pub(super) summary: String,
|
|
27
|
+
pub(super) description: Option<Value>,
|
|
28
|
+
pub(super) status: JiraStatus,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[derive(Deserialize)]
|
|
32
|
+
pub(super) struct JiraStatus {
|
|
33
|
+
pub(super) name: String,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Deserialize)]
|
|
37
|
+
pub(super) struct JiraCommentsPage {
|
|
38
|
+
#[serde(rename = "startAt")]
|
|
39
|
+
pub(super) start_at: u32,
|
|
40
|
+
#[serde(rename = "maxResults")]
|
|
41
|
+
pub(super) max_results: u32,
|
|
42
|
+
pub(super) total: u32,
|
|
43
|
+
pub(super) comments: Vec<JiraCommentItem>,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[derive(Deserialize)]
|
|
47
|
+
pub(super) struct JiraCommentItem {
|
|
48
|
+
pub(super) author: Option<JiraAuthor>,
|
|
49
|
+
pub(super) created: String,
|
|
50
|
+
pub(super) body: Value,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[derive(Deserialize)]
|
|
54
|
+
pub(super) struct JiraAuthor {
|
|
55
|
+
#[serde(rename = "displayName")]
|
|
56
|
+
pub(super) display_name: String,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub(super) fn extract_adf_text(value: &Value) -> String {
|
|
60
|
+
fn walk(v: &Value, out: &mut Vec<String>) {
|
|
61
|
+
match v {
|
|
62
|
+
Value::Object(map) => {
|
|
63
|
+
if let Some(Value::String(text)) = map.get("text") {
|
|
64
|
+
out.push(text.clone());
|
|
65
|
+
}
|
|
66
|
+
if let Some(content) = map.get("content") {
|
|
67
|
+
walk(content, out);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
Value::Array(items) => {
|
|
71
|
+
for item in items {
|
|
72
|
+
walk(item, out);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
_ => {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let mut chunks = Vec::new();
|
|
80
|
+
walk(value, &mut chunks);
|
|
81
|
+
chunks.join(" ").trim().to_string()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[cfg(test)]
|
|
85
|
+
mod tests {
|
|
86
|
+
use super::*;
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn extract_adf_text_reads_nested_nodes() {
|
|
90
|
+
let value: Value = serde_json::json!({
|
|
91
|
+
"type": "doc",
|
|
92
|
+
"content": [
|
|
93
|
+
{"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]},
|
|
94
|
+
{"type": "paragraph", "content": [{"type": "text", "text": "world"}]}
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
assert_eq!(extract_adf_text(&value), "Hello world");
|
|
98
|
+
}
|
|
99
|
+
}
|