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 ClickUpAttachment, ClickUpComment, ClickUpCommentList, ClickUpLinkedTask, ClickUpListInfo,
16 ClickUpPriority, ClickUpTask, ClickUpTaskList, ClickUpUser, CreateCommentRequest,
17 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 resolve_status(&self, state: &str) -> Result<String> {
227 let status_type = match state {
228 "closed" => "closed",
229 "open" | "opened" => "open",
230 _ => return Ok(state.to_string()),
231 };
232
233 let url = format!("{}/list/{}", self.base_url, self.list_id);
234 let list_info: ClickUpListInfo = self.get(&url).await?;
235
236 list_info
237 .statuses
238 .iter()
239 .find(|s| s.status_type.as_deref() == Some(status_type))
240 .map(|s| s.status.clone())
241 .ok_or_else(|| {
242 Error::InvalidData(format!(
243 "No status with type '{}' found in list {}",
244 status_type, self.list_id
245 ))
246 })
247 }
248
249 fn resolve_task_id(&self, key: &str) -> Result<String> {
253 if let Some(raw_id) = key.strip_prefix("CU-") {
254 Ok(raw_id.to_string())
255 } else {
256 Ok(key.to_string())
257 }
258 }
259
260 async fn resolve_to_native_id(&self, key: &str) -> Result<String> {
265 if let Some(raw_id) = key.strip_prefix("CU-") {
266 Ok(raw_id.to_string())
267 } else {
268 let url = self.task_url(key)?;
269 let task: ClickUpTask = self.get(&url).await?;
270 Ok(task.id)
271 }
272 }
273
274 fn task_url(&self, key: &str) -> Result<String> {
278 if let Some(raw_id) = key.strip_prefix("CU-") {
279 Ok(format!("{}/task/{}", self.base_url, raw_id))
280 } else {
281 let team_id = self.team_id.as_ref().ok_or_else(|| {
283 Error::Config(format!(
284 "team_id is required to resolve custom task ID '{}'. \
285 Run: devboy config set clickup.team_id <team_id>",
286 key
287 ))
288 })?;
289 Ok(format!(
290 "{}/task/{}?custom_task_ids=true&team_id={}",
291 self.base_url, key, team_id
292 ))
293 }
294 }
295}
296
297fn map_user(cu_user: Option<&ClickUpUser>) -> Option<User> {
302 cu_user.map(|u| User {
303 id: u.id.to_string(),
304 username: u.username.clone(),
305 name: Some(u.username.clone()),
306 email: u.email.clone(),
307 avatar_url: u.profile_picture.clone(),
308 })
309}
310
311fn map_user_required(cu_user: Option<&ClickUpUser>) -> User {
312 map_user(cu_user).unwrap_or_else(|| User {
313 id: "unknown".to_string(),
314 username: "unknown".to_string(),
315 name: Some("Unknown".to_string()),
316 ..Default::default()
317 })
318}
319
320fn map_tags(tags: &[crate::types::ClickUpTag]) -> Vec<String> {
321 tags.iter().map(|t| t.name.clone()).collect()
322}
323
324fn map_priority(priority: Option<&ClickUpPriority>) -> Option<String> {
325 priority.map(|p| match p.id.as_str() {
326 "1" => "urgent".to_string(),
327 "2" => "high".to_string(),
328 "3" => "normal".to_string(),
329 "4" => "low".to_string(),
330 _ => p.priority.to_lowercase(),
331 })
332}
333
334fn map_state(task: &ClickUpTask) -> String {
335 match task.status.status_type.as_deref() {
336 Some("closed") => "closed".to_string(),
337 _ => "open".to_string(),
338 }
339}
340
341fn map_status_category(status_type: Option<&str>, status_name: &str) -> String {
346 match status_type {
348 Some("closed") | Some("done") => return "done".to_string(),
349 _ => {}
352 }
353
354 let name_lower = status_name.to_lowercase();
356
357 if name_lower.contains("backlog") {
358 "backlog".to_string()
359 } else if name_lower.contains("cancel")
360 || name_lower.contains("archived")
361 || name_lower.contains("rejected")
362 {
363 "cancelled".to_string()
364 } else if name_lower.contains("done")
365 || name_lower.contains("complete")
366 || name_lower.contains("closed")
367 || name_lower.contains("resolved")
368 {
369 "done".to_string()
370 } else if name_lower.contains("progress")
371 || name_lower.contains("doing")
372 || name_lower.contains("active")
373 || name_lower.contains("review")
374 {
375 "in_progress".to_string()
376 } else if name_lower.contains("todo")
377 || name_lower.contains("to do")
378 || name_lower.contains("open")
379 || name_lower.contains("new")
380 {
381 "todo".to_string()
382 } else {
383 match status_type {
385 Some("open") => "todo".to_string(),
386 _ => "in_progress".to_string(),
387 }
388 }
389}
390
391fn map_task_key(task: &ClickUpTask) -> String {
394 if let Some(custom_id) = &task.custom_id {
395 custom_id.clone()
396 } else {
397 format!("CU-{}", task.id)
398 }
399}
400
401fn epoch_ms_to_iso8601(epoch_ms: &str) -> Option<String> {
403 let ms: i64 = epoch_ms.parse().ok()?;
404 let secs = ms / 1000;
405 let datetime = time_from_unix(secs);
406 Some(datetime)
407}
408
409fn time_from_unix(secs: i64) -> String {
411 let mut days = secs / 86400;
413 let day_secs = secs.rem_euclid(86400);
414 if secs % 86400 < 0 {
415 days -= 1;
416 }
417
418 let hours = day_secs / 3600;
419 let minutes = (day_secs % 3600) / 60;
420 let seconds = day_secs % 60;
421
422 let z = days + 719468;
425 let era = if z >= 0 { z } else { z - 146096 } / 146097;
426 let doe = (z - era * 146097) as u32;
427 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
428 let y = yoe as i64 + era * 400;
429 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
430 let mp = (5 * doy + 2) / 153;
431 let d = doy - (153 * mp + 2) / 5 + 1;
432 let m = if mp < 10 { mp + 3 } else { mp - 9 };
433 let y = if m <= 2 { y + 1 } else { y };
434
435 format!(
436 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
437 y, m, d, hours, minutes, seconds
438 )
439}
440
441fn map_timestamp(ts: &Option<String>) -> Option<String> {
442 ts.as_ref().and_then(|s| epoch_ms_to_iso8601(s))
443}
444
445fn map_task(task: &ClickUpTask) -> Issue {
446 Issue {
447 key: map_task_key(task),
448 title: task.name.clone(),
449 description: task
450 .text_content
451 .clone()
452 .or_else(|| task.description.clone()),
453 state: map_state(task),
454 source: "clickup".to_string(),
455 priority: map_priority(task.priority.as_ref()),
456 labels: map_tags(&task.tags),
457 author: map_user(task.creator.as_ref()),
458 assignees: task
459 .assignees
460 .iter()
461 .map(|u| map_user_required(Some(u)))
462 .collect(),
463 url: Some(task.url.clone()),
464 created_at: map_timestamp(&task.date_created),
465 updated_at: map_timestamp(&task.date_updated),
466 attachments_count: if task.attachments.is_empty() {
467 None
468 } else {
469 Some(task.attachments.len() as u32)
470 },
471 parent: task.parent.as_ref().map(|id| format!("CU-{id}")),
472 subtasks: task
473 .subtasks
474 .as_deref()
475 .unwrap_or_default()
476 .iter()
477 .map(map_task)
478 .collect(),
479 }
480}
481
482fn map_comment(cu_comment: &ClickUpComment) -> Comment {
483 Comment {
484 id: cu_comment.id.clone(),
485 body: cu_comment.comment_text.clone(),
486 author: map_user(cu_comment.user.as_ref()),
487 created_at: map_timestamp(&cu_comment.date),
488 updated_at: None,
489 position: None,
490 }
491}
492
493fn map_clickup_attachment(raw: &ClickUpAttachment) -> AssetMeta {
495 let filename = raw
496 .title
497 .clone()
498 .or_else(|| {
499 raw.url
500 .as_deref()
501 .map(devboy_core::asset::filename_from_url)
502 })
503 .unwrap_or_else(|| format!("attachment-{}", raw.id));
504
505 let size = match raw.size.as_ref() {
506 Some(serde_json::Value::Number(n)) => n.as_u64(),
507 Some(serde_json::Value::String(s)) => s.parse::<u64>().ok(),
508 _ => None,
509 };
510
511 let created_at = raw.date.as_deref().and_then(epoch_ms_to_iso8601);
512
513 let author = raw.user.as_ref().map(|u| u.username.clone());
514
515 AssetMeta {
516 id: raw.id.clone(),
517 filename,
518 mime_type: raw.mimetype.clone(),
519 size,
520 url: raw.url.clone(),
521 created_at,
522 author,
523 cached: false,
524 local_path: None,
525 checksum_sha256: None,
526 analysis: None,
527 }
528}
529
530fn priority_sort_key(priority: Option<&str>) -> u8 {
533 match priority {
534 Some("urgent") => 1,
535 Some("high") => 2,
536 Some("normal") => 3,
537 Some("low") => 4,
538 _ => 5,
539 }
540}
541
542fn priority_to_clickup(priority: &str) -> Option<u8> {
544 match priority {
545 "urgent" => Some(1),
546 "high" => Some(2),
547 "normal" => Some(3),
548 "low" => Some(4),
549 _ => None,
550 }
551}
552
553fn map_dependencies(
562 deps: &[serde_json::Value],
563 this_task_id: &str,
564) -> (Vec<IssueLink>, Vec<IssueLink>) {
565 let mut blocked_by = Vec::new();
566 let mut blocks = Vec::new();
567
568 for dep in deps {
569 let task_id = dep
570 .get("task_id")
571 .and_then(|v| v.as_str())
572 .unwrap_or_default();
573 let depends_on = dep
574 .get("depends_on")
575 .and_then(|v| v.as_str())
576 .unwrap_or_default();
577 let dependency_of = dep
578 .get("dependency_of")
579 .and_then(|v| v.as_str())
580 .unwrap_or_default();
581
582 let other_id = if !task_id.is_empty() {
583 task_id
584 } else {
585 continue;
586 };
587
588 let other_issue = Issue {
589 key: format!("CU-{other_id}"),
590 source: "clickup".to_string(),
591 ..Default::default()
592 };
593
594 if depends_on == this_task_id {
595 blocks.push(IssueLink {
597 issue: other_issue,
598 link_type: "blocks".to_string(),
599 });
600 } else if dependency_of == this_task_id {
601 blocked_by.push(IssueLink {
603 issue: other_issue,
604 link_type: "blocked_by".to_string(),
605 });
606 } else {
607 let dep_type = dep.get("type").and_then(|v| v.as_u64());
610 match dep_type {
611 Some(1) => {
612 blocked_by.push(IssueLink {
613 issue: other_issue,
614 link_type: "blocked_by".to_string(),
615 });
616 }
617 Some(0) => {
618 blocks.push(IssueLink {
619 issue: other_issue,
620 link_type: "blocks".to_string(),
621 });
622 }
623 _ => {
624 blocked_by.push(IssueLink {
626 issue: other_issue,
627 link_type: "blocked_by".to_string(),
628 });
629 }
630 }
631 }
632 }
633
634 (blocked_by, blocks)
635}
636
637fn map_linked_tasks(links: &[ClickUpLinkedTask]) -> Vec<IssueLink> {
639 links
640 .iter()
641 .map(|link| {
642 let link_type = match link.link_type.as_deref() {
643 Some("blocked_by") => "blocked_by",
644 Some("blocking") => "blocks",
645 _ => "relates_to",
646 }
647 .to_string();
648
649 IssueLink {
650 issue: Issue {
651 key: format!("CU-{}", link.task_id),
652 source: "clickup".to_string(),
653 ..Default::default()
654 },
655 link_type,
656 }
657 })
658 .collect()
659}
660
661#[async_trait]
666impl IssueProvider for ClickUpClient {
667 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
668 let limit = filter.limit.unwrap_or(20) as usize;
669 if limit == 0 {
670 return Ok(vec![].into());
671 }
672 let offset = filter.offset.unwrap_or(0) as usize;
673
674 let start_page = offset / PAGE_SIZE as usize;
676 let end_page = (offset + limit).saturating_sub(1) / PAGE_SIZE as usize;
677
678 let mut base_params: Vec<(&str, String)> = vec![];
681
682 let include_closed = matches!(filter.state.as_deref(), Some("closed") | Some("all"))
683 || matches!(
684 filter.state_category.as_deref(),
685 Some("done") | Some("cancelled")
686 );
687 if include_closed {
688 base_params.push(("include_closed", "true".to_string()));
689 }
690
691 base_params.push(("subtasks", "true".to_string()));
692
693 if let Some(assignee) = &filter.assignee {
694 warn!(
698 assignee = assignee.as_str(),
699 "ClickUp assignee filter expects numeric user IDs, not usernames"
700 );
701 base_params.push(("assignees[]", assignee.clone()));
702 }
703
704 if let Some(tags) = &filter.labels {
705 for tag in tags {
706 base_params.push(("tags[]", tag.clone()));
707 }
708 }
709
710 let mut client_side_sort: Option<String> = None;
712
713 if let Some(order_by) = &filter.sort_by {
714 match order_by.as_str() {
715 "created_at" | "created" => {
716 base_params.push(("order_by", "created".to_string()));
717 }
718 "updated_at" | "updated" => {
719 base_params.push(("order_by", "updated".to_string()));
720 }
721 other => {
722 client_side_sort = Some(other.to_string());
724 warn!(
725 sort_by = other,
726 "ClickUp API does not support sorting by '{}', applying client-side sort",
727 other
728 );
729 }
730 }
731 }
732
733 let sort_order_is_asc = filter.sort_order.as_deref().is_some_and(|o| o == "asc");
734
735 if sort_order_is_asc && client_side_sort.is_none() {
736 base_params.push(("reverse", "true".to_string()));
737 }
738
739 let base_url = format!("{}/list/{}/task", self.base_url, self.list_id);
741 let mut all_tasks: Vec<ClickUpTask> = Vec::new();
742
743 for page in start_page..=end_page {
744 let mut params = base_params.clone();
745 params.push(("page", page.to_string()));
746
747 let param_refs: Vec<(&str, &str)> =
748 params.iter().map(|(k, v)| (*k, v.as_str())).collect();
749 let response: ClickUpTaskList = self.get_with_query(&base_url, ¶m_refs).await?;
750 let page_len = response.tasks.len();
751 all_tasks.extend(response.tasks);
752
753 if page_len < PAGE_SIZE as usize {
755 break;
756 }
757 }
758
759 if let Some(ref state_category) = filter.state_category {
763 let statuses = self.get_statuses().await?;
764 let matching_status_names: Vec<String> = statuses
765 .items
766 .iter()
767 .filter(|s| s.category == *state_category)
768 .map(|s| s.name.to_lowercase())
769 .collect();
770
771 all_tasks.retain(|t| matching_status_names.contains(&t.status.status.to_lowercase()));
772 }
773
774 let mut issues: Vec<Issue> = all_tasks.iter().map(map_task).collect();
775
776 if let Some(state) = &filter.state {
778 match state.as_str() {
779 "opened" | "open" => {
780 issues.retain(|i| i.state == "open");
781 }
782 "closed" => {
783 issues.retain(|i| i.state == "closed");
784 }
785 _ => {} }
787 }
788
789 if filter.labels_operator.as_deref() == Some("and")
791 && let Some(ref required_labels) = filter.labels
792 {
793 let required: Vec<String> = required_labels.iter().map(|l| l.to_lowercase()).collect();
794 issues.retain(|issue| {
795 let issue_labels: Vec<String> =
796 issue.labels.iter().map(|l| l.to_lowercase()).collect();
797 required.iter().all(|r| issue_labels.contains(r))
798 });
799 }
800
801 if let Some(ref query) = filter.search {
803 let q = query.to_lowercase();
804 issues.retain(|issue| {
805 issue.title.to_lowercase().contains(&q)
806 || issue
807 .description
808 .as_ref()
809 .is_some_and(|d| d.to_lowercase().contains(&q))
810 || issue.key.to_lowercase().contains(&q)
811 });
812 }
813
814 if let Some(ref sort_field) = client_side_sort {
816 match sort_field.as_str() {
817 "priority" => {
818 issues.sort_by(|a, b| {
819 let pa = priority_sort_key(a.priority.as_deref());
820 let pb = priority_sort_key(b.priority.as_deref());
821 if sort_order_is_asc {
822 pa.cmp(&pb)
823 } else {
824 pb.cmp(&pa)
825 }
826 });
827 }
828 "title" => {
829 issues.sort_by(|a, b| {
830 let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase());
831 if sort_order_is_asc {
832 cmp
833 } else {
834 cmp.reverse()
835 }
836 });
837 }
838 _ => {
839 }
841 }
842 }
843
844 let offset_in_first_page = offset % PAGE_SIZE as usize;
846 if offset_in_first_page < issues.len() {
847 issues = issues.split_off(offset_in_first_page);
848 } else {
849 issues.clear();
850 }
851
852 issues.truncate(limit);
853
854 let sort_info = SortInfo {
856 sort_by: filter.sort_by.clone(),
857 sort_order: if sort_order_is_asc {
858 SortOrder::Asc
859 } else {
860 SortOrder::Desc
861 },
862 available_sorts: vec![
863 "created_at".into(),
864 "updated_at".into(),
865 "priority".into(),
866 "title".into(),
867 ],
868 };
869
870 Ok(ProviderResult::new(issues).with_sort_info(sort_info))
871 }
872
873 async fn get_issue(&self, key: &str) -> Result<Issue> {
874 let base_url = self.task_url(key)?;
875 let separator = if base_url.contains('?') { "&" } else { "?" };
876 let url = format!("{}{}include_subtasks=true", base_url, separator);
877 let task: ClickUpTask = self.get(&url).await?;
878 Ok(map_task(&task))
879 }
880
881 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
882 let url = format!("{}/list/{}/task", self.base_url, self.list_id);
883
884 let priority = input.priority.as_deref().and_then(priority_to_clickup);
885
886 let tags = if input.labels.is_empty() {
887 None
888 } else {
889 Some(input.labels)
890 };
891
892 let parent = match input.parent {
895 Some(ref parent_key) => {
896 if let Some(stripped) = parent_key.strip_prefix("CU-") {
897 Some(stripped.to_string())
898 } else {
899 let parent_url = self.task_url(parent_key)?;
900 let parent_task: ClickUpTask = self.get(&parent_url).await?;
901 Some(parent_task.id)
902 }
903 }
904 None => None,
905 };
906
907 let (description, markdown_content) = if input.markdown {
908 (None, input.description)
909 } else {
910 (input.description, None)
911 };
912
913 let request = CreateTaskRequest {
914 name: input.title,
915 description,
916 markdown_content,
917 parent,
918 status: None,
919 priority,
920 tags,
921 assignees: None, };
923
924 let task: ClickUpTask = self.post(&url, &request).await?;
925 let task_id = task.id.clone();
926
927 if task.custom_id.is_none() {
930 for attempt in 1..=3u64 {
931 tokio::time::sleep(std::time::Duration::from_millis(300 * attempt)).await;
932 let fetch_url = format!("{}/task/{}", self.base_url, task_id);
933 if let Ok(fetched) = self.get::<ClickUpTask>(&fetch_url).await
934 && fetched.custom_id.is_some()
935 {
936 debug!(
937 task_id = task_id,
938 custom_id = ?fetched.custom_id,
939 attempt = attempt,
940 "Got custom_id after retry"
941 );
942 return Ok(map_task(&fetched));
943 }
944 }
945 warn!(
946 task_id = task_id,
947 "custom_id not available after 3 retries, using POST response"
948 );
949 }
950
951 Ok(map_task(&task))
952 }
953
954 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
955 let url = self.task_url(key)?;
956
957 let status = match input.state {
958 Some(s) => Some(self.resolve_status(&s).await?),
959 None => None,
960 };
961
962 let priority = input.priority.as_deref().and_then(priority_to_clickup);
963
964 let (description, markdown_content) = if input.markdown {
965 (None, input.description)
966 } else {
967 (input.description, None)
968 };
969
970 let parent = match input.parent_id {
974 Some(ref parent_key) if parent_key == "none" || parent_key.is_empty() => {
975 Some("none".to_string())
976 }
977 Some(ref parent_key) => {
978 if let Some(stripped) = parent_key.strip_prefix("CU-") {
979 Some(stripped.to_string())
980 } else {
981 let parent_url = self.task_url(parent_key)?;
982 let parent_task: ClickUpTask = self.get(&parent_url).await?;
983 Some(parent_task.id)
984 }
985 }
986 None => None,
987 };
988
989 let request = UpdateTaskRequest {
990 name: input.title,
991 description,
992 markdown_content,
993 status,
994 priority,
995 parent,
996 tags: None, };
998
999 let task: ClickUpTask = self.put(&url, &request).await?;
1000
1001 if let Some(ref new_labels) = input.labels {
1004 let current_tags: Vec<String> = task.tags.iter().map(|t| t.name.clone()).collect();
1005 let new_tags: Vec<String> = new_labels.iter().map(|l| l.to_lowercase()).collect();
1006
1007 for tag in ¤t_tags {
1009 if !new_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1010 let tag_url =
1011 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1012 if let Err(e) = self.delete(&tag_url).await {
1013 warn!(tag = tag, error = %e, "Failed to remove tag");
1014 }
1015 }
1016 }
1017
1018 for tag in &new_tags {
1020 if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1021 let tag_url =
1022 format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1023 let resp = self
1024 .request(reqwest::Method::POST, &tag_url)
1025 .send()
1026 .await
1027 .map_err(|e| Error::Http(e.to_string()))?;
1028 if !resp.status().is_success() {
1029 warn!(
1030 tag = tag,
1031 status = resp.status().as_u16(),
1032 "Failed to add tag"
1033 );
1034 }
1035 }
1036 }
1037 }
1038
1039 match self.get::<ClickUpTask>(&url).await {
1042 Ok(updated_task) => Ok(map_task(&updated_task)),
1043 Err(e) => {
1044 warn!(
1045 issue_key = key,
1046 error = %e,
1047 "Task updated successfully, but failed to re-fetch fresh state; falling back to PUT response"
1048 );
1049 Ok(map_task(&task))
1050 }
1051 }
1052 }
1053
1054 async fn set_custom_fields(&self, issue_key: &str, fields: &[serde_json::Value]) -> Result<()> {
1055 let task_id = self.resolve_to_native_id(issue_key).await?;
1056 for field in fields {
1057 let field_id = field["id"].as_str().unwrap_or_default();
1058 if field_id.is_empty() {
1059 continue;
1060 }
1061 let url = format!("{}/task/{}/field/{}", self.base_url, task_id, field_id);
1062 let body = serde_json::json!({ "value": field["value"] });
1063 let resp = self
1064 .request(reqwest::Method::POST, &url)
1065 .json(&body)
1066 .send()
1067 .await
1068 .map_err(|e| Error::Http(e.to_string()))?;
1069 if !resp.status().is_success() {
1070 let status = resp.status().as_u16();
1071 let msg = resp.text().await.unwrap_or_default();
1072 warn!(
1073 field_id = field_id,
1074 status = status,
1075 "Failed to set custom field: {}",
1076 msg
1077 );
1078 }
1079 }
1080 Ok(())
1081 }
1082
1083 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1084 let base_url = self.task_url(issue_key)?;
1085 let url = if base_url.contains('?') {
1087 let (path, query) = base_url.split_once('?').unwrap();
1088 format!("{}/comment?{}", path, query)
1089 } else {
1090 format!("{}/comment", base_url)
1091 };
1092 let response: ClickUpCommentList = self.get(&url).await?;
1093 Ok(response
1094 .comments
1095 .iter()
1096 .map(map_comment)
1097 .collect::<Vec<_>>()
1098 .into())
1099 }
1100
1101 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1102 let base_url = self.task_url(issue_key)?;
1103 let url = if base_url.contains('?') {
1104 let (path, query) = base_url.split_once('?').unwrap();
1105 format!("{}/comment?{}", path, query)
1106 } else {
1107 format!("{}/comment", base_url)
1108 };
1109 let request = CreateCommentRequest {
1110 comment_text: body.to_string(),
1111 };
1112
1113 let response: CreateCommentResponse = self.post(&url, &request).await?;
1115 Ok(Comment {
1116 id: response.id,
1117 body: body.to_string(),
1118 author: None,
1119 created_at: map_timestamp(&response.date),
1120 updated_at: None,
1121 position: None,
1122 })
1123 }
1124
1125 async fn upload_attachment(
1126 &self,
1127 issue_key: &str,
1128 filename: &str,
1129 data: &[u8],
1130 ) -> Result<String> {
1131 let task_id = self.resolve_to_native_id(issue_key).await?;
1132 let url = format!("{}/task/{}/attachment", self.base_url, task_id);
1133
1134 let part = reqwest::multipart::Part::bytes(data.to_vec())
1135 .file_name(filename.to_string())
1136 .mime_str("application/octet-stream")
1137 .map_err(|e| Error::Http(format!("Failed to create multipart: {}", e)))?;
1138
1139 let form = reqwest::multipart::Form::new().part("attachment", part);
1140
1141 let response = self
1142 .client
1143 .post(&url)
1144 .header("Authorization", self.token.expose_secret())
1145 .multipart(form)
1146 .send()
1147 .await
1148 .map_err(|e| Error::Http(e.to_string()))?;
1149
1150 let status = response.status();
1151 if !status.is_success() {
1152 let message = response.text().await.unwrap_or_default();
1153 return Err(Error::from_status(status.as_u16(), message));
1154 }
1155
1156 let body: serde_json::Value = response.json().await.map_err(|e| {
1158 Error::InvalidData(format!("Failed to parse attachment response: {}", e))
1159 })?;
1160
1161 let download_url = body
1163 .pointer("/url")
1164 .or_else(|| body.pointer("/attachment/url"))
1165 .and_then(|v| v.as_str())
1166 .unwrap_or("")
1167 .to_string();
1168
1169 Ok(download_url)
1170 }
1171
1172 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
1173 let url = self.task_url(issue_key)?;
1174 let task: ClickUpTask = self.get(&url).await?;
1175 Ok(task
1176 .attachments
1177 .iter()
1178 .map(map_clickup_attachment)
1179 .collect())
1180 }
1181
1182 async fn download_attachment(&self, issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1183 let url = self.task_url(issue_key)?;
1188 let task: ClickUpTask = self.get(&url).await?;
1189 let attachment = task
1190 .attachments
1191 .iter()
1192 .find(|a| a.id == asset_id)
1193 .ok_or_else(|| {
1194 Error::NotFound(format!(
1195 "attachment '{asset_id}' not found on task {issue_key}",
1196 ))
1197 })?;
1198 let download_url = attachment.url.as_deref().ok_or_else(|| {
1199 Error::InvalidData(format!(
1200 "attachment '{asset_id}' on task {issue_key} has no URL",
1201 ))
1202 })?;
1203
1204 let response = self
1205 .client
1206 .get(download_url)
1207 .header("Authorization", self.token.expose_secret())
1208 .send()
1209 .await
1210 .map_err(|e| Error::Http(e.to_string()))?;
1211
1212 let status = response.status();
1213 if !status.is_success() {
1214 let message = response.text().await.unwrap_or_default();
1215 return Err(Error::from_status(status.as_u16(), message));
1216 }
1217
1218 let bytes = response
1219 .bytes()
1220 .await
1221 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
1222 Ok(bytes.to_vec())
1223 }
1224
1225 fn asset_capabilities(&self) -> AssetCapabilities {
1226 AssetCapabilities {
1230 issue: ContextCapabilities {
1231 upload: true,
1232 download: true,
1233 delete: false,
1234 list: true,
1235 max_file_size: None,
1236 allowed_types: Vec::new(),
1237 },
1238 ..Default::default()
1239 }
1240 }
1241
1242 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1243 let url = format!("{}/list/{}", self.base_url, self.list_id);
1244 let list_info: ClickUpListInfo = self.get(&url).await?;
1245
1246 let statuses: Vec<IssueStatus> = list_info
1247 .statuses
1248 .iter()
1249 .enumerate()
1250 .map(|(idx, s)| {
1251 let category = map_status_category(s.status_type.as_deref(), &s.status);
1252 IssueStatus {
1253 id: s.status.clone(),
1254 name: s.status.clone(),
1255 category,
1256 color: s.color.clone(),
1257 order: s.orderindex.or(Some(idx as u32)),
1258 }
1259 })
1260 .collect();
1261
1262 Ok(statuses.into())
1263 }
1264
1265 async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1266 match link_type {
1267 "subtask" => {
1268 let source_url = self.task_url(source_key)?;
1270 let target_native_id = self.resolve_to_native_id(target_key).await?;
1271 let body = serde_json::json!({ "parent": target_native_id });
1272 let _: ClickUpTask = self.put(&source_url, &body).await?;
1273 }
1274 "blocks" => {
1275 let source_id = self.resolve_task_id(source_key)?;
1277 let target_id = self.resolve_task_id(target_key)?;
1278 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1279 let body = serde_json::json!({ "depends_on": source_id });
1280 let _: serde_json::Value = self.post(&url, &body).await?;
1281 }
1282 "blocked_by" => {
1283 let source_id = self.resolve_task_id(source_key)?;
1285 let target_id = self.resolve_task_id(target_key)?;
1286 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1287 let body = serde_json::json!({ "depends_on": target_id });
1288 let _: serde_json::Value = self.post(&url, &body).await?;
1289 }
1290 _ => {
1291 let source_id = self.resolve_to_native_id(source_key).await?;
1293 let target_id = self.resolve_to_native_id(target_key).await?;
1294 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1295 let body = serde_json::json!({});
1296 let _: serde_json::Value = self.post(&url, &body).await?;
1297 }
1298 }
1299
1300 Ok(())
1301 }
1302
1303 async fn unlink_issues(
1304 &self,
1305 source_key: &str,
1306 target_key: &str,
1307 link_type: &str,
1308 ) -> Result<()> {
1309 match link_type {
1310 "subtask" => {
1311 let source_id = self.resolve_to_native_id(source_key).await?;
1314 let url = format!("{}/task/{}", self.base_url, source_id);
1315 let body = serde_json::json!({ "parent": "none" });
1316 let _: ClickUpTask = self.put(&url, &body).await?;
1317 }
1318 "blocks" => {
1319 let source_id = self.resolve_to_native_id(source_key).await?;
1321 let target_id = self.resolve_to_native_id(target_key).await?;
1322 let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1323 self.delete_with_query(&url, &[("depends_on", &source_id)])
1324 .await?;
1325 }
1326 "blocked_by" => {
1327 let source_id = self.resolve_to_native_id(source_key).await?;
1329 let target_id = self.resolve_to_native_id(target_key).await?;
1330 let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1331 self.delete_with_query(&url, &[("depends_on", &target_id)])
1332 .await?;
1333 }
1334 _ => {
1335 let source_id = self.resolve_to_native_id(source_key).await?;
1337 let target_id = self.resolve_to_native_id(target_key).await?;
1338 let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1339 self.delete(&url).await?;
1340 }
1341 }
1342
1343 Ok(())
1344 }
1345
1346 async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1347 let url = self.task_url(issue_key)?;
1348 let task: ClickUpTask = self
1349 .get_with_query(
1350 &url,
1351 &[("include_subtasks", "true"), ("include_closed", "true")],
1352 )
1353 .await?;
1354
1355 let mut relations = IssueRelations::default();
1356
1357 if let Some(ref parent_id) = task.parent {
1359 let parent_url = format!("{}/task/{}", self.base_url, parent_id);
1360 match self.get::<ClickUpTask>(&parent_url).await {
1361 Ok(parent_task) => {
1362 relations.parent = Some(map_task(&parent_task));
1363 }
1364 Err(e) => {
1365 tracing::warn!("Failed to fetch parent task {}: {}", parent_id, e);
1366 relations.parent = Some(Issue {
1368 key: format!("CU-{parent_id}"),
1369 source: "clickup".to_string(),
1370 ..Default::default()
1371 });
1372 }
1373 }
1374 }
1375
1376 if let Some(ref subtasks) = task.subtasks {
1378 relations.subtasks = subtasks.iter().map(map_task).collect();
1379 }
1380
1381 if let Some(ref deps) = task.dependencies {
1383 let (blocked_by, blocks) = map_dependencies(deps, &task.id);
1384 relations.blocked_by = blocked_by;
1385 relations.blocks = blocks;
1386 }
1387
1388 if let Some(ref linked) = task.linked_tasks {
1390 relations.related_to = map_linked_tasks(linked);
1391 }
1392
1393 Ok(relations)
1394 }
1395
1396 fn provider_name(&self) -> &'static str {
1397 "clickup"
1398 }
1399}
1400
1401#[async_trait]
1402impl MergeRequestProvider for ClickUpClient {
1403 fn provider_name(&self) -> &'static str {
1404 "clickup"
1405 }
1406}
1407
1408#[async_trait]
1409impl PipelineProvider for ClickUpClient {
1410 fn provider_name(&self) -> &'static str {
1411 "clickup"
1412 }
1413}
1414
1415#[async_trait]
1416impl Provider for ClickUpClient {
1417 async fn get_current_user(&self) -> Result<User> {
1418 let url = format!(
1421 "{}/list/{}/task?page=0&subtasks=false",
1422 self.base_url, self.list_id
1423 );
1424 let _: ClickUpTaskList = self.get(&url).await?;
1425
1426 Ok(User {
1428 id: "clickup".to_string(),
1429 username: "clickup-user".to_string(),
1430 name: Some("ClickUp User".to_string()),
1431 ..Default::default()
1432 })
1433 }
1434}
1435
1436#[cfg(test)]
1441mod tests {
1442 use super::*;
1443 use crate::types::{ClickUpStatus, ClickUpTag};
1444 use devboy_core::{CreateCommentInput, MrFilter};
1445
1446 fn token(s: &str) -> SecretString {
1447 SecretString::from(s.to_string())
1448 }
1449
1450 #[test]
1451 fn test_epoch_ms_to_iso8601() {
1452 assert_eq!(
1454 epoch_ms_to_iso8601("1704067200000"),
1455 Some("2024-01-01T00:00:00Z".to_string())
1456 );
1457
1458 assert_eq!(
1460 epoch_ms_to_iso8601("1704153600000"),
1461 Some("2024-01-02T00:00:00Z".to_string())
1462 );
1463
1464 assert_eq!(
1466 epoch_ms_to_iso8601("1705312800000"),
1467 Some("2024-01-15T10:00:00Z".to_string())
1468 );
1469
1470 assert_eq!(epoch_ms_to_iso8601("not_a_number"), None);
1472 }
1473
1474 #[test]
1475 fn test_task_url_cu_prefix() {
1476 let client =
1477 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1478 let url = client.task_url("CU-abc123").unwrap();
1479 assert_eq!(url, "https://api.clickup.com/api/v2/task/abc123");
1480 }
1481
1482 #[test]
1483 fn test_task_url_custom_id_with_team() {
1484 let client =
1485 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"))
1486 .with_team_id("9876");
1487 let url = client.task_url("DEV-42").unwrap();
1488 assert_eq!(
1489 url,
1490 "https://api.clickup.com/api/v2/task/DEV-42?custom_task_ids=true&team_id=9876"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_task_url_custom_id_without_team() {
1496 let client =
1497 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1498 let result = client.task_url("DEV-42");
1499 assert!(result.is_err());
1500 }
1501
1502 #[test]
1503 fn test_map_task() {
1504 let task = ClickUpTask {
1505 id: "abc123".to_string(),
1506 custom_id: None,
1507 name: "Fix bug".to_string(),
1508 description: Some("Bug description".to_string()),
1509 text_content: Some("Bug text content".to_string()),
1510 status: ClickUpStatus {
1511 status: "open".to_string(),
1512 status_type: Some("open".to_string()),
1513 },
1514 priority: Some(ClickUpPriority {
1515 id: "2".to_string(),
1516 priority: "high".to_string(),
1517 color: None,
1518 }),
1519 tags: vec![ClickUpTag {
1520 name: "bug".to_string(),
1521 }],
1522 assignees: vec![ClickUpUser {
1523 id: 1,
1524 username: "dev1".to_string(),
1525 email: Some("dev1@example.com".to_string()),
1526 profile_picture: None,
1527 }],
1528 creator: Some(ClickUpUser {
1529 id: 2,
1530 username: "creator".to_string(),
1531 email: None,
1532 profile_picture: None,
1533 }),
1534 url: "https://app.clickup.com/t/abc123".to_string(),
1535 date_created: Some("1704067200000".to_string()),
1536 date_updated: Some("1704153600000".to_string()),
1537 parent: None,
1538 subtasks: None,
1539 dependencies: None,
1540 linked_tasks: None,
1541 attachments: Vec::new(),
1542 };
1543
1544 let issue = map_task(&task);
1545 assert_eq!(issue.key, "CU-abc123");
1546 assert_eq!(issue.title, "Fix bug");
1547 assert_eq!(issue.description, Some("Bug text content".to_string()));
1548 assert_eq!(issue.state, "open");
1549 assert_eq!(issue.source, "clickup");
1550 assert_eq!(issue.priority, Some("high".to_string()));
1551 assert_eq!(issue.labels, vec!["bug"]);
1552 assert_eq!(issue.assignees.len(), 1);
1553 assert_eq!(issue.assignees[0].username, "dev1");
1554 assert!(issue.author.is_some());
1555 assert_eq!(issue.author.unwrap().username, "creator");
1556 assert_eq!(
1557 issue.url,
1558 Some("https://app.clickup.com/t/abc123".to_string())
1559 );
1560 assert_eq!(issue.created_at, Some("2024-01-01T00:00:00Z".to_string()));
1562 assert_eq!(issue.updated_at, Some("2024-01-02T00:00:00Z".to_string()));
1563 }
1564
1565 #[test]
1566 fn test_map_task_with_custom_id() {
1567 let task = ClickUpTask {
1568 id: "abc123".to_string(),
1569 custom_id: Some("DEV-42".to_string()),
1570 name: "Task with custom ID".to_string(),
1571 description: None,
1572 text_content: None,
1573 status: ClickUpStatus {
1574 status: "open".to_string(),
1575 status_type: Some("open".to_string()),
1576 },
1577 priority: None,
1578 tags: vec![],
1579 assignees: vec![],
1580 creator: None,
1581 url: "https://app.clickup.com/t/abc123".to_string(),
1582 date_created: None,
1583 date_updated: None,
1584 parent: None,
1585 subtasks: None,
1586 dependencies: None,
1587 linked_tasks: None,
1588 attachments: Vec::new(),
1589 };
1590
1591 let issue = map_task(&task);
1592 assert_eq!(issue.key, "DEV-42");
1593 }
1594
1595 #[test]
1596 fn test_map_task_closed_status() {
1597 let task = ClickUpTask {
1598 id: "abc123".to_string(),
1599 custom_id: None,
1600 name: "Closed task".to_string(),
1601 description: None,
1602 text_content: None,
1603 status: ClickUpStatus {
1604 status: "done".to_string(),
1605 status_type: Some("closed".to_string()),
1606 },
1607 priority: None,
1608 tags: vec![],
1609 assignees: vec![],
1610 creator: None,
1611 url: "https://app.clickup.com/t/abc123".to_string(),
1612 date_created: None,
1613 date_updated: None,
1614 parent: None,
1615 subtasks: None,
1616 dependencies: None,
1617 linked_tasks: None,
1618 attachments: Vec::new(),
1619 };
1620
1621 let issue = map_task(&task);
1622 assert_eq!(issue.state, "closed");
1623 }
1624
1625 #[test]
1626 fn test_map_priority_all_levels() {
1627 let make_priority = |id: &str, name: &str| ClickUpPriority {
1628 id: id.to_string(),
1629 priority: name.to_string(),
1630 color: None,
1631 };
1632
1633 assert_eq!(
1634 map_priority(Some(&make_priority("1", "urgent"))),
1635 Some("urgent".to_string())
1636 );
1637 assert_eq!(
1638 map_priority(Some(&make_priority("2", "high"))),
1639 Some("high".to_string())
1640 );
1641 assert_eq!(
1642 map_priority(Some(&make_priority("3", "normal"))),
1643 Some("normal".to_string())
1644 );
1645 assert_eq!(
1646 map_priority(Some(&make_priority("4", "low"))),
1647 Some("low".to_string())
1648 );
1649 assert_eq!(map_priority(None), None);
1650 }
1651
1652 #[test]
1653 fn test_map_user() {
1654 let cu_user = ClickUpUser {
1655 id: 123,
1656 username: "testuser".to_string(),
1657 email: Some("test@example.com".to_string()),
1658 profile_picture: Some("https://example.com/avatar.png".to_string()),
1659 };
1660
1661 let user = map_user(Some(&cu_user)).unwrap();
1662 assert_eq!(user.id, "123");
1663 assert_eq!(user.username, "testuser");
1664 assert_eq!(user.name, Some("testuser".to_string()));
1665 assert_eq!(user.email, Some("test@example.com".to_string()));
1666 assert_eq!(
1667 user.avatar_url,
1668 Some("https://example.com/avatar.png".to_string())
1669 );
1670 }
1671
1672 #[test]
1673 fn test_map_user_none() {
1674 assert!(map_user(None).is_none());
1675 }
1676
1677 #[test]
1678 fn test_map_user_required_with_user() {
1679 let cu_user = ClickUpUser {
1680 id: 1,
1681 username: "user1".to_string(),
1682 email: None,
1683 profile_picture: None,
1684 };
1685 let user = map_user_required(Some(&cu_user));
1686 assert_eq!(user.username, "user1");
1687 }
1688
1689 #[test]
1690 fn test_map_user_required_without_user() {
1691 let user = map_user_required(None);
1692 assert_eq!(user.id, "unknown");
1693 assert_eq!(user.username, "unknown");
1694 }
1695
1696 #[test]
1697 fn test_map_clickup_attachment_all_fields() {
1698 let raw = ClickUpAttachment {
1699 id: "att-1".into(),
1700 title: Some("report.log".into()),
1701 url: Some("https://attachments.clickup.com/abc/report.log".into()),
1702 size: Some(serde_json::json!("2048")),
1703 extension: Some("log".into()),
1704 mimetype: Some("text/plain".into()),
1705 date: Some("1704067200000".into()),
1706 user: Some(ClickUpUser {
1707 id: 7,
1708 username: "uploader".into(),
1709 email: None,
1710 profile_picture: None,
1711 }),
1712 };
1713 let meta = map_clickup_attachment(&raw);
1714 assert_eq!(meta.id, "att-1");
1715 assert_eq!(meta.filename, "report.log");
1716 assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
1717 assert_eq!(meta.size, Some(2048));
1718 assert_eq!(
1719 meta.url.as_deref(),
1720 Some("https://attachments.clickup.com/abc/report.log")
1721 );
1722 assert_eq!(meta.author.as_deref(), Some("uploader"));
1723 assert_eq!(meta.created_at, Some("2024-01-01T00:00:00Z".to_string()));
1724 assert!(!meta.cached);
1725 }
1726
1727 #[test]
1728 fn test_map_clickup_attachment_minimal_falls_back_to_url() {
1729 let raw = ClickUpAttachment {
1730 id: "att-2".into(),
1731 title: None,
1732 url: Some("https://cdn/a/b/screen.png?token=x".into()),
1733 size: Some(serde_json::json!(4096)),
1734 extension: None,
1735 mimetype: None,
1736 date: None,
1737 user: None,
1738 };
1739 let meta = map_clickup_attachment(&raw);
1740 assert_eq!(meta.filename, "screen.png");
1742 assert_eq!(meta.size, Some(4096));
1743 assert!(meta.created_at.is_none());
1744 assert!(meta.author.is_none());
1745 }
1746
1747 #[test]
1748 fn test_map_clickup_attachment_missing_everything() {
1749 let raw = ClickUpAttachment {
1750 id: "att-3".into(),
1751 title: None,
1752 url: None,
1753 size: None,
1754 extension: None,
1755 mimetype: None,
1756 date: None,
1757 user: None,
1758 };
1759 let meta = map_clickup_attachment(&raw);
1760 assert_eq!(meta.filename, "attachment-att-3");
1762 assert!(meta.url.is_none());
1763 assert!(meta.size.is_none());
1764 }
1765
1766 #[test]
1767 fn test_clickup_asset_capabilities() {
1768 let client =
1769 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1770 let caps = client.asset_capabilities();
1771 assert!(caps.issue.upload);
1772 assert!(caps.issue.download);
1773 assert!(caps.issue.list);
1774 assert!(!caps.issue.delete, "ClickUp has no delete attachment API");
1775 assert!(
1776 !caps.merge_request.upload,
1777 "ClickUp does not track merge requests",
1778 );
1779 }
1780
1781 #[test]
1782 fn test_map_comment() {
1783 let cu_comment = ClickUpComment {
1784 id: "42".to_string(),
1785 comment_text: "Nice work!".to_string(),
1786 user: Some(ClickUpUser {
1787 id: 1,
1788 username: "reviewer".to_string(),
1789 email: None,
1790 profile_picture: None,
1791 }),
1792 date: Some("1705312800000".to_string()),
1793 };
1794
1795 let comment = map_comment(&cu_comment);
1796 assert_eq!(comment.id, "42");
1797 assert_eq!(comment.body, "Nice work!");
1798 assert!(comment.author.is_some());
1799 assert_eq!(comment.author.unwrap().username, "reviewer");
1800 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
1802 assert!(comment.position.is_none());
1803 }
1804
1805 #[test]
1806 fn test_map_tags() {
1807 let tags = vec![
1808 ClickUpTag {
1809 name: "bug".to_string(),
1810 },
1811 ClickUpTag {
1812 name: "feature".to_string(),
1813 },
1814 ];
1815 let result = map_tags(&tags);
1816 assert_eq!(result, vec!["bug", "feature"]);
1817 }
1818
1819 #[test]
1820 fn test_map_tags_empty() {
1821 let result = map_tags(&[]);
1822 assert!(result.is_empty());
1823 }
1824
1825 #[test]
1826 fn test_priority_to_clickup() {
1827 assert_eq!(priority_to_clickup("urgent"), Some(1));
1828 assert_eq!(priority_to_clickup("high"), Some(2));
1829 assert_eq!(priority_to_clickup("normal"), Some(3));
1830 assert_eq!(priority_to_clickup("low"), Some(4));
1831 assert_eq!(priority_to_clickup("unknown"), None);
1832 }
1833
1834 #[test]
1835 fn test_api_url() {
1836 let client =
1837 ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1838 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
1839 assert_eq!(client.list_id, "12345");
1840 }
1841
1842 #[test]
1843 fn test_api_url_strips_trailing_slash() {
1844 let client = ClickUpClient::with_base_url(
1845 "https://api.clickup.com/api/v2/",
1846 "12345",
1847 token("token"),
1848 );
1849 assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
1850 }
1851
1852 #[test]
1853 fn test_with_team_id() {
1854 let client = ClickUpClient::new("12345", token("token")).with_team_id("9876");
1855 assert_eq!(client.team_id, Some("9876".to_string()));
1856 }
1857
1858 #[test]
1859 fn test_provider_name() {
1860 let client = ClickUpClient::new("12345", token("token"));
1861 assert_eq!(IssueProvider::provider_name(&client), "clickup");
1862 assert_eq!(MergeRequestProvider::provider_name(&client), "clickup");
1863 }
1864
1865 #[test]
1866 fn test_map_task_description_fallback() {
1867 let task = ClickUpTask {
1868 id: "abc".to_string(),
1869 custom_id: None,
1870 name: "Task".to_string(),
1871 description: Some("HTML description".to_string()),
1872 text_content: None,
1873 status: ClickUpStatus {
1874 status: "open".to_string(),
1875 status_type: Some("open".to_string()),
1876 },
1877 priority: None,
1878 tags: vec![],
1879 assignees: vec![],
1880 creator: None,
1881 url: "https://app.clickup.com/t/abc".to_string(),
1882 date_created: None,
1883 date_updated: None,
1884 parent: None,
1885 subtasks: None,
1886 dependencies: None,
1887 linked_tasks: None,
1888 attachments: Vec::new(),
1889 };
1890
1891 let issue = map_task(&task);
1892 assert_eq!(issue.description, Some("HTML description".to_string()));
1893 }
1894
1895 #[test]
1896 fn test_map_state_custom_type() {
1897 let task = ClickUpTask {
1898 id: "abc".to_string(),
1899 custom_id: None,
1900 name: "Task".to_string(),
1901 description: None,
1902 text_content: None,
1903 status: ClickUpStatus {
1904 status: "in progress".to_string(),
1905 status_type: Some("custom".to_string()),
1906 },
1907 priority: None,
1908 tags: vec![],
1909 assignees: vec![],
1910 creator: None,
1911 url: "https://app.clickup.com/t/abc".to_string(),
1912 date_created: None,
1913 date_updated: None,
1914 parent: None,
1915 subtasks: None,
1916 dependencies: None,
1917 linked_tasks: None,
1918 attachments: Vec::new(),
1919 };
1920
1921 let issue = map_task(&task);
1922 assert_eq!(issue.state, "open");
1923 }
1924
1925 #[test]
1926 fn test_map_task_with_parent() {
1927 let task = ClickUpTask {
1928 id: "child1".to_string(),
1929 custom_id: Some("DEV-100".to_string()),
1930 name: "Child task".to_string(),
1931 description: None,
1932 text_content: None,
1933 status: ClickUpStatus {
1934 status: "open".to_string(),
1935 status_type: Some("open".to_string()),
1936 },
1937 priority: None,
1938 tags: vec![],
1939 assignees: vec![],
1940 creator: None,
1941 url: "https://app.clickup.com/t/child1".to_string(),
1942 date_created: None,
1943 date_updated: None,
1944 parent: Some("parent123".to_string()),
1945 subtasks: None,
1946 dependencies: None,
1947 linked_tasks: None,
1948 attachments: Vec::new(),
1949 };
1950
1951 let issue = map_task(&task);
1952 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
1953 assert!(issue.subtasks.is_empty());
1954 }
1955
1956 #[test]
1957 fn test_map_task_with_subtasks() {
1958 let subtask = ClickUpTask {
1959 id: "sub1".to_string(),
1960 custom_id: Some("DEV-201".to_string()),
1961 name: "Subtask 1".to_string(),
1962 description: None,
1963 text_content: None,
1964 status: ClickUpStatus {
1965 status: "in progress".to_string(),
1966 status_type: Some("custom".to_string()),
1967 },
1968 priority: None,
1969 tags: vec![],
1970 assignees: vec![],
1971 creator: None,
1972 url: "https://app.clickup.com/t/sub1".to_string(),
1973 date_created: None,
1974 date_updated: None,
1975 parent: Some("epic1".to_string()),
1976 subtasks: None,
1977 dependencies: None,
1978 linked_tasks: None,
1979 attachments: Vec::new(),
1980 };
1981
1982 let task = ClickUpTask {
1983 id: "epic1".to_string(),
1984 custom_id: Some("DEV-200".to_string()),
1985 name: "Epic task".to_string(),
1986 description: None,
1987 text_content: None,
1988 status: ClickUpStatus {
1989 status: "open".to_string(),
1990 status_type: Some("open".to_string()),
1991 },
1992 priority: None,
1993 tags: vec![ClickUpTag {
1994 name: "epic".to_string(),
1995 }],
1996 assignees: vec![],
1997 creator: None,
1998 url: "https://app.clickup.com/t/epic1".to_string(),
1999 date_created: None,
2000 date_updated: None,
2001 parent: None,
2002 subtasks: Some(vec![subtask]),
2003 dependencies: None,
2004 linked_tasks: None,
2005 attachments: Vec::new(),
2006 };
2007
2008 let issue = map_task(&task);
2009 assert_eq!(issue.key, "DEV-200");
2010 assert!(issue.parent.is_none());
2011 assert_eq!(issue.subtasks.len(), 1);
2012 assert_eq!(issue.subtasks[0].key, "DEV-201");
2013 assert_eq!(issue.subtasks[0].title, "Subtask 1");
2014 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2015 }
2016
2017 #[test]
2018 fn test_map_task_no_parent_no_subtasks() {
2019 let task = ClickUpTask {
2020 id: "standalone".to_string(),
2021 custom_id: None,
2022 name: "Standalone task".to_string(),
2023 description: None,
2024 text_content: None,
2025 status: ClickUpStatus {
2026 status: "open".to_string(),
2027 status_type: Some("open".to_string()),
2028 },
2029 priority: None,
2030 tags: vec![],
2031 assignees: vec![],
2032 creator: None,
2033 url: "https://app.clickup.com/t/standalone".to_string(),
2034 date_created: None,
2035 date_updated: None,
2036 parent: None,
2037 subtasks: None,
2038 dependencies: None,
2039 linked_tasks: None,
2040 attachments: Vec::new(),
2041 };
2042
2043 let issue = map_task(&task);
2044 assert!(issue.parent.is_none());
2045 assert!(issue.subtasks.is_empty());
2046 }
2047
2048 #[test]
2049 fn test_deserialize_task_with_parent_and_subtasks() {
2050 let json = serde_json::json!({
2051 "id": "epic1",
2052 "custom_id": "DEV-300",
2053 "name": "Epic with subtasks",
2054 "status": {"status": "open", "type": "open"},
2055 "tags": [{"name": "epic"}],
2056 "assignees": [],
2057 "url": "https://app.clickup.com/t/epic1",
2058 "parent": null,
2059 "subtasks": [
2060 {
2061 "id": "sub1",
2062 "custom_id": "DEV-301",
2063 "name": "Subtask A",
2064 "status": {"status": "open", "type": "open"},
2065 "tags": [],
2066 "assignees": [],
2067 "url": "https://app.clickup.com/t/sub1",
2068 "parent": "epic1"
2069 },
2070 {
2071 "id": "sub2",
2072 "name": "Subtask B",
2073 "status": {"status": "closed", "type": "closed"},
2074 "tags": [],
2075 "assignees": [],
2076 "url": "https://app.clickup.com/t/sub2",
2077 "parent": "epic1"
2078 }
2079 ]
2080 });
2081
2082 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2083 assert!(task.parent.is_none());
2084 assert_eq!(task.subtasks.as_ref().unwrap().len(), 2);
2085 assert_eq!(
2086 task.subtasks.as_ref().unwrap()[0].custom_id,
2087 Some("DEV-301".to_string())
2088 );
2089 assert_eq!(
2090 task.subtasks.as_ref().unwrap()[1].parent,
2091 Some("epic1".to_string())
2092 );
2093
2094 let issue = map_task(&task);
2095 assert_eq!(issue.subtasks.len(), 2);
2096 assert_eq!(issue.subtasks[0].key, "DEV-301");
2097 assert_eq!(issue.subtasks[1].key, "CU-sub2");
2098 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2099 }
2100
2101 #[test]
2102 fn test_deserialize_task_without_subtasks_field() {
2103 let json = serde_json::json!({
2105 "id": "task1",
2106 "name": "Simple task",
2107 "status": {"status": "open", "type": "open"},
2108 "tags": [],
2109 "assignees": [],
2110 "url": "https://app.clickup.com/t/task1"
2111 });
2112
2113 let task: ClickUpTask = serde_json::from_value(json).unwrap();
2114 assert!(task.parent.is_none());
2115 assert!(task.subtasks.is_none());
2116
2117 let issue = map_task(&task);
2118 assert!(issue.parent.is_none());
2119 assert!(issue.subtasks.is_empty());
2120 }
2121
2122 #[test]
2123 fn test_map_status_category_name_heuristics() {
2124 assert_eq!(map_status_category(Some("closed"), "Done"), "done");
2126 assert_eq!(map_status_category(Some("done"), "Complete"), "done");
2127
2128 assert_eq!(map_status_category(Some("custom"), "Backlog"), "backlog");
2130 assert_eq!(
2131 map_status_category(Some("custom"), "Product Backlog"),
2132 "backlog"
2133 );
2134 assert_eq!(map_status_category(Some("custom"), "To Do"), "todo");
2135 assert_eq!(map_status_category(Some("custom"), "New"), "todo");
2136 assert_eq!(
2137 map_status_category(Some("custom"), "In Progress"),
2138 "in_progress"
2139 );
2140 assert_eq!(
2141 map_status_category(Some("custom"), "Code Review"),
2142 "in_progress"
2143 );
2144 assert_eq!(map_status_category(Some("custom"), "Doing"), "in_progress");
2145 assert_eq!(map_status_category(Some("custom"), "Active"), "in_progress");
2146 assert_eq!(map_status_category(Some("custom"), "Done"), "done");
2147 assert_eq!(map_status_category(Some("custom"), "Completed"), "done");
2148 assert_eq!(map_status_category(Some("custom"), "Resolved"), "done");
2149 assert_eq!(
2150 map_status_category(Some("custom"), "Cancelled"),
2151 "cancelled"
2152 );
2153 assert_eq!(map_status_category(Some("custom"), "Archived"), "cancelled");
2154 assert_eq!(map_status_category(Some("custom"), "Rejected"), "cancelled");
2155
2156 assert_eq!(map_status_category(Some("open"), "Open"), "todo");
2158
2159 assert_eq!(
2161 map_status_category(Some("custom"), "Some Custom Status"),
2162 "in_progress"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_priority_sort_key() {
2168 assert_eq!(priority_sort_key(Some("urgent")), 1);
2169 assert_eq!(priority_sort_key(Some("high")), 2);
2170 assert_eq!(priority_sort_key(Some("normal")), 3);
2171 assert_eq!(priority_sort_key(Some("low")), 4);
2172 assert_eq!(priority_sort_key(None), 5);
2173 }
2174
2175 mod integration {
2180 use super::*;
2181 use httpmock::prelude::*;
2182
2183 fn create_test_client(server: &MockServer) -> ClickUpClient {
2184 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2185 }
2186
2187 fn create_test_client_with_team(server: &MockServer) -> ClickUpClient {
2188 ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2189 .with_team_id("9876")
2190 }
2191
2192 fn sample_task_json() -> serde_json::Value {
2193 serde_json::json!({
2194 "id": "abc123",
2195 "name": "Test Task",
2196 "description": "<p>Task description</p>",
2197 "text_content": "Task description",
2198 "status": {
2199 "status": "open",
2200 "type": "open"
2201 },
2202 "priority": {
2203 "id": "2",
2204 "priority": "high",
2205 "color": "#ffcc00"
2206 },
2207 "tags": [{"name": "bug"}],
2208 "assignees": [{"id": 1, "username": "dev1"}],
2209 "creator": {"id": 2, "username": "creator"},
2210 "url": "https://app.clickup.com/t/abc123",
2211 "date_created": "1704067200000",
2212 "date_updated": "1704153600000"
2213 })
2214 }
2215
2216 fn sample_closed_task_json() -> serde_json::Value {
2217 serde_json::json!({
2218 "id": "def456",
2219 "name": "Closed Task",
2220 "status": {
2221 "status": "done",
2222 "type": "closed"
2223 },
2224 "tags": [],
2225 "assignees": [],
2226 "url": "https://app.clickup.com/t/def456",
2227 "date_created": "1704067200000",
2228 "date_updated": "1704153600000"
2229 })
2230 }
2231
2232 fn sample_task_with_custom_id_json() -> serde_json::Value {
2233 serde_json::json!({
2234 "id": "abc123",
2235 "custom_id": "DEV-42",
2236 "name": "Task with custom ID",
2237 "status": {
2238 "status": "open",
2239 "type": "open"
2240 },
2241 "tags": [],
2242 "assignees": [],
2243 "url": "https://app.clickup.com/t/abc123",
2244 "date_created": "1704067200000",
2245 "date_updated": "1704153600000"
2246 })
2247 }
2248
2249 #[tokio::test]
2250 async fn test_get_issues() {
2251 let server = MockServer::start();
2252
2253 server.mock(|when, then| {
2254 when.method(GET)
2255 .path("/list/12345/task")
2256 .header("Authorization", "pk_test_token");
2257 then.status(200)
2258 .json_body(serde_json::json!({"tasks": [sample_task_json()]}));
2259 });
2260
2261 let client = create_test_client(&server);
2262 let issues = client
2263 .get_issues(IssueFilter::default())
2264 .await
2265 .unwrap()
2266 .items;
2267
2268 assert_eq!(issues.len(), 1);
2269 assert_eq!(issues[0].key, "CU-abc123");
2270 assert_eq!(issues[0].title, "Test Task");
2271 assert_eq!(issues[0].source, "clickup");
2272 assert_eq!(issues[0].priority, Some("high".to_string()));
2273 assert_eq!(
2275 issues[0].created_at,
2276 Some("2024-01-01T00:00:00Z".to_string())
2277 );
2278 }
2279
2280 #[tokio::test]
2281 async fn test_get_issues_with_filters() {
2282 let server = MockServer::start();
2283
2284 server.mock(|when, then| {
2285 when.method(GET)
2286 .path("/list/12345/task")
2287 .query_param("include_closed", "true")
2288 .query_param("subtasks", "true")
2289 .query_param("tags[]", "bug");
2290 then.status(200).json_body(
2291 serde_json::json!({"tasks": [sample_task_json(), sample_closed_task_json()]}),
2292 );
2293 });
2294
2295 let client = create_test_client(&server);
2296 let issues = client
2297 .get_issues(IssueFilter {
2298 state: Some("all".to_string()),
2299 labels: Some(vec!["bug".to_string()]),
2300 ..Default::default()
2301 })
2302 .await
2303 .unwrap()
2304 .items;
2305
2306 assert_eq!(issues.len(), 2);
2307 }
2308
2309 #[tokio::test]
2310 async fn test_get_issues_state_filter_open() {
2311 let server = MockServer::start();
2312
2313 server.mock(|when, then| {
2314 when.method(GET).path("/list/12345/task");
2315 then.status(200).json_body(serde_json::json!({
2316 "tasks": [sample_task_json(), sample_closed_task_json()]
2317 }));
2318 });
2319
2320 let client = create_test_client(&server);
2321 let issues = client
2322 .get_issues(IssueFilter {
2323 state: Some("open".to_string()),
2324 ..Default::default()
2325 })
2326 .await
2327 .unwrap()
2328 .items;
2329
2330 assert_eq!(issues.len(), 1);
2331 assert_eq!(issues[0].state, "open");
2332 }
2333
2334 #[tokio::test]
2335 async fn test_get_issues_state_filter_closed() {
2336 let server = MockServer::start();
2337
2338 server.mock(|when, then| {
2339 when.method(GET)
2340 .path("/list/12345/task")
2341 .query_param("include_closed", "true");
2342 then.status(200).json_body(serde_json::json!({
2343 "tasks": [sample_task_json(), sample_closed_task_json()]
2344 }));
2345 });
2346
2347 let client = create_test_client(&server);
2348 let issues = client
2349 .get_issues(IssueFilter {
2350 state: Some("closed".to_string()),
2351 ..Default::default()
2352 })
2353 .await
2354 .unwrap()
2355 .items;
2356
2357 assert_eq!(issues.len(), 1);
2358 assert_eq!(issues[0].state, "closed");
2359 }
2360
2361 #[tokio::test]
2362 async fn test_get_issues_pagination() {
2363 let server = MockServer::start();
2364
2365 let tasks: Vec<serde_json::Value> = (0..5)
2366 .map(|i| {
2367 serde_json::json!({
2368 "id": format!("task{}", i),
2369 "name": format!("Task {}", i),
2370 "status": {"status": "open", "type": "open"},
2371 "tags": [],
2372 "assignees": [],
2373 "url": format!("https://app.clickup.com/t/task{}", i),
2374 "date_created": "1704067200000",
2375 "date_updated": "1704153600000"
2376 })
2377 })
2378 .collect();
2379
2380 server.mock(|when, then| {
2381 when.method(GET)
2382 .path("/list/12345/task")
2383 .query_param("page", "0");
2384 then.status(200)
2385 .json_body(serde_json::json!({"tasks": tasks}));
2386 });
2387
2388 let client = create_test_client(&server);
2389
2390 let issues = client
2391 .get_issues(IssueFilter {
2392 limit: Some(2),
2393 offset: Some(1),
2394 ..Default::default()
2395 })
2396 .await
2397 .unwrap()
2398 .items;
2399
2400 assert_eq!(issues.len(), 2);
2401 assert_eq!(issues[0].key, "CU-task1");
2402 assert_eq!(issues[1].key, "CU-task2");
2403 }
2404
2405 #[tokio::test]
2406 async fn test_get_issues_limit_zero() {
2407 let client = ClickUpClient::new("12345", token("token"));
2409 let issues = client
2410 .get_issues(IssueFilter {
2411 limit: Some(0),
2412 ..Default::default()
2413 })
2414 .await
2415 .unwrap()
2416 .items;
2417
2418 assert!(issues.is_empty());
2419 }
2420
2421 #[tokio::test]
2422 async fn test_get_issues_multi_page() {
2423 let server = MockServer::start();
2424
2425 let page0_tasks: Vec<serde_json::Value> = (0..100)
2427 .map(|i| {
2428 serde_json::json!({
2429 "id": format!("task{}", i),
2430 "name": format!("Task {}", i),
2431 "status": {"status": "open", "type": "open"},
2432 "tags": [],
2433 "assignees": [],
2434 "url": format!("https://app.clickup.com/t/task{}", i),
2435 "date_created": "1704067200000",
2436 "date_updated": "1704153600000"
2437 })
2438 })
2439 .collect();
2440
2441 let page1_tasks: Vec<serde_json::Value> = (100..150)
2443 .map(|i| {
2444 serde_json::json!({
2445 "id": format!("task{}", i),
2446 "name": format!("Task {}", i),
2447 "status": {"status": "open", "type": "open"},
2448 "tags": [],
2449 "assignees": [],
2450 "url": format!("https://app.clickup.com/t/task{}", i),
2451 "date_created": "1704067200000",
2452 "date_updated": "1704153600000"
2453 })
2454 })
2455 .collect();
2456
2457 server.mock(|when, then| {
2458 when.method(GET)
2459 .path("/list/12345/task")
2460 .query_param("page", "0");
2461 then.status(200)
2462 .json_body(serde_json::json!({"tasks": page0_tasks}));
2463 });
2464
2465 server.mock(|when, then| {
2466 when.method(GET)
2467 .path("/list/12345/task")
2468 .query_param("page", "1");
2469 then.status(200)
2470 .json_body(serde_json::json!({"tasks": page1_tasks}));
2471 });
2472
2473 let client = create_test_client(&server);
2474
2475 let issues = client
2477 .get_issues(IssueFilter {
2478 limit: Some(120),
2479 offset: Some(0),
2480 ..Default::default()
2481 })
2482 .await
2483 .unwrap()
2484 .items;
2485
2486 assert_eq!(issues.len(), 120);
2487 assert_eq!(issues[0].key, "CU-task0");
2488 assert_eq!(issues[99].key, "CU-task99");
2489 assert_eq!(issues[100].key, "CU-task100");
2490 assert_eq!(issues[119].key, "CU-task119");
2491 }
2492
2493 #[tokio::test]
2494 async fn test_get_issue() {
2495 let server = MockServer::start();
2496
2497 server.mock(|when, then| {
2498 when.method(GET).path("/task/abc123");
2499 then.status(200).json_body(sample_task_json());
2500 });
2501
2502 let client = create_test_client(&server);
2503 let issue = client.get_issue("CU-abc123").await.unwrap();
2504
2505 assert_eq!(issue.key, "CU-abc123");
2506 assert_eq!(issue.title, "Test Task");
2507 assert_eq!(issue.priority, Some("high".to_string()));
2508 }
2509
2510 #[tokio::test]
2511 async fn test_get_issue_by_custom_id() {
2512 let server = MockServer::start();
2513
2514 server.mock(|when, then| {
2515 when.method(GET)
2516 .path("/task/DEV-42")
2517 .query_param("custom_task_ids", "true")
2518 .query_param("team_id", "9876");
2519 then.status(200)
2520 .json_body(sample_task_with_custom_id_json());
2521 });
2522
2523 let client = create_test_client_with_team(&server);
2524 let issue = client.get_issue("DEV-42").await.unwrap();
2525
2526 assert_eq!(issue.key, "DEV-42");
2527 assert_eq!(issue.title, "Task with custom ID");
2528 }
2529
2530 #[tokio::test]
2531 async fn test_get_issue_custom_id_without_team_fails() {
2532 let client = ClickUpClient::new("12345", token("token"));
2533 let result = client.get_issue("DEV-42").await;
2534 assert!(result.is_err());
2535 }
2536
2537 #[tokio::test]
2538 async fn test_create_issue_with_custom_id_retry() {
2539 let server = MockServer::start();
2540
2541 server.mock(|when, then| {
2543 when.method(POST)
2544 .path("/list/12345/task")
2545 .body_includes("\"name\":\"New Task\"");
2546 then.status(200).json_body(sample_task_json());
2547 });
2548
2549 let mut task_with_custom_id = sample_task_json();
2551 task_with_custom_id["custom_id"] = serde_json::json!("DEV-100");
2552
2553 server.mock(|when, then| {
2554 when.method(GET).path("/task/abc123");
2555 then.status(200).json_body(task_with_custom_id);
2556 });
2557
2558 let client = create_test_client(&server);
2559 let issue = client
2560 .create_issue(CreateIssueInput {
2561 title: "New Task".to_string(),
2562 description: Some("Description".to_string()),
2563 labels: vec!["bug".to_string()],
2564 ..Default::default()
2565 })
2566 .await
2567 .unwrap();
2568
2569 assert_eq!(issue.key, "DEV-100");
2571 }
2572
2573 #[tokio::test]
2574 async fn test_create_issue_fallback_without_custom_id() {
2575 let server = MockServer::start();
2576
2577 server.mock(|when, then| {
2579 when.method(POST)
2580 .path("/list/12345/task")
2581 .body_includes("\"name\":\"New Task\"");
2582 then.status(200).json_body(sample_task_json());
2583 });
2584
2585 server.mock(|when, then| {
2587 when.method(GET).path("/task/abc123");
2588 then.status(200).json_body(sample_task_json());
2589 });
2590
2591 let client = create_test_client(&server);
2592 let issue = client
2593 .create_issue(CreateIssueInput {
2594 title: "New Task".to_string(),
2595 ..Default::default()
2596 })
2597 .await
2598 .unwrap();
2599
2600 assert_eq!(issue.key, "CU-abc123");
2602 }
2603
2604 #[tokio::test]
2605 async fn test_create_issue_with_priority() {
2606 let server = MockServer::start();
2607
2608 let mut task = sample_task_json();
2610 task["custom_id"] = serde_json::json!("DEV-101");
2611
2612 server.mock(|when, then| {
2613 when.method(POST)
2614 .path("/list/12345/task")
2615 .body_includes("\"priority\":1");
2616 then.status(200).json_body(task);
2617 });
2618
2619 let client = create_test_client(&server);
2620 let result = client
2621 .create_issue(CreateIssueInput {
2622 title: "Urgent Task".to_string(),
2623 priority: Some("urgent".to_string()),
2624 ..Default::default()
2625 })
2626 .await;
2627
2628 assert!(result.is_ok());
2629 assert_eq!(result.unwrap().key, "DEV-101");
2630 }
2631
2632 #[tokio::test]
2633 async fn test_update_issue() {
2634 let server = MockServer::start();
2635
2636 server.mock(|when, then| {
2637 when.method(PUT)
2638 .path("/task/abc123")
2639 .body_includes("\"name\":\"Updated Task\"");
2640 then.status(200).json_body(sample_task_json());
2641 });
2642
2643 server.mock(|when, then| {
2644 when.method(GET).path("/task/abc123");
2645 then.status(200).json_body(sample_task_json());
2646 });
2647
2648 let client = create_test_client(&server);
2649 let issue = client
2650 .update_issue(
2651 "CU-abc123",
2652 UpdateIssueInput {
2653 title: Some("Updated Task".to_string()),
2654 ..Default::default()
2655 },
2656 )
2657 .await
2658 .unwrap();
2659
2660 assert_eq!(issue.key, "CU-abc123");
2661 }
2662
2663 #[tokio::test]
2664 async fn test_update_issue_by_custom_id() {
2665 let server = MockServer::start();
2666
2667 server.mock(|when, then| {
2668 when.method(PUT)
2669 .path("/task/DEV-42")
2670 .query_param("custom_task_ids", "true")
2671 .query_param("team_id", "9876");
2672 then.status(200)
2673 .json_body(sample_task_with_custom_id_json());
2674 });
2675
2676 server.mock(|when, then| {
2677 when.method(GET)
2678 .path("/task/DEV-42")
2679 .query_param("custom_task_ids", "true")
2680 .query_param("team_id", "9876");
2681 then.status(200)
2682 .json_body(sample_task_with_custom_id_json());
2683 });
2684
2685 let client = create_test_client_with_team(&server);
2686 let issue = client
2687 .update_issue(
2688 "DEV-42",
2689 UpdateIssueInput {
2690 title: Some("Updated".to_string()),
2691 ..Default::default()
2692 },
2693 )
2694 .await
2695 .unwrap();
2696
2697 assert_eq!(issue.key, "DEV-42");
2698 }
2699
2700 #[tokio::test]
2701 async fn test_update_issue_state_mapping() {
2702 let server = MockServer::start();
2703
2704 server.mock(|when, then| {
2706 when.method(GET).path("/list/12345");
2707 then.status(200).json_body(serde_json::json!({
2708 "statuses": [
2709 {"status": "to do", "type": "open"},
2710 {"status": "in progress", "type": "custom"},
2711 {"status": "complete", "type": "closed"}
2712 ]
2713 }));
2714 });
2715
2716 server.mock(|when, then| {
2717 when.method(PUT)
2718 .path("/task/abc123")
2719 .body_includes("\"status\":\"complete\"");
2720 then.status(200).json_body(sample_task_json());
2721 });
2722
2723 server.mock(|when, then| {
2724 when.method(GET).path("/task/abc123");
2725 then.status(200).json_body(sample_task_json());
2726 });
2727
2728 let client = create_test_client(&server);
2729 let result = client
2730 .update_issue(
2731 "CU-abc123",
2732 UpdateIssueInput {
2733 state: Some("closed".to_string()),
2734 ..Default::default()
2735 },
2736 )
2737 .await;
2738
2739 assert!(result.is_ok());
2740 }
2741
2742 #[tokio::test]
2745 async fn test_update_issue_state_refetch_returns_fresh_state() {
2746 let server = MockServer::start();
2747
2748 server.mock(|when, then| {
2749 when.method(GET).path("/list/12345");
2750 then.status(200).json_body(serde_json::json!({
2751 "statuses": [
2752 {"status": "to do", "type": "open"},
2753 {"status": "complete", "type": "closed"}
2754 ]
2755 }));
2756 });
2757
2758 server.mock(|when, then| {
2760 when.method(PUT)
2761 .path("/task/abc123")
2762 .body_includes("\"status\":\"complete\"");
2763 then.status(200).json_body(sample_task_json()); });
2765
2766 server.mock(|when, then| {
2768 when.method(GET).path("/task/abc123");
2769 then.status(200).json_body(serde_json::json!({
2770 "id": "abc123",
2771 "name": "Test Task",
2772 "status": {
2773 "status": "complete",
2774 "type": "closed"
2775 },
2776 "tags": [{"name": "bug"}],
2777 "assignees": [{"id": 1, "username": "dev1"}],
2778 "url": "https://app.clickup.com/t/abc123",
2779 "date_created": "1704067200000",
2780 "date_updated": "1704153600000"
2781 }));
2782 });
2783
2784 let client = create_test_client(&server);
2785 let issue = client
2786 .update_issue(
2787 "CU-abc123",
2788 UpdateIssueInput {
2789 state: Some("closed".to_string()),
2790 ..Default::default()
2791 },
2792 )
2793 .await
2794 .unwrap();
2795
2796 assert_eq!(issue.state, "closed");
2797 }
2798
2799 #[tokio::test]
2800 async fn test_update_issue_state_open_mapping() {
2801 let server = MockServer::start();
2802
2803 server.mock(|when, then| {
2804 when.method(GET).path("/list/12345");
2805 then.status(200).json_body(serde_json::json!({
2806 "statuses": [
2807 {"status": "to do", "type": "open"},
2808 {"status": "complete", "type": "closed"}
2809 ]
2810 }));
2811 });
2812
2813 server.mock(|when, then| {
2814 when.method(PUT)
2815 .path("/task/abc123")
2816 .body_includes("\"status\":\"to do\"");
2817 then.status(200).json_body(sample_task_json());
2818 });
2819
2820 server.mock(|when, then| {
2821 when.method(GET).path("/task/abc123");
2822 then.status(200).json_body(sample_task_json());
2823 });
2824
2825 let client = create_test_client(&server);
2826 let result = client
2827 .update_issue(
2828 "CU-abc123",
2829 UpdateIssueInput {
2830 state: Some("open".to_string()),
2831 ..Default::default()
2832 },
2833 )
2834 .await;
2835
2836 assert!(result.is_ok());
2837 }
2838
2839 #[tokio::test]
2840 async fn test_update_issue_exact_status_name() {
2841 let server = MockServer::start();
2842
2843 server.mock(|when, then| {
2845 when.method(PUT)
2846 .path("/task/abc123")
2847 .body_includes("\"status\":\"in progress\"");
2848 then.status(200).json_body(sample_task_json());
2849 });
2850
2851 server.mock(|when, then| {
2852 when.method(GET).path("/task/abc123");
2853 then.status(200).json_body(sample_task_json());
2854 });
2855
2856 let client = create_test_client(&server);
2857 let result = client
2858 .update_issue(
2859 "CU-abc123",
2860 UpdateIssueInput {
2861 state: Some("in progress".to_string()),
2862 ..Default::default()
2863 },
2864 )
2865 .await;
2866
2867 assert!(result.is_ok());
2868 }
2869
2870 #[tokio::test]
2871 async fn test_get_comments() {
2872 let server = MockServer::start();
2873
2874 server.mock(|when, then| {
2875 when.method(GET).path("/task/abc123/comment");
2876 then.status(200).json_body(serde_json::json!({
2877 "comments": [{
2878 "id": "1",
2879 "comment_text": "Looks good!",
2880 "user": {"id": 1, "username": "reviewer"},
2881 "date": "1705312800000"
2882 }]
2883 }));
2884 });
2885
2886 let client = create_test_client(&server);
2887 let comments = client.get_comments("CU-abc123").await.unwrap().items;
2888
2889 assert_eq!(comments.len(), 1);
2890 assert_eq!(comments[0].body, "Looks good!");
2891 assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
2892 assert_eq!(
2894 comments[0].created_at,
2895 Some("2024-01-15T10:00:00Z".to_string())
2896 );
2897 }
2898
2899 #[tokio::test]
2900 async fn test_add_comment() {
2901 let server = MockServer::start();
2902
2903 server.mock(|when, then| {
2905 when.method(POST)
2906 .path("/task/abc123/comment")
2907 .body_includes("\"comment_text\":\"My comment\"");
2908 then.status(200).json_body(serde_json::json!({
2909 "id": 458315,
2910 "hist_id": "26b2d7f1-test",
2911 "date": 1705312800000_i64
2912 }));
2913 });
2914
2915 let client = create_test_client(&server);
2916 let comment = IssueProvider::add_comment(&client, "CU-abc123", "My comment")
2917 .await
2918 .unwrap();
2919
2920 assert_eq!(comment.body, "My comment");
2921 assert_eq!(comment.id, "458315");
2922 assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
2923 }
2924
2925 #[tokio::test]
2926 async fn test_handle_response_401() {
2927 let server = MockServer::start();
2928
2929 server.mock(|when, then| {
2930 when.method(GET).path("/list/12345/task");
2931 then.status(401).body("Token invalid");
2932 });
2933
2934 let client = create_test_client(&server);
2935 let result = client.get_issues(IssueFilter::default()).await;
2936
2937 assert!(result.is_err());
2938 let err = result.unwrap_err();
2939 assert!(matches!(err, Error::Unauthorized(_)));
2940 }
2941
2942 #[tokio::test]
2943 async fn test_handle_response_404() {
2944 let server = MockServer::start();
2945
2946 server.mock(|when, then| {
2947 when.method(GET).path("/task/nonexistent");
2948 then.status(404).body("Task not found");
2949 });
2950
2951 let client = create_test_client(&server);
2952 let result = client.get_issue("CU-nonexistent").await;
2953
2954 assert!(result.is_err());
2955 let err = result.unwrap_err();
2956 assert!(matches!(err, Error::NotFound(_)));
2957 }
2958
2959 #[tokio::test]
2960 async fn test_handle_response_500() {
2961 let server = MockServer::start();
2962
2963 server.mock(|when, then| {
2964 when.method(GET).path("/list/12345/task");
2965 then.status(500).body("Internal Server Error");
2966 });
2967
2968 let client = create_test_client(&server);
2969 let result = client.get_issues(IssueFilter::default()).await;
2970
2971 assert!(result.is_err());
2972 let err = result.unwrap_err();
2973 assert!(matches!(err, Error::ServerError { .. }));
2974 }
2975
2976 #[tokio::test]
2977 async fn test_mr_methods_unsupported() {
2978 let client = ClickUpClient::new("12345", token("token"));
2979
2980 let result = client.get_merge_requests(MrFilter::default()).await;
2981 assert!(matches!(
2982 result.unwrap_err(),
2983 Error::ProviderUnsupported { .. }
2984 ));
2985
2986 let result = client.get_merge_request("mr#1").await;
2987 assert!(matches!(
2988 result.unwrap_err(),
2989 Error::ProviderUnsupported { .. }
2990 ));
2991
2992 let result = client.get_discussions("mr#1").await;
2993 assert!(matches!(
2994 result.unwrap_err(),
2995 Error::ProviderUnsupported { .. }
2996 ));
2997
2998 let result = client.get_diffs("mr#1").await;
2999 assert!(matches!(
3000 result.unwrap_err(),
3001 Error::ProviderUnsupported { .. }
3002 ));
3003
3004 let result = MergeRequestProvider::add_comment(
3005 &client,
3006 "mr#1",
3007 CreateCommentInput {
3008 body: "test".to_string(),
3009 position: None,
3010 discussion_id: None,
3011 },
3012 )
3013 .await;
3014 assert!(matches!(
3015 result.unwrap_err(),
3016 Error::ProviderUnsupported { .. }
3017 ));
3018 }
3019
3020 #[tokio::test]
3021 async fn test_get_current_user() {
3022 let server = MockServer::start();
3023
3024 server.mock(|when, then| {
3025 when.method(GET).path("/list/12345/task");
3026 then.status(200).json_body(serde_json::json!({"tasks": []}));
3027 });
3028
3029 let client = create_test_client(&server);
3030 let user = client.get_current_user().await.unwrap();
3031
3032 assert_eq!(user.username, "clickup-user");
3033 }
3034
3035 #[tokio::test]
3036 async fn test_get_current_user_auth_failure() {
3037 let server = MockServer::start();
3038
3039 server.mock(|when, then| {
3040 when.method(GET).path("/list/12345/task");
3041 then.status(401).body("Unauthorized");
3042 });
3043
3044 let client = create_test_client(&server);
3045 let result = client.get_current_user().await;
3046
3047 assert!(result.is_err());
3048 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
3049 }
3050
3051 #[tokio::test]
3052 async fn test_get_issue_includes_subtasks() {
3053 let server = MockServer::start();
3054
3055 let task_with_subtasks = serde_json::json!({
3056 "id": "epic1",
3057 "custom_id": "DEV-400",
3058 "name": "Epic Task",
3059 "status": {"status": "open", "type": "open"},
3060 "tags": [{"name": "epic"}],
3061 "assignees": [],
3062 "creator": {"id": 1, "username": "author"},
3063 "url": "https://app.clickup.com/t/epic1",
3064 "date_created": "1704067200000",
3065 "date_updated": "1704153600000",
3066 "subtasks": [
3067 {
3068 "id": "sub1",
3069 "custom_id": "DEV-401",
3070 "name": "Subtask 1",
3071 "status": {"status": "open", "type": "open"},
3072 "tags": [],
3073 "assignees": [],
3074 "url": "https://app.clickup.com/t/sub1",
3075 "parent": "epic1"
3076 },
3077 {
3078 "id": "sub2",
3079 "custom_id": "DEV-402",
3080 "name": "Subtask 2",
3081 "status": {"status": "closed", "type": "closed"},
3082 "tags": [],
3083 "assignees": [],
3084 "url": "https://app.clickup.com/t/sub2",
3085 "parent": "epic1"
3086 }
3087 ]
3088 });
3089
3090 server.mock(|when, then| {
3091 when.method(GET)
3092 .path("/task/epic1")
3093 .query_param("include_subtasks", "true");
3094 then.status(200).json_body(task_with_subtasks);
3095 });
3096
3097 let client = create_test_client(&server);
3098 let issue = client.get_issue("CU-epic1").await.unwrap();
3099
3100 assert_eq!(issue.key, "DEV-400");
3101 assert!(issue.parent.is_none());
3102 assert_eq!(issue.subtasks.len(), 2);
3103 assert_eq!(issue.subtasks[0].key, "DEV-401");
3104 assert_eq!(issue.subtasks[0].title, "Subtask 1");
3105 assert_eq!(issue.subtasks[0].state, "open");
3106 assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
3107 assert_eq!(issue.subtasks[1].key, "DEV-402");
3108 assert_eq!(issue.subtasks[1].state, "closed");
3109 }
3110
3111 #[tokio::test]
3112 async fn test_get_issue_no_subtasks() {
3113 let server = MockServer::start();
3114
3115 let task = sample_task_json();
3116
3117 server.mock(|when, then| {
3118 when.method(GET)
3119 .path("/task/abc123")
3120 .query_param("include_subtasks", "true");
3121 then.status(200).json_body(task);
3122 });
3123
3124 let client = create_test_client(&server);
3125 let issue = client.get_issue("CU-abc123").await.unwrap();
3126
3127 assert!(issue.subtasks.is_empty());
3128 assert!(issue.parent.is_none());
3129 }
3130
3131 #[tokio::test]
3132 async fn test_get_issue_custom_id_includes_subtasks() {
3133 let server = MockServer::start();
3134
3135 let task = serde_json::json!({
3136 "id": "task1",
3137 "custom_id": "DEV-500",
3138 "name": "Task via custom ID",
3139 "status": {"status": "open", "type": "open"},
3140 "tags": [],
3141 "assignees": [],
3142 "url": "https://app.clickup.com/t/task1",
3143 "parent": "parent123",
3144 "subtasks": []
3145 });
3146
3147 server.mock(|when, then| {
3148 when.method(GET)
3149 .path("/task/DEV-500")
3150 .query_param("custom_task_ids", "true")
3151 .query_param("team_id", "9876")
3152 .query_param("include_subtasks", "true");
3153 then.status(200).json_body(task);
3154 });
3155
3156 let client = create_test_client_with_team(&server);
3157 let issue = client.get_issue("DEV-500").await.unwrap();
3158
3159 assert_eq!(issue.key, "DEV-500");
3160 assert_eq!(issue.parent, Some("CU-parent123".to_string()));
3161 assert!(issue.subtasks.is_empty());
3162 }
3163
3164 #[tokio::test]
3165 async fn test_update_issue_with_parent_id() {
3166 let server = MockServer::start();
3167
3168 let parent_task = serde_json::json!({
3170 "id": "parent_native_id",
3171 "custom_id": "DEV-600",
3172 "name": "Parent Epic",
3173 "status": {"status": "open", "type": "open"},
3174 "tags": [],
3175 "assignees": [],
3176 "url": "https://app.clickup.com/t/parent_native_id"
3177 });
3178
3179 server.mock(|when, then| {
3180 when.method(GET)
3181 .path("/task/DEV-600")
3182 .query_param("custom_task_ids", "true")
3183 .query_param("team_id", "9876");
3184 then.status(200).json_body(parent_task);
3185 });
3186
3187 let updated_task = serde_json::json!({
3189 "id": "child1",
3190 "custom_id": "DEV-601",
3191 "name": "Child Task",
3192 "status": {"status": "open", "type": "open"},
3193 "tags": [],
3194 "assignees": [],
3195 "url": "https://app.clickup.com/t/child1",
3196 "parent": "parent_native_id"
3197 });
3198
3199 server.mock(|when, then| {
3200 when.method(PUT)
3201 .path("/task/DEV-601")
3202 .query_param("custom_task_ids", "true")
3203 .query_param("team_id", "9876")
3204 .body_includes("\"parent\":\"parent_native_id\"");
3205 then.status(200).json_body(updated_task.clone());
3206 });
3207
3208 server.mock(|when, then| {
3209 when.method(GET)
3210 .path("/task/DEV-601")
3211 .query_param("custom_task_ids", "true")
3212 .query_param("team_id", "9876");
3213 then.status(200).json_body(updated_task);
3214 });
3215
3216 let client = create_test_client_with_team(&server);
3217 let issue = client
3218 .update_issue(
3219 "DEV-601",
3220 UpdateIssueInput {
3221 parent_id: Some("DEV-600".to_string()),
3222 ..Default::default()
3223 },
3224 )
3225 .await
3226 .unwrap();
3227
3228 assert_eq!(issue.key, "DEV-601");
3229 assert_eq!(issue.parent, Some("CU-parent_native_id".to_string()));
3230 }
3231
3232 #[tokio::test]
3233 async fn test_create_issue_with_parent() {
3234 let server = MockServer::start();
3235
3236 let parent_task = serde_json::json!({
3238 "id": "parent_id",
3239 "custom_id": "DEV-700",
3240 "name": "Parent",
3241 "status": {"status": "open", "type": "open"},
3242 "tags": [],
3243 "assignees": [],
3244 "url": "https://app.clickup.com/t/parent_id"
3245 });
3246
3247 server.mock(|when, then| {
3248 when.method(GET)
3249 .path("/task/DEV-700")
3250 .query_param("custom_task_ids", "true")
3251 .query_param("team_id", "9876");
3252 then.status(200).json_body(parent_task);
3253 });
3254
3255 let created_task = serde_json::json!({
3257 "id": "new_child",
3258 "custom_id": "DEV-701",
3259 "name": "New Subtask",
3260 "status": {"status": "open", "type": "open"},
3261 "tags": [],
3262 "assignees": [],
3263 "url": "https://app.clickup.com/t/new_child",
3264 "parent": "parent_id"
3265 });
3266
3267 server.mock(|when, then| {
3268 when.method(POST)
3269 .path("/list/12345/task")
3270 .body_includes("\"parent\":\"parent_id\"");
3271 then.status(200).json_body(created_task);
3272 });
3273
3274 let client = create_test_client_with_team(&server);
3275 let issue = client
3276 .create_issue(CreateIssueInput {
3277 title: "New Subtask".to_string(),
3278 parent: Some("DEV-700".to_string()),
3279 ..Default::default()
3280 })
3281 .await
3282 .unwrap();
3283
3284 assert_eq!(issue.key, "DEV-701");
3285 assert_eq!(issue.parent, Some("CU-parent_id".to_string()));
3286 }
3287
3288 #[tokio::test]
3289 async fn test_get_issues_search_filter() {
3290 let server = MockServer::start_async().await;
3291
3292 server.mock(|when, then| {
3293 when.method(GET).path("/list/12345/task");
3294 then.status(200).json_body(serde_json::json!({
3295 "tasks": [
3296 {
3297 "id": "1", "name": "Fix login bug",
3298 "description": "Authentication fails",
3299 "text_content": "Authentication fails",
3300 "status": {"status": "open", "type": "open"},
3301 "tags": [], "assignees": [],
3302 "url": "https://app.clickup.com/t/1"
3303 },
3304 {
3305 "id": "2", "name": "Add dark mode",
3306 "description": "Theme support",
3307 "text_content": "Theme support",
3308 "status": {"status": "open", "type": "open"},
3309 "tags": [], "assignees": [],
3310 "url": "https://app.clickup.com/t/2"
3311 },
3312 {
3313 "id": "3", "name": "Update docs",
3314 "description": "Fix login instructions",
3315 "text_content": "Fix login instructions",
3316 "status": {"status": "open", "type": "open"},
3317 "tags": [], "assignees": [],
3318 "url": "https://app.clickup.com/t/3"
3319 }
3320 ]
3321 }));
3322 });
3323
3324 let client = create_test_client(&server);
3325
3326 let issues = client
3328 .get_issues(IssueFilter {
3329 search: Some("login".to_string()),
3330 ..Default::default()
3331 })
3332 .await
3333 .unwrap()
3334 .items;
3335 assert_eq!(issues.len(), 2);
3336 assert!(issues.iter().any(|i| i.title == "Fix login bug"));
3337 assert!(issues.iter().any(|i| i.title == "Update docs")); let issues = client
3341 .get_issues(IssueFilter {
3342 search: Some("CU-2".to_string()),
3343 ..Default::default()
3344 })
3345 .await
3346 .unwrap()
3347 .items;
3348 assert_eq!(issues.len(), 1);
3349 assert_eq!(issues[0].title, "Add dark mode");
3350
3351 let issues = client
3353 .get_issues(IssueFilter {
3354 search: Some("nonexistent".to_string()),
3355 ..Default::default()
3356 })
3357 .await
3358 .unwrap()
3359 .items;
3360 assert!(issues.is_empty());
3361 }
3362
3363 #[tokio::test]
3364 async fn test_get_issues_sort_by_priority() {
3365 let server = MockServer::start_async().await;
3366
3367 server.mock(|when, then| {
3368 when.method(GET).path("/list/12345/task");
3369 then.status(200).json_body(serde_json::json!({
3370 "tasks": [
3371 {
3372 "id": "1", "name": "Low task",
3373 "status": {"status": "open", "type": "open"},
3374 "priority": {"id": "4", "priority": "low"},
3375 "tags": [], "assignees": [],
3376 "url": "https://app.clickup.com/t/1"
3377 },
3378 {
3379 "id": "2", "name": "Urgent task",
3380 "status": {"status": "open", "type": "open"},
3381 "priority": {"id": "1", "priority": "urgent"},
3382 "tags": [], "assignees": [],
3383 "url": "https://app.clickup.com/t/2"
3384 },
3385 {
3386 "id": "3", "name": "Normal task",
3387 "status": {"status": "open", "type": "open"},
3388 "priority": {"id": "3", "priority": "normal"},
3389 "tags": [], "assignees": [],
3390 "url": "https://app.clickup.com/t/3"
3391 }
3392 ]
3393 }));
3394 });
3395
3396 let client = create_test_client(&server);
3397
3398 let result = client
3400 .get_issues(IssueFilter {
3401 sort_by: Some("priority".to_string()),
3402 sort_order: Some("asc".to_string()),
3403 ..Default::default()
3404 })
3405 .await
3406 .unwrap();
3407 assert_eq!(result.items[0].priority, Some("urgent".to_string()));
3408 assert_eq!(result.items[1].priority, Some("normal".to_string()));
3409 assert_eq!(result.items[2].priority, Some("low".to_string()));
3410
3411 let sort_info = result.sort_info.unwrap();
3413 assert_eq!(sort_info.sort_by, Some("priority".to_string()));
3414 assert!(sort_info.available_sorts.contains(&"priority".into()));
3415 }
3416
3417 #[tokio::test]
3418 async fn test_get_issues_sort_by_title() {
3419 let server = MockServer::start_async().await;
3420
3421 server.mock(|when, then| {
3422 when.method(GET).path("/list/12345/task");
3423 then.status(200).json_body(serde_json::json!({
3424 "tasks": [
3425 {
3426 "id": "1", "name": "Charlie",
3427 "status": {"status": "open", "type": "open"},
3428 "tags": [], "assignees": [],
3429 "url": "https://app.clickup.com/t/1"
3430 },
3431 {
3432 "id": "2", "name": "Alpha",
3433 "status": {"status": "open", "type": "open"},
3434 "tags": [], "assignees": [],
3435 "url": "https://app.clickup.com/t/2"
3436 },
3437 {
3438 "id": "3", "name": "Bravo",
3439 "status": {"status": "open", "type": "open"},
3440 "tags": [], "assignees": [],
3441 "url": "https://app.clickup.com/t/3"
3442 }
3443 ]
3444 }));
3445 });
3446
3447 let client = create_test_client(&server);
3448
3449 let result = client
3450 .get_issues(IssueFilter {
3451 sort_by: Some("title".to_string()),
3452 sort_order: Some("asc".to_string()),
3453 ..Default::default()
3454 })
3455 .await
3456 .unwrap();
3457 assert_eq!(result.items[0].title, "Alpha");
3458 assert_eq!(result.items[1].title, "Bravo");
3459 assert_eq!(result.items[2].title, "Charlie");
3460 }
3461
3462 #[tokio::test]
3463 async fn test_get_statuses_category_mapping() {
3464 let server = MockServer::start_async().await;
3465
3466 server.mock(|when, then| {
3467 when.method(GET).path("/list/12345");
3468 then.status(200).json_body(serde_json::json!({
3469 "statuses": [
3470 {"status": "Backlog", "type": "custom", "color": "#aaa", "orderindex": 0},
3471 {"status": "To Do", "type": "open", "color": "#bbb", "orderindex": 1},
3472 {"status": "In Progress", "type": "custom", "color": "#ccc", "orderindex": 2},
3473 {"status": "In Review", "type": "custom", "color": "#ddd", "orderindex": 3},
3474 {"status": "Done", "type": "closed", "color": "#eee", "orderindex": 4},
3475 {"status": "Cancelled", "type": "custom", "color": "#fff", "orderindex": 5},
3476 {"status": "Archived", "type": "custom", "color": "#000", "orderindex": 6}
3477 ]
3478 }));
3479 });
3480
3481 let client = create_test_client(&server);
3482 let statuses = client.get_statuses().await.unwrap().items;
3483
3484 assert_eq!(statuses.len(), 7);
3485 assert_eq!(statuses[0].name, "Backlog");
3486 assert_eq!(statuses[0].category, "backlog");
3487 assert_eq!(statuses[1].name, "To Do");
3488 assert_eq!(statuses[1].category, "todo");
3489 assert_eq!(statuses[2].name, "In Progress");
3490 assert_eq!(statuses[2].category, "in_progress");
3491 assert_eq!(statuses[3].name, "In Review");
3492 assert_eq!(statuses[3].category, "in_progress");
3493 assert_eq!(statuses[4].name, "Done");
3494 assert_eq!(statuses[4].category, "done");
3495 assert_eq!(statuses[5].name, "Cancelled");
3496 assert_eq!(statuses[5].category, "cancelled");
3497 assert_eq!(statuses[6].name, "Archived");
3498 assert_eq!(statuses[6].category, "cancelled");
3499 }
3500
3501 #[tokio::test]
3502 async fn test_get_issues_state_category_filter() {
3503 let server = MockServer::start_async().await;
3504
3505 server.mock(|when, then| {
3507 when.method(GET).path("/list/12345").query_param_exists("!");
3508 then.status(200).json_body(serde_json::json!({
3509 "statuses": [
3510 {"status": "Backlog", "type": "custom"},
3511 {"status": "To Do", "type": "open"},
3512 {"status": "In Progress", "type": "custom"},
3513 {"status": "Done", "type": "closed"}
3514 ]
3515 }));
3516 });
3517
3518 server.mock(|when, then| {
3520 when.method(GET).path("/list/12345");
3521 then.status(200).json_body(serde_json::json!({
3522 "statuses": [
3523 {"status": "Backlog", "type": "custom"},
3524 {"status": "To Do", "type": "open"},
3525 {"status": "In Progress", "type": "custom"},
3526 {"status": "Done", "type": "closed"}
3527 ]
3528 }));
3529 });
3530
3531 server.mock(|when, then| {
3532 when.method(GET).path("/list/12345/task");
3533 then.status(200).json_body(serde_json::json!({
3534 "tasks": [
3535 {
3536 "id": "1", "name": "Backlog task",
3537 "status": {"status": "Backlog", "type": "custom"},
3538 "tags": [], "assignees": [],
3539 "url": "https://app.clickup.com/t/1"
3540 },
3541 {
3542 "id": "2", "name": "In progress task",
3543 "status": {"status": "In Progress", "type": "custom"},
3544 "tags": [], "assignees": [],
3545 "url": "https://app.clickup.com/t/2"
3546 },
3547 {
3548 "id": "3", "name": "Todo task",
3549 "status": {"status": "To Do", "type": "open"},
3550 "tags": [], "assignees": [],
3551 "url": "https://app.clickup.com/t/3"
3552 }
3553 ]
3554 }));
3555 });
3556
3557 let client = create_test_client(&server);
3558
3559 let issues = client
3561 .get_issues(IssueFilter {
3562 state_category: Some("in_progress".to_string()),
3563 ..Default::default()
3564 })
3565 .await
3566 .unwrap()
3567 .items;
3568 assert_eq!(issues.len(), 1);
3569 assert_eq!(issues[0].title, "In progress task");
3570
3571 let issues = client
3573 .get_issues(IssueFilter {
3574 state_category: Some("backlog".to_string()),
3575 ..Default::default()
3576 })
3577 .await
3578 .unwrap()
3579 .items;
3580 assert_eq!(issues.len(), 1);
3581 assert_eq!(issues[0].title, "Backlog task");
3582 }
3583
3584 #[tokio::test]
3585 async fn test_get_issue_attachments_maps_all_fields() {
3586 let server = MockServer::start();
3587
3588 let task_json = serde_json::json!({
3589 "id": "abc123",
3590 "name": "Test",
3591 "status": {"status": "open", "type": "open"},
3592 "tags": [], "assignees": [],
3593 "url": "https://app.clickup.com/t/abc123",
3594 "date_created": "1704067200000",
3595 "date_updated": "1704067200000",
3596 "attachments": [
3597 {
3598 "id": "att-1",
3599 "title": "screen.png",
3600 "url": "https://attachments.clickup.com/abc/screen.png",
3601 "size": "12345",
3602 "extension": "png",
3603 "mimetype": "image/png",
3604 "date": "1704067200000",
3605 "user": {"id": 7, "username": "uploader"}
3606 }
3607 ]
3608 });
3609
3610 server.mock(|when, then| {
3611 when.method(GET).path("/task/abc123");
3612 then.status(200).json_body(task_json);
3613 });
3614
3615 let client = create_test_client(&server);
3616 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3617 assert_eq!(assets.len(), 1);
3618 let a = &assets[0];
3619 assert_eq!(a.id, "att-1");
3620 assert_eq!(a.filename, "screen.png");
3621 assert_eq!(a.mime_type.as_deref(), Some("image/png"));
3622 assert_eq!(a.size, Some(12345));
3623 assert_eq!(a.author.as_deref(), Some("uploader"));
3624 }
3625
3626 #[tokio::test]
3627 async fn test_get_issue_attachments_empty_when_none() {
3628 let server = MockServer::start();
3629
3630 let task_json = serde_json::json!({
3631 "id": "abc123",
3632 "name": "Test",
3633 "status": {"status": "open", "type": "open"},
3634 "tags": [], "assignees": [],
3635 "url": "https://app.clickup.com/t/abc123",
3636 "date_created": "1704067200000",
3637 "date_updated": "1704067200000"
3638 });
3639
3640 server.mock(|when, then| {
3641 when.method(GET).path("/task/abc123");
3642 then.status(200).json_body(task_json);
3643 });
3644
3645 let client = create_test_client(&server);
3646 let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3647 assert!(assets.is_empty());
3648 }
3649
3650 #[tokio::test]
3651 async fn test_download_attachment_fetches_bytes() {
3652 let server = MockServer::start();
3653
3654 let task_json = serde_json::json!({
3655 "id": "abc123",
3656 "name": "Test",
3657 "status": {"status": "open", "type": "open"},
3658 "tags": [], "assignees": [],
3659 "url": "https://app.clickup.com/t/abc123",
3660 "date_created": "1704067200000",
3661 "date_updated": "1704067200000",
3662 "attachments": [
3663 {
3664 "id": "att-1",
3665 "title": "log.txt",
3666 "url": format!("{}/download/att-1", server.base_url()),
3667 }
3668 ]
3669 });
3670
3671 server.mock(|when, then| {
3672 when.method(GET).path("/task/abc123");
3673 then.status(200).json_body(task_json);
3674 });
3675 server.mock(|when, then| {
3676 when.method(GET).path("/download/att-1");
3677 then.status(200).body("hello world");
3678 });
3679
3680 let client = create_test_client(&server);
3681 let bytes = client
3682 .download_attachment("CU-abc123", "att-1")
3683 .await
3684 .unwrap();
3685 assert_eq!(bytes, b"hello world");
3686 }
3687
3688 #[tokio::test]
3689 async fn test_download_attachment_not_found() {
3690 let server = MockServer::start();
3691
3692 server.mock(|when, then| {
3693 when.method(GET).path("/task/abc123");
3694 then.status(200).json_body(serde_json::json!({
3695 "id": "abc123", "name": "Test",
3696 "status": {"status": "open", "type": "open"},
3697 "tags": [], "assignees": [],
3698 "url": "https://app.clickup.com/t/abc123",
3699 "date_created": "1704067200000",
3700 "date_updated": "1704067200000",
3701 "attachments": []
3702 }));
3703 });
3704
3705 let client = create_test_client(&server);
3706 let err = client
3707 .download_attachment("CU-abc123", "missing")
3708 .await
3709 .unwrap_err();
3710 assert!(matches!(err, Error::NotFound(_)));
3711 }
3712 }
3713}