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: Option<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();
444static RAW_RE: &str = r"([A-Z][A-Z0-9_]+-[0-9]+)";
445
446impl TryFrom<String> for IssueKey {
447 type Error = JiraClientError;
448 fn try_from(value: String) -> Result<Self, Self::Error> {
449 let issue_re =
450 ISSUE_RE.get_or_init(|| Regex::new(RAW_RE).expect("Unable to compile ISSUE_RE"));
451
452 let upper = value.to_uppercase();
453 let issue_key = match issue_re.captures(&upper) {
454 Some(c) => match c.get(0) {
455 Some(cap) => Ok(cap),
456 None => Err(JiraClientError::TryFromError(
457 "First capture is none: ISSUE_RE".to_string(),
458 )),
459 },
460 None => Err(JiraClientError::TryFromError(
461 "Malformed issue key supplied".to_string(),
462 )),
463 }?;
464
465 Ok(IssueKey(issue_key.as_str().to_string()))
466 }
467}
468
469#[derive(Deserialize, Serialize, Debug, Clone)]
470#[serde(rename_all = "camelCase")]
471pub struct GetTransitionsBody {
472 pub expand: String,
473 pub transitions: Vec<Transition>,
474}
475
476#[derive(Deserialize, Serialize, Debug, Clone)]
477pub struct Transition {
478 pub fields: HashMap<String, TransitionExpandedFields>,
479 pub id: String,
480 pub name: String,
481}
482
483#[derive(Deserialize, Serialize, Debug, Clone)]
484pub struct TransitionExpandedFields {
485 pub required: bool,
486 pub name: String,
487 pub operations: Vec<String>,
488 pub schema: TransitionExpandedFieldsSchema,
489 pub allowed_values: Option<Vec<TransitionFieldAllowedValue>>,
490 pub has_default_value: Option<bool>,
491 pub default_value: Option<String>,
492}
493
494#[derive(Deserialize, Serialize, Debug, Clone)]
495#[serde(untagged)]
496pub enum TransitionFieldAllowedValue {
497 Str(String),
498 Object {
499 #[serde(alias = "self")]
500 self_ref: String,
501 #[serde(alias = "name")]
502 value: String,
503 id: String,
504 },
505}
506
507#[derive(Deserialize, Serialize, Debug, Clone)]
508pub struct TransitionExpandedFieldsSchema {
509 #[serde(alias = "type")]
510 pub schema_type: String,
511 pub items: Option<String>,
512 pub custom: Option<String>,
513 pub custom_id: Option<u32>,
514 pub system: Option<String>,
515}
516
517#[derive(Serialize, Debug, Clone)]
518pub struct PostTransitionIdBody {
519 pub id: String,
520}
521
522#[derive(Serialize, Debug, Clone)]
523pub struct PostTransitionFieldBody {
524 pub name: String,
525}
526
527#[derive(Serialize, Debug, Clone)]
528pub struct PostTransitionBody {
529 pub transition: PostTransitionIdBody,
530 pub fields: Option<PostTransitionFieldBody>,
531 pub update: Option<PostTransitionUpdateField>,
532}
533
534#[derive(Serialize, Debug, Clone)]
536pub struct PostTransitionUpdateField {
537 pub add: Option<HashMap<String, Vec<String>>>,
538 pub copy: Option<HashMap<String, Vec<String>>>,
539 pub edit: Option<HashMap<String, Vec<String>>>,
540 pub remove: Option<HashMap<String, Vec<String>>>,
541 pub set: Option<HashMap<String, Vec<String>>>,
542}
543
544impl Display for Transition {
545 fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
546 write!(f, "{}", self.name)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn worklog_tryfrom_all_units_returns_duration_in_seconds() -> Result<(), JiraClientError> {
556 let worklogs = vec![
557 (60, "1"),
558 (60, "1m"),
559 (60, "1M"),
560 (3600, "1h"),
561 (3600, "1H"),
562 (3600 * 8, "1d"),
563 (3600 * 8, "1D"),
564 (3600 * 8 * 5, "1w"),
565 (3600 * 8 * 5, "1W"),
566 ];
567
568 for (expected_seconds, input) in worklogs {
569 let seconds = WorklogDuration::try_from(input.to_string())?.0;
570 assert_eq!(expected_seconds.to_string(), seconds);
571 }
572 Ok(())
573 }
574
575 #[test]
576 fn worklog_tryfrom_lowercase_unit() -> Result<(), JiraClientError> {
577 let wl = WorklogDuration::try_from(String::from("1h"))?;
578 assert_eq!(String::from("3600"), wl.to_string());
579 Ok(())
580 }
581 #[test]
582 fn worklog_tryfrom_uppercase_unit() -> Result<(), JiraClientError> {
583 let wl = WorklogDuration::try_from(String::from("2H"))?;
584 assert_eq!(String::from("7200"), wl.to_string());
585 Ok(())
586 }
587
588 #[test]
589 fn worklog_tostring() -> Result<(), JiraClientError> {
590 let wl = WorklogDuration::try_from(String::from("1h"))?;
591 let expected = String::from("3600");
592 assert_eq!(expected, wl.to_string());
593 Ok(())
594 }
595
596 #[test]
597 fn issuekey_tryfrom_uppercase_id() -> Result<(), JiraClientError> {
598 let key = String::from("JB-1");
599 let issue = IssueKey::try_from(key.clone());
600 assert!(issue.is_ok());
601 assert_eq!(key, issue?.0);
602 Ok(())
603 }
604
605 #[test]
606 fn issuekey_tryfrom_lowercase_id() {
607 let issue = IssueKey::try_from(String::from("jb-1"));
608 assert!(issue.is_ok());
609 }
610
611 #[test]
612 fn issuekey_tostring() {
613 let key = String::from("JB-1");
614 let issue = IssueKey(key.clone());
615 assert_eq!(key, issue.to_string());
616 }
617
618 #[test]
619 fn valid_issuekeys() {
620 let keys = vec!["JB-1", "JB1-2", "JB_1-3", "1JB-4"]; for k in keys {
623 println!("{k}");
624 let key = IssueKey::try_from(String::from(k));
625 assert!(key.is_ok())
626 }
627 }
628
629 #[test]
630 fn invalid_issuekeys() {
631 let keys = vec!["JB-", "-2", "J-B-3"];
632
633 for k in keys {
634 println!("{k}");
635 let key = IssueKey::try_from(String::from(k));
636 assert!(key.is_err())
637 }
638 }
639}