Skip to main content

clickup_cli/commands/
task.rs

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 tasks in a list
13    List {
14        /// List ID
15        #[arg(long)]
16        list: String,
17        /// Filter by status
18        #[arg(long)]
19        status: Option<Vec<String>>,
20        /// Filter by assignee
21        #[arg(long)]
22        assignee: Option<Vec<String>>,
23        /// Filter by tag
24        #[arg(long)]
25        tag: Option<Vec<String>>,
26        /// Include closed tasks
27        #[arg(long)]
28        include_closed: bool,
29        /// Order by field
30        #[arg(long)]
31        order_by: Option<String>,
32        /// Reverse sort order
33        #[arg(long)]
34        reverse: bool,
35    },
36    /// Search tasks across workspace
37    Search {
38        /// Filter by space
39        #[arg(long)]
40        space: Option<String>,
41        /// Filter by folder
42        #[arg(long)]
43        folder: Option<String>,
44        /// Filter by list
45        #[arg(long)]
46        list: Option<String>,
47        /// Filter by status
48        #[arg(long)]
49        status: Option<Vec<String>>,
50        /// Filter by assignee
51        #[arg(long)]
52        assignee: Option<Vec<String>>,
53        /// Filter by tag
54        #[arg(long)]
55        tag: Option<Vec<String>>,
56    },
57    /// Get task details
58    Get {
59        /// Task ID (auto-detected from git branch if omitted)
60        id: Option<String>,
61        /// Include subtasks
62        #[arg(long)]
63        subtasks: bool,
64        /// Treat ID as custom task ID
65        #[arg(long)]
66        custom_task_id: bool,
67    },
68    /// Create a task
69    Create {
70        /// List ID
71        #[arg(long)]
72        list: String,
73        /// Task name
74        #[arg(long)]
75        name: String,
76        /// Description
77        #[arg(long)]
78        description: Option<String>,
79        /// Status
80        #[arg(long)]
81        status: Option<String>,
82        /// Priority (1=urgent, 2=high, 3=normal, 4=low)
83        #[arg(long)]
84        priority: Option<u8>,
85        /// Assignee user ID
86        #[arg(long)]
87        assignee: Option<Vec<String>>,
88        /// Tag name
89        #[arg(long)]
90        tag: Option<Vec<String>>,
91        /// Due date (YYYY-MM-DD)
92        #[arg(long)]
93        due_date: Option<String>,
94        /// Parent task ID (creates subtask)
95        #[arg(long)]
96        parent: Option<String>,
97    },
98    /// Update a task
99    Update {
100        /// Task ID (auto-detected from git branch if omitted)
101        id: Option<String>,
102        /// New name
103        #[arg(long)]
104        name: Option<String>,
105        /// New status
106        #[arg(long)]
107        status: Option<String>,
108        /// New priority (1-4)
109        #[arg(long)]
110        priority: Option<u8>,
111        /// Add assignee
112        #[arg(long)]
113        add_assignee: Option<Vec<String>>,
114        /// Remove assignee
115        #[arg(long)]
116        rem_assignee: Option<Vec<String>>,
117        /// New description
118        #[arg(long)]
119        description: Option<String>,
120    },
121    /// Delete a task (explicit ID required — never auto-detects from branch)
122    Delete {
123        /// Task ID
124        id: Option<String>,
125    },
126    /// Get time in status for task(s)
127    TimeInStatus {
128        /// Task ID(s) — multiple IDs triggers bulk mode
129        ids: Vec<String>,
130    },
131    /// Add a tag to a task. Usage: add-tag <task_id> <tag_name>  OR  add-tag <tag_name> (task auto-detected from branch)
132    AddTag {
133        /// Task ID (or tag name if only one arg is given)
134        task_or_tag: String,
135        /// Tag name (when task_or_tag is a task ID)
136        tag_name: Option<String>,
137    },
138    /// Remove a tag from a task. Usage: remove-tag <task_id> <tag_name>  OR  remove-tag <tag_name> (task auto-detected)
139    RemoveTag {
140        /// Task ID (or tag name if only one arg is given)
141        task_or_tag: String,
142        /// Tag name (when task_or_tag is a task ID)
143        tag_name: Option<String>,
144    },
145    /// Add a dependency to a task
146    #[command(name = "add-dep")]
147    AddDep {
148        /// Task ID (auto-detected from git branch if omitted)
149        id: Option<String>,
150        /// This task depends on another task (task is a blocker)
151        #[arg(long, conflicts_with = "dependency_of")]
152        depends_on: Option<String>,
153        /// This task is a dependency of another task (task is blocked by)
154        #[arg(long)]
155        dependency_of: Option<String>,
156    },
157    /// Remove a dependency from a task
158    #[command(name = "remove-dep")]
159    RemoveDep {
160        /// Task ID (auto-detected from git branch if omitted)
161        id: Option<String>,
162        /// Remove depends-on relationship with this task ID
163        #[arg(long, conflicts_with = "dependency_of")]
164        depends_on: Option<String>,
165        /// Remove dependency-of relationship with this task ID
166        #[arg(long)]
167        dependency_of: Option<String>,
168    },
169    /// Link two tasks together
170    Link {
171        /// Task ID
172        id: String,
173        /// Target task ID to link to
174        target_id: String,
175    },
176    /// Unlink two tasks
177    Unlink {
178        /// Task ID
179        id: String,
180        /// Target task ID to unlink from
181        target_id: String,
182    },
183    /// Move a task to a different list (v3)
184    Move {
185        /// Destination list ID
186        #[arg(long)]
187        list: String,
188        /// Task ID (auto-detected from git branch if omitted)
189        id: Option<String>,
190    },
191    /// Set per-user time estimate on a task (v3)
192    #[command(name = "set-estimate")]
193    SetEstimate {
194        /// Assignee user ID
195        #[arg(long)]
196        assignee: String,
197        /// Time estimate in milliseconds
198        #[arg(long)]
199        time: u64,
200        /// Task ID (auto-detected from git branch if omitted)
201        id: Option<String>,
202    },
203    /// Replace all per-user time estimates on a task (v3)
204    #[command(name = "replace-estimates")]
205    ReplaceEstimates {
206        /// Assignee user ID
207        #[arg(long)]
208        assignee: String,
209        /// Time estimate in milliseconds
210        #[arg(long)]
211        time: u64,
212        /// Task ID (auto-detected from git branch if omitted)
213        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                // Auto-paginate
271                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            // Assignee add/remove uses nested object
467            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                    // Print status durations
668                    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                    // Print all statuses
684                    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                // Bulk mode
701                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                    // Print per-task summary
712                    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
729/// Disambiguate `add-tag` / `remove-tag` positionals:
730/// - Two args: `<task_id> <tag_name>` — explicit ID, parsed through `parse_task_id`.
731/// - One arg: `<tag_name>` — task auto-detected from branch.
732fn 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}