1use async_trait::async_trait;
4use devboy_core::{
5 AssetCapabilities, AssetMeta, Comment, ContextCapabilities, CreateIssueInput, Error, Issue,
6 IssueFilter, IssueLink, IssueProvider, IssueRelations, IssueStatus, MergeRequestProvider,
7 PipelineProvider, Provider, ProviderResult, Result, SortInfo, SortOrder, UpdateIssueInput,
8 User,
9};
10use secrecy::{ExposeSecret, SecretString};
11use tracing::{debug, warn};
12
13use crate::DEFAULT_CLICKUP_URL;
14use crate::types::{
15 AssigneeDiff, ClickUpAttachment, ClickUpComment, ClickUpCommentList, ClickUpLinkedTask,
16 ClickUpListInfo, ClickUpPriority, ClickUpTask, ClickUpTaskList, ClickUpTeamsResponse,
17 ClickUpUser, CreateCommentRequest, CreateCommentResponse, CreateTaskRequest, UpdateTaskRequest,
18};
19
20const PAGE_SIZE: u32 = 100;
22
23fn encode_tag(tag: &str) -> String {
25 urlencoding::encode(tag).into_owned()
26}
27
28pub struct ClickUpClient {
29 base_url: String,
30 list_id: String,
31 team_id: Option<String>,
32 token: SecretString,
33 client: reqwest::Client,
34}
35
36impl ClickUpClient {
37 pub fn new(list_id: impl Into<String>, token: SecretString) -> Self {
39 Self::with_base_url(DEFAULT_CLICKUP_URL, list_id, token)
40 }
41
42 pub fn with_base_url(
44 base_url: impl Into<String>,
45 list_id: impl Into<String>,
46 token: SecretString,
47 ) -> Self {
48 Self {
49 base_url: base_url.into().trim_end_matches('/').to_string(),
50 list_id: list_id.into(),
51 team_id: None,
52 token,
53 client: reqwest::Client::builder()
54 .user_agent("devboy-tools")
55 .build()
56 .expect("Failed to create HTTP client"),
57 }
58 }
59
60 pub fn with_team_id(mut self, team_id: impl Into<String>) -> Self {
62 self.team_id = Some(team_id.into());
63 self
64 }
65
66 fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
68 self.client
69 .request(method, url)
70 .header("Authorization", self.token.expose_secret())
71 .header("Content-Type", "application/json")
72 }
73
74 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
76 debug!(url = url, "ClickUp GET request");
77
78 let response = self
79 .request(reqwest::Method::GET, url)
80 .send()
81 .await
82 .map_err(|e| Error::Http(e.to_string()))?;
83
84 self.handle_response(response).await
85 }
86
87 async fn get_with_query<T: serde::de::DeserializeOwned>(
89 &self,
90 url: &str,
91 params: &[(&str, &str)],
92 ) -> Result<T> {
93 debug!(url = url, params = ?params, "ClickUp GET request with query");
94
95 let response = self
96 .request(reqwest::Method::GET, url)
97 .query(params)
98 .send()
99 .await
100 .map_err(|e| Error::Http(e.to_string()))?;
101
102 self.handle_response(response).await
103 }
104
105 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
107 &self,
108 url: &str,
109 body: &B,
110 ) -> Result<T> {
111 debug!(url = url, "ClickUp POST request");
112
113 let response = self
114 .request(reqwest::Method::POST, url)
115 .json(body)
116 .send()
117 .await
118 .map_err(|e| Error::Http(e.to_string()))?;
119
120 self.handle_response(response).await
121 }
122
123 async fn put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
125 &self,
126 url: &str,
127 body: &B,
128 ) -> Result<T> {
129 debug!(url = url, "ClickUp PUT request");
130
131 let response = self
132 .request(reqwest::Method::PUT, url)
133 .json(body)
134 .send()
135 .await
136 .map_err(|e| Error::Http(e.to_string()))?;
137
138 self.handle_response(response).await
139 }
140
141 async fn delete(&self, url: &str) -> Result<()> {
144 debug!(url = url, "ClickUp DELETE request");
145
146 let response = self
147 .request(reqwest::Method::DELETE, url)
148 .send()
149 .await
150 .map_err(|e| Error::Http(e.to_string()))?;
151
152 let status = response.status();
153 if status == reqwest::StatusCode::NOT_FOUND {
154 return Ok(());
156 }
157 if !status.is_success() {
158 let status_code = status.as_u16();
159 let message = response.text().await.unwrap_or_default();
160 warn!(
161 status = status_code,
162 message = message,
163 "ClickUp API error response"
164 );
165 return Err(Error::from_status(status_code, message));
166 }
167 Ok(())
168 }
169
170 async fn delete_with_query(&self, url: &str, params: &[(&str, &str)]) -> Result<()> {
173 debug!(url = url, params = ?params, "ClickUp DELETE request with query");
174
175 let response = self
176 .request(reqwest::Method::DELETE, url)
177 .query(params)
178 .send()
179 .await
180 .map_err(|e| Error::Http(e.to_string()))?;
181
182 let status = response.status();
183 if status == reqwest::StatusCode::NOT_FOUND {
184 return Ok(());
185 }
186 if !status.is_success() {
187 let status_code = status.as_u16();
188 let message = response.text().await.unwrap_or_default();
189 warn!(
190 status = status_code,
191 message = message,
192 "ClickUp API error response"
193 );
194 return Err(Error::from_status(status_code, message));
195 }
196 Ok(())
197 }
198
199 async fn handle_response<T: serde::de::DeserializeOwned>(
201 &self,
202 response: reqwest::Response,
203 ) -> Result<T> {
204 let status = response.status();
205
206 if !status.is_success() {
207 let status_code = status.as_u16();
208 let message = response.text().await.unwrap_or_default();
209 warn!(
210 status = status_code,
211 message = message,
212 "ClickUp API error response"
213 );
214 return Err(Error::from_status(status_code, message));
215 }
216
217 response
218 .json()
219 .await
220 .map_err(|e| Error::InvalidData(format!("Failed to parse response: {}", e)))
221 }
222
223 async fn fetch_list_statuses(&self) -> Result<ClickUpListInfo> {
229 let url = format!("{}/list/{}", self.base_url, self.list_id);
230 self.get(&url).await
231 }
232
233 const STATUS_LIST_ERROR_LIMIT: usize = 10;
238
239 async fn validate_status_name(&self, requested: &str) -> Result<String> {
254 let list_info = self.fetch_list_statuses().await?;
255 if let Some(found) = list_info
256 .statuses
257 .iter()
258 .find(|s| s.status.eq_ignore_ascii_case(requested))
259 {
260 return Ok(found.status.clone());
261 }
262 let total = list_info.statuses.len();
263 let valid_preview: Vec<&str> = list_info
264 .statuses
265 .iter()
266 .take(Self::STATUS_LIST_ERROR_LIMIT)
267 .map(|s| s.status.as_str())
268 .collect();
269 let valid_str = if total > Self::STATUS_LIST_ERROR_LIMIT {
270 format!(
271 "{}, …and {} more",
272 valid_preview.join(", "),
273 total - Self::STATUS_LIST_ERROR_LIMIT
274 )
275 } else {
276 valid_preview.join(", ")
277 };
278 let hint = match requested.to_ascii_lowercase().as_str() {
279 "open" | "opened" | "closed" => {
280 " (note: for generic open/closed transitions, pass via the `state` field instead)"
281 }
282 _ => "",
283 };
284 Err(Error::InvalidData(format!(
285 "Unknown ClickUp status '{requested}' for list {}. Valid: [{valid_str}]{hint}",
286 self.list_id
287 )))
288 }
289
290 async fn resolve_status(&self, state: &str) -> Result<String> {
294 let status_type = match state {
295 "closed" => "closed",
296 "open" | "opened" => "open",
297 _ => return Ok(state.to_string()),
298 };
299
300 let list_info = self.fetch_list_statuses().await?;
301
302 list_info
303 .statuses
304 .iter()
305 .find(|s| s.status_type.as_deref() == Some(status_type))
306 .map(|s| s.status.clone())
307 .ok_or_else(|| {
308 Error::InvalidData(format!(
309 "No status with type '{}' found in list {}",
310 status_type, self.list_id
311 ))
312 })
313 }
314
315 async fn fetch_workspace_members(&self) -> Result<Vec<ClickUpUser>> {
319 let url = format!("{}/team", self.base_url);
320 let resp: ClickUpTeamsResponse = self.get(&url).await?;
321 let target = self.team_id.as_deref();
322 let members: Vec<ClickUpUser> = resp
323 .teams
324 .into_iter()
325 .filter(|t| target.is_none_or(|id| t.id == id))
326 .flat_map(|t| t.members.into_iter().map(|m| m.user))
327 .collect();
328 Ok(members)
329 }
330
331 async fn resolve_assignee_ids(&self, inputs: &[String]) -> Result<Vec<u64>> {
355 let mut resolved: Vec<u64> = Vec::with_capacity(inputs.len());
356 let mut needs_lookup: Vec<&str> = Vec::new();
357 for raw in inputs {
358 let trimmed = raw.trim();
359 if trimmed.is_empty() {
360 continue;
361 }
362 if let Ok(id) = trimmed.parse::<u64>() {
363 resolved.push(id);
364 } else {
365 needs_lookup.push(trimmed);
366 }
367 }
368 if needs_lookup.is_empty() {
369 return Ok(resolved);
370 }
371 let members = self.fetch_workspace_members().await?;
372 for needle in needs_lookup {
373 let id = members
374 .iter()
375 .find(|u| {
376 u.email
377 .as_deref()
378 .is_some_and(|e| e.eq_ignore_ascii_case(needle))
379 || u.username.eq_ignore_ascii_case(needle)
380 })
381 .map(|u| u.id);
382 let id = id.ok_or_else(|| {
383 Error::InvalidData(format!(
384 "Cannot resolve assignee '{needle}' to a ClickUp user id \
385 (not found by email or username in any accessible workspace)"
386 ))
387 })?;
388 resolved.push(id);
389 }
390 Ok(resolved)
391 }
392
393 fn resolve_task_id(&self, key: &str) -> Result<String> {
397 if let Some(raw_id) = key.strip_prefix("CU-") {
398 Ok(raw_id.to_string())
399 } else {
400 Ok(key.to_string())
401 }
402 }
403
404 async fn resolve_to_native_id(&self, key: &str) -> Result<String> {
409 if let Some(raw_id) = key.strip_prefix("CU-") {
410 Ok(raw_id.to_string())
411 } else {
412 let url = self.task_url(key)?;
413 let task: ClickUpTask = self.get(&url).await?;
414 Ok(task.id)
415 }
416 }
417
418 fn task_url(&self, key: &str) -> Result<String> {
422 if let Some(raw_id) = key.strip_prefix("CU-") {
423 Ok(format!("{}/task/{}", self.base_url, raw_id))
424 } else {
425 let team_id = self.team_id.as_ref().ok_or_else(|| {
427 Error::Config(format!(
428 "team_id is required to resolve custom task ID '{}'. \
429 Run: devboy config set clickup.team_id <team_id>",
430 key
431 ))
432 })?;
433 Ok(format!(
434 "{}/task/{}?custom_task_ids=true&team_id={}",
435 self.base_url, key, team_id
436 ))
437 }
438 }
439}
440
441fn map_user(cu_user: Option<&ClickUpUser>) -> Option<User> {
446 cu_user.map(|u| User {
447 id: u.id.to_string(),
448 username: u.username.clone(),
449 name: Some(u.username.clone()),
450 email: u.email.clone(),
451 avatar_url: u.profile_picture.clone(),
452 })
453}
454
455fn map_user_required(cu_user: Option<&ClickUpUser>) -> User {
456 map_user(cu_user).unwrap_or_else(|| User {
457 id: "unknown".to_string(),
458 username: "unknown".to_string(),
459 name: Some("Unknown".to_string()),
460 ..Default::default()
461 })
462}
463
464fn map_tags(tags: &[crate::types::ClickUpTag]) -> Vec<String> {
465 tags.iter().map(|t| t.name.clone()).collect()
466}
467
468fn map_priority(priority: Option<&ClickUpPriority>) -> Option<String> {
469 priority.map(|p| match p.id.as_str() {
470 "1" => "urgent".to_string(),
471 "2" => "high".to_string(),
472 "3" => "normal".to_string(),
473 "4" => "low".to_string(),
474 _ => p.priority.to_lowercase(),
475 })
476}
477
478fn map_state(task: &ClickUpTask) -> String {
479 match task.status.status_type.as_deref() {
480 Some("closed") => "closed".to_string(),
481 _ => "open".to_string(),
482 }
483}
484
485fn map_status_category(status_type: Option<&str>, status_name: &str) -> String {
490 match status_type {
492 Some("closed") | Some("done") => return "done".to_string(),
493 _ => {}
496 }
497
498 let name_lower = status_name.to_lowercase();
500
501 if name_lower.contains("backlog") {
502 "backlog".to_string()
503 } else if name_lower.contains("cancel")
504 || name_lower.contains("archived")
505 || name_lower.contains("rejected")
506 {
507 "cancelled".to_string()
508 } else if name_lower.contains("done")
509 || name_lower.contains("complete")
510 || name_lower.contains("closed")
511 || name_lower.contains("resolved")
512 {
513 "done".to_string()
514 } else if name_lower.contains("progress")
515 || name_lower.contains("doing")
516 || name_lower.contains("active")
517 || name_lower.contains("review")
518 {
519 "in_progress".to_string()
520 } else if name_lower.contains("todo")
521 || name_lower.contains("to do")
522 || name_lower.contains("open")
523 || name_lower.contains("new")
524 {
525 "todo".to_string()
526 } else {
527 match status_type {
529 Some("open") => "todo".to_string(),
530 _ => "in_progress".to_string(),
531 }
532 }
533}
534
535fn map_task_key(task: &ClickUpTask) -> String {
538 if let Some(custom_id) = &task.custom_id {
539 custom_id.clone()
540 } else {
541 format!("CU-{}", task.id)
542 }
543}
544
545fn epoch_ms_to_iso8601(epoch_ms: &str) -> Option<String> {
547 let ms: i64 = epoch_ms.parse().ok()?;
548 let secs = ms / 1000;
549 let datetime = time_from_unix(secs);
550 Some(datetime)
551}
552
553fn time_from_unix(secs: i64) -> String {
555 let mut days = secs / 86400;
557 let day_secs = secs.rem_euclid(86400);
558 if secs % 86400 < 0 {
559 days -= 1;
560 }
561
562 let hours = day_secs / 3600;
563 let minutes = (day_secs % 3600) / 60;
564 let seconds = day_secs % 60;
565
566 let z = days + 719468;
569 let era = if z >= 0 { z } else { z - 146096 } / 146097;
570 let doe = (z - era * 146097) as u32;
571 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
572 let y = yoe as i64 + era * 400;
573 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
574 let mp = (5 * doy + 2) / 153;
575 let d = doy - (153 * mp + 2) / 5 + 1;
576 let m = if mp < 10 { mp + 3 } else { mp - 9 };
577 let y = if m <= 2 { y + 1 } else { y };
578
579 format!(
580 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
581 y, m, d, hours, minutes, seconds
582 )
583}
584
585fn map_timestamp(ts: &Option<String>) -> Option<String> {
586 ts.as_ref().and_then(|s| epoch_ms_to_iso8601(s))
587}
588
589fn map_task(task: &ClickUpTask) -> Issue {
590 let custom_fields: std::collections::HashMap<String, devboy_core::CustomFieldValue> = task
596 .custom_fields
597 .iter()
598 .filter_map(|cf| {
599 cf.value.as_ref().map(|v| {
600 (
601 cf.id.clone(),
602 devboy_core::CustomFieldValue {
603 name: cf.name.clone(),
604 value: v.clone(),
605 },
606 )
607 })
608 })
609 .collect();
610 Issue {
611 custom_fields,
612 key: map_task_key(task),
613 title: task.name.clone(),
614 description: task
615 .text_content
616 .clone()
617 .or_else(|| task.description.clone()),
618 state: map_state(task),
619 source: "clickup".to_string(),
620 priority: map_priority(task.priority.as_ref()),
621 labels: map_tags(&task.tags),
622 author: map_user(task.creator.as_ref()),
623 assignees: task
624 .assignees
625 .iter()
626 .map(|u| map_user_required(Some(u)))
627 .collect(),
628 url: Some(task.url.clone()),
629 created_at: map_timestamp(&task.date_created),
630 updated_at: map_timestamp(&task.date_updated),
631 attachments_count: if task.attachments.is_empty() {
632 None
633 } else {
634 Some(task.attachments.len() as u32)
635 },
636 parent: task.parent.as_ref().map(|id| format!("CU-{id}")),
637 subtasks: task
638 .subtasks
639 .as_deref()
640 .unwrap_or_default()
641 .iter()
642 .map(map_task)
643 .collect(),
644 }
645}
646
647fn map_comment(cu_comment: &ClickUpComment) -> Comment {
648 Comment {
649 id: cu_comment.id.clone(),
650 body: cu_comment.comment_text.clone(),
651 author: map_user(cu_comment.user.as_ref()),
652 created_at: map_timestamp(&cu_comment.date),
653 updated_at: None,
654 position: None,
655 }
656}
657
658fn map_clickup_attachment(raw: &ClickUpAttachment) -> AssetMeta {
660 let filename = raw
661 .title
662 .clone()
663 .or_else(|| {
664 raw.url
665 .as_deref()
666 .map(devboy_core::asset::filename_from_url)
667 })
668 .unwrap_or_else(|| format!("attachment-{}", raw.id));
669
670 let size = match raw.size.as_ref() {
671 Some(serde_json::Value::Number(n)) => n.as_u64(),
672 Some(serde_json::Value::String(s)) => s.parse::<u64>().ok(),
673 _ => None,
674 };
675
676 let created_at = raw.date.as_deref().and_then(epoch_ms_to_iso8601);
677
678 let author = raw.user.as_ref().map(|u| u.username.clone());
679
680 AssetMeta {
681 id: raw.id.clone(),
682 filename,
683 mime_type: raw.mimetype.clone(),
684 size,
685 url: raw.url.clone(),
686 created_at,
687 author,
688 cached: false,
689 local_path: None,
690 checksum_sha256: None,
691 analysis: None,
692 }
693}
694
695fn priority_sort_key(priority: Option<&str>) -> u8 {
698 match priority {
699 Some("urgent") => 1,
700 Some("high") => 2,
701 Some("normal") => 3,
702 Some("low") => 4,
703 _ => 5,
704 }
705}
706
707fn priority_to_clickup(priority: &str) -> Option<u8> {
709 match priority {
710 "urgent" => Some(1),
711 "high" => Some(2),
712 "normal" => Some(3),
713 "low" => Some(4),
714 _ => None,
715 }
716}
717
718fn map_dependencies(
727 deps: &[serde_json::Value],
728 this_task_id: &str,
729) -> (Vec<IssueLink>, Vec<IssueLink>) {
730 let mut blocked_by = Vec::new();
731 let mut blocks = Vec::new();
732
733 for dep in deps {
734 let task_id = dep
735 .get("task_id")
736 .and_then(|v| v.as_str())
737 .unwrap_or_default();
738 let depends_on = dep
739 .get("depends_on")
740 .and_then(|v| v.as_str())
741 .unwrap_or_default();
742 let dependency_of = dep
743 .get("dependency_of")
744 .and_then(|v| v.as_str())
745 .unwrap_or_default();
746
747 let other_id = if !task_id.is_empty() {
748 task_id
749 } else {
750 continue;
751 };
752
753 let other_issue = Issue {
754 key: format!("CU-{other_id}"),
755 source: "clickup".to_string(),
756 ..Default::default()
757 };
758
759 if depends_on == this_task_id {
760 blocks.push(IssueLink {
762 issue: other_issue,
763 link_type: "blocks".to_string(),
764 });
765 } else if dependency_of == this_task_id {
766 blocked_by.push(IssueLink {
768 issue: other_issue,
769 link_type: "blocked_by".to_string(),
770 });
771 } else {
772 let dep_type = dep.get("type").and_then(|v| v.as_u64());
775 match dep_type {
776 Some(1) => {
777 blocked_by.push(IssueLink {
778 issue: other_issue,
779 link_type: "blocked_by".to_string(),
780 });
781 }
782 Some(0) => {
783 blocks.push(IssueLink {
784 issue: other_issue,
785 link_type: "blocks".to_string(),
786 });
787 }
788 _ => {
789 blocked_by.push(IssueLink {
791 issue: other_issue,
792 link_type: "blocked_by".to_string(),
793 });
794 }
795 }
796 }
797 }
798
799 (blocked_by, blocks)
800}
801
802fn map_linked_tasks(links: &[ClickUpLinkedTask]) -> Vec<IssueLink> {
804 links
805 .iter()
806 .map(|link| {
807 let link_type = match link.link_type.as_deref() {
808 Some("blocked_by") => "blocked_by",
809 Some("blocking") => "blocks",
810 _ => "relates_to",
811 }
812 .to_string();
813
814 IssueLink {
815 issue: Issue {
816 key: format!("CU-{}", link.task_id),
817 source: "clickup".to_string(),
818 ..Default::default()
819 },
820 link_type,
821 }
822 })
823 .collect()
824}
825
826#[async_trait]
831impl IssueProvider for ClickUpClient {
832 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
833 let limit = filter.limit.unwrap_or(20) as usize;
834 if limit == 0 {
835 return Ok(vec![].into());
836 }
837 let offset = filter.offset.unwrap_or(0) as usize;
838
839 let start_page = offset / PAGE_SIZE as usize;
841 let end_page = (offset + limit).saturating_sub(1) / PAGE_SIZE as usize;
842
843 let mut base_params: Vec<(&str, String)> = vec![];
846
847 let include_closed = matches!(filter.state.as_deref(), Some("closed") | Some("all"))
848 || matches!(
849 filter.state_category.as_deref(),
850 Some("done") | Some("cancelled")
851 );
852 if include_closed {
853 base_params.push(("include_closed", "true".to_string()));
854 }
855
856 base_params.push(("subtasks", "true".to_string()));
857
858 if let Some(assignee) = &filter.assignee {
859 warn!(
863 assignee = assignee.as_str(),
864 "ClickUp assignee filter expects numeric user IDs, not usernames"
865 );
866 base_params.push(("assignees[]", assignee.clone()));
867 }
868
869 if let Some(tags) = &filter.labels {
870 for tag in tags {
871 base_params.push(("tags[]", tag.clone()));
872 }
873 }
874
875 let mut client_side_sort: Option<String> = None;
877
878 if let Some(order_by) = &filter.sort_by {
879 match order_by.as_str() {
880 "created_at" | "created" => {
881 base_params.push(("order_by", "created".to_string()));
882 }
883 "updated_at" | "updated" => {
884 base_params.push(("order_by", "updated".to_string()));
885 }
886 other => {
887 client_side_sort = Some(other.to_string());
889 warn!(
890 sort_by = other,
891 "ClickUp API does not support sorting by '{}', applying client-side sort",
892 other
893 );
894 }
895 }
896 }
897
898 let sort_order_is_asc = filter.sort_order.as_deref().is_some_and(|o| o == "asc");
899
900 if sort_order_is_asc && client_side_sort.is_none() {
901 base_params.push(("reverse", "true".to_string()));
902 }
903
904 let base_url = format!("{}/list/{}/task", self.base_url, self.list_id);
906 let mut all_tasks: Vec<ClickUpTask> = Vec::new();
907
908 for page in start_page..=end_page {
909 let mut params = base_params.clone();
910 params.push(("page", page.to_string()));
911
912 let param_refs: Vec<(&str, &str)> =
913 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
914 let response: ClickUpTaskList = self.get_with_query(&base_url, ¶m_refs).await?;
915 let page_len = response.tasks.len();
916 all_tasks.extend(response.tasks);
917
918 if page_len < PAGE_SIZE as usize {
920 break;
921 }
922 }
923
924 if let Some(ref state_category) = filter.state_category {
928 let statuses = self.get_statuses().await?;
929 let matching_status_names: Vec<String> = statuses
930 .items
931 .iter()
932 .filter(|s| s.category == *state_category)
933 .map(|s| s.name.to_lowercase())
934 .collect();
935
936 all_tasks.retain(|t| matching_status_names.contains(&t.status.status.to_lowercase()));
937 }
938
939 let mut issues: Vec<Issue> = all_tasks.iter().map(map_task).collect();
940
941 if let Some(state) = &filter.state {
943 match state.as_str() {
944 "opened" | "open" => {
945 issues.retain(|i| i.state == "open");
946 }
947 "closed" => {
948 issues.retain(|i| i.state == "closed");
949 }
950 _ => {} }
952 }
953
954 if filter.labels_operator.as_deref() == Some("and")
956 && let Some(ref required_labels) = filter.labels
957 {
958 let required: Vec<String> = required_labels.iter().map(|l| l.to_lowercase()).collect();
959 issues.retain(|issue| {
960 let issue_labels: Vec<String> =
961 issue.labels.iter().map(|l| l.to_lowercase()).collect();
962 required.iter().all(|r| issue_labels.contains(r))
963 });
964 }
965
966 if let Some(ref query) = filter.search {
968 let q = query.to_lowercase();
969 issues.retain(|issue| {
970 issue.title.to_lowercase().contains(&q)
971 || issue
972 .description
973 .as_ref()
974 .is_some_and(|d| d.to_lowercase().contains(&q))
975 || issue.key.to_lowercase().contains(&q)
976 });
977 }
978
979 if let Some(ref sort_field) = client_side_sort {
981 match sort_field.as_str() {
982 "priority" => {
983 issues.sort_by(|a, b| {
984 let pa = priority_sort_key(a.priority.as_deref());
985 let pb = priority_sort_key(b.priority.as_deref());
986 if sort_order_is_asc {
987 pa.cmp(&pb)
988 } else {
989 pb.cmp(&pa)
990 }
991 });
992 }
993 "title" => {
994 issues.sort_by(|a, b| {
995 let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase());
996 if sort_order_is_asc {
997 cmp
998 } else {
999 cmp.reverse()
1000 }
1001 });
1002 }
1003 _ => {
1004 }
1006 }
1007 }
1008
1009 let offset_in_first_page = offset % PAGE_SIZE as usize;
1011 if offset_in_first_page < issues.len() {
1012 issues = issues.split_off(offset_in_first_page);
1013 } else {
1014 issues.clear();
1015 }
1016
1017 issues.truncate(limit);
1018
1019 let sort_info = SortInfo {
1021 sort_by: filter.sort_by.clone(),
1022 sort_order: if sort_order_is_asc {
1023 SortOrder::Asc
1024 } else {
1025 SortOrder::Desc
1026 },
1027 available_sorts: vec![
1028 "created_at".into(),
1029 "updated_at".into(),
1030 "priority".into(),
1031 "title".into(),
1032 ],
1033 };
1034
1035 Ok(ProviderResult::new(issues).with_sort_info(sort_info))
1036 }
1037
1038 async fn get_issue(&self, key: &str) -> Result<Issue> {
1039 let base_url = self.task_url(key)?;
1040 let separator = if base_url.contains('?') { "&" } else { "?" };
1041 let url = format!("{}{}include_subtasks=true", base_url, separator);
1042 let task: ClickUpTask = self.get(&url).await?;
1043 Ok(map_task(&task))
1044 }
1045
1046 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1047 let url = format!("{}/list/{}/task", self.base_url, self.list_id);
1048
1049 let priority = input.priority.as_deref().and_then(priority_to_clickup);
1050
1051 let tags = if input.labels.is_empty() {
1052 None
1053 } else {
1054 Some(input.labels)
1055 };
1056
1057 let parent = match input.parent {
1060 Some(ref parent_key) => {
1061 if let Some(stripped) = parent_key.strip_prefix("CU-") {
1062 Some(stripped.to_string())
1063 } else {
1064 let parent_url = self.task_url(parent_key)?;
1065 let parent_task: ClickUpTask = self.get(&parent_url).await?;
1066 Some(parent_task.id)
1067 }
1068 }
1069 None => None,
1070 };
1071
1072 let (description, markdown_content) = if input.markdown {
1073 (None, input.description)
1074 } else {
1075 (input.description, None)
1076 };
1077
1078 let assignees = if input.assignees.is_empty() {
1082 None
1083 } else {
1084 Some(self.resolve_assignee_ids(&input.assignees).await?)
1085 };
1086
1087 let request = CreateTaskRequest {
1088 name: input.title,
1089 description,
1090 markdown_content,
1091 parent,
1092 status: None,
1093 priority,
1094 tags,
1095 assignees,
1096 };
1097
1098 let task: ClickUpTask = self.post(&url, &request).await?;
1099 let task_id = task.id.clone();
1100
1101 if task.custom_id.is_none() {
1104 for attempt in 1..=3u64 {
1105 tokio::time::sleep(std::time::Duration::from_millis(300 * attempt)).await;
1106 let fetch_url = format!("{}/task/{}", self.base_url, task_id);
1107 if let Ok(fetched) = self.get::<ClickUpTask>(&fetch_url).await
1108 && fetched.custom_id.is_some()
1109 {
1110 debug!(
1111 task_id = task_id,
1112 custom_id = ?fetched.custom_id,
1113 attempt = attempt,
1114 "Got custom_id after retry"
1115 );
1116 return Ok(map_task(&fetched));
1117 }
1118 }
1119 warn!(
1120 task_id = task_id,
1121 "custom_id not available after 3 retries, using POST response"
1122 );
1123 }
1124
1125 Ok(map_task(&task))
1126 }
1127
1128 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1129 let url = self.task_url(key)?;
1130
1131 let status = if let Some(s) = input.status.as_deref() {
1137 Some(self.validate_status_name(s).await?)
1138 } else if let Some(s) = input.state.as_deref() {
1139 Some(self.resolve_status(s).await?)
1140 } else {
1141 None
1142 };
1143
1144 let priority = input.priority.as_deref().and_then(priority_to_clickup);
1145
1146 let (description, markdown_content) = if input.markdown {
1147 (None, input.description)
1148 } else {
1149 (input.description, None)
1150 };
1151
1152 let parent = match input.parent_id {
1156 Some(ref parent_key) if parent_key == "none" || parent_key.is_empty() => {
1157 Some("none".to_string())
1158 }
1159 Some(ref parent_key) => {
1160 if let Some(stripped) = parent_key.strip_prefix("CU-") {
1161 Some(stripped.to_string())
1162 } else {
1163 let parent_url = self.task_url(parent_key)?;
1164 let parent_task: ClickUpTask = self.get(&parent_url).await?;
1165 Some(parent_task.id)
1166 }
1167 }
1168 None => None,
1169 };
1170
1171 let assignees_diff = match input.assignees.as_deref() {
1186 Some(requested) => {
1187 let new_ids = self.resolve_assignee_ids(requested).await?;
1188 let current_task: ClickUpTask = self.get(&url).await?;
1189 let current_ids: Vec<u64> = current_task.assignees.iter().map(|u| u.id).collect();
1190 let add: Vec<u64> = new_ids
1191 .iter()
1192 .copied()
1193 .filter(|id| !current_ids.contains(id))
1194 .collect();
1195 let rem: Vec<u64> = current_ids
1196 .iter()
1197 .copied()
1198 .filter(|id| !new_ids.contains(id))
1199 .collect();
1200 if add.is_empty() && rem.is_empty() {
1201 None
1202 } else {
1203 Some(AssigneeDiff { add, rem })
1204 }
1205 }
1206 None => None,
1207 };
1208
1209 let request = UpdateTaskRequest {
1210 name: input.title,
1211 description,
1212 markdown_content,
1213 status,
1214 priority,
1215 parent,
1216 tags: None, assignees: assignees_diff,
1218 };
1219
1220 let task: ClickUpTask = self.put(&url, &request).await?;
1221
1222 if let Some(ref new_labels) = input.labels {
1225 let current_tags: Vec<String> = task.tags.iter().map(|t| t.name.clone()).collect();
1226 let new_tags: Vec<String> = new_labels.iter().map(|l| l.to_lowercase()).collect();
1227
1228 for tag in ¤t_tags {
1230 if !new_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1231 let tag_url =
1232 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1233 if let Err(e) = self.delete(&tag_url).await {
1234 warn!(tag = tag, error = %e, "Failed to remove tag");
1235 }
1236 }
1237 }
1238
1239 for tag in &new_tags {
1241 if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1242 let tag_url =
1243 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1244 let resp = self
1245 .request(reqwest::Method::POST, &tag_url)
1246 .send()
1247 .await
1248 .map_err(|e| Error::Http(e.to_string()))?;
1249 if !resp.status().is_success() {
1250 warn!(
1251 tag = tag,
1252 status = resp.status().as_u16(),
1253 "Failed to add tag"
1254 );
1255 }
1256 }
1257 }
1258 }
1259
1260 match self.get::<ClickUpTask>(&url).await {
1263 Ok(updated_task) => Ok(map_task(&updated_task)),
1264 Err(e) => {
1265 warn!(
1266 issue_key = key,
1267 error = %e,
1268 "Task updated successfully, but failed to re-fetch fresh state; falling back to PUT response"
1269 );
1270 Ok(map_task(&task))
1271 }
1272 }
1273 }
1274
1275 async fn set_custom_fields(&self, issue_key: &str, fields: &[serde_json::Value]) -> Result<()> {
1276 let task_id = self.resolve_to_native_id(issue_key).await?;
1277 for field in fields {
1278 let field_id = field["id"].as_str().unwrap_or_default();
1279 if field_id.is_empty() {
1280 continue;
1281 }
1282 let url = format!("{}/task/{}/field/{}", self.base_url, task_id, field_id);
1283 let body = serde_json::json!({ "value": field["value"] });
1284 let resp = self
1285 .request(reqwest::Method::POST, &url)
1286 .json(&body)
1287 .send()
1288 .await
1289 .map_err(|e| Error::Http(e.to_string()))?;
1290 if !resp.status().is_success() {
1291 let status = resp.status().as_u16();
1292 let msg = resp.text().await.unwrap_or_default();
1293 warn!(
1294 field_id = field_id,
1295 status = status,
1296 "Failed to set custom field: {}",
1297 msg
1298 );
1299 }
1300 }
1301 Ok(())
1302 }
1303
1304 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1305 let base_url = self.task_url(issue_key)?;
1306 let url = if base_url.contains('?') {
1308 let (path, query) = base_url.split_once('?').unwrap();
1309 format!("{}/comment?{}", path, query)
1310 } else {
1311 format!("{}/comment", base_url)
1312 };
1313 let response: ClickUpCommentList = self.get(&url).await?;
1314 Ok(response
1315 .comments
1316 .iter()
1317 .map(map_comment)
1318 .collect::<Vec<_>>()
1319 .into())
1320 }
1321
1322 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1323 let base_url = self.task_url(issue_key)?;
1324 let url = if base_url.contains('?') {
1325 let (path, query) = base_url.split_once('?').unwrap();
1326 format!("{}/comment?{}", path, query)
1327 } else {
1328 format!("{}/comment", base_url)
1329 };
1330 let request = CreateCommentRequest {
1331 comment_text: body.to_string(),
1332 };
1333
1334 let response: CreateCommentResponse = self.post(&url, &request).await?;
1336 Ok(Comment {
1337 id: response.id,
1338 body: body.to_string(),
1339 author: None,
1340 created_at: map_timestamp(&response.date),
1341 updated_at: None,
1342 position: None,
1343 })
1344 }
1345
1346 async fn upload_attachment(
1347 &self,
1348 issue_key: &str,
1349 filename: &str,
1350 data: &[u8],
1351 ) -> Result<String> {
1352 let task_id = self.resolve_to_native_id(issue_key).await?;
1353 let url = format!("{}/task/{}/attachment", self.base_url, task_id);
1354
1355 let part = reqwest::multipart::Part::bytes(data.to_vec())
1356 .file_name(filename.to_string())
1357 .mime_str("application/octet-stream")
1358 .map_err(|e| Error::Http(format!("Failed to create multipart: {}", e)))?;
1359
1360 let form = reqwest::multipart::Form::new().part("attachment", part);
1361
1362 let response = self
1363 .client
1364 .post(&url)
1365 .header("Authorization", self.token.expose_secret())
1366 .multipart(form)
1367 .send()
1368 .await
1369 .map_err(|e| Error::Http(e.to_string()))?;
1370
1371 let status = response.status();
1372 if !status.is_success() {
1373 let message = response.text().await.unwrap_or_default();
1374 return Err(Error::from_status(status.as_u16(), message));
1375 }
1376
1377 let body: serde_json::Value = response.json().await.map_err(|e| {
1379 Error::InvalidData(format!("Failed to parse attachment response: {}", e))
1380 })?;
1381
1382 let download_url = body
1384 .pointer("/url")
1385 .or_else(|| body.pointer("/attachment/url"))
1386 .and_then(|v| v.as_str())
1387 .unwrap_or("")
1388 .to_string();
1389
1390 Ok(download_url)
1391 }
1392
1393 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
1394 let url = self.task_url(issue_key)?;
1395 let task: ClickUpTask = self.get(&url).await?;
1396 Ok(task
1397 .attachments
1398 .iter()
1399 .map(map_clickup_attachment)
1400 .collect())
1401 }
1402
1403 async fn download_attachment(&self, issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1404 let url = self.task_url(issue_key)?;
1409 let task: ClickUpTask = self.get(&url).await?;
1410 let attachment = task
1411 .attachments
1412 .iter()
1413 .find(|a| a.id == asset_id)
1414 .ok_or_else(|| {
1415 Error::NotFound(format!(
1416 "attachment '{asset_id}' not found on task {issue_key}",
1417 ))
1418 })?;
1419 let download_url = attachment.url.as_deref().ok_or_else(|| {
1420 Error::InvalidData(format!(
1421 "attachment '{asset_id}' on task {issue_key} has no URL",
1422 ))
1423 })?;
1424
1425 let response = self
1426 .client
1427 .get(download_url)
1428 .header("Authorization", self.token.expose_secret())
1429 .send()
1430 .await
1431 .map_err(|e| Error::Http(e.to_string()))?;
1432
1433 let status = response.status();
1434 if !status.is_success() {
1435 let message = response.text().await.unwrap_or_default();
1436 return Err(Error::from_status(status.as_u16(), message));
1437 }
1438
1439 let bytes = response
1440 .bytes()
1441 .await
1442 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
1443 Ok(bytes.to_vec())
1444 }
1445
1446 fn asset_capabilities(&self) -> AssetCapabilities {
1447 AssetCapabilities {
1451 issue: ContextCapabilities {
1452 upload: true,
1453 download: true,
1454 delete: false,
1455 list: true,
1456 max_file_size: None,
1457 allowed_types: Vec::new(),
1458 },
1459 ..Default::default()
1460 }
1461 }
1462
1463 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1464 let url = format!("{}/list/{}", self.base_url, self.list_id);
1465 let list_info: ClickUpListInfo = self.get(&url).await?;
1466
1467 let statuses: Vec<IssueStatus> = list_info
1468 .statuses
1469 .iter()
1470 .enumerate()
1471 .map(|(idx, s)| {
1472 let category = map_status_category(s.status_type.as_deref(), &s.status);
1473 IssueStatus {
1474 id: s.status.clone(),
1475 name: s.status.clone(),
1476 category,
1477 color: s.color.clone(),
1478 order: s.orderindex.or(Some(idx as u32)),
1479 }
1480 })
1481 .collect();
1482
1483 Ok(statuses.into())
1484 }
1485
1486 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1487 match link_type {
1488 "subtask" => {
1489 let source_url = self.task_url(source_key)?;
1491 let target_native_id = self.resolve_to_native_id(target_key).await?;
1492 let body = serde_json::json!({ "parent": target_native_id });
1493 let _: ClickUpTask = self.put(&source_url, &body).await?;
1494 }
1495 "blocks" => {
1496 let source_id = self.resolve_task_id(source_key)?;
1498 let target_id = self.resolve_task_id(target_key)?;
1499 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1500 let body = serde_json::json!({ "depends_on": source_id });
1501 let _: serde_json::Value = self.post(&url, &body).await?;
1502 }
1503 "blocked_by" => {
1504 let source_id = self.resolve_task_id(source_key)?;
1506 let target_id = self.resolve_task_id(target_key)?;
1507 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1508 let body = serde_json::json!({ "depends_on": target_id });
1509 let _: serde_json::Value = self.post(&url, &body).await?;
1510 }
1511 _ => {
1512 let source_id = self.resolve_to_native_id(source_key).await?;
1514 let target_id = self.resolve_to_native_id(target_key).await?;
1515 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1516 let body = serde_json::json!({});
1517 let _: serde_json::Value = self.post(&url, &body).await?;
1518 }
1519 }
1520
1521 Ok(())
1522 }
1523
1524 async fn unlink_issues(
1525 &self,
1526 source_key: &str,
1527 target_key: &str,
1528 link_type: &str,
1529 ) -> Result<()> {
1530 match link_type {
1531 "subtask" => {
1532 let source_id = self.resolve_to_native_id(source_key).await?;
1535 let url = format!("{}/task/{}", self.base_url, source_id);
1536 let body = serde_json::json!({ "parent": "none" });
1537 let _: ClickUpTask = self.put(&url, &body).await?;
1538 }
1539 "blocks" => {
1540 let source_id = self.resolve_to_native_id(source_key).await?;
1542 let target_id = self.resolve_to_native_id(target_key).await?;
1543 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1544 self.delete_with_query(&url, &[("depends_on", &source_id)])
1545 .await?;
1546 }
1547 "blocked_by" => {
1548 let source_id = self.resolve_to_native_id(source_key).await?;
1550 let target_id = self.resolve_to_native_id(target_key).await?;
1551 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1552 self.delete_with_query(&url, &[("depends_on", &target_id)])
1553 .await?;
1554 }
1555 _ => {
1556 let source_id = self.resolve_to_native_id(source_key).await?;
1558 let target_id = self.resolve_to_native_id(target_key).await?;
1559 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1560 self.delete(&url).await?;
1561 }
1562 }
1563
1564 Ok(())
1565 }
1566
1567 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1568 let url = self.task_url(issue_key)?;
1569 let task: ClickUpTask = self
1570 .get_with_query(
1571 &url,
1572 &[("include_subtasks", "true"), ("include_closed", "true")],
1573 )
1574 .await?;
1575
1576 let mut relations = IssueRelations::default();
1577
1578 if let Some(ref parent_id) = task.parent {
1580 let parent_url = format!("{}/task/{}", self.base_url, parent_id);
1581 match self.get::<ClickUpTask>(&parent_url).await {
1582 Ok(parent_task) => {
1583 relations.parent = Some(map_task(&parent_task));
1584 }
1585 Err(e) => {
1586 tracing::warn!("Failed to fetch parent task {}: {}", parent_id, e);
1587 relations.parent = Some(Issue {
1589 key: format!("CU-{parent_id}"),
1590 source: "clickup".to_string(),
1591 ..Default::default()
1592 });
1593 }
1594 }
1595 }
1596
1597 if let Some(ref subtasks) = task.subtasks {
1599 relations.subtasks = subtasks.iter().map(map_task).collect();
1600 }
1601
1602 if let Some(ref deps) = task.dependencies {
1604 let (blocked_by, blocks) = map_dependencies(deps, &task.id);
1605 relations.blocked_by = blocked_by;
1606 relations.blocks = blocks;
1607 }
1608
1609 if let Some(ref linked) = task.linked_tasks {
1611 relations.related_to = map_linked_tasks(linked);
1612 }
1613
1614 Ok(relations)
1615 }
1616
1617 fn provider_name(&self) -> &'static str {
1618 "clickup"
1619 }
1620}
1621
1622#[async_trait]
1623impl MergeRequestProvider for ClickUpClient {
1624 fn provider_name(&self) -> &'static str {
1625 "clickup"
1626 }
1627}
1628
1629#[async_trait]
1630impl PipelineProvider for ClickUpClient {
1631 fn provider_name(&self) -> &'static str {
1632 "clickup"
1633 }
1634}
1635
1636#[async_trait]
1637impl Provider for ClickUpClient {
1638 async fn get_current_user(&self) -> Result<User> {
1639 let url = format!(
1642 "{}/list/{}/task?page=0&subtasks=false",
1643 self.base_url, self.list_id
1644 );
1645 let _: ClickUpTaskList = self.get(&url).await?;
1646
1647 Ok(User {
1649 id: "clickup".to_string(),
1650 username: "clickup-user".to_string(),
1651 name: Some("ClickUp User".to_string()),
1652 ..Default::default()
1653 })
1654 }
1655}
1656
1657#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use crate::types::{ClickUpStatus, ClickUpTag};
1665 use devboy_core::{CreateCommentInput, MrFilter};
1666
1667 fn token(s: &str) -> SecretString {
1668 SecretString::from(s.to_string())
1669 }
1670
1671 #[test]
1672 fn test_epoch_ms_to_iso8601() {
1673 assert_eq!(
1675 epoch_ms_to_iso8601("1704067200000"),
1676 Some("2024-01-01T00:00:00Z".to_string())
1677 );
1678
1679 assert_eq!(
1681 epoch_ms_to_iso8601("1704153600000"),
1682 Some("2024-01-02T00:00:00Z".to_string())
1683 );
1684
1685 assert_eq!(
1687 epoch_ms_to_iso8601("1705312800000"),
1688 Some("2024-01-15T10:00:00Z".to_string())
1689 );
1690
1691 assert_eq!(epoch_ms_to_iso8601("not_a_number"), None);
1693 }
1694
1695 #[test]
1696 fn test_task_url_cu_prefix() {
1697 let client =
1698 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1699 let url = client.task_url("CU-abc123").unwrap();
1700 assert_eq!(url, "https://api.clickup.com/api/v2/task/abc123");
1701 }
1702
1703 #[test]
1704 fn test_task_url_custom_id_with_team() {
1705 let client =
1706 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"))
1707 .with_team_id("9876");
1708 let url = client.task_url("DEV-42").unwrap();
1709 assert_eq!(
1710 url,
1711 "https://api.clickup.com/api/v2/task/DEV-42?custom_task_ids=true&team_id=9876"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_task_url_custom_id_without_team() {
1717 let client =
1718 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1719 let result = client.task_url("DEV-42");
1720 assert!(result.is_err());
1721 }
1722
1723 #[test]
1729 fn test_map_task_surfaces_custom_field_values() {
1730 let task = ClickUpTask {
1731 id: "abc123".to_string(),
1732 custom_id: None,
1733 name: "T".to_string(),
1734 description: None,
1735 text_content: None,
1736 status: ClickUpStatus {
1737 status: "open".to_string(),
1738 status_type: Some("open".to_string()),
1739 },
1740 priority: None,
1741 tags: vec![],
1742 assignees: vec![],
1743 creator: None,
1744 url: "https://app.clickup.com/t/abc123".to_string(),
1745 date_created: None,
1746 date_updated: None,
1747 parent: None,
1748 subtasks: None,
1749 dependencies: None,
1750 linked_tasks: None,
1751 attachments: Vec::new(),
1752 custom_fields: vec![
1753 crate::types::ClickUpCustomField {
1754 id: "cf-1".to_string(),
1755 name: Some("Severity".to_string()),
1756 field_type: Some("drop_down".to_string()),
1757 value: Some(serde_json::json!("High")),
1758 },
1759 crate::types::ClickUpCustomField {
1760 id: "cf-2".to_string(),
1761 name: Some("Sprint".to_string()),
1762 field_type: Some("text".to_string()),
1763 value: None, },
1765 crate::types::ClickUpCustomField {
1766 id: "cf-3".to_string(),
1767 name: None, field_type: Some("number".to_string()),
1769 value: Some(serde_json::json!(42)),
1770 },
1771 ],
1772 };
1773
1774 let issue = map_task(&task);
1775 let severity = issue.custom_fields.get("cf-1").expect("cf-1 present");
1778 assert_eq!(severity.name.as_deref(), Some("Severity"));
1779 assert_eq!(severity.value, serde_json::json!("High"));
1780 assert!(!issue.custom_fields.contains_key("cf-2"));
1782 let anon = issue.custom_fields.get("cf-3").expect("cf-3 present");
1784 assert!(anon.name.is_none());
1785 assert_eq!(anon.value, serde_json::json!(42));
1786 }
1787
1788 #[test]
1789 fn test_map_task() {
1790 let task = ClickUpTask {
1791 id: "abc123".to_string(),
1792 custom_id: None,
1793 name: "Fix bug".to_string(),
1794 description: Some("Bug description".to_string()),
1795 text_content: Some("Bug text content".to_string()),
1796 status: ClickUpStatus {
1797 status: "open".to_string(),
1798 status_type: Some("open".to_string()),
1799 },
1800 priority: Some(ClickUpPriority {
1801 id: "2".to_string(),
1802 priority: "high".to_string(),
1803 color: None,
1804 }),
1805 tags: vec![ClickUpTag {
1806 name: "bug".to_string(),
1807 }],
1808 assignees: vec![ClickUpUser {
1809 id: 1,
1810 username: "dev1".to_string(),
1811 email: Some("dev1@example.com".to_string()),
1812 profile_picture: None,
1813 }],
1814 creator: Some(ClickUpUser {
1815 id: 2,
1816 username: "creator".to_string(),
1817 email: None,
1818 profile_picture: None,
1819 }),
1820 url: "https://app.clickup.com/t/abc123".to_string(),
1821 date_created: Some("1704067200000".to_string()),
1822 date_updated: Some("1704153600000".to_string()),
1823 parent: None,
1824 subtasks: None,
1825 dependencies: None,
1826 linked_tasks: None,
1827 attachments: Vec::new(),
1828 custom_fields: Vec::new(),
1829 };
1830
1831 let issue = map_task(&task);
1832 assert_eq!(issue.key, "CU-abc123");
1833 assert_eq!(issue.title, "Fix bug");
1834 assert_eq!(issue.description, Some("Bug text content".to_string()));
1835 assert_eq!(issue.state, "open");
1836 assert_eq!(issue.source, "clickup");
1837 assert_eq!(issue.priority, Some("high".to_string()));
1838 assert_eq!(issue.labels, vec!["bug"]);
1839 assert_eq!(issue.assignees.len(), 1);
1840 assert_eq!(issue.assignees[0].username, "dev1");
1841 assert!(issue.author.is_some());
1842 assert_eq!(issue.author.unwrap().username, "creator");
1843 assert_eq!(
1844 issue.url,
1845 Some("https://app.clickup.com/t/abc123".to_string())
1846 );
1847 assert_eq!(issue.created_at, Some("2024-01-01T00:00:00Z".to_string()));
1849 assert_eq!(issue.updated_at, Some("2024-01-02T00:00:00Z".to_string()));
1850 }
1851
1852 #[test]
1853 fn test_map_task_with_custom_id() {
1854 let task = ClickUpTask {
1855 id: "abc123".to_string(),
1856 custom_id: Some("DEV-42".to_string()),
1857 name: "Task with custom ID".to_string(),
1858 description: None,
1859 text_content: None,
1860 status: ClickUpStatus {
1861 status: "open".to_string(),
1862 status_type: Some("open".to_string()),
1863 },
1864 priority: None,
1865 tags: vec![],
1866 assignees: vec![],
1867 creator: None,
1868 url: "https://app.clickup.com/t/abc123".to_string(),
1869 date_created: None,
1870 date_updated: None,
1871 parent: None,
1872 subtasks: None,
1873 dependencies: None,
1874 linked_tasks: None,
1875 attachments: Vec::new(),
1876 custom_fields: Vec::new(),
1877 };
1878
1879 let issue = map_task(&task);
1880 assert_eq!(issue.key, "DEV-42");
1881 }
1882
1883 #[test]
1884 fn test_map_task_closed_status() {
1885 let task = ClickUpTask {
1886 id: "abc123".to_string(),
1887 custom_id: None,
1888 name: "Closed task".to_string(),
1889 description: None,
1890 text_content: None,
1891 status: ClickUpStatus {
1892 status: "done".to_string(),
1893 status_type: Some("closed".to_string()),
1894 },
1895 priority: None,
1896 tags: vec![],
1897 assignees: vec![],
1898 creator: None,
1899 url: "https://app.clickup.com/t/abc123".to_string(),
1900 date_created: None,
1901 date_updated: None,
1902 parent: None,
1903 subtasks: None,
1904 dependencies: None,
1905 linked_tasks: None,
1906 attachments: Vec::new(),
1907 custom_fields: Vec::new(),
1908 };
1909
1910 let issue = map_task(&task);
1911 assert_eq!(issue.state, "closed");
1912 }
1913
1914 #[test]
1915 fn test_map_priority_all_levels() {
1916 let make_priority = |id: &str, name: &str| ClickUpPriority {
1917 id: id.to_string(),
1918 priority: name.to_string(),
1919 color: None,
1920 };
1921
1922 assert_eq!(
1923 map_priority(Some(&make_priority("1", "urgent"))),
1924 Some("urgent".to_string())
1925 );
1926 assert_eq!(
1927 map_priority(Some(&make_priority("2", "high"))),
1928 Some("high".to_string())
1929 );
1930 assert_eq!(
1931 map_priority(Some(&make_priority("3", "normal"))),
1932 Some("normal".to_string())
1933 );
1934 assert_eq!(
1935 map_priority(Some(&make_priority("4", "low"))),
1936 Some("low".to_string())
1937 );
1938 assert_eq!(map_priority(None), None);
1939 }
1940
1941 #[test]
1942 fn test_map_user() {
1943 let cu_user = ClickUpUser {
1944 id: 123,
1945 username: "testuser".to_string(),
1946 email: Some("test@example.com".to_string()),
1947 profile_picture: Some("https://example.com/avatar.png".to_string()),
1948 };
1949
1950 let user = map_user(Some(&cu_user)).unwrap();
1951 assert_eq!(user.id, "123");
1952 assert_eq!(user.username, "testuser");
1953 assert_eq!(user.name, Some("testuser".to_string()));
1954 assert_eq!(user.email, Some("test@example.com".to_string()));
1955 assert_eq!(
1956 user.avatar_url,
1957 Some("https://example.com/avatar.png".to_string())
1958 );
1959 }
1960
1961 #[test]
1962 fn test_map_user_none() {
1963 assert!(map_user(None).is_none());
1964 }
1965
1966 #[test]
1967 fn test_map_user_required_with_user() {
1968 let cu_user = ClickUpUser {
1969 id: 1,
1970 username: "user1".to_string(),
1971 email: None,
1972 profile_picture: None,
1973 };
1974 let user = map_user_required(Some(&cu_user));
1975 assert_eq!(user.username, "user1");
1976 }
1977
1978 #[test]
1979 fn test_map_user_required_without_user() {
1980 let user = map_user_required(None);
1981 assert_eq!(user.id, "unknown");
1982 assert_eq!(user.username, "unknown");
1983 }
1984
1985 #[test]
1986 fn test_map_clickup_attachment_all_fields() {
1987 let raw = ClickUpAttachment {
1988 id: "att-1".into(),
1989 title: Some("report.log".into()),
1990 url: Some("https://attachments.clickup.com/abc/report.log".into()),
1991 size: Some(serde_json::json!("2048")),
1992 extension: Some("log".into()),
1993 mimetype: Some("text/plain".into()),
1994 date: Some("1704067200000".into()),
1995 user: Some(ClickUpUser {
1996 id: 7,
1997 username: "uploader".into(),
1998 email: None,
1999 profile_picture: None,
2000 }),
2001 };
2002 let meta = map_clickup_attachment(&raw);
2003 assert_eq!(meta.id, "att-1");
2004 assert_eq!(meta.filename, "report.log");
2005 assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
2006 assert_eq!(meta.size, Some(2048));
2007 assert_eq!(
2008 meta.url.as_deref(),
2009 Some("https://attachments.clickup.com/abc/report.log")
2010 );
2011 assert_eq!(meta.author.as_deref(), Some("uploader"));
2012 assert_eq!(meta.created_at, Some("2024-01-01T00:00:00Z".to_string()));
2013 assert!(!meta.cached);
2014 }
2015
2016 #[test]
2017 fn test_map_clickup_attachment_minimal_falls_back_to_url() {
2018 let raw = ClickUpAttachment {
2019 id: "att-2".into(),
2020 title: None,
2021 url: Some("https://cdn/a/b/screen.png?token=x".into()),
2022 size: Some(serde_json::json!(4096)),
2023 extension: None,
2024 mimetype: None,
2025 date: None,
2026 user: None,
2027 };
2028 let meta = map_clickup_attachment(&raw);
2029 assert_eq!(meta.filename, "screen.png");
2031 assert_eq!(meta.size, Some(4096));
2032 assert!(meta.created_at.is_none());
2033 assert!(meta.author.is_none());
2034 }
2035
2036 #[test]
2037 fn test_map_clickup_attachment_missing_everything() {
2038 let raw = ClickUpAttachment {
2039 id: "att-3".into(),
2040 title: None,
2041 url: None,
2042 size: None,
2043 extension: None,
2044 mimetype: None,
2045 date: None,
2046 user: None,
2047 };
2048 let meta = map_clickup_attachment(&raw);
2049 assert_eq!(meta.filename, "attachment-att-3");
2051 assert!(meta.url.is_none());
2052 assert!(meta.size.is_none());
2053 }
2054
2055 #[test]
2056 fn test_clickup_asset_capabilities() {
2057 let client =
2058 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2059 let caps = client.asset_capabilities();
2060 assert!(caps.issue.upload);
2061 assert!(caps.issue.download);
2062 assert!(caps.issue.list);
2063 assert!(!caps.issue.delete, "ClickUp has no delete attachment API");
2064 assert!(
2065 !caps.merge_request.upload,
2066 "ClickUp does not track merge requests",
2067 );
2068 }
2069
2070 #[test]
2071 fn test_map_comment() {
2072 let cu_comment = ClickUpComment {
2073 id: "42".to_string(),
2074 comment_text: "Nice work!".to_string(),
2075 user: Some(ClickUpUser {
2076 id: 1,
2077 username: "reviewer".to_string(),
2078 email: None,
2079 profile_picture: None,
2080 }),
2081 date: Some("1705312800000".to_string()),
2082 };
2083
2084 let comment = map_comment(&cu_comment);
2085 assert_eq!(comment.id, "42");
2086 assert_eq!(comment.body, "Nice work!");
2087 assert!(comment.author.is_some());
2088 assert_eq!(comment.author.unwrap().username, "reviewer");
2089 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
2091 assert!(comment.position.is_none());
2092 }
2093
2094 #[test]
2095 fn test_map_tags() {
2096 let tags = vec![
2097 ClickUpTag {
2098 name: "bug".to_string(),
2099 },
2100 ClickUpTag {
2101 name: "feature".to_string(),
2102 },
2103 ];
2104 let result = map_tags(&tags);
2105 assert_eq!(result, vec!["bug", "feature"]);
2106 }
2107
2108 #[test]
2109 fn test_map_tags_empty() {
2110 let result = map_tags(&[]);
2111 assert!(result.is_empty());
2112 }
2113
2114 #[test]
2115 fn test_priority_to_clickup() {
2116 assert_eq!(priority_to_clickup("urgent"), Some(1));
2117 assert_eq!(priority_to_clickup("high"), Some(2));
2118 assert_eq!(priority_to_clickup("normal"), Some(3));
2119 assert_eq!(priority_to_clickup("low"), Some(4));
2120 assert_eq!(priority_to_clickup("unknown"), None);
2121 }
2122
2123 #[test]
2124 fn test_api_url() {
2125 let client =
2126 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2127 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2128 assert_eq!(client.list_id, "12345");
2129 }
2130
2131 #[test]
2132 fn test_api_url_strips_trailing_slash() {
2133 let client = ClickUpClient::with_base_url(
2134 "https://api.clickup.com/api/v2/",
2135 "12345",
2136 token("token"),
2137 );
2138 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2139 }
2140
2141 #[test]
2142 fn test_with_team_id() {
2143 let client = ClickUpClient::new("12345", token("token")).with_team_id("9876");
2144 assert_eq!(client.team_id, Some("9876".to_string()));
2145 }
2146
2147 #[test]
2148 fn test_provider_name() {
2149 let client = ClickUpClient::new("12345", token("token"));
2150 assert_eq!(IssueProvider::provider_name(&client), "clickup");
2151 assert_eq!(MergeRequestProvider::provider_name(&client), "clickup");
2152 }
2153
2154 #[test]
2155 fn test_map_task_description_fallback() {
2156 let task = ClickUpTask {
2157 id: "abc".to_string(),
2158 custom_id: None,
2159 name: "Task".to_string(),
2160 description: Some("HTML description".to_string()),
2161 text_content: None,
2162 status: ClickUpStatus {
2163 status: "open".to_string(),
2164 status_type: Some("open".to_string()),
2165 },
2166 priority: None,
2167 tags: vec![],
2168 assignees: vec![],
2169 creator: None,
2170 url: "https://app.clickup.com/t/abc".to_string(),
2171 date_created: None,
2172 date_updated: None,
2173 parent: None,
2174 subtasks: None,
2175 dependencies: None,
2176 linked_tasks: None,
2177 attachments: Vec::new(),
2178 custom_fields: Vec::new(),
2179 };
2180
2181 let issue = map_task(&task);
2182 assert_eq!(issue.description, Some("HTML description".to_string()));
2183 }
2184
2185 #[test]
2186 fn test_map_state_custom_type() {
2187 let task = ClickUpTask {
2188 id: "abc".to_string(),
2189 custom_id: None,
2190 name: "Task".to_string(),
2191 description: None,
2192 text_content: None,
2193 status: ClickUpStatus {
2194 status: "in progress".to_string(),
2195 status_type: Some("custom".to_string()),
2196 },
2197 priority: None,
2198 tags: vec![],
2199 assignees: vec![],
2200 creator: None,
2201 url: "https://app.clickup.com/t/abc".to_string(),
2202 date_created: None,
2203 date_updated: None,
2204 parent: None,
2205 subtasks: None,
2206 dependencies: None,
2207 linked_tasks: None,
2208 attachments: Vec::new(),
2209 custom_fields: Vec::new(),
2210 };
2211
2212 let issue = map_task(&task);
2213 assert_eq!(issue.state, "open");
2214 }
2215
2216 #[test]
2217 fn test_map_task_with_parent() {
2218 let task = ClickUpTask {
2219 id: "child1".to_string(),
2220 custom_id: Some("DEV-100".to_string()),
2221 name: "Child task".to_string(),
2222 description: None,
2223 text_content: None,
2224 status: ClickUpStatus {
2225 status: "open".to_string(),
2226 status_type: Some("open".to_string()),
2227 },
2228 priority: None,
2229 tags: vec![],
2230 assignees: vec![],
2231 creator: None,
2232 url: "https://app.clickup.com/t/child1".to_string(),
2233 date_created: None,
2234 date_updated: None,
2235 parent: Some("parent123".to_string()),
2236 subtasks: None,
2237 dependencies: None,
2238 linked_tasks: None,
2239 attachments: Vec::new(),
2240 custom_fields: Vec::new(),
2241 };
2242
2243 let issue = map_task(&task);
2244 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
2245 assert!(issue.subtasks.is_empty());
2246 }
2247
2248 #[test]
2249 fn test_map_task_with_subtasks() {
2250 let subtask = ClickUpTask {
2251 id: "sub1".to_string(),
2252 custom_id: Some("DEV-201".to_string()),
2253 name: "Subtask 1".to_string(),
2254 description: None,
2255 text_content: None,
2256 status: ClickUpStatus {
2257 status: "in progress".to_string(),
2258 status_type: Some("custom".to_string()),
2259 },
2260 priority: None,
2261 tags: vec![],
2262 assignees: vec![],
2263 creator: None,
2264 url: "https://app.clickup.com/t/sub1".to_string(),
2265 date_created: None,
2266 date_updated: None,
2267 parent: Some("epic1".to_string()),
2268 subtasks: None,
2269 dependencies: None,
2270 linked_tasks: None,
2271 attachments: Vec::new(),
2272 custom_fields: Vec::new(),
2273 };
2274
2275 let task = ClickUpTask {
2276 id: "epic1".to_string(),
2277 custom_id: Some("DEV-200".to_string()),
2278 name: "Epic task".to_string(),
2279 description: None,
2280 text_content: None,
2281 status: ClickUpStatus {
2282 status: "open".to_string(),
2283 status_type: Some("open".to_string()),
2284 },
2285 priority: None,
2286 tags: vec![ClickUpTag {
2287 name: "epic".to_string(),
2288 }],
2289 assignees: vec![],
2290 creator: None,
2291 url: "https://app.clickup.com/t/epic1".to_string(),
2292 date_created: None,
2293 date_updated: None,
2294 parent: None,
2295 subtasks: Some(vec![subtask]),
2296 dependencies: None,
2297 linked_tasks: None,
2298 attachments: Vec::new(),
2299 custom_fields: Vec::new(),
2300 };
2301
2302 let issue = map_task(&task);
2303 assert_eq!(issue.key, "DEV-200");
2304 assert!(issue.parent.is_none());
2305 assert_eq!(issue.subtasks.len(), 1);
2306 assert_eq!(issue.subtasks[0].key, "DEV-201");
2307 assert_eq!(issue.subtasks[0].title, "Subtask 1");
2308 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2309 }
2310
2311 #[test]
2312 fn test_map_task_no_parent_no_subtasks() {
2313 let task = ClickUpTask {
2314 id: "standalone".to_string(),
2315 custom_id: None,
2316 name: "Standalone task".to_string(),
2317 description: None,
2318 text_content: None,
2319 status: ClickUpStatus {
2320 status: "open".to_string(),
2321 status_type: Some("open".to_string()),
2322 },
2323 priority: None,
2324 tags: vec![],
2325 assignees: vec![],
2326 creator: None,
2327 url: "https://app.clickup.com/t/standalone".to_string(),
2328 date_created: None,
2329 date_updated: None,
2330 parent: None,
2331 subtasks: None,
2332 dependencies: None,
2333 linked_tasks: None,
2334 attachments: Vec::new(),
2335 custom_fields: Vec::new(),
2336 };
2337
2338 let issue = map_task(&task);
2339 assert!(issue.parent.is_none());
2340 assert!(issue.subtasks.is_empty());
2341 }
2342
2343 #[test]
2344 fn test_deserialize_task_with_parent_and_subtasks() {
2345 let json = serde_json::json!({
2346 "id": "epic1",
2347 "custom_id": "DEV-300",
2348 "name": "Epic with subtasks",
2349 "status": {"status": "open", "type": "open"},
2350 "tags": [{"name": "epic"}],
2351 "assignees": [],
2352 "url": "https://app.clickup.com/t/epic1",
2353 "parent": null,
2354 "subtasks": [
2355 {
2356 "id": "sub1",
2357 "custom_id": "DEV-301",
2358 "name": "Subtask A",
2359 "status": {"status": "open", "type": "open"},
2360 "tags": [],
2361 "assignees": [],
2362 "url": "https://app.clickup.com/t/sub1",
2363 "parent": "epic1"
2364 },
2365 {
2366 "id": "sub2",
2367 "name": "Subtask B",
2368 "status": {"status": "closed", "type": "closed"},
2369 "tags": [],
2370 "assignees": [],
2371 "url": "https://app.clickup.com/t/sub2",
2372 "parent": "epic1"
2373 }
2374 ]
2375 });
2376
2377 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2378 assert!(task.parent.is_none());
2379 assert_eq!(task.subtasks.as_ref().unwrap().len(), 2);
2380 assert_eq!(
2381 task.subtasks.as_ref().unwrap()[0].custom_id,
2382 Some("DEV-301".to_string())
2383 );
2384 assert_eq!(
2385 task.subtasks.as_ref().unwrap()[1].parent,
2386 Some("epic1".to_string())
2387 );
2388
2389 let issue = map_task(&task);
2390 assert_eq!(issue.subtasks.len(), 2);
2391 assert_eq!(issue.subtasks[0].key, "DEV-301");
2392 assert_eq!(issue.subtasks[1].key, "CU-sub2");
2393 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2394 }
2395
2396 #[test]
2397 fn test_deserialize_task_without_subtasks_field() {
2398 let json = serde_json::json!({
2400 "id": "task1",
2401 "name": "Simple task",
2402 "status": {"status": "open", "type": "open"},
2403 "tags": [],
2404 "assignees": [],
2405 "url": "https://app.clickup.com/t/task1"
2406 });
2407
2408 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2409 assert!(task.parent.is_none());
2410 assert!(task.subtasks.is_none());
2411
2412 let issue = map_task(&task);
2413 assert!(issue.parent.is_none());
2414 assert!(issue.subtasks.is_empty());
2415 }
2416
2417 #[test]
2418 fn test_map_status_category_name_heuristics() {
2419 assert_eq!(map_status_category(Some("closed"), "Done"), "done");
2421 assert_eq!(map_status_category(Some("done"), "Complete"), "done");
2422
2423 assert_eq!(map_status_category(Some("custom"), "Backlog"), "backlog");
2425 assert_eq!(
2426 map_status_category(Some("custom"), "Product Backlog"),
2427 "backlog"
2428 );
2429 assert_eq!(map_status_category(Some("custom"), "To Do"), "todo");
2430 assert_eq!(map_status_category(Some("custom"), "New"), "todo");
2431 assert_eq!(
2432 map_status_category(Some("custom"), "In Progress"),
2433 "in_progress"
2434 );
2435 assert_eq!(
2436 map_status_category(Some("custom"), "Code Review"),
2437 "in_progress"
2438 );
2439 assert_eq!(map_status_category(Some("custom"), "Doing"), "in_progress");
2440 assert_eq!(map_status_category(Some("custom"), "Active"), "in_progress");
2441 assert_eq!(map_status_category(Some("custom"), "Done"), "done");
2442 assert_eq!(map_status_category(Some("custom"), "Completed"), "done");
2443 assert_eq!(map_status_category(Some("custom"), "Resolved"), "done");
2444 assert_eq!(
2445 map_status_category(Some("custom"), "Cancelled"),
2446 "cancelled"
2447 );
2448 assert_eq!(map_status_category(Some("custom"), "Archived"), "cancelled");
2449 assert_eq!(map_status_category(Some("custom"), "Rejected"), "cancelled");
2450
2451 assert_eq!(map_status_category(Some("open"), "Open"), "todo");
2453
2454 assert_eq!(
2456 map_status_category(Some("custom"), "Some Custom Status"),
2457 "in_progress"
2458 );
2459 }
2460
2461 #[test]
2462 fn test_priority_sort_key() {
2463 assert_eq!(priority_sort_key(Some("urgent")), 1);
2464 assert_eq!(priority_sort_key(Some("high")), 2);
2465 assert_eq!(priority_sort_key(Some("normal")), 3);
2466 assert_eq!(priority_sort_key(Some("low")), 4);
2467 assert_eq!(priority_sort_key(None), 5);
2468 }
2469
2470 mod integration {
2475 use super::*;
2476 use httpmock::prelude::*;
2477
2478 fn create_test_client(server: &MockServer) -> ClickUpClient {
2479 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2480 }
2481
2482 fn create_test_client_with_team(server: &MockServer) -> ClickUpClient {
2483 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2484 .with_team_id("9876")
2485 }
2486
2487 fn sample_task_json() -> serde_json::Value {
2488 serde_json::json!({
2489 "id": "abc123",
2490 "name": "Test Task",
2491 "description": "<p>Task description</p>",
2492 "text_content": "Task description",
2493 "status": {
2494 "status": "open",
2495 "type": "open"
2496 },
2497 "priority": {
2498 "id": "2",
2499 "priority": "high",
2500 "color": "#ffcc00"
2501 },
2502 "tags": [{"name": "bug"}],
2503 "assignees": [{"id": 1, "username": "dev1"}],
2504 "creator": {"id": 2, "username": "creator"},
2505 "url": "https://app.clickup.com/t/abc123",
2506 "date_created": "1704067200000",
2507 "date_updated": "1704153600000"
2508 })
2509 }
2510
2511 fn sample_closed_task_json() -> serde_json::Value {
2512 serde_json::json!({
2513 "id": "def456",
2514 "name": "Closed Task",
2515 "status": {
2516 "status": "done",
2517 "type": "closed"
2518 },
2519 "tags": [],
2520 "assignees": [],
2521 "url": "https://app.clickup.com/t/def456",
2522 "date_created": "1704067200000",
2523 "date_updated": "1704153600000"
2524 })
2525 }
2526
2527 fn sample_task_with_custom_id_json() -> serde_json::Value {
2528 serde_json::json!({
2529 "id": "abc123",
2530 "custom_id": "DEV-42",
2531 "name": "Task with custom ID",
2532 "status": {
2533 "status": "open",
2534 "type": "open"
2535 },
2536 "tags": [],
2537 "assignees": [],
2538 "url": "https://app.clickup.com/t/abc123",
2539 "date_created": "1704067200000",
2540 "date_updated": "1704153600000"
2541 })
2542 }
2543
2544 #[tokio::test]
2545 async fn test_get_issues() {
2546 let server = MockServer::start();
2547
2548 server.mock(|when, then| {
2549 when.method(GET)
2550 .path("/list/12345/task")
2551 .header("Authorization", "pk_test_token");
2552 then.status(200)
2553 .json_body(serde_json::json!({"tasks": [sample_task_json()]}));
2554 });
2555
2556 let client = create_test_client(&server);
2557 let issues = client
2558 .get_issues(IssueFilter::default())
2559 .await
2560 .unwrap()
2561 .items;
2562
2563 assert_eq!(issues.len(), 1);
2564 assert_eq!(issues[0].key, "CU-abc123");
2565 assert_eq!(issues[0].title, "Test Task");
2566 assert_eq!(issues[0].source, "clickup");
2567 assert_eq!(issues[0].priority, Some("high".to_string()));
2568 assert_eq!(
2570 issues[0].created_at,
2571 Some("2024-01-01T00:00:00Z".to_string())
2572 );
2573 }
2574
2575 #[tokio::test]
2576 async fn test_get_issues_with_filters() {
2577 let server = MockServer::start();
2578
2579 server.mock(|when, then| {
2580 when.method(GET)
2581 .path("/list/12345/task")
2582 .query_param("include_closed", "true")
2583 .query_param("subtasks", "true")
2584 .query_param("tags[]", "bug");
2585 then.status(200).json_body(
2586 serde_json::json!({"tasks": [sample_task_json(), sample_closed_task_json()]}),
2587 );
2588 });
2589
2590 let client = create_test_client(&server);
2591 let issues = client
2592 .get_issues(IssueFilter {
2593 state: Some("all".to_string()),
2594 labels: Some(vec!["bug".to_string()]),
2595 ..Default::default()
2596 })
2597 .await
2598 .unwrap()
2599 .items;
2600
2601 assert_eq!(issues.len(), 2);
2602 }
2603
2604 #[tokio::test]
2605 async fn test_get_issues_state_filter_open() {
2606 let server = MockServer::start();
2607
2608 server.mock(|when, then| {
2609 when.method(GET).path("/list/12345/task");
2610 then.status(200).json_body(serde_json::json!({
2611 "tasks": [sample_task_json(), sample_closed_task_json()]
2612 }));
2613 });
2614
2615 let client = create_test_client(&server);
2616 let issues = client
2617 .get_issues(IssueFilter {
2618 state: Some("open".to_string()),
2619 ..Default::default()
2620 })
2621 .await
2622 .unwrap()
2623 .items;
2624
2625 assert_eq!(issues.len(), 1);
2626 assert_eq!(issues[0].state, "open");
2627 }
2628
2629 #[tokio::test]
2630 async fn test_get_issues_state_filter_closed() {
2631 let server = MockServer::start();
2632
2633 server.mock(|when, then| {
2634 when.method(GET)
2635 .path("/list/12345/task")
2636 .query_param("include_closed", "true");
2637 then.status(200).json_body(serde_json::json!({
2638 "tasks": [sample_task_json(), sample_closed_task_json()]
2639 }));
2640 });
2641
2642 let client = create_test_client(&server);
2643 let issues = client
2644 .get_issues(IssueFilter {
2645 state: Some("closed".to_string()),
2646 ..Default::default()
2647 })
2648 .await
2649 .unwrap()
2650 .items;
2651
2652 assert_eq!(issues.len(), 1);
2653 assert_eq!(issues[0].state, "closed");
2654 }
2655
2656 #[tokio::test]
2657 async fn test_get_issues_pagination() {
2658 let server = MockServer::start();
2659
2660 let tasks: Vec<serde_json::Value> = (0..5)
2661 .map(|i| {
2662 serde_json::json!({
2663 "id": format!("task{}", i),
2664 "name": format!("Task {}", i),
2665 "status": {"status": "open", "type": "open"},
2666 "tags": [],
2667 "assignees": [],
2668 "url": format!("https://app.clickup.com/t/task{}", i),
2669 "date_created": "1704067200000",
2670 "date_updated": "1704153600000"
2671 })
2672 })
2673 .collect();
2674
2675 server.mock(|when, then| {
2676 when.method(GET)
2677 .path("/list/12345/task")
2678 .query_param("page", "0");
2679 then.status(200)
2680 .json_body(serde_json::json!({"tasks": tasks}));
2681 });
2682
2683 let client = create_test_client(&server);
2684
2685 let issues = client
2686 .get_issues(IssueFilter {
2687 limit: Some(2),
2688 offset: Some(1),
2689 ..Default::default()
2690 })
2691 .await
2692 .unwrap()
2693 .items;
2694
2695 assert_eq!(issues.len(), 2);
2696 assert_eq!(issues[0].key, "CU-task1");
2697 assert_eq!(issues[1].key, "CU-task2");
2698 }
2699
2700 #[tokio::test]
2701 async fn test_get_issues_limit_zero() {
2702 let client = ClickUpClient::new("12345", token("token"));
2704 let issues = client
2705 .get_issues(IssueFilter {
2706 limit: Some(0),
2707 ..Default::default()
2708 })
2709 .await
2710 .unwrap()
2711 .items;
2712
2713 assert!(issues.is_empty());
2714 }
2715
2716 #[tokio::test]
2717 async fn test_get_issues_multi_page() {
2718 let server = MockServer::start();
2719
2720 let page0_tasks: Vec<serde_json::Value> = (0..100)
2722 .map(|i| {
2723 serde_json::json!({
2724 "id": format!("task{}", i),
2725 "name": format!("Task {}", i),
2726 "status": {"status": "open", "type": "open"},
2727 "tags": [],
2728 "assignees": [],
2729 "url": format!("https://app.clickup.com/t/task{}", i),
2730 "date_created": "1704067200000",
2731 "date_updated": "1704153600000"
2732 })
2733 })
2734 .collect();
2735
2736 let page1_tasks: Vec<serde_json::Value> = (100..150)
2738 .map(|i| {
2739 serde_json::json!({
2740 "id": format!("task{}", i),
2741 "name": format!("Task {}", i),
2742 "status": {"status": "open", "type": "open"},
2743 "tags": [],
2744 "assignees": [],
2745 "url": format!("https://app.clickup.com/t/task{}", i),
2746 "date_created": "1704067200000",
2747 "date_updated": "1704153600000"
2748 })
2749 })
2750 .collect();
2751
2752 server.mock(|when, then| {
2753 when.method(GET)
2754 .path("/list/12345/task")
2755 .query_param("page", "0");
2756 then.status(200)
2757 .json_body(serde_json::json!({"tasks": page0_tasks}));
2758 });
2759
2760 server.mock(|when, then| {
2761 when.method(GET)
2762 .path("/list/12345/task")
2763 .query_param("page", "1");
2764 then.status(200)
2765 .json_body(serde_json::json!({"tasks": page1_tasks}));
2766 });
2767
2768 let client = create_test_client(&server);
2769
2770 let issues = client
2772 .get_issues(IssueFilter {
2773 limit: Some(120),
2774 offset: Some(0),
2775 ..Default::default()
2776 })
2777 .await
2778 .unwrap()
2779 .items;
2780
2781 assert_eq!(issues.len(), 120);
2782 assert_eq!(issues[0].key, "CU-task0");
2783 assert_eq!(issues[99].key, "CU-task99");
2784 assert_eq!(issues[100].key, "CU-task100");
2785 assert_eq!(issues[119].key, "CU-task119");
2786 }
2787
2788 #[tokio::test]
2789 async fn test_get_issue() {
2790 let server = MockServer::start();
2791
2792 server.mock(|when, then| {
2793 when.method(GET).path("/task/abc123");
2794 then.status(200).json_body(sample_task_json());
2795 });
2796
2797 let client = create_test_client(&server);
2798 let issue = client.get_issue("CU-abc123").await.unwrap();
2799
2800 assert_eq!(issue.key, "CU-abc123");
2801 assert_eq!(issue.title, "Test Task");
2802 assert_eq!(issue.priority, Some("high".to_string()));
2803 }
2804
2805 #[tokio::test]
2806 async fn test_get_issue_by_custom_id() {
2807 let server = MockServer::start();
2808
2809 server.mock(|when, then| {
2810 when.method(GET)
2811 .path("/task/DEV-42")
2812 .query_param("custom_task_ids", "true")
2813 .query_param("team_id", "9876");
2814 then.status(200)
2815 .json_body(sample_task_with_custom_id_json());
2816 });
2817
2818 let client = create_test_client_with_team(&server);
2819 let issue = client.get_issue("DEV-42").await.unwrap();
2820
2821 assert_eq!(issue.key, "DEV-42");
2822 assert_eq!(issue.title, "Task with custom ID");
2823 }
2824
2825 #[tokio::test]
2826 async fn test_get_issue_custom_id_without_team_fails() {
2827 let client = ClickUpClient::new("12345", token("token"));
2828 let result = client.get_issue("DEV-42").await;
2829 assert!(result.is_err());
2830 }
2831
2832 #[tokio::test]
2833 async fn test_create_issue_with_custom_id_retry() {
2834 let server = MockServer::start();
2835
2836 server.mock(|when, then| {
2838 when.method(POST)
2839 .path("/list/12345/task")
2840 .body_includes("\"name\":\"New Task\"");
2841 then.status(200).json_body(sample_task_json());
2842 });
2843
2844 let mut task_with_custom_id = sample_task_json();
2846 task_with_custom_id["custom_id"] = serde_json::json!("DEV-100");
2847
2848 server.mock(|when, then| {
2849 when.method(GET).path("/task/abc123");
2850 then.status(200).json_body(task_with_custom_id);
2851 });
2852
2853 let client = create_test_client(&server);
2854 let issue = client
2855 .create_issue(CreateIssueInput {
2856 title: "New Task".to_string(),
2857 description: Some("Description".to_string()),
2858 labels: vec!["bug".to_string()],
2859 ..Default::default()
2860 })
2861 .await
2862 .unwrap();
2863
2864 assert_eq!(issue.key, "DEV-100");
2866 }
2867
2868 #[tokio::test]
2869 async fn test_create_issue_fallback_without_custom_id() {
2870 let server = MockServer::start();
2871
2872 server.mock(|when, then| {
2874 when.method(POST)
2875 .path("/list/12345/task")
2876 .body_includes("\"name\":\"New Task\"");
2877 then.status(200).json_body(sample_task_json());
2878 });
2879
2880 server.mock(|when, then| {
2882 when.method(GET).path("/task/abc123");
2883 then.status(200).json_body(sample_task_json());
2884 });
2885
2886 let client = create_test_client(&server);
2887 let issue = client
2888 .create_issue(CreateIssueInput {
2889 title: "New Task".to_string(),
2890 ..Default::default()
2891 })
2892 .await
2893 .unwrap();
2894
2895 assert_eq!(issue.key, "CU-abc123");
2897 }
2898
2899 #[tokio::test]
2900 async fn test_create_issue_with_priority() {
2901 let server = MockServer::start();
2902
2903 let mut task = sample_task_json();
2905 task["custom_id"] = serde_json::json!("DEV-101");
2906
2907 server.mock(|when, then| {
2908 when.method(POST)
2909 .path("/list/12345/task")
2910 .body_includes("\"priority\":1");
2911 then.status(200).json_body(task);
2912 });
2913
2914 let client = create_test_client(&server);
2915 let result = client
2916 .create_issue(CreateIssueInput {
2917 title: "Urgent Task".to_string(),
2918 priority: Some("urgent".to_string()),
2919 ..Default::default()
2920 })
2921 .await;
2922
2923 assert!(result.is_ok());
2924 assert_eq!(result.unwrap().key, "DEV-101");
2925 }
2926
2927 #[tokio::test]
2928 async fn test_update_issue() {
2929 let server = MockServer::start();
2930
2931 server.mock(|when, then| {
2932 when.method(PUT)
2933 .path("/task/abc123")
2934 .body_includes("\"name\":\"Updated Task\"");
2935 then.status(200).json_body(sample_task_json());
2936 });
2937
2938 server.mock(|when, then| {
2939 when.method(GET).path("/task/abc123");
2940 then.status(200).json_body(sample_task_json());
2941 });
2942
2943 let client = create_test_client(&server);
2944 let issue = client
2945 .update_issue(
2946 "CU-abc123",
2947 UpdateIssueInput {
2948 title: Some("Updated Task".to_string()),
2949 ..Default::default()
2950 },
2951 )
2952 .await
2953 .unwrap();
2954
2955 assert_eq!(issue.key, "CU-abc123");
2956 }
2957
2958 #[tokio::test]
2959 async fn test_update_issue_by_custom_id() {
2960 let server = MockServer::start();
2961
2962 server.mock(|when, then| {
2963 when.method(PUT)
2964 .path("/task/DEV-42")
2965 .query_param("custom_task_ids", "true")
2966 .query_param("team_id", "9876");
2967 then.status(200)
2968 .json_body(sample_task_with_custom_id_json());
2969 });
2970
2971 server.mock(|when, then| {
2972 when.method(GET)
2973 .path("/task/DEV-42")
2974 .query_param("custom_task_ids", "true")
2975 .query_param("team_id", "9876");
2976 then.status(200)
2977 .json_body(sample_task_with_custom_id_json());
2978 });
2979
2980 let client = create_test_client_with_team(&server);
2981 let issue = client
2982 .update_issue(
2983 "DEV-42",
2984 UpdateIssueInput {
2985 title: Some("Updated".to_string()),
2986 ..Default::default()
2987 },
2988 )
2989 .await
2990 .unwrap();
2991
2992 assert_eq!(issue.key, "DEV-42");
2993 }
2994
2995 #[tokio::test]
2996 async fn test_update_issue_state_mapping() {
2997 let server = MockServer::start();
2998
2999 server.mock(|when, then| {
3001 when.method(GET).path("/list/12345");
3002 then.status(200).json_body(serde_json::json!({
3003 "statuses": [
3004 {"status": "to do", "type": "open"},
3005 {"status": "in progress", "type": "custom"},
3006 {"status": "complete", "type": "closed"}
3007 ]
3008 }));
3009 });
3010
3011 server.mock(|when, then| {
3012 when.method(PUT)
3013 .path("/task/abc123")
3014 .body_includes("\"status\":\"complete\"");
3015 then.status(200).json_body(sample_task_json());
3016 });
3017
3018 server.mock(|when, then| {
3019 when.method(GET).path("/task/abc123");
3020 then.status(200).json_body(sample_task_json());
3021 });
3022
3023 let client = create_test_client(&server);
3024 let result = client
3025 .update_issue(
3026 "CU-abc123",
3027 UpdateIssueInput {
3028 state: Some("closed".to_string()),
3029 ..Default::default()
3030 },
3031 )
3032 .await;
3033
3034 assert!(result.is_ok());
3035 }
3036
3037 #[tokio::test]
3040 async fn test_update_issue_state_refetch_returns_fresh_state() {
3041 let server = MockServer::start();
3042
3043 server.mock(|when, then| {
3044 when.method(GET).path("/list/12345");
3045 then.status(200).json_body(serde_json::json!({
3046 "statuses": [
3047 {"status": "to do", "type": "open"},
3048 {"status": "complete", "type": "closed"}
3049 ]
3050 }));
3051 });
3052
3053 server.mock(|when, then| {
3055 when.method(PUT)
3056 .path("/task/abc123")
3057 .body_includes("\"status\":\"complete\"");
3058 then.status(200).json_body(sample_task_json()); });
3060
3061 server.mock(|when, then| {
3063 when.method(GET).path("/task/abc123");
3064 then.status(200).json_body(serde_json::json!({
3065 "id": "abc123",
3066 "name": "Test Task",
3067 "status": {
3068 "status": "complete",
3069 "type": "closed"
3070 },
3071 "tags": [{"name": "bug"}],
3072 "assignees": [{"id": 1, "username": "dev1"}],
3073 "url": "https://app.clickup.com/t/abc123",
3074 "date_created": "1704067200000",
3075 "date_updated": "1704153600000"
3076 }));
3077 });
3078
3079 let client = create_test_client(&server);
3080 let issue = client
3081 .update_issue(
3082 "CU-abc123",
3083 UpdateIssueInput {
3084 state: Some("closed".to_string()),
3085 ..Default::default()
3086 },
3087 )
3088 .await
3089 .unwrap();
3090
3091 assert_eq!(issue.state, "closed");
3092 }
3093
3094 #[tokio::test]
3095 async fn test_update_issue_state_open_mapping() {
3096 let server = MockServer::start();
3097
3098 server.mock(|when, then| {
3099 when.method(GET).path("/list/12345");
3100 then.status(200).json_body(serde_json::json!({
3101 "statuses": [
3102 {"status": "to do", "type": "open"},
3103 {"status": "complete", "type": "closed"}
3104 ]
3105 }));
3106 });
3107
3108 server.mock(|when, then| {
3109 when.method(PUT)
3110 .path("/task/abc123")
3111 .body_includes("\"status\":\"to do\"");
3112 then.status(200).json_body(sample_task_json());
3113 });
3114
3115 server.mock(|when, then| {
3116 when.method(GET).path("/task/abc123");
3117 then.status(200).json_body(sample_task_json());
3118 });
3119
3120 let client = create_test_client(&server);
3121 let result = client
3122 .update_issue(
3123 "CU-abc123",
3124 UpdateIssueInput {
3125 state: Some("open".to_string()),
3126 ..Default::default()
3127 },
3128 )
3129 .await;
3130
3131 assert!(result.is_ok());
3132 }
3133
3134 #[tokio::test]
3135 async fn test_update_issue_exact_status_name() {
3136 let server = MockServer::start();
3137
3138 server.mock(|when, then| {
3140 when.method(PUT)
3141 .path("/task/abc123")
3142 .body_includes("\"status\":\"in progress\"");
3143 then.status(200).json_body(sample_task_json());
3144 });
3145
3146 server.mock(|when, then| {
3147 when.method(GET).path("/task/abc123");
3148 then.status(200).json_body(sample_task_json());
3149 });
3150
3151 let client = create_test_client(&server);
3152 let result = client
3153 .update_issue(
3154 "CU-abc123",
3155 UpdateIssueInput {
3156 state: Some("in progress".to_string()),
3157 ..Default::default()
3158 },
3159 )
3160 .await;
3161
3162 assert!(result.is_ok());
3163 }
3164
3165 #[tokio::test]
3166 async fn test_get_comments() {
3167 let server = MockServer::start();
3168
3169 server.mock(|when, then| {
3170 when.method(GET).path("/task/abc123/comment");
3171 then.status(200).json_body(serde_json::json!({
3172 "comments": [{
3173 "id": "1",
3174 "comment_text": "Looks good!",
3175 "user": {"id": 1, "username": "reviewer"},
3176 "date": "1705312800000"
3177 }]
3178 }));
3179 });
3180
3181 let client = create_test_client(&server);
3182 let comments = client.get_comments("CU-abc123").await.unwrap().items;
3183
3184 assert_eq!(comments.len(), 1);
3185 assert_eq!(comments[0].body, "Looks good!");
3186 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
3187 assert_eq!(
3189 comments[0].created_at,
3190 Some("2024-01-15T10:00:00Z".to_string())
3191 );
3192 }
3193
3194 #[tokio::test]
3195 async fn test_add_comment() {
3196 let server = MockServer::start();
3197
3198 server.mock(|when, then| {
3200 when.method(POST)
3201 .path("/task/abc123/comment")
3202 .body_includes("\"comment_text\":\"My comment\"");
3203 then.status(200).json_body(serde_json::json!({
3204 "id": 458315,
3205 "hist_id": "26b2d7f1-test",
3206 "date": 1705312800000_i64
3207 }));
3208 });
3209
3210 let client = create_test_client(&server);
3211 let comment = IssueProvider::add_comment(&client, "CU-abc123", "My comment")
3212 .await
3213 .unwrap();
3214
3215 assert_eq!(comment.body, "My comment");
3216 assert_eq!(comment.id, "458315");
3217 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
3218 }
3219
3220 #[tokio::test]
3221 async fn test_handle_response_401() {
3222 let server = MockServer::start();
3223
3224 server.mock(|when, then| {
3225 when.method(GET).path("/list/12345/task");
3226 then.status(401).body("Token invalid");
3227 });
3228
3229 let client = create_test_client(&server);
3230 let result = client.get_issues(IssueFilter::default()).await;
3231
3232 assert!(result.is_err());
3233 let err = result.unwrap_err();
3234 assert!(matches!(err, Error::Unauthorized(_)));
3235 }
3236
3237 #[tokio::test]
3238 async fn test_handle_response_404() {
3239 let server = MockServer::start();
3240
3241 server.mock(|when, then| {
3242 when.method(GET).path("/task/nonexistent");
3243 then.status(404).body("Task not found");
3244 });
3245
3246 let client = create_test_client(&server);
3247 let result = client.get_issue("CU-nonexistent").await;
3248
3249 assert!(result.is_err());
3250 let err = result.unwrap_err();
3251 assert!(matches!(err, Error::NotFound(_)));
3252 }
3253
3254 #[tokio::test]
3255 async fn test_handle_response_500() {
3256 let server = MockServer::start();
3257
3258 server.mock(|when, then| {
3259 when.method(GET).path("/list/12345/task");
3260 then.status(500).body("Internal Server Error");
3261 });
3262
3263 let client = create_test_client(&server);
3264 let result = client.get_issues(IssueFilter::default()).await;
3265
3266 assert!(result.is_err());
3267 let err = result.unwrap_err();
3268 assert!(matches!(err, Error::ServerError { .. }));
3269 }
3270
3271 #[tokio::test]
3272 async fn test_mr_methods_unsupported() {
3273 let client = ClickUpClient::new("12345", token("token"));
3274
3275 let result = client.get_merge_requests(MrFilter::default()).await;
3276 assert!(matches!(
3277 result.unwrap_err(),
3278 Error::ProviderUnsupported { .. }
3279 ));
3280
3281 let result = client.get_merge_request("mr#1").await;
3282 assert!(matches!(
3283 result.unwrap_err(),
3284 Error::ProviderUnsupported { .. }
3285 ));
3286
3287 let result = client.get_discussions("mr#1").await;
3288 assert!(matches!(
3289 result.unwrap_err(),
3290 Error::ProviderUnsupported { .. }
3291 ));
3292
3293 let result = client.get_diffs("mr#1").await;
3294 assert!(matches!(
3295 result.unwrap_err(),
3296 Error::ProviderUnsupported { .. }
3297 ));
3298
3299 let result = MergeRequestProvider::add_comment(
3300 &client,
3301 "mr#1",
3302 CreateCommentInput {
3303 body: "test".to_string(),
3304 position: None,
3305 discussion_id: None,
3306 },
3307 )
3308 .await;
3309 assert!(matches!(
3310 result.unwrap_err(),
3311 Error::ProviderUnsupported { .. }
3312 ));
3313 }
3314
3315 #[tokio::test]
3316 async fn test_get_current_user() {
3317 let server = MockServer::start();
3318
3319 server.mock(|when, then| {
3320 when.method(GET).path("/list/12345/task");
3321 then.status(200).json_body(serde_json::json!({"tasks": []}));
3322 });
3323
3324 let client = create_test_client(&server);
3325 let user = client.get_current_user().await.unwrap();
3326
3327 assert_eq!(user.username, "clickup-user");
3328 }
3329
3330 #[tokio::test]
3331 async fn test_get_current_user_auth_failure() {
3332 let server = MockServer::start();
3333
3334 server.mock(|when, then| {
3335 when.method(GET).path("/list/12345/task");
3336 then.status(401).body("Unauthorized");
3337 });
3338
3339 let client = create_test_client(&server);
3340 let result = client.get_current_user().await;
3341
3342 assert!(result.is_err());
3343 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
3344 }
3345
3346 #[tokio::test]
3347 async fn test_get_issue_includes_subtasks() {
3348 let server = MockServer::start();
3349
3350 let task_with_subtasks = serde_json::json!({
3351 "id": "epic1",
3352 "custom_id": "DEV-400",
3353 "name": "Epic Task",
3354 "status": {"status": "open", "type": "open"},
3355 "tags": [{"name": "epic"}],
3356 "assignees": [],
3357 "creator": {"id": 1, "username": "author"},
3358 "url": "https://app.clickup.com/t/epic1",
3359 "date_created": "1704067200000",
3360 "date_updated": "1704153600000",
3361 "subtasks": [
3362 {
3363 "id": "sub1",
3364 "custom_id": "DEV-401",
3365 "name": "Subtask 1",
3366 "status": {"status": "open", "type": "open"},
3367 "tags": [],
3368 "assignees": [],
3369 "url": "https://app.clickup.com/t/sub1",
3370 "parent": "epic1"
3371 },
3372 {
3373 "id": "sub2",
3374 "custom_id": "DEV-402",
3375 "name": "Subtask 2",
3376 "status": {"status": "closed", "type": "closed"},
3377 "tags": [],
3378 "assignees": [],
3379 "url": "https://app.clickup.com/t/sub2",
3380 "parent": "epic1"
3381 }
3382 ]
3383 });
3384
3385 server.mock(|when, then| {
3386 when.method(GET)
3387 .path("/task/epic1")
3388 .query_param("include_subtasks", "true");
3389 then.status(200).json_body(task_with_subtasks);
3390 });
3391
3392 let client = create_test_client(&server);
3393 let issue = client.get_issue("CU-epic1").await.unwrap();
3394
3395 assert_eq!(issue.key, "DEV-400");
3396 assert!(issue.parent.is_none());
3397 assert_eq!(issue.subtasks.len(), 2);
3398 assert_eq!(issue.subtasks[0].key, "DEV-401");
3399 assert_eq!(issue.subtasks[0].title, "Subtask 1");
3400 assert_eq!(issue.subtasks[0].state, "open");
3401 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
3402 assert_eq!(issue.subtasks[1].key, "DEV-402");
3403 assert_eq!(issue.subtasks[1].state, "closed");
3404 }
3405
3406 #[tokio::test]
3407 async fn test_get_issue_no_subtasks() {
3408 let server = MockServer::start();
3409
3410 let task = sample_task_json();
3411
3412 server.mock(|when, then| {
3413 when.method(GET)
3414 .path("/task/abc123")
3415 .query_param("include_subtasks", "true");
3416 then.status(200).json_body(task);
3417 });
3418
3419 let client = create_test_client(&server);
3420 let issue = client.get_issue("CU-abc123").await.unwrap();
3421
3422 assert!(issue.subtasks.is_empty());
3423 assert!(issue.parent.is_none());
3424 }
3425
3426 #[tokio::test]
3427 async fn test_get_issue_custom_id_includes_subtasks() {
3428 let server = MockServer::start();
3429
3430 let task = serde_json::json!({
3431 "id": "task1",
3432 "custom_id": "DEV-500",
3433 "name": "Task via custom ID",
3434 "status": {"status": "open", "type": "open"},
3435 "tags": [],
3436 "assignees": [],
3437 "url": "https://app.clickup.com/t/task1",
3438 "parent": "parent123",
3439 "subtasks": []
3440 });
3441
3442 server.mock(|when, then| {
3443 when.method(GET)
3444 .path("/task/DEV-500")
3445 .query_param("custom_task_ids", "true")
3446 .query_param("team_id", "9876")
3447 .query_param("include_subtasks", "true");
3448 then.status(200).json_body(task);
3449 });
3450
3451 let client = create_test_client_with_team(&server);
3452 let issue = client.get_issue("DEV-500").await.unwrap();
3453
3454 assert_eq!(issue.key, "DEV-500");
3455 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
3456 assert!(issue.subtasks.is_empty());
3457 }
3458
3459 #[tokio::test]
3460 async fn test_update_issue_with_parent_id() {
3461 let server = MockServer::start();
3462
3463 let parent_task = serde_json::json!({
3465 "id": "parent_native_id",
3466 "custom_id": "DEV-600",
3467 "name": "Parent Epic",
3468 "status": {"status": "open", "type": "open"},
3469 "tags": [],
3470 "assignees": [],
3471 "url": "https://app.clickup.com/t/parent_native_id"
3472 });
3473
3474 server.mock(|when, then| {
3475 when.method(GET)
3476 .path("/task/DEV-600")
3477 .query_param("custom_task_ids", "true")
3478 .query_param("team_id", "9876");
3479 then.status(200).json_body(parent_task);
3480 });
3481
3482 let updated_task = serde_json::json!({
3484 "id": "child1",
3485 "custom_id": "DEV-601",
3486 "name": "Child Task",
3487 "status": {"status": "open", "type": "open"},
3488 "tags": [],
3489 "assignees": [],
3490 "url": "https://app.clickup.com/t/child1",
3491 "parent": "parent_native_id"
3492 });
3493
3494 server.mock(|when, then| {
3495 when.method(PUT)
3496 .path("/task/DEV-601")
3497 .query_param("custom_task_ids", "true")
3498 .query_param("team_id", "9876")
3499 .body_includes("\"parent\":\"parent_native_id\"");
3500 then.status(200).json_body(updated_task.clone());
3501 });
3502
3503 server.mock(|when, then| {
3504 when.method(GET)
3505 .path("/task/DEV-601")
3506 .query_param("custom_task_ids", "true")
3507 .query_param("team_id", "9876");
3508 then.status(200).json_body(updated_task);
3509 });
3510
3511 let client = create_test_client_with_team(&server);
3512 let issue = client
3513 .update_issue(
3514 "DEV-601",
3515 UpdateIssueInput {
3516 parent_id: Some("DEV-600".to_string()),
3517 ..Default::default()
3518 },
3519 )
3520 .await
3521 .unwrap();
3522
3523 assert_eq!(issue.key, "DEV-601");
3524 assert_eq!(issue.parent, Some("CU-parent_native_id".to_string()));
3525 }
3526
3527 #[tokio::test]
3528 async fn test_create_issue_with_parent() {
3529 let server = MockServer::start();
3530
3531 let parent_task = serde_json::json!({
3533 "id": "parent_id",
3534 "custom_id": "DEV-700",
3535 "name": "Parent",
3536 "status": {"status": "open", "type": "open"},
3537 "tags": [],
3538 "assignees": [],
3539 "url": "https://app.clickup.com/t/parent_id"
3540 });
3541
3542 server.mock(|when, then| {
3543 when.method(GET)
3544 .path("/task/DEV-700")
3545 .query_param("custom_task_ids", "true")
3546 .query_param("team_id", "9876");
3547 then.status(200).json_body(parent_task);
3548 });
3549
3550 let created_task = serde_json::json!({
3552 "id": "new_child",
3553 "custom_id": "DEV-701",
3554 "name": "New Subtask",
3555 "status": {"status": "open", "type": "open"},
3556 "tags": [],
3557 "assignees": [],
3558 "url": "https://app.clickup.com/t/new_child",
3559 "parent": "parent_id"
3560 });
3561
3562 server.mock(|when, then| {
3563 when.method(POST)
3564 .path("/list/12345/task")
3565 .body_includes("\"parent\":\"parent_id\"");
3566 then.status(200).json_body(created_task);
3567 });
3568
3569 let client = create_test_client_with_team(&server);
3570 let issue = client
3571 .create_issue(CreateIssueInput {
3572 title: "New Subtask".to_string(),
3573 parent: Some("DEV-700".to_string()),
3574 ..Default::default()
3575 })
3576 .await
3577 .unwrap();
3578
3579 assert_eq!(issue.key, "DEV-701");
3580 assert_eq!(issue.parent, Some("CU-parent_id".to_string()));
3581 }
3582
3583 #[tokio::test]
3584 async fn test_get_issues_search_filter() {
3585 let server = MockServer::start_async().await;
3586
3587 server.mock(|when, then| {
3588 when.method(GET).path("/list/12345/task");
3589 then.status(200).json_body(serde_json::json!({
3590 "tasks": [
3591 {
3592 "id": "1", "name": "Fix login bug",
3593 "description": "Authentication fails",
3594 "text_content": "Authentication fails",
3595 "status": {"status": "open", "type": "open"},
3596 "tags": [], "assignees": [],
3597 "url": "https://app.clickup.com/t/1"
3598 },
3599 {
3600 "id": "2", "name": "Add dark mode",
3601 "description": "Theme support",
3602 "text_content": "Theme support",
3603 "status": {"status": "open", "type": "open"},
3604 "tags": [], "assignees": [],
3605 "url": "https://app.clickup.com/t/2"
3606 },
3607 {
3608 "id": "3", "name": "Update docs",
3609 "description": "Fix login instructions",
3610 "text_content": "Fix login instructions",
3611 "status": {"status": "open", "type": "open"},
3612 "tags": [], "assignees": [],
3613 "url": "https://app.clickup.com/t/3"
3614 }
3615 ]
3616 }));
3617 });
3618
3619 let client = create_test_client(&server);
3620
3621 let issues = client
3623 .get_issues(IssueFilter {
3624 search: Some("login".to_string()),
3625 ..Default::default()
3626 })
3627 .await
3628 .unwrap()
3629 .items;
3630 assert_eq!(issues.len(), 2);
3631 assert!(issues.iter().any(|i| i.title == "Fix login bug"));
3632 assert!(issues.iter().any(|i| i.title == "Update docs")); let issues = client
3636 .get_issues(IssueFilter {
3637 search: Some("CU-2".to_string()),
3638 ..Default::default()
3639 })
3640 .await
3641 .unwrap()
3642 .items;
3643 assert_eq!(issues.len(), 1);
3644 assert_eq!(issues[0].title, "Add dark mode");
3645
3646 let issues = client
3648 .get_issues(IssueFilter {
3649 search: Some("nonexistent".to_string()),
3650 ..Default::default()
3651 })
3652 .await
3653 .unwrap()
3654 .items;
3655 assert!(issues.is_empty());
3656 }
3657
3658 #[tokio::test]
3659 async fn test_get_issues_sort_by_priority() {
3660 let server = MockServer::start_async().await;
3661
3662 server.mock(|when, then| {
3663 when.method(GET).path("/list/12345/task");
3664 then.status(200).json_body(serde_json::json!({
3665 "tasks": [
3666 {
3667 "id": "1", "name": "Low task",
3668 "status": {"status": "open", "type": "open"},
3669 "priority": {"id": "4", "priority": "low"},
3670 "tags": [], "assignees": [],
3671 "url": "https://app.clickup.com/t/1"
3672 },
3673 {
3674 "id": "2", "name": "Urgent task",
3675 "status": {"status": "open", "type": "open"},
3676 "priority": {"id": "1", "priority": "urgent"},
3677 "tags": [], "assignees": [],
3678 "url": "https://app.clickup.com/t/2"
3679 },
3680 {
3681 "id": "3", "name": "Normal task",
3682 "status": {"status": "open", "type": "open"},
3683 "priority": {"id": "3", "priority": "normal"},
3684 "tags": [], "assignees": [],
3685 "url": "https://app.clickup.com/t/3"
3686 }
3687 ]
3688 }));
3689 });
3690
3691 let client = create_test_client(&server);
3692
3693 let result = client
3695 .get_issues(IssueFilter {
3696 sort_by: Some("priority".to_string()),
3697 sort_order: Some("asc".to_string()),
3698 ..Default::default()
3699 })
3700 .await
3701 .unwrap();
3702 assert_eq!(result.items[0].priority, Some("urgent".to_string()));
3703 assert_eq!(result.items[1].priority, Some("normal".to_string()));
3704 assert_eq!(result.items[2].priority, Some("low".to_string()));
3705
3706 let sort_info = result.sort_info.unwrap();
3708 assert_eq!(sort_info.sort_by, Some("priority".to_string()));
3709 assert!(sort_info.available_sorts.contains(&"priority".into()));
3710 }
3711
3712 #[tokio::test]
3713 async fn test_get_issues_sort_by_title() {
3714 let server = MockServer::start_async().await;
3715
3716 server.mock(|when, then| {
3717 when.method(GET).path("/list/12345/task");
3718 then.status(200).json_body(serde_json::json!({
3719 "tasks": [
3720 {
3721 "id": "1", "name": "Charlie",
3722 "status": {"status": "open", "type": "open"},
3723 "tags": [], "assignees": [],
3724 "url": "https://app.clickup.com/t/1"
3725 },
3726 {
3727 "id": "2", "name": "Alpha",
3728 "status": {"status": "open", "type": "open"},
3729 "tags": [], "assignees": [],
3730 "url": "https://app.clickup.com/t/2"
3731 },
3732 {
3733 "id": "3", "name": "Bravo",
3734 "status": {"status": "open", "type": "open"},
3735 "tags": [], "assignees": [],
3736 "url": "https://app.clickup.com/t/3"
3737 }
3738 ]
3739 }));
3740 });
3741
3742 let client = create_test_client(&server);
3743
3744 let result = client
3745 .get_issues(IssueFilter {
3746 sort_by: Some("title".to_string()),
3747 sort_order: Some("asc".to_string()),
3748 ..Default::default()
3749 })
3750 .await
3751 .unwrap();
3752 assert_eq!(result.items[0].title, "Alpha");
3753 assert_eq!(result.items[1].title, "Bravo");
3754 assert_eq!(result.items[2].title, "Charlie");
3755 }
3756
3757 #[tokio::test]
3758 async fn test_get_statuses_category_mapping() {
3759 let server = MockServer::start_async().await;
3760
3761 server.mock(|when, then| {
3762 when.method(GET).path("/list/12345");
3763 then.status(200).json_body(serde_json::json!({
3764 "statuses": [
3765 {"status": "Backlog", "type": "custom", "color": "#aaa", "orderindex": 0},
3766 {"status": "To Do", "type": "open", "color": "#bbb", "orderindex": 1},
3767 {"status": "In Progress", "type": "custom", "color": "#ccc", "orderindex": 2},
3768 {"status": "In Review", "type": "custom", "color": "#ddd", "orderindex": 3},
3769 {"status": "Done", "type": "closed", "color": "#eee", "orderindex": 4},
3770 {"status": "Cancelled", "type": "custom", "color": "#fff", "orderindex": 5},
3771 {"status": "Archived", "type": "custom", "color": "#000", "orderindex": 6}
3772 ]
3773 }));
3774 });
3775
3776 let client = create_test_client(&server);
3777 let statuses = client.get_statuses().await.unwrap().items;
3778
3779 assert_eq!(statuses.len(), 7);
3780 assert_eq!(statuses[0].name, "Backlog");
3781 assert_eq!(statuses[0].category, "backlog");
3782 assert_eq!(statuses[1].name, "To Do");
3783 assert_eq!(statuses[1].category, "todo");
3784 assert_eq!(statuses[2].name, "In Progress");
3785 assert_eq!(statuses[2].category, "in_progress");
3786 assert_eq!(statuses[3].name, "In Review");
3787 assert_eq!(statuses[3].category, "in_progress");
3788 assert_eq!(statuses[4].name, "Done");
3789 assert_eq!(statuses[4].category, "done");
3790 assert_eq!(statuses[5].name, "Cancelled");
3791 assert_eq!(statuses[5].category, "cancelled");
3792 assert_eq!(statuses[6].name, "Archived");
3793 assert_eq!(statuses[6].category, "cancelled");
3794 }
3795
3796 #[tokio::test]
3797 async fn test_get_issues_state_category_filter() {
3798 let server = MockServer::start_async().await;
3799
3800 server.mock(|when, then| {
3802 when.method(GET).path("/list/12345").query_param_exists("!");
3803 then.status(200).json_body(serde_json::json!({
3804 "statuses": [
3805 {"status": "Backlog", "type": "custom"},
3806 {"status": "To Do", "type": "open"},
3807 {"status": "In Progress", "type": "custom"},
3808 {"status": "Done", "type": "closed"}
3809 ]
3810 }));
3811 });
3812
3813 server.mock(|when, then| {
3815 when.method(GET).path("/list/12345");
3816 then.status(200).json_body(serde_json::json!({
3817 "statuses": [
3818 {"status": "Backlog", "type": "custom"},
3819 {"status": "To Do", "type": "open"},
3820 {"status": "In Progress", "type": "custom"},
3821 {"status": "Done", "type": "closed"}
3822 ]
3823 }));
3824 });
3825
3826 server.mock(|when, then| {
3827 when.method(GET).path("/list/12345/task");
3828 then.status(200).json_body(serde_json::json!({
3829 "tasks": [
3830 {
3831 "id": "1", "name": "Backlog task",
3832 "status": {"status": "Backlog", "type": "custom"},
3833 "tags": [], "assignees": [],
3834 "url": "https://app.clickup.com/t/1"
3835 },
3836 {
3837 "id": "2", "name": "In progress task",
3838 "status": {"status": "In Progress", "type": "custom"},
3839 "tags": [], "assignees": [],
3840 "url": "https://app.clickup.com/t/2"
3841 },
3842 {
3843 "id": "3", "name": "Todo task",
3844 "status": {"status": "To Do", "type": "open"},
3845 "tags": [], "assignees": [],
3846 "url": "https://app.clickup.com/t/3"
3847 }
3848 ]
3849 }));
3850 });
3851
3852 let client = create_test_client(&server);
3853
3854 let issues = client
3856 .get_issues(IssueFilter {
3857 state_category: Some("in_progress".to_string()),
3858 ..Default::default()
3859 })
3860 .await
3861 .unwrap()
3862 .items;
3863 assert_eq!(issues.len(), 1);
3864 assert_eq!(issues[0].title, "In progress task");
3865
3866 let issues = client
3868 .get_issues(IssueFilter {
3869 state_category: Some("backlog".to_string()),
3870 ..Default::default()
3871 })
3872 .await
3873 .unwrap()
3874 .items;
3875 assert_eq!(issues.len(), 1);
3876 assert_eq!(issues[0].title, "Backlog task");
3877 }
3878
3879 #[tokio::test]
3880 async fn test_get_issue_attachments_maps_all_fields() {
3881 let server = MockServer::start();
3882
3883 let task_json = serde_json::json!({
3884 "id": "abc123",
3885 "name": "Test",
3886 "status": {"status": "open", "type": "open"},
3887 "tags": [], "assignees": [],
3888 "url": "https://app.clickup.com/t/abc123",
3889 "date_created": "1704067200000",
3890 "date_updated": "1704067200000",
3891 "attachments": [
3892 {
3893 "id": "att-1",
3894 "title": "screen.png",
3895 "url": "https://attachments.clickup.com/abc/screen.png",
3896 "size": "12345",
3897 "extension": "png",
3898 "mimetype": "image/png",
3899 "date": "1704067200000",
3900 "user": {"id": 7, "username": "uploader"}
3901 }
3902 ]
3903 });
3904
3905 server.mock(|when, then| {
3906 when.method(GET).path("/task/abc123");
3907 then.status(200).json_body(task_json);
3908 });
3909
3910 let client = create_test_client(&server);
3911 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3912 assert_eq!(assets.len(), 1);
3913 let a = &assets[0];
3914 assert_eq!(a.id, "att-1");
3915 assert_eq!(a.filename, "screen.png");
3916 assert_eq!(a.mime_type.as_deref(), Some("image/png"));
3917 assert_eq!(a.size, Some(12345));
3918 assert_eq!(a.author.as_deref(), Some("uploader"));
3919 }
3920
3921 #[tokio::test]
3922 async fn test_get_issue_attachments_empty_when_none() {
3923 let server = MockServer::start();
3924
3925 let task_json = serde_json::json!({
3926 "id": "abc123",
3927 "name": "Test",
3928 "status": {"status": "open", "type": "open"},
3929 "tags": [], "assignees": [],
3930 "url": "https://app.clickup.com/t/abc123",
3931 "date_created": "1704067200000",
3932 "date_updated": "1704067200000"
3933 });
3934
3935 server.mock(|when, then| {
3936 when.method(GET).path("/task/abc123");
3937 then.status(200).json_body(task_json);
3938 });
3939
3940 let client = create_test_client(&server);
3941 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3942 assert!(assets.is_empty());
3943 }
3944
3945 #[tokio::test]
3946 async fn test_download_attachment_fetches_bytes() {
3947 let server = MockServer::start();
3948
3949 let task_json = serde_json::json!({
3950 "id": "abc123",
3951 "name": "Test",
3952 "status": {"status": "open", "type": "open"},
3953 "tags": [], "assignees": [],
3954 "url": "https://app.clickup.com/t/abc123",
3955 "date_created": "1704067200000",
3956 "date_updated": "1704067200000",
3957 "attachments": [
3958 {
3959 "id": "att-1",
3960 "title": "log.txt",
3961 "url": format!("{}/download/att-1", server.base_url()),
3962 }
3963 ]
3964 });
3965
3966 server.mock(|when, then| {
3967 when.method(GET).path("/task/abc123");
3968 then.status(200).json_body(task_json);
3969 });
3970 server.mock(|when, then| {
3971 when.method(GET).path("/download/att-1");
3972 then.status(200).body("hello world");
3973 });
3974
3975 let client = create_test_client(&server);
3976 let bytes = client
3977 .download_attachment("CU-abc123", "att-1")
3978 .await
3979 .unwrap();
3980 assert_eq!(bytes, b"hello world");
3981 }
3982
3983 #[tokio::test]
3984 async fn test_download_attachment_not_found() {
3985 let server = MockServer::start();
3986
3987 server.mock(|when, then| {
3988 when.method(GET).path("/task/abc123");
3989 then.status(200).json_body(serde_json::json!({
3990 "id": "abc123", "name": "Test",
3991 "status": {"status": "open", "type": "open"},
3992 "tags": [], "assignees": [],
3993 "url": "https://app.clickup.com/t/abc123",
3994 "date_created": "1704067200000",
3995 "date_updated": "1704067200000",
3996 "attachments": []
3997 }));
3998 });
3999
4000 let client = create_test_client(&server);
4001 let err = client
4002 .download_attachment("CU-abc123", "missing")
4003 .await
4004 .unwrap_err();
4005 assert!(matches!(err, Error::NotFound(_)));
4006 }
4007
4008 fn list_with_statuses() -> serde_json::Value {
4019 serde_json::json!({
4020 "statuses": [
4021 {"status": "to do", "type": "open"},
4022 {"status": "in progress", "type": "custom"},
4023 {"status": "review", "type": "custom"},
4024 {"status": "complete", "type": "closed"}
4025 ]
4026 })
4027 }
4028
4029 #[tokio::test]
4030 async fn test_update_issue_status_sets_custom_status() {
4031 let server = MockServer::start();
4032
4033 server.mock(|when, then| {
4034 when.method(GET).path("/list/12345");
4035 then.status(200).json_body(list_with_statuses());
4036 });
4037
4038 server.mock(|when, then| {
4039 when.method(PUT)
4040 .path("/task/abc123")
4041 .body_includes("\"status\":\"in progress\"");
4042 then.status(200).json_body(sample_task_json());
4043 });
4044
4045 server.mock(|when, then| {
4046 when.method(GET).path("/task/abc123");
4047 then.status(200).json_body(sample_task_json());
4048 });
4049
4050 let client = create_test_client(&server);
4051 let result = client
4052 .update_issue(
4053 "CU-abc123",
4054 UpdateIssueInput {
4055 status: Some("in progress".to_string()),
4056 ..Default::default()
4057 },
4058 )
4059 .await;
4060 assert!(result.is_ok(), "got {:?}", result.err());
4061 }
4062
4063 #[tokio::test]
4064 async fn test_update_issue_status_case_insensitive_match() {
4065 let server = MockServer::start();
4066
4067 server.mock(|when, then| {
4068 when.method(GET).path("/list/12345");
4069 then.status(200).json_body(list_with_statuses());
4070 });
4071
4072 server.mock(|when, then| {
4074 when.method(PUT)
4075 .path("/task/abc123")
4076 .body_includes("\"status\":\"review\"");
4077 then.status(200).json_body(sample_task_json());
4078 });
4079
4080 server.mock(|when, then| {
4081 when.method(GET).path("/task/abc123");
4082 then.status(200).json_body(sample_task_json());
4083 });
4084
4085 let client = create_test_client(&server);
4086 let result = client
4087 .update_issue(
4088 "CU-abc123",
4089 UpdateIssueInput {
4090 status: Some("REVIEW".to_string()),
4091 ..Default::default()
4092 },
4093 )
4094 .await;
4095 assert!(result.is_ok(), "got {:?}", result.err());
4096 }
4097
4098 #[tokio::test]
4099 async fn test_update_issue_status_unknown_fails_with_valid_list() {
4100 let server = MockServer::start();
4101
4102 server.mock(|when, then| {
4103 when.method(GET).path("/list/12345");
4104 then.status(200).json_body(list_with_statuses());
4105 });
4106
4107 let put_mock = server.mock(|when, then| {
4108 when.method(PUT).path("/task/abc123");
4109 then.status(200).json_body(sample_task_json());
4110 });
4111
4112 let client = create_test_client(&server);
4113 let err = client
4114 .update_issue(
4115 "CU-abc123",
4116 UpdateIssueInput {
4117 status: Some("released-to-prod".to_string()),
4118 ..Default::default()
4119 },
4120 )
4121 .await
4122 .expect_err("unknown status must fail before PUT");
4123 let msg = format!("{err:?}");
4124 assert!(msg.contains("Unknown ClickUp status"), "msg: {msg}");
4125 assert!(msg.contains("released-to-prod"), "msg: {msg}");
4126 assert!(msg.contains("in progress"), "list of valids missing: {msg}");
4127 put_mock.assert_calls(0);
4128 }
4129
4130 #[tokio::test]
4131 async fn test_update_issue_status_overrides_state_when_both_set() {
4132 let server = MockServer::start();
4133
4134 let list_mock = server.mock(|when, then| {
4138 when.method(GET).path("/list/12345");
4139 then.status(200).json_body(list_with_statuses());
4140 });
4141
4142 server.mock(|when, then| {
4143 when.method(PUT)
4144 .path("/task/abc123")
4145 .body_includes("\"status\":\"in progress\"");
4148 then.status(200).json_body(sample_task_json());
4149 });
4150
4151 server.mock(|when, then| {
4152 when.method(GET).path("/task/abc123");
4153 then.status(200).json_body(sample_task_json());
4154 });
4155
4156 let client = create_test_client(&server);
4157 let result = client
4158 .update_issue(
4159 "CU-abc123",
4160 UpdateIssueInput {
4161 state: Some("closed".to_string()),
4162 status: Some("in progress".to_string()),
4163 ..Default::default()
4164 },
4165 )
4166 .await;
4167 assert!(result.is_ok(), "got {:?}", result.err());
4168 list_mock.assert_calls(1);
4169 }
4170
4171 #[tokio::test]
4172 async fn test_update_issue_state_path_unchanged_when_status_absent() {
4173 let server = MockServer::start();
4177
4178 server.mock(|when, then| {
4179 when.method(GET).path("/list/12345");
4180 then.status(200).json_body(list_with_statuses());
4181 });
4182
4183 server.mock(|when, then| {
4184 when.method(PUT)
4185 .path("/task/abc123")
4186 .body_includes("\"status\":\"complete\"");
4187 then.status(200).json_body(sample_task_json());
4188 });
4189
4190 server.mock(|when, then| {
4191 when.method(GET).path("/task/abc123");
4192 then.status(200).json_body(sample_task_json());
4193 });
4194
4195 let client = create_test_client(&server);
4196 let result = client
4197 .update_issue(
4198 "CU-abc123",
4199 UpdateIssueInput {
4200 state: Some("closed".to_string()),
4201 ..Default::default()
4202 },
4203 )
4204 .await;
4205 assert!(result.is_ok(), "got {:?}", result.err());
4206 }
4207
4208 #[tokio::test]
4209 async fn test_update_issue_status_open_keyword_hints_at_state_field() {
4210 let server = MockServer::start();
4215
4216 server.mock(|when, then| {
4217 when.method(GET).path("/list/12345");
4218 then.status(200).json_body(list_with_statuses());
4219 });
4220
4221 let put_mock = server.mock(|when, then| {
4222 when.method(PUT).path("/task/abc123");
4223 then.status(200).json_body(sample_task_json());
4224 });
4225
4226 let client = create_test_client(&server);
4227 for keyword in ["open", "Opened", "CLOSED"] {
4228 let err = client
4229 .update_issue(
4230 "CU-abc123",
4231 UpdateIssueInput {
4232 status: Some(keyword.to_string()),
4233 ..Default::default()
4234 },
4235 )
4236 .await
4237 .expect_err("open/closed via `status` must fail");
4238 let msg = format!("{err:?}");
4239 assert!(msg.contains("state` field"), "keyword={keyword} msg={msg}");
4240 }
4241 put_mock.assert_calls(0);
4242 }
4243
4244 #[tokio::test]
4245 async fn test_update_issue_status_unknown_truncates_large_status_list() {
4246 let server = MockServer::start();
4250
4251 let many_statuses: Vec<serde_json::Value> = (0..15)
4252 .map(|i| serde_json::json!({"status": format!("status-{i:02}"), "type": "custom"}))
4253 .collect();
4254
4255 server.mock(|when, then| {
4256 when.method(GET).path("/list/12345");
4257 then.status(200)
4258 .json_body(serde_json::json!({"statuses": many_statuses}));
4259 });
4260
4261 let client = create_test_client(&server);
4262 let err = client
4263 .update_issue(
4264 "CU-abc123",
4265 UpdateIssueInput {
4266 status: Some("nonexistent".to_string()),
4267 ..Default::default()
4268 },
4269 )
4270 .await
4271 .expect_err("must fail");
4272 let msg = format!("{err:?}");
4273 assert!(msg.contains("status-00"), "preview missing first: {msg}");
4274 assert!(msg.contains("status-09"), "preview missing 10th: {msg}");
4275 assert!(
4276 !msg.contains("status-10"),
4277 "11th status leaked past truncation: {msg}"
4278 );
4279 assert!(
4280 msg.contains("…and 5 more"),
4281 "truncation suffix missing: {msg}"
4282 );
4283 }
4284
4285 fn team_payload(members: serde_json::Value) -> serde_json::Value {
4295 serde_json::json!({
4296 "teams": [
4297 {
4298 "id": "9876",
4299 "members": members,
4300 }
4301 ]
4302 })
4303 }
4304
4305 #[tokio::test]
4306 async fn test_update_issue_assignees_resolves_email_and_sends_diff() {
4307 let server = MockServer::start();
4308
4309 server.mock(|when, then| {
4311 when.method(GET).path("/team");
4312 then.status(200).json_body(team_payload(serde_json::json!([
4313 {"user": {"id": 94519669, "username": "m.kitaev",
4314 "email": "m.kitaev@meteora.pro"}},
4315 {"user": {"id": 11111, "username": "other",
4316 "email": "other@meteora.pro"}}
4317 ])));
4318 });
4319
4320 server.mock(|when, then| {
4322 when.method(GET).path("/task/abc123");
4323 then.status(200).json_body(sample_task_no_assignees_json());
4324 });
4325
4326 server.mock(|when, then| {
4328 when.method(PUT)
4329 .path("/task/abc123")
4330 .body_includes("\"assignees\":{\"add\":[94519669]")
4331 .body_excludes("\"rem\":");
4332 then.status(200).json_body(sample_task_no_assignees_json());
4333 });
4334
4335 let client = create_test_client_with_team(&server);
4336 let result = client
4337 .update_issue(
4338 "CU-abc123",
4339 UpdateIssueInput {
4340 assignees: Some(vec!["m.kitaev@meteora.pro".to_string()]),
4341 ..Default::default()
4342 },
4343 )
4344 .await;
4345 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4346 }
4347
4348 #[tokio::test]
4349 async fn test_update_issue_assignees_accepts_numeric_id_without_lookup() {
4350 let server = MockServer::start();
4351
4352 server.mock(|when, then| {
4354 when.method(GET).path("/task/abc123");
4355 then.status(200).json_body(sample_task_no_assignees_json());
4356 });
4357
4358 server.mock(|when, then| {
4359 when.method(PUT)
4360 .path("/task/abc123")
4361 .body_includes("\"assignees\":{\"add\":[42]");
4362 then.status(200).json_body(sample_task_no_assignees_json());
4363 });
4364
4365 let client = create_test_client(&server);
4366 let result = client
4367 .update_issue(
4368 "CU-abc123",
4369 UpdateIssueInput {
4370 assignees: Some(vec!["42".to_string()]),
4371 ..Default::default()
4372 },
4373 )
4374 .await;
4375 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4376 }
4377
4378 #[tokio::test]
4379 async fn test_update_issue_assignees_diff_add_and_rem() {
4380 let server = MockServer::start();
4381
4382 server.mock(|when, then| {
4384 when.method(GET).path("/task/abc123");
4385 then.status(200).json_body(serde_json::json!({
4386 "id": "abc123", "name": "T",
4387 "status": {"status": "open", "type": "open"},
4388 "tags": [],
4389 "assignees": [
4390 {"id": 1, "username": "u1"},
4391 {"id": 2, "username": "u2"}
4392 ],
4393 "url": "https://app.clickup.com/t/abc123",
4394 "date_created": "1704067200000",
4395 "date_updated": "1704067200000"
4396 }));
4397 });
4398
4399 server.mock(|when, then| {
4400 when.method(PUT)
4401 .path("/task/abc123")
4402 .body_includes("\"add\":[3]")
4403 .body_includes("\"rem\":[1]");
4404 then.status(200).json_body(sample_task_no_assignees_json());
4405 });
4406
4407 let client = create_test_client(&server);
4408 let result = client
4409 .update_issue(
4410 "CU-abc123",
4411 UpdateIssueInput {
4412 assignees: Some(vec!["2".to_string(), "3".to_string()]),
4413 ..Default::default()
4414 },
4415 )
4416 .await;
4417 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4418 }
4419
4420 #[tokio::test]
4421 async fn test_update_issue_assignees_empty_input_clears_all() {
4422 let server = MockServer::start();
4423
4424 server.mock(|when, then| {
4426 when.method(GET).path("/task/abc123");
4427 then.status(200).json_body(serde_json::json!({
4428 "id": "abc123", "name": "T",
4429 "status": {"status": "open", "type": "open"},
4430 "tags": [],
4431 "assignees": [
4432 {"id": 1, "username": "u1"},
4433 {"id": 2, "username": "u2"}
4434 ],
4435 "url": "https://app.clickup.com/t/abc123",
4436 "date_created": "1704067200000",
4437 "date_updated": "1704067200000"
4438 }));
4439 });
4440
4441 server.mock(|when, then| {
4442 when.method(PUT)
4443 .path("/task/abc123")
4444 .body_includes("\"rem\":[1,2]")
4445 .body_excludes("\"add\":");
4446 then.status(200).json_body(sample_task_no_assignees_json());
4447 });
4448
4449 let client = create_test_client(&server);
4450 let result = client
4451 .update_issue(
4452 "CU-abc123",
4453 UpdateIssueInput {
4454 assignees: Some(vec![]),
4455 ..Default::default()
4456 },
4457 )
4458 .await;
4459 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4460 }
4461
4462 #[tokio::test]
4463 async fn test_update_issue_assignees_none_leaves_field_untouched() {
4464 let server = MockServer::start();
4465
4466 server.mock(|when, then| {
4469 when.method(PUT)
4470 .path("/task/abc123")
4471 .body_excludes("\"assignees\"");
4472 then.status(200).json_body(sample_task_no_assignees_json());
4473 });
4474
4475 server.mock(|when, then| {
4476 when.method(GET).path("/task/abc123");
4477 then.status(200).json_body(sample_task_no_assignees_json());
4478 });
4479
4480 let client = create_test_client(&server);
4481 let result = client
4482 .update_issue(
4483 "CU-abc123",
4484 UpdateIssueInput {
4485 title: Some("renamed".to_string()),
4486 ..Default::default()
4488 },
4489 )
4490 .await;
4491 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4492 }
4493
4494 #[tokio::test]
4495 async fn test_update_issue_assignees_unknown_email_fails_clearly() {
4496 let server = MockServer::start();
4497
4498 server.mock(|when, then| {
4499 when.method(GET).path("/team");
4500 then.status(200).json_body(team_payload(serde_json::json!([
4501 {"user": {"id": 1, "username": "u1", "email": "u1@x.com"}}
4502 ])));
4503 });
4504
4505 let put_mock = server.mock(|when, then| {
4507 when.method(PUT).path("/task/abc123");
4508 then.status(200).json_body(sample_task_no_assignees_json());
4509 });
4510
4511 let client = create_test_client(&server);
4512 let err = client
4513 .update_issue(
4514 "CU-abc123",
4515 UpdateIssueInput {
4516 assignees: Some(vec!["nobody@nowhere.com".to_string()]),
4517 ..Default::default()
4518 },
4519 )
4520 .await
4521 .expect_err("unresolvable email must fail");
4522 assert!(
4523 format!("{err:?}").contains("Cannot resolve assignee"),
4524 "unexpected error: {err:?}"
4525 );
4526 put_mock.assert_calls(0);
4527 }
4528
4529 #[tokio::test]
4530 async fn test_update_issue_assignees_no_change_omits_field() {
4531 let server = MockServer::start();
4532
4533 server.mock(|when, then| {
4535 when.method(GET).path("/task/abc123");
4536 then.status(200).json_body(serde_json::json!({
4537 "id": "abc123", "name": "T",
4538 "status": {"status": "open", "type": "open"},
4539 "tags": [],
4540 "assignees": [{"id": 1, "username": "u1"}],
4541 "url": "https://app.clickup.com/t/abc123",
4542 "date_created": "1704067200000",
4543 "date_updated": "1704067200000"
4544 }));
4545 });
4546
4547 server.mock(|when, then| {
4548 when.method(PUT)
4549 .path("/task/abc123")
4550 .body_excludes("\"assignees\"");
4551 then.status(200).json_body(sample_task_no_assignees_json());
4552 });
4553
4554 let client = create_test_client(&server);
4555 let result = client
4556 .update_issue(
4557 "CU-abc123",
4558 UpdateIssueInput {
4559 assignees: Some(vec!["1".to_string()]),
4560 ..Default::default()
4561 },
4562 )
4563 .await;
4564 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4565 }
4566
4567 #[tokio::test]
4568 async fn test_create_issue_assignees_resolves_and_sends_flat_array() {
4569 let server = MockServer::start();
4570
4571 server.mock(|when, then| {
4573 when.method(GET).path("/team");
4574 then.status(200).json_body(team_payload(serde_json::json!([
4575 {"user": {"id": 555, "username": "u",
4576 "email": "u@example.com"}}
4577 ])));
4578 });
4579
4580 server.mock(|when, then| {
4582 when.method(POST)
4583 .path("/list/12345/task")
4584 .body_includes("\"assignees\":[555]");
4585 then.status(200).json_body(sample_task_json());
4586 });
4587
4588 let client = create_test_client(&server);
4589 let result = client
4590 .create_issue(CreateIssueInput {
4591 title: "New".to_string(),
4592 assignees: vec!["u@example.com".to_string()],
4593 ..Default::default()
4594 })
4595 .await;
4596 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4597 }
4598
4599 fn sample_task_no_assignees_json() -> serde_json::Value {
4600 serde_json::json!({
4601 "id": "abc123", "name": "Test Task",
4602 "status": {"status": "open", "type": "open"},
4603 "tags": [], "assignees": [],
4604 "url": "https://app.clickup.com/t/abc123",
4605 "date_created": "1704067200000",
4606 "date_updated": "1704067200000"
4607 })
4608 }
4609 }
4610}