redmine_api/api/
issues.rs

1//! Issues Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Issues)
4//!
5//! [Redmine Documentation Journals](https://www.redmine.org/projects/redmine/wiki/Rest_IssueJournals)
6//! (Journals in Redmine terminology are notes/comments and change histories for an issue)
7//!
8//! - [ ] all issues endpoint
9//!   - [x] sort
10//!     - [ ] limit sort to the existing columns only instead of a string value
11//!   - [x] query_id parameter
12//!   - [x] pagination
13//!   - [x] issue_id filter
14//!     - [x] issue id (multiple are possible, comma separated)
15//!   - [x] project_id filter
16//!     - [x] project id (multiple are possible, comma separated)
17//!   - [x] subproject_id filter
18//!     - [x] !* filter to only get parent project issues
19//!   - [x] tracker_id filter
20//!     - [x] tracker id (multiple are possible, comma separated)
21//!   - [x] status_id filter
22//!     - [x] open (default)
23//!     - [x] closed
24//!     - [x] * for both
25//!     - [x] status id (multiple are possible, comma separated)
26//!   - [x] category_id filter
27//!     - [x] category id (multiple are possible, comma separated)
28//!   - [x] priority_id filter
29//!     - [x] priority id (multiple are possible, comma separated)
30//!   - [x] author_id filter
31//!     - [x] any
32//!     - [x] me
33//!     - [x] !me
34//!     - [x] user/group id (multiple are possible, comma separated)
35//!     - [x] negation of list
36//!   - [x] assigned_to_id filter
37//!     - [x] any
38//!     - [x] me
39//!     - [x] !me
40//!     - [x] user/group id (multiple are possible, comma separated)
41//!     - [x] negation of list
42//!     - [x] none (!*)
43//!   - [x] fixed_version_id filter (Target version, API uses old name)
44//!     - [x] version id (multiple are possible, comma separated)
45//!   - [ ] is_private filter
46//!   - [x] parent_id filter
47//!     - [x] issue id (multiple are possible, comma separated)
48//!   - [ ] custom field filter
49//!     - [ ] exact match
50//!     - [ ] substring match
51//!     - [ ] what about multiple value custom fields?
52//!   - [x] subject filter
53//!     - [x] exact match
54//!     - [x] substring match
55//!   - [x] description filter
56//!     - [x] exact match
57//!     - [x] substring match
58//!   - [ ] done_ratio filter
59//!     - [ ] exact match
60//!     - [ ] less than, greater than ?
61//!     - [ ] range?
62//!   - [ ] estimated_hours filter
63//!     - [ ] exact match
64//!     - [ ] less than, greater than ?
65//!     - [ ] range?
66//!   - [x] created_on filter
67//!     - [x] exact match
68//!     - [x] less than, greater than
69//!     - [x] date range
70//!   - [x] updated_on filter
71//!     - [x] exact match
72//!     - [x] less than, greater than
73//!     - [x] date range
74//!   - [x] start_date filter
75//!     - [x] exact match
76//!     - [x] less than, greater than
77//!     - [x] date range
78//!   - [x] due_date filter
79//!     - [x] exact match
80//!     - [x] less than, greater than
81//!     - [x] date range
82//! - [x] specific issue endpoint
83//! - [x] create issue endpoint
84//!   - [ ] attachments
85//! - [x] update issue endpoint
86//!   - [ ] attachments
87//! - [x] delete issue endpoint
88//! - [x] add watcher endpoint
89//! - [x] remove watcher endpoint
90//!
91use derive_builder::Builder;
92use reqwest::Method;
93use std::borrow::Cow;
94
95use crate::api::attachments::Attachment;
96use crate::api::custom_fields::CustomFieldEssentialsWithValue;
97use crate::api::enumerations::IssuePriorityEssentials;
98use crate::api::groups::{Group, GroupEssentials};
99use crate::api::issue_categories::IssueCategoryEssentials;
100use crate::api::issue_relations::IssueRelation;
101use crate::api::issue_statuses::IssueStatusEssentials;
102use crate::api::projects::ProjectEssentials;
103use crate::api::trackers::TrackerEssentials;
104use crate::api::users::UserEssentials;
105use crate::api::versions::VersionEssentials;
106use crate::api::{Endpoint, Pageable, QueryParams, ReturnsJsonResponse};
107use serde::Serialize;
108
109/// a minimal type for Redmine users or groups used in lists of assignees included in
110/// other Redmine objects
111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub struct AssigneeEssentials {
113    /// numeric id
114    pub id: u64,
115    /// display name
116    pub name: String,
117}
118
119impl From<UserEssentials> for AssigneeEssentials {
120    fn from(v: UserEssentials) -> Self {
121        AssigneeEssentials {
122            id: v.id,
123            name: v.name,
124        }
125    }
126}
127
128impl From<&UserEssentials> for AssigneeEssentials {
129    fn from(v: &UserEssentials) -> Self {
130        AssigneeEssentials {
131            id: v.id,
132            name: v.name.to_owned(),
133        }
134    }
135}
136
137impl From<GroupEssentials> for AssigneeEssentials {
138    fn from(v: GroupEssentials) -> Self {
139        AssigneeEssentials {
140            id: v.id,
141            name: v.name,
142        }
143    }
144}
145
146impl From<&GroupEssentials> for AssigneeEssentials {
147    fn from(v: &GroupEssentials) -> Self {
148        AssigneeEssentials {
149            id: v.id,
150            name: v.name.to_owned(),
151        }
152    }
153}
154
155impl From<Group> for AssigneeEssentials {
156    fn from(v: Group) -> Self {
157        AssigneeEssentials {
158            id: v.id,
159            name: v.name,
160        }
161    }
162}
163
164impl From<&Group> for AssigneeEssentials {
165    fn from(v: &Group) -> Self {
166        AssigneeEssentials {
167            id: v.id,
168            name: v.name.to_owned(),
169        }
170    }
171}
172
173/// a minimal type for Redmine issues included in
174/// other Redmine objects
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct IssueEssentials {
177    /// numeric id
178    pub id: u64,
179}
180
181impl From<Issue> for IssueEssentials {
182    fn from(v: Issue) -> Self {
183        IssueEssentials { id: v.id }
184    }
185}
186
187impl From<&Issue> for IssueEssentials {
188    fn from(v: &Issue) -> Self {
189        IssueEssentials { id: v.id }
190    }
191}
192
193/// the type of journal change
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
195pub enum ChangePropertyType {
196    /// issue attribute change
197    #[serde(rename = "attr")]
198    Attr,
199    /// TODO: not quite sure what cf stands for
200    #[serde(rename = "cf")]
201    Cf,
202    /// change in issue relations
203    #[serde(rename = "relation")]
204    Relation,
205    /// change in attachments
206    #[serde(rename = "attachment")]
207    Attachment,
208}
209
210/// a changed attribute entry in a journal entry
211#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
212pub struct JournalChange {
213    /// name of the attribute
214    pub name: String,
215    /// old value
216    pub old_value: Option<String>,
217    /// new value
218    pub new_value: Option<String>,
219    /// what kind of property we are dealing with
220    pub property: ChangePropertyType,
221}
222
223/// journals (issue comments and changes)
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct Journal {
226    /// numeric id
227    pub id: u64,
228    /// the author of the journal entry
229    pub user: UserEssentials,
230    /// the comment content
231    pub notes: Option<String>,
232    /// is this a private comment
233    pub private_notes: bool,
234    /// The time when this comment/change was created
235    #[serde(
236        serialize_with = "crate::api::serialize_rfc3339",
237        deserialize_with = "crate::api::deserialize_rfc3339"
238    )]
239    pub created_on: time::OffsetDateTime,
240    /// changed issue attributes
241    pub details: Vec<JournalChange>,
242}
243
244/// minimal issue used e.g. in child issues
245#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
246pub struct ChildIssue {
247    /// numeric id
248    pub id: u64,
249    /// subject
250    pub subject: String,
251    /// tracker
252    pub tracker: TrackerEssentials,
253    /// children
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub children: Option<Vec<ChildIssue>>,
256}
257
258/// a type for issue to use as an API return type
259///
260/// alternatively you can use your own type limited to the fields you need
261#[derive(Debug, Clone, Serialize, serde::Deserialize)]
262pub struct Issue {
263    /// numeric id
264    pub id: u64,
265    /// parent issue
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub parent: Option<IssueEssentials>,
268    /// the project of the issue
269    pub project: ProjectEssentials,
270    /// the tracker of the issue
271    pub tracker: TrackerEssentials,
272    /// the issue status
273    pub status: IssueStatusEssentials,
274    /// the issue priority
275    pub priority: IssuePriorityEssentials,
276    /// the issue author
277    pub author: UserEssentials,
278    /// the user or group the issue is assigned to
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub assigned_to: Option<AssigneeEssentials>,
281    /// the issue category
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub category: Option<IssueCategoryEssentials>,
284    /// the version the issue is assigned to
285    #[serde(rename = "fixed_version", skip_serializing_if = "Option::is_none")]
286    pub version: Option<VersionEssentials>,
287    /// the issue subject
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub subject: Option<String>,
290    /// the issue description
291    pub description: Option<String>,
292    /// is the issue private (only visible to roles that have the relevant permission enabled)
293    is_private: Option<bool>,
294    /// the start date for the issue
295    pub start_date: Option<time::Date>,
296    /// the due date for the issue
297    pub due_date: Option<time::Date>,
298    /// the time when the issue was closed
299    #[serde(
300        serialize_with = "crate::api::serialize_optional_rfc3339",
301        deserialize_with = "crate::api::deserialize_optional_rfc3339"
302    )]
303    pub closed_on: Option<time::OffsetDateTime>,
304    /// the percentage done
305    pub done_ratio: u64,
306    /// custom fields with values
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
309    /// estimated hours it will take to implement this issue
310    pub estimated_hours: Option<f64>,
311    /// The time when this issue was created
312    #[serde(
313        serialize_with = "crate::api::serialize_rfc3339",
314        deserialize_with = "crate::api::deserialize_rfc3339"
315    )]
316    pub created_on: time::OffsetDateTime,
317    /// The time when this issue was last updated
318    #[serde(
319        serialize_with = "crate::api::serialize_rfc3339",
320        deserialize_with = "crate::api::deserialize_rfc3339"
321    )]
322    pub updated_on: time::OffsetDateTime,
323    /// issue attachments (only when include parameter is used)
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub attachments: Option<Vec<Attachment>>,
326    /// issue relations (only when include parameter is used)
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub relations: Option<Vec<IssueRelation>>,
329    /// journal entries (comments and changes), only when include parameter is used
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub journals: Option<Vec<Journal>>,
332    /// child issues (only when include parameter is used)
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub children: Option<Vec<ChildIssue>>,
335    /// watchers
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub watchers: Option<Vec<UserEssentials>>,
338    /// the hours spent
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub spent_hours: Option<f64>,
341    /// the total hours spent on this and sub-tasks
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub total_spent_hours: Option<f64>,
344    /// the total hours estimated on this and sub-tasks
345    #[serde(default)]
346    pub total_estimated_hours: Option<f64>,
347}
348
349/// ways to filter for subproject
350#[derive(Debug, Clone)]
351pub enum SubProjectFilter {
352    /// return no issues from subjects
353    OnlyParentProject,
354    /// return issues from a specific list of sub project ids
355    TheseSubProjects(Vec<u64>),
356    /// return issues from any but a specific list of sub project ids
357    NotTheseSubProjects(Vec<u64>),
358}
359
360impl std::fmt::Display for SubProjectFilter {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            SubProjectFilter::OnlyParentProject => {
364                write!(f, "!*")
365            }
366            SubProjectFilter::TheseSubProjects(ids) => {
367                let s: String = ids
368                    .iter()
369                    .map(|e| e.to_string())
370                    .collect::<Vec<_>>()
371                    .join(",");
372                write!(f, "{}", s)
373            }
374            SubProjectFilter::NotTheseSubProjects(ids) => {
375                let s: String = ids
376                    .iter()
377                    .map(|e| format!("!{}", e))
378                    .collect::<Vec<_>>()
379                    .join(",");
380                write!(f, "{}", s)
381            }
382        }
383    }
384}
385
386/// ways to filter for issue status
387#[derive(Debug, Clone)]
388pub enum StatusFilter {
389    /// match all open statuses (default if no status filter is specified
390    Open,
391    /// match all closed statuses
392    Closed,
393    /// match both open and closed statuses
394    All,
395    /// match a specific list of statuses
396    TheseStatuses(Vec<u64>),
397    /// match any status but a specific list of statuses
398    NotTheseStatuses(Vec<u64>),
399}
400
401impl std::fmt::Display for StatusFilter {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        match self {
404            StatusFilter::Open => {
405                write!(f, "open")
406            }
407            StatusFilter::Closed => {
408                write!(f, "closed")
409            }
410            StatusFilter::All => {
411                write!(f, "*")
412            }
413            StatusFilter::TheseStatuses(ids) => {
414                let s: String = ids
415                    .iter()
416                    .map(|e| e.to_string())
417                    .collect::<Vec<_>>()
418                    .join(",");
419                write!(f, "{}", s)
420            }
421            StatusFilter::NotTheseStatuses(ids) => {
422                let s: String = ids
423                    .iter()
424                    .map(|e| format!("!{}", e))
425                    .collect::<Vec<_>>()
426                    .join(",");
427                write!(f, "{}", s)
428            }
429        }
430    }
431}
432
433/// ways to filter for users in author (always a user (not group), never !*)
434#[derive(Debug, Clone)]
435pub enum AuthorFilter {
436    /// match any user
437    AnyAuthor,
438    /// match the current API user
439    Me,
440    /// match any author but the current API user
441    NotMe,
442    /// match a specific list of users
443    TheseAuthors(Vec<u64>),
444    /// match a negated specific list of users
445    NotTheseAuthors(Vec<u64>),
446}
447
448impl std::fmt::Display for AuthorFilter {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        match self {
451            AuthorFilter::AnyAuthor => {
452                write!(f, "*")
453            }
454            AuthorFilter::Me => {
455                write!(f, "me")
456            }
457            AuthorFilter::NotMe => {
458                write!(f, "!me")
459            }
460            AuthorFilter::TheseAuthors(ids) => {
461                let s: String = ids
462                    .iter()
463                    .map(|e| e.to_string())
464                    .collect::<Vec<_>>()
465                    .join(",");
466                write!(f, "{}", s)
467            }
468            AuthorFilter::NotTheseAuthors(ids) => {
469                let s: String = ids
470                    .iter()
471                    .map(|e| format!("!{}", e))
472                    .collect::<Vec<_>>()
473                    .join(",");
474                write!(f, "{}", s)
475            }
476        }
477    }
478}
479
480/// ways to filter for users or groups in assignee
481#[derive(Debug, Clone)]
482pub enum AssigneeFilter {
483    /// match any user or group
484    AnyAssignee,
485    /// match the current API user
486    Me,
487    /// match any assignee but the current API user
488    NotMe,
489    /// match a specific list of users or groups
490    TheseAssignees(Vec<u64>),
491    /// match a negated specific list of users or groups
492    NotTheseAssignees(Vec<u64>),
493    /// match unassigned
494    NoAssignee,
495}
496
497impl std::fmt::Display for AssigneeFilter {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        match self {
500            AssigneeFilter::AnyAssignee => {
501                write!(f, "*")
502            }
503            AssigneeFilter::Me => {
504                write!(f, "me")
505            }
506            AssigneeFilter::NotMe => {
507                write!(f, "!me")
508            }
509            AssigneeFilter::TheseAssignees(ids) => {
510                let s: String = ids
511                    .iter()
512                    .map(|e| e.to_string())
513                    .collect::<Vec<_>>()
514                    .join(",");
515                write!(f, "{}", s)
516            }
517            AssigneeFilter::NotTheseAssignees(ids) => {
518                let s: String = ids
519                    .iter()
520                    .map(|e| format!("!{}", e))
521                    .collect::<Vec<_>>()
522                    .join(",");
523                write!(f, "{}", s)
524            }
525            AssigneeFilter::NoAssignee => {
526                write!(f, "!*")
527            }
528        }
529    }
530}
531
532/// Filter options for subject and description
533#[derive(Debug, Clone)]
534pub enum StringFieldFilter {
535    /// match exactly this value
536    ExactMatch(String),
537    /// match this substring of the actual value
538    SubStringMatch(String),
539}
540
541impl std::fmt::Display for StringFieldFilter {
542    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
543        match self {
544            StringFieldFilter::ExactMatch(s) => {
545                write!(f, "{}", s)
546            }
547            StringFieldFilter::SubStringMatch(s) => {
548                write!(f, "~{}", s)
549            }
550        }
551    }
552}
553
554/// a trait for comparable filter values, we do not just use Display because
555/// one of our main application is dates and we need a specific format
556pub trait ComparableFilterValue {
557    /// returns a string representation of a single value (e.g. a date)
558    /// to be combined with the comparison operators by the Display trait of
559    /// [ComparableFilter]
560    fn value_string(&self) -> Cow<'static, str>;
561}
562
563impl ComparableFilterValue for time::Date {
564    fn value_string(&self) -> Cow<'static, str> {
565        let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
566        self.format(&format).unwrap().into()
567    }
568}
569
570impl ComparableFilterValue for time::OffsetDateTime {
571    fn value_string(&self) -> Cow<'static, str> {
572        self.format(&time::format_description::well_known::Rfc3339)
573            .unwrap()
574            .into()
575    }
576}
577
578/// Filter for a comparable filter (those you can use ranges, less, greater,...) on
579#[derive(Debug, Clone)]
580pub enum ComparableFilter<V> {
581    /// an exact match
582    ExactMatch(V),
583    /// a range match
584    Range(V, V),
585    /// we only want values less than the parameter
586    LessThan(V),
587    /// we only want values less than or equal to the parameter
588    LessThanOrEqual(V),
589    /// we only want values greater than the parameter
590    GreaterThan(V),
591    /// we only want values greater than or equal to the parameter
592    GreaterThanOrEqual(V),
593}
594
595impl<V> std::fmt::Display for ComparableFilter<V>
596where
597    V: ComparableFilterValue,
598{
599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600        match self {
601            ComparableFilter::ExactMatch(v) => {
602                write!(f, "{}", v.value_string())
603            }
604            ComparableFilter::Range(v_start, v_end) => {
605                write!(f, "><{}|{}", v_start.value_string(), v_end.value_string())
606            }
607            ComparableFilter::LessThan(v) => {
608                write!(f, "<{}", v.value_string())
609            }
610            ComparableFilter::LessThanOrEqual(v) => {
611                write!(f, "<={}", v.value_string())
612            }
613            ComparableFilter::GreaterThan(v) => {
614                write!(f, ">{}", v.value_string())
615            }
616            ComparableFilter::GreaterThanOrEqual(v) => {
617                write!(f, ">={}", v.value_string())
618            }
619        }
620    }
621}
622
623/// Sort by this column
624#[derive(Debug, Clone)]
625pub enum SortByColumn {
626    /// Sort in an ascending direction
627    Forward {
628        /// the column to sort by
629        column_name: String,
630    },
631    /// Sort in a descending direction
632    Reverse {
633        /// the column to sort by
634        column_name: String,
635    },
636}
637
638impl std::fmt::Display for SortByColumn {
639    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
640        match self {
641            SortByColumn::Forward { column_name } => {
642                write!(f, "{}", column_name)
643            }
644            SortByColumn::Reverse { column_name } => {
645                write!(f, "{}:desc", column_name)
646            }
647        }
648    }
649}
650
651/// The types of associated data which can be fetched along with a issue
652#[derive(Debug, Clone)]
653pub enum IssueListInclude {
654    /// Issue Attachments
655    Attachments,
656    /// Issue relations
657    Relations,
658}
659
660impl std::fmt::Display for IssueListInclude {
661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662        match self {
663            Self::Attachments => {
664                write!(f, "attachments")
665            }
666            Self::Relations => {
667                write!(f, "relations")
668            }
669        }
670    }
671}
672
673/// The endpoint for all Redmine issues
674#[derive(Debug, Clone, Builder)]
675#[builder(setter(strip_option))]
676pub struct ListIssues {
677    /// Include associated data
678    #[builder(default)]
679    include: Option<Vec<IssueListInclude>>,
680    /// Sort by column
681    #[builder(default)]
682    sort: Option<Vec<SortByColumn>>,
683    /// Filter by issue id(s)
684    #[builder(default)]
685    issue_id: Option<Vec<u64>>,
686    /// Filter by project id
687    #[builder(default)]
688    project_id: Option<Vec<u64>>,
689    /// Filter by subproject
690    #[builder(default)]
691    subproject_id: Option<SubProjectFilter>,
692    /// Filter by tracker id
693    #[builder(default)]
694    tracker_id: Option<Vec<u64>>,
695    /// Filter by priority id
696    #[builder(default)]
697    priority_id: Option<Vec<u64>>,
698    /// Filter by parent issue id
699    #[builder(default)]
700    parent_id: Option<Vec<u64>>,
701    /// Filter by issue category id
702    #[builder(default)]
703    category_id: Option<Vec<u64>>,
704    /// Filter by issue status
705    #[builder(default)]
706    status_id: Option<StatusFilter>,
707    /// Filter by subject
708    #[builder(default)]
709    subject: Option<StringFieldFilter>,
710    /// Filter by description
711    #[builder(default)]
712    description: Option<StringFieldFilter>,
713    /// Filter by author
714    #[builder(default)]
715    author: Option<AuthorFilter>,
716    /// Filter by assignee
717    #[builder(default)]
718    assignee: Option<AssigneeFilter>,
719    /// Filter by a saved query
720    #[builder(default)]
721    query_id: Option<u64>,
722    /// Filter by target version
723    #[builder(default)]
724    version_id: Option<Vec<u64>>,
725    /// Filter by creation time
726    #[builder(default)]
727    created_on: Option<ComparableFilter<time::OffsetDateTime>>,
728    /// Filter by update time
729    #[builder(default)]
730    updated_on: Option<ComparableFilter<time::OffsetDateTime>>,
731    /// Filter by start date
732    #[builder(default)]
733    start_date: Option<ComparableFilter<time::Date>>,
734    /// Filter by due date
735    #[builder(default)]
736    due_date: Option<ComparableFilter<time::Date>>,
737}
738
739impl ReturnsJsonResponse for ListIssues {}
740
741impl Pageable for ListIssues {
742    fn response_wrapper_key(&self) -> String {
743        "issues".to_string()
744    }
745}
746
747impl ListIssues {
748    /// Create a builder for the endpoint.
749    #[must_use]
750    pub fn builder() -> ListIssuesBuilder {
751        ListIssuesBuilder::default()
752    }
753}
754
755impl Endpoint for ListIssues {
756    fn method(&self) -> Method {
757        Method::GET
758    }
759
760    fn endpoint(&self) -> Cow<'static, str> {
761        "issues.json".into()
762    }
763
764    fn parameters(&self) -> QueryParams {
765        let mut params = QueryParams::default();
766        params.push_opt("include", self.include.as_ref());
767        params.push_opt("sort", self.sort.as_ref());
768        params.push_opt("issue_id", self.issue_id.as_ref());
769        params.push_opt("project_id", self.project_id.as_ref());
770        params.push_opt(
771            "subproject_id",
772            self.subproject_id.as_ref().map(|s| s.to_string()),
773        );
774        params.push_opt("tracker_id", self.tracker_id.as_ref());
775        params.push_opt("priority_id", self.priority_id.as_ref());
776        params.push_opt("parent_id", self.parent_id.as_ref());
777        params.push_opt("category_id", self.category_id.as_ref());
778        params.push_opt("status_id", self.status_id.as_ref().map(|s| s.to_string()));
779        params.push_opt("subject", self.subject.as_ref().map(|s| s.to_string()));
780        params.push_opt(
781            "description",
782            self.description.as_ref().map(|s| s.to_string()),
783        );
784        params.push_opt("author_id", self.author.as_ref().map(|s| s.to_string()));
785        params.push_opt(
786            "assigned_to_id",
787            self.assignee.as_ref().map(|s| s.to_string()),
788        );
789        params.push_opt("query_id", self.query_id);
790        params.push_opt("fixed_version_id", self.version_id.as_ref());
791        params.push_opt(
792            "created_on",
793            self.created_on.as_ref().map(|s| s.to_string()),
794        );
795        params.push_opt(
796            "updated_on",
797            self.updated_on.as_ref().map(|s| s.to_string()),
798        );
799        params.push_opt(
800            "start_date",
801            self.start_date.as_ref().map(|s| s.to_string()),
802        );
803        params.push_opt("due_date", self.due_date.as_ref().map(|s| s.to_string()));
804        params
805    }
806}
807
808/// The types of associated data which can be fetched along with a issue
809#[derive(Debug, Clone)]
810pub enum IssueInclude {
811    /// Child issues
812    Children,
813    /// Issue attachments
814    Attachments,
815    /// Issue relations
816    Relations,
817    /// VCS changesets
818    Changesets,
819    /// the notes and changes to the issue
820    Journals,
821    /// Users watching the issue
822    Watchers,
823    /// The statuses this issue can transition to
824    ///
825    /// This can be influenced by
826    ///
827    /// - the defined workflow
828    ///   - the issue's current tracker
829    ///   - the issue's current status
830    ///   - the member's role
831    /// - the existence of any open subtask(s)
832    /// - the existence of any open blocking issue(s)
833    /// - the existence of a closed parent issue
834    ///
835    AllowedStatuses,
836}
837
838impl std::fmt::Display for IssueInclude {
839    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
840        match self {
841            Self::Children => {
842                write!(f, "children")
843            }
844            Self::Attachments => {
845                write!(f, "attachments")
846            }
847            Self::Relations => {
848                write!(f, "relations")
849            }
850            Self::Changesets => {
851                write!(f, "relations")
852            }
853            Self::Journals => {
854                write!(f, "journals")
855            }
856            Self::Watchers => {
857                write!(f, "watchers")
858            }
859            Self::AllowedStatuses => {
860                write!(f, "allowed_statuses")
861            }
862        }
863    }
864}
865
866/// The endpoint for a specific Redmine issue
867#[derive(Debug, Clone, Builder)]
868#[builder(setter(strip_option))]
869pub struct GetIssue {
870    /// id of the issue to retrieve
871    id: u64,
872    /// associated data to include
873    #[builder(default)]
874    include: Option<Vec<IssueInclude>>,
875}
876
877impl ReturnsJsonResponse for GetIssue {}
878
879impl GetIssue {
880    /// Create a builder for the endpoint.
881    #[must_use]
882    pub fn builder() -> GetIssueBuilder {
883        GetIssueBuilder::default()
884    }
885}
886
887impl Endpoint for GetIssue {
888    fn method(&self) -> Method {
889        Method::GET
890    }
891
892    fn endpoint(&self) -> Cow<'static, str> {
893        format!("issues/{}.json", &self.id).into()
894    }
895
896    fn parameters(&self) -> QueryParams {
897        let mut params = QueryParams::default();
898        params.push_opt("include", self.include.as_ref());
899        params
900    }
901}
902
903/// a custom field
904#[derive(Debug, Clone, Serialize, serde::Deserialize)]
905pub struct CustomField<'a> {
906    /// the custom field's id
907    pub id: u64,
908    /// is usually present in contexts where it is returned by Redmine but can be omitted when it is sent by the client
909    pub name: Option<Cow<'a, str>>,
910    /// the custom field's value
911    pub value: Cow<'a, str>,
912}
913
914/// the information the uploader needs to supply for an attachment
915/// in [CreateIssue] or [UpdateIssue]
916#[derive(Debug, Clone, Serialize)]
917pub struct UploadedAttachment<'a> {
918    /// the upload token from [UploadFile|crate::api::uploads::UploadFile]
919    pub token: Cow<'a, str>,
920    /// the filename
921    pub filename: Cow<'a, str>,
922    /// a description for the file
923    #[serde(skip_serializing_if = "Option::is_none")]
924    pub description: Option<Cow<'a, str>>,
925    /// the MIME content type of the file
926    pub content_type: Cow<'a, str>,
927}
928
929/// The endpoint to create a Redmine issue
930#[serde_with::skip_serializing_none]
931#[derive(Debug, Clone, Builder, Serialize)]
932#[builder(setter(strip_option))]
933pub struct CreateIssue<'a> {
934    /// project for the issue
935    project_id: u64,
936    /// tracker for the issue
937    #[builder(default)]
938    tracker_id: Option<u64>,
939    /// status of the issue
940    #[builder(default)]
941    status_id: Option<u64>,
942    /// issue priority
943    #[builder(default)]
944    priority_id: Option<u64>,
945    /// issue subject
946    #[builder(setter(into), default)]
947    subject: Option<Cow<'a, str>>,
948    /// issue description
949    #[builder(setter(into), default)]
950    description: Option<Cow<'a, str>>,
951    /// issue category
952    #[builder(default)]
953    category_id: Option<u64>,
954    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
955    #[builder(default, setter(name = "version"))]
956    fixed_version_id: Option<u64>,
957    /// user/group id the issue will be assigned to
958    #[builder(default)]
959    assigned_to_id: Option<u64>,
960    /// Id of the parent issue
961    #[builder(default)]
962    parent_issue_id: Option<u64>,
963    /// custom field values
964    #[builder(default)]
965    custom_fields: Option<Vec<CustomField<'a>>>,
966    /// user ids of watchers of the issue
967    #[builder(default)]
968    watcher_user_ids: Option<Vec<u64>>,
969    /// is the issue private (only visible to roles that have the relevant permission enabled)
970    #[builder(default)]
971    is_private: Option<bool>,
972    /// estimated hours it will take to implement this issue
973    #[builder(default)]
974    estimated_hours: Option<f64>,
975    /// attachments (files)
976    #[builder(default)]
977    uploads: Option<Vec<UploadedAttachment<'a>>>,
978}
979
980impl<'a> CreateIssue<'a> {
981    /// Create a builder for the endpoint.
982    #[must_use]
983    pub fn builder() -> CreateIssueBuilder<'a> {
984        CreateIssueBuilder::default()
985    }
986}
987
988impl ReturnsJsonResponse for CreateIssue<'_> {}
989
990impl Endpoint for CreateIssue<'_> {
991    fn method(&self) -> Method {
992        Method::POST
993    }
994
995    fn endpoint(&self) -> Cow<'static, str> {
996        "issues.json".into()
997    }
998
999    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1000        Ok(Some((
1001            "application/json",
1002            serde_json::to_vec(&IssueWrapper::<CreateIssue> {
1003                issue: (*self).to_owned(),
1004            })?,
1005        )))
1006    }
1007}
1008
1009/// The endpoint to update an existing Redmine issue
1010#[serde_with::skip_serializing_none]
1011#[derive(Debug, Clone, Builder, Serialize)]
1012#[builder(setter(strip_option))]
1013pub struct UpdateIssue<'a> {
1014    /// id of the issue to update
1015    #[serde(skip_serializing)]
1016    id: u64,
1017    /// project for the issue
1018    #[builder(default)]
1019    project_id: Option<u64>,
1020    /// tracker for the issue
1021    #[builder(default)]
1022    tracker_id: Option<u64>,
1023    /// status of the issue
1024    #[builder(default)]
1025    status_id: Option<u64>,
1026    /// issue priority
1027    #[builder(default)]
1028    priority_id: Option<u64>,
1029    /// issue subject
1030    #[builder(setter(into), default)]
1031    subject: Option<Cow<'a, str>>,
1032    /// issue description
1033    #[builder(setter(into), default)]
1034    description: Option<Cow<'a, str>>,
1035    /// issue category
1036    #[builder(default)]
1037    category_id: Option<u64>,
1038    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
1039    #[builder(default, setter(name = "version"))]
1040    fixed_version_id: Option<u64>,
1041    /// user/group id the issue will be assigned to
1042    #[builder(default)]
1043    assigned_to_id: Option<u64>,
1044    /// Id of the parent issue
1045    #[builder(default)]
1046    parent_issue_id: Option<u64>,
1047    /// custom field values
1048    #[builder(default)]
1049    custom_fields: Option<Vec<CustomField<'a>>>,
1050    /// user ids of watchers of the issue
1051    #[builder(default)]
1052    watcher_user_ids: Option<Vec<u64>>,
1053    /// is the issue private (only visible to roles that have the relevant permission enabled)
1054    #[builder(default)]
1055    is_private: Option<bool>,
1056    /// estimated hours it will take to implement this issue
1057    #[builder(default)]
1058    estimated_hours: Option<f64>,
1059    /// add a comment (note)
1060    #[builder(default)]
1061    notes: Option<Cow<'a, str>>,
1062    /// is the added comment/note private
1063    #[builder(default)]
1064    private_notes: Option<bool>,
1065    /// attachments (files)
1066    #[builder(default)]
1067    uploads: Option<Vec<UploadedAttachment<'a>>>,
1068}
1069
1070impl<'a> UpdateIssue<'a> {
1071    /// Create a builder for the endpoint.
1072    #[must_use]
1073    pub fn builder() -> UpdateIssueBuilder<'a> {
1074        UpdateIssueBuilder::default()
1075    }
1076}
1077
1078impl Endpoint for UpdateIssue<'_> {
1079    fn method(&self) -> Method {
1080        Method::PUT
1081    }
1082
1083    fn endpoint(&self) -> Cow<'static, str> {
1084        format!("issues/{}.json", self.id).into()
1085    }
1086
1087    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1088        Ok(Some((
1089            "application/json",
1090            serde_json::to_vec(&IssueWrapper::<UpdateIssue> {
1091                issue: (*self).to_owned(),
1092            })?,
1093        )))
1094    }
1095}
1096
1097/// The endpoint to delete a Redmine issue
1098#[derive(Debug, Clone, Builder)]
1099#[builder(setter(strip_option))]
1100pub struct DeleteIssue {
1101    /// id of the issue to delete
1102    id: u64,
1103}
1104
1105impl DeleteIssue {
1106    /// Create a builder for the endpoint.
1107    #[must_use]
1108    pub fn builder() -> DeleteIssueBuilder {
1109        DeleteIssueBuilder::default()
1110    }
1111}
1112
1113impl Endpoint for DeleteIssue {
1114    fn method(&self) -> Method {
1115        Method::DELETE
1116    }
1117
1118    fn endpoint(&self) -> Cow<'static, str> {
1119        format!("issues/{}.json", &self.id).into()
1120    }
1121}
1122
1123/// The endpoint to add a Redmine user as a watcher on a Redmine issue
1124#[derive(Debug, Clone, Builder, Serialize)]
1125#[builder(setter(strip_option))]
1126pub struct AddWatcher {
1127    /// id of the issue to add the watcher to
1128    #[serde(skip_serializing)]
1129    issue_id: u64,
1130    /// id of the user to add as a watcher
1131    user_id: u64,
1132}
1133
1134impl AddWatcher {
1135    /// Create a builder for the endpoint.
1136    #[must_use]
1137    pub fn builder() -> AddWatcherBuilder {
1138        AddWatcherBuilder::default()
1139    }
1140}
1141
1142impl Endpoint for AddWatcher {
1143    fn method(&self) -> Method {
1144        Method::POST
1145    }
1146
1147    fn endpoint(&self) -> Cow<'static, str> {
1148        format!("issues/{}/watchers.json", &self.issue_id).into()
1149    }
1150
1151    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1152        Ok(Some(("application/json", serde_json::to_vec(self)?)))
1153    }
1154}
1155
1156/// The endpoint to remove a Redmine user from a Redmine issue as a watcher
1157#[derive(Debug, Clone, Builder)]
1158#[builder(setter(strip_option))]
1159pub struct RemoveWatcher {
1160    /// id of the issue to remove the watcher from
1161    issue_id: u64,
1162    /// id of the user to remove as a watcher
1163    user_id: u64,
1164}
1165
1166impl RemoveWatcher {
1167    /// Create a builder for the endpoint.
1168    #[must_use]
1169    pub fn builder() -> RemoveWatcherBuilder {
1170        RemoveWatcherBuilder::default()
1171    }
1172}
1173
1174impl Endpoint for RemoveWatcher {
1175    fn method(&self) -> Method {
1176        Method::DELETE
1177    }
1178
1179    fn endpoint(&self) -> Cow<'static, str> {
1180        format!("issues/{}/watchers/{}.json", &self.issue_id, &self.user_id).into()
1181    }
1182}
1183
1184/// helper struct for outer layers with a issues field holding the inner data
1185#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1186pub struct IssuesWrapper<T> {
1187    /// to parse JSON with issues key
1188    pub issues: Vec<T>,
1189}
1190
1191/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
1192/// helper struct for outer layers with a issue field holding the inner data
1193#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1194pub struct IssueWrapper<T> {
1195    /// to parse JSON with an issue key
1196    pub issue: T,
1197}
1198
1199#[cfg(test)]
1200pub(crate) mod test {
1201    use super::*;
1202    use crate::api::test_helpers::with_project;
1203    use pretty_assertions::assert_eq;
1204    use std::error::Error;
1205    use tokio::sync::RwLock;
1206    use tracing_test::traced_test;
1207
1208    /// needed so we do not get 404s when listing while
1209    /// creating/deleting or creating/updating/deleting
1210    pub static ISSUES_LOCK: RwLock<()> = RwLock::const_new(());
1211
1212    #[traced_test]
1213    #[test]
1214    fn test_list_issues_no_pagination() -> Result<(), Box<dyn Error>> {
1215        let _r_issues = ISSUES_LOCK.read();
1216        dotenvy::dotenv()?;
1217        let redmine = crate::api::Redmine::from_env()?;
1218        let endpoint = ListIssues::builder().build()?;
1219        redmine.json_response_body::<_, IssuesWrapper<Issue>>(&endpoint)?;
1220        Ok(())
1221    }
1222
1223    #[traced_test]
1224    #[test]
1225    fn test_list_issues_first_page() -> Result<(), Box<dyn Error>> {
1226        let _r_issues = ISSUES_LOCK.read();
1227        dotenvy::dotenv()?;
1228        let redmine = crate::api::Redmine::from_env()?;
1229        let endpoint = ListIssues::builder().build()?;
1230        redmine.json_response_body_page::<_, Issue>(&endpoint, 0, 25)?;
1231        Ok(())
1232    }
1233
1234    /// this version of the test will load all pages of issues which means it
1235    /// can take a while (a minute or more) so you need to use --include-ignored
1236    /// or --ignored to run it
1237    #[traced_test]
1238    #[test]
1239    #[ignore]
1240    fn test_list_issues_all_pages() -> Result<(), Box<dyn Error>> {
1241        let _r_issues = ISSUES_LOCK.read();
1242        dotenvy::dotenv()?;
1243        let redmine = crate::api::Redmine::from_env()?;
1244        let endpoint = ListIssues::builder().build()?;
1245        redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1246        Ok(())
1247    }
1248
1249    #[traced_test]
1250    #[test]
1251    fn test_get_issue() -> Result<(), Box<dyn Error>> {
1252        let _r_issues = ISSUES_LOCK.read();
1253        dotenvy::dotenv()?;
1254        let redmine = crate::api::Redmine::from_env()?;
1255        let endpoint = GetIssue::builder().id(40000).build()?;
1256        redmine.json_response_body::<_, IssueWrapper<Issue>>(&endpoint)?;
1257        Ok(())
1258    }
1259
1260    #[function_name::named]
1261    #[traced_test]
1262    #[test]
1263    fn test_create_issue() -> Result<(), Box<dyn Error>> {
1264        let _w_issues = ISSUES_LOCK.write();
1265        let name = format!("unittest_{}", function_name!());
1266        with_project(&name, |redmine, project_id, _| {
1267            let create_endpoint = super::CreateIssue::builder()
1268                .project_id(project_id)
1269                .subject("old test subject")
1270                .build()?;
1271            redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_endpoint)?;
1272            Ok(())
1273        })?;
1274        Ok(())
1275    }
1276
1277    #[function_name::named]
1278    #[traced_test]
1279    #[test]
1280    fn test_update_issue() -> Result<(), Box<dyn Error>> {
1281        let _w_issues = ISSUES_LOCK.write();
1282        let name = format!("unittest_{}", function_name!());
1283        with_project(&name, |redmine, project_id, _name| {
1284            let create_endpoint = super::CreateIssue::builder()
1285                .project_id(project_id)
1286                .subject("old test subject")
1287                .build()?;
1288            let IssueWrapper { issue }: IssueWrapper<Issue> =
1289                redmine.json_response_body::<_, _>(&create_endpoint)?;
1290            let update_endpoint = super::UpdateIssue::builder()
1291                .id(issue.id)
1292                .subject("New test subject")
1293                .build()?;
1294            redmine.ignore_response_body::<_>(&update_endpoint)?;
1295            Ok(())
1296        })?;
1297        Ok(())
1298    }
1299
1300    #[function_name::named]
1301    #[traced_test]
1302    #[test]
1303    fn test_delete_issue() -> Result<(), Box<dyn Error>> {
1304        let _w_issues = ISSUES_LOCK.write();
1305        let name = format!("unittest_{}", function_name!());
1306        with_project(&name, |redmine, project_id, _name| {
1307            let create_endpoint = super::CreateIssue::builder()
1308                .project_id(project_id)
1309                .subject("test subject")
1310                .build()?;
1311            let IssueWrapper { issue }: IssueWrapper<Issue> =
1312                redmine.json_response_body::<_, _>(&create_endpoint)?;
1313            let delete_endpoint = super::DeleteIssue::builder().id(issue.id).build()?;
1314            redmine.ignore_response_body::<_>(&delete_endpoint)?;
1315            Ok(())
1316        })?;
1317        Ok(())
1318    }
1319
1320    /// this tests if any of the results contain a field we are not deserializing
1321    ///
1322    /// this will only catch fields we missed if they are part of the response but
1323    /// it is better than nothing
1324    #[traced_test]
1325    #[test]
1326    fn test_completeness_issue_type() -> Result<(), Box<dyn Error>> {
1327        let _r_issues = ISSUES_LOCK.read();
1328        dotenvy::dotenv()?;
1329        let redmine = crate::api::Redmine::from_env()?;
1330        let endpoint = ListIssues::builder()
1331            .include(vec![
1332                IssueListInclude::Attachments,
1333                IssueListInclude::Relations,
1334            ])
1335            .build()?;
1336        let IssuesWrapper { issues: values } =
1337            redmine.json_response_body::<_, IssuesWrapper<serde_json::Value>>(&endpoint)?;
1338        for value in values {
1339            let o: Issue = serde_json::from_value(value.clone())?;
1340            let reserialized = serde_json::to_value(o)?;
1341            assert_eq!(value, reserialized);
1342        }
1343        Ok(())
1344    }
1345
1346    /// this tests if any of the results contain a field we are not deserializing
1347    ///
1348    /// this will only catch fields we missed if they are part of the response but
1349    /// it is better than nothing
1350    ///
1351    /// this version of the test will load all pages of issues which means it
1352    /// can take a while (a minute or more) so you need to use --include-ignored
1353    /// or --ignored to run it
1354    #[traced_test]
1355    #[test]
1356    #[ignore]
1357    fn test_completeness_issue_type_all_pages() -> Result<(), Box<dyn Error>> {
1358        let _r_issues = ISSUES_LOCK.read();
1359        dotenvy::dotenv()?;
1360        let redmine = crate::api::Redmine::from_env()?;
1361        let endpoint = ListIssues::builder()
1362            .include(vec![
1363                IssueListInclude::Attachments,
1364                IssueListInclude::Relations,
1365            ])
1366            .build()?;
1367        let values = redmine.json_response_body_all_pages::<_, serde_json::Value>(&endpoint)?;
1368        for value in values {
1369            let o: Issue = serde_json::from_value(value.clone())?;
1370            let reserialized = serde_json::to_value(o)?;
1371            assert_eq!(value, reserialized);
1372        }
1373        Ok(())
1374    }
1375
1376    /// this tests if any of the results contain a field we are not deserializing
1377    ///
1378    /// this will only catch fields we missed if they are part of the response but
1379    /// it is better than nothing
1380    ///
1381    /// this version of the test will load all pages of issues and the individual
1382    /// issues for each via GetIssue which means it
1383    /// can take a while (about 400 seconds) so you need to use --include-ignored
1384    /// or --ignored to run it
1385    #[traced_test]
1386    #[test]
1387    #[ignore]
1388    fn test_completeness_issue_type_all_pages_all_issue_details() -> Result<(), Box<dyn Error>> {
1389        let _r_issues = ISSUES_LOCK.read();
1390        dotenvy::dotenv()?;
1391        let redmine = crate::api::Redmine::from_env()?;
1392        let endpoint = ListIssues::builder()
1393            .include(vec![
1394                IssueListInclude::Attachments,
1395                IssueListInclude::Relations,
1396            ])
1397            .build()?;
1398        let issues = redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1399        for issue in issues {
1400            let get_endpoint = GetIssue::builder()
1401                .id(issue.id)
1402                .include(vec![
1403                    IssueInclude::Attachments,
1404                    IssueInclude::Children,
1405                    IssueInclude::Changesets,
1406                    IssueInclude::Relations,
1407                    IssueInclude::Journals,
1408                    IssueInclude::Watchers,
1409                ])
1410                .build()?;
1411            let IssueWrapper { issue: mut value } =
1412                redmine.json_response_body::<_, IssueWrapper<serde_json::Value>>(&get_endpoint)?;
1413            let o: Issue = serde_json::from_value(value.clone())?;
1414            // workaround for the fact that the field total_estimated_hours is put into the result
1415            // when its null in the GetIssue endpoint but not in the ListIssues one
1416            // we can only do one or the other in our JSON serialization unless we want to add
1417            // extra fields just to keep track of the missing field vs. field with null value
1418            // difference
1419            let value_object = value.as_object_mut().unwrap();
1420            if value_object.get("total_estimated_hours") == Some(&serde_json::Value::Null) {
1421                value_object.remove("total_estimated_hours");
1422            }
1423            let reserialized = serde_json::to_value(o)?;
1424            assert_eq!(value, reserialized);
1425        }
1426        Ok(())
1427    }
1428}