Skip to main content

jira_issue_api/
models.rs

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/// Define query parameters
33#[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/// Comment related types
42#[derive(Serialize, Debug, Clone)]
43#[serde(rename_all = "camelCase")]
44pub struct PostCommentBody {
45    pub body: String,
46}
47
48/// Worklog related types
49#[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")]
60/// If duration unit is unspecififed, defaults to minutes.
61pub 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,     // 8 Hours is default for cloud.
94            Some('w') => 3600 * 8 * 5, // 5 days of work in a week.
95            Some(maybe_digit) if maybe_digit.is_ascii_digit() => {
96                worklog.push(maybe_digit); // Unit was omitted
97                60
98            }
99            _ => 60, // Should never reach this due to the Regex Match, but try parsing input anyways.
100        };
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/// Issue related types
111#[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    /// Expects camelCase and the API is case-sensitive
119    pub expand: Option<Vec<String>>,
120}
121
122#[derive(Deserialize, Serialize, Debug, Clone)]
123#[serde(rename_all = "camelCase")]
124pub struct PostIssueQueryResponseBody {
125    /// <https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/#api/2/search>
126    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    /// Some when expanding names on query_issues
132    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    /// Some when expanding names on query_issue
145    pub names: Option<HashMap<String, String>>,
146
147    #[serde(flatten)]
148    pub remainder: BTreeMap<String, Value>,
149}
150
151/// All fields are optional as it's possible to define what fields you want in the request
152#[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 project: Project,            //TODO
173    // pub issuetype: IssueType,        //TODO
174    pub status: Option<Status>,
175    // pub comment: CommentContainer,   //TODO
176    // pub resolution: Resolution,      //TODO
177    // pub priority: Priority,          //TODO
178    // pub progress: Progress,          //TODO
179    pub subtasks: Option<Vec<SubTask>>,
180    // pub issue_links: Vec<Value>,     //TODO
181    // pub votes: Votes,                //TODO
182    pub worklog: Option<WorkLog>,
183    // pub timetracking: TimeTracking,  //TODO
184    // pub watches: Watches,            //TODO
185    // pub fix_versions: Vec<Version>,  //TODO
186    // pub versions: Vec<Version>,      //TODO
187    // pub attachment: Vec<Attachment>, //TODO
188    #[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    // pub subscriptions: FilterSubscriptions
262    // pub share_permissions: FilterSharePermissions
263}
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, // TODO: chrono?
320    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/// Server
535#[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"]; // Last one is technically valid as the preceding number is ignored
621
622        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}