1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub struct AssigneeEssentials {
113 pub id: u64,
115 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct IssueEssentials {
177 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
195pub enum ChangePropertyType {
196 #[serde(rename = "attr")]
198 Attr,
199 #[serde(rename = "cf")]
201 Cf,
202 #[serde(rename = "relation")]
204 Relation,
205 #[serde(rename = "attachment")]
207 Attachment,
208}
209
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
212pub struct JournalChange {
213 pub name: String,
215 pub old_value: Option<String>,
217 pub new_value: Option<String>,
219 pub property: ChangePropertyType,
221}
222
223#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct Journal {
226 pub id: u64,
228 pub user: UserEssentials,
230 pub notes: Option<String>,
232 pub private_notes: bool,
234 #[serde(
236 serialize_with = "crate::api::serialize_rfc3339",
237 deserialize_with = "crate::api::deserialize_rfc3339"
238 )]
239 pub created_on: time::OffsetDateTime,
240 pub details: Vec<JournalChange>,
242}
243
244#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
246pub struct ChildIssue {
247 pub id: u64,
249 pub subject: String,
251 pub tracker: TrackerEssentials,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub children: Option<Vec<ChildIssue>>,
256}
257
258#[derive(Debug, Clone, Serialize, serde::Deserialize)]
262pub struct Issue {
263 pub id: u64,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub parent: Option<IssueEssentials>,
268 pub project: ProjectEssentials,
270 pub tracker: TrackerEssentials,
272 pub status: IssueStatusEssentials,
274 pub priority: IssuePriorityEssentials,
276 pub author: UserEssentials,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub assigned_to: Option<AssigneeEssentials>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub category: Option<IssueCategoryEssentials>,
284 #[serde(rename = "fixed_version", skip_serializing_if = "Option::is_none")]
286 pub version: Option<VersionEssentials>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub subject: Option<String>,
290 pub description: Option<String>,
292 is_private: Option<bool>,
294 pub start_date: Option<time::Date>,
296 pub due_date: Option<time::Date>,
298 #[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 pub done_ratio: u64,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
309 pub estimated_hours: Option<f64>,
311 #[serde(
313 serialize_with = "crate::api::serialize_rfc3339",
314 deserialize_with = "crate::api::deserialize_rfc3339"
315 )]
316 pub created_on: time::OffsetDateTime,
317 #[serde(
319 serialize_with = "crate::api::serialize_rfc3339",
320 deserialize_with = "crate::api::deserialize_rfc3339"
321 )]
322 pub updated_on: time::OffsetDateTime,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub attachments: Option<Vec<Attachment>>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub relations: Option<Vec<IssueRelation>>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub journals: Option<Vec<Journal>>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub children: Option<Vec<ChildIssue>>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub watchers: Option<Vec<UserEssentials>>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub spent_hours: Option<f64>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub total_spent_hours: Option<f64>,
344 #[serde(default)]
346 pub total_estimated_hours: Option<f64>,
347}
348
349#[derive(Debug, Clone)]
351pub enum SubProjectFilter {
352 OnlyParentProject,
354 TheseSubProjects(Vec<u64>),
356 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#[derive(Debug, Clone)]
388pub enum StatusFilter {
389 Open,
391 Closed,
393 All,
395 TheseStatuses(Vec<u64>),
397 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#[derive(Debug, Clone)]
435pub enum AuthorFilter {
436 AnyAuthor,
438 Me,
440 NotMe,
442 TheseAuthors(Vec<u64>),
444 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#[derive(Debug, Clone)]
482pub enum AssigneeFilter {
483 AnyAssignee,
485 Me,
487 NotMe,
489 TheseAssignees(Vec<u64>),
491 NotTheseAssignees(Vec<u64>),
493 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#[derive(Debug, Clone)]
534pub enum StringFieldFilter {
535 ExactMatch(String),
537 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
554pub trait ComparableFilterValue {
557 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#[derive(Debug, Clone)]
580pub enum ComparableFilter<V> {
581 ExactMatch(V),
583 Range(V, V),
585 LessThan(V),
587 LessThanOrEqual(V),
589 GreaterThan(V),
591 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#[derive(Debug, Clone)]
625pub enum SortByColumn {
626 Forward {
628 column_name: String,
630 },
631 Reverse {
633 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#[derive(Debug, Clone)]
653pub enum IssueListInclude {
654 Attachments,
656 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#[derive(Debug, Clone, Builder)]
675#[builder(setter(strip_option))]
676pub struct ListIssues {
677 #[builder(default)]
679 include: Option<Vec<IssueListInclude>>,
680 #[builder(default)]
682 sort: Option<Vec<SortByColumn>>,
683 #[builder(default)]
685 issue_id: Option<Vec<u64>>,
686 #[builder(default)]
688 project_id: Option<Vec<u64>>,
689 #[builder(default)]
691 subproject_id: Option<SubProjectFilter>,
692 #[builder(default)]
694 tracker_id: Option<Vec<u64>>,
695 #[builder(default)]
697 priority_id: Option<Vec<u64>>,
698 #[builder(default)]
700 parent_id: Option<Vec<u64>>,
701 #[builder(default)]
703 category_id: Option<Vec<u64>>,
704 #[builder(default)]
706 status_id: Option<StatusFilter>,
707 #[builder(default)]
709 subject: Option<StringFieldFilter>,
710 #[builder(default)]
712 description: Option<StringFieldFilter>,
713 #[builder(default)]
715 author: Option<AuthorFilter>,
716 #[builder(default)]
718 assignee: Option<AssigneeFilter>,
719 #[builder(default)]
721 query_id: Option<u64>,
722 #[builder(default)]
724 version_id: Option<Vec<u64>>,
725 #[builder(default)]
727 created_on: Option<ComparableFilter<time::OffsetDateTime>>,
728 #[builder(default)]
730 updated_on: Option<ComparableFilter<time::OffsetDateTime>>,
731 #[builder(default)]
733 start_date: Option<ComparableFilter<time::Date>>,
734 #[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 #[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#[derive(Debug, Clone)]
810pub enum IssueInclude {
811 Children,
813 Attachments,
815 Relations,
817 Changesets,
819 Journals,
821 Watchers,
823 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#[derive(Debug, Clone, Builder)]
868#[builder(setter(strip_option))]
869pub struct GetIssue {
870 id: u64,
872 #[builder(default)]
874 include: Option<Vec<IssueInclude>>,
875}
876
877impl ReturnsJsonResponse for GetIssue {}
878
879impl GetIssue {
880 #[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#[derive(Debug, Clone, Serialize, serde::Deserialize)]
905pub struct CustomField<'a> {
906 pub id: u64,
908 pub name: Option<Cow<'a, str>>,
910 pub value: Cow<'a, str>,
912}
913
914#[derive(Debug, Clone, Serialize)]
917pub struct UploadedAttachment<'a> {
918 pub token: Cow<'a, str>,
920 pub filename: Cow<'a, str>,
922 #[serde(skip_serializing_if = "Option::is_none")]
924 pub description: Option<Cow<'a, str>>,
925 pub content_type: Cow<'a, str>,
927}
928
929#[serde_with::skip_serializing_none]
931#[derive(Debug, Clone, Builder, Serialize)]
932#[builder(setter(strip_option))]
933pub struct CreateIssue<'a> {
934 project_id: u64,
936 #[builder(default)]
938 tracker_id: Option<u64>,
939 #[builder(default)]
941 status_id: Option<u64>,
942 #[builder(default)]
944 priority_id: Option<u64>,
945 #[builder(setter(into), default)]
947 subject: Option<Cow<'a, str>>,
948 #[builder(setter(into), default)]
950 description: Option<Cow<'a, str>>,
951 #[builder(default)]
953 category_id: Option<u64>,
954 #[builder(default, setter(name = "version"))]
956 fixed_version_id: Option<u64>,
957 #[builder(default)]
959 assigned_to_id: Option<u64>,
960 #[builder(default)]
962 parent_issue_id: Option<u64>,
963 #[builder(default)]
965 custom_fields: Option<Vec<CustomField<'a>>>,
966 #[builder(default)]
968 watcher_user_ids: Option<Vec<u64>>,
969 #[builder(default)]
971 is_private: Option<bool>,
972 #[builder(default)]
974 estimated_hours: Option<f64>,
975 #[builder(default)]
977 uploads: Option<Vec<UploadedAttachment<'a>>>,
978}
979
980impl<'a> CreateIssue<'a> {
981 #[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#[serde_with::skip_serializing_none]
1011#[derive(Debug, Clone, Builder, Serialize)]
1012#[builder(setter(strip_option))]
1013pub struct UpdateIssue<'a> {
1014 #[serde(skip_serializing)]
1016 id: u64,
1017 #[builder(default)]
1019 project_id: Option<u64>,
1020 #[builder(default)]
1022 tracker_id: Option<u64>,
1023 #[builder(default)]
1025 status_id: Option<u64>,
1026 #[builder(default)]
1028 priority_id: Option<u64>,
1029 #[builder(setter(into), default)]
1031 subject: Option<Cow<'a, str>>,
1032 #[builder(setter(into), default)]
1034 description: Option<Cow<'a, str>>,
1035 #[builder(default)]
1037 category_id: Option<u64>,
1038 #[builder(default, setter(name = "version"))]
1040 fixed_version_id: Option<u64>,
1041 #[builder(default)]
1043 assigned_to_id: Option<u64>,
1044 #[builder(default)]
1046 parent_issue_id: Option<u64>,
1047 #[builder(default)]
1049 custom_fields: Option<Vec<CustomField<'a>>>,
1050 #[builder(default)]
1052 watcher_user_ids: Option<Vec<u64>>,
1053 #[builder(default)]
1055 is_private: Option<bool>,
1056 #[builder(default)]
1058 estimated_hours: Option<f64>,
1059 #[builder(default)]
1061 notes: Option<Cow<'a, str>>,
1062 #[builder(default)]
1064 private_notes: Option<bool>,
1065 #[builder(default)]
1067 uploads: Option<Vec<UploadedAttachment<'a>>>,
1068}
1069
1070impl<'a> UpdateIssue<'a> {
1071 #[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#[derive(Debug, Clone, Builder)]
1099#[builder(setter(strip_option))]
1100pub struct DeleteIssue {
1101 id: u64,
1103}
1104
1105impl DeleteIssue {
1106 #[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#[derive(Debug, Clone, Builder, Serialize)]
1125#[builder(setter(strip_option))]
1126pub struct AddWatcher {
1127 #[serde(skip_serializing)]
1129 issue_id: u64,
1130 user_id: u64,
1132}
1133
1134impl AddWatcher {
1135 #[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#[derive(Debug, Clone, Builder)]
1158#[builder(setter(strip_option))]
1159pub struct RemoveWatcher {
1160 issue_id: u64,
1162 user_id: u64,
1164}
1165
1166impl RemoveWatcher {
1167 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1186pub struct IssuesWrapper<T> {
1187 pub issues: Vec<T>,
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
1194pub struct IssueWrapper<T> {
1195 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 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 #[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 #[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 #[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 #[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 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}