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)]
208 assignee: String,
209 #[arg(long)]
211 time: u64,
212 id: Option<String>,
214 },
215}
216
217const TASK_FIELDS: &[&str] = &["id", "name", "status", "priority", "assignees", "due_date"];
218
219pub async fn execute(command: TaskCommands, cli: &Cli) -> Result<(), CliError> {
220 let token = resolve_token(cli)?;
221 let client = ClickUpClient::new(&token, cli.timeout)?;
222 let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
223
224 match command {
225 TaskCommands::List {
226 list,
227 status,
228 assignee,
229 tag,
230 include_closed,
231 order_by,
232 reverse,
233 } => {
234 let mut params = Vec::new();
235 if include_closed {
236 params.push("include_closed=true".to_string());
237 }
238 if let Some(statuses) = &status {
239 for s in statuses {
240 params.push(format!("statuses[]={}", s));
241 }
242 }
243 if let Some(assignees) = &assignee {
244 for a in assignees {
245 params.push(format!("assignees[]={}", a));
246 }
247 }
248 if let Some(tags) = &tag {
249 for t in tags {
250 params.push(format!("tags[]={}", t));
251 }
252 }
253 if let Some(ob) = &order_by {
254 params.push(format!("order_by={}", ob));
255 }
256 if reverse {
257 params.push("reverse=true".to_string());
258 }
259 if let Some(page) = cli.page {
260 params.push(format!("page={}", page));
261 }
262
263 let query = if params.is_empty() {
264 String::new()
265 } else {
266 format!("?{}", params.join("&"))
267 };
268
269 if cli.all {
270 let mut all_tasks = Vec::new();
272 let mut page = 0u32;
273 loop {
274 let mut page_params = params.clone();
275 page_params.push(format!("page={}", page));
276 let page_query = format!("?{}", page_params.join("&"));
277 let resp = client
278 .get(&format!("/v2/list/{}/task{}", list, page_query))
279 .await?;
280 let tasks = resp
281 .get("tasks")
282 .and_then(|t| t.as_array())
283 .cloned()
284 .unwrap_or_default();
285 let is_last = resp
286 .get("last_page")
287 .and_then(|v| v.as_bool())
288 .unwrap_or(true);
289 all_tasks.extend(tasks);
290 if is_last {
291 break;
292 }
293 if let Some(limit) = cli.limit {
294 if all_tasks.len() >= limit {
295 all_tasks.truncate(limit);
296 break;
297 }
298 }
299 page += 1;
300 }
301 output.print_items(&all_tasks, TASK_FIELDS, "id");
302 } else {
303 let resp = client
304 .get(&format!("/v2/list/{}/task{}", list, query))
305 .await?;
306 let mut tasks = resp
307 .get("tasks")
308 .and_then(|t| t.as_array())
309 .cloned()
310 .unwrap_or_default();
311 if let Some(limit) = cli.limit {
312 tasks.truncate(limit);
313 }
314 output.print_items(&tasks, TASK_FIELDS, "id");
315 }
316 Ok(())
317 }
318 TaskCommands::Search {
319 space,
320 folder,
321 list,
322 status,
323 assignee,
324 tag,
325 } => {
326 let ws_id = resolve_workspace(cli)?;
327 let mut params = Vec::new();
328 if let Some(s) = &space {
329 params.push(format!("space_ids[]={}", s));
330 }
331 if let Some(f) = &folder {
332 params.push(format!("project_ids[]={}", f));
333 }
334 if let Some(l) = &list {
335 params.push(format!("list_ids[]={}", l));
336 }
337 if let Some(statuses) = &status {
338 for s in statuses {
339 params.push(format!("statuses[]={}", s));
340 }
341 }
342 if let Some(assignees) = &assignee {
343 for a in assignees {
344 params.push(format!("assignees[]={}", a));
345 }
346 }
347 if let Some(tags) = &tag {
348 for t in tags {
349 params.push(format!("tags[]={}", t));
350 }
351 }
352 if let Some(page) = cli.page {
353 params.push(format!("page={}", page));
354 }
355 let query = if params.is_empty() {
356 String::new()
357 } else {
358 format!("?{}", params.join("&"))
359 };
360 let resp = client
361 .get(&format!("/v2/team/{}/task{}", ws_id, query))
362 .await?;
363 let mut tasks = resp
364 .get("tasks")
365 .and_then(|t| t.as_array())
366 .cloned()
367 .unwrap_or_default();
368 if let Some(limit) = cli.limit {
369 tasks.truncate(limit);
370 }
371 output.print_items(&tasks, TASK_FIELDS, "id");
372 Ok(())
373 }
374 TaskCommands::Get {
375 id,
376 subtasks,
377 custom_task_id,
378 } => {
379 let task = git::require_task(cli, id.as_deref(), true)?;
380 let mut params = Vec::new();
381 if subtasks {
382 params.push("include_subtasks=true".to_string());
383 }
384 if custom_task_id || task.is_custom {
385 params.push("custom_task_ids=true".to_string());
386 let ws_id = resolve_workspace(cli)?;
387 params.push(format!("team_id={}", ws_id));
388 }
389 let query = if params.is_empty() {
390 String::new()
391 } else {
392 format!("?{}", params.join("&"))
393 };
394 let resp = client
395 .get(&format!("/v2/task/{}{}", task.id, query))
396 .await?;
397 output.print_single(&resp, TASK_FIELDS, "id");
398 Ok(())
399 }
400 TaskCommands::Create {
401 list,
402 name,
403 description,
404 status,
405 priority,
406 assignee,
407 tag,
408 due_date,
409 parent,
410 } => {
411 let mut body = serde_json::json!({ "name": name });
412 if let Some(d) = description {
413 body["description"] = serde_json::Value::String(d);
414 }
415 if let Some(s) = status {
416 body["status"] = serde_json::Value::String(s);
417 }
418 if let Some(p) = priority {
419 body["priority"] = serde_json::json!(p);
420 }
421 if let Some(assignees) = assignee {
422 let ids: Vec<serde_json::Value> = assignees
423 .iter()
424 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
425 .collect();
426 body["assignees"] = serde_json::Value::Array(ids);
427 }
428 if let Some(tags) = tag {
429 body["tags"] = serde_json::json!(tags);
430 }
431 if let Some(d) = due_date {
432 body["due_date"] = serde_json::Value::String(date_to_ms(&d)?);
433 }
434 if let Some(p) = parent {
435 body["parent"] = serde_json::Value::String(p);
436 }
437 let resp = client
438 .post(&format!("/v2/list/{}/task", list), &body)
439 .await?;
440 output.print_single(&resp, TASK_FIELDS, "id");
441 Ok(())
442 }
443 TaskCommands::Update {
444 id,
445 name,
446 status,
447 priority,
448 add_assignee,
449 rem_assignee,
450 description,
451 } => {
452 let task = git::require_task(cli, id.as_deref(), true)?;
453 let mut body = serde_json::Map::new();
454 if let Some(n) = name {
455 body.insert("name".into(), serde_json::Value::String(n));
456 }
457 if let Some(s) = status {
458 body.insert("status".into(), serde_json::Value::String(s));
459 }
460 if let Some(p) = priority {
461 body.insert("priority".into(), serde_json::json!(p));
462 }
463 if let Some(d) = description {
464 body.insert("description".into(), serde_json::Value::String(d));
465 }
466 if add_assignee.is_some() || rem_assignee.is_some() {
468 let mut assignees = serde_json::Map::new();
469 if let Some(add) = add_assignee {
470 let ids: Vec<serde_json::Value> = add
471 .iter()
472 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
473 .collect();
474 assignees.insert("add".into(), serde_json::Value::Array(ids));
475 }
476 if let Some(rem) = rem_assignee {
477 let ids: Vec<serde_json::Value> = rem
478 .iter()
479 .map(|a| serde_json::json!(a.parse::<i64>().unwrap_or(0)))
480 .collect();
481 assignees.insert("rem".into(), serde_json::Value::Array(ids));
482 }
483 body.insert("assignees".into(), serde_json::Value::Object(assignees));
484 }
485 let path = if task.is_custom {
486 let ws_id = resolve_workspace(cli)?;
487 format!(
488 "/v2/task/{}?custom_task_ids=true&team_id={}",
489 task.id, ws_id
490 )
491 } else {
492 format!("/v2/task/{}", task.id)
493 };
494 let resp = client.put(&path, &serde_json::Value::Object(body)).await?;
495 output.print_single(&resp, TASK_FIELDS, "id");
496 Ok(())
497 }
498 TaskCommands::Delete { id } => {
499 let task = git::require_task(cli, id.as_deref(), false)?;
500 let path = if task.is_custom {
501 let ws_id = resolve_workspace(cli)?;
502 format!(
503 "/v2/task/{}?custom_task_ids=true&team_id={}",
504 task.id, ws_id
505 )
506 } else {
507 format!("/v2/task/{}", task.id)
508 };
509 client.delete(&path).await?;
510 output.print_message(&format!("Task {} deleted", task.raw));
511 Ok(())
512 }
513 TaskCommands::AddTag {
514 task_or_tag,
515 tag_name,
516 } => {
517 let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
518 client
519 .post(
520 &format!("/v2/task/{}/tag/{}", task.id, tag_name),
521 &serde_json::json!({}),
522 )
523 .await?;
524 output.print_message(&format!("Tag '{}' added to task {}", tag_name, task.raw));
525 Ok(())
526 }
527 TaskCommands::RemoveTag {
528 task_or_tag,
529 tag_name,
530 } => {
531 let (task, tag_name) = resolve_task_tag(cli, task_or_tag, tag_name)?;
532 client
533 .delete(&format!("/v2/task/{}/tag/{}", task.id, tag_name))
534 .await?;
535 output.print_message(&format!(
536 "Tag '{}' removed from task {}",
537 tag_name, task.raw
538 ));
539 Ok(())
540 }
541 TaskCommands::AddDep {
542 id,
543 depends_on,
544 dependency_of,
545 } => {
546 let task = git::require_task(cli, id.as_deref(), true)?;
547 let body = if let Some(other) = depends_on {
548 serde_json::json!({ "depends_on": other })
549 } else if let Some(other) = dependency_of {
550 serde_json::json!({ "dependency_of": other })
551 } else {
552 return Err(CliError::ClientError {
553 message: "Specify --depends-on or --dependency-of".into(),
554 status: 0,
555 });
556 };
557 client
558 .post(&format!("/v2/task/{}/dependency", task.id), &body)
559 .await?;
560 output.print_message(&format!("Dependency added to task {}", task.raw));
561 Ok(())
562 }
563 TaskCommands::RemoveDep {
564 id,
565 depends_on,
566 dependency_of,
567 } => {
568 let task = git::require_task(cli, id.as_deref(), true)?;
569 let body = if let Some(other) = depends_on {
570 serde_json::json!({ "depends_on": other })
571 } else if let Some(other) = dependency_of {
572 serde_json::json!({ "dependency_of": other })
573 } else {
574 return Err(CliError::ClientError {
575 message: "Specify --depends-on or --dependency-of".into(),
576 status: 0,
577 });
578 };
579 client
580 .delete_with_body(&format!("/v2/task/{}/dependency", task.id), &body)
581 .await?;
582 output.print_message(&format!("Dependency removed from task {}", task.raw));
583 Ok(())
584 }
585 TaskCommands::Link { id, target_id } => {
586 client
587 .post(
588 &format!("/v2/task/{}/link/{}", id, target_id),
589 &serde_json::json!({}),
590 )
591 .await?;
592 output.print_message(&format!("Task {} linked to {}", id, target_id));
593 Ok(())
594 }
595 TaskCommands::Unlink { id, target_id } => {
596 client
597 .delete(&format!("/v2/task/{}/link/{}", id, target_id))
598 .await?;
599 output.print_message(&format!("Task {} unlinked from {}", id, target_id));
600 Ok(())
601 }
602 TaskCommands::Move { id, list } => {
603 let task = git::require_task(cli, id.as_deref(), true)?;
604 let ws_id = resolve_workspace(cli)?;
605 client
606 .put(
607 &format!(
608 "/v3/workspaces/{}/tasks/{}/home_list/{}",
609 ws_id, task.id, list
610 ),
611 &serde_json::json!({}),
612 )
613 .await?;
614 output.print_message(&format!("Task {} moved to list {}", task.raw, list));
615 Ok(())
616 }
617 TaskCommands::SetEstimate { id, assignee, time } => {
618 let task = git::require_task(cli, id.as_deref(), true)?;
619 let ws_id = resolve_workspace(cli)?;
620 let body = serde_json::json!({
621 "time_estimates": [{"user_id": assignee, "time_estimate": time}]
622 });
623 let resp = client
624 .patch(
625 &format!(
626 "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
627 ws_id, task.id
628 ),
629 &body,
630 )
631 .await?;
632 output.print_single(&resp, TASK_FIELDS, "id");
633 Ok(())
634 }
635 TaskCommands::ReplaceEstimates { id, assignee, time } => {
636 let task = git::require_task(cli, id.as_deref(), true)?;
637 let ws_id = resolve_workspace(cli)?;
638 let body = serde_json::json!({
639 "time_estimates": [{"user_id": assignee, "time_estimate": time}]
640 });
641 let resp = client
642 .put(
643 &format!(
644 "/v3/workspaces/{}/tasks/{}/time_estimates_by_user",
645 ws_id, task.id
646 ),
647 &body,
648 )
649 .await?;
650 output.print_single(&resp, TASK_FIELDS, "id");
651 Ok(())
652 }
653 TaskCommands::TimeInStatus { ids } => {
654 let ids = if ids.is_empty() {
655 let task = git::require_task(cli, None, true)?;
656 vec![task.id]
657 } else {
658 ids
659 };
660 if ids.len() == 1 {
661 let resp = client
662 .get(&format!("/v2/task/{}/time_in_status", ids[0]))
663 .await?;
664 if cli.output == "json" {
665 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
666 } else {
667 if let Some(statuses) = resp.get("current_status").and_then(|v| v.as_object()) {
669 println!(
670 "Current: {} ({}ms)",
671 statuses
672 .get("status")
673 .and_then(|v| v.as_str())
674 .unwrap_or("-"),
675 statuses
676 .get("total_time")
677 .and_then(|v| v.as_object())
678 .and_then(|o| o.get("by_minute"))
679 .and_then(|v| v.as_u64())
680 .unwrap_or(0)
681 );
682 }
683 if let Some(statuses_arr) =
685 resp.get("status_history").and_then(|v| v.as_array())
686 {
687 for s in statuses_arr {
688 let name = s.get("status").and_then(|v| v.as_str()).unwrap_or("-");
689 let time = s
690 .get("total_time")
691 .and_then(|v| v.as_object())
692 .and_then(|o| o.get("by_minute"))
693 .and_then(|v| v.as_u64())
694 .unwrap_or(0);
695 println!(" {} — {}ms", name, time);
696 }
697 }
698 }
699 } else {
700 let task_ids = ids.join(",");
702 let resp = client
703 .get(&format!(
704 "/v2/task/bulk_time_in_status/task_ids?task_ids={}",
705 task_ids
706 ))
707 .await?;
708 if cli.output == "json" {
709 println!("{}", serde_json::to_string_pretty(&resp).unwrap());
710 } else {
711 if let Some(obj) = resp.as_object() {
713 for (task_id, data) in obj {
714 let current = data
715 .get("current_status")
716 .and_then(|v| v.get("status"))
717 .and_then(|v| v.as_str())
718 .unwrap_or("-");
719 println!("{}: {}", task_id, current);
720 }
721 }
722 }
723 }
724 Ok(())
725 }
726 }
727}
728
729fn resolve_task_tag(
733 cli: &Cli,
734 task_or_tag: String,
735 tag_name: Option<String>,
736) -> Result<(git::ResolvedTask, String), CliError> {
737 match tag_name {
738 Some(tag) => Ok((git::parse_task_id(&task_or_tag), tag)),
739 None => {
740 let task = git::require_task(cli, None, true)?;
741 Ok((task, task_or_tag))
742 }
743 }
744}
745
746fn date_to_ms(date_str: &str) -> Result<String, CliError> {
747 let naive = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
748 CliError::ClientError {
749 message: format!("Invalid date '{}'. Use YYYY-MM-DD format.", date_str),
750 status: 0,
751 }
752 })?;
753 let dt = naive.and_hms_opt(0, 0, 0).unwrap().and_utc();
754 Ok((dt.timestamp_millis()).to_string())
755}