@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,282 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use reqwest::StatusCode;
|
|
5
|
+
use reqwest::blocking::{RequestBuilder, Response};
|
|
6
|
+
use serde::Deserialize;
|
|
7
|
+
use tracing::{debug, trace};
|
|
8
|
+
|
|
9
|
+
use super::model::{
|
|
10
|
+
ConversationSeed, GitLabDiscussion, GitLabIssueItem, GitLabMergeRequestItem, GitLabNote,
|
|
11
|
+
map_note_comment, map_review_comment,
|
|
12
|
+
};
|
|
13
|
+
use super::query::encode_project_path;
|
|
14
|
+
use super::{GitLabSource, PAGE_SIZE};
|
|
15
|
+
use crate::error::{AppError, app_error_from_decode, app_error_from_reqwest};
|
|
16
|
+
use crate::model::{Comment, Conversation};
|
|
17
|
+
use crate::source::FetchRequest;
|
|
18
|
+
|
|
19
|
+
impl GitLabSource {
|
|
20
|
+
pub(super) fn apply_auth(req: RequestBuilder, token: Option<&str>) -> RequestBuilder {
|
|
21
|
+
match token {
|
|
22
|
+
Some(t) => req.header("PRIVATE-TOKEN", t),
|
|
23
|
+
None => req,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub(super) fn bounded_per_page(per_page: u32) -> u32 {
|
|
28
|
+
per_page.clamp(1, PAGE_SIZE)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub(super) fn send(req: RequestBuilder, operation: &str) -> Result<Response> {
|
|
32
|
+
req.send()
|
|
33
|
+
.map_err(|err| app_error_from_reqwest("GitLab", operation, &err).into())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub(super) fn get_pages<T: for<'de> Deserialize<'de>>(
|
|
37
|
+
&self,
|
|
38
|
+
url: &str,
|
|
39
|
+
params: &[(String, String)],
|
|
40
|
+
token: Option<&str>,
|
|
41
|
+
per_page: u32,
|
|
42
|
+
allow_unauthenticated_empty: bool,
|
|
43
|
+
) -> Result<Vec<T>> {
|
|
44
|
+
let mut results = vec![];
|
|
45
|
+
self.get_pages_stream(
|
|
46
|
+
url,
|
|
47
|
+
params,
|
|
48
|
+
token,
|
|
49
|
+
per_page,
|
|
50
|
+
allow_unauthenticated_empty,
|
|
51
|
+
&mut |item| {
|
|
52
|
+
results.push(item);
|
|
53
|
+
Ok(())
|
|
54
|
+
},
|
|
55
|
+
)?;
|
|
56
|
+
Ok(results)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[allow(clippy::too_many_arguments)]
|
|
60
|
+
pub(super) fn get_pages_stream<T: for<'de> Deserialize<'de>>(
|
|
61
|
+
&self,
|
|
62
|
+
url: &str,
|
|
63
|
+
params: &[(String, String)],
|
|
64
|
+
token: Option<&str>,
|
|
65
|
+
per_page: u32,
|
|
66
|
+
allow_unauthenticated_empty: bool,
|
|
67
|
+
emit: &mut dyn FnMut(T) -> Result<()>,
|
|
68
|
+
) -> Result<usize> {
|
|
69
|
+
let mut emitted = 0usize;
|
|
70
|
+
let mut page = 1u32;
|
|
71
|
+
let per_page = Self::bounded_per_page(per_page);
|
|
72
|
+
|
|
73
|
+
loop {
|
|
74
|
+
let mut query = params.to_vec();
|
|
75
|
+
query.push(("per_page".into(), per_page.to_string()));
|
|
76
|
+
query.push(("page".into(), page.to_string()));
|
|
77
|
+
debug!(url = %url, page, per_page, "fetching GitLab page");
|
|
78
|
+
|
|
79
|
+
let req = Self::apply_auth(self.client.get(url).query(&query), token);
|
|
80
|
+
let resp = Self::send(req, "page fetch")?;
|
|
81
|
+
|
|
82
|
+
if !resp.status().is_success() {
|
|
83
|
+
let status = resp.status();
|
|
84
|
+
if allow_unauthenticated_empty
|
|
85
|
+
&& token.is_none()
|
|
86
|
+
&& (status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN)
|
|
87
|
+
{
|
|
88
|
+
return Ok(0);
|
|
89
|
+
}
|
|
90
|
+
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
|
91
|
+
let body = resp.text()?;
|
|
92
|
+
let hint = if token.is_some() {
|
|
93
|
+
"GitLab token seems invalid or lacks required scope (use read_api)."
|
|
94
|
+
} else {
|
|
95
|
+
"No GitLab token detected. Set --token, GITLAB_TOKEN, or [instances.<alias>].token."
|
|
96
|
+
};
|
|
97
|
+
return Err(AppError::auth(format!(
|
|
98
|
+
"GitLab API auth error {status}: {hint} {body}"
|
|
99
|
+
))
|
|
100
|
+
.with_provider("gitlab")
|
|
101
|
+
.with_http_status(status)
|
|
102
|
+
.into());
|
|
103
|
+
}
|
|
104
|
+
let body = resp
|
|
105
|
+
.text()
|
|
106
|
+
.map_err(|err| app_error_from_reqwest("GitLab", "error body read", &err))?;
|
|
107
|
+
return Err(AppError::from_http("GitLab", "page fetch", status, &body).into());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let next_page = resp
|
|
111
|
+
.headers()
|
|
112
|
+
.get("x-next-page")
|
|
113
|
+
.and_then(|v| v.to_str().ok())
|
|
114
|
+
.unwrap_or("")
|
|
115
|
+
.trim()
|
|
116
|
+
.to_string();
|
|
117
|
+
|
|
118
|
+
let items: Vec<T> = resp
|
|
119
|
+
.json()
|
|
120
|
+
.map_err(|err| app_error_from_decode("GitLab", "page fetch", err))?;
|
|
121
|
+
trace!(count = items.len(), page, "decoded GitLab page");
|
|
122
|
+
for item in items {
|
|
123
|
+
emit(item)?;
|
|
124
|
+
emitted += 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if next_page.is_empty() {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
page = next_page.parse::<u32>().unwrap_or(page + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Ok(emitted)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
pub(super) fn get_one<T: for<'de> Deserialize<'de>>(
|
|
138
|
+
&self,
|
|
139
|
+
url: &str,
|
|
140
|
+
token: Option<&str>,
|
|
141
|
+
) -> Result<Option<T>> {
|
|
142
|
+
let req = Self::apply_auth(self.client.get(url), token);
|
|
143
|
+
let resp = Self::send(req, "single fetch")?;
|
|
144
|
+
|
|
145
|
+
if resp.status() == StatusCode::NOT_FOUND {
|
|
146
|
+
return Ok(None);
|
|
147
|
+
}
|
|
148
|
+
if resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN {
|
|
149
|
+
let status = resp.status();
|
|
150
|
+
let hint = if token.is_some() {
|
|
151
|
+
"GitLab token seems invalid or lacks required scope (use read_api)."
|
|
152
|
+
} else {
|
|
153
|
+
"No GitLab token detected. Set --token, GITLAB_TOKEN, or [instances.<alias>].token."
|
|
154
|
+
};
|
|
155
|
+
let body = resp.text()?;
|
|
156
|
+
return Err(
|
|
157
|
+
AppError::auth(format!("GitLab API auth error {status}: {hint} {body}"))
|
|
158
|
+
.with_provider("gitlab")
|
|
159
|
+
.with_http_status(status)
|
|
160
|
+
.into(),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if !resp.status().is_success() {
|
|
164
|
+
let status = resp.status();
|
|
165
|
+
let body = resp
|
|
166
|
+
.text()
|
|
167
|
+
.map_err(|err| app_error_from_reqwest("GitLab", "error body read", &err))?;
|
|
168
|
+
return Err(AppError::from_http("GitLab", "single fetch", status, &body).into());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Ok(Some(resp.json().map_err(|err| {
|
|
172
|
+
app_error_from_decode("GitLab", "single fetch", err)
|
|
173
|
+
})?))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pub(super) fn fetch_notes(
|
|
177
|
+
&self,
|
|
178
|
+
repo: &str,
|
|
179
|
+
iid: u64,
|
|
180
|
+
is_pr: bool,
|
|
181
|
+
req: &FetchRequest,
|
|
182
|
+
) -> Result<Vec<Comment>> {
|
|
183
|
+
let project = encode_project_path(repo);
|
|
184
|
+
let url = if is_pr {
|
|
185
|
+
format!(
|
|
186
|
+
"{}/api/v4/projects/{project}/merge_requests/{iid}/notes",
|
|
187
|
+
self.base_url
|
|
188
|
+
)
|
|
189
|
+
} else {
|
|
190
|
+
format!(
|
|
191
|
+
"{}/api/v4/projects/{project}/issues/{iid}/notes",
|
|
192
|
+
self.base_url
|
|
193
|
+
)
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
let notes: Vec<GitLabNote> =
|
|
197
|
+
self.get_pages(&url, &[], req.token.as_deref(), req.per_page, true)?;
|
|
198
|
+
Ok(notes
|
|
199
|
+
.into_iter()
|
|
200
|
+
.filter(|n| !n.system)
|
|
201
|
+
.map(map_note_comment)
|
|
202
|
+
.collect())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pub(super) fn fetch_review_comments(
|
|
206
|
+
&self,
|
|
207
|
+
repo: &str,
|
|
208
|
+
iid: u64,
|
|
209
|
+
req: &FetchRequest,
|
|
210
|
+
) -> Result<Vec<Comment>> {
|
|
211
|
+
let project = encode_project_path(repo);
|
|
212
|
+
let url = format!(
|
|
213
|
+
"{}/api/v4/projects/{project}/merge_requests/{iid}/discussions",
|
|
214
|
+
self.base_url
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
let discussions: Vec<GitLabDiscussion> =
|
|
218
|
+
self.get_pages(&url, &[], req.token.as_deref(), req.per_page, true)?;
|
|
219
|
+
let mut seen = HashSet::new();
|
|
220
|
+
let mut comments = vec![];
|
|
221
|
+
|
|
222
|
+
for discussion in discussions {
|
|
223
|
+
for note in discussion.notes {
|
|
224
|
+
if note.system || note.position.is_none() || !seen.insert(note.id) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
comments.push(map_review_comment(note));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
Ok(comments)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
pub(super) fn fetch_conversation(
|
|
235
|
+
&self,
|
|
236
|
+
repo: &str,
|
|
237
|
+
seed: ConversationSeed,
|
|
238
|
+
req: &FetchRequest,
|
|
239
|
+
) -> Result<Conversation> {
|
|
240
|
+
let mut comments = Vec::new();
|
|
241
|
+
if req.include_comments {
|
|
242
|
+
comments = self.fetch_notes(repo, seed.id, seed.is_pr, req)?;
|
|
243
|
+
if seed.is_pr && req.include_review_comments {
|
|
244
|
+
comments.extend(self.fetch_review_comments(repo, seed.id, req)?);
|
|
245
|
+
comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Ok(Conversation {
|
|
250
|
+
id: seed.id.to_string(),
|
|
251
|
+
title: seed.title,
|
|
252
|
+
state: seed.state,
|
|
253
|
+
body: seed.body,
|
|
254
|
+
comments,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pub(super) fn fetch_issue_by_iid(
|
|
259
|
+
&self,
|
|
260
|
+
repo: &str,
|
|
261
|
+
iid: u64,
|
|
262
|
+
token: Option<&str>,
|
|
263
|
+
) -> Result<Option<GitLabIssueItem>> {
|
|
264
|
+
let project = encode_project_path(repo);
|
|
265
|
+
let url = format!("{}/api/v4/projects/{project}/issues/{iid}", self.base_url);
|
|
266
|
+
self.get_one(&url, token)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
pub(super) fn fetch_mr_by_iid(
|
|
270
|
+
&self,
|
|
271
|
+
repo: &str,
|
|
272
|
+
iid: u64,
|
|
273
|
+
token: Option<&str>,
|
|
274
|
+
) -> Result<Option<GitLabMergeRequestItem>> {
|
|
275
|
+
let project = encode_project_path(repo);
|
|
276
|
+
let url = format!(
|
|
277
|
+
"{}/api/v4/projects/{project}/merge_requests/{iid}",
|
|
278
|
+
self.base_url
|
|
279
|
+
);
|
|
280
|
+
self.get_one(&url, token)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use reqwest::blocking::Client;
|
|
3
|
+
use tracing::warn;
|
|
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::{ConversationSeed, GitLabIssueItem, GitLabMergeRequestItem};
|
|
14
|
+
use query::{build_search_params, encode_project_path, parse_gitlab_query};
|
|
15
|
+
|
|
16
|
+
pub(super) const GITLAB_DEFAULT_BASE_URL: &str = "https://gitlab.com";
|
|
17
|
+
pub(super) const PAGE_SIZE: u32 = 100;
|
|
18
|
+
|
|
19
|
+
pub struct GitLabSource {
|
|
20
|
+
client: Client,
|
|
21
|
+
base_url: String,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl GitLabSource {
|
|
25
|
+
/// Create a GitLab 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
|
+
|
|
35
|
+
let base_url = base_url
|
|
36
|
+
.unwrap_or_else(|| GITLAB_DEFAULT_BASE_URL.to_string())
|
|
37
|
+
.trim_end_matches('/')
|
|
38
|
+
.to_string();
|
|
39
|
+
|
|
40
|
+
Ok(Self { client, base_url })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn search_stream(
|
|
44
|
+
&self,
|
|
45
|
+
req: &FetchRequest,
|
|
46
|
+
raw_query: &str,
|
|
47
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
48
|
+
) -> Result<usize> {
|
|
49
|
+
let filters = parse_gitlab_query(raw_query);
|
|
50
|
+
let repo = filters
|
|
51
|
+
.repo
|
|
52
|
+
.as_deref()
|
|
53
|
+
.ok_or_else(|| {
|
|
54
|
+
AppError::usage(
|
|
55
|
+
"No repo: found in query. Use --repo or include 'repo:group/project' in -q",
|
|
56
|
+
)
|
|
57
|
+
})?
|
|
58
|
+
.to_string();
|
|
59
|
+
let project = encode_project_path(&repo);
|
|
60
|
+
let params = build_search_params(&filters);
|
|
61
|
+
let mut emitted = 0usize;
|
|
62
|
+
|
|
63
|
+
match filters.kind {
|
|
64
|
+
ContentKind::Issue => {
|
|
65
|
+
let url = format!("{}/api/v4/projects/{project}/issues", self.base_url);
|
|
66
|
+
self.get_pages_stream(
|
|
67
|
+
&url,
|
|
68
|
+
¶ms,
|
|
69
|
+
req.token.as_deref(),
|
|
70
|
+
req.per_page,
|
|
71
|
+
false,
|
|
72
|
+
&mut |i: GitLabIssueItem| {
|
|
73
|
+
let conversation = self.fetch_conversation(
|
|
74
|
+
&repo,
|
|
75
|
+
ConversationSeed {
|
|
76
|
+
id: i.iid,
|
|
77
|
+
title: i.title,
|
|
78
|
+
state: i.state,
|
|
79
|
+
body: i.description,
|
|
80
|
+
is_pr: false,
|
|
81
|
+
},
|
|
82
|
+
req,
|
|
83
|
+
)?;
|
|
84
|
+
emit(conversation)?;
|
|
85
|
+
emitted += 1;
|
|
86
|
+
Ok(())
|
|
87
|
+
},
|
|
88
|
+
)?;
|
|
89
|
+
}
|
|
90
|
+
ContentKind::Pr => {
|
|
91
|
+
let url = format!("{}/api/v4/projects/{project}/merge_requests", self.base_url);
|
|
92
|
+
self.get_pages_stream(
|
|
93
|
+
&url,
|
|
94
|
+
¶ms,
|
|
95
|
+
req.token.as_deref(),
|
|
96
|
+
req.per_page,
|
|
97
|
+
false,
|
|
98
|
+
&mut |mr: GitLabMergeRequestItem| {
|
|
99
|
+
let conversation = self.fetch_conversation(
|
|
100
|
+
&repo,
|
|
101
|
+
ConversationSeed {
|
|
102
|
+
id: mr.iid,
|
|
103
|
+
title: mr.title,
|
|
104
|
+
state: mr.state,
|
|
105
|
+
body: mr.description,
|
|
106
|
+
is_pr: true,
|
|
107
|
+
},
|
|
108
|
+
req,
|
|
109
|
+
)?;
|
|
110
|
+
emit(conversation)?;
|
|
111
|
+
emitted += 1;
|
|
112
|
+
Ok(())
|
|
113
|
+
},
|
|
114
|
+
)?;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
Ok(emitted)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn fetch_by_id_stream(
|
|
121
|
+
&self,
|
|
122
|
+
req: &FetchRequest,
|
|
123
|
+
repo: &str,
|
|
124
|
+
id: &str,
|
|
125
|
+
kind: ContentKind,
|
|
126
|
+
allow_fallback_to_pr: bool,
|
|
127
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
128
|
+
) -> Result<usize> {
|
|
129
|
+
let iid = id.parse::<u64>().map_err(|_| {
|
|
130
|
+
AppError::usage(format!("GitLab expects a numeric issue/MR id, got '{id}'."))
|
|
131
|
+
})?;
|
|
132
|
+
match kind {
|
|
133
|
+
ContentKind::Issue => {
|
|
134
|
+
if let Some(issue) = self.fetch_issue_by_iid(repo, iid, req.token.as_deref())? {
|
|
135
|
+
let conversation = self.fetch_conversation(
|
|
136
|
+
repo,
|
|
137
|
+
ConversationSeed {
|
|
138
|
+
id: issue.iid,
|
|
139
|
+
title: issue.title,
|
|
140
|
+
state: issue.state,
|
|
141
|
+
body: issue.description,
|
|
142
|
+
is_pr: false,
|
|
143
|
+
},
|
|
144
|
+
req,
|
|
145
|
+
)?;
|
|
146
|
+
emit(conversation)?;
|
|
147
|
+
return Ok(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if allow_fallback_to_pr
|
|
151
|
+
&& let Some(mr) = self.fetch_mr_by_iid(repo, iid, req.token.as_deref())?
|
|
152
|
+
{
|
|
153
|
+
warn!(
|
|
154
|
+
"Warning: --id defaulted to issue, but found MR !{iid}; use --type pr for clarity."
|
|
155
|
+
);
|
|
156
|
+
let conversation = self.fetch_conversation(
|
|
157
|
+
repo,
|
|
158
|
+
ConversationSeed {
|
|
159
|
+
id: mr.iid,
|
|
160
|
+
title: mr.title,
|
|
161
|
+
state: mr.state,
|
|
162
|
+
body: mr.description,
|
|
163
|
+
is_pr: true,
|
|
164
|
+
},
|
|
165
|
+
req,
|
|
166
|
+
)?;
|
|
167
|
+
emit(conversation)?;
|
|
168
|
+
return Ok(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Err(AppError::not_found(format!("Issue #{iid} not found in repo {repo}.")).into())
|
|
172
|
+
}
|
|
173
|
+
ContentKind::Pr => {
|
|
174
|
+
if let Some(mr) = self.fetch_mr_by_iid(repo, iid, req.token.as_deref())? {
|
|
175
|
+
let conversation = self.fetch_conversation(
|
|
176
|
+
repo,
|
|
177
|
+
ConversationSeed {
|
|
178
|
+
id: mr.iid,
|
|
179
|
+
title: mr.title,
|
|
180
|
+
state: mr.state,
|
|
181
|
+
body: mr.description,
|
|
182
|
+
is_pr: true,
|
|
183
|
+
},
|
|
184
|
+
req,
|
|
185
|
+
)?;
|
|
186
|
+
emit(conversation)?;
|
|
187
|
+
return Ok(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if self
|
|
191
|
+
.fetch_issue_by_iid(repo, iid, req.token.as_deref())?
|
|
192
|
+
.is_some()
|
|
193
|
+
{
|
|
194
|
+
return Err(AppError::usage(format!(
|
|
195
|
+
"ID {iid} in repo {repo} is an issue, not a merge request."
|
|
196
|
+
))
|
|
197
|
+
.into());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Err(
|
|
201
|
+
AppError::not_found(format!("Merge request !{iid} not found in repo {repo}."))
|
|
202
|
+
.into(),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
impl Source for GitLabSource {
|
|
210
|
+
fn fetch_stream(
|
|
211
|
+
&self,
|
|
212
|
+
req: &FetchRequest,
|
|
213
|
+
emit: &mut dyn FnMut(Conversation) -> Result<()>,
|
|
214
|
+
) -> Result<usize> {
|
|
215
|
+
match &req.target {
|
|
216
|
+
FetchTarget::Search { raw_query } => self.search_stream(req, raw_query, emit),
|
|
217
|
+
FetchTarget::Id {
|
|
218
|
+
repo,
|
|
219
|
+
id,
|
|
220
|
+
kind,
|
|
221
|
+
allow_fallback_to_pr,
|
|
222
|
+
} => self.fetch_by_id_stream(req, repo, id, *kind, *allow_fallback_to_pr, emit),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
use serde::Deserialize;
|
|
2
|
+
|
|
3
|
+
use crate::model::Comment;
|
|
4
|
+
|
|
5
|
+
#[derive(Deserialize)]
|
|
6
|
+
pub(super) struct GitLabIssueItem {
|
|
7
|
+
pub(super) iid: u64,
|
|
8
|
+
pub(super) title: String,
|
|
9
|
+
pub(super) state: String,
|
|
10
|
+
pub(super) description: Option<String>,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#[derive(Deserialize)]
|
|
14
|
+
pub(super) struct GitLabMergeRequestItem {
|
|
15
|
+
pub(super) iid: u64,
|
|
16
|
+
pub(super) title: String,
|
|
17
|
+
pub(super) state: String,
|
|
18
|
+
pub(super) description: Option<String>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Deserialize)]
|
|
22
|
+
pub(super) struct GitLabNote {
|
|
23
|
+
pub(super) author: Option<GitLabAuthor>,
|
|
24
|
+
pub(super) created_at: String,
|
|
25
|
+
pub(super) body: String,
|
|
26
|
+
pub(super) system: bool,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Deserialize)]
|
|
30
|
+
pub(super) struct GitLabAuthor {
|
|
31
|
+
pub(super) username: String,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Deserialize)]
|
|
35
|
+
pub(super) struct GitLabDiscussion {
|
|
36
|
+
pub(super) notes: Vec<GitLabDiscussionNote>,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Deserialize)]
|
|
40
|
+
pub(super) struct GitLabDiscussionNote {
|
|
41
|
+
pub(super) id: u64,
|
|
42
|
+
pub(super) author: Option<GitLabAuthor>,
|
|
43
|
+
pub(super) created_at: String,
|
|
44
|
+
pub(super) body: String,
|
|
45
|
+
pub(super) system: bool,
|
|
46
|
+
pub(super) position: Option<GitLabPosition>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Deserialize)]
|
|
50
|
+
pub(super) struct GitLabPosition {
|
|
51
|
+
pub(super) new_path: Option<String>,
|
|
52
|
+
pub(super) old_path: Option<String>,
|
|
53
|
+
pub(super) new_line: Option<u64>,
|
|
54
|
+
pub(super) old_line: Option<u64>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub(super) struct ConversationSeed {
|
|
58
|
+
pub(super) id: u64,
|
|
59
|
+
pub(super) title: String,
|
|
60
|
+
pub(super) state: String,
|
|
61
|
+
pub(super) body: Option<String>,
|
|
62
|
+
pub(super) is_pr: bool,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub(super) fn map_note_comment(note: GitLabNote) -> Comment {
|
|
66
|
+
Comment {
|
|
67
|
+
author: note.author.map(|a| a.username),
|
|
68
|
+
created_at: note.created_at,
|
|
69
|
+
body: Some(note.body),
|
|
70
|
+
kind: Some("issue_comment".into()),
|
|
71
|
+
review_path: None,
|
|
72
|
+
review_line: None,
|
|
73
|
+
review_side: None,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub(super) fn map_review_comment(note: GitLabDiscussionNote) -> Comment {
|
|
78
|
+
let position = note.position;
|
|
79
|
+
let review_path = position
|
|
80
|
+
.as_ref()
|
|
81
|
+
.and_then(|p| p.new_path.clone().or_else(|| p.old_path.clone()));
|
|
82
|
+
let review_line = position.as_ref().and_then(|p| p.new_line.or(p.old_line));
|
|
83
|
+
let review_side = position.as_ref().and_then(|p| {
|
|
84
|
+
if p.new_line.is_some() {
|
|
85
|
+
Some("RIGHT".to_string())
|
|
86
|
+
} else if p.old_line.is_some() {
|
|
87
|
+
Some("LEFT".to_string())
|
|
88
|
+
} else {
|
|
89
|
+
None
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
Comment {
|
|
94
|
+
author: note.author.map(|a| a.username),
|
|
95
|
+
created_at: note.created_at,
|
|
96
|
+
body: Some(note.body),
|
|
97
|
+
kind: Some("review_comment".into()),
|
|
98
|
+
review_path,
|
|
99
|
+
review_line,
|
|
100
|
+
review_side,
|
|
101
|
+
}
|
|
102
|
+
}
|