1use crate::JiraClientError;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::{
7 collections::HashMap,
8 fmt::{Display, Error, Formatter},
9 sync::OnceLock,
10};
11
12#[derive(Deserialize, Serialize, Debug, Clone)]
13#[serde(rename_all = "camelCase")]
14pub struct User {
15 pub active: bool,
16 pub display_name: String,
17 pub deleted: Option<bool>,
18 pub name: String,
19}
20
21#[derive(Serialize, Debug, Clone)]
22pub struct PostAssignBody {
23 pub name: String,
24}
25
26impl From<User> for PostAssignBody {
27 fn from(value: User) -> Self {
28 PostAssignBody { name: value.name }
29 }
30}
31
32#[derive(Debug, Clone)]
34pub struct GetAssignableUserParams {
35 pub username: Option<String>,
36 pub project: Option<String>,
37 pub issue_key: Option<IssueKey>,
38 pub max_results: Option<u32>,
39}
40
41#[derive(Serialize, Debug, Clone)]
43#[serde(rename_all = "camelCase")]
44pub struct PostCommentBody {
45 pub body: String,
46}
47
48#[derive(Serialize, Debug, Clone)]
50#[serde(rename_all = "camelCase")]
51pub struct PostWorklogBody {
52 pub comment: String,
53 pub started: String,
54 pub time_spent: Option<String>,
55 pub time_spent_seconds: Option<String>,
56}
57
58#[derive(Serialize, Debug, Clone)]
59#[serde(rename_all = "camelCase")]
60pub struct WorklogDuration(String);
62
63impl Display for WorklogDuration {
64 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
65 write!(f, "{}", self.0)
66 }
67}
68
69static WORKLOG_RE: OnceLock<Regex> = OnceLock::new();
70
71impl TryFrom<String> for WorklogDuration {
72 type Error = JiraClientError;
73 fn try_from(value: String) -> Result<Self, JiraClientError> {
74 let worklog_re = WORKLOG_RE.get_or_init(|| {
75 Regex::new(r"([0-9]+(?:\.[0-9]+)?)[WwDdHhMm]?").expect("Unable to compile WORKLOG_RE")
76 });
77
78 let mut worklog = match worklog_re.captures(&value) {
79 Some(c) => match c.get(0) {
80 Some(worklog_match) => Ok(worklog_match.as_str().to_lowercase()),
81 None => Err(JiraClientError::TryFromError(
82 "First capture is none: WORKLOG_RE".to_string(),
83 )),
84 },
85 None => Err(JiraClientError::TryFromError(
86 "Malformed worklog duration".to_string(),
87 )),
88 }?;
89
90 let multiplier = match worklog.pop() {
91 Some('m') => 60,
92 Some('h') => 3600,
93 Some('d') => 3600 * 8, Some('w') => 3600 * 8 * 5, Some(maybe_digit) if maybe_digit.is_ascii_digit() => {
96 worklog.push(maybe_digit); 60
98 }
99 _ => 60, };
101
102 let seconds = worklog.parse::<f64>().map_err(|_| {
103 JiraClientError::TryFromError("Unexpected worklog duration input".to_string())
104 })? * f64::from(multiplier);
105
106 Ok(WorklogDuration(format!("{seconds:.0}")))
107 }
108}
109
110#[derive(Serialize, Debug, Clone)]
112#[serde(rename_all = "camelCase")]
113pub struct PostIssueQueryBody {
114 pub fields: Option<Vec<String>>,
115 pub jql: String,
116 pub max_results: u32,
117 pub start_at: u32,
118 pub expand: Option<Vec<String>>,
120}
121
122#[derive(Deserialize, Serialize, Debug, Clone)]
123#[serde(rename_all = "camelCase")]
124pub struct PostIssueQueryResponseBody {
125 pub expand: Option<String>,
127 pub issues: Option<Vec<Issue>>,
128 pub max_results: Option<u32>,
129 pub start_at: Option<u32>,
130 pub total: Option<u32>,
131 pub names: Option<HashMap<String, String>>,
133}
134
135#[derive(Deserialize, Serialize, Debug, Clone)]
136#[serde(rename_all = "camelCase")]
137pub struct Issue {
138 pub expand: Option<String>,
139 pub fields: IssueFields,
140 pub id: Option<serde_json::Value>,
141 pub key: IssueKey,
142 #[serde(alias = "self")]
143 pub self_ref: String,
144 pub names: Option<HashMap<String, String>>,
146
147 #[serde(flatten)]
148 pub remainder: BTreeMap<String, Value>,
149}
150
151#[derive(Deserialize, Serialize, Debug, Clone, Default)]
153#[serde(rename_all = "camelCase")]
154pub struct IssueFields {
155 pub assignee: Option<User>,
156 pub components: Option<Vec<Component>>,
157 pub created: Option<String>,
158 pub creator: Option<User>,
159 pub description: Option<String>,
160 pub duedate: Option<String>,
161 pub labels: Option<Vec<String>>,
162 pub last_viewed: Option<String>,
163 pub reporter: Option<User>,
164 pub resolutiondate: Option<String>,
165 pub summary: Option<String>,
166 pub timeestimate: Option<u32>,
167 pub timeoriginalestimate: Option<u32>,
168 pub timespent: Option<u32>,
169 pub updated: Option<String>,
170 pub workratio: Option<i32>,
171
172 pub status: Option<Status>,
175 pub subtasks: Option<Vec<SubTask>>,
180 pub worklog: Option<WorkLog>,
183 #[serde(flatten)]
189 pub customfields: BTreeMap<String, Value>,
190}
191
192#[derive(Deserialize, Serialize, Debug, Clone)]
193#[serde(rename_all = "camelCase")]
194pub struct Field {
195 pub id: String,
196 pub name: String,
197 pub custom: bool,
198 pub orderable: bool,
199 pub navigable: bool,
200 pub searchable: bool,
201 pub clause_names: Vec<String>,
202 pub schema: Option<FieldSchema>,
203}
204
205#[derive(Deserialize, Serialize, Debug, Clone)]
206#[serde(rename_all = "camelCase")]
207pub struct FieldSchema {
208 pub custom: Option<FieldSchemaType>,
209 pub custom_id: Option<u32>,
210 pub items: Option<FieldSchemaType>,
211 pub system: Option<FieldSchemaType>,
212 #[serde(alias = "type")]
213 pub field_type: Option<String>,
214}
215
216#[derive(Deserialize, Serialize, Debug, Clone)]
217#[serde(rename_all = "kebab-case")]
218pub enum FieldSchemaType {
219 Any,
220 Array,
221 Attachment,
222 CommentsPage,
223 Component,
224 Date,
225 Datetime,
226 Issuelinks,
227 Issuetype,
228 Number,
229 Option,
230 Priority,
231 Progress,
232 Project,
233 Resolution,
234 Securitylevel,
235 Status,
236 String,
237 Timetracking,
238 User,
239 Version,
240 Votes,
241 Watches,
242 Worklog,
243 #[serde(untagged)]
244 Custom(String),
245}
246
247#[derive(Deserialize, Serialize, Debug, Clone)]
248#[serde(rename_all = "camelCase")]
249pub struct Filter {
250 #[serde(alias = "self")]
251 pub self_ref: String,
252 pub id: String,
253 pub name: String,
254 pub description: Option<String>,
255 pub owner: User,
256 pub jql: String,
257 pub view_url: String,
258 pub search_url: String,
259 pub favourite: bool,
260 pub shared_users: FilterSharedUsers,
261 }
264
265impl Display for Filter {
266 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
267 write!(f, "{}: {}", self.name, self.jql)
268 }
269}
270
271#[derive(Deserialize, Serialize, Debug, Clone)]
272#[serde(rename_all = "kebab-case")]
273pub struct FilterSharedUsers {
274 pub size: u32,
275 pub max_results: u32,
276 pub start_index: u32,
277 pub end_index: u32,
278 pub items: Vec<User>,
279}
280
281#[derive(Deserialize, Serialize, Debug, Clone)]
282pub struct Component {
283 pub id: String,
284 pub name: String,
285 #[serde(alias = "self")]
286 pub self_ref: String,
287}
288
289#[derive(Deserialize, Serialize, Debug, Clone)]
290pub struct Status {
291 #[serde(alias = "self")]
292 pub self_ref: String,
293 pub description: String,
294 #[serde(alias = "iconUrl")]
295 pub icon_url: String,
296 pub name: String,
297 pub id: String,
298}
299
300#[derive(Deserialize, Serialize, Debug, Clone)]
301pub struct WorkLog {
302 #[serde(alias = "startAt")]
303 pub start_at: usize,
304 #[serde(alias = "maxResults")]
305 pub max_results: usize,
306 pub total: usize,
307 #[serde(alias = "worklogs")]
308 pub work_logs: Vec<WorkLogItem>,
309}
310
311#[derive(Deserialize, Serialize, Debug, Clone)]
312pub struct WorkLogItem {
313 #[serde(alias = "self")]
314 pub self_ref: String,
315 pub author: Author,
316 #[serde(alias = "updateAuthor")]
317 pub update_author: Author,
318 pub comment: String,
319 pub created: String, pub updated: String,
321 pub started: String,
322 #[serde(alias = "timeSpent")]
323 pub time_spent: String,
324 #[serde(alias = "timeSpentSeconds")]
325 pub time_spent_seconds: usize,
326 pub id: String,
327 #[serde(alias = "issueId")]
328 pub issue_id: String,
329}
330
331#[derive(Deserialize, Serialize, Debug, Clone)]
332pub struct Author {
333 #[serde(alias = "self")]
334 pub self_ref: String,
335 pub name: String,
336 pub key: String,
337 #[serde(alias = "emailAddress")]
338 pub email_address: String,
339 #[serde(alias = "avatarUrls")]
340 pub avatar_urls: AvatarUrls,
341 #[serde(alias = "displayName")]
342 pub display_name: String,
343 pub active: bool,
344 #[serde(alias = "timeZone")]
345 pub time_zone: String,
346}
347
348#[derive(Deserialize, Serialize, Debug, Clone)]
349pub struct AvatarUrls {
350 #[serde(rename = "48x48")]
351 avatar_48x48: String,
352 #[serde(rename = "24x24")]
353 avatar_24x24: String,
354 #[serde(rename = "16x16")]
355 avatar_16x16: String,
356 #[serde(rename = "32x32")]
357 avatar_32x32: String,
358}
359
360#[derive(Deserialize, Serialize, Debug, Clone)]
361pub struct SubTask {
362 pub id: String,
363 pub key: String,
364 #[serde(alias = "self")]
365 pub self_ref: String,
366 pub fields: SubTaskFields,
367}
368
369#[derive(Deserialize, Serialize, Debug, Clone)]
370pub struct SubTaskFields {
371 pub summary: String,
372 pub status: SubTaskFieldsStatus,
373 #[serde(alias = "issuetype")]
374 pub issue_type: SubTaskFieldsIssueType,
375}
376
377#[derive(Deserialize, Serialize, Debug, Clone)]
378pub struct SubTaskFieldsStatus {
379 #[serde(alias = "self")]
380 pub self_ref: String,
381 pub description: String,
382 #[serde(alias = "iconUrl")]
383 pub icon_url: String,
384 pub name: String,
385 pub id: String,
386 #[serde(alias = "statusCategory")]
387 pub status_category: SubTaskFieldsStatusCategory,
388}
389
390#[derive(Deserialize, Serialize, Debug, Clone)]
391pub struct SubTaskFieldsStatusCategory {
392 #[serde(alias = "self")]
393 pub self_ref: String,
394 pub id: u32,
395 pub key: String,
396 #[serde(alias = "colorName")]
397 pub color_name: String,
398 pub name: String,
399}
400#[derive(Deserialize, Serialize, Debug, Clone)]
401pub struct SubTaskFieldsIssueType {
402 #[serde(alias = "self")]
403 pub self_ref: String,
404 pub id: String,
405 pub description: String,
406 #[serde(alias = "iconUrl")]
407 pub icon_url: String,
408 pub name: String,
409 pub subtask: bool,
410 #[serde(alias = "avatarId")]
411 pub avatar_id: usize,
412}
413
414impl Display for Issue {
415 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
416 write!(
417 f,
418 "{} {}",
419 self.key,
420 self.fields
421 .summary
422 .clone()
423 .unwrap_or("summary is None or missing from query response".to_string())
424 )
425 }
426}
427
428#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
429pub struct IssueKey(String);
430
431impl From<IssueKey> for String {
432 fn from(val: IssueKey) -> Self {
433 val.0
434 }
435}
436
437impl Display for IssueKey {
438 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
439 write!(f, "{}", self.0)
440 }
441}
442
443static ISSUE_RE: OnceLock<Regex> = OnceLock::new();
444
445impl TryFrom<String> for IssueKey {
446 type Error = JiraClientError;
447 fn try_from(value: String) -> Result<Self, Self::Error> {
448 let issue_re = ISSUE_RE
449 .get_or_init(|| Regex::new(r"([A-Z]{2,}-[0-9]+)").expect("Unable to compile ISSUE_RE"));
450
451 let upper = value.to_uppercase();
452 let issue_key = match issue_re.captures(&upper) {
453 Some(c) => match c.get(0) {
454 Some(cap) => Ok(cap),
455 None => Err(JiraClientError::TryFromError(
456 "First capture is none: ISSUE_RE".to_string(),
457 )),
458 },
459 None => Err(JiraClientError::TryFromError(
460 "Malformed issue key supplied".to_string(),
461 )),
462 }?;
463
464 Ok(IssueKey(issue_key.as_str().to_string()))
465 }
466}
467
468#[derive(Deserialize, Serialize, Debug, Clone)]
469#[serde(rename_all = "camelCase")]
470pub struct GetTransitionsBody {
471 pub expand: String,
472 pub transitions: Vec<Transition>,
473}
474
475#[derive(Deserialize, Serialize, Debug, Clone)]
476pub struct Transition {
477 pub fields: HashMap<String, TransitionExpandedFields>,
478 pub id: String,
479 pub name: String,
480}
481
482#[derive(Deserialize, Serialize, Debug, Clone)]
483pub struct TransitionExpandedFields {
484 pub required: bool,
485 pub name: String,
486 pub operations: Vec<String>,
487 pub schema: TransitionExpandedFieldsSchema,
488 pub allowed_values: Option<Vec<TransitionFieldAllowedValue>>,
489 pub has_default_value: Option<bool>,
490 pub default_value: Option<String>,
491}
492
493#[derive(Deserialize, Serialize, Debug, Clone)]
494#[serde(untagged)]
495pub enum TransitionFieldAllowedValue {
496 Str(String),
497 Object {
498 #[serde(alias = "self")]
499 self_ref: String,
500 #[serde(alias = "name")]
501 value: String,
502 id: String,
503 },
504}
505
506#[derive(Deserialize, Serialize, Debug, Clone)]
507pub struct TransitionExpandedFieldsSchema {
508 #[serde(alias = "type")]
509 pub schema_type: String,
510 pub items: Option<String>,
511 pub custom: Option<String>,
512 pub custom_id: Option<u32>,
513 pub system: Option<String>,
514}
515
516#[derive(Serialize, Debug, Clone)]
517pub struct PostTransitionIdBody {
518 pub id: String,
519}
520
521#[derive(Serialize, Debug, Clone)]
522pub struct PostTransitionFieldBody {
523 pub name: String,
524}
525
526#[derive(Serialize, Debug, Clone)]
527pub struct PostTransitionBody {
528 pub transition: PostTransitionIdBody,
529 pub fields: Option<PostTransitionFieldBody>,
530 pub update: Option<PostTransitionUpdateField>,
531}
532
533#[derive(Serialize, Debug, Clone)]
535pub struct PostTransitionUpdateField {
536 pub add: Option<HashMap<String, Vec<String>>>,
537 pub copy: Option<HashMap<String, Vec<String>>>,
538 pub edit: Option<HashMap<String, Vec<String>>>,
539 pub remove: Option<HashMap<String, Vec<String>>>,
540 pub set: Option<HashMap<String, Vec<String>>>,
541}
542
543impl Display for Transition {
544 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
545 write!(f, "{}", self.name)
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn worklog_tryfrom_all_units_returns_duration_in_seconds() -> Result<(), JiraClientError> {
555 let worklogs = vec![
556 (60, "1"),
557 (60, "1m"),
558 (60, "1M"),
559 (3600, "1h"),
560 (3600, "1H"),
561 (3600 * 8, "1d"),
562 (3600 * 8, "1D"),
563 (3600 * 8 * 5, "1w"),
564 (3600 * 8 * 5, "1W"),
565 ];
566
567 for (expected_seconds, input) in worklogs {
568 let seconds = WorklogDuration::try_from(input.to_string())?.0;
569 assert_eq!(expected_seconds.to_string(), seconds);
570 }
571 Ok(())
572 }
573
574 #[test]
575 fn worklog_tryfrom_lowercase_unit() -> Result<(), JiraClientError> {
576 let wl = WorklogDuration::try_from(String::from("1h"))?;
577 assert_eq!(String::from("3600"), wl.to_string());
578 Ok(())
579 }
580 #[test]
581 fn worklog_tryfrom_uppercase_unit() -> Result<(), JiraClientError> {
582 let wl = WorklogDuration::try_from(String::from("2H"))?;
583 assert_eq!(String::from("7200"), wl.to_string());
584 Ok(())
585 }
586
587 #[test]
588 fn worklog_tostring() -> Result<(), JiraClientError> {
589 let wl = WorklogDuration::try_from(String::from("1h"))?;
590 let expected = String::from("3600");
591 assert_eq!(expected, wl.to_string());
592 Ok(())
593 }
594
595 #[test]
596 fn issuekey_tryfrom_uppercase_id() -> Result<(), JiraClientError> {
597 let key = String::from("JB-1");
598 let issue = IssueKey::try_from(key.clone());
599 assert!(issue.is_ok());
600 assert_eq!(key, issue?.0);
601 Ok(())
602 }
603
604 #[test]
605 fn issuekey_tryfrom_lowercase_id() {
606 let issue = IssueKey::try_from(String::from("jb-1"));
607 assert!(issue.is_ok());
608 }
609
610 #[test]
611 fn issuekey_tostring() {
612 let key = String::from("JB-1");
613 let issue = IssueKey(key.clone());
614 assert_eq!(key, issue.to_string());
615 }
616}