jira_issue_api/
models.rs

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        // https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-filters/#api-rest-api-2-filter-search-get
44        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/// Define query parameters
81#[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/// Comment related types
90#[derive(Serialize, Debug, Clone)]
91#[serde(rename_all = "camelCase")]
92pub struct PostCommentBody {
93    pub body: String,
94}
95
96/// Worklog related types
97#[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")]
108/// If duration unit is unspecififed, defaults to minutes.
109pub 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,     // 8 Hours is default for cloud.
142            Some('w') => 3600 * 8 * 5, // 5 days of work in a week.
143            Some(maybe_digit) if maybe_digit.is_ascii_digit() => {
144                worklog.push(maybe_digit); // Unit was omitted
145                60
146            }
147            _ => 60, // Should never reach this due to the Regex Match, but try parsing input anyways.
148        };
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/// Issue related types
159#[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    /// Expects camelCase and the API is case-sensitive
167    pub expand: Option<Vec<String>>,
168}
169
170#[derive(Deserialize, Debug, Clone)]
171#[serde(rename_all = "camelCase")]
172pub struct PostIssueQueryResponseBody {
173    /// https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/#api/2/search
174    pub expand: String,
175    pub issues: Vec<Issue>,
176    pub max_results: u32,
177    pub start_at: u32,
178    pub total: u32,
179    /// Some when expanding names on query_issues
180    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    /// Some when expanding names on query_issue
193    pub names: Option<HashMap<String, String>>,
194}
195
196/// All fields are optional as it's possible to define what fields you want in the request
197#[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    // pub project: Project,            //TODO
218    // pub issuetype: IssueType,        //TODO
219    // pub status: Status,              //TODO
220    // pub comment: CommentContainer,   //TODO
221    // pub resolution: Resolution,      //TODO
222    // pub priority: Priority,          //TODO
223    // pub progress: Progress,          //TODO
224    // pub subtasks: Vec<Value>,        //TODO
225    // pub issue_links: Vec<Value>,     //TODO
226    // pub votes: Votes,                //TODO
227    // pub worklog: Worklog,            //TODO
228    // pub timetracking: TimeTracking,  //TODO
229    // pub watches: Watches,            //TODO
230    // pub fix_versions: Vec<Version>,  //TODO
231    // pub versions: Vec<Version>,      //TODO
232    // pub attachment: Vec<Attachment>, //TODO
233    #[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    // pub subscriptions: FilterSubscriptions
307    // pub share_permissions: FilterSharePermissions
308}
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/// Server
455#[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}