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