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, NoPagination, 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 {}
878impl NoPagination for GetIssue {}
879
880impl GetIssue {
881    /// Create a builder for the endpoint.
882    #[must_use]
883    pub fn builder() -> GetIssueBuilder {
884        GetIssueBuilder::default()
885    }
886}
887
888impl Endpoint for GetIssue {
889    fn method(&self) -> Method {
890        Method::GET
891    }
892
893    fn endpoint(&self) -> Cow<'static, str> {
894        format!("issues/{}.json", &self.id).into()
895    }
896
897    fn parameters(&self) -> QueryParams {
898        let mut params = QueryParams::default();
899        params.push_opt("include", self.include.as_ref());
900        params
901    }
902}
903
904/// a custom field
905#[derive(Debug, Clone, Serialize, serde::Deserialize)]
906pub struct CustomField<'a> {
907    /// the custom field's id
908    pub id: u64,
909    /// is usually present in contexts where it is returned by Redmine but can be omitted when it is sent by the client
910    pub name: Option<Cow<'a, str>>,
911    /// the custom field's value
912    pub value: Cow<'a, str>,
913}
914
915/// the information the uploader needs to supply for an attachment
916/// in [CreateIssue] or [UpdateIssue]
917#[derive(Debug, Clone, Serialize)]
918pub struct UploadedAttachment<'a> {
919    /// the upload token from [UploadFile|crate::api::uploads::UploadFile]
920    pub token: Cow<'a, str>,
921    /// the filename
922    pub filename: Cow<'a, str>,
923    /// a description for the file
924    #[serde(skip_serializing_if = "Option::is_none")]
925    pub description: Option<Cow<'a, str>>,
926    /// the MIME content type of the file
927    pub content_type: Cow<'a, str>,
928}
929
930/// The endpoint to create a Redmine issue
931#[serde_with::skip_serializing_none]
932#[derive(Debug, Clone, Builder, Serialize)]
933#[builder(setter(strip_option))]
934pub struct CreateIssue<'a> {
935    /// project for the issue
936    project_id: u64,
937    /// tracker for the issue
938    #[builder(default)]
939    tracker_id: Option<u64>,
940    /// status of the issue
941    #[builder(default)]
942    status_id: Option<u64>,
943    /// issue priority
944    #[builder(default)]
945    priority_id: Option<u64>,
946    /// issue subject
947    #[builder(setter(into), default)]
948    subject: Option<Cow<'a, str>>,
949    /// issue description
950    #[builder(setter(into), default)]
951    description: Option<Cow<'a, str>>,
952    /// issue category
953    #[builder(default)]
954    category_id: Option<u64>,
955    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
956    #[builder(default, setter(name = "version"))]
957    fixed_version_id: Option<u64>,
958    /// user/group id the issue will be assigned to
959    #[builder(default)]
960    assigned_to_id: Option<u64>,
961    /// Id of the parent issue
962    #[builder(default)]
963    parent_issue_id: Option<u64>,
964    /// custom field values
965    #[builder(default)]
966    custom_fields: Option<Vec<CustomField<'a>>>,
967    /// user ids of watchers of the issue
968    #[builder(default)]
969    watcher_user_ids: Option<Vec<u64>>,
970    /// is the issue private (only visible to roles that have the relevant permission enabled)
971    #[builder(default)]
972    is_private: Option<bool>,
973    /// estimated hours it will take to implement this issue
974    #[builder(default)]
975    estimated_hours: Option<f64>,
976    /// attachments (files)
977    #[builder(default)]
978    uploads: Option<Vec<UploadedAttachment<'a>>>,
979}
980
981impl<'a> CreateIssue<'a> {
982    /// Create a builder for the endpoint.
983    #[must_use]
984    pub fn builder() -> CreateIssueBuilder<'a> {
985        CreateIssueBuilder::default()
986    }
987}
988
989impl ReturnsJsonResponse for CreateIssue<'_> {}
990impl NoPagination for CreateIssue<'_> {}
991
992impl Endpoint for CreateIssue<'_> {
993    fn method(&self) -> Method {
994        Method::POST
995    }
996
997    fn endpoint(&self) -> Cow<'static, str> {
998        "issues.json".into()
999    }
1000
1001    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1002        Ok(Some((
1003            "application/json",
1004            serde_json::to_vec(&IssueWrapper::<CreateIssue> {
1005                issue: (*self).to_owned(),
1006            })?,
1007        )))
1008    }
1009}
1010
1011/// The endpoint to update an existing Redmine issue
1012#[serde_with::skip_serializing_none]
1013#[derive(Debug, Clone, Builder, Serialize)]
1014#[builder(setter(strip_option))]
1015pub struct UpdateIssue<'a> {
1016    /// id of the issue to update
1017    #[serde(skip_serializing)]
1018    id: u64,
1019    /// project for the issue
1020    #[builder(default)]
1021    project_id: Option<u64>,
1022    /// tracker for the issue
1023    #[builder(default)]
1024    tracker_id: Option<u64>,
1025    /// status of the issue
1026    #[builder(default)]
1027    status_id: Option<u64>,
1028    /// issue priority
1029    #[builder(default)]
1030    priority_id: Option<u64>,
1031    /// issue subject
1032    #[builder(setter(into), default)]
1033    subject: Option<Cow<'a, str>>,
1034    /// issue description
1035    #[builder(setter(into), default)]
1036    description: Option<Cow<'a, str>>,
1037    /// issue category
1038    #[builder(default)]
1039    category_id: Option<u64>,
1040    /// ID of the Target Versions (previously called 'Fixed Version' and still referred to as such in the API)
1041    #[builder(default, setter(name = "version"))]
1042    fixed_version_id: Option<u64>,
1043    /// user/group id the issue will be assigned to
1044    #[builder(default)]
1045    assigned_to_id: Option<u64>,
1046    /// Id of the parent issue
1047    #[builder(default)]
1048    parent_issue_id: Option<u64>,
1049    /// custom field values
1050    #[builder(default)]
1051    custom_fields: Option<Vec<CustomField<'a>>>,
1052    /// user ids of watchers of the issue
1053    #[builder(default)]
1054    watcher_user_ids: Option<Vec<u64>>,
1055    /// is the issue private (only visible to roles that have the relevant permission enabled)
1056    #[builder(default)]
1057    is_private: Option<bool>,
1058    /// estimated hours it will take to implement this issue
1059    #[builder(default)]
1060    estimated_hours: Option<f64>,
1061    /// add a comment (note)
1062    #[builder(default)]
1063    notes: Option<Cow<'a, str>>,
1064    /// is the added comment/note private
1065    #[builder(default)]
1066    private_notes: Option<bool>,
1067    /// attachments (files)
1068    #[builder(default)]
1069    uploads: Option<Vec<UploadedAttachment<'a>>>,
1070}
1071
1072impl<'a> UpdateIssue<'a> {
1073    /// Create a builder for the endpoint.
1074    #[must_use]
1075    pub fn builder() -> UpdateIssueBuilder<'a> {
1076        UpdateIssueBuilder::default()
1077    }
1078}
1079
1080impl Endpoint for UpdateIssue<'_> {
1081    fn method(&self) -> Method {
1082        Method::PUT
1083    }
1084
1085    fn endpoint(&self) -> Cow<'static, str> {
1086        format!("issues/{}.json", self.id).into()
1087    }
1088
1089    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1090        Ok(Some((
1091            "application/json",
1092            serde_json::to_vec(&IssueWrapper::<UpdateIssue> {
1093                issue: (*self).to_owned(),
1094            })?,
1095        )))
1096    }
1097}
1098
1099/// The endpoint to delete a Redmine issue
1100#[derive(Debug, Clone, Builder)]
1101#[builder(setter(strip_option))]
1102pub struct DeleteIssue {
1103    /// id of the issue to delete
1104    id: u64,
1105}
1106
1107impl DeleteIssue {
1108    /// Create a builder for the endpoint.
1109    #[must_use]
1110    pub fn builder() -> DeleteIssueBuilder {
1111        DeleteIssueBuilder::default()
1112    }
1113}
1114
1115impl Endpoint for DeleteIssue {
1116    fn method(&self) -> Method {
1117        Method::DELETE
1118    }
1119
1120    fn endpoint(&self) -> Cow<'static, str> {
1121        format!("issues/{}.json", &self.id).into()
1122    }
1123}
1124
1125/// The endpoint to add a Redmine user as a watcher on a Redmine issue
1126#[derive(Debug, Clone, Builder, Serialize)]
1127#[builder(setter(strip_option))]
1128pub struct AddWatcher {
1129    /// id of the issue to add the watcher to
1130    #[serde(skip_serializing)]
1131    issue_id: u64,
1132    /// id of the user to add as a watcher
1133    user_id: u64,
1134}
1135
1136impl AddWatcher {
1137    /// Create a builder for the endpoint.
1138    #[must_use]
1139    pub fn builder() -> AddWatcherBuilder {
1140        AddWatcherBuilder::default()
1141    }
1142}
1143
1144impl Endpoint for AddWatcher {
1145    fn method(&self) -> Method {
1146        Method::POST
1147    }
1148
1149    fn endpoint(&self) -> Cow<'static, str> {
1150        format!("issues/{}/watchers.json", &self.issue_id).into()
1151    }
1152
1153    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1154        Ok(Some(("application/json", serde_json::to_vec(self)?)))
1155    }
1156}
1157
1158/// The endpoint to remove a Redmine user from a Redmine issue as a watcher
1159#[derive(Debug, Clone, Builder)]
1160#[builder(setter(strip_option))]
1161pub struct RemoveWatcher {
1162    /// id of the issue to remove the watcher from
1163    issue_id: u64,
1164    /// id of the user to remove as a watcher
1165    user_id: u64,
1166}
1167
1168impl RemoveWatcher {
1169    /// Create a builder for the endpoint.
1170    #[must_use]
1171    pub fn builder() -> RemoveWatcherBuilder {
1172        RemoveWatcherBuilder::default()
1173    }
1174}
1175
1176impl Endpoint for RemoveWatcher {
1177    fn method(&self) -> Method {
1178        Method::DELETE
1179    }
1180
1181    fn endpoint(&self) -> Cow<'static, str> {
1182        format!("issues/{}/watchers/{}.json", &self.issue_id, &self.user_id).into()
1183    }
1184}
1185
1186/// helper struct for outer layers with a issues field holding the inner data
1187#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1188pub struct IssuesWrapper<T> {
1189    /// to parse JSON with issues key
1190    pub issues: Vec<T>,
1191}
1192
1193/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
1194/// helper struct for outer layers with a issue field holding the inner data
1195#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1196pub struct IssueWrapper<T> {
1197    /// to parse JSON with an issue key
1198    pub issue: T,
1199}
1200
1201#[cfg(test)]
1202pub(crate) mod test {
1203    use super::*;
1204    use crate::api::test_helpers::with_project;
1205    use crate::api::ResponsePage;
1206    use pretty_assertions::assert_eq;
1207    use std::error::Error;
1208    use tokio::sync::RwLock;
1209    use tracing_test::traced_test;
1210
1211    /// needed so we do not get 404s when listing while
1212    /// creating/deleting or creating/updating/deleting
1213    pub static ISSUES_LOCK: RwLock<()> = RwLock::const_new(());
1214
1215    #[traced_test]
1216    #[test]
1217    fn test_list_issues_first_page() -> Result<(), Box<dyn Error>> {
1218        let _r_issues = ISSUES_LOCK.read();
1219        dotenvy::dotenv()?;
1220        let redmine = crate::api::Redmine::from_env()?;
1221        let endpoint = ListIssues::builder().build()?;
1222        redmine.json_response_body_page::<_, Issue>(&endpoint, 0, 25)?;
1223        Ok(())
1224    }
1225
1226    /// this version of the test will load all pages of issues which means it
1227    /// can take a while (a minute or more) so you need to use --include-ignored
1228    /// or --ignored to run it
1229    #[traced_test]
1230    #[test]
1231    #[ignore]
1232    fn test_list_issues_all_pages() -> Result<(), Box<dyn Error>> {
1233        let _r_issues = ISSUES_LOCK.read();
1234        dotenvy::dotenv()?;
1235        let redmine = crate::api::Redmine::from_env()?;
1236        let endpoint = ListIssues::builder().build()?;
1237        redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1238        Ok(())
1239    }
1240
1241    #[traced_test]
1242    #[test]
1243    fn test_get_issue() -> Result<(), Box<dyn Error>> {
1244        let _r_issues = ISSUES_LOCK.read();
1245        dotenvy::dotenv()?;
1246        let redmine = crate::api::Redmine::from_env()?;
1247        let endpoint = GetIssue::builder().id(40000).build()?;
1248        redmine.json_response_body::<_, IssueWrapper<Issue>>(&endpoint)?;
1249        Ok(())
1250    }
1251
1252    #[function_name::named]
1253    #[traced_test]
1254    #[test]
1255    fn test_create_issue() -> Result<(), Box<dyn Error>> {
1256        let _w_issues = ISSUES_LOCK.write();
1257        let name = format!("unittest_{}", function_name!());
1258        with_project(&name, |redmine, project_id, _| {
1259            let create_endpoint = super::CreateIssue::builder()
1260                .project_id(project_id)
1261                .subject("old test subject")
1262                .build()?;
1263            redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_endpoint)?;
1264            Ok(())
1265        })?;
1266        Ok(())
1267    }
1268
1269    #[function_name::named]
1270    #[traced_test]
1271    #[test]
1272    fn test_update_issue() -> Result<(), Box<dyn Error>> {
1273        let _w_issues = ISSUES_LOCK.write();
1274        let name = format!("unittest_{}", function_name!());
1275        with_project(&name, |redmine, project_id, _name| {
1276            let create_endpoint = super::CreateIssue::builder()
1277                .project_id(project_id)
1278                .subject("old test subject")
1279                .build()?;
1280            let IssueWrapper { issue }: IssueWrapper<Issue> =
1281                redmine.json_response_body::<_, _>(&create_endpoint)?;
1282            let update_endpoint = super::UpdateIssue::builder()
1283                .id(issue.id)
1284                .subject("New test subject")
1285                .build()?;
1286            redmine.ignore_response_body::<_>(&update_endpoint)?;
1287            Ok(())
1288        })?;
1289        Ok(())
1290    }
1291
1292    #[function_name::named]
1293    #[traced_test]
1294    #[test]
1295    fn test_delete_issue() -> Result<(), Box<dyn Error>> {
1296        let _w_issues = ISSUES_LOCK.write();
1297        let name = format!("unittest_{}", function_name!());
1298        with_project(&name, |redmine, project_id, _name| {
1299            let create_endpoint = super::CreateIssue::builder()
1300                .project_id(project_id)
1301                .subject("test subject")
1302                .build()?;
1303            let IssueWrapper { issue }: IssueWrapper<Issue> =
1304                redmine.json_response_body::<_, _>(&create_endpoint)?;
1305            let delete_endpoint = super::DeleteIssue::builder().id(issue.id).build()?;
1306            redmine.ignore_response_body::<_>(&delete_endpoint)?;
1307            Ok(())
1308        })?;
1309        Ok(())
1310    }
1311
1312    /// this tests if any of the results contain a field we are not deserializing
1313    ///
1314    /// this will only catch fields we missed if they are part of the response but
1315    /// it is better than nothing
1316    #[traced_test]
1317    #[test]
1318    fn test_completeness_issue_type_first_page() -> Result<(), Box<dyn Error>> {
1319        let _r_issues = ISSUES_LOCK.read();
1320        dotenvy::dotenv()?;
1321        let redmine = crate::api::Redmine::from_env()?;
1322        let endpoint = ListIssues::builder()
1323            .include(vec![
1324                IssueListInclude::Attachments,
1325                IssueListInclude::Relations,
1326            ])
1327            .build()?;
1328        let ResponsePage {
1329            values,
1330            total_count: _,
1331            offset: _,
1332            limit: _,
1333        } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
1334        for value in values {
1335            let o: Issue = serde_json::from_value(value.clone())?;
1336            let reserialized = serde_json::to_value(o)?;
1337            assert_eq!(value, reserialized);
1338        }
1339        Ok(())
1340    }
1341
1342    /// this tests if any of the results contain a field we are not deserializing
1343    ///
1344    /// this will only catch fields we missed if they are part of the response but
1345    /// it is better than nothing
1346    ///
1347    /// this version of the test will load all pages of issues which means it
1348    /// can take a while (a minute or more) so you need to use --include-ignored
1349    /// or --ignored to run it
1350    #[traced_test]
1351    #[test]
1352    #[ignore]
1353    fn test_completeness_issue_type_all_pages() -> Result<(), Box<dyn Error>> {
1354        let _r_issues = ISSUES_LOCK.read();
1355        dotenvy::dotenv()?;
1356        let redmine = crate::api::Redmine::from_env()?;
1357        let endpoint = ListIssues::builder()
1358            .include(vec![
1359                IssueListInclude::Attachments,
1360                IssueListInclude::Relations,
1361            ])
1362            .build()?;
1363        let values = redmine.json_response_body_all_pages::<_, serde_json::Value>(&endpoint)?;
1364        for value in values {
1365            let o: Issue = serde_json::from_value(value.clone())?;
1366            let reserialized = serde_json::to_value(o)?;
1367            assert_eq!(value, reserialized);
1368        }
1369        Ok(())
1370    }
1371
1372    /// this tests if any of the results contain a field we are not deserializing
1373    ///
1374    /// this will only catch fields we missed if they are part of the response but
1375    /// it is better than nothing
1376    ///
1377    /// this version of the test will load all pages of issues and the individual
1378    /// issues for each via GetIssue which means it
1379    /// can take a while (about 400 seconds) so you need to use --include-ignored
1380    /// or --ignored to run it
1381    #[traced_test]
1382    #[test]
1383    #[ignore]
1384    fn test_completeness_issue_type_all_pages_all_issue_details() -> Result<(), Box<dyn Error>> {
1385        let _r_issues = ISSUES_LOCK.read();
1386        dotenvy::dotenv()?;
1387        let redmine = crate::api::Redmine::from_env()?;
1388        let endpoint = ListIssues::builder()
1389            .include(vec![
1390                IssueListInclude::Attachments,
1391                IssueListInclude::Relations,
1392            ])
1393            .build()?;
1394        let issues = redmine.json_response_body_all_pages::<_, Issue>(&endpoint)?;
1395        for issue in issues {
1396            let get_endpoint = GetIssue::builder()
1397                .id(issue.id)
1398                .include(vec![
1399                    IssueInclude::Attachments,
1400                    IssueInclude::Children,
1401                    IssueInclude::Changesets,
1402                    IssueInclude::Relations,
1403                    IssueInclude::Journals,
1404                    IssueInclude::Watchers,
1405                ])
1406                .build()?;
1407            let IssueWrapper { issue: mut value } =
1408                redmine.json_response_body::<_, IssueWrapper<serde_json::Value>>(&get_endpoint)?;
1409            let o: Issue = serde_json::from_value(value.clone())?;
1410            // workaround for the fact that the field total_estimated_hours is put into the result
1411            // when its null in the GetIssue endpoint but not in the ListIssues one
1412            // we can only do one or the other in our JSON serialization unless we want to add
1413            // extra fields just to keep track of the missing field vs. field with null value
1414            // difference
1415            let value_object = value.as_object_mut().unwrap();
1416            if value_object.get("total_estimated_hours") == Some(&serde_json::Value::Null) {
1417                value_object.remove("total_estimated_hours");
1418            }
1419            let reserialized = serde_json::to_value(o)?;
1420            assert_eq!(value, reserialized);
1421        }
1422        Ok(())
1423    }
1424}