@mbe24/99problems 0.2.0 → 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 +150 -107
  12. package/Cargo.toml +7 -2
  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,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
+ &params,
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
+ &params,
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
+ }