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