1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5use std::{
6 collections::HashMap,
7 fmt::{Display, Error, Formatter},
8 sync::OnceLock,
9};
10
11#[cfg(not(feature = "cloud"))]
12mod versioned {
13 use super::*;
14
15 #[derive(Deserialize, Serialize, Debug, Clone)]
16 #[serde(rename_all = "camelCase")]
17 pub struct User {
18 pub active: bool,
19 pub display_name: String,
20 pub deleted: Option<bool>,
21 pub name: String,
22 }
23
24 #[derive(Serialize, Debug, Clone)]
25 pub struct PostAssignBody {
26 pub name: String,
27 }
28
29 impl From<User> for PostAssignBody {
30 fn from(value: User) -> Self {
31 PostAssignBody { name: value.name }
32 }
33 }
34}
35
36#[cfg(feature = "cloud")]
37mod versioned {
38 use super::*;
39
40 #[derive(Deserialize, Debug, Clone)]
41 #[serde(rename_all = "camelCase")]
42 pub struct GetFilterSearchResponseBody {
43 pub max_results: u32,
45 pub start_at: u32,
46 pub total: u32,
47 pub is_last: bool,
48 #[serde(alias = "values")]
49 pub filters: Vec<Filter>,
50 }
51
52 #[derive(Deserialize, Serialize, Debug, Clone)]
53 #[serde(rename_all = "camelCase")]
54 pub struct User {
55 pub active: bool,
56 pub display_name: String,
57 pub account_id: String,
58 pub email_address: String,
59 }
60
61 #[derive(Serialize, Debug, Clone)]
62 #[serde(rename_all = "camelCase")]
63 pub struct PostAssignBody {
64 pub account_id: String,
65 }
66
67 impl From<User> for PostAssignBody {
68 fn from(value: User) -> Self {
69 PostAssignBody {
70 account_id: value.account_id,
71 }
72 }
73 }
74}
75
76pub use versioned::*;
77
78use crate::JiraClientError;
79
80#[derive(Debug, Clone)]
82pub struct GetAssignableUserParams {
83 pub username: Option<String>,
84 pub project: Option<String>,
85 pub issue_key: Option<IssueKey>,
86 pub max_results: Option<u32>,
87}
88
89#[derive(Serialize, Debug, Clone)]
91#[serde(rename_all = "camelCase")]
92pub struct PostCommentBody {
93 pub body: String,
94}
95
96#[derive(Serialize, Debug, Clone)]
98#[serde(rename_all = "camelCase")]
99pub struct PostWorklogBody {
100 pub comment: String,
101 pub started: String,
102 pub time_spent: Option<String>,
103 pub time_spent_seconds: Option<String>,
104}
105
106#[derive(Serialize, Debug, Clone)]
107#[serde(rename_all = "camelCase")]
108pub struct WorklogDuration(String);
110
111impl Display for WorklogDuration {
112 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
113 write!(f, "{}", self.0)
114 }
115}
116
117static WORKLOG_RE: OnceLock<Regex> = OnceLock::new();
118
119impl TryFrom<String> for WorklogDuration {
120 type Error = JiraClientError;
121 fn try_from(value: String) -> Result<Self, JiraClientError> {
122 let worklog_re = WORKLOG_RE.get_or_init(|| {
123 Regex::new(r"([0-9]+(?:\.[0-9]+)?)[WwDdHhMm]?").expect("Unable to compile WORKLOG_RE")
124 });
125
126 let mut worklog = match worklog_re.captures(&value) {
127 Some(c) => match c.get(0) {
128 Some(worklog_match) => Ok(worklog_match.as_str().to_lowercase()),
129 None => Err(JiraClientError::TryFromError(
130 "First capture is none: WORKLOG_RE".to_string(),
131 )),
132 },
133 None => Err(JiraClientError::TryFromError(
134 "Malformed worklog duration".to_string(),
135 )),
136 }?;
137
138 let multiplier = match worklog.pop() {
139 Some('m') => 60,
140 Some('h') => 3600,
141 Some('d') => 3600 * 8, Some('w') => 3600 * 8 * 5, Some(maybe_digit) if maybe_digit.is_ascii_digit() => {
144 worklog.push(maybe_digit); 60
146 }
147 _ => 60, };
149
150 let seconds = worklog.parse::<f64>().map_err(|_| {
151 JiraClientError::TryFromError("Unexpected worklog duration input".to_string())
152 })? * f64::from(multiplier);
153
154 Ok(WorklogDuration(format!("{:.0}", seconds)))
155 }
156}
157
158#[derive(Serialize, Debug, Clone)]
160#[serde(rename_all = "camelCase")]
161pub struct PostIssueQueryBody {
162 pub fields: Option<Vec<String>>,
163 pub jql: String,
164 pub max_results: u32,
165 pub start_at: u32,
166 pub expand: Option<Vec<String>>,
168}
169
170#[derive(Deserialize, Debug, Clone)]
171#[serde(rename_all = "camelCase")]
172pub struct PostIssueQueryResponseBody {
173 pub expand: String,
175 pub issues: Vec<Issue>,
176 pub max_results: u32,
177 pub start_at: u32,
178 pub total: u32,
179 pub names: Option<HashMap<String, String>>,
181}
182
183#[derive(Deserialize, Serialize, Debug, Clone)]
184#[serde(rename_all = "camelCase")]
185pub struct Issue {
186 pub expand: Option<String>,
187 pub fields: IssueFields,
188 pub id: String,
189 pub key: IssueKey,
190 #[serde(alias = "self")]
191 pub self_ref: String,
192 pub names: Option<HashMap<String, String>>,
194}
195
196#[derive(Deserialize, Serialize, Debug, Clone, Default)]
198#[serde(rename_all = "camelCase")]
199pub struct IssueFields {
200 pub assignee: Option<User>,
201 pub components: Option<Vec<Component>>,
202 pub created: Option<String>,
203 pub creator: Option<User>,
204 pub description: Option<String>,
205 pub duedate: Option<String>,
206 pub labels: Option<Vec<String>>,
207 pub last_viewed: Option<String>,
208 pub reporter: Option<User>,
209 pub resolutiondate: Option<String>,
210 pub summary: Option<String>,
211 pub timeestimate: Option<u32>,
212 pub timeoriginalestimate: Option<u32>,
213 pub timespent: Option<u32>,
214 pub updated: Option<String>,
215 pub workratio: Option<i32>,
216
217 #[serde(flatten)]
234 pub customfields: BTreeMap<String, Value>,
235}
236
237#[derive(Deserialize, Serialize, Debug, Clone)]
238#[serde(rename_all = "camelCase")]
239pub struct Field {
240 pub id: String,
241 pub name: String,
242 pub custom: bool,
243 pub orderable: bool,
244 pub navigable: bool,
245 pub searchable: bool,
246 pub clause_names: Vec<String>,
247 pub schema: Option<FieldSchema>,
248}
249
250#[derive(Deserialize, Serialize, Debug, Clone)]
251#[serde(rename_all = "camelCase")]
252pub struct FieldSchema {
253 pub custom: Option<FieldSchemaType>,
254 pub custom_id: Option<u32>,
255 pub items: Option<FieldSchemaType>,
256 pub system: Option<FieldSchemaType>,
257 #[serde(alias = "type")]
258 pub field_type: Option<String>,
259}
260
261#[derive(Deserialize, Serialize, Debug, Clone)]
262#[serde(rename_all = "kebab-case")]
263pub enum FieldSchemaType {
264 Any,
265 Array,
266 Attachment,
267 CommentsPage,
268 Component,
269 Date,
270 Datetime,
271 Issuelinks,
272 Issuetype,
273 Number,
274 Option,
275 Priority,
276 Progress,
277 Project,
278 Resolution,
279 Securitylevel,
280 Status,
281 String,
282 Timetracking,
283 User,
284 Version,
285 Votes,
286 Watches,
287 Worklog,
288 #[serde(untagged)]
289 Custom(String),
290}
291
292#[derive(Deserialize, Serialize, Debug, Clone)]
293#[serde(rename_all = "camelCase")]
294pub struct Filter {
295 #[serde(alias = "self")]
296 pub self_ref: String,
297 pub id: String,
298 pub name: String,
299 pub description: Option<String>,
300 pub owner: User,
301 pub jql: String,
302 pub view_url: String,
303 pub search_url: String,
304 pub favourite: bool,
305 pub shared_users: FilterSharedUsers,
306 }
309
310impl Display for Filter {
311 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
312 write!(f, "{}: {}", self.name, self.jql)
313 }
314}
315
316#[derive(Deserialize, Serialize, Debug, Clone)]
317#[serde(rename_all = "kebab-case")]
318pub struct FilterSharedUsers {
319 pub size: u32,
320 pub max_results: u32,
321 pub start_index: u32,
322 pub end_index: u32,
323 pub items: Vec<User>,
324}
325
326#[derive(Deserialize, Serialize, Debug, Clone)]
327pub struct Component {
328 pub id: String,
329 pub name: String,
330 #[serde(alias = "self")]
331 pub self_ref: String,
332}
333
334impl Display for Issue {
335 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
336 write!(
337 f,
338 "{} {}",
339 self.key,
340 self.fields
341 .summary
342 .clone()
343 .unwrap_or("summary is None or missing from query response".to_string())
344 )
345 }
346}
347
348#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
349pub struct IssueKey(String);
350
351impl From<IssueKey> for String {
352 fn from(val: IssueKey) -> Self {
353 val.0
354 }
355}
356
357impl Display for IssueKey {
358 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
359 write!(f, "{}", self.0)
360 }
361}
362
363static ISSUE_RE: OnceLock<Regex> = OnceLock::new();
364
365impl TryFrom<String> for IssueKey {
366 type Error = JiraClientError;
367 fn try_from(value: String) -> Result<Self, Self::Error> {
368 let issue_re = ISSUE_RE
369 .get_or_init(|| Regex::new(r"([A-Z]{2,}-[0-9]+)").expect("Unable to compile ISSUE_RE"));
370
371 let upper = value.to_uppercase();
372 let issue_key = match issue_re.captures(&upper) {
373 Some(c) => match c.get(0) {
374 Some(cap) => Ok(cap),
375 None => Err(JiraClientError::TryFromError(
376 "First capture is none: ISSUE_RE".to_string(),
377 )),
378 },
379 None => Err(JiraClientError::TryFromError(
380 "Malformed issue key supplied".to_string(),
381 )),
382 }?;
383
384 Ok(IssueKey(issue_key.as_str().to_string()))
385 }
386}
387
388#[derive(Deserialize, Serialize, Debug, Clone)]
389#[serde(rename_all = "camelCase")]
390pub struct GetTransitionsBody {
391 pub expand: String,
392 pub transitions: Vec<Transition>,
393}
394
395#[derive(Deserialize, Serialize, Debug, Clone)]
396pub struct Transition {
397 pub fields: HashMap<String, TransitionExpandedFields>,
398 pub id: String,
399 pub name: String,
400}
401
402#[derive(Deserialize, Serialize, Debug, Clone)]
403pub struct TransitionExpandedFields {
404 pub required: bool,
405 pub name: String,
406 pub operations: Vec<String>,
407 pub schema: TransitionExpandedFieldsSchema,
408 pub allowed_values: Option<Vec<TransitionFieldAllowedValue>>,
409 pub has_default_value: Option<bool>,
410 pub default_value: Option<String>,
411}
412
413#[derive(Deserialize, Serialize, Debug, Clone)]
414#[serde(untagged)]
415pub enum TransitionFieldAllowedValue {
416 Str(String),
417 Object {
418 #[serde(alias = "self")]
419 self_ref: String,
420 #[serde(alias = "name")]
421 value: String,
422 id: String,
423 },
424}
425
426#[derive(Deserialize, Serialize, Debug, Clone)]
427pub struct TransitionExpandedFieldsSchema {
428 #[serde(alias = "type")]
429 pub schema_type: String,
430 pub items: String,
431 pub custom: String,
432 pub custom_id: u32,
433 #[cfg(not(feature = "cloud"))]
434 pub system: Option<String>,
435}
436
437#[derive(Serialize, Debug, Clone)]
438pub struct PostTransitionIdBody {
439 pub id: String,
440}
441
442#[derive(Serialize, Debug, Clone)]
443pub struct PostTransitionFieldBody {
444 pub name: String,
445}
446
447#[derive(Serialize, Debug, Clone)]
448pub struct PostTransitionBody {
449 pub transition: PostTransitionIdBody,
450 pub fields: Option<HashMap<String, PostTransitionFieldBody>>,
451 pub update: Option<PostTransitionUpdateField>,
452}
453
454#[derive(Serialize, Debug, Clone)]
456pub struct PostTransitionUpdateField {
457 pub add: Option<HashMap<String, Vec<String>>>,
458 pub copy: Option<HashMap<String, Vec<String>>>,
459 pub edit: Option<HashMap<String, Vec<String>>>,
460 pub remove: Option<HashMap<String, Vec<String>>>,
461 pub set: Option<HashMap<String, Vec<String>>>,
462}
463
464impl Display for Transition {
465 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
466 write!(f, "{}", self.name)
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn worklog_tryfrom_all_units_returns_duration_in_seconds() -> Result<(), JiraClientError> {
476 let worklogs = vec![
477 (60, "1"),
478 (60, "1m"),
479 (60, "1M"),
480 (3600, "1h"),
481 (3600, "1H"),
482 (3600 * 8, "1d"),
483 (3600 * 8, "1D"),
484 (3600 * 8 * 5, "1w"),
485 (3600 * 8 * 5, "1W"),
486 ];
487
488 for (expected_seconds, input) in worklogs {
489 let seconds = WorklogDuration::try_from(input.to_string())?.0;
490 assert_eq!(expected_seconds.to_string(), seconds);
491 }
492 Ok(())
493 }
494
495 #[test]
496 fn worklog_tryfrom_lowercase_unit() -> Result<(), JiraClientError> {
497 let wl = WorklogDuration::try_from(String::from("1h"))?;
498 assert_eq!(String::from("3600"), wl.to_string());
499 Ok(())
500 }
501 #[test]
502 fn worklog_tryfrom_uppercase_unit() -> Result<(), JiraClientError> {
503 let wl = WorklogDuration::try_from(String::from("2H"))?;
504 assert_eq!(String::from("7200"), wl.to_string());
505 Ok(())
506 }
507
508 #[test]
509 fn worklog_tostring() -> Result<(), JiraClientError> {
510 let wl = WorklogDuration::try_from(String::from("1h"))?;
511 let expected = String::from("3600");
512 assert_eq!(expected, wl.to_string());
513 Ok(())
514 }
515
516 #[test]
517 fn issuekey_tryfrom_uppercase_id() -> Result<(), JiraClientError> {
518 let key = String::from("JB-1");
519 let issue = IssueKey::try_from(key.clone());
520 assert!(issue.is_ok());
521 assert_eq!(key, issue?.0);
522 Ok(())
523 }
524
525 #[test]
526 fn issuekey_tryfrom_lowercase_id() {
527 let issue = IssueKey::try_from(String::from("jb-1"));
528 assert!(issue.is_ok());
529 }
530
531 #[test]
532 fn issuekey_tostring() {
533 let key = String::from("JB-1");
534 let issue = IssueKey(key.clone());
535 assert_eq!(key, issue.to_string());
536 }
537}