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 resolve_custom_field_display(cf: &crate::types::ClickUpCustomField) -> Option<String> {
597 let tc = cf.type_config.as_ref()?;
598 if tc.options.is_empty() {
599 return None;
600 }
601 let value = cf.value.as_ref()?;
602 match cf.field_type.as_deref() {
603 Some("drop_down") => {
604 let chosen = match value {
605 serde_json::Value::Number(n) => {
609 let idx = u32::try_from(n.as_u64()?).ok()?;
610 tc.options.iter().find(|o| o.orderindex == Some(idx))
611 }
612 serde_json::Value::String(s) => tc
614 .options
615 .iter()
616 .find(|o| o.id.as_deref() == Some(s.as_str())),
617 _ => None,
618 }?;
619 chosen.name.clone()
620 }
621 Some("labels") => {
622 let names: Vec<String> = value
624 .as_array()?
625 .iter()
626 .filter_map(|v| v.as_str())
627 .filter_map(|id| {
628 tc.options
629 .iter()
630 .find(|o| o.id.as_deref() == Some(id))
631 .and_then(|o| o.name.clone())
632 })
633 .collect();
634 (!names.is_empty()).then(|| names.join(", "))
635 }
636 _ => None,
637 }
638}
639
640fn map_task(task: &ClickUpTask) -> Issue {
641 let custom_fields: std::collections::HashMap<String, devboy_core::CustomFieldValue> = task
647 .custom_fields
648 .iter()
649 .filter_map(|cf| {
650 cf.value.as_ref().map(|v| {
651 (
652 cf.id.clone(),
653 devboy_core::CustomFieldValue {
654 name: cf.name.clone(),
655 value: v.clone(),
656 display: resolve_custom_field_display(cf),
660 },
661 )
662 })
663 })
664 .collect();
665 Issue {
666 custom_fields,
667 key: map_task_key(task),
668 title: task.name.clone(),
669 description: task
670 .text_content
671 .clone()
672 .or_else(|| task.description.clone()),
673 state: map_state(task),
674 status: Some(task.status.status.clone()),
678 status_category: Some(map_status_category(
679 task.status.status_type.as_deref(),
680 &task.status.status,
681 )),
682 source: "clickup".to_string(),
683 priority: map_priority(task.priority.as_ref()),
684 labels: map_tags(&task.tags),
685 author: map_user(task.creator.as_ref()),
686 assignees: task
687 .assignees
688 .iter()
689 .map(|u| map_user_required(Some(u)))
690 .collect(),
691 url: Some(task.url.clone()),
692 created_at: map_timestamp(&task.date_created),
693 updated_at: map_timestamp(&task.date_updated),
694 attachments_count: if task.attachments.is_empty() {
695 None
696 } else {
697 Some(task.attachments.len() as u32)
698 },
699 parent: task.parent.as_ref().map(|id| format!("CU-{id}")),
700 subtasks: task
701 .subtasks
702 .as_deref()
703 .unwrap_or_default()
704 .iter()
705 .map(map_task)
706 .collect(),
707 }
708}
709
710fn map_comment(cu_comment: &ClickUpComment) -> Comment {
711 Comment {
712 id: cu_comment.id.clone(),
713 body: cu_comment.comment_text.clone(),
714 author: map_user(cu_comment.user.as_ref()),
715 created_at: map_timestamp(&cu_comment.date),
716 updated_at: None,
717 position: None,
718 }
719}
720
721fn map_clickup_attachment(raw: &ClickUpAttachment) -> AssetMeta {
723 let filename = raw
724 .title
725 .clone()
726 .or_else(|| {
727 raw.url
728 .as_deref()
729 .map(devboy_core::asset::filename_from_url)
730 })
731 .unwrap_or_else(|| format!("attachment-{}", raw.id));
732
733 let size = match raw.size.as_ref() {
734 Some(serde_json::Value::Number(n)) => n.as_u64(),
735 Some(serde_json::Value::String(s)) => s.parse::<u64>().ok(),
736 _ => None,
737 };
738
739 let created_at = raw.date.as_deref().and_then(epoch_ms_to_iso8601);
740
741 let author = raw.user.as_ref().map(|u| u.username.clone());
742
743 AssetMeta {
744 id: raw.id.clone(),
745 filename,
746 mime_type: raw.mimetype.clone(),
747 size,
748 url: raw.url.clone(),
749 created_at,
750 author,
751 cached: false,
752 local_path: None,
753 checksum_sha256: None,
754 analysis: None,
755 }
756}
757
758fn priority_sort_key(priority: Option<&str>) -> u8 {
761 match priority {
762 Some("urgent") => 1,
763 Some("high") => 2,
764 Some("normal") => 3,
765 Some("low") => 4,
766 _ => 5,
767 }
768}
769
770fn priority_to_clickup(priority: &str) -> Option<u8> {
772 match priority {
773 "urgent" => Some(1),
774 "high" => Some(2),
775 "normal" => Some(3),
776 "low" => Some(4),
777 _ => None,
778 }
779}
780
781fn map_dependencies(
790 deps: &[serde_json::Value],
791 this_task_id: &str,
792) -> (Vec<IssueLink>, Vec<IssueLink>) {
793 let mut blocked_by = Vec::new();
794 let mut blocks = Vec::new();
795
796 for dep in deps {
797 let task_id = dep
798 .get("task_id")
799 .and_then(|v| v.as_str())
800 .unwrap_or_default();
801 let depends_on = dep
802 .get("depends_on")
803 .and_then(|v| v.as_str())
804 .unwrap_or_default();
805 let dependency_of = dep
806 .get("dependency_of")
807 .and_then(|v| v.as_str())
808 .unwrap_or_default();
809
810 let other_id = if !task_id.is_empty() {
811 task_id
812 } else {
813 continue;
814 };
815
816 let other_issue = Issue {
817 key: format!("CU-{other_id}"),
818 source: "clickup".to_string(),
819 ..Default::default()
820 };
821
822 if depends_on == this_task_id {
823 blocks.push(IssueLink {
825 issue: other_issue,
826 link_type: "blocks".to_string(),
827 });
828 } else if dependency_of == this_task_id {
829 blocked_by.push(IssueLink {
831 issue: other_issue,
832 link_type: "blocked_by".to_string(),
833 });
834 } else {
835 let dep_type = dep.get("type").and_then(|v| v.as_u64());
838 match dep_type {
839 Some(1) => {
840 blocked_by.push(IssueLink {
841 issue: other_issue,
842 link_type: "blocked_by".to_string(),
843 });
844 }
845 Some(0) => {
846 blocks.push(IssueLink {
847 issue: other_issue,
848 link_type: "blocks".to_string(),
849 });
850 }
851 _ => {
852 blocked_by.push(IssueLink {
854 issue: other_issue,
855 link_type: "blocked_by".to_string(),
856 });
857 }
858 }
859 }
860 }
861
862 (blocked_by, blocks)
863}
864
865fn map_linked_tasks(links: &[ClickUpLinkedTask]) -> Vec<IssueLink> {
867 links
868 .iter()
869 .map(|link| {
870 let link_type = match link.link_type.as_deref() {
871 Some("blocked_by") => "blocked_by",
872 Some("blocking") => "blocks",
873 _ => "relates_to",
874 }
875 .to_string();
876
877 IssueLink {
878 issue: Issue {
879 key: format!("CU-{}", link.task_id),
880 source: "clickup".to_string(),
881 ..Default::default()
882 },
883 link_type,
884 }
885 })
886 .collect()
887}
888
889#[async_trait]
894impl IssueProvider for ClickUpClient {
895 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
896 let limit = filter.limit.unwrap_or(20) as usize;
897 if limit == 0 {
898 return Ok(vec![].into());
899 }
900 let offset = filter.offset.unwrap_or(0) as usize;
901
902 let start_page = offset / PAGE_SIZE as usize;
904 let end_page = (offset + limit).saturating_sub(1) / PAGE_SIZE as usize;
905
906 let mut base_params: Vec<(&str, String)> = vec![];
909
910 let include_closed = matches!(filter.state.as_deref(), Some("closed") | Some("all"))
911 || matches!(
912 filter.state_category.as_deref(),
913 Some("done") | Some("cancelled")
914 );
915 if include_closed {
916 base_params.push(("include_closed", "true".to_string()));
917 }
918
919 base_params.push(("subtasks", "true".to_string()));
920
921 if let Some(assignee) = &filter.assignee {
922 warn!(
926 assignee = assignee.as_str(),
927 "ClickUp assignee filter expects numeric user IDs, not usernames"
928 );
929 base_params.push(("assignees[]", assignee.clone()));
930 }
931
932 if let Some(tags) = &filter.labels {
933 for tag in tags {
934 base_params.push(("tags[]", tag.clone()));
935 }
936 }
937
938 let mut client_side_sort: Option<String> = None;
940
941 if let Some(order_by) = &filter.sort_by {
942 match order_by.as_str() {
943 "created_at" | "created" => {
944 base_params.push(("order_by", "created".to_string()));
945 }
946 "updated_at" | "updated" => {
947 base_params.push(("order_by", "updated".to_string()));
948 }
949 other => {
950 client_side_sort = Some(other.to_string());
952 warn!(
953 sort_by = other,
954 "ClickUp API does not support sorting by '{}', applying client-side sort",
955 other
956 );
957 }
958 }
959 }
960
961 let sort_order_is_asc = filter.sort_order.as_deref().is_some_and(|o| o == "asc");
962
963 if sort_order_is_asc && client_side_sort.is_none() {
964 base_params.push(("reverse", "true".to_string()));
965 }
966
967 let base_url = format!("{}/list/{}/task", self.base_url, self.list_id);
969 let mut all_tasks: Vec<ClickUpTask> = Vec::new();
970
971 for page in start_page..=end_page {
972 let mut params = base_params.clone();
973 params.push(("page", page.to_string()));
974
975 let param_refs: Vec<(&str, &str)> =
976 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
977 let response: ClickUpTaskList = self.get_with_query(&base_url, ¶m_refs).await?;
978 let page_len = response.tasks.len();
979 all_tasks.extend(response.tasks);
980
981 if page_len < PAGE_SIZE as usize {
983 break;
984 }
985 }
986
987 if let Some(ref state_category) = filter.state_category {
991 let statuses = self.get_statuses().await?;
992 let matching_status_names: Vec<String> = statuses
993 .items
994 .iter()
995 .filter(|s| s.category == *state_category)
996 .map(|s| s.name.to_lowercase())
997 .collect();
998
999 all_tasks.retain(|t| matching_status_names.contains(&t.status.status.to_lowercase()));
1000 }
1001
1002 let mut issues: Vec<Issue> = all_tasks.iter().map(map_task).collect();
1003
1004 if let Some(state) = &filter.state {
1006 match state.as_str() {
1007 "opened" | "open" => {
1008 issues.retain(|i| i.state == "open");
1009 }
1010 "closed" => {
1011 issues.retain(|i| i.state == "closed");
1012 }
1013 _ => {} }
1015 }
1016
1017 if filter.labels_operator.as_deref() == Some("and")
1019 && let Some(ref required_labels) = filter.labels
1020 {
1021 let required: Vec<String> = required_labels.iter().map(|l| l.to_lowercase()).collect();
1022 issues.retain(|issue| {
1023 let issue_labels: Vec<String> =
1024 issue.labels.iter().map(|l| l.to_lowercase()).collect();
1025 required.iter().all(|r| issue_labels.contains(r))
1026 });
1027 }
1028
1029 if let Some(ref query) = filter.search {
1031 let q = query.to_lowercase();
1032 issues.retain(|issue| {
1033 issue.title.to_lowercase().contains(&q)
1034 || issue
1035 .description
1036 .as_ref()
1037 .is_some_and(|d| d.to_lowercase().contains(&q))
1038 || issue.key.to_lowercase().contains(&q)
1039 });
1040 }
1041
1042 if let Some(ref sort_field) = client_side_sort {
1044 match sort_field.as_str() {
1045 "priority" => {
1046 issues.sort_by(|a, b| {
1047 let pa = priority_sort_key(a.priority.as_deref());
1048 let pb = priority_sort_key(b.priority.as_deref());
1049 if sort_order_is_asc {
1050 pa.cmp(&pb)
1051 } else {
1052 pb.cmp(&pa)
1053 }
1054 });
1055 }
1056 "title" => {
1057 issues.sort_by(|a, b| {
1058 let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase());
1059 if sort_order_is_asc {
1060 cmp
1061 } else {
1062 cmp.reverse()
1063 }
1064 });
1065 }
1066 _ => {
1067 }
1069 }
1070 }
1071
1072 let offset_in_first_page = offset % PAGE_SIZE as usize;
1074 if offset_in_first_page < issues.len() {
1075 issues = issues.split_off(offset_in_first_page);
1076 } else {
1077 issues.clear();
1078 }
1079
1080 issues.truncate(limit);
1081
1082 let sort_info = SortInfo {
1084 sort_by: filter.sort_by.clone(),
1085 sort_order: if sort_order_is_asc {
1086 SortOrder::Asc
1087 } else {
1088 SortOrder::Desc
1089 },
1090 available_sorts: vec![
1091 "created_at".into(),
1092 "updated_at".into(),
1093 "priority".into(),
1094 "title".into(),
1095 ],
1096 };
1097
1098 Ok(ProviderResult::new(issues).with_sort_info(sort_info))
1099 }
1100
1101 async fn get_issue(&self, key: &str) -> Result<Issue> {
1102 let base_url = self.task_url(key)?;
1103 let separator = if base_url.contains('?') { "&" } else { "?" };
1104 let url = format!("{}{}include_subtasks=true", base_url, separator);
1105 let task: ClickUpTask = self.get(&url).await?;
1106 Ok(map_task(&task))
1107 }
1108
1109 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1110 let url = format!("{}/list/{}/task", self.base_url, self.list_id);
1111
1112 let priority = input.priority.as_deref().and_then(priority_to_clickup);
1113
1114 let tags = if input.labels.is_empty() {
1115 None
1116 } else {
1117 Some(input.labels)
1118 };
1119
1120 let parent = match input.parent {
1123 Some(ref parent_key) => {
1124 if let Some(stripped) = parent_key.strip_prefix("CU-") {
1125 Some(stripped.to_string())
1126 } else {
1127 let parent_url = self.task_url(parent_key)?;
1128 let parent_task: ClickUpTask = self.get(&parent_url).await?;
1129 Some(parent_task.id)
1130 }
1131 }
1132 None => None,
1133 };
1134
1135 let (description, markdown_content) = if input.markdown {
1136 (None, input.description)
1137 } else {
1138 (input.description, None)
1139 };
1140
1141 let assignees = if input.assignees.is_empty() {
1145 None
1146 } else {
1147 Some(self.resolve_assignee_ids(&input.assignees).await?)
1148 };
1149
1150 let request = CreateTaskRequest {
1151 name: input.title,
1152 description,
1153 markdown_content,
1154 parent,
1155 status: None,
1156 priority,
1157 tags,
1158 assignees,
1159 };
1160
1161 let task: ClickUpTask = self.post(&url, &request).await?;
1162 let task_id = task.id.clone();
1163
1164 if task.custom_id.is_none() {
1167 for attempt in 1..=3u64 {
1168 tokio::time::sleep(std::time::Duration::from_millis(300 * attempt)).await;
1169 let fetch_url = format!("{}/task/{}", self.base_url, task_id);
1170 if let Ok(fetched) = self.get::<ClickUpTask>(&fetch_url).await
1171 && fetched.custom_id.is_some()
1172 {
1173 debug!(
1174 task_id = task_id,
1175 custom_id = ?fetched.custom_id,
1176 attempt = attempt,
1177 "Got custom_id after retry"
1178 );
1179 return Ok(map_task(&fetched));
1180 }
1181 }
1182 warn!(
1183 task_id = task_id,
1184 "custom_id not available after 3 retries, using POST response"
1185 );
1186 }
1187
1188 Ok(map_task(&task))
1189 }
1190
1191 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1192 let url = self.task_url(key)?;
1193
1194 let status = if let Some(s) = input.status.as_deref() {
1200 Some(self.validate_status_name(s).await?)
1201 } else if let Some(s) = input.state.as_deref() {
1202 Some(self.resolve_status(s).await?)
1203 } else {
1204 None
1205 };
1206
1207 let priority = input.priority.as_deref().and_then(priority_to_clickup);
1208
1209 let (description, markdown_content) = if input.markdown {
1210 (None, input.description)
1211 } else {
1212 (input.description, None)
1213 };
1214
1215 let parent = match input.parent_id {
1219 Some(ref parent_key) if parent_key == "none" || parent_key.is_empty() => {
1220 Some("none".to_string())
1221 }
1222 Some(ref parent_key) => {
1223 if let Some(stripped) = parent_key.strip_prefix("CU-") {
1224 Some(stripped.to_string())
1225 } else {
1226 let parent_url = self.task_url(parent_key)?;
1227 let parent_task: ClickUpTask = self.get(&parent_url).await?;
1228 Some(parent_task.id)
1229 }
1230 }
1231 None => None,
1232 };
1233
1234 let assignees_diff = match input.assignees.as_deref() {
1249 Some(requested) => {
1250 let new_ids = self.resolve_assignee_ids(requested).await?;
1251 let current_task: ClickUpTask = self.get(&url).await?;
1252 let current_ids: Vec<u64> = current_task.assignees.iter().map(|u| u.id).collect();
1253 let add: Vec<u64> = new_ids
1254 .iter()
1255 .copied()
1256 .filter(|id| !current_ids.contains(id))
1257 .collect();
1258 let rem: Vec<u64> = current_ids
1259 .iter()
1260 .copied()
1261 .filter(|id| !new_ids.contains(id))
1262 .collect();
1263 if add.is_empty() && rem.is_empty() {
1264 None
1265 } else {
1266 Some(AssigneeDiff { add, rem })
1267 }
1268 }
1269 None => None,
1270 };
1271
1272 let request = UpdateTaskRequest {
1273 name: input.title,
1274 description,
1275 markdown_content,
1276 status,
1277 priority,
1278 parent,
1279 tags: None, assignees: assignees_diff,
1281 };
1282
1283 let task: ClickUpTask = self.put(&url, &request).await?;
1284
1285 if let Some(ref new_labels) = input.labels {
1288 let current_tags: Vec<String> = task.tags.iter().map(|t| t.name.clone()).collect();
1289 let new_tags: Vec<String> = new_labels.iter().map(|l| l.to_lowercase()).collect();
1290
1291 for tag in ¤t_tags {
1293 if !new_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1294 let tag_url =
1295 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1296 if let Err(e) = self.delete(&tag_url).await {
1297 warn!(tag = tag, error = %e, "Failed to remove tag");
1298 }
1299 }
1300 }
1301
1302 for tag in &new_tags {
1304 if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1305 let tag_url =
1306 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1307 let resp = self
1308 .request(reqwest::Method::POST, &tag_url)
1309 .send()
1310 .await
1311 .map_err(|e| Error::Http(e.to_string()))?;
1312 if !resp.status().is_success() {
1313 warn!(
1314 tag = tag,
1315 status = resp.status().as_u16(),
1316 "Failed to add tag"
1317 );
1318 }
1319 }
1320 }
1321 }
1322
1323 match self.get::<ClickUpTask>(&url).await {
1326 Ok(updated_task) => Ok(map_task(&updated_task)),
1327 Err(e) => {
1328 warn!(
1329 issue_key = key,
1330 error = %e,
1331 "Task updated successfully, but failed to re-fetch fresh state; falling back to PUT response"
1332 );
1333 Ok(map_task(&task))
1334 }
1335 }
1336 }
1337
1338 async fn set_custom_fields(&self, issue_key: &str, fields: &[serde_json::Value]) -> Result<()> {
1339 let task_id = self.resolve_to_native_id(issue_key).await?;
1340 for field in fields {
1341 let field_id = field["id"].as_str().unwrap_or_default();
1342 if field_id.is_empty() {
1343 continue;
1344 }
1345 let url = format!("{}/task/{}/field/{}", self.base_url, task_id, field_id);
1346 let body = serde_json::json!({ "value": field["value"] });
1347 let resp = self
1348 .request(reqwest::Method::POST, &url)
1349 .json(&body)
1350 .send()
1351 .await
1352 .map_err(|e| Error::Http(e.to_string()))?;
1353 if !resp.status().is_success() {
1354 let status = resp.status().as_u16();
1355 let msg = resp.text().await.unwrap_or_default();
1356 warn!(
1357 field_id = field_id,
1358 status = status,
1359 "Failed to set custom field: {}",
1360 msg
1361 );
1362 }
1363 }
1364 Ok(())
1365 }
1366
1367 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1368 let base_url = self.task_url(issue_key)?;
1369 let url = if base_url.contains('?') {
1371 let (path, query) = base_url.split_once('?').unwrap();
1372 format!("{}/comment?{}", path, query)
1373 } else {
1374 format!("{}/comment", base_url)
1375 };
1376 let response: ClickUpCommentList = self.get(&url).await?;
1377 Ok(response
1378 .comments
1379 .iter()
1380 .map(map_comment)
1381 .collect::<Vec<_>>()
1382 .into())
1383 }
1384
1385 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1386 let base_url = self.task_url(issue_key)?;
1387 let url = if base_url.contains('?') {
1388 let (path, query) = base_url.split_once('?').unwrap();
1389 format!("{}/comment?{}", path, query)
1390 } else {
1391 format!("{}/comment", base_url)
1392 };
1393 let comment = crate::comment_format::markdown_to_comment_blocks(body);
1404 let comment_text = if comment.is_empty() {
1405 body.to_string()
1406 } else {
1407 String::new()
1408 };
1409 let request = CreateCommentRequest {
1410 comment_text,
1411 comment,
1412 };
1413
1414 let response: CreateCommentResponse = self.post(&url, &request).await?;
1416 Ok(Comment {
1417 id: response.id,
1418 body: body.to_string(),
1419 author: None,
1420 created_at: map_timestamp(&response.date),
1421 updated_at: None,
1422 position: None,
1423 })
1424 }
1425
1426 async fn upload_attachment(
1427 &self,
1428 issue_key: &str,
1429 filename: &str,
1430 data: &[u8],
1431 ) -> Result<String> {
1432 let task_id = self.resolve_to_native_id(issue_key).await?;
1433 let url = format!("{}/task/{}/attachment", self.base_url, task_id);
1434
1435 let part = reqwest::multipart::Part::bytes(data.to_vec())
1436 .file_name(filename.to_string())
1437 .mime_str("application/octet-stream")
1438 .map_err(|e| Error::Http(format!("Failed to create multipart: {}", e)))?;
1439
1440 let form = reqwest::multipart::Form::new().part("attachment", part);
1441
1442 let response = self
1443 .client
1444 .post(&url)
1445 .header("Authorization", self.token.expose_secret())
1446 .multipart(form)
1447 .send()
1448 .await
1449 .map_err(|e| Error::Http(e.to_string()))?;
1450
1451 let status = response.status();
1452 if !status.is_success() {
1453 let message = response.text().await.unwrap_or_default();
1454 return Err(Error::from_status(status.as_u16(), message));
1455 }
1456
1457 let body: serde_json::Value = response.json().await.map_err(|e| {
1459 Error::InvalidData(format!("Failed to parse attachment response: {}", e))
1460 })?;
1461
1462 let download_url = body
1464 .pointer("/url")
1465 .or_else(|| body.pointer("/attachment/url"))
1466 .and_then(|v| v.as_str())
1467 .unwrap_or("")
1468 .to_string();
1469
1470 Ok(download_url)
1471 }
1472
1473 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
1474 let url = self.task_url(issue_key)?;
1475 let task: ClickUpTask = self.get(&url).await?;
1476 Ok(task
1477 .attachments
1478 .iter()
1479 .map(map_clickup_attachment)
1480 .collect())
1481 }
1482
1483 async fn download_attachment(&self, issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1484 let url = self.task_url(issue_key)?;
1489 let task: ClickUpTask = self.get(&url).await?;
1490 let attachment = task
1491 .attachments
1492 .iter()
1493 .find(|a| a.id == asset_id)
1494 .ok_or_else(|| {
1495 Error::NotFound(format!(
1496 "attachment '{asset_id}' not found on task {issue_key}",
1497 ))
1498 })?;
1499 let download_url = attachment.url.as_deref().ok_or_else(|| {
1500 Error::InvalidData(format!(
1501 "attachment '{asset_id}' on task {issue_key} has no URL",
1502 ))
1503 })?;
1504
1505 let response = self
1506 .client
1507 .get(download_url)
1508 .header("Authorization", self.token.expose_secret())
1509 .send()
1510 .await
1511 .map_err(|e| Error::Http(e.to_string()))?;
1512
1513 let status = response.status();
1514 if !status.is_success() {
1515 let message = response.text().await.unwrap_or_default();
1516 return Err(Error::from_status(status.as_u16(), message));
1517 }
1518
1519 let bytes = response
1520 .bytes()
1521 .await
1522 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
1523 Ok(bytes.to_vec())
1524 }
1525
1526 fn asset_capabilities(&self) -> AssetCapabilities {
1527 AssetCapabilities {
1531 issue: ContextCapabilities {
1532 upload: true,
1533 download: true,
1534 delete: false,
1535 list: true,
1536 max_file_size: None,
1537 allowed_types: Vec::new(),
1538 },
1539 ..Default::default()
1540 }
1541 }
1542
1543 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1544 let url = format!("{}/list/{}", self.base_url, self.list_id);
1545 let list_info: ClickUpListInfo = self.get(&url).await?;
1546
1547 let statuses: Vec<IssueStatus> = list_info
1548 .statuses
1549 .iter()
1550 .enumerate()
1551 .map(|(idx, s)| {
1552 let category = map_status_category(s.status_type.as_deref(), &s.status);
1553 IssueStatus {
1554 id: s.status.clone(),
1555 name: s.status.clone(),
1556 category,
1557 color: s.color.clone(),
1558 order: s.orderindex.or(Some(idx as u32)),
1559 }
1560 })
1561 .collect();
1562
1563 Ok(statuses.into())
1564 }
1565
1566 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1567 match link_type {
1568 "subtask" => {
1569 let source_url = self.task_url(source_key)?;
1571 let target_native_id = self.resolve_to_native_id(target_key).await?;
1572 let body = serde_json::json!({ "parent": target_native_id });
1573 let _: ClickUpTask = self.put(&source_url, &body).await?;
1574 }
1575 "blocks" => {
1576 let source_id = self.resolve_task_id(source_key)?;
1578 let target_id = self.resolve_task_id(target_key)?;
1579 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1580 let body = serde_json::json!({ "depends_on": source_id });
1581 let _: serde_json::Value = self.post(&url, &body).await?;
1582 }
1583 "blocked_by" => {
1584 let source_id = self.resolve_task_id(source_key)?;
1586 let target_id = self.resolve_task_id(target_key)?;
1587 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1588 let body = serde_json::json!({ "depends_on": target_id });
1589 let _: serde_json::Value = self.post(&url, &body).await?;
1590 }
1591 _ => {
1592 let source_id = self.resolve_to_native_id(source_key).await?;
1594 let target_id = self.resolve_to_native_id(target_key).await?;
1595 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1596 let body = serde_json::json!({});
1597 let _: serde_json::Value = self.post(&url, &body).await?;
1598 }
1599 }
1600
1601 Ok(())
1602 }
1603
1604 async fn unlink_issues(
1605 &self,
1606 source_key: &str,
1607 target_key: &str,
1608 link_type: &str,
1609 ) -> Result<()> {
1610 match link_type {
1611 "subtask" => {
1612 let source_id = self.resolve_to_native_id(source_key).await?;
1615 let url = format!("{}/task/{}", self.base_url, source_id);
1616 let body = serde_json::json!({ "parent": "none" });
1617 let _: ClickUpTask = self.put(&url, &body).await?;
1618 }
1619 "blocks" => {
1620 let source_id = self.resolve_to_native_id(source_key).await?;
1622 let target_id = self.resolve_to_native_id(target_key).await?;
1623 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1624 self.delete_with_query(&url, &[("depends_on", &source_id)])
1625 .await?;
1626 }
1627 "blocked_by" => {
1628 let source_id = self.resolve_to_native_id(source_key).await?;
1630 let target_id = self.resolve_to_native_id(target_key).await?;
1631 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1632 self.delete_with_query(&url, &[("depends_on", &target_id)])
1633 .await?;
1634 }
1635 _ => {
1636 let source_id = self.resolve_to_native_id(source_key).await?;
1638 let target_id = self.resolve_to_native_id(target_key).await?;
1639 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1640 self.delete(&url).await?;
1641 }
1642 }
1643
1644 Ok(())
1645 }
1646
1647 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1648 let url = self.task_url(issue_key)?;
1649 let task: ClickUpTask = self
1650 .get_with_query(
1651 &url,
1652 &[("include_subtasks", "true"), ("include_closed", "true")],
1653 )
1654 .await?;
1655
1656 let mut relations = IssueRelations::default();
1657
1658 if let Some(ref parent_id) = task.parent {
1660 let parent_url = format!("{}/task/{}", self.base_url, parent_id);
1661 match self.get::<ClickUpTask>(&parent_url).await {
1662 Ok(parent_task) => {
1663 relations.parent = Some(map_task(&parent_task));
1664 }
1665 Err(e) => {
1666 tracing::warn!("Failed to fetch parent task {}: {}", parent_id, e);
1667 relations.parent = Some(Issue {
1669 key: format!("CU-{parent_id}"),
1670 source: "clickup".to_string(),
1671 ..Default::default()
1672 });
1673 }
1674 }
1675 }
1676
1677 if let Some(ref subtasks) = task.subtasks {
1679 relations.subtasks = subtasks.iter().map(map_task).collect();
1680 }
1681
1682 if let Some(ref deps) = task.dependencies {
1684 let (blocked_by, blocks) = map_dependencies(deps, &task.id);
1685 relations.blocked_by = blocked_by;
1686 relations.blocks = blocks;
1687 }
1688
1689 if let Some(ref linked) = task.linked_tasks {
1691 relations.related_to = map_linked_tasks(linked);
1692 }
1693
1694 Ok(relations)
1695 }
1696
1697 fn provider_name(&self) -> &'static str {
1698 "clickup"
1699 }
1700}
1701
1702#[async_trait]
1703impl MergeRequestProvider for ClickUpClient {
1704 fn provider_name(&self) -> &'static str {
1705 "clickup"
1706 }
1707}
1708
1709#[async_trait]
1710impl PipelineProvider for ClickUpClient {
1711 fn provider_name(&self) -> &'static str {
1712 "clickup"
1713 }
1714}
1715
1716#[async_trait]
1717impl Provider for ClickUpClient {
1718 async fn get_current_user(&self) -> Result<User> {
1719 let url = format!(
1722 "{}/list/{}/task?page=0&subtasks=false",
1723 self.base_url, self.list_id
1724 );
1725 let _: ClickUpTaskList = self.get(&url).await?;
1726
1727 Ok(User {
1729 id: "clickup".to_string(),
1730 username: "clickup-user".to_string(),
1731 name: Some("ClickUp User".to_string()),
1732 ..Default::default()
1733 })
1734 }
1735}
1736
1737#[cfg(test)]
1742mod tests {
1743 use super::*;
1744 use crate::types::{ClickUpStatus, ClickUpTag};
1745 use devboy_core::{CreateCommentInput, MrFilter};
1746
1747 fn token(s: &str) -> SecretString {
1748 SecretString::from(s.to_string())
1749 }
1750
1751 #[test]
1752 fn test_epoch_ms_to_iso8601() {
1753 assert_eq!(
1755 epoch_ms_to_iso8601("1704067200000"),
1756 Some("2024-01-01T00:00:00Z".to_string())
1757 );
1758
1759 assert_eq!(
1761 epoch_ms_to_iso8601("1704153600000"),
1762 Some("2024-01-02T00:00:00Z".to_string())
1763 );
1764
1765 assert_eq!(
1767 epoch_ms_to_iso8601("1705312800000"),
1768 Some("2024-01-15T10:00:00Z".to_string())
1769 );
1770
1771 assert_eq!(epoch_ms_to_iso8601("not_a_number"), None);
1773 }
1774
1775 #[test]
1776 fn test_task_url_cu_prefix() {
1777 let client =
1778 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1779 let url = client.task_url("CU-abc123").unwrap();
1780 assert_eq!(url, "https://api.clickup.com/api/v2/task/abc123");
1781 }
1782
1783 #[test]
1784 fn test_task_url_custom_id_with_team() {
1785 let client =
1786 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"))
1787 .with_team_id("9876");
1788 let url = client.task_url("DEV-42").unwrap();
1789 assert_eq!(
1790 url,
1791 "https://api.clickup.com/api/v2/task/DEV-42?custom_task_ids=true&team_id=9876"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_task_url_custom_id_without_team() {
1797 let client =
1798 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1799 let result = client.task_url("DEV-42");
1800 assert!(result.is_err());
1801 }
1802
1803 #[test]
1809 fn test_map_task_surfaces_custom_field_values() {
1810 let task = ClickUpTask {
1811 id: "abc123".to_string(),
1812 custom_id: None,
1813 name: "T".to_string(),
1814 description: None,
1815 text_content: None,
1816 status: ClickUpStatus {
1817 status: "open".to_string(),
1818 status_type: Some("open".to_string()),
1819 },
1820 priority: None,
1821 tags: vec![],
1822 assignees: vec![],
1823 creator: None,
1824 url: "https://app.clickup.com/t/abc123".to_string(),
1825 date_created: None,
1826 date_updated: None,
1827 parent: None,
1828 subtasks: None,
1829 dependencies: None,
1830 linked_tasks: None,
1831 attachments: Vec::new(),
1832 custom_fields: vec![
1833 crate::types::ClickUpCustomField {
1834 id: "cf-1".to_string(),
1835 name: Some("Severity".to_string()),
1836 field_type: Some("drop_down".to_string()),
1837 value: Some(serde_json::json!("High")),
1838 type_config: None,
1839 },
1840 crate::types::ClickUpCustomField {
1841 id: "cf-2".to_string(),
1842 name: Some("Sprint".to_string()),
1843 field_type: Some("text".to_string()),
1844 value: None, type_config: None,
1846 },
1847 crate::types::ClickUpCustomField {
1848 id: "cf-3".to_string(),
1849 name: None, field_type: Some("number".to_string()),
1851 value: Some(serde_json::json!(42)),
1852 type_config: None,
1853 },
1854 ],
1855 };
1856
1857 let issue = map_task(&task);
1858 let severity = issue.custom_fields.get("cf-1").expect("cf-1 present");
1861 assert_eq!(severity.name.as_deref(), Some("Severity"));
1862 assert_eq!(severity.value, serde_json::json!("High"));
1863 assert!(severity.display.is_none());
1865 assert!(!issue.custom_fields.contains_key("cf-2"));
1867 let anon = issue.custom_fields.get("cf-3").expect("cf-3 present");
1869 assert!(anon.name.is_none());
1870 assert_eq!(anon.value, serde_json::json!(42));
1871 }
1872
1873 #[test]
1877 fn test_map_task_resolves_select_custom_fields_to_labels() {
1878 let opt = |id: &str, name: &str, idx: u32| crate::types::ClickUpFieldOptionInline {
1879 id: Some(id.to_string()),
1880 name: Some(name.to_string()),
1881 orderindex: Some(idx),
1882 };
1883 let shipped_opts = || {
1884 Some(crate::types::ClickUpFieldTypeConfig {
1885 options: vec![
1886 opt("o-dev", "dev", 0),
1887 opt("o-test", "test", 1),
1888 opt("o-prod", "prod", 2),
1889 ],
1890 })
1891 };
1892 let task = ClickUpTask {
1893 id: "t".to_string(),
1894 custom_id: None,
1895 name: "T".to_string(),
1896 description: None,
1897 text_content: None,
1898 status: ClickUpStatus {
1899 status: "open".to_string(),
1900 status_type: Some("open".to_string()),
1901 },
1902 priority: None,
1903 tags: vec![],
1904 assignees: vec![],
1905 creator: None,
1906 url: "https://app.clickup.com/t/t".to_string(),
1907 date_created: None,
1908 date_updated: None,
1909 parent: None,
1910 subtasks: None,
1911 dependencies: None,
1912 linked_tasks: None,
1913 attachments: Vec::new(),
1914 custom_fields: vec![
1915 crate::types::ClickUpCustomField {
1917 id: "shipped".to_string(),
1918 name: Some("Shipped Version".to_string()),
1919 field_type: Some("drop_down".to_string()),
1920 value: Some(serde_json::json!(0)),
1921 type_config: shipped_opts(),
1922 },
1923 crate::types::ClickUpCustomField {
1925 id: "shipped2".to_string(),
1926 name: Some("Shipped Version".to_string()),
1927 field_type: Some("drop_down".to_string()),
1928 value: Some(serde_json::json!("o-prod")),
1929 type_config: shipped_opts(),
1930 },
1931 crate::types::ClickUpCustomField {
1933 id: "areas".to_string(),
1934 name: Some("Areas".to_string()),
1935 field_type: Some("labels".to_string()),
1936 value: Some(serde_json::json!(["a", "b"])),
1937 type_config: Some(crate::types::ClickUpFieldTypeConfig {
1938 options: vec![
1939 opt("a", "backend", 0),
1940 opt("b", "infra", 1),
1941 opt("c", "frontend", 2),
1942 ],
1943 }),
1944 },
1945 ],
1946 };
1947
1948 let issue = map_task(&task);
1949 let shipped = issue.custom_fields.get("shipped").expect("shipped present");
1950 assert_eq!(shipped.value, serde_json::json!(0)); assert_eq!(shipped.display.as_deref(), Some("dev"));
1952
1953 let shipped2 = issue
1954 .custom_fields
1955 .get("shipped2")
1956 .expect("shipped2 present");
1957 assert_eq!(shipped2.display.as_deref(), Some("prod"));
1958
1959 let areas = issue.custom_fields.get("areas").expect("areas present");
1960 assert_eq!(areas.display.as_deref(), Some("backend, infra"));
1961 }
1962
1963 #[test]
1968 fn test_resolve_custom_field_display_degrades_gracefully() {
1969 use crate::types::{ClickUpCustomField, ClickUpFieldOptionInline, ClickUpFieldTypeConfig};
1970 let opt = |id: &str, name: &str, idx: u32| ClickUpFieldOptionInline {
1971 id: Some(id.to_string()),
1972 name: Some(name.to_string()),
1973 orderindex: Some(idx),
1974 };
1975 let field =
1976 |id: &str, ty: &str, value: serde_json::Value, opts: Vec<ClickUpFieldOptionInline>| {
1977 ClickUpCustomField {
1978 id: id.to_string(),
1979 name: Some(id.to_string()),
1980 field_type: Some(ty.to_string()),
1981 value: Some(value),
1982 type_config: Some(ClickUpFieldTypeConfig { options: opts }),
1983 }
1984 };
1985 let dd_opts = || vec![opt("o-dev", "dev", 0), opt("o-test", "test", 1)];
1986 let task = ClickUpTask {
1987 id: "t".to_string(),
1988 custom_id: None,
1989 name: "T".to_string(),
1990 description: None,
1991 text_content: None,
1992 status: ClickUpStatus {
1993 status: "open".to_string(),
1994 status_type: Some("open".to_string()),
1995 },
1996 priority: None,
1997 tags: vec![],
1998 assignees: vec![],
1999 creator: None,
2000 url: "https://app.clickup.com/t/t".to_string(),
2001 date_created: None,
2002 date_updated: None,
2003 parent: None,
2004 subtasks: None,
2005 dependencies: None,
2006 linked_tasks: None,
2007 attachments: Vec::new(),
2008 custom_fields: vec![
2009 field(
2011 "dd_overflow",
2012 "drop_down",
2013 serde_json::json!(4_294_967_296u64),
2014 dd_opts(),
2015 ),
2016 field("dd_no_index", "drop_down", serde_json::json!(99), dd_opts()),
2018 field(
2020 "dd_bad_id",
2021 "drop_down",
2022 serde_json::json!("nope"),
2023 dd_opts(),
2024 ),
2025 field("dd_empty", "drop_down", serde_json::json!(0), vec![]),
2027 field(
2029 "lbl_nomatch",
2030 "labels",
2031 serde_json::json!(["zzz"]),
2032 vec![opt("a", "backend", 0)],
2033 ),
2034 ],
2035 };
2036
2037 let issue = map_task(&task);
2038 for key in [
2039 "dd_overflow",
2040 "dd_no_index",
2041 "dd_bad_id",
2042 "dd_empty",
2043 "lbl_nomatch",
2044 ] {
2045 let f = issue
2046 .custom_fields
2047 .get(key)
2048 .unwrap_or_else(|| panic!("{key} present"));
2049 assert!(
2050 f.display.is_none(),
2051 "{key} must not resolve, got {:?}",
2052 f.display
2053 );
2054 assert!(!f.value.is_null(), "{key} raw value preserved");
2055 }
2056 }
2057
2058 #[test]
2059 fn test_map_task() {
2060 let task = ClickUpTask {
2061 id: "abc123".to_string(),
2062 custom_id: None,
2063 name: "Fix bug".to_string(),
2064 description: Some("Bug description".to_string()),
2065 text_content: Some("Bug text content".to_string()),
2066 status: ClickUpStatus {
2067 status: "open".to_string(),
2068 status_type: Some("open".to_string()),
2069 },
2070 priority: Some(ClickUpPriority {
2071 id: "2".to_string(),
2072 priority: "high".to_string(),
2073 color: None,
2074 }),
2075 tags: vec![ClickUpTag {
2076 name: "bug".to_string(),
2077 }],
2078 assignees: vec![ClickUpUser {
2079 id: 1,
2080 username: "dev1".to_string(),
2081 email: Some("dev1@example.com".to_string()),
2082 profile_picture: None,
2083 }],
2084 creator: Some(ClickUpUser {
2085 id: 2,
2086 username: "creator".to_string(),
2087 email: None,
2088 profile_picture: None,
2089 }),
2090 url: "https://app.clickup.com/t/abc123".to_string(),
2091 date_created: Some("1704067200000".to_string()),
2092 date_updated: Some("1704153600000".to_string()),
2093 parent: None,
2094 subtasks: None,
2095 dependencies: None,
2096 linked_tasks: None,
2097 attachments: Vec::new(),
2098 custom_fields: Vec::new(),
2099 };
2100
2101 let issue = map_task(&task);
2102 assert_eq!(issue.key, "CU-abc123");
2103 assert_eq!(issue.title, "Fix bug");
2104 assert_eq!(issue.description, Some("Bug text content".to_string()));
2105 assert_eq!(issue.state, "open");
2106 assert_eq!(issue.source, "clickup");
2107 assert_eq!(issue.priority, Some("high".to_string()));
2108 assert_eq!(issue.labels, vec!["bug"]);
2109 assert_eq!(issue.assignees.len(), 1);
2110 assert_eq!(issue.assignees[0].username, "dev1");
2111 assert!(issue.author.is_some());
2112 assert_eq!(issue.author.unwrap().username, "creator");
2113 assert_eq!(
2114 issue.url,
2115 Some("https://app.clickup.com/t/abc123".to_string())
2116 );
2117 assert_eq!(issue.created_at, Some("2024-01-01T00:00:00Z".to_string()));
2119 assert_eq!(issue.updated_at, Some("2024-01-02T00:00:00Z".to_string()));
2120 assert_eq!(issue.status, Some("open".to_string()));
2122 assert_eq!(issue.status_category, Some("todo".to_string()));
2123 }
2124
2125 #[test]
2131 fn test_map_task_surfaces_display_status() {
2132 let make = |name: &str, ty: &str| ClickUpTask {
2133 id: "t1".to_string(),
2134 custom_id: None,
2135 name: "Task".to_string(),
2136 description: None,
2137 text_content: None,
2138 status: ClickUpStatus {
2139 status: name.to_string(),
2140 status_type: Some(ty.to_string()),
2141 },
2142 priority: None,
2143 tags: vec![],
2144 assignees: vec![],
2145 creator: None,
2146 url: "https://app.clickup.com/t/t1".to_string(),
2147 date_created: None,
2148 date_updated: None,
2149 parent: None,
2150 subtasks: None,
2151 dependencies: None,
2152 linked_tasks: None,
2153 attachments: Vec::new(),
2154 custom_fields: Vec::new(),
2155 };
2156
2157 let in_progress = map_task(&make("in progress", "custom"));
2159 assert_eq!(in_progress.status, Some("in progress".to_string()));
2160 assert_eq!(in_progress.status_category, Some("in_progress".to_string()));
2161 assert_eq!(in_progress.state, "open"); let review = map_task(&make("review", "custom"));
2164 assert_eq!(review.status, Some("review".to_string()));
2165 assert_eq!(review.status_category, Some("in_progress".to_string()));
2166
2167 let complete = map_task(&make("complete", "closed"));
2169 assert_eq!(complete.status, Some("complete".to_string()));
2170 assert_eq!(complete.status_category, Some("done".to_string()));
2171 assert_eq!(complete.state, "closed");
2172 }
2173
2174 #[test]
2175 fn test_map_task_with_custom_id() {
2176 let task = ClickUpTask {
2177 id: "abc123".to_string(),
2178 custom_id: Some("DEV-42".to_string()),
2179 name: "Task with custom ID".to_string(),
2180 description: None,
2181 text_content: None,
2182 status: ClickUpStatus {
2183 status: "open".to_string(),
2184 status_type: Some("open".to_string()),
2185 },
2186 priority: None,
2187 tags: vec![],
2188 assignees: vec![],
2189 creator: None,
2190 url: "https://app.clickup.com/t/abc123".to_string(),
2191 date_created: None,
2192 date_updated: None,
2193 parent: None,
2194 subtasks: None,
2195 dependencies: None,
2196 linked_tasks: None,
2197 attachments: Vec::new(),
2198 custom_fields: Vec::new(),
2199 };
2200
2201 let issue = map_task(&task);
2202 assert_eq!(issue.key, "DEV-42");
2203 }
2204
2205 #[test]
2206 fn test_map_task_closed_status() {
2207 let task = ClickUpTask {
2208 id: "abc123".to_string(),
2209 custom_id: None,
2210 name: "Closed task".to_string(),
2211 description: None,
2212 text_content: None,
2213 status: ClickUpStatus {
2214 status: "done".to_string(),
2215 status_type: Some("closed".to_string()),
2216 },
2217 priority: None,
2218 tags: vec![],
2219 assignees: vec![],
2220 creator: None,
2221 url: "https://app.clickup.com/t/abc123".to_string(),
2222 date_created: None,
2223 date_updated: None,
2224 parent: None,
2225 subtasks: None,
2226 dependencies: None,
2227 linked_tasks: None,
2228 attachments: Vec::new(),
2229 custom_fields: Vec::new(),
2230 };
2231
2232 let issue = map_task(&task);
2233 assert_eq!(issue.state, "closed");
2234 }
2235
2236 #[test]
2237 fn test_map_priority_all_levels() {
2238 let make_priority = |id: &str, name: &str| ClickUpPriority {
2239 id: id.to_string(),
2240 priority: name.to_string(),
2241 color: None,
2242 };
2243
2244 assert_eq!(
2245 map_priority(Some(&make_priority("1", "urgent"))),
2246 Some("urgent".to_string())
2247 );
2248 assert_eq!(
2249 map_priority(Some(&make_priority("2", "high"))),
2250 Some("high".to_string())
2251 );
2252 assert_eq!(
2253 map_priority(Some(&make_priority("3", "normal"))),
2254 Some("normal".to_string())
2255 );
2256 assert_eq!(
2257 map_priority(Some(&make_priority("4", "low"))),
2258 Some("low".to_string())
2259 );
2260 assert_eq!(map_priority(None), None);
2261 }
2262
2263 #[test]
2264 fn test_map_user() {
2265 let cu_user = ClickUpUser {
2266 id: 123,
2267 username: "testuser".to_string(),
2268 email: Some("test@example.com".to_string()),
2269 profile_picture: Some("https://example.com/avatar.png".to_string()),
2270 };
2271
2272 let user = map_user(Some(&cu_user)).unwrap();
2273 assert_eq!(user.id, "123");
2274 assert_eq!(user.username, "testuser");
2275 assert_eq!(user.name, Some("testuser".to_string()));
2276 assert_eq!(user.email, Some("test@example.com".to_string()));
2277 assert_eq!(
2278 user.avatar_url,
2279 Some("https://example.com/avatar.png".to_string())
2280 );
2281 }
2282
2283 #[test]
2284 fn test_map_user_none() {
2285 assert!(map_user(None).is_none());
2286 }
2287
2288 #[test]
2289 fn test_map_user_required_with_user() {
2290 let cu_user = ClickUpUser {
2291 id: 1,
2292 username: "user1".to_string(),
2293 email: None,
2294 profile_picture: None,
2295 };
2296 let user = map_user_required(Some(&cu_user));
2297 assert_eq!(user.username, "user1");
2298 }
2299
2300 #[test]
2301 fn test_map_user_required_without_user() {
2302 let user = map_user_required(None);
2303 assert_eq!(user.id, "unknown");
2304 assert_eq!(user.username, "unknown");
2305 }
2306
2307 #[test]
2308 fn test_map_clickup_attachment_all_fields() {
2309 let raw = ClickUpAttachment {
2310 id: "att-1".into(),
2311 title: Some("report.log".into()),
2312 url: Some("https://attachments.clickup.com/abc/report.log".into()),
2313 size: Some(serde_json::json!("2048")),
2314 extension: Some("log".into()),
2315 mimetype: Some("text/plain".into()),
2316 date: Some("1704067200000".into()),
2317 user: Some(ClickUpUser {
2318 id: 7,
2319 username: "uploader".into(),
2320 email: None,
2321 profile_picture: None,
2322 }),
2323 };
2324 let meta = map_clickup_attachment(&raw);
2325 assert_eq!(meta.id, "att-1");
2326 assert_eq!(meta.filename, "report.log");
2327 assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
2328 assert_eq!(meta.size, Some(2048));
2329 assert_eq!(
2330 meta.url.as_deref(),
2331 Some("https://attachments.clickup.com/abc/report.log")
2332 );
2333 assert_eq!(meta.author.as_deref(), Some("uploader"));
2334 assert_eq!(meta.created_at, Some("2024-01-01T00:00:00Z".to_string()));
2335 assert!(!meta.cached);
2336 }
2337
2338 #[test]
2339 fn test_map_clickup_attachment_minimal_falls_back_to_url() {
2340 let raw = ClickUpAttachment {
2341 id: "att-2".into(),
2342 title: None,
2343 url: Some("https://cdn/a/b/screen.png?token=x".into()),
2344 size: Some(serde_json::json!(4096)),
2345 extension: None,
2346 mimetype: None,
2347 date: None,
2348 user: None,
2349 };
2350 let meta = map_clickup_attachment(&raw);
2351 assert_eq!(meta.filename, "screen.png");
2353 assert_eq!(meta.size, Some(4096));
2354 assert!(meta.created_at.is_none());
2355 assert!(meta.author.is_none());
2356 }
2357
2358 #[test]
2359 fn test_map_clickup_attachment_missing_everything() {
2360 let raw = ClickUpAttachment {
2361 id: "att-3".into(),
2362 title: None,
2363 url: None,
2364 size: None,
2365 extension: None,
2366 mimetype: None,
2367 date: None,
2368 user: None,
2369 };
2370 let meta = map_clickup_attachment(&raw);
2371 assert_eq!(meta.filename, "attachment-att-3");
2373 assert!(meta.url.is_none());
2374 assert!(meta.size.is_none());
2375 }
2376
2377 #[test]
2378 fn test_clickup_asset_capabilities() {
2379 let client =
2380 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2381 let caps = client.asset_capabilities();
2382 assert!(caps.issue.upload);
2383 assert!(caps.issue.download);
2384 assert!(caps.issue.list);
2385 assert!(!caps.issue.delete, "ClickUp has no delete attachment API");
2386 assert!(
2387 !caps.merge_request.upload,
2388 "ClickUp does not track merge requests",
2389 );
2390 }
2391
2392 #[test]
2393 fn test_map_comment() {
2394 let cu_comment = ClickUpComment {
2395 id: "42".to_string(),
2396 comment_text: "Nice work!".to_string(),
2397 user: Some(ClickUpUser {
2398 id: 1,
2399 username: "reviewer".to_string(),
2400 email: None,
2401 profile_picture: None,
2402 }),
2403 date: Some("1705312800000".to_string()),
2404 };
2405
2406 let comment = map_comment(&cu_comment);
2407 assert_eq!(comment.id, "42");
2408 assert_eq!(comment.body, "Nice work!");
2409 assert!(comment.author.is_some());
2410 assert_eq!(comment.author.unwrap().username, "reviewer");
2411 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
2413 assert!(comment.position.is_none());
2414 }
2415
2416 #[test]
2417 fn test_map_tags() {
2418 let tags = vec![
2419 ClickUpTag {
2420 name: "bug".to_string(),
2421 },
2422 ClickUpTag {
2423 name: "feature".to_string(),
2424 },
2425 ];
2426 let result = map_tags(&tags);
2427 assert_eq!(result, vec!["bug", "feature"]);
2428 }
2429
2430 #[test]
2431 fn test_map_tags_empty() {
2432 let result = map_tags(&[]);
2433 assert!(result.is_empty());
2434 }
2435
2436 #[test]
2437 fn test_priority_to_clickup() {
2438 assert_eq!(priority_to_clickup("urgent"), Some(1));
2439 assert_eq!(priority_to_clickup("high"), Some(2));
2440 assert_eq!(priority_to_clickup("normal"), Some(3));
2441 assert_eq!(priority_to_clickup("low"), Some(4));
2442 assert_eq!(priority_to_clickup("unknown"), None);
2443 }
2444
2445 #[test]
2446 fn test_api_url() {
2447 let client =
2448 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2449 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2450 assert_eq!(client.list_id, "12345");
2451 }
2452
2453 #[test]
2454 fn test_api_url_strips_trailing_slash() {
2455 let client = ClickUpClient::with_base_url(
2456 "https://api.clickup.com/api/v2/",
2457 "12345",
2458 token("token"),
2459 );
2460 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2461 }
2462
2463 #[test]
2464 fn test_with_team_id() {
2465 let client = ClickUpClient::new("12345", token("token")).with_team_id("9876");
2466 assert_eq!(client.team_id, Some("9876".to_string()));
2467 }
2468
2469 #[test]
2470 fn test_provider_name() {
2471 let client = ClickUpClient::new("12345", token("token"));
2472 assert_eq!(IssueProvider::provider_name(&client), "clickup");
2473 assert_eq!(MergeRequestProvider::provider_name(&client), "clickup");
2474 }
2475
2476 #[test]
2477 fn test_map_task_description_fallback() {
2478 let task = ClickUpTask {
2479 id: "abc".to_string(),
2480 custom_id: None,
2481 name: "Task".to_string(),
2482 description: Some("HTML description".to_string()),
2483 text_content: None,
2484 status: ClickUpStatus {
2485 status: "open".to_string(),
2486 status_type: Some("open".to_string()),
2487 },
2488 priority: None,
2489 tags: vec![],
2490 assignees: vec![],
2491 creator: None,
2492 url: "https://app.clickup.com/t/abc".to_string(),
2493 date_created: None,
2494 date_updated: None,
2495 parent: None,
2496 subtasks: None,
2497 dependencies: None,
2498 linked_tasks: None,
2499 attachments: Vec::new(),
2500 custom_fields: Vec::new(),
2501 };
2502
2503 let issue = map_task(&task);
2504 assert_eq!(issue.description, Some("HTML description".to_string()));
2505 }
2506
2507 #[test]
2508 fn test_map_state_custom_type() {
2509 let task = ClickUpTask {
2510 id: "abc".to_string(),
2511 custom_id: None,
2512 name: "Task".to_string(),
2513 description: None,
2514 text_content: None,
2515 status: ClickUpStatus {
2516 status: "in progress".to_string(),
2517 status_type: Some("custom".to_string()),
2518 },
2519 priority: None,
2520 tags: vec![],
2521 assignees: vec![],
2522 creator: None,
2523 url: "https://app.clickup.com/t/abc".to_string(),
2524 date_created: None,
2525 date_updated: None,
2526 parent: None,
2527 subtasks: None,
2528 dependencies: None,
2529 linked_tasks: None,
2530 attachments: Vec::new(),
2531 custom_fields: Vec::new(),
2532 };
2533
2534 let issue = map_task(&task);
2535 assert_eq!(issue.state, "open");
2536 }
2537
2538 #[test]
2539 fn test_map_task_with_parent() {
2540 let task = ClickUpTask {
2541 id: "child1".to_string(),
2542 custom_id: Some("DEV-100".to_string()),
2543 name: "Child task".to_string(),
2544 description: None,
2545 text_content: None,
2546 status: ClickUpStatus {
2547 status: "open".to_string(),
2548 status_type: Some("open".to_string()),
2549 },
2550 priority: None,
2551 tags: vec![],
2552 assignees: vec![],
2553 creator: None,
2554 url: "https://app.clickup.com/t/child1".to_string(),
2555 date_created: None,
2556 date_updated: None,
2557 parent: Some("parent123".to_string()),
2558 subtasks: None,
2559 dependencies: None,
2560 linked_tasks: None,
2561 attachments: Vec::new(),
2562 custom_fields: Vec::new(),
2563 };
2564
2565 let issue = map_task(&task);
2566 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
2567 assert!(issue.subtasks.is_empty());
2568 }
2569
2570 #[test]
2571 fn test_map_task_with_subtasks() {
2572 let subtask = ClickUpTask {
2573 id: "sub1".to_string(),
2574 custom_id: Some("DEV-201".to_string()),
2575 name: "Subtask 1".to_string(),
2576 description: None,
2577 text_content: None,
2578 status: ClickUpStatus {
2579 status: "in progress".to_string(),
2580 status_type: Some("custom".to_string()),
2581 },
2582 priority: None,
2583 tags: vec![],
2584 assignees: vec![],
2585 creator: None,
2586 url: "https://app.clickup.com/t/sub1".to_string(),
2587 date_created: None,
2588 date_updated: None,
2589 parent: Some("epic1".to_string()),
2590 subtasks: None,
2591 dependencies: None,
2592 linked_tasks: None,
2593 attachments: Vec::new(),
2594 custom_fields: Vec::new(),
2595 };
2596
2597 let task = ClickUpTask {
2598 id: "epic1".to_string(),
2599 custom_id: Some("DEV-200".to_string()),
2600 name: "Epic task".to_string(),
2601 description: None,
2602 text_content: None,
2603 status: ClickUpStatus {
2604 status: "open".to_string(),
2605 status_type: Some("open".to_string()),
2606 },
2607 priority: None,
2608 tags: vec![ClickUpTag {
2609 name: "epic".to_string(),
2610 }],
2611 assignees: vec![],
2612 creator: None,
2613 url: "https://app.clickup.com/t/epic1".to_string(),
2614 date_created: None,
2615 date_updated: None,
2616 parent: None,
2617 subtasks: Some(vec![subtask]),
2618 dependencies: None,
2619 linked_tasks: None,
2620 attachments: Vec::new(),
2621 custom_fields: Vec::new(),
2622 };
2623
2624 let issue = map_task(&task);
2625 assert_eq!(issue.key, "DEV-200");
2626 assert!(issue.parent.is_none());
2627 assert_eq!(issue.subtasks.len(), 1);
2628 assert_eq!(issue.subtasks[0].key, "DEV-201");
2629 assert_eq!(issue.subtasks[0].title, "Subtask 1");
2630 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2631 }
2632
2633 #[test]
2634 fn test_map_task_no_parent_no_subtasks() {
2635 let task = ClickUpTask {
2636 id: "standalone".to_string(),
2637 custom_id: None,
2638 name: "Standalone task".to_string(),
2639 description: None,
2640 text_content: None,
2641 status: ClickUpStatus {
2642 status: "open".to_string(),
2643 status_type: Some("open".to_string()),
2644 },
2645 priority: None,
2646 tags: vec![],
2647 assignees: vec![],
2648 creator: None,
2649 url: "https://app.clickup.com/t/standalone".to_string(),
2650 date_created: None,
2651 date_updated: None,
2652 parent: None,
2653 subtasks: None,
2654 dependencies: None,
2655 linked_tasks: None,
2656 attachments: Vec::new(),
2657 custom_fields: Vec::new(),
2658 };
2659
2660 let issue = map_task(&task);
2661 assert!(issue.parent.is_none());
2662 assert!(issue.subtasks.is_empty());
2663 }
2664
2665 #[test]
2666 fn test_deserialize_task_with_parent_and_subtasks() {
2667 let json = serde_json::json!({
2668 "id": "epic1",
2669 "custom_id": "DEV-300",
2670 "name": "Epic with subtasks",
2671 "status": {"status": "open", "type": "open"},
2672 "tags": [{"name": "epic"}],
2673 "assignees": [],
2674 "url": "https://app.clickup.com/t/epic1",
2675 "parent": null,
2676 "subtasks": [
2677 {
2678 "id": "sub1",
2679 "custom_id": "DEV-301",
2680 "name": "Subtask A",
2681 "status": {"status": "open", "type": "open"},
2682 "tags": [],
2683 "assignees": [],
2684 "url": "https://app.clickup.com/t/sub1",
2685 "parent": "epic1"
2686 },
2687 {
2688 "id": "sub2",
2689 "name": "Subtask B",
2690 "status": {"status": "closed", "type": "closed"},
2691 "tags": [],
2692 "assignees": [],
2693 "url": "https://app.clickup.com/t/sub2",
2694 "parent": "epic1"
2695 }
2696 ]
2697 });
2698
2699 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2700 assert!(task.parent.is_none());
2701 assert_eq!(task.subtasks.as_ref().unwrap().len(), 2);
2702 assert_eq!(
2703 task.subtasks.as_ref().unwrap()[0].custom_id,
2704 Some("DEV-301".to_string())
2705 );
2706 assert_eq!(
2707 task.subtasks.as_ref().unwrap()[1].parent,
2708 Some("epic1".to_string())
2709 );
2710
2711 let issue = map_task(&task);
2712 assert_eq!(issue.subtasks.len(), 2);
2713 assert_eq!(issue.subtasks[0].key, "DEV-301");
2714 assert_eq!(issue.subtasks[1].key, "CU-sub2");
2715 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2716 }
2717
2718 #[test]
2719 fn test_deserialize_task_without_subtasks_field() {
2720 let json = serde_json::json!({
2722 "id": "task1",
2723 "name": "Simple task",
2724 "status": {"status": "open", "type": "open"},
2725 "tags": [],
2726 "assignees": [],
2727 "url": "https://app.clickup.com/t/task1"
2728 });
2729
2730 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2731 assert!(task.parent.is_none());
2732 assert!(task.subtasks.is_none());
2733
2734 let issue = map_task(&task);
2735 assert!(issue.parent.is_none());
2736 assert!(issue.subtasks.is_empty());
2737 }
2738
2739 #[test]
2740 fn test_map_status_category_name_heuristics() {
2741 assert_eq!(map_status_category(Some("closed"), "Done"), "done");
2743 assert_eq!(map_status_category(Some("done"), "Complete"), "done");
2744
2745 assert_eq!(map_status_category(Some("custom"), "Backlog"), "backlog");
2747 assert_eq!(
2748 map_status_category(Some("custom"), "Product Backlog"),
2749 "backlog"
2750 );
2751 assert_eq!(map_status_category(Some("custom"), "To Do"), "todo");
2752 assert_eq!(map_status_category(Some("custom"), "New"), "todo");
2753 assert_eq!(
2754 map_status_category(Some("custom"), "In Progress"),
2755 "in_progress"
2756 );
2757 assert_eq!(
2758 map_status_category(Some("custom"), "Code Review"),
2759 "in_progress"
2760 );
2761 assert_eq!(map_status_category(Some("custom"), "Doing"), "in_progress");
2762 assert_eq!(map_status_category(Some("custom"), "Active"), "in_progress");
2763 assert_eq!(map_status_category(Some("custom"), "Done"), "done");
2764 assert_eq!(map_status_category(Some("custom"), "Completed"), "done");
2765 assert_eq!(map_status_category(Some("custom"), "Resolved"), "done");
2766 assert_eq!(
2767 map_status_category(Some("custom"), "Cancelled"),
2768 "cancelled"
2769 );
2770 assert_eq!(map_status_category(Some("custom"), "Archived"), "cancelled");
2771 assert_eq!(map_status_category(Some("custom"), "Rejected"), "cancelled");
2772
2773 assert_eq!(map_status_category(Some("open"), "Open"), "todo");
2775
2776 assert_eq!(
2778 map_status_category(Some("custom"), "Some Custom Status"),
2779 "in_progress"
2780 );
2781 }
2782
2783 #[test]
2784 fn test_priority_sort_key() {
2785 assert_eq!(priority_sort_key(Some("urgent")), 1);
2786 assert_eq!(priority_sort_key(Some("high")), 2);
2787 assert_eq!(priority_sort_key(Some("normal")), 3);
2788 assert_eq!(priority_sort_key(Some("low")), 4);
2789 assert_eq!(priority_sort_key(None), 5);
2790 }
2791
2792 mod integration {
2797 use super::*;
2798 use httpmock::prelude::*;
2799
2800 fn create_test_client(server: &MockServer) -> ClickUpClient {
2801 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2802 }
2803
2804 fn create_test_client_with_team(server: &MockServer) -> ClickUpClient {
2805 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2806 .with_team_id("9876")
2807 }
2808
2809 fn sample_task_json() -> serde_json::Value {
2810 serde_json::json!({
2811 "id": "abc123",
2812 "name": "Test Task",
2813 "description": "<p>Task description</p>",
2814 "text_content": "Task description",
2815 "status": {
2816 "status": "open",
2817 "type": "open"
2818 },
2819 "priority": {
2820 "id": "2",
2821 "priority": "high",
2822 "color": "#ffcc00"
2823 },
2824 "tags": [{"name": "bug"}],
2825 "assignees": [{"id": 1, "username": "dev1"}],
2826 "creator": {"id": 2, "username": "creator"},
2827 "url": "https://app.clickup.com/t/abc123",
2828 "date_created": "1704067200000",
2829 "date_updated": "1704153600000"
2830 })
2831 }
2832
2833 fn sample_closed_task_json() -> serde_json::Value {
2834 serde_json::json!({
2835 "id": "def456",
2836 "name": "Closed Task",
2837 "status": {
2838 "status": "done",
2839 "type": "closed"
2840 },
2841 "tags": [],
2842 "assignees": [],
2843 "url": "https://app.clickup.com/t/def456",
2844 "date_created": "1704067200000",
2845 "date_updated": "1704153600000"
2846 })
2847 }
2848
2849 fn sample_task_with_custom_id_json() -> serde_json::Value {
2850 serde_json::json!({
2851 "id": "abc123",
2852 "custom_id": "DEV-42",
2853 "name": "Task with custom ID",
2854 "status": {
2855 "status": "open",
2856 "type": "open"
2857 },
2858 "tags": [],
2859 "assignees": [],
2860 "url": "https://app.clickup.com/t/abc123",
2861 "date_created": "1704067200000",
2862 "date_updated": "1704153600000"
2863 })
2864 }
2865
2866 #[tokio::test]
2867 async fn test_get_issues() {
2868 let server = MockServer::start();
2869
2870 server.mock(|when, then| {
2871 when.method(GET)
2872 .path("/list/12345/task")
2873 .header("Authorization", "pk_test_token");
2874 then.status(200)
2875 .json_body(serde_json::json!({"tasks": [sample_task_json()]}));
2876 });
2877
2878 let client = create_test_client(&server);
2879 let issues = client
2880 .get_issues(IssueFilter::default())
2881 .await
2882 .unwrap()
2883 .items;
2884
2885 assert_eq!(issues.len(), 1);
2886 assert_eq!(issues[0].key, "CU-abc123");
2887 assert_eq!(issues[0].title, "Test Task");
2888 assert_eq!(issues[0].source, "clickup");
2889 assert_eq!(issues[0].priority, Some("high".to_string()));
2890 assert_eq!(
2892 issues[0].created_at,
2893 Some("2024-01-01T00:00:00Z".to_string())
2894 );
2895 }
2896
2897 #[tokio::test]
2906 async fn test_get_issues_resolves_select_field_display() {
2907 let server = MockServer::start();
2908 let task = serde_json::json!({
2909 "id": "t1",
2910 "name": "Tracked task",
2911 "status": {"status": "in progress", "type": "custom"},
2912 "tags": [],
2913 "assignees": [],
2914 "url": "https://app.clickup.com/t/t1",
2915 "date_created": "1704067200000",
2916 "date_updated": "1704153600000",
2917 "custom_fields": [
2918 {
2919 "id": "8b31f785-shipped",
2920 "name": "Shipped Version",
2921 "type": "drop_down",
2922 "type_config": { "options": [
2923 {"id": "o-dev", "name": "dev", "orderindex": 0},
2924 {"id": "o-test", "name": "test", "orderindex": 1},
2925 {"id": "o-prod", "name": "prod", "orderindex": 2}
2926 ]},
2927 "value": 0
2928 },
2929 {
2930 "id": "areas",
2931 "name": "Areas",
2932 "type": "labels",
2933 "type_config": { "options": [
2935 {"id": "a", "label": "backend"},
2936 {"id": "b", "label": "infra"}
2937 ]},
2938 "value": ["a", "b"]
2939 }
2940 ]
2941 });
2942
2943 server.mock(|when, then| {
2944 when.method(GET).path("/list/12345/task");
2945 then.status(200)
2946 .json_body(serde_json::json!({"tasks": [task]}));
2947 });
2948
2949 let client = create_test_client(&server);
2950 let issues = client
2951 .get_issues(IssueFilter::default())
2952 .await
2953 .unwrap()
2954 .items;
2955 let issue = issues.into_iter().next().expect("one issue");
2956
2957 assert_eq!(issue.status.as_deref(), Some("in progress"));
2959 assert_eq!(issue.status_category.as_deref(), Some("in_progress"));
2960
2961 let shipped = issue
2963 .custom_fields
2964 .get("8b31f785-shipped")
2965 .expect("shipped field present");
2966 assert_eq!(shipped.value, serde_json::json!(0));
2967 assert_eq!(shipped.display.as_deref(), Some("dev"));
2968
2969 let areas = issue.custom_fields.get("areas").expect("areas present");
2971 assert_eq!(areas.display.as_deref(), Some("backend, infra"));
2972
2973 let json = serde_json::to_string(&issue).unwrap();
2975 assert!(json.contains("\"display\":\"dev\""), "json = {json}");
2976 }
2977
2978 #[tokio::test]
2979 async fn test_get_issues_with_filters() {
2980 let server = MockServer::start();
2981
2982 server.mock(|when, then| {
2983 when.method(GET)
2984 .path("/list/12345/task")
2985 .query_param("include_closed", "true")
2986 .query_param("subtasks", "true")
2987 .query_param("tags[]", "bug");
2988 then.status(200).json_body(
2989 serde_json::json!({"tasks": [sample_task_json(), sample_closed_task_json()]}),
2990 );
2991 });
2992
2993 let client = create_test_client(&server);
2994 let issues = client
2995 .get_issues(IssueFilter {
2996 state: Some("all".to_string()),
2997 labels: Some(vec!["bug".to_string()]),
2998 ..Default::default()
2999 })
3000 .await
3001 .unwrap()
3002 .items;
3003
3004 assert_eq!(issues.len(), 2);
3005 }
3006
3007 #[tokio::test]
3008 async fn test_get_issues_state_filter_open() {
3009 let server = MockServer::start();
3010
3011 server.mock(|when, then| {
3012 when.method(GET).path("/list/12345/task");
3013 then.status(200).json_body(serde_json::json!({
3014 "tasks": [sample_task_json(), sample_closed_task_json()]
3015 }));
3016 });
3017
3018 let client = create_test_client(&server);
3019 let issues = client
3020 .get_issues(IssueFilter {
3021 state: Some("open".to_string()),
3022 ..Default::default()
3023 })
3024 .await
3025 .unwrap()
3026 .items;
3027
3028 assert_eq!(issues.len(), 1);
3029 assert_eq!(issues[0].state, "open");
3030 }
3031
3032 #[tokio::test]
3033 async fn test_get_issues_state_filter_closed() {
3034 let server = MockServer::start();
3035
3036 server.mock(|when, then| {
3037 when.method(GET)
3038 .path("/list/12345/task")
3039 .query_param("include_closed", "true");
3040 then.status(200).json_body(serde_json::json!({
3041 "tasks": [sample_task_json(), sample_closed_task_json()]
3042 }));
3043 });
3044
3045 let client = create_test_client(&server);
3046 let issues = client
3047 .get_issues(IssueFilter {
3048 state: Some("closed".to_string()),
3049 ..Default::default()
3050 })
3051 .await
3052 .unwrap()
3053 .items;
3054
3055 assert_eq!(issues.len(), 1);
3056 assert_eq!(issues[0].state, "closed");
3057 }
3058
3059 #[tokio::test]
3060 async fn test_get_issues_pagination() {
3061 let server = MockServer::start();
3062
3063 let tasks: Vec<serde_json::Value> = (0..5)
3064 .map(|i| {
3065 serde_json::json!({
3066 "id": format!("task{}", i),
3067 "name": format!("Task {}", i),
3068 "status": {"status": "open", "type": "open"},
3069 "tags": [],
3070 "assignees": [],
3071 "url": format!("https://app.clickup.com/t/task{}", i),
3072 "date_created": "1704067200000",
3073 "date_updated": "1704153600000"
3074 })
3075 })
3076 .collect();
3077
3078 server.mock(|when, then| {
3079 when.method(GET)
3080 .path("/list/12345/task")
3081 .query_param("page", "0");
3082 then.status(200)
3083 .json_body(serde_json::json!({"tasks": tasks}));
3084 });
3085
3086 let client = create_test_client(&server);
3087
3088 let issues = client
3089 .get_issues(IssueFilter {
3090 limit: Some(2),
3091 offset: Some(1),
3092 ..Default::default()
3093 })
3094 .await
3095 .unwrap()
3096 .items;
3097
3098 assert_eq!(issues.len(), 2);
3099 assert_eq!(issues[0].key, "CU-task1");
3100 assert_eq!(issues[1].key, "CU-task2");
3101 }
3102
3103 #[tokio::test]
3104 async fn test_get_issues_limit_zero() {
3105 let client = ClickUpClient::new("12345", token("token"));
3107 let issues = client
3108 .get_issues(IssueFilter {
3109 limit: Some(0),
3110 ..Default::default()
3111 })
3112 .await
3113 .unwrap()
3114 .items;
3115
3116 assert!(issues.is_empty());
3117 }
3118
3119 #[tokio::test]
3120 async fn test_get_issues_multi_page() {
3121 let server = MockServer::start();
3122
3123 let page0_tasks: Vec<serde_json::Value> = (0..100)
3125 .map(|i| {
3126 serde_json::json!({
3127 "id": format!("task{}", i),
3128 "name": format!("Task {}", i),
3129 "status": {"status": "open", "type": "open"},
3130 "tags": [],
3131 "assignees": [],
3132 "url": format!("https://app.clickup.com/t/task{}", i),
3133 "date_created": "1704067200000",
3134 "date_updated": "1704153600000"
3135 })
3136 })
3137 .collect();
3138
3139 let page1_tasks: Vec<serde_json::Value> = (100..150)
3141 .map(|i| {
3142 serde_json::json!({
3143 "id": format!("task{}", i),
3144 "name": format!("Task {}", i),
3145 "status": {"status": "open", "type": "open"},
3146 "tags": [],
3147 "assignees": [],
3148 "url": format!("https://app.clickup.com/t/task{}", i),
3149 "date_created": "1704067200000",
3150 "date_updated": "1704153600000"
3151 })
3152 })
3153 .collect();
3154
3155 server.mock(|when, then| {
3156 when.method(GET)
3157 .path("/list/12345/task")
3158 .query_param("page", "0");
3159 then.status(200)
3160 .json_body(serde_json::json!({"tasks": page0_tasks}));
3161 });
3162
3163 server.mock(|when, then| {
3164 when.method(GET)
3165 .path("/list/12345/task")
3166 .query_param("page", "1");
3167 then.status(200)
3168 .json_body(serde_json::json!({"tasks": page1_tasks}));
3169 });
3170
3171 let client = create_test_client(&server);
3172
3173 let issues = client
3175 .get_issues(IssueFilter {
3176 limit: Some(120),
3177 offset: Some(0),
3178 ..Default::default()
3179 })
3180 .await
3181 .unwrap()
3182 .items;
3183
3184 assert_eq!(issues.len(), 120);
3185 assert_eq!(issues[0].key, "CU-task0");
3186 assert_eq!(issues[99].key, "CU-task99");
3187 assert_eq!(issues[100].key, "CU-task100");
3188 assert_eq!(issues[119].key, "CU-task119");
3189 }
3190
3191 #[tokio::test]
3192 async fn test_get_issue() {
3193 let server = MockServer::start();
3194
3195 server.mock(|when, then| {
3196 when.method(GET).path("/task/abc123");
3197 then.status(200).json_body(sample_task_json());
3198 });
3199
3200 let client = create_test_client(&server);
3201 let issue = client.get_issue("CU-abc123").await.unwrap();
3202
3203 assert_eq!(issue.key, "CU-abc123");
3204 assert_eq!(issue.title, "Test Task");
3205 assert_eq!(issue.priority, Some("high".to_string()));
3206 }
3207
3208 #[tokio::test]
3209 async fn test_get_issue_by_custom_id() {
3210 let server = MockServer::start();
3211
3212 server.mock(|when, then| {
3213 when.method(GET)
3214 .path("/task/DEV-42")
3215 .query_param("custom_task_ids", "true")
3216 .query_param("team_id", "9876");
3217 then.status(200)
3218 .json_body(sample_task_with_custom_id_json());
3219 });
3220
3221 let client = create_test_client_with_team(&server);
3222 let issue = client.get_issue("DEV-42").await.unwrap();
3223
3224 assert_eq!(issue.key, "DEV-42");
3225 assert_eq!(issue.title, "Task with custom ID");
3226 }
3227
3228 #[tokio::test]
3229 async fn test_get_issue_custom_id_without_team_fails() {
3230 let client = ClickUpClient::new("12345", token("token"));
3231 let result = client.get_issue("DEV-42").await;
3232 assert!(result.is_err());
3233 }
3234
3235 #[tokio::test]
3236 async fn test_create_issue_with_custom_id_retry() {
3237 let server = MockServer::start();
3238
3239 server.mock(|when, then| {
3241 when.method(POST)
3242 .path("/list/12345/task")
3243 .body_includes("\"name\":\"New Task\"");
3244 then.status(200).json_body(sample_task_json());
3245 });
3246
3247 let mut task_with_custom_id = sample_task_json();
3249 task_with_custom_id["custom_id"] = serde_json::json!("DEV-100");
3250
3251 server.mock(|when, then| {
3252 when.method(GET).path("/task/abc123");
3253 then.status(200).json_body(task_with_custom_id);
3254 });
3255
3256 let client = create_test_client(&server);
3257 let issue = client
3258 .create_issue(CreateIssueInput {
3259 title: "New Task".to_string(),
3260 description: Some("Description".to_string()),
3261 labels: vec!["bug".to_string()],
3262 ..Default::default()
3263 })
3264 .await
3265 .unwrap();
3266
3267 assert_eq!(issue.key, "DEV-100");
3269 }
3270
3271 #[tokio::test]
3272 async fn test_create_issue_fallback_without_custom_id() {
3273 let server = MockServer::start();
3274
3275 server.mock(|when, then| {
3277 when.method(POST)
3278 .path("/list/12345/task")
3279 .body_includes("\"name\":\"New Task\"");
3280 then.status(200).json_body(sample_task_json());
3281 });
3282
3283 server.mock(|when, then| {
3285 when.method(GET).path("/task/abc123");
3286 then.status(200).json_body(sample_task_json());
3287 });
3288
3289 let client = create_test_client(&server);
3290 let issue = client
3291 .create_issue(CreateIssueInput {
3292 title: "New Task".to_string(),
3293 ..Default::default()
3294 })
3295 .await
3296 .unwrap();
3297
3298 assert_eq!(issue.key, "CU-abc123");
3300 }
3301
3302 #[tokio::test]
3303 async fn test_create_issue_with_priority() {
3304 let server = MockServer::start();
3305
3306 let mut task = sample_task_json();
3308 task["custom_id"] = serde_json::json!("DEV-101");
3309
3310 server.mock(|when, then| {
3311 when.method(POST)
3312 .path("/list/12345/task")
3313 .body_includes("\"priority\":1");
3314 then.status(200).json_body(task);
3315 });
3316
3317 let client = create_test_client(&server);
3318 let result = client
3319 .create_issue(CreateIssueInput {
3320 title: "Urgent Task".to_string(),
3321 priority: Some("urgent".to_string()),
3322 ..Default::default()
3323 })
3324 .await;
3325
3326 assert!(result.is_ok());
3327 assert_eq!(result.unwrap().key, "DEV-101");
3328 }
3329
3330 #[tokio::test]
3331 async fn test_update_issue() {
3332 let server = MockServer::start();
3333
3334 server.mock(|when, then| {
3335 when.method(PUT)
3336 .path("/task/abc123")
3337 .body_includes("\"name\":\"Updated Task\"");
3338 then.status(200).json_body(sample_task_json());
3339 });
3340
3341 server.mock(|when, then| {
3342 when.method(GET).path("/task/abc123");
3343 then.status(200).json_body(sample_task_json());
3344 });
3345
3346 let client = create_test_client(&server);
3347 let issue = client
3348 .update_issue(
3349 "CU-abc123",
3350 UpdateIssueInput {
3351 title: Some("Updated Task".to_string()),
3352 ..Default::default()
3353 },
3354 )
3355 .await
3356 .unwrap();
3357
3358 assert_eq!(issue.key, "CU-abc123");
3359 }
3360
3361 #[tokio::test]
3362 async fn test_update_issue_by_custom_id() {
3363 let server = MockServer::start();
3364
3365 server.mock(|when, then| {
3366 when.method(PUT)
3367 .path("/task/DEV-42")
3368 .query_param("custom_task_ids", "true")
3369 .query_param("team_id", "9876");
3370 then.status(200)
3371 .json_body(sample_task_with_custom_id_json());
3372 });
3373
3374 server.mock(|when, then| {
3375 when.method(GET)
3376 .path("/task/DEV-42")
3377 .query_param("custom_task_ids", "true")
3378 .query_param("team_id", "9876");
3379 then.status(200)
3380 .json_body(sample_task_with_custom_id_json());
3381 });
3382
3383 let client = create_test_client_with_team(&server);
3384 let issue = client
3385 .update_issue(
3386 "DEV-42",
3387 UpdateIssueInput {
3388 title: Some("Updated".to_string()),
3389 ..Default::default()
3390 },
3391 )
3392 .await
3393 .unwrap();
3394
3395 assert_eq!(issue.key, "DEV-42");
3396 }
3397
3398 #[tokio::test]
3399 async fn test_update_issue_state_mapping() {
3400 let server = MockServer::start();
3401
3402 server.mock(|when, then| {
3404 when.method(GET).path("/list/12345");
3405 then.status(200).json_body(serde_json::json!({
3406 "statuses": [
3407 {"status": "to do", "type": "open"},
3408 {"status": "in progress", "type": "custom"},
3409 {"status": "complete", "type": "closed"}
3410 ]
3411 }));
3412 });
3413
3414 server.mock(|when, then| {
3415 when.method(PUT)
3416 .path("/task/abc123")
3417 .body_includes("\"status\":\"complete\"");
3418 then.status(200).json_body(sample_task_json());
3419 });
3420
3421 server.mock(|when, then| {
3422 when.method(GET).path("/task/abc123");
3423 then.status(200).json_body(sample_task_json());
3424 });
3425
3426 let client = create_test_client(&server);
3427 let result = client
3428 .update_issue(
3429 "CU-abc123",
3430 UpdateIssueInput {
3431 state: Some("closed".to_string()),
3432 ..Default::default()
3433 },
3434 )
3435 .await;
3436
3437 assert!(result.is_ok());
3438 }
3439
3440 #[tokio::test]
3443 async fn test_update_issue_state_refetch_returns_fresh_state() {
3444 let server = MockServer::start();
3445
3446 server.mock(|when, then| {
3447 when.method(GET).path("/list/12345");
3448 then.status(200).json_body(serde_json::json!({
3449 "statuses": [
3450 {"status": "to do", "type": "open"},
3451 {"status": "complete", "type": "closed"}
3452 ]
3453 }));
3454 });
3455
3456 server.mock(|when, then| {
3458 when.method(PUT)
3459 .path("/task/abc123")
3460 .body_includes("\"status\":\"complete\"");
3461 then.status(200).json_body(sample_task_json()); });
3463
3464 server.mock(|when, then| {
3466 when.method(GET).path("/task/abc123");
3467 then.status(200).json_body(serde_json::json!({
3468 "id": "abc123",
3469 "name": "Test Task",
3470 "status": {
3471 "status": "complete",
3472 "type": "closed"
3473 },
3474 "tags": [{"name": "bug"}],
3475 "assignees": [{"id": 1, "username": "dev1"}],
3476 "url": "https://app.clickup.com/t/abc123",
3477 "date_created": "1704067200000",
3478 "date_updated": "1704153600000"
3479 }));
3480 });
3481
3482 let client = create_test_client(&server);
3483 let issue = client
3484 .update_issue(
3485 "CU-abc123",
3486 UpdateIssueInput {
3487 state: Some("closed".to_string()),
3488 ..Default::default()
3489 },
3490 )
3491 .await
3492 .unwrap();
3493
3494 assert_eq!(issue.state, "closed");
3495 }
3496
3497 #[tokio::test]
3498 async fn test_update_issue_state_open_mapping() {
3499 let server = MockServer::start();
3500
3501 server.mock(|when, then| {
3502 when.method(GET).path("/list/12345");
3503 then.status(200).json_body(serde_json::json!({
3504 "statuses": [
3505 {"status": "to do", "type": "open"},
3506 {"status": "complete", "type": "closed"}
3507 ]
3508 }));
3509 });
3510
3511 server.mock(|when, then| {
3512 when.method(PUT)
3513 .path("/task/abc123")
3514 .body_includes("\"status\":\"to do\"");
3515 then.status(200).json_body(sample_task_json());
3516 });
3517
3518 server.mock(|when, then| {
3519 when.method(GET).path("/task/abc123");
3520 then.status(200).json_body(sample_task_json());
3521 });
3522
3523 let client = create_test_client(&server);
3524 let result = client
3525 .update_issue(
3526 "CU-abc123",
3527 UpdateIssueInput {
3528 state: Some("open".to_string()),
3529 ..Default::default()
3530 },
3531 )
3532 .await;
3533
3534 assert!(result.is_ok());
3535 }
3536
3537 #[tokio::test]
3538 async fn test_update_issue_exact_status_name() {
3539 let server = MockServer::start();
3540
3541 server.mock(|when, then| {
3543 when.method(PUT)
3544 .path("/task/abc123")
3545 .body_includes("\"status\":\"in progress\"");
3546 then.status(200).json_body(sample_task_json());
3547 });
3548
3549 server.mock(|when, then| {
3550 when.method(GET).path("/task/abc123");
3551 then.status(200).json_body(sample_task_json());
3552 });
3553
3554 let client = create_test_client(&server);
3555 let result = client
3556 .update_issue(
3557 "CU-abc123",
3558 UpdateIssueInput {
3559 state: Some("in progress".to_string()),
3560 ..Default::default()
3561 },
3562 )
3563 .await;
3564
3565 assert!(result.is_ok());
3566 }
3567
3568 #[tokio::test]
3569 async fn test_get_comments() {
3570 let server = MockServer::start();
3571
3572 server.mock(|when, then| {
3573 when.method(GET).path("/task/abc123/comment");
3574 then.status(200).json_body(serde_json::json!({
3575 "comments": [{
3576 "id": "1",
3577 "comment_text": "Looks good!",
3578 "user": {"id": 1, "username": "reviewer"},
3579 "date": "1705312800000"
3580 }]
3581 }));
3582 });
3583
3584 let client = create_test_client(&server);
3585 let comments = client.get_comments("CU-abc123").await.unwrap().items;
3586
3587 assert_eq!(comments.len(), 1);
3588 assert_eq!(comments[0].body, "Looks good!");
3589 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
3590 assert_eq!(
3592 comments[0].created_at,
3593 Some("2024-01-15T10:00:00Z".to_string())
3594 );
3595 }
3596
3597 #[tokio::test]
3598 async fn test_add_comment() {
3599 let server = MockServer::start();
3600
3601 server.mock(|when, then| {
3603 when.method(POST)
3604 .path("/task/abc123/comment")
3605 .body_includes("\"comment_text\":\"\"")
3609 .body_includes("\"comment\":[{\"text\":\"My comment\"}]");
3610 then.status(200).json_body(serde_json::json!({
3611 "id": 458315,
3612 "hist_id": "26b2d7f1-test",
3613 "date": 1705312800000_i64
3614 }));
3615 });
3616
3617 let client = create_test_client(&server);
3618 let comment = IssueProvider::add_comment(&client, "CU-abc123", "My comment")
3619 .await
3620 .unwrap();
3621
3622 assert_eq!(comment.body, "My comment");
3623 assert_eq!(comment.id, "458315");
3624 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
3625 }
3626
3627 #[tokio::test]
3628 async fn test_handle_response_401() {
3629 let server = MockServer::start();
3630
3631 server.mock(|when, then| {
3632 when.method(GET).path("/list/12345/task");
3633 then.status(401).body("Token invalid");
3634 });
3635
3636 let client = create_test_client(&server);
3637 let result = client.get_issues(IssueFilter::default()).await;
3638
3639 assert!(result.is_err());
3640 let err = result.unwrap_err();
3641 assert!(matches!(err, Error::Unauthorized(_)));
3642 }
3643
3644 #[tokio::test]
3645 async fn test_handle_response_404() {
3646 let server = MockServer::start();
3647
3648 server.mock(|when, then| {
3649 when.method(GET).path("/task/nonexistent");
3650 then.status(404).body("Task not found");
3651 });
3652
3653 let client = create_test_client(&server);
3654 let result = client.get_issue("CU-nonexistent").await;
3655
3656 assert!(result.is_err());
3657 let err = result.unwrap_err();
3658 assert!(matches!(err, Error::NotFound(_)));
3659 }
3660
3661 #[tokio::test]
3662 async fn test_handle_response_500() {
3663 let server = MockServer::start();
3664
3665 server.mock(|when, then| {
3666 when.method(GET).path("/list/12345/task");
3667 then.status(500).body("Internal Server Error");
3668 });
3669
3670 let client = create_test_client(&server);
3671 let result = client.get_issues(IssueFilter::default()).await;
3672
3673 assert!(result.is_err());
3674 let err = result.unwrap_err();
3675 assert!(matches!(err, Error::ServerError { .. }));
3676 }
3677
3678 #[tokio::test]
3679 async fn test_mr_methods_unsupported() {
3680 let client = ClickUpClient::new("12345", token("token"));
3681
3682 let result = client.get_merge_requests(MrFilter::default()).await;
3683 assert!(matches!(
3684 result.unwrap_err(),
3685 Error::ProviderUnsupported { .. }
3686 ));
3687
3688 let result = client.get_merge_request("mr#1").await;
3689 assert!(matches!(
3690 result.unwrap_err(),
3691 Error::ProviderUnsupported { .. }
3692 ));
3693
3694 let result = client.get_discussions("mr#1").await;
3695 assert!(matches!(
3696 result.unwrap_err(),
3697 Error::ProviderUnsupported { .. }
3698 ));
3699
3700 let result = client.get_diffs("mr#1").await;
3701 assert!(matches!(
3702 result.unwrap_err(),
3703 Error::ProviderUnsupported { .. }
3704 ));
3705
3706 let result = MergeRequestProvider::add_comment(
3707 &client,
3708 "mr#1",
3709 CreateCommentInput {
3710 body: "test".to_string(),
3711 position: None,
3712 discussion_id: None,
3713 },
3714 )
3715 .await;
3716 assert!(matches!(
3717 result.unwrap_err(),
3718 Error::ProviderUnsupported { .. }
3719 ));
3720 }
3721
3722 #[tokio::test]
3723 async fn test_get_current_user() {
3724 let server = MockServer::start();
3725
3726 server.mock(|when, then| {
3727 when.method(GET).path("/list/12345/task");
3728 then.status(200).json_body(serde_json::json!({"tasks": []}));
3729 });
3730
3731 let client = create_test_client(&server);
3732 let user = client.get_current_user().await.unwrap();
3733
3734 assert_eq!(user.username, "clickup-user");
3735 }
3736
3737 #[tokio::test]
3738 async fn test_get_current_user_auth_failure() {
3739 let server = MockServer::start();
3740
3741 server.mock(|when, then| {
3742 when.method(GET).path("/list/12345/task");
3743 then.status(401).body("Unauthorized");
3744 });
3745
3746 let client = create_test_client(&server);
3747 let result = client.get_current_user().await;
3748
3749 assert!(result.is_err());
3750 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
3751 }
3752
3753 #[tokio::test]
3754 async fn test_get_issue_includes_subtasks() {
3755 let server = MockServer::start();
3756
3757 let task_with_subtasks = serde_json::json!({
3758 "id": "epic1",
3759 "custom_id": "DEV-400",
3760 "name": "Epic Task",
3761 "status": {"status": "open", "type": "open"},
3762 "tags": [{"name": "epic"}],
3763 "assignees": [],
3764 "creator": {"id": 1, "username": "author"},
3765 "url": "https://app.clickup.com/t/epic1",
3766 "date_created": "1704067200000",
3767 "date_updated": "1704153600000",
3768 "subtasks": [
3769 {
3770 "id": "sub1",
3771 "custom_id": "DEV-401",
3772 "name": "Subtask 1",
3773 "status": {"status": "open", "type": "open"},
3774 "tags": [],
3775 "assignees": [],
3776 "url": "https://app.clickup.com/t/sub1",
3777 "parent": "epic1"
3778 },
3779 {
3780 "id": "sub2",
3781 "custom_id": "DEV-402",
3782 "name": "Subtask 2",
3783 "status": {"status": "closed", "type": "closed"},
3784 "tags": [],
3785 "assignees": [],
3786 "url": "https://app.clickup.com/t/sub2",
3787 "parent": "epic1"
3788 }
3789 ]
3790 });
3791
3792 server.mock(|when, then| {
3793 when.method(GET)
3794 .path("/task/epic1")
3795 .query_param("include_subtasks", "true");
3796 then.status(200).json_body(task_with_subtasks);
3797 });
3798
3799 let client = create_test_client(&server);
3800 let issue = client.get_issue("CU-epic1").await.unwrap();
3801
3802 assert_eq!(issue.key, "DEV-400");
3803 assert!(issue.parent.is_none());
3804 assert_eq!(issue.subtasks.len(), 2);
3805 assert_eq!(issue.subtasks[0].key, "DEV-401");
3806 assert_eq!(issue.subtasks[0].title, "Subtask 1");
3807 assert_eq!(issue.subtasks[0].state, "open");
3808 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
3809 assert_eq!(issue.subtasks[1].key, "DEV-402");
3810 assert_eq!(issue.subtasks[1].state, "closed");
3811 }
3812
3813 #[tokio::test]
3814 async fn test_get_issue_no_subtasks() {
3815 let server = MockServer::start();
3816
3817 let task = sample_task_json();
3818
3819 server.mock(|when, then| {
3820 when.method(GET)
3821 .path("/task/abc123")
3822 .query_param("include_subtasks", "true");
3823 then.status(200).json_body(task);
3824 });
3825
3826 let client = create_test_client(&server);
3827 let issue = client.get_issue("CU-abc123").await.unwrap();
3828
3829 assert!(issue.subtasks.is_empty());
3830 assert!(issue.parent.is_none());
3831 }
3832
3833 #[tokio::test]
3834 async fn test_get_issue_custom_id_includes_subtasks() {
3835 let server = MockServer::start();
3836
3837 let task = serde_json::json!({
3838 "id": "task1",
3839 "custom_id": "DEV-500",
3840 "name": "Task via custom ID",
3841 "status": {"status": "open", "type": "open"},
3842 "tags": [],
3843 "assignees": [],
3844 "url": "https://app.clickup.com/t/task1",
3845 "parent": "parent123",
3846 "subtasks": []
3847 });
3848
3849 server.mock(|when, then| {
3850 when.method(GET)
3851 .path("/task/DEV-500")
3852 .query_param("custom_task_ids", "true")
3853 .query_param("team_id", "9876")
3854 .query_param("include_subtasks", "true");
3855 then.status(200).json_body(task);
3856 });
3857
3858 let client = create_test_client_with_team(&server);
3859 let issue = client.get_issue("DEV-500").await.unwrap();
3860
3861 assert_eq!(issue.key, "DEV-500");
3862 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
3863 assert!(issue.subtasks.is_empty());
3864 }
3865
3866 #[tokio::test]
3867 async fn test_update_issue_with_parent_id() {
3868 let server = MockServer::start();
3869
3870 let parent_task = serde_json::json!({
3872 "id": "parent_native_id",
3873 "custom_id": "DEV-600",
3874 "name": "Parent Epic",
3875 "status": {"status": "open", "type": "open"},
3876 "tags": [],
3877 "assignees": [],
3878 "url": "https://app.clickup.com/t/parent_native_id"
3879 });
3880
3881 server.mock(|when, then| {
3882 when.method(GET)
3883 .path("/task/DEV-600")
3884 .query_param("custom_task_ids", "true")
3885 .query_param("team_id", "9876");
3886 then.status(200).json_body(parent_task);
3887 });
3888
3889 let updated_task = serde_json::json!({
3891 "id": "child1",
3892 "custom_id": "DEV-601",
3893 "name": "Child Task",
3894 "status": {"status": "open", "type": "open"},
3895 "tags": [],
3896 "assignees": [],
3897 "url": "https://app.clickup.com/t/child1",
3898 "parent": "parent_native_id"
3899 });
3900
3901 server.mock(|when, then| {
3902 when.method(PUT)
3903 .path("/task/DEV-601")
3904 .query_param("custom_task_ids", "true")
3905 .query_param("team_id", "9876")
3906 .body_includes("\"parent\":\"parent_native_id\"");
3907 then.status(200).json_body(updated_task.clone());
3908 });
3909
3910 server.mock(|when, then| {
3911 when.method(GET)
3912 .path("/task/DEV-601")
3913 .query_param("custom_task_ids", "true")
3914 .query_param("team_id", "9876");
3915 then.status(200).json_body(updated_task);
3916 });
3917
3918 let client = create_test_client_with_team(&server);
3919 let issue = client
3920 .update_issue(
3921 "DEV-601",
3922 UpdateIssueInput {
3923 parent_id: Some("DEV-600".to_string()),
3924 ..Default::default()
3925 },
3926 )
3927 .await
3928 .unwrap();
3929
3930 assert_eq!(issue.key, "DEV-601");
3931 assert_eq!(issue.parent, Some("CU-parent_native_id".to_string()));
3932 }
3933
3934 #[tokio::test]
3935 async fn test_create_issue_with_parent() {
3936 let server = MockServer::start();
3937
3938 let parent_task = serde_json::json!({
3940 "id": "parent_id",
3941 "custom_id": "DEV-700",
3942 "name": "Parent",
3943 "status": {"status": "open", "type": "open"},
3944 "tags": [],
3945 "assignees": [],
3946 "url": "https://app.clickup.com/t/parent_id"
3947 });
3948
3949 server.mock(|when, then| {
3950 when.method(GET)
3951 .path("/task/DEV-700")
3952 .query_param("custom_task_ids", "true")
3953 .query_param("team_id", "9876");
3954 then.status(200).json_body(parent_task);
3955 });
3956
3957 let created_task = serde_json::json!({
3959 "id": "new_child",
3960 "custom_id": "DEV-701",
3961 "name": "New Subtask",
3962 "status": {"status": "open", "type": "open"},
3963 "tags": [],
3964 "assignees": [],
3965 "url": "https://app.clickup.com/t/new_child",
3966 "parent": "parent_id"
3967 });
3968
3969 server.mock(|when, then| {
3970 when.method(POST)
3971 .path("/list/12345/task")
3972 .body_includes("\"parent\":\"parent_id\"");
3973 then.status(200).json_body(created_task);
3974 });
3975
3976 let client = create_test_client_with_team(&server);
3977 let issue = client
3978 .create_issue(CreateIssueInput {
3979 title: "New Subtask".to_string(),
3980 parent: Some("DEV-700".to_string()),
3981 ..Default::default()
3982 })
3983 .await
3984 .unwrap();
3985
3986 assert_eq!(issue.key, "DEV-701");
3987 assert_eq!(issue.parent, Some("CU-parent_id".to_string()));
3988 }
3989
3990 #[tokio::test]
3991 async fn test_get_issues_search_filter() {
3992 let server = MockServer::start_async().await;
3993
3994 server.mock(|when, then| {
3995 when.method(GET).path("/list/12345/task");
3996 then.status(200).json_body(serde_json::json!({
3997 "tasks": [
3998 {
3999 "id": "1", "name": "Fix login bug",
4000 "description": "Authentication fails",
4001 "text_content": "Authentication fails",
4002 "status": {"status": "open", "type": "open"},
4003 "tags": [], "assignees": [],
4004 "url": "https://app.clickup.com/t/1"
4005 },
4006 {
4007 "id": "2", "name": "Add dark mode",
4008 "description": "Theme support",
4009 "text_content": "Theme support",
4010 "status": {"status": "open", "type": "open"},
4011 "tags": [], "assignees": [],
4012 "url": "https://app.clickup.com/t/2"
4013 },
4014 {
4015 "id": "3", "name": "Update docs",
4016 "description": "Fix login instructions",
4017 "text_content": "Fix login instructions",
4018 "status": {"status": "open", "type": "open"},
4019 "tags": [], "assignees": [],
4020 "url": "https://app.clickup.com/t/3"
4021 }
4022 ]
4023 }));
4024 });
4025
4026 let client = create_test_client(&server);
4027
4028 let issues = client
4030 .get_issues(IssueFilter {
4031 search: Some("login".to_string()),
4032 ..Default::default()
4033 })
4034 .await
4035 .unwrap()
4036 .items;
4037 assert_eq!(issues.len(), 2);
4038 assert!(issues.iter().any(|i| i.title == "Fix login bug"));
4039 assert!(issues.iter().any(|i| i.title == "Update docs")); let issues = client
4043 .get_issues(IssueFilter {
4044 search: Some("CU-2".to_string()),
4045 ..Default::default()
4046 })
4047 .await
4048 .unwrap()
4049 .items;
4050 assert_eq!(issues.len(), 1);
4051 assert_eq!(issues[0].title, "Add dark mode");
4052
4053 let issues = client
4055 .get_issues(IssueFilter {
4056 search: Some("nonexistent".to_string()),
4057 ..Default::default()
4058 })
4059 .await
4060 .unwrap()
4061 .items;
4062 assert!(issues.is_empty());
4063 }
4064
4065 #[tokio::test]
4066 async fn test_get_issues_sort_by_priority() {
4067 let server = MockServer::start_async().await;
4068
4069 server.mock(|when, then| {
4070 when.method(GET).path("/list/12345/task");
4071 then.status(200).json_body(serde_json::json!({
4072 "tasks": [
4073 {
4074 "id": "1", "name": "Low task",
4075 "status": {"status": "open", "type": "open"},
4076 "priority": {"id": "4", "priority": "low"},
4077 "tags": [], "assignees": [],
4078 "url": "https://app.clickup.com/t/1"
4079 },
4080 {
4081 "id": "2", "name": "Urgent task",
4082 "status": {"status": "open", "type": "open"},
4083 "priority": {"id": "1", "priority": "urgent"},
4084 "tags": [], "assignees": [],
4085 "url": "https://app.clickup.com/t/2"
4086 },
4087 {
4088 "id": "3", "name": "Normal task",
4089 "status": {"status": "open", "type": "open"},
4090 "priority": {"id": "3", "priority": "normal"},
4091 "tags": [], "assignees": [],
4092 "url": "https://app.clickup.com/t/3"
4093 }
4094 ]
4095 }));
4096 });
4097
4098 let client = create_test_client(&server);
4099
4100 let result = client
4102 .get_issues(IssueFilter {
4103 sort_by: Some("priority".to_string()),
4104 sort_order: Some("asc".to_string()),
4105 ..Default::default()
4106 })
4107 .await
4108 .unwrap();
4109 assert_eq!(result.items[0].priority, Some("urgent".to_string()));
4110 assert_eq!(result.items[1].priority, Some("normal".to_string()));
4111 assert_eq!(result.items[2].priority, Some("low".to_string()));
4112
4113 let sort_info = result.sort_info.unwrap();
4115 assert_eq!(sort_info.sort_by, Some("priority".to_string()));
4116 assert!(sort_info.available_sorts.contains(&"priority".into()));
4117 }
4118
4119 #[tokio::test]
4120 async fn test_get_issues_sort_by_title() {
4121 let server = MockServer::start_async().await;
4122
4123 server.mock(|when, then| {
4124 when.method(GET).path("/list/12345/task");
4125 then.status(200).json_body(serde_json::json!({
4126 "tasks": [
4127 {
4128 "id": "1", "name": "Charlie",
4129 "status": {"status": "open", "type": "open"},
4130 "tags": [], "assignees": [],
4131 "url": "https://app.clickup.com/t/1"
4132 },
4133 {
4134 "id": "2", "name": "Alpha",
4135 "status": {"status": "open", "type": "open"},
4136 "tags": [], "assignees": [],
4137 "url": "https://app.clickup.com/t/2"
4138 },
4139 {
4140 "id": "3", "name": "Bravo",
4141 "status": {"status": "open", "type": "open"},
4142 "tags": [], "assignees": [],
4143 "url": "https://app.clickup.com/t/3"
4144 }
4145 ]
4146 }));
4147 });
4148
4149 let client = create_test_client(&server);
4150
4151 let result = client
4152 .get_issues(IssueFilter {
4153 sort_by: Some("title".to_string()),
4154 sort_order: Some("asc".to_string()),
4155 ..Default::default()
4156 })
4157 .await
4158 .unwrap();
4159 assert_eq!(result.items[0].title, "Alpha");
4160 assert_eq!(result.items[1].title, "Bravo");
4161 assert_eq!(result.items[2].title, "Charlie");
4162 }
4163
4164 #[tokio::test]
4165 async fn test_get_statuses_category_mapping() {
4166 let server = MockServer::start_async().await;
4167
4168 server.mock(|when, then| {
4169 when.method(GET).path("/list/12345");
4170 then.status(200).json_body(serde_json::json!({
4171 "statuses": [
4172 {"status": "Backlog", "type": "custom", "color": "#aaa", "orderindex": 0},
4173 {"status": "To Do", "type": "open", "color": "#bbb", "orderindex": 1},
4174 {"status": "In Progress", "type": "custom", "color": "#ccc", "orderindex": 2},
4175 {"status": "In Review", "type": "custom", "color": "#ddd", "orderindex": 3},
4176 {"status": "Done", "type": "closed", "color": "#eee", "orderindex": 4},
4177 {"status": "Cancelled", "type": "custom", "color": "#fff", "orderindex": 5},
4178 {"status": "Archived", "type": "custom", "color": "#000", "orderindex": 6}
4179 ]
4180 }));
4181 });
4182
4183 let client = create_test_client(&server);
4184 let statuses = client.get_statuses().await.unwrap().items;
4185
4186 assert_eq!(statuses.len(), 7);
4187 assert_eq!(statuses[0].name, "Backlog");
4188 assert_eq!(statuses[0].category, "backlog");
4189 assert_eq!(statuses[1].name, "To Do");
4190 assert_eq!(statuses[1].category, "todo");
4191 assert_eq!(statuses[2].name, "In Progress");
4192 assert_eq!(statuses[2].category, "in_progress");
4193 assert_eq!(statuses[3].name, "In Review");
4194 assert_eq!(statuses[3].category, "in_progress");
4195 assert_eq!(statuses[4].name, "Done");
4196 assert_eq!(statuses[4].category, "done");
4197 assert_eq!(statuses[5].name, "Cancelled");
4198 assert_eq!(statuses[5].category, "cancelled");
4199 assert_eq!(statuses[6].name, "Archived");
4200 assert_eq!(statuses[6].category, "cancelled");
4201 }
4202
4203 #[tokio::test]
4204 async fn test_get_issues_state_category_filter() {
4205 let server = MockServer::start_async().await;
4206
4207 server.mock(|when, then| {
4209 when.method(GET).path("/list/12345").query_param_exists("!");
4210 then.status(200).json_body(serde_json::json!({
4211 "statuses": [
4212 {"status": "Backlog", "type": "custom"},
4213 {"status": "To Do", "type": "open"},
4214 {"status": "In Progress", "type": "custom"},
4215 {"status": "Done", "type": "closed"}
4216 ]
4217 }));
4218 });
4219
4220 server.mock(|when, then| {
4222 when.method(GET).path("/list/12345");
4223 then.status(200).json_body(serde_json::json!({
4224 "statuses": [
4225 {"status": "Backlog", "type": "custom"},
4226 {"status": "To Do", "type": "open"},
4227 {"status": "In Progress", "type": "custom"},
4228 {"status": "Done", "type": "closed"}
4229 ]
4230 }));
4231 });
4232
4233 server.mock(|when, then| {
4234 when.method(GET).path("/list/12345/task");
4235 then.status(200).json_body(serde_json::json!({
4236 "tasks": [
4237 {
4238 "id": "1", "name": "Backlog task",
4239 "status": {"status": "Backlog", "type": "custom"},
4240 "tags": [], "assignees": [],
4241 "url": "https://app.clickup.com/t/1"
4242 },
4243 {
4244 "id": "2", "name": "In progress task",
4245 "status": {"status": "In Progress", "type": "custom"},
4246 "tags": [], "assignees": [],
4247 "url": "https://app.clickup.com/t/2"
4248 },
4249 {
4250 "id": "3", "name": "Todo task",
4251 "status": {"status": "To Do", "type": "open"},
4252 "tags": [], "assignees": [],
4253 "url": "https://app.clickup.com/t/3"
4254 }
4255 ]
4256 }));
4257 });
4258
4259 let client = create_test_client(&server);
4260
4261 let issues = client
4263 .get_issues(IssueFilter {
4264 state_category: Some("in_progress".to_string()),
4265 ..Default::default()
4266 })
4267 .await
4268 .unwrap()
4269 .items;
4270 assert_eq!(issues.len(), 1);
4271 assert_eq!(issues[0].title, "In progress task");
4272
4273 let issues = client
4275 .get_issues(IssueFilter {
4276 state_category: Some("backlog".to_string()),
4277 ..Default::default()
4278 })
4279 .await
4280 .unwrap()
4281 .items;
4282 assert_eq!(issues.len(), 1);
4283 assert_eq!(issues[0].title, "Backlog task");
4284 }
4285
4286 #[tokio::test]
4287 async fn test_get_issue_attachments_maps_all_fields() {
4288 let server = MockServer::start();
4289
4290 let task_json = serde_json::json!({
4291 "id": "abc123",
4292 "name": "Test",
4293 "status": {"status": "open", "type": "open"},
4294 "tags": [], "assignees": [],
4295 "url": "https://app.clickup.com/t/abc123",
4296 "date_created": "1704067200000",
4297 "date_updated": "1704067200000",
4298 "attachments": [
4299 {
4300 "id": "att-1",
4301 "title": "screen.png",
4302 "url": "https://attachments.clickup.com/abc/screen.png",
4303 "size": "12345",
4304 "extension": "png",
4305 "mimetype": "image/png",
4306 "date": "1704067200000",
4307 "user": {"id": 7, "username": "uploader"}
4308 }
4309 ]
4310 });
4311
4312 server.mock(|when, then| {
4313 when.method(GET).path("/task/abc123");
4314 then.status(200).json_body(task_json);
4315 });
4316
4317 let client = create_test_client(&server);
4318 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
4319 assert_eq!(assets.len(), 1);
4320 let a = &assets[0];
4321 assert_eq!(a.id, "att-1");
4322 assert_eq!(a.filename, "screen.png");
4323 assert_eq!(a.mime_type.as_deref(), Some("image/png"));
4324 assert_eq!(a.size, Some(12345));
4325 assert_eq!(a.author.as_deref(), Some("uploader"));
4326 }
4327
4328 #[tokio::test]
4329 async fn test_get_issue_attachments_empty_when_none() {
4330 let server = MockServer::start();
4331
4332 let task_json = serde_json::json!({
4333 "id": "abc123",
4334 "name": "Test",
4335 "status": {"status": "open", "type": "open"},
4336 "tags": [], "assignees": [],
4337 "url": "https://app.clickup.com/t/abc123",
4338 "date_created": "1704067200000",
4339 "date_updated": "1704067200000"
4340 });
4341
4342 server.mock(|when, then| {
4343 when.method(GET).path("/task/abc123");
4344 then.status(200).json_body(task_json);
4345 });
4346
4347 let client = create_test_client(&server);
4348 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
4349 assert!(assets.is_empty());
4350 }
4351
4352 #[tokio::test]
4353 async fn test_download_attachment_fetches_bytes() {
4354 let server = MockServer::start();
4355
4356 let task_json = serde_json::json!({
4357 "id": "abc123",
4358 "name": "Test",
4359 "status": {"status": "open", "type": "open"},
4360 "tags": [], "assignees": [],
4361 "url": "https://app.clickup.com/t/abc123",
4362 "date_created": "1704067200000",
4363 "date_updated": "1704067200000",
4364 "attachments": [
4365 {
4366 "id": "att-1",
4367 "title": "log.txt",
4368 "url": format!("{}/download/att-1", server.base_url()),
4369 }
4370 ]
4371 });
4372
4373 server.mock(|when, then| {
4374 when.method(GET).path("/task/abc123");
4375 then.status(200).json_body(task_json);
4376 });
4377 server.mock(|when, then| {
4378 when.method(GET).path("/download/att-1");
4379 then.status(200).body("hello world");
4380 });
4381
4382 let client = create_test_client(&server);
4383 let bytes = client
4384 .download_attachment("CU-abc123", "att-1")
4385 .await
4386 .unwrap();
4387 assert_eq!(bytes, b"hello world");
4388 }
4389
4390 #[tokio::test]
4391 async fn test_download_attachment_not_found() {
4392 let server = MockServer::start();
4393
4394 server.mock(|when, then| {
4395 when.method(GET).path("/task/abc123");
4396 then.status(200).json_body(serde_json::json!({
4397 "id": "abc123", "name": "Test",
4398 "status": {"status": "open", "type": "open"},
4399 "tags": [], "assignees": [],
4400 "url": "https://app.clickup.com/t/abc123",
4401 "date_created": "1704067200000",
4402 "date_updated": "1704067200000",
4403 "attachments": []
4404 }));
4405 });
4406
4407 let client = create_test_client(&server);
4408 let err = client
4409 .download_attachment("CU-abc123", "missing")
4410 .await
4411 .unwrap_err();
4412 assert!(matches!(err, Error::NotFound(_)));
4413 }
4414
4415 fn list_with_statuses() -> serde_json::Value {
4426 serde_json::json!({
4427 "statuses": [
4428 {"status": "to do", "type": "open"},
4429 {"status": "in progress", "type": "custom"},
4430 {"status": "review", "type": "custom"},
4431 {"status": "complete", "type": "closed"}
4432 ]
4433 })
4434 }
4435
4436 #[tokio::test]
4437 async fn test_update_issue_status_sets_custom_status() {
4438 let server = MockServer::start();
4439
4440 server.mock(|when, then| {
4441 when.method(GET).path("/list/12345");
4442 then.status(200).json_body(list_with_statuses());
4443 });
4444
4445 server.mock(|when, then| {
4446 when.method(PUT)
4447 .path("/task/abc123")
4448 .body_includes("\"status\":\"in progress\"");
4449 then.status(200).json_body(sample_task_json());
4450 });
4451
4452 server.mock(|when, then| {
4453 when.method(GET).path("/task/abc123");
4454 then.status(200).json_body(sample_task_json());
4455 });
4456
4457 let client = create_test_client(&server);
4458 let result = client
4459 .update_issue(
4460 "CU-abc123",
4461 UpdateIssueInput {
4462 status: Some("in progress".to_string()),
4463 ..Default::default()
4464 },
4465 )
4466 .await;
4467 assert!(result.is_ok(), "got {:?}", result.err());
4468 }
4469
4470 #[tokio::test]
4471 async fn test_update_issue_status_case_insensitive_match() {
4472 let server = MockServer::start();
4473
4474 server.mock(|when, then| {
4475 when.method(GET).path("/list/12345");
4476 then.status(200).json_body(list_with_statuses());
4477 });
4478
4479 server.mock(|when, then| {
4481 when.method(PUT)
4482 .path("/task/abc123")
4483 .body_includes("\"status\":\"review\"");
4484 then.status(200).json_body(sample_task_json());
4485 });
4486
4487 server.mock(|when, then| {
4488 when.method(GET).path("/task/abc123");
4489 then.status(200).json_body(sample_task_json());
4490 });
4491
4492 let client = create_test_client(&server);
4493 let result = client
4494 .update_issue(
4495 "CU-abc123",
4496 UpdateIssueInput {
4497 status: Some("REVIEW".to_string()),
4498 ..Default::default()
4499 },
4500 )
4501 .await;
4502 assert!(result.is_ok(), "got {:?}", result.err());
4503 }
4504
4505 #[tokio::test]
4506 async fn test_update_issue_status_unknown_fails_with_valid_list() {
4507 let server = MockServer::start();
4508
4509 server.mock(|when, then| {
4510 when.method(GET).path("/list/12345");
4511 then.status(200).json_body(list_with_statuses());
4512 });
4513
4514 let put_mock = server.mock(|when, then| {
4515 when.method(PUT).path("/task/abc123");
4516 then.status(200).json_body(sample_task_json());
4517 });
4518
4519 let client = create_test_client(&server);
4520 let err = client
4521 .update_issue(
4522 "CU-abc123",
4523 UpdateIssueInput {
4524 status: Some("released-to-prod".to_string()),
4525 ..Default::default()
4526 },
4527 )
4528 .await
4529 .expect_err("unknown status must fail before PUT");
4530 let msg = format!("{err:?}");
4531 assert!(msg.contains("Unknown ClickUp status"), "msg: {msg}");
4532 assert!(msg.contains("released-to-prod"), "msg: {msg}");
4533 assert!(msg.contains("in progress"), "list of valids missing: {msg}");
4534 put_mock.assert_calls(0);
4535 }
4536
4537 #[tokio::test]
4538 async fn test_update_issue_status_overrides_state_when_both_set() {
4539 let server = MockServer::start();
4540
4541 let list_mock = server.mock(|when, then| {
4545 when.method(GET).path("/list/12345");
4546 then.status(200).json_body(list_with_statuses());
4547 });
4548
4549 server.mock(|when, then| {
4550 when.method(PUT)
4551 .path("/task/abc123")
4552 .body_includes("\"status\":\"in progress\"");
4555 then.status(200).json_body(sample_task_json());
4556 });
4557
4558 server.mock(|when, then| {
4559 when.method(GET).path("/task/abc123");
4560 then.status(200).json_body(sample_task_json());
4561 });
4562
4563 let client = create_test_client(&server);
4564 let result = client
4565 .update_issue(
4566 "CU-abc123",
4567 UpdateIssueInput {
4568 state: Some("closed".to_string()),
4569 status: Some("in progress".to_string()),
4570 ..Default::default()
4571 },
4572 )
4573 .await;
4574 assert!(result.is_ok(), "got {:?}", result.err());
4575 list_mock.assert_calls(1);
4576 }
4577
4578 #[tokio::test]
4579 async fn test_update_issue_state_path_unchanged_when_status_absent() {
4580 let server = MockServer::start();
4584
4585 server.mock(|when, then| {
4586 when.method(GET).path("/list/12345");
4587 then.status(200).json_body(list_with_statuses());
4588 });
4589
4590 server.mock(|when, then| {
4591 when.method(PUT)
4592 .path("/task/abc123")
4593 .body_includes("\"status\":\"complete\"");
4594 then.status(200).json_body(sample_task_json());
4595 });
4596
4597 server.mock(|when, then| {
4598 when.method(GET).path("/task/abc123");
4599 then.status(200).json_body(sample_task_json());
4600 });
4601
4602 let client = create_test_client(&server);
4603 let result = client
4604 .update_issue(
4605 "CU-abc123",
4606 UpdateIssueInput {
4607 state: Some("closed".to_string()),
4608 ..Default::default()
4609 },
4610 )
4611 .await;
4612 assert!(result.is_ok(), "got {:?}", result.err());
4613 }
4614
4615 #[tokio::test]
4616 async fn test_update_issue_status_open_keyword_hints_at_state_field() {
4617 let server = MockServer::start();
4622
4623 server.mock(|when, then| {
4624 when.method(GET).path("/list/12345");
4625 then.status(200).json_body(list_with_statuses());
4626 });
4627
4628 let put_mock = server.mock(|when, then| {
4629 when.method(PUT).path("/task/abc123");
4630 then.status(200).json_body(sample_task_json());
4631 });
4632
4633 let client = create_test_client(&server);
4634 for keyword in ["open", "Opened", "CLOSED"] {
4635 let err = client
4636 .update_issue(
4637 "CU-abc123",
4638 UpdateIssueInput {
4639 status: Some(keyword.to_string()),
4640 ..Default::default()
4641 },
4642 )
4643 .await
4644 .expect_err("open/closed via `status` must fail");
4645 let msg = format!("{err:?}");
4646 assert!(msg.contains("state` field"), "keyword={keyword} msg={msg}");
4647 }
4648 put_mock.assert_calls(0);
4649 }
4650
4651 #[tokio::test]
4652 async fn test_update_issue_status_unknown_truncates_large_status_list() {
4653 let server = MockServer::start();
4657
4658 let many_statuses: Vec<serde_json::Value> = (0..15)
4659 .map(|i| serde_json::json!({"status": format!("status-{i:02}"), "type": "custom"}))
4660 .collect();
4661
4662 server.mock(|when, then| {
4663 when.method(GET).path("/list/12345");
4664 then.status(200)
4665 .json_body(serde_json::json!({"statuses": many_statuses}));
4666 });
4667
4668 let client = create_test_client(&server);
4669 let err = client
4670 .update_issue(
4671 "CU-abc123",
4672 UpdateIssueInput {
4673 status: Some("nonexistent".to_string()),
4674 ..Default::default()
4675 },
4676 )
4677 .await
4678 .expect_err("must fail");
4679 let msg = format!("{err:?}");
4680 assert!(msg.contains("status-00"), "preview missing first: {msg}");
4681 assert!(msg.contains("status-09"), "preview missing 10th: {msg}");
4682 assert!(
4683 !msg.contains("status-10"),
4684 "11th status leaked past truncation: {msg}"
4685 );
4686 assert!(
4687 msg.contains("…and 5 more"),
4688 "truncation suffix missing: {msg}"
4689 );
4690 }
4691
4692 fn team_payload(members: serde_json::Value) -> serde_json::Value {
4702 serde_json::json!({
4703 "teams": [
4704 {
4705 "id": "9876",
4706 "members": members,
4707 }
4708 ]
4709 })
4710 }
4711
4712 #[tokio::test]
4713 async fn test_update_issue_assignees_resolves_email_and_sends_diff() {
4714 let server = MockServer::start();
4715
4716 server.mock(|when, then| {
4718 when.method(GET).path("/team");
4719 then.status(200).json_body(team_payload(serde_json::json!([
4720 {"user": {"id": 94519669, "username": "m.kitaev",
4721 "email": "m.kitaev@meteora.pro"}},
4722 {"user": {"id": 11111, "username": "other",
4723 "email": "other@meteora.pro"}}
4724 ])));
4725 });
4726
4727 server.mock(|when, then| {
4729 when.method(GET).path("/task/abc123");
4730 then.status(200).json_body(sample_task_no_assignees_json());
4731 });
4732
4733 server.mock(|when, then| {
4735 when.method(PUT)
4736 .path("/task/abc123")
4737 .body_includes("\"assignees\":{\"add\":[94519669]")
4738 .body_excludes("\"rem\":");
4739 then.status(200).json_body(sample_task_no_assignees_json());
4740 });
4741
4742 let client = create_test_client_with_team(&server);
4743 let result = client
4744 .update_issue(
4745 "CU-abc123",
4746 UpdateIssueInput {
4747 assignees: Some(vec!["m.kitaev@meteora.pro".to_string()]),
4748 ..Default::default()
4749 },
4750 )
4751 .await;
4752 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4753 }
4754
4755 #[tokio::test]
4756 async fn test_update_issue_assignees_accepts_numeric_id_without_lookup() {
4757 let server = MockServer::start();
4758
4759 server.mock(|when, then| {
4761 when.method(GET).path("/task/abc123");
4762 then.status(200).json_body(sample_task_no_assignees_json());
4763 });
4764
4765 server.mock(|when, then| {
4766 when.method(PUT)
4767 .path("/task/abc123")
4768 .body_includes("\"assignees\":{\"add\":[42]");
4769 then.status(200).json_body(sample_task_no_assignees_json());
4770 });
4771
4772 let client = create_test_client(&server);
4773 let result = client
4774 .update_issue(
4775 "CU-abc123",
4776 UpdateIssueInput {
4777 assignees: Some(vec!["42".to_string()]),
4778 ..Default::default()
4779 },
4780 )
4781 .await;
4782 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4783 }
4784
4785 #[tokio::test]
4786 async fn test_update_issue_assignees_diff_add_and_rem() {
4787 let server = MockServer::start();
4788
4789 server.mock(|when, then| {
4791 when.method(GET).path("/task/abc123");
4792 then.status(200).json_body(serde_json::json!({
4793 "id": "abc123", "name": "T",
4794 "status": {"status": "open", "type": "open"},
4795 "tags": [],
4796 "assignees": [
4797 {"id": 1, "username": "u1"},
4798 {"id": 2, "username": "u2"}
4799 ],
4800 "url": "https://app.clickup.com/t/abc123",
4801 "date_created": "1704067200000",
4802 "date_updated": "1704067200000"
4803 }));
4804 });
4805
4806 server.mock(|when, then| {
4807 when.method(PUT)
4808 .path("/task/abc123")
4809 .body_includes("\"add\":[3]")
4810 .body_includes("\"rem\":[1]");
4811 then.status(200).json_body(sample_task_no_assignees_json());
4812 });
4813
4814 let client = create_test_client(&server);
4815 let result = client
4816 .update_issue(
4817 "CU-abc123",
4818 UpdateIssueInput {
4819 assignees: Some(vec!["2".to_string(), "3".to_string()]),
4820 ..Default::default()
4821 },
4822 )
4823 .await;
4824 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4825 }
4826
4827 #[tokio::test]
4828 async fn test_update_issue_assignees_empty_input_clears_all() {
4829 let server = MockServer::start();
4830
4831 server.mock(|when, then| {
4833 when.method(GET).path("/task/abc123");
4834 then.status(200).json_body(serde_json::json!({
4835 "id": "abc123", "name": "T",
4836 "status": {"status": "open", "type": "open"},
4837 "tags": [],
4838 "assignees": [
4839 {"id": 1, "username": "u1"},
4840 {"id": 2, "username": "u2"}
4841 ],
4842 "url": "https://app.clickup.com/t/abc123",
4843 "date_created": "1704067200000",
4844 "date_updated": "1704067200000"
4845 }));
4846 });
4847
4848 server.mock(|when, then| {
4849 when.method(PUT)
4850 .path("/task/abc123")
4851 .body_includes("\"rem\":[1,2]")
4852 .body_excludes("\"add\":");
4853 then.status(200).json_body(sample_task_no_assignees_json());
4854 });
4855
4856 let client = create_test_client(&server);
4857 let result = client
4858 .update_issue(
4859 "CU-abc123",
4860 UpdateIssueInput {
4861 assignees: Some(vec![]),
4862 ..Default::default()
4863 },
4864 )
4865 .await;
4866 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4867 }
4868
4869 #[tokio::test]
4870 async fn test_update_issue_assignees_none_leaves_field_untouched() {
4871 let server = MockServer::start();
4872
4873 server.mock(|when, then| {
4876 when.method(PUT)
4877 .path("/task/abc123")
4878 .body_excludes("\"assignees\"");
4879 then.status(200).json_body(sample_task_no_assignees_json());
4880 });
4881
4882 server.mock(|when, then| {
4883 when.method(GET).path("/task/abc123");
4884 then.status(200).json_body(sample_task_no_assignees_json());
4885 });
4886
4887 let client = create_test_client(&server);
4888 let result = client
4889 .update_issue(
4890 "CU-abc123",
4891 UpdateIssueInput {
4892 title: Some("renamed".to_string()),
4893 ..Default::default()
4895 },
4896 )
4897 .await;
4898 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4899 }
4900
4901 #[tokio::test]
4902 async fn test_update_issue_assignees_unknown_email_fails_clearly() {
4903 let server = MockServer::start();
4904
4905 server.mock(|when, then| {
4906 when.method(GET).path("/team");
4907 then.status(200).json_body(team_payload(serde_json::json!([
4908 {"user": {"id": 1, "username": "u1", "email": "u1@x.com"}}
4909 ])));
4910 });
4911
4912 let put_mock = server.mock(|when, then| {
4914 when.method(PUT).path("/task/abc123");
4915 then.status(200).json_body(sample_task_no_assignees_json());
4916 });
4917
4918 let client = create_test_client(&server);
4919 let err = client
4920 .update_issue(
4921 "CU-abc123",
4922 UpdateIssueInput {
4923 assignees: Some(vec!["nobody@nowhere.com".to_string()]),
4924 ..Default::default()
4925 },
4926 )
4927 .await
4928 .expect_err("unresolvable email must fail");
4929 assert!(
4930 format!("{err:?}").contains("Cannot resolve assignee"),
4931 "unexpected error: {err:?}"
4932 );
4933 put_mock.assert_calls(0);
4934 }
4935
4936 #[tokio::test]
4937 async fn test_update_issue_assignees_no_change_omits_field() {
4938 let server = MockServer::start();
4939
4940 server.mock(|when, then| {
4942 when.method(GET).path("/task/abc123");
4943 then.status(200).json_body(serde_json::json!({
4944 "id": "abc123", "name": "T",
4945 "status": {"status": "open", "type": "open"},
4946 "tags": [],
4947 "assignees": [{"id": 1, "username": "u1"}],
4948 "url": "https://app.clickup.com/t/abc123",
4949 "date_created": "1704067200000",
4950 "date_updated": "1704067200000"
4951 }));
4952 });
4953
4954 server.mock(|when, then| {
4955 when.method(PUT)
4956 .path("/task/abc123")
4957 .body_excludes("\"assignees\"");
4958 then.status(200).json_body(sample_task_no_assignees_json());
4959 });
4960
4961 let client = create_test_client(&server);
4962 let result = client
4963 .update_issue(
4964 "CU-abc123",
4965 UpdateIssueInput {
4966 assignees: Some(vec!["1".to_string()]),
4967 ..Default::default()
4968 },
4969 )
4970 .await;
4971 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4972 }
4973
4974 #[tokio::test]
4975 async fn test_create_issue_assignees_resolves_and_sends_flat_array() {
4976 let server = MockServer::start();
4977
4978 server.mock(|when, then| {
4980 when.method(GET).path("/team");
4981 then.status(200).json_body(team_payload(serde_json::json!([
4982 {"user": {"id": 555, "username": "u",
4983 "email": "u@example.com"}}
4984 ])));
4985 });
4986
4987 server.mock(|when, then| {
4989 when.method(POST)
4990 .path("/list/12345/task")
4991 .body_includes("\"assignees\":[555]");
4992 then.status(200).json_body(sample_task_json());
4993 });
4994
4995 let client = create_test_client(&server);
4996 let result = client
4997 .create_issue(CreateIssueInput {
4998 title: "New".to_string(),
4999 assignees: vec!["u@example.com".to_string()],
5000 ..Default::default()
5001 })
5002 .await;
5003 assert!(result.is_ok(), "expected ok, got {:?}", result.err());
5004 }
5005
5006 fn sample_task_no_assignees_json() -> serde_json::Value {
5007 serde_json::json!({
5008 "id": "abc123", "name": "Test Task",
5009 "status": {"status": "open", "type": "open"},
5010 "tags": [], "assignees": [],
5011 "url": "https://app.clickup.com/t/abc123",
5012 "date_created": "1704067200000",
5013 "date_updated": "1704067200000"
5014 })
5015 }
5016 }
5017}