1use crate::client::ClickUpClient;
2use crate::commands::auth::resolve_token;
3use crate::commands::workspace::resolve_workspace;
4use crate::error::CliError;
5use crate::git;
6use crate::output::OutputConfig;
7use crate::Cli;
8use clap::Subcommand;
9
10#[derive(Subcommand)]
11pub enum TaskCommands {
12 List {
14 #[arg(long)]
16 list: String,
17 #[arg(long)]
19 status: Option<Vec<String>>,
20 #[arg(long)]
22 assignee: Option<Vec<String>>,
23 #[arg(long)]
25 tag: Option<Vec<String>>,
26 #[arg(long)]
28 include_closed: bool,
29 #[arg(long)]
31 order_by: Option<String>,
32 #[arg(long)]
34 reverse: bool,
35 },
36 Search {
38 #[arg(long)]
40 space: Option<String>,
41 #[arg(long)]
43 folder: Option<String>,
44 #[arg(long)]
46 list: Option<String>,
47 #[arg(long)]
49 status: Option<Vec<String>>,
50 #[arg(long)]
52 assignee: Option<Vec<String>>,
53 #[arg(long)]
55 tag: Option<Vec<String>>,
56 },
57 Get {
59 id: Option<String>,
61 #[arg(long)]
63 subtasks: bool,
64 #[arg(long)]
66 custom_task_id: bool,
67 },
68 Create {
70 #[arg(long)]
72 list: String,
73 #[arg(long)]
75 name: String,
76 #[arg(long)]
78 description: Option<String>,
79 #[arg(long)]
81 status: Option<String>,
82 #[arg(long)]
84 priority: Option<u8>,
85 #[arg(long)]
87 assignee: Option<Vec<String>>,
88 #[arg(long)]
90 tag: Option<Vec<String>>,
91 #[arg(long)]
93 due_date: Option<String>,
94 #[arg(long)]
96 parent: Option<String>,
97 },
98 Update {
100 id: Option<String>,
102 #[arg(long)]
104 name: Option<String>,
105 #[arg(long)]
107 status: Option<String>,
108 #[arg(long)]
110 priority: Option<u8>,
111 #[arg(long)]
113 add_assignee: Option<Vec<String>>,
114 #[arg(long)]
116 rem_assignee: Option<Vec<String>>,
117 #[arg(long)]
119 description: Option<String>,
120 #[arg(long)]
122 time_estimate: Option<u64>,
123 },
124 Delete {
126 id: Option<String>,
128 },
129 TimeInStatus {
131 ids: Vec<String>,
133 },
134 AddTag {
136 task_or_tag: String,
138 tag_name: Option<String>,
140 },
141 RemoveTag {
143 task_or_tag: String,
145 tag_name: Option<String>,
147 },
148 #[command(name = "add-dep")]
150 AddDep {
151 id: Option<String>,
153 #[arg(long, conflicts_with = "dependency_of")]
155 depends_on: Option<String>,
156 #[arg(long)]
158 dependency_of: Option<String>,
159 },
160 #[command(name = "remove-dep")]
162 RemoveDep {
163 id: Option<String>,
165 #[arg(long, conflicts_with = "dependency_of")]
167 depends_on: Option<String>,
168 #[arg(long)]
170 dependency_of: Option<String>,
171 },
172 Link {
174 id: String,
176 target_id: String,
178 },
179 Unlink {
181 id: String,
183 target_id: String,
185 },
186 Move {
188 #[arg(long)]
190 list: String,
191 id: Option<String>,
193 },
194 #[command(name = "set-estimate")]
196 SetEstimate {
197 #[arg(long)]
199 assignee: Option<String>,
200 #[arg(long)]
202 time: u64,
203 #[arg(long)]
205 id: Option<String>,
206 },
207 #[command(name = "replace-estimates")]
215 ReplaceEstimates {
216 #[arg(long = "estimate")]
222 estimates: Vec<String>,
223 #[arg(long)]
226 body: Option<String>,
227 #[arg(long)]
229 id: Option<String>,
230 },
231}
232
233const TASK_FIELDS: &[&str] = &["id", "name", "status", "priority", "assignees", "due_date"];
234
235pub async fn execute(command: TaskCommands, cli: &Cli) -> Result<(), CliError> {
236 let token = resolve_token(cli)?;
237 let client = ClickUpClient::new(&token, cli.timeout)?;
238 let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
239
240 match command {
241 TaskCommands::List {
242 list,
243 status,
244 assignee,
245 tag,
246 include_closed,
247 order_by,
248 reverse,
249 } => {
250 let mut params = Vec::new();
251 if include_closed {
252 params.push("include_closed=true".to_string());
253 }
254 if let Some(statuses) = &status {
255 for s in statuses {
256 params.push(format!("statuses[]={}", s));
257 }
258 }
259 if let Some(assignees) = &assignee {
260 for a in assignees {
261 params.push(format!("assignees[]={}", a));
262 }
263 }
264 if let Some(tags) = &tag {
265 for t in tags {
266 params.push(format!("tags[]={}", t));
267 }
268 }
269 if let Some(ob) = &order_by {
270 params.push(format!("order_by={}", ob));
271 }
272 if reverse {
273 params.push("reverse=true".to_string());
274 }
275 if let Some(page) = cli.page {
276 params.push(format!("page={}", page));
277 }
278
279 let query = if params.is_empty() {
280 String::new()
281 } else {
282 format!("?{}", params.join("&"))
283 };
284
285 if cli.all {
286 let mut all_tasks = Vec::new();
288 let mut page = 0u32;
289 loop {
290 let mut page_params = params.clone();
291 page_params.push(format!("page={}", page));
292 let page_query = format!("?{}", page_params.join("&"));
293 let resp = client
294 .get(&format!("/v2/list/{}/task{}", list, page_query))
295 .await?;
296 let tasks = resp
297 .get("tasks")
298 .and_then(|t| t.as_array())
299 .cloned()
300 .unwrap_or_default();
301 let is_last = resp
302 .get("last_page")
303 .and_then(|v| v.as_bool())
304 .unwrap_or(true);
305 all_tasks.extend(tasks);
306 if is_last {
307 break;
308 }
309 if let Some(limit) = cli.limit {
310 if all_tasks.len() >= limit {
311 all_tasks.truncate(limit);
312 break;
313 }
314 }
315 page += 1;
316 }
317 output.print_items(&all_tasks, TASK_FIELDS, "id");
318 } else {
319 let resp = client
320 .get(&format!("/v2/list/{}/task{}", list, query))
321 .await?;
322 let mut tasks = resp
323 .get("tasks")
324 .and_then(|t| t.as_array())
325 .cloned()
326 .unwrap_or_default();
327 if let Some(limit) = cli.limit {
328 tasks.truncate(limit);
329 }
330 output.print_items(&tasks, TASK_FIELDS, "id");
331 }
332 Ok(())
333 }
334 TaskCommands::Search {
335 space,
336 folder,
337 list,
338 status,
339 assignee,
340 tag,
341 } => {
342 let ws_id = resolve_workspace(cli)?;
343 let mut params = Vec::new();
344 if let Some(s) = &space {
345 params.push(format!("space_ids[]={}", s));
346 }
347 if let Some(f) = &folder {
348 params.push(format!("project_ids[]={}", f));
349 }
350 if let Some(l) = &list {
351 params.push(format!("list_ids[]={}", l));
352 }
353 if let Some(statuses) = &status {
354 for s in statuses {
355 params.push(format!("statuses[]={}", s));
356 }
357 }
358 if let Some(assignees) = &assignee {
359 for a in assignees {
360 params.push(format!("assignees[]={}", a));
361 }
362 }
363 if let Some(tags) = &tag {
364 for t in tags {
365 params.push(format!("tags[]={}", t));
366 }
367 }
368 if let Some(page) = cli.page {
369 params.push(format!("page={}", page));
370 }
371 let query = if params.is_empty() {
372 String::new()
373 } else {
374 format!("?{}", params.join("&"))
375 };
376 let resp = client
377 .get(&format!("/v2/team/{}/task{}", ws_id, query))
378 .await?;
379 let mut tasks = resp
380 .get("tasks")
381 .and_then(|t| t.as_array())
382 .cloned()
383 .unwrap_or_default();
384 if let Some(limit) = cli.limit {
385 tasks.truncate(limit);
386 }
387 output.print_items(&tasks, TASK_FIELDS, "id");
388 Ok(())
389 }
390 TaskCommands::Get {
391 id,
392 subtasks,
393 custom_task_id,
394 } => {
395 let task = git::require_task(cli, id.as_deref(), true)?;
396 let mut params = Vec::new();
397 if subtasks {
398 params.push("include_subtasks=true".to_string());
399 }
400 if custom_task_id || task.is_custom {
401 params.push("custom_task_ids=true".to_string());
402 let ws_id = resolve_workspace(cli)?;
403 params.push(format!("team_id={}", ws_id));
404 }
405 let query = if params.is_empty() {
406 String::new()
407 } else {
408 format!("?{}", params.join("&"))
409 };
410 let resp = client
411 .get(&format!("/v2/task/{}{}", task.id, query))
412 .await?;
413 output.print_single(&resp, TASK_FIELDS, "id");
414 Ok(())
415 }
416 TaskCommands::Create {
417 list,
418 name,
419 description,
420 status,
421 priority,
422 assignee,
423 tag,
424 due_date,
425 parent,
426 } => {
427 let mut body = serde_json::json!({ "name": name });
428 if let Some(d) = description {
429 body["markdown_content"] = serde_json::Value::String(d);
430 }
431 if let Some(s) = status {
432 body["status"] = serde_json::Value::String(s);
433 }
434 if let Some(p) = priority {
435 body["priority"] = serde_json::json!(p);
436 }
437 if let Some(assignees) = assignee {
438 let ids: Vec<serde_json::Value> = assignees
439 .iter()
440 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
441 .collect();
442 body["assignees"] = serde_json::Value::Array(ids);
443 }
444 if let Some(tags) = tag {
445 body["tags"] = serde_json::json!(tags);
446 }
447 if let Some(d) = due_date {
448 body["due_date"] = serde_json::Value::String(date_to_ms(&d)?);
449 }
450 if let Some(p) = parent {
451 body["parent"] = serde_json::Value::String(p);
452 }
453 let resp = client
454 .post(&format!("/v2/list/{}/task", list), &body)
455 .await?;
456 output.print_single(&resp, TASK_FIELDS, "id");
457 Ok(())
458 }
459 TaskCommands::Update {
460 id,
461 name,
462 status,
463 priority,
464 add_assignee,
465 rem_assignee,
466 description,
467 time_estimate,
468 } => {
469 let task = git::require_task(cli, id.as_deref(), true)?;
470 let mut body = serde_json::Map::new();
471 if let Some(n) = name {
472 body.insert("name".into(), serde_json::Value::String(n));
473 }
474 if let Some(s) = status {
475 body.insert("status".into(), serde_json::Value::String(s));
476 }
477 if let Some(p) = priority {
478 body.insert("priority".into(), serde_json::json!(p));
479 }
480 if let Some(d) = description {
481 body.insert("markdown_content".into(), serde_json::Value::String(d));
482 }
483 if let Some(te) = time_estimate {
484 body.insert("time_estimate".into(), serde_json::json!(te));
485 }
486 if add_assignee.is_some() || rem_assignee.is_some() {
488 let mut assignees = serde_json::Map::new();
489 if let Some(add) = add_assignee {
490 let ids: Vec<serde_json::Value> = add
491 .iter()
492 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
493 .collect();
494 assignees.insert("add".into(), serde_json::Value::Array(ids));
495 }
496 if let Some(rem) = rem_assignee {
497 let ids: Vec<serde_json::Value> = rem
498 .iter()
499 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
500 .collect();
501 assignees.insert("rem".into(), serde_json::Value::Array(ids));
502 }
503 body.insert("assignees".into(), serde_json::Value::Object(assignees));
504 }
505 let path = if task.is_custom {
506 let ws_id = resolve_workspace(cli)?;
507 format!(
508 "/v2/task/{}?custom_task_ids=true&team_id={}",
509 task.id, ws_id
510 )
511 } else {
512 format!("/v2/task/{}", task.id)
513 };
514 let resp = client.put(&path, &serde_json::Value::Object(body)).await?;
515 output.print_single(&resp, TASK_FIELDS, "id");
516 Ok(())
517 }
518 TaskCommands::Delete { id } => {
519 let task = git::require_task(cli, id.as_deref(), false)?;
520 let path = if task.is_custom {
521 let ws_id = resolve_workspace(cli)?;
522 format!(
523 "/v2/task/{}?custom_task_ids=true&team_id={}",
524 task.id, ws_id
525 )
526 } else {
527 format!("/v2/task/{}", task.id)
528 };
529 client.delete(&path).await?;
530 output.print_message(&format!("Task {} deleted", task.raw));
531 Ok(())
532 }
533 TaskCommands::AddTag {
534 task_or_tag,
535 tag_name,
536 } => {
537 let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
538 client
539 .post(
540 &format!("/v2/task/{}/tag/{}", task.id, tag_name),
541 &serde_json::json!({}),
542 )
543 .await?;
544 output.print_message(&format!("Tag '{}' added to task {}", tag_name, task.raw));
545 Ok(())
546 }
547 TaskCommands::RemoveTag {
548 task_or_tag,
549 tag_name,
550 } => {
551 let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
552 client
553 .delete(&format!("/v2/task/{}/tag/{}", task.id, tag_name))
554 .await?;
555 output.print_message(&format!(
556 "Tag '{}' removed from task {}",
557 tag_name, task.raw
558 ));
559 Ok(())
560 }
561 TaskCommands::AddDep {
562 id,
563 depends_on,
564 dependency_of,
565 } => {
566 let task = git::require_task(cli, id.as_deref(), true)?;
567 let body = if let Some(other) = depends_on {
568 serde_json::json!({ "depends_on": other })
569 } else if let Some(other) = dependency_of {
570 serde_json::json!({ "dependency_of": other })
571 } else {
572 return Err(CliError::ClientError {
573 message: "Specify --depends-on or --dependency-of".into(),
574 status: 0,
575 });
576 };
577 client
578 .post(&format!("/v2/task/{}/dependency", task.id), &body)
579 .await?;
580 output.print_message(&format!("Dependency added to task {}", task.raw));
581 Ok(())
582 }
583 TaskCommands::RemoveDep {
584 id,
585 depends_on,
586 dependency_of,
587 } => {
588 let task = git::require_task(cli, id.as_deref(), true)?;
589 let body = if let Some(other) = depends_on {
590 serde_json::json!({ "depends_on": other })
591 } else if let Some(other) = dependency_of {
592 serde_json::json!({ "dependency_of": other })
593 } else {
594 return Err(CliError::ClientError {
595 message: "Specify --depends-on or --dependency-of".into(),
596 status: 0,
597 });
598 };
599 client
600 .delete_with_body(&format!("/v2/task/{}/dependency", task.id), &body)
601 .await?;
602 output.print_message(&format!("Dependency removed from task {}", task.raw));
603 Ok(())
604 }
605 TaskCommands::Link { id, target_id } => {
606 client
607 .post(
608 &format!("/v2/task/{}/link/{}", id, target_id),
609 &serde_json::json!({}),
610 )
611 .await?;
612 output.print_message(&format!("Task {} linked to {}", id, target_id));
613 Ok(())
614 }
615 TaskCommands::Unlink { id, target_id } => {
616 client
617 .delete(&format!("/v2/task/{}/link/{}", id, target_id))
618 .await?;
619 output.print_message(&format!("Task {} unlinked from {}", id, target_id));
620 Ok(())
621 }
622 TaskCommands::Move { id, list } => {
623 let task = git::require_task(cli, id.as_deref(), true)?;
624 let ws_id = resolve_workspace(cli)?;
625 client
626 .put(
627 &format!(
628 "/v3/workspaces/{}/tasks/{}/home_list/{}",
629 ws_id, task.id, list
630 ),
631 &serde_json::json!({}),
632 )
633 .await?;
634 output.print_message(&format!("Task {} moved to list {}", task.raw, list));
635 Ok(())
636 }
637 TaskCommands::SetEstimate { id, assignee, time } => {
638 let task = git::require_task(cli, id.as_deref(), true)?;
639 let resp = if let Some(assignee) = assignee {
640 let ws_id = resolve_workspace(cli)?;
641 let body = serde_json::json!({
642 "time_estimates": [{"user_id": assignee, "time_estimate": time}]
643 });
644 client
645 .patch(
646 &format!(
647 "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
648 ws_id, task.id
649 ),
650 &body,
651 )
652 .await?
653 } else {
654 let body = serde_json::json!({
655 "time_estimate": time
656 });
657 let path = if task.is_custom {
658 let ws_id = resolve_workspace(cli)?;
659 format!(
660 "/v2/task/{}?custom_task_ids=true&team_id={}",
661 task.id, ws_id
662 )
663 } else {
664 format!("/v2/task/{}", task.id)
665 };
666 client.put(&path, &body).await?
667 };
668 output.print_single(&resp, TASK_FIELDS, "id");
669 Ok(())
670 }
671 TaskCommands::ReplaceEstimates {
672 id,
673 estimates,
674 body: raw_body,
675 ..
676 } => {
677 let task = git::require_task(cli, id.as_deref(), true)?;
678 let ws_id = resolve_workspace(cli)?;
679
680 let body: serde_json::Value = if let Some(raw) = raw_body {
690 serde_json::from_str(&raw).map_err(|e| CliError::ClientError {
691 message: format!("Invalid JSON body: {}", e),
692 status: 0,
693 })?
694 } else {
695 if estimates.is_empty() {
696 return Err(CliError::ClientError {
697 message: "Provide at least one --estimate ASSIGNEE:MS or use --body for the raw JSON array.".into(),
698 status: 0,
699 });
700 }
701 let entries: Result<Vec<serde_json::Value>, CliError> = estimates
702 .into_iter()
703 .map(|raw| {
704 let (assignee_raw, ms_raw) = raw.split_once(':').ok_or_else(|| {
705 CliError::ClientError {
706 message: format!(
707 "--estimate must be ASSIGNEE:MS (got '{}')",
708 raw
709 ),
710 status: 0,
711 }
712 })?;
713 let assignee_raw = assignee_raw.trim();
714 let ms = ms_raw.trim().parse::<i64>().map_err(|_| {
715 CliError::ClientError {
716 message: format!(
717 "--estimate MS must be a non-negative integer (got '{}')",
718 ms_raw
719 ),
720 status: 0,
721 }
722 })?;
723 let assignee_val = if assignee_raw.eq_ignore_ascii_case("unassigned") {
724 serde_json::Value::String("unassigned".to_string())
725 } else {
726 let n = assignee_raw.parse::<i64>().map_err(|_| {
727 CliError::ClientError {
728 message: format!(
729 "--estimate ASSIGNEE must be a numeric user id or 'unassigned' (got '{}')",
730 assignee_raw
731 ),
732 status: 0,
733 }
734 })?;
735 serde_json::json!(n)
736 };
737 Ok(serde_json::json!({"assignee": assignee_val, "time": ms}))
738 })
739 .collect();
740 serde_json::Value::Array(entries?)
741 };
742
743 let resp = client
744 .put(
745 &format!(
746 "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
747 ws_id, task.id
748 ),
749 &body,
750 )
751 .await?;
752 output.print_single(&resp, TASK_FIELDS, "id");
753 Ok(())
754 }
755 TaskCommands::TimeInStatus { ids } => {
756 let ids = if ids.is_empty() {
757 let task = git::require_task(cli, None, true)?;
758 vec![task.id]
759 } else {
760 ids
761 };
762 if ids.len() == 1 {
763 let resp = client
764 .get(&format!("/v2/task/{}/time_in_status", ids[0]))
765 .await?;
766 if cli.output == "json" {
767 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
768 } else {
769 if let Some(statuses) = resp.get("current_status").and_then(|v| v.as_object()) {
771 println!(
772 "Current: {} ({}ms)",
773 statuses
774 .get("status")
775 .and_then(|v| v.as_str())
776 .unwrap_or("-"),
777 statuses
778 .get("total_time")
779 .and_then(|v| v.as_object())
780 .and_then(|o| o.get("by_minute"))
781 .and_then(|v| v.as_u64())
782 .unwrap_or(0)
783 );
784 }
785 if let Some(statuses_arr) =
787 resp.get("status_history").and_then(|v| v.as_array())
788 {
789 for s in statuses_arr {
790 let name = s.get("status").and_then(|v| v.as_str()).unwrap_or("-");
791 let time = s
792 .get("total_time")
793 .and_then(|v| v.as_object())
794 .and_then(|o| o.get("by_minute"))
795 .and_then(|v| v.as_u64())
796 .unwrap_or(0);
797 println!(" {} — {}ms", name, time);
798 }
799 }
800 }
801 } else {
802 let query = ids
806 .iter()
807 .map(|id| format!("task_ids={}", id))
808 .collect::<Vec<_>>()
809 .join("&");
810 let resp = client
811 .get(&format!("/v2/task/bulk_time_in_status/task_ids?{}", query))
812 .await?;
813 if cli.output == "json" {
814 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
815 } else {
816 if let Some(obj) = resp.as_object() {
818 for (task_id, data) in obj {
819 let current = data
820 .get("current_status")
821 .and_then(|v| v.get("status"))
822 .and_then(|v| v.as_str())
823 .unwrap_or("-");
824 println!("{}: {}", task_id, current);
825 }
826 }
827 }
828 }
829 Ok(())
830 }
831 }
832}
833
834fn resolve_task_tag(
838 cli: &Cli,
839 task_or_tag: String,
840 tag_name: Option<String>,
841) -> Result<(git::ResolvedTask, String), CliError> {
842 match tag_name {
843 Some(tag) => Ok((git::parse_task_id(&task_or_tag), tag)),
844 None => {
845 let task = git::require_task(cli, None, true)?;
846 Ok((task, task_or_tag))
847 }
848 }
849}
850
851fn date_to_ms(date_str: &str) -> Result<String, CliError> {
852 let naive = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
853 CliError::ClientError {
854 message: format!("Invalid date '{}'. Use YYYY-MM-DD format.", date_str),
855 status: 0,
856 }
857 })?;
858 let dt = naive.and_hms_opt(0, 0, 0).unwrap().and_utc();
859 Ok((dt.timestamp_millis()).to_string())
860}