Skip to main content

devboy_clickup/
client.rs

1//! ClickUp API client implementation.
2
3use 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    AssigneeDiff, ClickUpAttachment, ClickUpComment, ClickUpCommentList, ClickUpLinkedTask,
16    ClickUpListInfo, ClickUpPriority, ClickUpTask, ClickUpTaskList, ClickUpTeamsResponse,
17    ClickUpUser, CreateCommentRequest, CreateCommentResponse, CreateTaskRequest, UpdateTaskRequest,
18};
19
20/// Maximum number of tasks per page in ClickUp API.
21const PAGE_SIZE: u32 = 100;
22
23/// Percent-encode a tag name for use in ClickUp URL paths.
24fn 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    /// Create a new ClickUp client.
38    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    /// Create a new ClickUp client with a custom base URL (for testing).
43    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    /// Set team (workspace) ID — required for custom task ID resolution.
61    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    /// Build request with common headers.
67    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    /// Make an authenticated GET request.
75    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    /// Make an authenticated GET request with properly encoded query parameters.
88    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    /// Make an authenticated POST request.
106    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    /// Make an authenticated PUT request.
124    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    /// Make an authenticated DELETE request.
142    /// Returns `Ok(())` on success. Treats 404 as success (idempotent delete).
143    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            // 404 = already removed, treat as success
155            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    /// Make an authenticated DELETE request with query parameters.
171    /// Returns `Ok(())` on success. Treats 404 as success (idempotent delete).
172    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    /// Handle response and map errors.
200    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    /// Fetch the configured statuses for `self.list_id`. Shared by
224    /// `validate_status_name` (literal-name match, #288) and
225    /// `resolve_status` (generic open/closed → type-matched name);
226    /// extracted so both stay in sync if the list endpoint shape
227    /// evolves.
228    async fn fetch_list_statuses(&self) -> Result<ClickUpListInfo> {
229        let url = format!("{}/list/{}", self.base_url, self.list_id);
230        self.get(&url).await
231    }
232
233    /// Maximum number of status names embedded in the "Unknown status"
234    /// error message. Some workspaces have 50+ configured statuses and
235    /// dumping all of them creates wall-of-text errors that are worse
236    /// than just listing the common ones.
237    const STATUS_LIST_ERROR_LIMIT: usize = 10;
238
239    /// Validate a literal ClickUp custom status name (#288) against the
240    /// list's configured statuses. Returns the canonical name from the
241    /// list (preserving the case as configured in ClickUp) when the
242    /// input matches case-insensitively. Errors with a clear message
243    /// listing valid statuses when there's no match — silent fallthrough
244    /// caused the regression on the cloud side where the MCP layer
245    /// accepted "in progress" and ClickUp returned 200 without changing
246    /// anything.
247    ///
248    /// If the input looks like a generic state keyword (`open`,
249    /// `opened`, `closed`), the error message points the caller at the
250    /// `state` field instead. Those keywords are rarely actual ClickUp
251    /// status names; the most likely cause is the caller mistakenly
252    /// passing them through the wrong field.
253    async fn validate_status_name(&self, requested: &str) -> Result<String> {
254        let list_info = self.fetch_list_statuses().await?;
255        if let Some(found) = list_info
256            .statuses
257            .iter()
258            .find(|s| s.status.eq_ignore_ascii_case(requested))
259        {
260            return Ok(found.status.clone());
261        }
262        let total = list_info.statuses.len();
263        let valid_preview: Vec<&str> = list_info
264            .statuses
265            .iter()
266            .take(Self::STATUS_LIST_ERROR_LIMIT)
267            .map(|s| s.status.as_str())
268            .collect();
269        let valid_str = if total > Self::STATUS_LIST_ERROR_LIMIT {
270            format!(
271                "{}, …and {} more",
272                valid_preview.join(", "),
273                total - Self::STATUS_LIST_ERROR_LIMIT
274            )
275        } else {
276            valid_preview.join(", ")
277        };
278        let hint = match requested.to_ascii_lowercase().as_str() {
279            "open" | "opened" | "closed" => {
280                " (note: for generic open/closed transitions, pass via the `state` field instead)"
281            }
282            _ => "",
283        };
284        Err(Error::InvalidData(format!(
285            "Unknown ClickUp status '{requested}' for list {}. Valid: [{valid_str}]{hint}",
286            self.list_id
287        )))
288    }
289
290    /// Resolve a unified state name ("open"/"closed") to the actual ClickUp status name
291    /// by fetching the list's configured statuses.
292    /// If the state doesn't match a known type, it's passed as-is (exact status name).
293    async fn resolve_status(&self, state: &str) -> Result<String> {
294        let status_type = match state {
295            "closed" => "closed",
296            "open" | "opened" => "open",
297            _ => return Ok(state.to_string()),
298        };
299
300        let list_info = self.fetch_list_statuses().await?;
301
302        list_info
303            .statuses
304            .iter()
305            .find(|s| s.status_type.as_deref() == Some(status_type))
306            .map(|s| s.status.clone())
307            .ok_or_else(|| {
308                Error::InvalidData(format!(
309                    "No status with type '{}' found in list {}",
310                    status_type, self.list_id
311                ))
312            })
313    }
314
315    /// Fetch the auth user's workspaces with embedded members.
316    /// If a `team_id` is configured on the client, returns only that team's
317    /// members; otherwise flattens across all teams the token has access to.
318    async fn fetch_workspace_members(&self) -> Result<Vec<ClickUpUser>> {
319        let url = format!("{}/team", self.base_url);
320        let resp: ClickUpTeamsResponse = self.get(&url).await?;
321        let target = self.team_id.as_deref();
322        let members: Vec<ClickUpUser> = resp
323            .teams
324            .into_iter()
325            .filter(|t| target.is_none_or(|id| t.id == id))
326            .flat_map(|t| t.members.into_iter().map(|m| m.user))
327            .collect();
328        Ok(members)
329    }
330
331    /// Resolve a list of assignee identifiers (numeric ClickUp user IDs,
332    /// emails, or usernames) to numeric IDs.
333    ///
334    /// Resolution order per input:
335    ///   1. A string that parses as `u64` is treated as a ClickUp user ID
336    ///      and used as-is. **No verification** that the user exists in
337    ///      the workspace — ClickUp's PUT will simply ignore unknown IDs
338    ///      (the same silent-no-op we are fixing for the flat-array
339    ///      case is, ironically, still the failure mode for unknown
340    ///      numeric IDs on PUT). If you need verification, pass an
341    ///      email or username instead. We accept the trade-off because
342    ///      it lets numeric callers skip the workspace fetch entirely.
343    ///   2. Otherwise: a single `GET /team` call loads workspace members
344    ///      and the input is matched case-insensitively against `email`
345    ///      first, then `username`. Both fields are matched
346    ///      `eq_ignore_ascii_case` (ClickUp's UI treats both as
347    ///      case-insensitive; non-ASCII emails per RFC 6531 will fall
348    ///      back to exact-case comparison via the ASCII path, which is
349    ///      acceptable as ClickUp doesn't allow non-ASCII identifiers
350    ///      in practice).
351    ///   3. Unresolvable inputs fail the **whole** call rather than
352    ///      silently dropping — callers expect that what they pass to
353    ///      `assignees` is what gets persisted.
354    async fn resolve_assignee_ids(&self, inputs: &[String]) -> Result<Vec<u64>> {
355        let mut resolved: Vec<u64> = Vec::with_capacity(inputs.len());
356        let mut needs_lookup: Vec<&str> = Vec::new();
357        for raw in inputs {
358            let trimmed = raw.trim();
359            if trimmed.is_empty() {
360                continue;
361            }
362            if let Ok(id) = trimmed.parse::<u64>() {
363                resolved.push(id);
364            } else {
365                needs_lookup.push(trimmed);
366            }
367        }
368        if needs_lookup.is_empty() {
369            return Ok(resolved);
370        }
371        let members = self.fetch_workspace_members().await?;
372        for needle in needs_lookup {
373            let id = members
374                .iter()
375                .find(|u| {
376                    u.email
377                        .as_deref()
378                        .is_some_and(|e| e.eq_ignore_ascii_case(needle))
379                        || u.username.eq_ignore_ascii_case(needle)
380                })
381                .map(|u| u.id);
382            let id = id.ok_or_else(|| {
383                Error::InvalidData(format!(
384                    "Cannot resolve assignee '{needle}' to a ClickUp user id \
385                     (not found by email or username in any accessible workspace)"
386                ))
387            })?;
388            resolved.push(id);
389        }
390        Ok(resolved)
391    }
392
393    /// Resolve a task key to its raw ClickUp task ID.
394    /// For `CU-{id}` keys, strips the prefix.
395    /// For custom IDs (e.g., `DEV-42`), returns as-is (ClickUp dependency API accepts custom IDs).
396    fn resolve_task_id(&self, key: &str) -> Result<String> {
397        if let Some(raw_id) = key.strip_prefix("CU-") {
398            Ok(raw_id.to_string())
399        } else {
400            Ok(key.to_string())
401        }
402    }
403
404    /// Resolve a task key to its native ClickUp task ID by fetching the task if needed.
405    /// For `CU-{id}` keys, strips the prefix (fast path).
406    /// For custom IDs (e.g., `DEV-42`), fetches the task to get the native `.id`.
407    /// Use this when the native ID is required in URL path segments.
408    async fn resolve_to_native_id(&self, key: &str) -> Result<String> {
409        if let Some(raw_id) = key.strip_prefix("CU-") {
410            Ok(raw_id.to_string())
411        } else {
412            let url = self.task_url(key)?;
413            let task: ClickUpTask = self.get(&url).await?;
414            Ok(task.id)
415        }
416    }
417
418    /// Build the URL for accessing a task by key.
419    /// For `CU-{id}` keys, uses the raw task ID directly.
420    /// For custom IDs (e.g., `DEV-42`), appends `?custom_task_ids=true&team_id=` params.
421    fn task_url(&self, key: &str) -> Result<String> {
422        if let Some(raw_id) = key.strip_prefix("CU-") {
423            Ok(format!("{}/task/{}", self.base_url, raw_id))
424        } else {
425            // Custom task ID — requires team_id
426            let team_id = self.team_id.as_ref().ok_or_else(|| {
427                Error::Config(format!(
428                    "team_id is required to resolve custom task ID '{}'. \
429                     Run: devboy config set clickup.team_id <team_id>",
430                    key
431                ))
432            })?;
433            Ok(format!(
434                "{}/task/{}?custom_task_ids=true&team_id={}",
435                self.base_url, key, team_id
436            ))
437        }
438    }
439}
440
441// =============================================================================
442// Mapping functions: ClickUp types -> Unified types
443// =============================================================================
444
445fn map_user(cu_user: Option<&ClickUpUser>) -> Option<User> {
446    cu_user.map(|u| User {
447        id: u.id.to_string(),
448        username: u.username.clone(),
449        name: Some(u.username.clone()),
450        email: u.email.clone(),
451        avatar_url: u.profile_picture.clone(),
452    })
453}
454
455fn map_user_required(cu_user: Option<&ClickUpUser>) -> User {
456    map_user(cu_user).unwrap_or_else(|| User {
457        id: "unknown".to_string(),
458        username: "unknown".to_string(),
459        name: Some("Unknown".to_string()),
460        ..Default::default()
461    })
462}
463
464fn map_tags(tags: &[crate::types::ClickUpTag]) -> Vec<String> {
465    tags.iter().map(|t| t.name.clone()).collect()
466}
467
468fn map_priority(priority: Option<&ClickUpPriority>) -> Option<String> {
469    priority.map(|p| match p.id.as_str() {
470        "1" => "urgent".to_string(),
471        "2" => "high".to_string(),
472        "3" => "normal".to_string(),
473        "4" => "low".to_string(),
474        _ => p.priority.to_lowercase(),
475    })
476}
477
478fn map_state(task: &ClickUpTask) -> String {
479    match task.status.status_type.as_deref() {
480        Some("closed") => "closed".to_string(),
481        _ => "open".to_string(),
482    }
483}
484
485/// Map a ClickUp status to a semantic category using both the status type field
486/// and name-based heuristics (for custom statuses where the type is always "custom").
487///
488/// Categories: "backlog", "todo", "in_progress", "done", "cancelled"
489fn map_status_category(status_type: Option<&str>, status_name: &str) -> String {
490    // First, use the explicit type from ClickUp
491    match status_type {
492        Some("closed") | Some("done") => return "done".to_string(),
493        // "open" type in ClickUp is the initial/default status — map via name heuristics below
494        // "custom" type covers most user-defined statuses — also use name heuristics
495        _ => {}
496    }
497
498    // Name-based heuristic matching (case-insensitive)
499    let name_lower = status_name.to_lowercase();
500
501    if name_lower.contains("backlog") {
502        "backlog".to_string()
503    } else if name_lower.contains("cancel")
504        || name_lower.contains("archived")
505        || name_lower.contains("rejected")
506    {
507        "cancelled".to_string()
508    } else if name_lower.contains("done")
509        || name_lower.contains("complete")
510        || name_lower.contains("closed")
511        || name_lower.contains("resolved")
512    {
513        "done".to_string()
514    } else if name_lower.contains("progress")
515        || name_lower.contains("doing")
516        || name_lower.contains("active")
517        || name_lower.contains("review")
518    {
519        "in_progress".to_string()
520    } else if name_lower.contains("todo")
521        || name_lower.contains("to do")
522        || name_lower.contains("open")
523        || name_lower.contains("new")
524    {
525        "todo".to_string()
526    } else {
527        // Unknown custom status — default based on type
528        match status_type {
529            Some("open") => "todo".to_string(),
530            _ => "in_progress".to_string(),
531        }
532    }
533}
534
535/// Build the unified issue key for a task.
536/// Uses `custom_id` when available (e.g., `DEV-42`), otherwise `CU-{id}`.
537fn map_task_key(task: &ClickUpTask) -> String {
538    if let Some(custom_id) = &task.custom_id {
539        custom_id.clone()
540    } else {
541        format!("CU-{}", task.id)
542    }
543}
544
545/// Convert ClickUp epoch-millisecond timestamp to ISO 8601 string.
546fn epoch_ms_to_iso8601(epoch_ms: &str) -> Option<String> {
547    let ms: i64 = epoch_ms.parse().ok()?;
548    let secs = ms / 1000;
549    let datetime = time_from_unix(secs);
550    Some(datetime)
551}
552
553/// Convert unix timestamp to ISO 8601 string without external crate.
554fn time_from_unix(secs: i64) -> String {
555    // Days from unix epoch
556    let mut days = secs / 86400;
557    let day_secs = secs.rem_euclid(86400);
558    if secs % 86400 < 0 {
559        days -= 1;
560    }
561
562    let hours = day_secs / 3600;
563    let minutes = (day_secs % 3600) / 60;
564    let seconds = day_secs % 60;
565
566    // Convert days since epoch to year-month-day
567    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
568    let z = days + 719468;
569    let era = if z >= 0 { z } else { z - 146096 } / 146097;
570    let doe = (z - era * 146097) as u32;
571    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
572    let y = yoe as i64 + era * 400;
573    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
574    let mp = (5 * doy + 2) / 153;
575    let d = doy - (153 * mp + 2) / 5 + 1;
576    let m = if mp < 10 { mp + 3 } else { mp - 9 };
577    let y = if m <= 2 { y + 1 } else { y };
578
579    format!(
580        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
581        y, m, d, hours, minutes, seconds
582    )
583}
584
585fn map_timestamp(ts: &Option<String>) -> Option<String> {
586    ts.as_ref().and_then(|s| epoch_ms_to_iso8601(s))
587}
588
589/// Resolve a select-like custom field's opaque value to its human label
590/// using the `type_config.options` embedded in the task payload.
591/// `drop_down` → the chosen option's name (matched by order index, or by
592/// id when ClickUp returns the option id); `labels` → comma-joined option
593/// names for the selected ids. Returns `None` for non-select fields, empty
594/// option lists, or values that don't resolve (the raw `value` still rides
595/// along on `CustomFieldValue`).
596fn resolve_custom_field_display(cf: &crate::types::ClickUpCustomField) -> Option<String> {
597    let tc = cf.type_config.as_ref()?;
598    if tc.options.is_empty() {
599        return None;
600    }
601    let value = cf.value.as_ref()?;
602    match cf.field_type.as_deref() {
603        Some("drop_down") => {
604            let chosen = match value {
605                // ClickUp returns the selected option's order index (e.g. 0).
606                // Fail cleanly (→ None, raw value kept) rather than truncate
607                // an out-of-range index to a wrong option.
608                serde_json::Value::Number(n) => {
609                    let idx = u32::try_from(n.as_u64()?).ok()?;
610                    tc.options.iter().find(|o| o.orderindex == Some(idx))
611                }
612                // …or, in some configs, the option id directly.
613                serde_json::Value::String(s) => tc
614                    .options
615                    .iter()
616                    .find(|o| o.id.as_deref() == Some(s.as_str())),
617                _ => None,
618            }?;
619            chosen.name.clone()
620        }
621        Some("labels") => {
622            // `labels` value is an array of selected option ids.
623            let names: Vec<String> = value
624                .as_array()?
625                .iter()
626                .filter_map(|v| v.as_str())
627                .filter_map(|id| {
628                    tc.options
629                        .iter()
630                        .find(|o| o.id.as_deref() == Some(id))
631                        .and_then(|o| o.name.clone())
632                })
633                .collect();
634            (!names.is_empty()).then(|| names.join(", "))
635        }
636        _ => None,
637    }
638}
639
640fn map_task(task: &ClickUpTask) -> Issue {
641    // Surface set custom fields keyed by ClickUp's stable field id
642    // (matches `get_custom_fields` output across providers). Display
643    // name rides along inside `CustomFieldValue.name` so consumers
644    // don't lose it. Unset fields (`value: None`) are skipped to
645    // keep the map noise-free.
646    let custom_fields: std::collections::HashMap<String, devboy_core::CustomFieldValue> = task
647        .custom_fields
648        .iter()
649        .filter_map(|cf| {
650            cf.value.as_ref().map(|v| {
651                (
652                    cf.id.clone(),
653                    devboy_core::CustomFieldValue {
654                        name: cf.name.clone(),
655                        value: v.clone(),
656                        // Resolve drop_down/labels indices → human label
657                        // (e.g. 0 → "dev") so agents read the meaning, not
658                        // the opaque id. Raw `value` is kept for writes.
659                        display: resolve_custom_field_display(cf),
660                    },
661                )
662            })
663        })
664        .collect();
665    Issue {
666        custom_fields,
667        key: map_task_key(task),
668        title: task.name.clone(),
669        description: task
670            .text_content
671            .clone()
672            .or_else(|| task.description.clone()),
673        state: map_state(task),
674        // DEV-1578: surface the rich ClickUp display status (e.g. "in
675        // progress" / "ready to release" / "complete") + unified category,
676        // alongside the binary `state`.
677        status: Some(task.status.status.clone()),
678        status_category: Some(map_status_category(
679            task.status.status_type.as_deref(),
680            &task.status.status,
681        )),
682        source: "clickup".to_string(),
683        priority: map_priority(task.priority.as_ref()),
684        labels: map_tags(&task.tags),
685        author: map_user(task.creator.as_ref()),
686        assignees: task
687            .assignees
688            .iter()
689            .map(|u| map_user_required(Some(u)))
690            .collect(),
691        url: Some(task.url.clone()),
692        created_at: map_timestamp(&task.date_created),
693        updated_at: map_timestamp(&task.date_updated),
694        attachments_count: if task.attachments.is_empty() {
695            None
696        } else {
697            Some(task.attachments.len() as u32)
698        },
699        parent: task.parent.as_ref().map(|id| format!("CU-{id}")),
700        subtasks: task
701            .subtasks
702            .as_deref()
703            .unwrap_or_default()
704            .iter()
705            .map(map_task)
706            .collect(),
707    }
708}
709
710fn map_comment(cu_comment: &ClickUpComment) -> Comment {
711    Comment {
712        id: cu_comment.id.clone(),
713        body: cu_comment.comment_text.clone(),
714        author: map_user(cu_comment.user.as_ref()),
715        created_at: map_timestamp(&cu_comment.date),
716        updated_at: None,
717        position: None,
718    }
719}
720
721/// Map a ClickUp attachment payload to the provider-agnostic [`AssetMeta`].
722fn map_clickup_attachment(raw: &ClickUpAttachment) -> AssetMeta {
723    let filename = raw
724        .title
725        .clone()
726        .or_else(|| {
727            raw.url
728                .as_deref()
729                .map(devboy_core::asset::filename_from_url)
730        })
731        .unwrap_or_else(|| format!("attachment-{}", raw.id));
732
733    let size = match raw.size.as_ref() {
734        Some(serde_json::Value::Number(n)) => n.as_u64(),
735        Some(serde_json::Value::String(s)) => s.parse::<u64>().ok(),
736        _ => None,
737    };
738
739    let created_at = raw.date.as_deref().and_then(epoch_ms_to_iso8601);
740
741    let author = raw.user.as_ref().map(|u| u.username.clone());
742
743    AssetMeta {
744        id: raw.id.clone(),
745        filename,
746        mime_type: raw.mimetype.clone(),
747        size,
748        url: raw.url.clone(),
749        created_at,
750        author,
751        cached: false,
752        local_path: None,
753        checksum_sha256: None,
754        analysis: None,
755    }
756}
757
758/// Sorting key for priority (lower = more urgent).
759/// urgent=1, high=2, normal=3, low=4, None=5
760fn priority_sort_key(priority: Option<&str>) -> u8 {
761    match priority {
762        Some("urgent") => 1,
763        Some("high") => 2,
764        Some("normal") => 3,
765        Some("low") => 4,
766        _ => 5,
767    }
768}
769
770/// Map a unified priority string to a ClickUp priority number.
771fn priority_to_clickup(priority: &str) -> Option<u8> {
772    match priority {
773        "urgent" => Some(1),
774        "high" => Some(2),
775        "normal" => Some(3),
776        "low" => Some(4),
777        _ => None,
778    }
779}
780
781/// Map ClickUp dependencies (serde_json::Value) to (blocked_by, blocks) IssueLink vectors.
782///
783/// ClickUp dependency JSON shape (observed):
784/// ```json
785/// { "task_id": "abc", "depends_on": "xyz", "type": 1, ... }
786/// ```
787/// - If `depends_on` == this task's ID → `task_id` depends on this task, so this task **blocks** `task_id`
788/// - If `dependency_of` == this task's ID → this task depends on `task_id`, so this task is **blocked by** `task_id`
789fn map_dependencies(
790    deps: &[serde_json::Value],
791    this_task_id: &str,
792) -> (Vec<IssueLink>, Vec<IssueLink>) {
793    let mut blocked_by = Vec::new();
794    let mut blocks = Vec::new();
795
796    for dep in deps {
797        let task_id = dep
798            .get("task_id")
799            .and_then(|v| v.as_str())
800            .unwrap_or_default();
801        let depends_on = dep
802            .get("depends_on")
803            .and_then(|v| v.as_str())
804            .unwrap_or_default();
805        let dependency_of = dep
806            .get("dependency_of")
807            .and_then(|v| v.as_str())
808            .unwrap_or_default();
809
810        let other_id = if !task_id.is_empty() {
811            task_id
812        } else {
813            continue;
814        };
815
816        let other_issue = Issue {
817            key: format!("CU-{other_id}"),
818            source: "clickup".to_string(),
819            ..Default::default()
820        };
821
822        if depends_on == this_task_id {
823            // task_id depends on this task → this task blocks task_id
824            blocks.push(IssueLink {
825                issue: other_issue,
826                link_type: "blocks".to_string(),
827            });
828        } else if dependency_of == this_task_id {
829            // task_id is dependency of this task → this task is blocked by task_id
830            blocked_by.push(IssueLink {
831                issue: other_issue,
832                link_type: "blocked_by".to_string(),
833            });
834        } else {
835            // Fallback: try to infer from "type" field
836            // ClickUp type 1 = waiting on, type 0 = blocking
837            let dep_type = dep.get("type").and_then(|v| v.as_u64());
838            match dep_type {
839                Some(1) => {
840                    blocked_by.push(IssueLink {
841                        issue: other_issue,
842                        link_type: "blocked_by".to_string(),
843                    });
844                }
845                Some(0) => {
846                    blocks.push(IssueLink {
847                        issue: other_issue,
848                        link_type: "blocks".to_string(),
849                    });
850                }
851                _ => {
852                    // Unknown direction, add as blocked_by by default
853                    blocked_by.push(IssueLink {
854                        issue: other_issue,
855                        link_type: "blocked_by".to_string(),
856                    });
857                }
858            }
859        }
860    }
861
862    (blocked_by, blocks)
863}
864
865/// Map ClickUp linked tasks to IssueLinks, preserving dependency semantics when available.
866fn map_linked_tasks(links: &[ClickUpLinkedTask]) -> Vec<IssueLink> {
867    links
868        .iter()
869        .map(|link| {
870            let link_type = match link.link_type.as_deref() {
871                Some("blocked_by") => "blocked_by",
872                Some("blocking") => "blocks",
873                _ => "relates_to",
874            }
875            .to_string();
876
877            IssueLink {
878                issue: Issue {
879                    key: format!("CU-{}", link.task_id),
880                    source: "clickup".to_string(),
881                    ..Default::default()
882                },
883                link_type,
884            }
885        })
886        .collect()
887}
888
889// =============================================================================
890// Trait implementations
891// =============================================================================
892
893#[async_trait]
894impl IssueProvider for ClickUpClient {
895    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
896        let limit = filter.limit.unwrap_or(20) as usize;
897        if limit == 0 {
898            return Ok(vec![].into());
899        }
900        let offset = filter.offset.unwrap_or(0) as usize;
901
902        // Calculate which pages we need to fetch
903        let start_page = offset / PAGE_SIZE as usize;
904        let end_page = (offset + limit).saturating_sub(1) / PAGE_SIZE as usize;
905
906        // Build base query params (without page).
907        // Values are properly URL-encoded by reqwest's .query() method.
908        let mut base_params: Vec<(&str, String)> = vec![];
909
910        let include_closed = matches!(filter.state.as_deref(), Some("closed") | Some("all"))
911            || matches!(
912                filter.state_category.as_deref(),
913                Some("done") | Some("cancelled")
914            );
915        if include_closed {
916            base_params.push(("include_closed", "true".to_string()));
917        }
918
919        base_params.push(("subtasks", "true".to_string()));
920
921        if let Some(assignee) = &filter.assignee {
922            // ClickUp API expects numeric user IDs for assignee filtering,
923            // but IssueFilter.assignee is documented as a username.
924            // Pass through as-is — it will work if the caller provides a user ID.
925            warn!(
926                assignee = assignee.as_str(),
927                "ClickUp assignee filter expects numeric user IDs, not usernames"
928            );
929            base_params.push(("assignees[]", assignee.clone()));
930        }
931
932        if let Some(tags) = &filter.labels {
933            for tag in tags {
934                base_params.push(("tags[]", tag.clone()));
935            }
936        }
937
938        // Track whether client-side sorting is needed for unsupported fields
939        let mut client_side_sort: Option<String> = None;
940
941        if let Some(order_by) = &filter.sort_by {
942            match order_by.as_str() {
943                "created_at" | "created" => {
944                    base_params.push(("order_by", "created".to_string()));
945                }
946                "updated_at" | "updated" => {
947                    base_params.push(("order_by", "updated".to_string()));
948                }
949                other => {
950                    // Unsupported by ClickUp API — will sort client-side after fetch
951                    client_side_sort = Some(other.to_string());
952                    warn!(
953                        sort_by = other,
954                        "ClickUp API does not support sorting by '{}', applying client-side sort",
955                        other
956                    );
957                }
958            }
959        }
960
961        let sort_order_is_asc = filter.sort_order.as_deref().is_some_and(|o| o == "asc");
962
963        if sort_order_is_asc && client_side_sort.is_none() {
964            base_params.push(("reverse", "true".to_string()));
965        }
966
967        // Fetch all needed pages
968        let base_url = format!("{}/list/{}/task", self.base_url, self.list_id);
969        let mut all_tasks: Vec<ClickUpTask> = Vec::new();
970
971        for page in start_page..=end_page {
972            let mut params = base_params.clone();
973            params.push(("page", page.to_string()));
974
975            let param_refs: Vec<(&str, &str)> =
976                params.iter().map(|(k, v)| (*k, v.as_str())).collect();
977            let response: ClickUpTaskList = self.get_with_query(&base_url, &param_refs).await?;
978            let page_len = response.tasks.len();
979            all_tasks.extend(response.tasks);
980
981            // Stop if this page has fewer than PAGE_SIZE items (no more data)
982            if page_len < PAGE_SIZE as usize {
983                break;
984            }
985        }
986
987        // Filter by stateCategory if provided (semantic status filtering).
988        // This must happen on raw ClickUp tasks before mapping, since the actual
989        // status name (e.g., "Backlog", "In Progress") is lost during map_task().
990        if let Some(ref state_category) = filter.state_category {
991            let statuses = self.get_statuses().await?;
992            let matching_status_names: Vec<String> = statuses
993                .items
994                .iter()
995                .filter(|s| s.category == *state_category)
996                .map(|s| s.name.to_lowercase())
997                .collect();
998
999            all_tasks.retain(|t| matching_status_names.contains(&t.status.status.to_lowercase()));
1000        }
1001
1002        let mut issues: Vec<Issue> = all_tasks.iter().map(map_task).collect();
1003
1004        // Filter by state client-side if needed
1005        if let Some(state) = &filter.state {
1006            match state.as_str() {
1007                "opened" | "open" => {
1008                    issues.retain(|i| i.state == "open");
1009                }
1010                "closed" => {
1011                    issues.retain(|i| i.state == "closed");
1012                }
1013                _ => {} // "all" — no filter
1014            }
1015        }
1016
1017        // Labels AND operator: ClickUp API uses OR by default. For AND, post-filter.
1018        if filter.labels_operator.as_deref() == Some("and")
1019            && let Some(ref required_labels) = filter.labels
1020        {
1021            let required: Vec<String> = required_labels.iter().map(|l| l.to_lowercase()).collect();
1022            issues.retain(|issue| {
1023                let issue_labels: Vec<String> =
1024                    issue.labels.iter().map(|l| l.to_lowercase()).collect();
1025                required.iter().all(|r| issue_labels.contains(r))
1026            });
1027        }
1028
1029        // Client-side search filtering (ClickUp API has no search endpoint for tasks)
1030        if let Some(ref query) = filter.search {
1031            let q = query.to_lowercase();
1032            issues.retain(|issue| {
1033                issue.title.to_lowercase().contains(&q)
1034                    || issue
1035                        .description
1036                        .as_ref()
1037                        .is_some_and(|d| d.to_lowercase().contains(&q))
1038                    || issue.key.to_lowercase().contains(&q)
1039            });
1040        }
1041
1042        // Client-side sorting for fields unsupported by ClickUp API
1043        if let Some(ref sort_field) = client_side_sort {
1044            match sort_field.as_str() {
1045                "priority" => {
1046                    issues.sort_by(|a, b| {
1047                        let pa = priority_sort_key(a.priority.as_deref());
1048                        let pb = priority_sort_key(b.priority.as_deref());
1049                        if sort_order_is_asc {
1050                            pa.cmp(&pb)
1051                        } else {
1052                            pb.cmp(&pa)
1053                        }
1054                    });
1055                }
1056                "title" => {
1057                    issues.sort_by(|a, b| {
1058                        let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase());
1059                        if sort_order_is_asc {
1060                            cmp
1061                        } else {
1062                            cmp.reverse()
1063                        }
1064                    });
1065                }
1066                _ => {
1067                    // Unknown sort field — leave as-is (API default order)
1068                }
1069            }
1070        }
1071
1072        // Apply offset within first page and limit
1073        let offset_in_first_page = offset % PAGE_SIZE as usize;
1074        if offset_in_first_page < issues.len() {
1075            issues = issues.split_off(offset_in_first_page);
1076        } else {
1077            issues.clear();
1078        }
1079
1080        issues.truncate(limit);
1081
1082        // Build sort info metadata
1083        let sort_info = SortInfo {
1084            sort_by: filter.sort_by.clone(),
1085            sort_order: if sort_order_is_asc {
1086                SortOrder::Asc
1087            } else {
1088                SortOrder::Desc
1089            },
1090            available_sorts: vec![
1091                "created_at".into(),
1092                "updated_at".into(),
1093                "priority".into(),
1094                "title".into(),
1095            ],
1096        };
1097
1098        Ok(ProviderResult::new(issues).with_sort_info(sort_info))
1099    }
1100
1101    async fn get_issue(&self, key: &str) -> Result<Issue> {
1102        let base_url = self.task_url(key)?;
1103        let separator = if base_url.contains('?') { "&" } else { "?" };
1104        let url = format!("{}{}include_subtasks=true", base_url, separator);
1105        let task: ClickUpTask = self.get(&url).await?;
1106        Ok(map_task(&task))
1107    }
1108
1109    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1110        let url = format!("{}/list/{}/task", self.base_url, self.list_id);
1111
1112        let priority = input.priority.as_deref().and_then(priority_to_clickup);
1113
1114        let tags = if input.labels.is_empty() {
1115            None
1116        } else {
1117            Some(input.labels)
1118        };
1119
1120        // Resolve parent key to native ClickUp task ID if provided.
1121        // Fast-path: if the key is already a CU-{id} key, the native ID is known.
1122        let parent = match input.parent {
1123            Some(ref parent_key) => {
1124                if let Some(stripped) = parent_key.strip_prefix("CU-") {
1125                    Some(stripped.to_string())
1126                } else {
1127                    let parent_url = self.task_url(parent_key)?;
1128                    let parent_task: ClickUpTask = self.get(&parent_url).await?;
1129                    Some(parent_task.id)
1130                }
1131            }
1132            None => None,
1133        };
1134
1135        let (description, markdown_content) = if input.markdown {
1136            (None, input.description)
1137        } else {
1138            (input.description, None)
1139        };
1140
1141        // ClickUp POST /task accepts a flat `assignees: [u64]` array (no
1142        // diff envelope, unlike PUT). Resolve email/username strings to
1143        // numeric ids — see `resolve_assignee_ids` for the lookup logic.
1144        let assignees = if input.assignees.is_empty() {
1145            None
1146        } else {
1147            Some(self.resolve_assignee_ids(&input.assignees).await?)
1148        };
1149
1150        let request = CreateTaskRequest {
1151            name: input.title,
1152            description,
1153            markdown_content,
1154            parent,
1155            status: None,
1156            priority,
1157            tags,
1158            assignees,
1159        };
1160
1161        let task: ClickUpTask = self.post(&url, &request).await?;
1162        let task_id = task.id.clone();
1163
1164        // ClickUp generates custom_id asynchronously after task creation.
1165        // Retry GET until custom_id is available (matching DevBoy backend pattern).
1166        if task.custom_id.is_none() {
1167            for attempt in 1..=3u64 {
1168                tokio::time::sleep(std::time::Duration::from_millis(300 * attempt)).await;
1169                let fetch_url = format!("{}/task/{}", self.base_url, task_id);
1170                if let Ok(fetched) = self.get::<ClickUpTask>(&fetch_url).await
1171                    && fetched.custom_id.is_some()
1172                {
1173                    debug!(
1174                        task_id = task_id,
1175                        custom_id = ?fetched.custom_id,
1176                        attempt = attempt,
1177                        "Got custom_id after retry"
1178                    );
1179                    return Ok(map_task(&fetched));
1180                }
1181            }
1182            warn!(
1183                task_id = task_id,
1184                "custom_id not available after 3 retries, using POST response"
1185            );
1186        }
1187
1188        Ok(map_task(&task))
1189    }
1190
1191    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1192        let url = self.task_url(key)?;
1193
1194        // `status` takes precedence over `state` (#288). It is a literal
1195        // ClickUp custom status name (e.g. "in progress", "review");
1196        // we validate against the list's configured statuses
1197        // (case-insensitive) and forward the canonical form. `state`
1198        // is the legacy generic open/closed path resolved by type.
1199        let status = if let Some(s) = input.status.as_deref() {
1200            Some(self.validate_status_name(s).await?)
1201        } else if let Some(s) = input.state.as_deref() {
1202            Some(self.resolve_status(s).await?)
1203        } else {
1204            None
1205        };
1206
1207        let priority = input.priority.as_deref().and_then(priority_to_clickup);
1208
1209        let (description, markdown_content) = if input.markdown {
1210            (None, input.description)
1211        } else {
1212            (input.description, None)
1213        };
1214
1215        // Resolve parent key to native ClickUp task ID if provided.
1216        // "none" or "" → detach from parent (convert subtask → standalone task).
1217        // ClickUp API accepts {"parent": "none"} to remove parent (undocumented but verified).
1218        let parent = match input.parent_id {
1219            Some(ref parent_key) if parent_key == "none" || parent_key.is_empty() => {
1220                Some("none".to_string())
1221            }
1222            Some(ref parent_key) => {
1223                if let Some(stripped) = parent_key.strip_prefix("CU-") {
1224                    Some(stripped.to_string())
1225                } else {
1226                    let parent_url = self.task_url(parent_key)?;
1227                    let parent_task: ClickUpTask = self.get(&parent_url).await?;
1228                    Some(parent_task.id)
1229                }
1230            }
1231            None => None,
1232        };
1233
1234        // Compute assignee diff (`{add, rem}`) before the PUT. ClickUp
1235        // silently drops a flat `assignees: [...]` array on update — see
1236        // https://clickup.com/api/clickupreference/operation/UpdateTask/.
1237        // `None` (not present in the request body) leaves the field
1238        // untouched; `Some(empty_diff)` means input was provided but no
1239        // change is needed.
1240        //
1241        // Race window: there is an inherent gap between this GET and the
1242        // PUT below — a parallel actor that mutates assignees in between
1243        // will see a stale diff applied. ClickUp doesn't offer
1244        // optimistic-locking on `/task/:id`, so we can't close this
1245        // cleanly here; the practical impact is small (assignee changes
1246        // are rare and idempotent enough that the next call usually
1247        // converges).
1248        let assignees_diff = match input.assignees.as_deref() {
1249            Some(requested) => {
1250                let new_ids = self.resolve_assignee_ids(requested).await?;
1251                let current_task: ClickUpTask = self.get(&url).await?;
1252                let current_ids: Vec<u64> = current_task.assignees.iter().map(|u| u.id).collect();
1253                let add: Vec<u64> = new_ids
1254                    .iter()
1255                    .copied()
1256                    .filter(|id| !current_ids.contains(id))
1257                    .collect();
1258                let rem: Vec<u64> = current_ids
1259                    .iter()
1260                    .copied()
1261                    .filter(|id| !new_ids.contains(id))
1262                    .collect();
1263                if add.is_empty() && rem.is_empty() {
1264                    None
1265                } else {
1266                    Some(AssigneeDiff { add, rem })
1267                }
1268            }
1269            None => None,
1270        };
1271
1272        let request = UpdateTaskRequest {
1273            name: input.title,
1274            description,
1275            markdown_content,
1276            status,
1277            priority,
1278            parent,
1279            tags: None, // Tags updated via separate API below
1280            assignees: assignees_diff,
1281        };
1282
1283        let task: ClickUpTask = self.put(&url, &request).await?;
1284
1285        // Update tags via ClickUp Tag API (PUT /task ignores tags field).
1286        // POST /task/{id}/tag/{name} to add, DELETE /task/{id}/tag/{name} to remove.
1287        if let Some(ref new_labels) = input.labels {
1288            let current_tags: Vec<String> = task.tags.iter().map(|t| t.name.clone()).collect();
1289            let new_tags: Vec<String> = new_labels.iter().map(|l| l.to_lowercase()).collect();
1290
1291            // Remove tags not in new list
1292            for tag in &current_tags {
1293                if !new_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1294                    let tag_url =
1295                        format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1296                    if let Err(e) = self.delete(&tag_url).await {
1297                        warn!(tag = tag, error = %e, "Failed to remove tag");
1298                    }
1299                }
1300            }
1301
1302            // Add tags not in current list
1303            for tag in &new_tags {
1304                if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1305                    let tag_url =
1306                        format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1307                    let resp = self
1308                        .request(reqwest::Method::POST, &tag_url)
1309                        .send()
1310                        .await
1311                        .map_err(|e| Error::Http(e.to_string()))?;
1312                    if !resp.status().is_success() {
1313                        warn!(
1314                            tag = tag,
1315                            status = resp.status().as_u16(),
1316                            "Failed to add tag"
1317                        );
1318                    }
1319                }
1320            }
1321        }
1322
1323        // Re-fetch after PUT because ClickUp can return stale status in the PUT response (#117),
1324        // but do not fail the whole update if the refresh itself is transiently unavailable.
1325        match self.get::<ClickUpTask>(&url).await {
1326            Ok(updated_task) => Ok(map_task(&updated_task)),
1327            Err(e) => {
1328                warn!(
1329                    issue_key = key,
1330                    error = %e,
1331                    "Task updated successfully, but failed to re-fetch fresh state; falling back to PUT response"
1332                );
1333                Ok(map_task(&task))
1334            }
1335        }
1336    }
1337
1338    async fn set_custom_fields(&self, issue_key: &str, fields: &[serde_json::Value]) -> Result<()> {
1339        let task_id = self.resolve_to_native_id(issue_key).await?;
1340        for field in fields {
1341            let field_id = field["id"].as_str().unwrap_or_default();
1342            if field_id.is_empty() {
1343                continue;
1344            }
1345            let url = format!("{}/task/{}/field/{}", self.base_url, task_id, field_id);
1346            let body = serde_json::json!({ "value": field["value"] });
1347            let resp = self
1348                .request(reqwest::Method::POST, &url)
1349                .json(&body)
1350                .send()
1351                .await
1352                .map_err(|e| Error::Http(e.to_string()))?;
1353            if !resp.status().is_success() {
1354                let status = resp.status().as_u16();
1355                let msg = resp.text().await.unwrap_or_default();
1356                warn!(
1357                    field_id = field_id,
1358                    status = status,
1359                    "Failed to set custom field: {}",
1360                    msg
1361                );
1362            }
1363        }
1364        Ok(())
1365    }
1366
1367    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1368        let base_url = self.task_url(issue_key)?;
1369        // Append /comment — handle both raw URL and URL with query params
1370        let url = if base_url.contains('?') {
1371            let (path, query) = base_url.split_once('?').unwrap();
1372            format!("{}/comment?{}", path, query)
1373        } else {
1374            format!("{}/comment", base_url)
1375        };
1376        let response: ClickUpCommentList = self.get(&url).await?;
1377        Ok(response
1378            .comments
1379            .iter()
1380            .map(map_comment)
1381            .collect::<Vec<_>>()
1382            .into())
1383    }
1384
1385    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1386        let base_url = self.task_url(issue_key)?;
1387        let url = if base_url.contains('?') {
1388            let (path, query) = base_url.split_once('?').unwrap();
1389            format!("{}/comment?{}", path, query)
1390        } else {
1391            format!("{}/comment", base_url)
1392        };
1393        // ClickUp's Comments API does not render markdown: `comment_text` is
1394        // run through a lossy auto-formatter that fragments backtick spans into
1395        // isolated inline-code chips. Send the structured `comment` array
1396        // (rich-text runs) so code, lists and prose render cleanly.
1397        //
1398        // ClickUp renders BOTH fields when present, appending the raw
1399        // `comment_text` as a trailing block — so the body must be sent in
1400        // exactly one of them. When we have structured blocks, leave
1401        // `comment_text` empty (the field is required, but empty avoids the
1402        // duplicate); otherwise fall back to plain `comment_text`.
1403        let comment = crate::comment_format::markdown_to_comment_blocks(body);
1404        let comment_text = if comment.is_empty() {
1405            body.to_string()
1406        } else {
1407            String::new()
1408        };
1409        let request = CreateCommentRequest {
1410            comment_text,
1411            comment,
1412        };
1413
1414        // ClickUp POST returns minimal response (id + date), not full comment
1415        let response: CreateCommentResponse = self.post(&url, &request).await?;
1416        Ok(Comment {
1417            id: response.id,
1418            body: body.to_string(),
1419            author: None,
1420            created_at: map_timestamp(&response.date),
1421            updated_at: None,
1422            position: None,
1423        })
1424    }
1425
1426    async fn upload_attachment(
1427        &self,
1428        issue_key: &str,
1429        filename: &str,
1430        data: &[u8],
1431    ) -> Result<String> {
1432        let task_id = self.resolve_to_native_id(issue_key).await?;
1433        let url = format!("{}/task/{}/attachment", self.base_url, task_id);
1434
1435        let part = reqwest::multipart::Part::bytes(data.to_vec())
1436            .file_name(filename.to_string())
1437            .mime_str("application/octet-stream")
1438            .map_err(|e| Error::Http(format!("Failed to create multipart: {}", e)))?;
1439
1440        let form = reqwest::multipart::Form::new().part("attachment", part);
1441
1442        let response = self
1443            .client
1444            .post(&url)
1445            .header("Authorization", self.token.expose_secret())
1446            .multipart(form)
1447            .send()
1448            .await
1449            .map_err(|e| Error::Http(e.to_string()))?;
1450
1451        let status = response.status();
1452        if !status.is_success() {
1453            let message = response.text().await.unwrap_or_default();
1454            return Err(Error::from_status(status.as_u16(), message));
1455        }
1456
1457        // ClickUp returns: { "attachment": { "url": "..." } } or similar
1458        let body: serde_json::Value = response.json().await.map_err(|e| {
1459            Error::InvalidData(format!("Failed to parse attachment response: {}", e))
1460        })?;
1461
1462        // Extract URL from response
1463        let download_url = body
1464            .pointer("/url")
1465            .or_else(|| body.pointer("/attachment/url"))
1466            .and_then(|v| v.as_str())
1467            .unwrap_or("")
1468            .to_string();
1469
1470        Ok(download_url)
1471    }
1472
1473    async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
1474        let url = self.task_url(issue_key)?;
1475        let task: ClickUpTask = self.get(&url).await?;
1476        Ok(task
1477            .attachments
1478            .iter()
1479            .map(map_clickup_attachment)
1480            .collect())
1481    }
1482
1483    async fn download_attachment(&self, issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1484        // ClickUp does not expose an "attachment by id" endpoint: the
1485        // download URL lives on the task payload. Fetch the task, look up
1486        // the attachment, and download from its URL using our authenticated
1487        // client.
1488        let url = self.task_url(issue_key)?;
1489        let task: ClickUpTask = self.get(&url).await?;
1490        let attachment = task
1491            .attachments
1492            .iter()
1493            .find(|a| a.id == asset_id)
1494            .ok_or_else(|| {
1495                Error::NotFound(format!(
1496                    "attachment '{asset_id}' not found on task {issue_key}",
1497                ))
1498            })?;
1499        let download_url = attachment.url.as_deref().ok_or_else(|| {
1500            Error::InvalidData(format!(
1501                "attachment '{asset_id}' on task {issue_key} has no URL",
1502            ))
1503        })?;
1504
1505        let response = self
1506            .client
1507            .get(download_url)
1508            .header("Authorization", self.token.expose_secret())
1509            .send()
1510            .await
1511            .map_err(|e| Error::Http(e.to_string()))?;
1512
1513        let status = response.status();
1514        if !status.is_success() {
1515            let message = response.text().await.unwrap_or_default();
1516            return Err(Error::from_status(status.as_u16(), message));
1517        }
1518
1519        let bytes = response
1520            .bytes()
1521            .await
1522            .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
1523        Ok(bytes.to_vec())
1524    }
1525
1526    fn asset_capabilities(&self) -> AssetCapabilities {
1527        // ClickUp supports upload / download / list on issue (task) bodies.
1528        // There is no public delete attachment endpoint, so `delete` stays
1529        // false for every context.
1530        AssetCapabilities {
1531            issue: ContextCapabilities {
1532                upload: true,
1533                download: true,
1534                delete: false,
1535                list: true,
1536                max_file_size: None,
1537                allowed_types: Vec::new(),
1538            },
1539            ..Default::default()
1540        }
1541    }
1542
1543    async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1544        let url = format!("{}/list/{}", self.base_url, self.list_id);
1545        let list_info: ClickUpListInfo = self.get(&url).await?;
1546
1547        let statuses: Vec<IssueStatus> = list_info
1548            .statuses
1549            .iter()
1550            .enumerate()
1551            .map(|(idx, s)| {
1552                let category = map_status_category(s.status_type.as_deref(), &s.status);
1553                IssueStatus {
1554                    id: s.status.clone(),
1555                    name: s.status.clone(),
1556                    category,
1557                    color: s.color.clone(),
1558                    order: s.orderindex.or(Some(idx as u32)),
1559                }
1560            })
1561            .collect();
1562
1563        Ok(statuses.into())
1564    }
1565
1566    async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1567        match link_type {
1568            "subtask" => {
1569                // source becomes subtask of target → set parent on source task
1570                let source_url = self.task_url(source_key)?;
1571                let target_native_id = self.resolve_to_native_id(target_key).await?;
1572                let body = serde_json::json!({ "parent": target_native_id });
1573                let _: ClickUpTask = self.put(&source_url, &body).await?;
1574            }
1575            "blocks" => {
1576                // source blocks target → target depends_on source
1577                let source_id = self.resolve_task_id(source_key)?;
1578                let target_id = self.resolve_task_id(target_key)?;
1579                let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1580                let body = serde_json::json!({ "depends_on": source_id });
1581                let _: serde_json::Value = self.post(&url, &body).await?;
1582            }
1583            "blocked_by" => {
1584                // source is blocked by target → source depends_on target
1585                let source_id = self.resolve_task_id(source_key)?;
1586                let target_id = self.resolve_task_id(target_key)?;
1587                let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1588                let body = serde_json::json!({ "depends_on": target_id });
1589                let _: serde_json::Value = self.post(&url, &body).await?;
1590            }
1591            _ => {
1592                // Link tasks (bidirectional, non-dependency relationship)
1593                let source_id = self.resolve_to_native_id(source_key).await?;
1594                let target_id = self.resolve_to_native_id(target_key).await?;
1595                let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1596                let body = serde_json::json!({});
1597                let _: serde_json::Value = self.post(&url, &body).await?;
1598            }
1599        }
1600
1601        Ok(())
1602    }
1603
1604    async fn unlink_issues(
1605        &self,
1606        source_key: &str,
1607        target_key: &str,
1608        link_type: &str,
1609    ) -> Result<()> {
1610        match link_type {
1611            "subtask" => {
1612                // Detach source from parent (convert subtask → standalone task).
1613                // Use native task ID + "parent": "none" (undocumented but verified working).
1614                let source_id = self.resolve_to_native_id(source_key).await?;
1615                let url = format!("{}/task/{}", self.base_url, source_id);
1616                let body = serde_json::json!({ "parent": "none" });
1617                let _: ClickUpTask = self.put(&url, &body).await?;
1618            }
1619            "blocks" => {
1620                // Remove: source blocks target → target depends_on source
1621                let source_id = self.resolve_to_native_id(source_key).await?;
1622                let target_id = self.resolve_to_native_id(target_key).await?;
1623                let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1624                self.delete_with_query(&url, &[("depends_on", &source_id)])
1625                    .await?;
1626            }
1627            "blocked_by" => {
1628                // Remove: source is blocked by target → source depends_on target
1629                let source_id = self.resolve_to_native_id(source_key).await?;
1630                let target_id = self.resolve_to_native_id(target_key).await?;
1631                let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1632                self.delete_with_query(&url, &[("depends_on", &target_id)])
1633                    .await?;
1634            }
1635            _ => {
1636                // Remove bidirectional link
1637                let source_id = self.resolve_to_native_id(source_key).await?;
1638                let target_id = self.resolve_to_native_id(target_key).await?;
1639                let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1640                self.delete(&url).await?;
1641            }
1642        }
1643
1644        Ok(())
1645    }
1646
1647    async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1648        let url = self.task_url(issue_key)?;
1649        let task: ClickUpTask = self
1650            .get_with_query(
1651                &url,
1652                &[("include_subtasks", "true"), ("include_closed", "true")],
1653            )
1654            .await?;
1655
1656        let mut relations = IssueRelations::default();
1657
1658        // Parent: fetch parent task for full Issue data
1659        if let Some(ref parent_id) = task.parent {
1660            let parent_url = format!("{}/task/{}", self.base_url, parent_id);
1661            match self.get::<ClickUpTask>(&parent_url).await {
1662                Ok(parent_task) => {
1663                    relations.parent = Some(map_task(&parent_task));
1664                }
1665                Err(e) => {
1666                    tracing::warn!("Failed to fetch parent task {}: {}", parent_id, e);
1667                    // Still include a minimal parent reference
1668                    relations.parent = Some(Issue {
1669                        key: format!("CU-{parent_id}"),
1670                        source: "clickup".to_string(),
1671                        ..Default::default()
1672                    });
1673                }
1674            }
1675        }
1676
1677        // Subtasks
1678        if let Some(ref subtasks) = task.subtasks {
1679            relations.subtasks = subtasks.iter().map(map_task).collect();
1680        }
1681
1682        // Dependencies → blocked_by / blocks
1683        if let Some(ref deps) = task.dependencies {
1684            let (blocked_by, blocks) = map_dependencies(deps, &task.id);
1685            relations.blocked_by = blocked_by;
1686            relations.blocks = blocks;
1687        }
1688
1689        // Linked tasks → related_to
1690        if let Some(ref linked) = task.linked_tasks {
1691            relations.related_to = map_linked_tasks(linked);
1692        }
1693
1694        Ok(relations)
1695    }
1696
1697    fn provider_name(&self) -> &'static str {
1698        "clickup"
1699    }
1700}
1701
1702#[async_trait]
1703impl MergeRequestProvider for ClickUpClient {
1704    fn provider_name(&self) -> &'static str {
1705        "clickup"
1706    }
1707}
1708
1709#[async_trait]
1710impl PipelineProvider for ClickUpClient {
1711    fn provider_name(&self) -> &'static str {
1712        "clickup"
1713    }
1714}
1715
1716#[async_trait]
1717impl Provider for ClickUpClient {
1718    async fn get_current_user(&self) -> Result<User> {
1719        // ClickUp v2 API does not have a /user/me endpoint.
1720        // Verify the token by fetching the first page of tasks with a minimal request.
1721        let url = format!(
1722            "{}/list/{}/task?page=0&subtasks=false",
1723            self.base_url, self.list_id
1724        );
1725        let _: ClickUpTaskList = self.get(&url).await?;
1726
1727        // Token is valid — return a synthetic user
1728        Ok(User {
1729            id: "clickup".to_string(),
1730            username: "clickup-user".to_string(),
1731            name: Some("ClickUp User".to_string()),
1732            ..Default::default()
1733        })
1734    }
1735}
1736
1737// =============================================================================
1738// Tests
1739// =============================================================================
1740
1741#[cfg(test)]
1742mod tests {
1743    use super::*;
1744    use crate::types::{ClickUpStatus, ClickUpTag};
1745    use devboy_core::{CreateCommentInput, MrFilter};
1746
1747    fn token(s: &str) -> SecretString {
1748        SecretString::from(s.to_string())
1749    }
1750
1751    #[test]
1752    fn test_epoch_ms_to_iso8601() {
1753        // 2024-01-01T00:00:00Z = 1704067200000 ms
1754        assert_eq!(
1755            epoch_ms_to_iso8601("1704067200000"),
1756            Some("2024-01-01T00:00:00Z".to_string())
1757        );
1758
1759        // 2024-01-02T00:00:00Z = 1704153600000 ms
1760        assert_eq!(
1761            epoch_ms_to_iso8601("1704153600000"),
1762            Some("2024-01-02T00:00:00Z".to_string())
1763        );
1764
1765        // 2024-01-15T10:00:00Z = 1705312800000 ms
1766        assert_eq!(
1767            epoch_ms_to_iso8601("1705312800000"),
1768            Some("2024-01-15T10:00:00Z".to_string())
1769        );
1770
1771        // Invalid input
1772        assert_eq!(epoch_ms_to_iso8601("not_a_number"), None);
1773    }
1774
1775    #[test]
1776    fn test_task_url_cu_prefix() {
1777        let client =
1778            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1779        let url = client.task_url("CU-abc123").unwrap();
1780        assert_eq!(url, "https://api.clickup.com/api/v2/task/abc123");
1781    }
1782
1783    #[test]
1784    fn test_task_url_custom_id_with_team() {
1785        let client =
1786            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"))
1787                .with_team_id("9876");
1788        let url = client.task_url("DEV-42").unwrap();
1789        assert_eq!(
1790            url,
1791            "https://api.clickup.com/api/v2/task/DEV-42?custom_task_ids=true&team_id=9876"
1792        );
1793    }
1794
1795    #[test]
1796    fn test_task_url_custom_id_without_team() {
1797        let client =
1798            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1799        let result = client.task_url("DEV-42");
1800        assert!(result.is_err());
1801    }
1802
1803    /// `task.custom_fields` populates `issue.custom_fields` keyed
1804    /// by ClickUp's stable field **id** (matches the homogeneous
1805    /// shape `JOIN`able with `get_custom_fields` across providers).
1806    /// Display name rides along inside `CustomFieldValue.name`.
1807    /// Unset fields (`value == None`) are filtered.
1808    #[test]
1809    fn test_map_task_surfaces_custom_field_values() {
1810        let task = ClickUpTask {
1811            id: "abc123".to_string(),
1812            custom_id: None,
1813            name: "T".to_string(),
1814            description: None,
1815            text_content: None,
1816            status: ClickUpStatus {
1817                status: "open".to_string(),
1818                status_type: Some("open".to_string()),
1819            },
1820            priority: None,
1821            tags: vec![],
1822            assignees: vec![],
1823            creator: None,
1824            url: "https://app.clickup.com/t/abc123".to_string(),
1825            date_created: None,
1826            date_updated: None,
1827            parent: None,
1828            subtasks: None,
1829            dependencies: None,
1830            linked_tasks: None,
1831            attachments: Vec::new(),
1832            custom_fields: vec![
1833                crate::types::ClickUpCustomField {
1834                    id: "cf-1".to_string(),
1835                    name: Some("Severity".to_string()),
1836                    field_type: Some("drop_down".to_string()),
1837                    value: Some(serde_json::json!("High")),
1838                    type_config: None,
1839                },
1840                crate::types::ClickUpCustomField {
1841                    id: "cf-2".to_string(),
1842                    name: Some("Sprint".to_string()),
1843                    field_type: Some("text".to_string()),
1844                    value: None, // unset → must be skipped
1845                    type_config: None,
1846                },
1847                crate::types::ClickUpCustomField {
1848                    id: "cf-3".to_string(),
1849                    name: None, // anonymous → falls back to id
1850                    field_type: Some("number".to_string()),
1851                    value: Some(serde_json::json!(42)),
1852                    type_config: None,
1853                },
1854            ],
1855        };
1856
1857        let issue = map_task(&task);
1858        // Keyed by id; display name rides along in
1859        // `CustomFieldValue.name`.
1860        let severity = issue.custom_fields.get("cf-1").expect("cf-1 present");
1861        assert_eq!(severity.name.as_deref(), Some("Severity"));
1862        assert_eq!(severity.value, serde_json::json!("High"));
1863        // No `type_config` options on the payload → nothing to resolve.
1864        assert!(severity.display.is_none());
1865        // Unset value (`cf-2`) is filtered out entirely.
1866        assert!(!issue.custom_fields.contains_key("cf-2"));
1867        // Anonymous field (no name) keeps `name: None`.
1868        let anon = issue.custom_fields.get("cf-3").expect("cf-3 present");
1869        assert!(anon.name.is_none());
1870        assert_eq!(anon.value, serde_json::json!(42));
1871    }
1872
1873    /// DEV-1578b: drop_down / labels custom fields must resolve their
1874    /// opaque value (order index or option id) to the human label, so the
1875    /// agent reads "dev" not `0`. Raw `value` is preserved for writes.
1876    #[test]
1877    fn test_map_task_resolves_select_custom_fields_to_labels() {
1878        let opt = |id: &str, name: &str, idx: u32| crate::types::ClickUpFieldOptionInline {
1879            id: Some(id.to_string()),
1880            name: Some(name.to_string()),
1881            orderindex: Some(idx),
1882        };
1883        let shipped_opts = || {
1884            Some(crate::types::ClickUpFieldTypeConfig {
1885                options: vec![
1886                    opt("o-dev", "dev", 0),
1887                    opt("o-test", "test", 1),
1888                    opt("o-prod", "prod", 2),
1889                ],
1890            })
1891        };
1892        let task = ClickUpTask {
1893            id: "t".to_string(),
1894            custom_id: None,
1895            name: "T".to_string(),
1896            description: None,
1897            text_content: None,
1898            status: ClickUpStatus {
1899                status: "open".to_string(),
1900                status_type: Some("open".to_string()),
1901            },
1902            priority: None,
1903            tags: vec![],
1904            assignees: vec![],
1905            creator: None,
1906            url: "https://app.clickup.com/t/t".to_string(),
1907            date_created: None,
1908            date_updated: None,
1909            parent: None,
1910            subtasks: None,
1911            dependencies: None,
1912            linked_tasks: None,
1913            attachments: Vec::new(),
1914            custom_fields: vec![
1915                // drop_down selected by order index 0 → "dev"
1916                crate::types::ClickUpCustomField {
1917                    id: "shipped".to_string(),
1918                    name: Some("Shipped Version".to_string()),
1919                    field_type: Some("drop_down".to_string()),
1920                    value: Some(serde_json::json!(0)),
1921                    type_config: shipped_opts(),
1922                },
1923                // drop_down selected by option id → "prod"
1924                crate::types::ClickUpCustomField {
1925                    id: "shipped2".to_string(),
1926                    name: Some("Shipped Version".to_string()),
1927                    field_type: Some("drop_down".to_string()),
1928                    value: Some(serde_json::json!("o-prod")),
1929                    type_config: shipped_opts(),
1930                },
1931                // labels multi-select → joined "backend, infra"
1932                crate::types::ClickUpCustomField {
1933                    id: "areas".to_string(),
1934                    name: Some("Areas".to_string()),
1935                    field_type: Some("labels".to_string()),
1936                    value: Some(serde_json::json!(["a", "b"])),
1937                    type_config: Some(crate::types::ClickUpFieldTypeConfig {
1938                        options: vec![
1939                            opt("a", "backend", 0),
1940                            opt("b", "infra", 1),
1941                            opt("c", "frontend", 2),
1942                        ],
1943                    }),
1944                },
1945            ],
1946        };
1947
1948        let issue = map_task(&task);
1949        let shipped = issue.custom_fields.get("shipped").expect("shipped present");
1950        assert_eq!(shipped.value, serde_json::json!(0)); // raw preserved for writes
1951        assert_eq!(shipped.display.as_deref(), Some("dev"));
1952
1953        let shipped2 = issue
1954            .custom_fields
1955            .get("shipped2")
1956            .expect("shipped2 present");
1957        assert_eq!(shipped2.display.as_deref(), Some("prod"));
1958
1959        let areas = issue.custom_fields.get("areas").expect("areas present");
1960        assert_eq!(areas.display.as_deref(), Some("backend, infra"));
1961    }
1962
1963    /// DEV-1578b: every non-resolving branch must degrade to `display =
1964    /// None` with the raw `value` preserved — never a wrong label, never a
1965    /// panic. Covers the u32 out-of-range guard, unmatched order index,
1966    /// unmatched option id, empty options, and unmatched labels.
1967    #[test]
1968    fn test_resolve_custom_field_display_degrades_gracefully() {
1969        use crate::types::{ClickUpCustomField, ClickUpFieldOptionInline, ClickUpFieldTypeConfig};
1970        let opt = |id: &str, name: &str, idx: u32| ClickUpFieldOptionInline {
1971            id: Some(id.to_string()),
1972            name: Some(name.to_string()),
1973            orderindex: Some(idx),
1974        };
1975        let field =
1976            |id: &str, ty: &str, value: serde_json::Value, opts: Vec<ClickUpFieldOptionInline>| {
1977                ClickUpCustomField {
1978                    id: id.to_string(),
1979                    name: Some(id.to_string()),
1980                    field_type: Some(ty.to_string()),
1981                    value: Some(value),
1982                    type_config: Some(ClickUpFieldTypeConfig { options: opts }),
1983                }
1984            };
1985        let dd_opts = || vec![opt("o-dev", "dev", 0), opt("o-test", "test", 1)];
1986        let task = ClickUpTask {
1987            id: "t".to_string(),
1988            custom_id: None,
1989            name: "T".to_string(),
1990            description: None,
1991            text_content: None,
1992            status: ClickUpStatus {
1993                status: "open".to_string(),
1994                status_type: Some("open".to_string()),
1995            },
1996            priority: None,
1997            tags: vec![],
1998            assignees: vec![],
1999            creator: None,
2000            url: "https://app.clickup.com/t/t".to_string(),
2001            date_created: None,
2002            date_updated: None,
2003            parent: None,
2004            subtasks: None,
2005            dependencies: None,
2006            linked_tasks: None,
2007            attachments: Vec::new(),
2008            custom_fields: vec![
2009                // drop_down order index out of u32 range → None (clean, not truncated)
2010                field(
2011                    "dd_overflow",
2012                    "drop_down",
2013                    serde_json::json!(4_294_967_296u64),
2014                    dd_opts(),
2015                ),
2016                // drop_down order index with no matching option → None
2017                field("dd_no_index", "drop_down", serde_json::json!(99), dd_opts()),
2018                // drop_down option id that doesn't exist → None
2019                field(
2020                    "dd_bad_id",
2021                    "drop_down",
2022                    serde_json::json!("nope"),
2023                    dd_opts(),
2024                ),
2025                // type_config present but options empty → None
2026                field("dd_empty", "drop_down", serde_json::json!(0), vec![]),
2027                // labels ids with no matching option → None
2028                field(
2029                    "lbl_nomatch",
2030                    "labels",
2031                    serde_json::json!(["zzz"]),
2032                    vec![opt("a", "backend", 0)],
2033                ),
2034            ],
2035        };
2036
2037        let issue = map_task(&task);
2038        for key in [
2039            "dd_overflow",
2040            "dd_no_index",
2041            "dd_bad_id",
2042            "dd_empty",
2043            "lbl_nomatch",
2044        ] {
2045            let f = issue
2046                .custom_fields
2047                .get(key)
2048                .unwrap_or_else(|| panic!("{key} present"));
2049            assert!(
2050                f.display.is_none(),
2051                "{key} must not resolve, got {:?}",
2052                f.display
2053            );
2054            assert!(!f.value.is_null(), "{key} raw value preserved");
2055        }
2056    }
2057
2058    #[test]
2059    fn test_map_task() {
2060        let task = ClickUpTask {
2061            id: "abc123".to_string(),
2062            custom_id: None,
2063            name: "Fix bug".to_string(),
2064            description: Some("Bug description".to_string()),
2065            text_content: Some("Bug text content".to_string()),
2066            status: ClickUpStatus {
2067                status: "open".to_string(),
2068                status_type: Some("open".to_string()),
2069            },
2070            priority: Some(ClickUpPriority {
2071                id: "2".to_string(),
2072                priority: "high".to_string(),
2073                color: None,
2074            }),
2075            tags: vec![ClickUpTag {
2076                name: "bug".to_string(),
2077            }],
2078            assignees: vec![ClickUpUser {
2079                id: 1,
2080                username: "dev1".to_string(),
2081                email: Some("dev1@example.com".to_string()),
2082                profile_picture: None,
2083            }],
2084            creator: Some(ClickUpUser {
2085                id: 2,
2086                username: "creator".to_string(),
2087                email: None,
2088                profile_picture: None,
2089            }),
2090            url: "https://app.clickup.com/t/abc123".to_string(),
2091            date_created: Some("1704067200000".to_string()),
2092            date_updated: Some("1704153600000".to_string()),
2093            parent: None,
2094            subtasks: None,
2095            dependencies: None,
2096            linked_tasks: None,
2097            attachments: Vec::new(),
2098            custom_fields: Vec::new(),
2099        };
2100
2101        let issue = map_task(&task);
2102        assert_eq!(issue.key, "CU-abc123");
2103        assert_eq!(issue.title, "Fix bug");
2104        assert_eq!(issue.description, Some("Bug text content".to_string()));
2105        assert_eq!(issue.state, "open");
2106        assert_eq!(issue.source, "clickup");
2107        assert_eq!(issue.priority, Some("high".to_string()));
2108        assert_eq!(issue.labels, vec!["bug"]);
2109        assert_eq!(issue.assignees.len(), 1);
2110        assert_eq!(issue.assignees[0].username, "dev1");
2111        assert!(issue.author.is_some());
2112        assert_eq!(issue.author.unwrap().username, "creator");
2113        assert_eq!(
2114            issue.url,
2115            Some("https://app.clickup.com/t/abc123".to_string())
2116        );
2117        // Timestamps are now ISO 8601
2118        assert_eq!(issue.created_at, Some("2024-01-01T00:00:00Z".to_string()));
2119        assert_eq!(issue.updated_at, Some("2024-01-02T00:00:00Z".to_string()));
2120        // DEV-1578: the rich display status + unified category surface alongside `state`.
2121        assert_eq!(issue.status, Some("open".to_string()));
2122        assert_eq!(issue.status_category, Some("todo".to_string()));
2123    }
2124
2125    /// DEV-1578: custom ClickUp statuses (the ones that drive the team's
2126    /// "what's deployed where" board — e.g. "in progress", "review",
2127    /// "complete") must surface verbatim in `status`, with a normalized
2128    /// `status_category` for cross-provider grouping. The binary `state`
2129    /// alone collapses these and loses the promotion stage.
2130    #[test]
2131    fn test_map_task_surfaces_display_status() {
2132        let make = |name: &str, ty: &str| ClickUpTask {
2133            id: "t1".to_string(),
2134            custom_id: None,
2135            name: "Task".to_string(),
2136            description: None,
2137            text_content: None,
2138            status: ClickUpStatus {
2139                status: name.to_string(),
2140                status_type: Some(ty.to_string()),
2141            },
2142            priority: None,
2143            tags: vec![],
2144            assignees: vec![],
2145            creator: None,
2146            url: "https://app.clickup.com/t/t1".to_string(),
2147            date_created: None,
2148            date_updated: None,
2149            parent: None,
2150            subtasks: None,
2151            dependencies: None,
2152            linked_tasks: None,
2153            attachments: Vec::new(),
2154            custom_fields: Vec::new(),
2155        };
2156
2157        // Display name is preserved verbatim (not collapsed to open/closed).
2158        let in_progress = map_task(&make("in progress", "custom"));
2159        assert_eq!(in_progress.status, Some("in progress".to_string()));
2160        assert_eq!(in_progress.status_category, Some("in_progress".to_string()));
2161        assert_eq!(in_progress.state, "open"); // `state` still binary
2162
2163        let review = map_task(&make("review", "custom"));
2164        assert_eq!(review.status, Some("review".to_string()));
2165        assert_eq!(review.status_category, Some("in_progress".to_string()));
2166
2167        // A "done"-typed status maps to the done category and closed state.
2168        let complete = map_task(&make("complete", "closed"));
2169        assert_eq!(complete.status, Some("complete".to_string()));
2170        assert_eq!(complete.status_category, Some("done".to_string()));
2171        assert_eq!(complete.state, "closed");
2172    }
2173
2174    #[test]
2175    fn test_map_task_with_custom_id() {
2176        let task = ClickUpTask {
2177            id: "abc123".to_string(),
2178            custom_id: Some("DEV-42".to_string()),
2179            name: "Task with custom ID".to_string(),
2180            description: None,
2181            text_content: None,
2182            status: ClickUpStatus {
2183                status: "open".to_string(),
2184                status_type: Some("open".to_string()),
2185            },
2186            priority: None,
2187            tags: vec![],
2188            assignees: vec![],
2189            creator: None,
2190            url: "https://app.clickup.com/t/abc123".to_string(),
2191            date_created: None,
2192            date_updated: None,
2193            parent: None,
2194            subtasks: None,
2195            dependencies: None,
2196            linked_tasks: None,
2197            attachments: Vec::new(),
2198            custom_fields: Vec::new(),
2199        };
2200
2201        let issue = map_task(&task);
2202        assert_eq!(issue.key, "DEV-42");
2203    }
2204
2205    #[test]
2206    fn test_map_task_closed_status() {
2207        let task = ClickUpTask {
2208            id: "abc123".to_string(),
2209            custom_id: None,
2210            name: "Closed task".to_string(),
2211            description: None,
2212            text_content: None,
2213            status: ClickUpStatus {
2214                status: "done".to_string(),
2215                status_type: Some("closed".to_string()),
2216            },
2217            priority: None,
2218            tags: vec![],
2219            assignees: vec![],
2220            creator: None,
2221            url: "https://app.clickup.com/t/abc123".to_string(),
2222            date_created: None,
2223            date_updated: None,
2224            parent: None,
2225            subtasks: None,
2226            dependencies: None,
2227            linked_tasks: None,
2228            attachments: Vec::new(),
2229            custom_fields: Vec::new(),
2230        };
2231
2232        let issue = map_task(&task);
2233        assert_eq!(issue.state, "closed");
2234    }
2235
2236    #[test]
2237    fn test_map_priority_all_levels() {
2238        let make_priority = |id: &str, name: &str| ClickUpPriority {
2239            id: id.to_string(),
2240            priority: name.to_string(),
2241            color: None,
2242        };
2243
2244        assert_eq!(
2245            map_priority(Some(&make_priority("1", "urgent"))),
2246            Some("urgent".to_string())
2247        );
2248        assert_eq!(
2249            map_priority(Some(&make_priority("2", "high"))),
2250            Some("high".to_string())
2251        );
2252        assert_eq!(
2253            map_priority(Some(&make_priority("3", "normal"))),
2254            Some("normal".to_string())
2255        );
2256        assert_eq!(
2257            map_priority(Some(&make_priority("4", "low"))),
2258            Some("low".to_string())
2259        );
2260        assert_eq!(map_priority(None), None);
2261    }
2262
2263    #[test]
2264    fn test_map_user() {
2265        let cu_user = ClickUpUser {
2266            id: 123,
2267            username: "testuser".to_string(),
2268            email: Some("test@example.com".to_string()),
2269            profile_picture: Some("https://example.com/avatar.png".to_string()),
2270        };
2271
2272        let user = map_user(Some(&cu_user)).unwrap();
2273        assert_eq!(user.id, "123");
2274        assert_eq!(user.username, "testuser");
2275        assert_eq!(user.name, Some("testuser".to_string()));
2276        assert_eq!(user.email, Some("test@example.com".to_string()));
2277        assert_eq!(
2278            user.avatar_url,
2279            Some("https://example.com/avatar.png".to_string())
2280        );
2281    }
2282
2283    #[test]
2284    fn test_map_user_none() {
2285        assert!(map_user(None).is_none());
2286    }
2287
2288    #[test]
2289    fn test_map_user_required_with_user() {
2290        let cu_user = ClickUpUser {
2291            id: 1,
2292            username: "user1".to_string(),
2293            email: None,
2294            profile_picture: None,
2295        };
2296        let user = map_user_required(Some(&cu_user));
2297        assert_eq!(user.username, "user1");
2298    }
2299
2300    #[test]
2301    fn test_map_user_required_without_user() {
2302        let user = map_user_required(None);
2303        assert_eq!(user.id, "unknown");
2304        assert_eq!(user.username, "unknown");
2305    }
2306
2307    #[test]
2308    fn test_map_clickup_attachment_all_fields() {
2309        let raw = ClickUpAttachment {
2310            id: "att-1".into(),
2311            title: Some("report.log".into()),
2312            url: Some("https://attachments.clickup.com/abc/report.log".into()),
2313            size: Some(serde_json::json!("2048")),
2314            extension: Some("log".into()),
2315            mimetype: Some("text/plain".into()),
2316            date: Some("1704067200000".into()),
2317            user: Some(ClickUpUser {
2318                id: 7,
2319                username: "uploader".into(),
2320                email: None,
2321                profile_picture: None,
2322            }),
2323        };
2324        let meta = map_clickup_attachment(&raw);
2325        assert_eq!(meta.id, "att-1");
2326        assert_eq!(meta.filename, "report.log");
2327        assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
2328        assert_eq!(meta.size, Some(2048));
2329        assert_eq!(
2330            meta.url.as_deref(),
2331            Some("https://attachments.clickup.com/abc/report.log")
2332        );
2333        assert_eq!(meta.author.as_deref(), Some("uploader"));
2334        assert_eq!(meta.created_at, Some("2024-01-01T00:00:00Z".to_string()));
2335        assert!(!meta.cached);
2336    }
2337
2338    #[test]
2339    fn test_map_clickup_attachment_minimal_falls_back_to_url() {
2340        let raw = ClickUpAttachment {
2341            id: "att-2".into(),
2342            title: None,
2343            url: Some("https://cdn/a/b/screen.png?token=x".into()),
2344            size: Some(serde_json::json!(4096)),
2345            extension: None,
2346            mimetype: None,
2347            date: None,
2348            user: None,
2349        };
2350        let meta = map_clickup_attachment(&raw);
2351        // Filename falls back to the last path segment, query stripped.
2352        assert_eq!(meta.filename, "screen.png");
2353        assert_eq!(meta.size, Some(4096));
2354        assert!(meta.created_at.is_none());
2355        assert!(meta.author.is_none());
2356    }
2357
2358    #[test]
2359    fn test_map_clickup_attachment_missing_everything() {
2360        let raw = ClickUpAttachment {
2361            id: "att-3".into(),
2362            title: None,
2363            url: None,
2364            size: None,
2365            extension: None,
2366            mimetype: None,
2367            date: None,
2368            user: None,
2369        };
2370        let meta = map_clickup_attachment(&raw);
2371        // When title and URL are missing the fallback uses the attachment id.
2372        assert_eq!(meta.filename, "attachment-att-3");
2373        assert!(meta.url.is_none());
2374        assert!(meta.size.is_none());
2375    }
2376
2377    #[test]
2378    fn test_clickup_asset_capabilities() {
2379        let client =
2380            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2381        let caps = client.asset_capabilities();
2382        assert!(caps.issue.upload);
2383        assert!(caps.issue.download);
2384        assert!(caps.issue.list);
2385        assert!(!caps.issue.delete, "ClickUp has no delete attachment API");
2386        assert!(
2387            !caps.merge_request.upload,
2388            "ClickUp does not track merge requests",
2389        );
2390    }
2391
2392    #[test]
2393    fn test_map_comment() {
2394        let cu_comment = ClickUpComment {
2395            id: "42".to_string(),
2396            comment_text: "Nice work!".to_string(),
2397            user: Some(ClickUpUser {
2398                id: 1,
2399                username: "reviewer".to_string(),
2400                email: None,
2401                profile_picture: None,
2402            }),
2403            date: Some("1705312800000".to_string()),
2404        };
2405
2406        let comment = map_comment(&cu_comment);
2407        assert_eq!(comment.id, "42");
2408        assert_eq!(comment.body, "Nice work!");
2409        assert!(comment.author.is_some());
2410        assert_eq!(comment.author.unwrap().username, "reviewer");
2411        // Timestamp is now ISO 8601
2412        assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
2413        assert!(comment.position.is_none());
2414    }
2415
2416    #[test]
2417    fn test_map_tags() {
2418        let tags = vec![
2419            ClickUpTag {
2420                name: "bug".to_string(),
2421            },
2422            ClickUpTag {
2423                name: "feature".to_string(),
2424            },
2425        ];
2426        let result = map_tags(&tags);
2427        assert_eq!(result, vec!["bug", "feature"]);
2428    }
2429
2430    #[test]
2431    fn test_map_tags_empty() {
2432        let result = map_tags(&[]);
2433        assert!(result.is_empty());
2434    }
2435
2436    #[test]
2437    fn test_priority_to_clickup() {
2438        assert_eq!(priority_to_clickup("urgent"), Some(1));
2439        assert_eq!(priority_to_clickup("high"), Some(2));
2440        assert_eq!(priority_to_clickup("normal"), Some(3));
2441        assert_eq!(priority_to_clickup("low"), Some(4));
2442        assert_eq!(priority_to_clickup("unknown"), None);
2443    }
2444
2445    #[test]
2446    fn test_api_url() {
2447        let client =
2448            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
2449        assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2450        assert_eq!(client.list_id, "12345");
2451    }
2452
2453    #[test]
2454    fn test_api_url_strips_trailing_slash() {
2455        let client = ClickUpClient::with_base_url(
2456            "https://api.clickup.com/api/v2/",
2457            "12345",
2458            token("token"),
2459        );
2460        assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
2461    }
2462
2463    #[test]
2464    fn test_with_team_id() {
2465        let client = ClickUpClient::new("12345", token("token")).with_team_id("9876");
2466        assert_eq!(client.team_id, Some("9876".to_string()));
2467    }
2468
2469    #[test]
2470    fn test_provider_name() {
2471        let client = ClickUpClient::new("12345", token("token"));
2472        assert_eq!(IssueProvider::provider_name(&client), "clickup");
2473        assert_eq!(MergeRequestProvider::provider_name(&client), "clickup");
2474    }
2475
2476    #[test]
2477    fn test_map_task_description_fallback() {
2478        let task = ClickUpTask {
2479            id: "abc".to_string(),
2480            custom_id: None,
2481            name: "Task".to_string(),
2482            description: Some("HTML description".to_string()),
2483            text_content: None,
2484            status: ClickUpStatus {
2485                status: "open".to_string(),
2486                status_type: Some("open".to_string()),
2487            },
2488            priority: None,
2489            tags: vec![],
2490            assignees: vec![],
2491            creator: None,
2492            url: "https://app.clickup.com/t/abc".to_string(),
2493            date_created: None,
2494            date_updated: None,
2495            parent: None,
2496            subtasks: None,
2497            dependencies: None,
2498            linked_tasks: None,
2499            attachments: Vec::new(),
2500            custom_fields: Vec::new(),
2501        };
2502
2503        let issue = map_task(&task);
2504        assert_eq!(issue.description, Some("HTML description".to_string()));
2505    }
2506
2507    #[test]
2508    fn test_map_state_custom_type() {
2509        let task = ClickUpTask {
2510            id: "abc".to_string(),
2511            custom_id: None,
2512            name: "Task".to_string(),
2513            description: None,
2514            text_content: None,
2515            status: ClickUpStatus {
2516                status: "in progress".to_string(),
2517                status_type: Some("custom".to_string()),
2518            },
2519            priority: None,
2520            tags: vec![],
2521            assignees: vec![],
2522            creator: None,
2523            url: "https://app.clickup.com/t/abc".to_string(),
2524            date_created: None,
2525            date_updated: None,
2526            parent: None,
2527            subtasks: None,
2528            dependencies: None,
2529            linked_tasks: None,
2530            attachments: Vec::new(),
2531            custom_fields: Vec::new(),
2532        };
2533
2534        let issue = map_task(&task);
2535        assert_eq!(issue.state, "open");
2536    }
2537
2538    #[test]
2539    fn test_map_task_with_parent() {
2540        let task = ClickUpTask {
2541            id: "child1".to_string(),
2542            custom_id: Some("DEV-100".to_string()),
2543            name: "Child task".to_string(),
2544            description: None,
2545            text_content: None,
2546            status: ClickUpStatus {
2547                status: "open".to_string(),
2548                status_type: Some("open".to_string()),
2549            },
2550            priority: None,
2551            tags: vec![],
2552            assignees: vec![],
2553            creator: None,
2554            url: "https://app.clickup.com/t/child1".to_string(),
2555            date_created: None,
2556            date_updated: None,
2557            parent: Some("parent123".to_string()),
2558            subtasks: None,
2559            dependencies: None,
2560            linked_tasks: None,
2561            attachments: Vec::new(),
2562            custom_fields: Vec::new(),
2563        };
2564
2565        let issue = map_task(&task);
2566        assert_eq!(issue.parent, Some("CU-parent123".to_string()));
2567        assert!(issue.subtasks.is_empty());
2568    }
2569
2570    #[test]
2571    fn test_map_task_with_subtasks() {
2572        let subtask = ClickUpTask {
2573            id: "sub1".to_string(),
2574            custom_id: Some("DEV-201".to_string()),
2575            name: "Subtask 1".to_string(),
2576            description: None,
2577            text_content: None,
2578            status: ClickUpStatus {
2579                status: "in progress".to_string(),
2580                status_type: Some("custom".to_string()),
2581            },
2582            priority: None,
2583            tags: vec![],
2584            assignees: vec![],
2585            creator: None,
2586            url: "https://app.clickup.com/t/sub1".to_string(),
2587            date_created: None,
2588            date_updated: None,
2589            parent: Some("epic1".to_string()),
2590            subtasks: None,
2591            dependencies: None,
2592            linked_tasks: None,
2593            attachments: Vec::new(),
2594            custom_fields: Vec::new(),
2595        };
2596
2597        let task = ClickUpTask {
2598            id: "epic1".to_string(),
2599            custom_id: Some("DEV-200".to_string()),
2600            name: "Epic task".to_string(),
2601            description: None,
2602            text_content: None,
2603            status: ClickUpStatus {
2604                status: "open".to_string(),
2605                status_type: Some("open".to_string()),
2606            },
2607            priority: None,
2608            tags: vec![ClickUpTag {
2609                name: "epic".to_string(),
2610            }],
2611            assignees: vec![],
2612            creator: None,
2613            url: "https://app.clickup.com/t/epic1".to_string(),
2614            date_created: None,
2615            date_updated: None,
2616            parent: None,
2617            subtasks: Some(vec![subtask]),
2618            dependencies: None,
2619            linked_tasks: None,
2620            attachments: Vec::new(),
2621            custom_fields: Vec::new(),
2622        };
2623
2624        let issue = map_task(&task);
2625        assert_eq!(issue.key, "DEV-200");
2626        assert!(issue.parent.is_none());
2627        assert_eq!(issue.subtasks.len(), 1);
2628        assert_eq!(issue.subtasks[0].key, "DEV-201");
2629        assert_eq!(issue.subtasks[0].title, "Subtask 1");
2630        assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2631    }
2632
2633    #[test]
2634    fn test_map_task_no_parent_no_subtasks() {
2635        let task = ClickUpTask {
2636            id: "standalone".to_string(),
2637            custom_id: None,
2638            name: "Standalone task".to_string(),
2639            description: None,
2640            text_content: None,
2641            status: ClickUpStatus {
2642                status: "open".to_string(),
2643                status_type: Some("open".to_string()),
2644            },
2645            priority: None,
2646            tags: vec![],
2647            assignees: vec![],
2648            creator: None,
2649            url: "https://app.clickup.com/t/standalone".to_string(),
2650            date_created: None,
2651            date_updated: None,
2652            parent: None,
2653            subtasks: None,
2654            dependencies: None,
2655            linked_tasks: None,
2656            attachments: Vec::new(),
2657            custom_fields: Vec::new(),
2658        };
2659
2660        let issue = map_task(&task);
2661        assert!(issue.parent.is_none());
2662        assert!(issue.subtasks.is_empty());
2663    }
2664
2665    #[test]
2666    fn test_deserialize_task_with_parent_and_subtasks() {
2667        let json = serde_json::json!({
2668            "id": "epic1",
2669            "custom_id": "DEV-300",
2670            "name": "Epic with subtasks",
2671            "status": {"status": "open", "type": "open"},
2672            "tags": [{"name": "epic"}],
2673            "assignees": [],
2674            "url": "https://app.clickup.com/t/epic1",
2675            "parent": null,
2676            "subtasks": [
2677                {
2678                    "id": "sub1",
2679                    "custom_id": "DEV-301",
2680                    "name": "Subtask A",
2681                    "status": {"status": "open", "type": "open"},
2682                    "tags": [],
2683                    "assignees": [],
2684                    "url": "https://app.clickup.com/t/sub1",
2685                    "parent": "epic1"
2686                },
2687                {
2688                    "id": "sub2",
2689                    "name": "Subtask B",
2690                    "status": {"status": "closed", "type": "closed"},
2691                    "tags": [],
2692                    "assignees": [],
2693                    "url": "https://app.clickup.com/t/sub2",
2694                    "parent": "epic1"
2695                }
2696            ]
2697        });
2698
2699        let task: ClickUpTask = serde_json::from_value(json).unwrap();
2700        assert!(task.parent.is_none());
2701        assert_eq!(task.subtasks.as_ref().unwrap().len(), 2);
2702        assert_eq!(
2703            task.subtasks.as_ref().unwrap()[0].custom_id,
2704            Some("DEV-301".to_string())
2705        );
2706        assert_eq!(
2707            task.subtasks.as_ref().unwrap()[1].parent,
2708            Some("epic1".to_string())
2709        );
2710
2711        let issue = map_task(&task);
2712        assert_eq!(issue.subtasks.len(), 2);
2713        assert_eq!(issue.subtasks[0].key, "DEV-301");
2714        assert_eq!(issue.subtasks[1].key, "CU-sub2");
2715        assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2716    }
2717
2718    #[test]
2719    fn test_deserialize_task_without_subtasks_field() {
2720        // ClickUp API may omit subtasks field entirely
2721        let json = serde_json::json!({
2722            "id": "task1",
2723            "name": "Simple task",
2724            "status": {"status": "open", "type": "open"},
2725            "tags": [],
2726            "assignees": [],
2727            "url": "https://app.clickup.com/t/task1"
2728        });
2729
2730        let task: ClickUpTask = serde_json::from_value(json).unwrap();
2731        assert!(task.parent.is_none());
2732        assert!(task.subtasks.is_none());
2733
2734        let issue = map_task(&task);
2735        assert!(issue.parent.is_none());
2736        assert!(issue.subtasks.is_empty());
2737    }
2738
2739    #[test]
2740    fn test_map_status_category_name_heuristics() {
2741        // Explicit types
2742        assert_eq!(map_status_category(Some("closed"), "Done"), "done");
2743        assert_eq!(map_status_category(Some("done"), "Complete"), "done");
2744
2745        // Custom statuses — name-based mapping
2746        assert_eq!(map_status_category(Some("custom"), "Backlog"), "backlog");
2747        assert_eq!(
2748            map_status_category(Some("custom"), "Product Backlog"),
2749            "backlog"
2750        );
2751        assert_eq!(map_status_category(Some("custom"), "To Do"), "todo");
2752        assert_eq!(map_status_category(Some("custom"), "New"), "todo");
2753        assert_eq!(
2754            map_status_category(Some("custom"), "In Progress"),
2755            "in_progress"
2756        );
2757        assert_eq!(
2758            map_status_category(Some("custom"), "Code Review"),
2759            "in_progress"
2760        );
2761        assert_eq!(map_status_category(Some("custom"), "Doing"), "in_progress");
2762        assert_eq!(map_status_category(Some("custom"), "Active"), "in_progress");
2763        assert_eq!(map_status_category(Some("custom"), "Done"), "done");
2764        assert_eq!(map_status_category(Some("custom"), "Completed"), "done");
2765        assert_eq!(map_status_category(Some("custom"), "Resolved"), "done");
2766        assert_eq!(
2767            map_status_category(Some("custom"), "Cancelled"),
2768            "cancelled"
2769        );
2770        assert_eq!(map_status_category(Some("custom"), "Archived"), "cancelled");
2771        assert_eq!(map_status_category(Some("custom"), "Rejected"), "cancelled");
2772
2773        // Open type — defaults to "todo"
2774        assert_eq!(map_status_category(Some("open"), "Open"), "todo");
2775
2776        // Unknown custom status — defaults to "in_progress"
2777        assert_eq!(
2778            map_status_category(Some("custom"), "Some Custom Status"),
2779            "in_progress"
2780        );
2781    }
2782
2783    #[test]
2784    fn test_priority_sort_key() {
2785        assert_eq!(priority_sort_key(Some("urgent")), 1);
2786        assert_eq!(priority_sort_key(Some("high")), 2);
2787        assert_eq!(priority_sort_key(Some("normal")), 3);
2788        assert_eq!(priority_sort_key(Some("low")), 4);
2789        assert_eq!(priority_sort_key(None), 5);
2790    }
2791
2792    // =========================================================================
2793    // Integration tests with httpmock
2794    // =========================================================================
2795
2796    mod integration {
2797        use super::*;
2798        use httpmock::prelude::*;
2799
2800        fn create_test_client(server: &MockServer) -> ClickUpClient {
2801            ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2802        }
2803
2804        fn create_test_client_with_team(server: &MockServer) -> ClickUpClient {
2805            ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2806                .with_team_id("9876")
2807        }
2808
2809        fn sample_task_json() -> serde_json::Value {
2810            serde_json::json!({
2811                "id": "abc123",
2812                "name": "Test Task",
2813                "description": "<p>Task description</p>",
2814                "text_content": "Task description",
2815                "status": {
2816                    "status": "open",
2817                    "type": "open"
2818                },
2819                "priority": {
2820                    "id": "2",
2821                    "priority": "high",
2822                    "color": "#ffcc00"
2823                },
2824                "tags": [{"name": "bug"}],
2825                "assignees": [{"id": 1, "username": "dev1"}],
2826                "creator": {"id": 2, "username": "creator"},
2827                "url": "https://app.clickup.com/t/abc123",
2828                "date_created": "1704067200000",
2829                "date_updated": "1704153600000"
2830            })
2831        }
2832
2833        fn sample_closed_task_json() -> serde_json::Value {
2834            serde_json::json!({
2835                "id": "def456",
2836                "name": "Closed Task",
2837                "status": {
2838                    "status": "done",
2839                    "type": "closed"
2840                },
2841                "tags": [],
2842                "assignees": [],
2843                "url": "https://app.clickup.com/t/def456",
2844                "date_created": "1704067200000",
2845                "date_updated": "1704153600000"
2846            })
2847        }
2848
2849        fn sample_task_with_custom_id_json() -> serde_json::Value {
2850            serde_json::json!({
2851                "id": "abc123",
2852                "custom_id": "DEV-42",
2853                "name": "Task with custom ID",
2854                "status": {
2855                    "status": "open",
2856                    "type": "open"
2857                },
2858                "tags": [],
2859                "assignees": [],
2860                "url": "https://app.clickup.com/t/abc123",
2861                "date_created": "1704067200000",
2862                "date_updated": "1704153600000"
2863            })
2864        }
2865
2866        #[tokio::test]
2867        async fn test_get_issues() {
2868            let server = MockServer::start();
2869
2870            server.mock(|when, then| {
2871                when.method(GET)
2872                    .path("/list/12345/task")
2873                    .header("Authorization", "pk_test_token");
2874                then.status(200)
2875                    .json_body(serde_json::json!({"tasks": [sample_task_json()]}));
2876            });
2877
2878            let client = create_test_client(&server);
2879            let issues = client
2880                .get_issues(IssueFilter::default())
2881                .await
2882                .unwrap()
2883                .items;
2884
2885            assert_eq!(issues.len(), 1);
2886            assert_eq!(issues[0].key, "CU-abc123");
2887            assert_eq!(issues[0].title, "Test Task");
2888            assert_eq!(issues[0].source, "clickup");
2889            assert_eq!(issues[0].priority, Some("high".to_string()));
2890            // Verify ISO 8601 timestamps
2891            assert_eq!(
2892                issues[0].created_at,
2893                Some("2024-01-01T00:00:00Z".to_string())
2894            );
2895        }
2896
2897        /// DEV-1578b end-to-end: a task whose drop_down/labels custom fields
2898        /// arrive in ClickUp's real wire shape (drop_down `value` = the option
2899        /// order index, labels `value` = array of option ids, option defs in
2900        /// `type_config.options`) must deserialize and resolve to human labels
2901        /// through the full get_issues path. Proves the serde wiring (incl. the
2902        /// drop_down `name` / labels `label` option-key alias) matches ClickUp
2903        /// — not just the in-memory mapper — and that the agent-facing JSON
2904        /// carries the resolved label.
2905        #[tokio::test]
2906        async fn test_get_issues_resolves_select_field_display() {
2907            let server = MockServer::start();
2908            let task = serde_json::json!({
2909                "id": "t1",
2910                "name": "Tracked task",
2911                "status": {"status": "in progress", "type": "custom"},
2912                "tags": [],
2913                "assignees": [],
2914                "url": "https://app.clickup.com/t/t1",
2915                "date_created": "1704067200000",
2916                "date_updated": "1704153600000",
2917                "custom_fields": [
2918                    {
2919                        "id": "8b31f785-shipped",
2920                        "name": "Shipped Version",
2921                        "type": "drop_down",
2922                        "type_config": { "options": [
2923                            {"id": "o-dev",  "name": "dev",  "orderindex": 0},
2924                            {"id": "o-test", "name": "test", "orderindex": 1},
2925                            {"id": "o-prod", "name": "prod", "orderindex": 2}
2926                        ]},
2927                        "value": 0
2928                    },
2929                    {
2930                        "id": "areas",
2931                        "name": "Areas",
2932                        "type": "labels",
2933                        // ClickUp `labels` options use the `label` key, not `name`.
2934                        "type_config": { "options": [
2935                            {"id": "a", "label": "backend"},
2936                            {"id": "b", "label": "infra"}
2937                        ]},
2938                        "value": ["a", "b"]
2939                    }
2940                ]
2941            });
2942
2943            server.mock(|when, then| {
2944                when.method(GET).path("/list/12345/task");
2945                then.status(200)
2946                    .json_body(serde_json::json!({"tasks": [task]}));
2947            });
2948
2949            let client = create_test_client(&server);
2950            let issues = client
2951                .get_issues(IssueFilter::default())
2952                .await
2953                .unwrap()
2954                .items;
2955            let issue = issues.into_iter().next().expect("one issue");
2956
2957            // Rich display status surfaces (DEV-1578).
2958            assert_eq!(issue.status.as_deref(), Some("in progress"));
2959            assert_eq!(issue.status_category.as_deref(), Some("in_progress"));
2960
2961            // drop_down order index 0 → "dev"; raw value preserved for writes.
2962            let shipped = issue
2963                .custom_fields
2964                .get("8b31f785-shipped")
2965                .expect("shipped field present");
2966            assert_eq!(shipped.value, serde_json::json!(0));
2967            assert_eq!(shipped.display.as_deref(), Some("dev"));
2968
2969            // labels ids → joined labels (validates the `label` serde alias).
2970            let areas = issue.custom_fields.get("areas").expect("areas present");
2971            assert_eq!(areas.display.as_deref(), Some("backend, infra"));
2972
2973            // Agent-facing JSON output carries the resolved label.
2974            let json = serde_json::to_string(&issue).unwrap();
2975            assert!(json.contains("\"display\":\"dev\""), "json = {json}");
2976        }
2977
2978        #[tokio::test]
2979        async fn test_get_issues_with_filters() {
2980            let server = MockServer::start();
2981
2982            server.mock(|when, then| {
2983                when.method(GET)
2984                    .path("/list/12345/task")
2985                    .query_param("include_closed", "true")
2986                    .query_param("subtasks", "true")
2987                    .query_param("tags[]", "bug");
2988                then.status(200).json_body(
2989                    serde_json::json!({"tasks": [sample_task_json(), sample_closed_task_json()]}),
2990                );
2991            });
2992
2993            let client = create_test_client(&server);
2994            let issues = client
2995                .get_issues(IssueFilter {
2996                    state: Some("all".to_string()),
2997                    labels: Some(vec!["bug".to_string()]),
2998                    ..Default::default()
2999                })
3000                .await
3001                .unwrap()
3002                .items;
3003
3004            assert_eq!(issues.len(), 2);
3005        }
3006
3007        #[tokio::test]
3008        async fn test_get_issues_state_filter_open() {
3009            let server = MockServer::start();
3010
3011            server.mock(|when, then| {
3012                when.method(GET).path("/list/12345/task");
3013                then.status(200).json_body(serde_json::json!({
3014                    "tasks": [sample_task_json(), sample_closed_task_json()]
3015                }));
3016            });
3017
3018            let client = create_test_client(&server);
3019            let issues = client
3020                .get_issues(IssueFilter {
3021                    state: Some("open".to_string()),
3022                    ..Default::default()
3023                })
3024                .await
3025                .unwrap()
3026                .items;
3027
3028            assert_eq!(issues.len(), 1);
3029            assert_eq!(issues[0].state, "open");
3030        }
3031
3032        #[tokio::test]
3033        async fn test_get_issues_state_filter_closed() {
3034            let server = MockServer::start();
3035
3036            server.mock(|when, then| {
3037                when.method(GET)
3038                    .path("/list/12345/task")
3039                    .query_param("include_closed", "true");
3040                then.status(200).json_body(serde_json::json!({
3041                    "tasks": [sample_task_json(), sample_closed_task_json()]
3042                }));
3043            });
3044
3045            let client = create_test_client(&server);
3046            let issues = client
3047                .get_issues(IssueFilter {
3048                    state: Some("closed".to_string()),
3049                    ..Default::default()
3050                })
3051                .await
3052                .unwrap()
3053                .items;
3054
3055            assert_eq!(issues.len(), 1);
3056            assert_eq!(issues[0].state, "closed");
3057        }
3058
3059        #[tokio::test]
3060        async fn test_get_issues_pagination() {
3061            let server = MockServer::start();
3062
3063            let tasks: Vec<serde_json::Value> = (0..5)
3064                .map(|i| {
3065                    serde_json::json!({
3066                        "id": format!("task{}", i),
3067                        "name": format!("Task {}", i),
3068                        "status": {"status": "open", "type": "open"},
3069                        "tags": [],
3070                        "assignees": [],
3071                        "url": format!("https://app.clickup.com/t/task{}", i),
3072                        "date_created": "1704067200000",
3073                        "date_updated": "1704153600000"
3074                    })
3075                })
3076                .collect();
3077
3078            server.mock(|when, then| {
3079                when.method(GET)
3080                    .path("/list/12345/task")
3081                    .query_param("page", "0");
3082                then.status(200)
3083                    .json_body(serde_json::json!({"tasks": tasks}));
3084            });
3085
3086            let client = create_test_client(&server);
3087
3088            let issues = client
3089                .get_issues(IssueFilter {
3090                    limit: Some(2),
3091                    offset: Some(1),
3092                    ..Default::default()
3093                })
3094                .await
3095                .unwrap()
3096                .items;
3097
3098            assert_eq!(issues.len(), 2);
3099            assert_eq!(issues[0].key, "CU-task1");
3100            assert_eq!(issues[1].key, "CU-task2");
3101        }
3102
3103        #[tokio::test]
3104        async fn test_get_issues_limit_zero() {
3105            // No server needed — should return immediately without making API calls
3106            let client = ClickUpClient::new("12345", token("token"));
3107            let issues = client
3108                .get_issues(IssueFilter {
3109                    limit: Some(0),
3110                    ..Default::default()
3111                })
3112                .await
3113                .unwrap()
3114                .items;
3115
3116            assert!(issues.is_empty());
3117        }
3118
3119        #[tokio::test]
3120        async fn test_get_issues_multi_page() {
3121            let server = MockServer::start();
3122
3123            // Page 0: 100 tasks
3124            let page0_tasks: Vec<serde_json::Value> = (0..100)
3125                .map(|i| {
3126                    serde_json::json!({
3127                        "id": format!("task{}", i),
3128                        "name": format!("Task {}", i),
3129                        "status": {"status": "open", "type": "open"},
3130                        "tags": [],
3131                        "assignees": [],
3132                        "url": format!("https://app.clickup.com/t/task{}", i),
3133                        "date_created": "1704067200000",
3134                        "date_updated": "1704153600000"
3135                    })
3136                })
3137                .collect();
3138
3139            // Page 1: 50 tasks
3140            let page1_tasks: Vec<serde_json::Value> = (100..150)
3141                .map(|i| {
3142                    serde_json::json!({
3143                        "id": format!("task{}", i),
3144                        "name": format!("Task {}", i),
3145                        "status": {"status": "open", "type": "open"},
3146                        "tags": [],
3147                        "assignees": [],
3148                        "url": format!("https://app.clickup.com/t/task{}", i),
3149                        "date_created": "1704067200000",
3150                        "date_updated": "1704153600000"
3151                    })
3152                })
3153                .collect();
3154
3155            server.mock(|when, then| {
3156                when.method(GET)
3157                    .path("/list/12345/task")
3158                    .query_param("page", "0");
3159                then.status(200)
3160                    .json_body(serde_json::json!({"tasks": page0_tasks}));
3161            });
3162
3163            server.mock(|when, then| {
3164                when.method(GET)
3165                    .path("/list/12345/task")
3166                    .query_param("page", "1");
3167                then.status(200)
3168                    .json_body(serde_json::json!({"tasks": page1_tasks}));
3169            });
3170
3171            let client = create_test_client(&server);
3172
3173            // Request 120 tasks — should fetch 2 pages
3174            let issues = client
3175                .get_issues(IssueFilter {
3176                    limit: Some(120),
3177                    offset: Some(0),
3178                    ..Default::default()
3179                })
3180                .await
3181                .unwrap()
3182                .items;
3183
3184            assert_eq!(issues.len(), 120);
3185            assert_eq!(issues[0].key, "CU-task0");
3186            assert_eq!(issues[99].key, "CU-task99");
3187            assert_eq!(issues[100].key, "CU-task100");
3188            assert_eq!(issues[119].key, "CU-task119");
3189        }
3190
3191        #[tokio::test]
3192        async fn test_get_issue() {
3193            let server = MockServer::start();
3194
3195            server.mock(|when, then| {
3196                when.method(GET).path("/task/abc123");
3197                then.status(200).json_body(sample_task_json());
3198            });
3199
3200            let client = create_test_client(&server);
3201            let issue = client.get_issue("CU-abc123").await.unwrap();
3202
3203            assert_eq!(issue.key, "CU-abc123");
3204            assert_eq!(issue.title, "Test Task");
3205            assert_eq!(issue.priority, Some("high".to_string()));
3206        }
3207
3208        #[tokio::test]
3209        async fn test_get_issue_by_custom_id() {
3210            let server = MockServer::start();
3211
3212            server.mock(|when, then| {
3213                when.method(GET)
3214                    .path("/task/DEV-42")
3215                    .query_param("custom_task_ids", "true")
3216                    .query_param("team_id", "9876");
3217                then.status(200)
3218                    .json_body(sample_task_with_custom_id_json());
3219            });
3220
3221            let client = create_test_client_with_team(&server);
3222            let issue = client.get_issue("DEV-42").await.unwrap();
3223
3224            assert_eq!(issue.key, "DEV-42");
3225            assert_eq!(issue.title, "Task with custom ID");
3226        }
3227
3228        #[tokio::test]
3229        async fn test_get_issue_custom_id_without_team_fails() {
3230            let client = ClickUpClient::new("12345", token("token"));
3231            let result = client.get_issue("DEV-42").await;
3232            assert!(result.is_err());
3233        }
3234
3235        #[tokio::test]
3236        async fn test_create_issue_with_custom_id_retry() {
3237            let server = MockServer::start();
3238
3239            // POST returns task without custom_id
3240            server.mock(|when, then| {
3241                when.method(POST)
3242                    .path("/list/12345/task")
3243                    .body_includes("\"name\":\"New Task\"");
3244                then.status(200).json_body(sample_task_json());
3245            });
3246
3247            // GET retry returns task with custom_id
3248            let mut task_with_custom_id = sample_task_json();
3249            task_with_custom_id["custom_id"] = serde_json::json!("DEV-100");
3250
3251            server.mock(|when, then| {
3252                when.method(GET).path("/task/abc123");
3253                then.status(200).json_body(task_with_custom_id);
3254            });
3255
3256            let client = create_test_client(&server);
3257            let issue = client
3258                .create_issue(CreateIssueInput {
3259                    title: "New Task".to_string(),
3260                    description: Some("Description".to_string()),
3261                    labels: vec!["bug".to_string()],
3262                    ..Default::default()
3263                })
3264                .await
3265                .unwrap();
3266
3267            // Should use custom_id from retry GET
3268            assert_eq!(issue.key, "DEV-100");
3269        }
3270
3271        #[tokio::test]
3272        async fn test_create_issue_fallback_without_custom_id() {
3273            let server = MockServer::start();
3274
3275            // POST returns task without custom_id
3276            server.mock(|when, then| {
3277                when.method(POST)
3278                    .path("/list/12345/task")
3279                    .body_includes("\"name\":\"New Task\"");
3280                then.status(200).json_body(sample_task_json());
3281            });
3282
3283            // GET retry also returns without custom_id
3284            server.mock(|when, then| {
3285                when.method(GET).path("/task/abc123");
3286                then.status(200).json_body(sample_task_json());
3287            });
3288
3289            let client = create_test_client(&server);
3290            let issue = client
3291                .create_issue(CreateIssueInput {
3292                    title: "New Task".to_string(),
3293                    ..Default::default()
3294                })
3295                .await
3296                .unwrap();
3297
3298            // Fallback to CU-{id}
3299            assert_eq!(issue.key, "CU-abc123");
3300        }
3301
3302        #[tokio::test]
3303        async fn test_create_issue_with_priority() {
3304            let server = MockServer::start();
3305
3306            // Return task with custom_id to skip retry
3307            let mut task = sample_task_json();
3308            task["custom_id"] = serde_json::json!("DEV-101");
3309
3310            server.mock(|when, then| {
3311                when.method(POST)
3312                    .path("/list/12345/task")
3313                    .body_includes("\"priority\":1");
3314                then.status(200).json_body(task);
3315            });
3316
3317            let client = create_test_client(&server);
3318            let result = client
3319                .create_issue(CreateIssueInput {
3320                    title: "Urgent Task".to_string(),
3321                    priority: Some("urgent".to_string()),
3322                    ..Default::default()
3323                })
3324                .await;
3325
3326            assert!(result.is_ok());
3327            assert_eq!(result.unwrap().key, "DEV-101");
3328        }
3329
3330        #[tokio::test]
3331        async fn test_update_issue() {
3332            let server = MockServer::start();
3333
3334            server.mock(|when, then| {
3335                when.method(PUT)
3336                    .path("/task/abc123")
3337                    .body_includes("\"name\":\"Updated Task\"");
3338                then.status(200).json_body(sample_task_json());
3339            });
3340
3341            server.mock(|when, then| {
3342                when.method(GET).path("/task/abc123");
3343                then.status(200).json_body(sample_task_json());
3344            });
3345
3346            let client = create_test_client(&server);
3347            let issue = client
3348                .update_issue(
3349                    "CU-abc123",
3350                    UpdateIssueInput {
3351                        title: Some("Updated Task".to_string()),
3352                        ..Default::default()
3353                    },
3354                )
3355                .await
3356                .unwrap();
3357
3358            assert_eq!(issue.key, "CU-abc123");
3359        }
3360
3361        #[tokio::test]
3362        async fn test_update_issue_by_custom_id() {
3363            let server = MockServer::start();
3364
3365            server.mock(|when, then| {
3366                when.method(PUT)
3367                    .path("/task/DEV-42")
3368                    .query_param("custom_task_ids", "true")
3369                    .query_param("team_id", "9876");
3370                then.status(200)
3371                    .json_body(sample_task_with_custom_id_json());
3372            });
3373
3374            server.mock(|when, then| {
3375                when.method(GET)
3376                    .path("/task/DEV-42")
3377                    .query_param("custom_task_ids", "true")
3378                    .query_param("team_id", "9876");
3379                then.status(200)
3380                    .json_body(sample_task_with_custom_id_json());
3381            });
3382
3383            let client = create_test_client_with_team(&server);
3384            let issue = client
3385                .update_issue(
3386                    "DEV-42",
3387                    UpdateIssueInput {
3388                        title: Some("Updated".to_string()),
3389                        ..Default::default()
3390                    },
3391                )
3392                .await
3393                .unwrap();
3394
3395            assert_eq!(issue.key, "DEV-42");
3396        }
3397
3398        #[tokio::test]
3399        async fn test_update_issue_state_mapping() {
3400            let server = MockServer::start();
3401
3402            // Mock list info endpoint for status resolution
3403            server.mock(|when, then| {
3404                when.method(GET).path("/list/12345");
3405                then.status(200).json_body(serde_json::json!({
3406                    "statuses": [
3407                        {"status": "to do", "type": "open"},
3408                        {"status": "in progress", "type": "custom"},
3409                        {"status": "complete", "type": "closed"}
3410                    ]
3411                }));
3412            });
3413
3414            server.mock(|when, then| {
3415                when.method(PUT)
3416                    .path("/task/abc123")
3417                    .body_includes("\"status\":\"complete\"");
3418                then.status(200).json_body(sample_task_json());
3419            });
3420
3421            server.mock(|when, then| {
3422                when.method(GET).path("/task/abc123");
3423                then.status(200).json_body(sample_task_json());
3424            });
3425
3426            let client = create_test_client(&server);
3427            let result = client
3428                .update_issue(
3429                    "CU-abc123",
3430                    UpdateIssueInput {
3431                        state: Some("closed".to_string()),
3432                        ..Default::default()
3433                    },
3434                )
3435                .await;
3436
3437            assert!(result.is_ok());
3438        }
3439
3440        /// Regression test for #117: PUT response returns stale status,
3441        /// but re-fetched GET response reflects the actual closed state.
3442        #[tokio::test]
3443        async fn test_update_issue_state_refetch_returns_fresh_state() {
3444            let server = MockServer::start();
3445
3446            server.mock(|when, then| {
3447                when.method(GET).path("/list/12345");
3448                then.status(200).json_body(serde_json::json!({
3449                    "statuses": [
3450                        {"status": "to do", "type": "open"},
3451                        {"status": "complete", "type": "closed"}
3452                    ]
3453                }));
3454            });
3455
3456            // PUT returns stale "open" status (ClickUp behavior)
3457            server.mock(|when, then| {
3458                when.method(PUT)
3459                    .path("/task/abc123")
3460                    .body_includes("\"status\":\"complete\"");
3461                then.status(200).json_body(sample_task_json()); // status.type = "open"
3462            });
3463
3464            // GET returns the updated "closed" status
3465            server.mock(|when, then| {
3466                when.method(GET).path("/task/abc123");
3467                then.status(200).json_body(serde_json::json!({
3468                    "id": "abc123",
3469                    "name": "Test Task",
3470                    "status": {
3471                        "status": "complete",
3472                        "type": "closed"
3473                    },
3474                    "tags": [{"name": "bug"}],
3475                    "assignees": [{"id": 1, "username": "dev1"}],
3476                    "url": "https://app.clickup.com/t/abc123",
3477                    "date_created": "1704067200000",
3478                    "date_updated": "1704153600000"
3479                }));
3480            });
3481
3482            let client = create_test_client(&server);
3483            let issue = client
3484                .update_issue(
3485                    "CU-abc123",
3486                    UpdateIssueInput {
3487                        state: Some("closed".to_string()),
3488                        ..Default::default()
3489                    },
3490                )
3491                .await
3492                .unwrap();
3493
3494            assert_eq!(issue.state, "closed");
3495        }
3496
3497        #[tokio::test]
3498        async fn test_update_issue_state_open_mapping() {
3499            let server = MockServer::start();
3500
3501            server.mock(|when, then| {
3502                when.method(GET).path("/list/12345");
3503                then.status(200).json_body(serde_json::json!({
3504                    "statuses": [
3505                        {"status": "to do", "type": "open"},
3506                        {"status": "complete", "type": "closed"}
3507                    ]
3508                }));
3509            });
3510
3511            server.mock(|when, then| {
3512                when.method(PUT)
3513                    .path("/task/abc123")
3514                    .body_includes("\"status\":\"to do\"");
3515                then.status(200).json_body(sample_task_json());
3516            });
3517
3518            server.mock(|when, then| {
3519                when.method(GET).path("/task/abc123");
3520                then.status(200).json_body(sample_task_json());
3521            });
3522
3523            let client = create_test_client(&server);
3524            let result = client
3525                .update_issue(
3526                    "CU-abc123",
3527                    UpdateIssueInput {
3528                        state: Some("open".to_string()),
3529                        ..Default::default()
3530                    },
3531                )
3532                .await;
3533
3534            assert!(result.is_ok());
3535        }
3536
3537        #[tokio::test]
3538        async fn test_update_issue_exact_status_name() {
3539            let server = MockServer::start();
3540
3541            // Exact status name — no list lookup needed
3542            server.mock(|when, then| {
3543                when.method(PUT)
3544                    .path("/task/abc123")
3545                    .body_includes("\"status\":\"in progress\"");
3546                then.status(200).json_body(sample_task_json());
3547            });
3548
3549            server.mock(|when, then| {
3550                when.method(GET).path("/task/abc123");
3551                then.status(200).json_body(sample_task_json());
3552            });
3553
3554            let client = create_test_client(&server);
3555            let result = client
3556                .update_issue(
3557                    "CU-abc123",
3558                    UpdateIssueInput {
3559                        state: Some("in progress".to_string()),
3560                        ..Default::default()
3561                    },
3562                )
3563                .await;
3564
3565            assert!(result.is_ok());
3566        }
3567
3568        #[tokio::test]
3569        async fn test_get_comments() {
3570            let server = MockServer::start();
3571
3572            server.mock(|when, then| {
3573                when.method(GET).path("/task/abc123/comment");
3574                then.status(200).json_body(serde_json::json!({
3575                    "comments": [{
3576                        "id": "1",
3577                        "comment_text": "Looks good!",
3578                        "user": {"id": 1, "username": "reviewer"},
3579                        "date": "1705312800000"
3580                    }]
3581                }));
3582            });
3583
3584            let client = create_test_client(&server);
3585            let comments = client.get_comments("CU-abc123").await.unwrap().items;
3586
3587            assert_eq!(comments.len(), 1);
3588            assert_eq!(comments[0].body, "Looks good!");
3589            assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
3590            // Verify ISO 8601 timestamp
3591            assert_eq!(
3592                comments[0].created_at,
3593                Some("2024-01-15T10:00:00Z".to_string())
3594            );
3595        }
3596
3597        #[tokio::test]
3598        async fn test_add_comment() {
3599            let server = MockServer::start();
3600
3601            // ClickUp POST /comment returns minimal response (id as number, no comment_text)
3602            server.mock(|when, then| {
3603                when.method(POST)
3604                    .path("/task/abc123/comment")
3605                    // Structured rich-text array drives clean rendering;
3606                    // comment_text is emptied so ClickUp doesn't append a
3607                    // duplicate raw-text block. See add_comment / comment_format.
3608                    .body_includes("\"comment_text\":\"\"")
3609                    .body_includes("\"comment\":[{\"text\":\"My comment\"}]");
3610                then.status(200).json_body(serde_json::json!({
3611                    "id": 458315,
3612                    "hist_id": "26b2d7f1-test",
3613                    "date": 1705312800000_i64
3614                }));
3615            });
3616
3617            let client = create_test_client(&server);
3618            let comment = IssueProvider::add_comment(&client, "CU-abc123", "My comment")
3619                .await
3620                .unwrap();
3621
3622            assert_eq!(comment.body, "My comment");
3623            assert_eq!(comment.id, "458315");
3624            assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
3625        }
3626
3627        #[tokio::test]
3628        async fn test_handle_response_401() {
3629            let server = MockServer::start();
3630
3631            server.mock(|when, then| {
3632                when.method(GET).path("/list/12345/task");
3633                then.status(401).body("Token invalid");
3634            });
3635
3636            let client = create_test_client(&server);
3637            let result = client.get_issues(IssueFilter::default()).await;
3638
3639            assert!(result.is_err());
3640            let err = result.unwrap_err();
3641            assert!(matches!(err, Error::Unauthorized(_)));
3642        }
3643
3644        #[tokio::test]
3645        async fn test_handle_response_404() {
3646            let server = MockServer::start();
3647
3648            server.mock(|when, then| {
3649                when.method(GET).path("/task/nonexistent");
3650                then.status(404).body("Task not found");
3651            });
3652
3653            let client = create_test_client(&server);
3654            let result = client.get_issue("CU-nonexistent").await;
3655
3656            assert!(result.is_err());
3657            let err = result.unwrap_err();
3658            assert!(matches!(err, Error::NotFound(_)));
3659        }
3660
3661        #[tokio::test]
3662        async fn test_handle_response_500() {
3663            let server = MockServer::start();
3664
3665            server.mock(|when, then| {
3666                when.method(GET).path("/list/12345/task");
3667                then.status(500).body("Internal Server Error");
3668            });
3669
3670            let client = create_test_client(&server);
3671            let result = client.get_issues(IssueFilter::default()).await;
3672
3673            assert!(result.is_err());
3674            let err = result.unwrap_err();
3675            assert!(matches!(err, Error::ServerError { .. }));
3676        }
3677
3678        #[tokio::test]
3679        async fn test_mr_methods_unsupported() {
3680            let client = ClickUpClient::new("12345", token("token"));
3681
3682            let result = client.get_merge_requests(MrFilter::default()).await;
3683            assert!(matches!(
3684                result.unwrap_err(),
3685                Error::ProviderUnsupported { .. }
3686            ));
3687
3688            let result = client.get_merge_request("mr#1").await;
3689            assert!(matches!(
3690                result.unwrap_err(),
3691                Error::ProviderUnsupported { .. }
3692            ));
3693
3694            let result = client.get_discussions("mr#1").await;
3695            assert!(matches!(
3696                result.unwrap_err(),
3697                Error::ProviderUnsupported { .. }
3698            ));
3699
3700            let result = client.get_diffs("mr#1").await;
3701            assert!(matches!(
3702                result.unwrap_err(),
3703                Error::ProviderUnsupported { .. }
3704            ));
3705
3706            let result = MergeRequestProvider::add_comment(
3707                &client,
3708                "mr#1",
3709                CreateCommentInput {
3710                    body: "test".to_string(),
3711                    position: None,
3712                    discussion_id: None,
3713                },
3714            )
3715            .await;
3716            assert!(matches!(
3717                result.unwrap_err(),
3718                Error::ProviderUnsupported { .. }
3719            ));
3720        }
3721
3722        #[tokio::test]
3723        async fn test_get_current_user() {
3724            let server = MockServer::start();
3725
3726            server.mock(|when, then| {
3727                when.method(GET).path("/list/12345/task");
3728                then.status(200).json_body(serde_json::json!({"tasks": []}));
3729            });
3730
3731            let client = create_test_client(&server);
3732            let user = client.get_current_user().await.unwrap();
3733
3734            assert_eq!(user.username, "clickup-user");
3735        }
3736
3737        #[tokio::test]
3738        async fn test_get_current_user_auth_failure() {
3739            let server = MockServer::start();
3740
3741            server.mock(|when, then| {
3742                when.method(GET).path("/list/12345/task");
3743                then.status(401).body("Unauthorized");
3744            });
3745
3746            let client = create_test_client(&server);
3747            let result = client.get_current_user().await;
3748
3749            assert!(result.is_err());
3750            assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
3751        }
3752
3753        #[tokio::test]
3754        async fn test_get_issue_includes_subtasks() {
3755            let server = MockServer::start();
3756
3757            let task_with_subtasks = serde_json::json!({
3758                "id": "epic1",
3759                "custom_id": "DEV-400",
3760                "name": "Epic Task",
3761                "status": {"status": "open", "type": "open"},
3762                "tags": [{"name": "epic"}],
3763                "assignees": [],
3764                "creator": {"id": 1, "username": "author"},
3765                "url": "https://app.clickup.com/t/epic1",
3766                "date_created": "1704067200000",
3767                "date_updated": "1704153600000",
3768                "subtasks": [
3769                    {
3770                        "id": "sub1",
3771                        "custom_id": "DEV-401",
3772                        "name": "Subtask 1",
3773                        "status": {"status": "open", "type": "open"},
3774                        "tags": [],
3775                        "assignees": [],
3776                        "url": "https://app.clickup.com/t/sub1",
3777                        "parent": "epic1"
3778                    },
3779                    {
3780                        "id": "sub2",
3781                        "custom_id": "DEV-402",
3782                        "name": "Subtask 2",
3783                        "status": {"status": "closed", "type": "closed"},
3784                        "tags": [],
3785                        "assignees": [],
3786                        "url": "https://app.clickup.com/t/sub2",
3787                        "parent": "epic1"
3788                    }
3789                ]
3790            });
3791
3792            server.mock(|when, then| {
3793                when.method(GET)
3794                    .path("/task/epic1")
3795                    .query_param("include_subtasks", "true");
3796                then.status(200).json_body(task_with_subtasks);
3797            });
3798
3799            let client = create_test_client(&server);
3800            let issue = client.get_issue("CU-epic1").await.unwrap();
3801
3802            assert_eq!(issue.key, "DEV-400");
3803            assert!(issue.parent.is_none());
3804            assert_eq!(issue.subtasks.len(), 2);
3805            assert_eq!(issue.subtasks[0].key, "DEV-401");
3806            assert_eq!(issue.subtasks[0].title, "Subtask 1");
3807            assert_eq!(issue.subtasks[0].state, "open");
3808            assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
3809            assert_eq!(issue.subtasks[1].key, "DEV-402");
3810            assert_eq!(issue.subtasks[1].state, "closed");
3811        }
3812
3813        #[tokio::test]
3814        async fn test_get_issue_no_subtasks() {
3815            let server = MockServer::start();
3816
3817            let task = sample_task_json();
3818
3819            server.mock(|when, then| {
3820                when.method(GET)
3821                    .path("/task/abc123")
3822                    .query_param("include_subtasks", "true");
3823                then.status(200).json_body(task);
3824            });
3825
3826            let client = create_test_client(&server);
3827            let issue = client.get_issue("CU-abc123").await.unwrap();
3828
3829            assert!(issue.subtasks.is_empty());
3830            assert!(issue.parent.is_none());
3831        }
3832
3833        #[tokio::test]
3834        async fn test_get_issue_custom_id_includes_subtasks() {
3835            let server = MockServer::start();
3836
3837            let task = serde_json::json!({
3838                "id": "task1",
3839                "custom_id": "DEV-500",
3840                "name": "Task via custom ID",
3841                "status": {"status": "open", "type": "open"},
3842                "tags": [],
3843                "assignees": [],
3844                "url": "https://app.clickup.com/t/task1",
3845                "parent": "parent123",
3846                "subtasks": []
3847            });
3848
3849            server.mock(|when, then| {
3850                when.method(GET)
3851                    .path("/task/DEV-500")
3852                    .query_param("custom_task_ids", "true")
3853                    .query_param("team_id", "9876")
3854                    .query_param("include_subtasks", "true");
3855                then.status(200).json_body(task);
3856            });
3857
3858            let client = create_test_client_with_team(&server);
3859            let issue = client.get_issue("DEV-500").await.unwrap();
3860
3861            assert_eq!(issue.key, "DEV-500");
3862            assert_eq!(issue.parent, Some("CU-parent123".to_string()));
3863            assert!(issue.subtasks.is_empty());
3864        }
3865
3866        #[tokio::test]
3867        async fn test_update_issue_with_parent_id() {
3868            let server = MockServer::start();
3869
3870            // Mock: resolve parent task by custom ID
3871            let parent_task = serde_json::json!({
3872                "id": "parent_native_id",
3873                "custom_id": "DEV-600",
3874                "name": "Parent Epic",
3875                "status": {"status": "open", "type": "open"},
3876                "tags": [],
3877                "assignees": [],
3878                "url": "https://app.clickup.com/t/parent_native_id"
3879            });
3880
3881            server.mock(|when, then| {
3882                when.method(GET)
3883                    .path("/task/DEV-600")
3884                    .query_param("custom_task_ids", "true")
3885                    .query_param("team_id", "9876");
3886                then.status(200).json_body(parent_task);
3887            });
3888
3889            // Mock: update task with parent
3890            let updated_task = serde_json::json!({
3891                "id": "child1",
3892                "custom_id": "DEV-601",
3893                "name": "Child Task",
3894                "status": {"status": "open", "type": "open"},
3895                "tags": [],
3896                "assignees": [],
3897                "url": "https://app.clickup.com/t/child1",
3898                "parent": "parent_native_id"
3899            });
3900
3901            server.mock(|when, then| {
3902                when.method(PUT)
3903                    .path("/task/DEV-601")
3904                    .query_param("custom_task_ids", "true")
3905                    .query_param("team_id", "9876")
3906                    .body_includes("\"parent\":\"parent_native_id\"");
3907                then.status(200).json_body(updated_task.clone());
3908            });
3909
3910            server.mock(|when, then| {
3911                when.method(GET)
3912                    .path("/task/DEV-601")
3913                    .query_param("custom_task_ids", "true")
3914                    .query_param("team_id", "9876");
3915                then.status(200).json_body(updated_task);
3916            });
3917
3918            let client = create_test_client_with_team(&server);
3919            let issue = client
3920                .update_issue(
3921                    "DEV-601",
3922                    UpdateIssueInput {
3923                        parent_id: Some("DEV-600".to_string()),
3924                        ..Default::default()
3925                    },
3926                )
3927                .await
3928                .unwrap();
3929
3930            assert_eq!(issue.key, "DEV-601");
3931            assert_eq!(issue.parent, Some("CU-parent_native_id".to_string()));
3932        }
3933
3934        #[tokio::test]
3935        async fn test_create_issue_with_parent() {
3936            let server = MockServer::start();
3937
3938            // Mock: resolve parent task
3939            let parent_task = serde_json::json!({
3940                "id": "parent_id",
3941                "custom_id": "DEV-700",
3942                "name": "Parent",
3943                "status": {"status": "open", "type": "open"},
3944                "tags": [],
3945                "assignees": [],
3946                "url": "https://app.clickup.com/t/parent_id"
3947            });
3948
3949            server.mock(|when, then| {
3950                when.method(GET)
3951                    .path("/task/DEV-700")
3952                    .query_param("custom_task_ids", "true")
3953                    .query_param("team_id", "9876");
3954                then.status(200).json_body(parent_task);
3955            });
3956
3957            // Mock: create task with parent
3958            let created_task = serde_json::json!({
3959                "id": "new_child",
3960                "custom_id": "DEV-701",
3961                "name": "New Subtask",
3962                "status": {"status": "open", "type": "open"},
3963                "tags": [],
3964                "assignees": [],
3965                "url": "https://app.clickup.com/t/new_child",
3966                "parent": "parent_id"
3967            });
3968
3969            server.mock(|when, then| {
3970                when.method(POST)
3971                    .path("/list/12345/task")
3972                    .body_includes("\"parent\":\"parent_id\"");
3973                then.status(200).json_body(created_task);
3974            });
3975
3976            let client = create_test_client_with_team(&server);
3977            let issue = client
3978                .create_issue(CreateIssueInput {
3979                    title: "New Subtask".to_string(),
3980                    parent: Some("DEV-700".to_string()),
3981                    ..Default::default()
3982                })
3983                .await
3984                .unwrap();
3985
3986            assert_eq!(issue.key, "DEV-701");
3987            assert_eq!(issue.parent, Some("CU-parent_id".to_string()));
3988        }
3989
3990        #[tokio::test]
3991        async fn test_get_issues_search_filter() {
3992            let server = MockServer::start_async().await;
3993
3994            server.mock(|when, then| {
3995                when.method(GET).path("/list/12345/task");
3996                then.status(200).json_body(serde_json::json!({
3997                    "tasks": [
3998                        {
3999                            "id": "1", "name": "Fix login bug",
4000                            "description": "Authentication fails",
4001                            "text_content": "Authentication fails",
4002                            "status": {"status": "open", "type": "open"},
4003                            "tags": [], "assignees": [],
4004                            "url": "https://app.clickup.com/t/1"
4005                        },
4006                        {
4007                            "id": "2", "name": "Add dark mode",
4008                            "description": "Theme support",
4009                            "text_content": "Theme support",
4010                            "status": {"status": "open", "type": "open"},
4011                            "tags": [], "assignees": [],
4012                            "url": "https://app.clickup.com/t/2"
4013                        },
4014                        {
4015                            "id": "3", "name": "Update docs",
4016                            "description": "Fix login instructions",
4017                            "text_content": "Fix login instructions",
4018                            "status": {"status": "open", "type": "open"},
4019                            "tags": [], "assignees": [],
4020                            "url": "https://app.clickup.com/t/3"
4021                        }
4022                    ]
4023                }));
4024            });
4025
4026            let client = create_test_client(&server);
4027
4028            // Search by title
4029            let issues = client
4030                .get_issues(IssueFilter {
4031                    search: Some("login".to_string()),
4032                    ..Default::default()
4033                })
4034                .await
4035                .unwrap()
4036                .items;
4037            assert_eq!(issues.len(), 2);
4038            assert!(issues.iter().any(|i| i.title == "Fix login bug"));
4039            assert!(issues.iter().any(|i| i.title == "Update docs")); // matches description
4040
4041            // Search by key
4042            let issues = client
4043                .get_issues(IssueFilter {
4044                    search: Some("CU-2".to_string()),
4045                    ..Default::default()
4046                })
4047                .await
4048                .unwrap()
4049                .items;
4050            assert_eq!(issues.len(), 1);
4051            assert_eq!(issues[0].title, "Add dark mode");
4052
4053            // Search — no matches
4054            let issues = client
4055                .get_issues(IssueFilter {
4056                    search: Some("nonexistent".to_string()),
4057                    ..Default::default()
4058                })
4059                .await
4060                .unwrap()
4061                .items;
4062            assert!(issues.is_empty());
4063        }
4064
4065        #[tokio::test]
4066        async fn test_get_issues_sort_by_priority() {
4067            let server = MockServer::start_async().await;
4068
4069            server.mock(|when, then| {
4070                when.method(GET).path("/list/12345/task");
4071                then.status(200).json_body(serde_json::json!({
4072                    "tasks": [
4073                        {
4074                            "id": "1", "name": "Low task",
4075                            "status": {"status": "open", "type": "open"},
4076                            "priority": {"id": "4", "priority": "low"},
4077                            "tags": [], "assignees": [],
4078                            "url": "https://app.clickup.com/t/1"
4079                        },
4080                        {
4081                            "id": "2", "name": "Urgent task",
4082                            "status": {"status": "open", "type": "open"},
4083                            "priority": {"id": "1", "priority": "urgent"},
4084                            "tags": [], "assignees": [],
4085                            "url": "https://app.clickup.com/t/2"
4086                        },
4087                        {
4088                            "id": "3", "name": "Normal task",
4089                            "status": {"status": "open", "type": "open"},
4090                            "priority": {"id": "3", "priority": "normal"},
4091                            "tags": [], "assignees": [],
4092                            "url": "https://app.clickup.com/t/3"
4093                        }
4094                    ]
4095                }));
4096            });
4097
4098            let client = create_test_client(&server);
4099
4100            // Sort by priority descending (most urgent first)
4101            let result = client
4102                .get_issues(IssueFilter {
4103                    sort_by: Some("priority".to_string()),
4104                    sort_order: Some("asc".to_string()),
4105                    ..Default::default()
4106                })
4107                .await
4108                .unwrap();
4109            assert_eq!(result.items[0].priority, Some("urgent".to_string()));
4110            assert_eq!(result.items[1].priority, Some("normal".to_string()));
4111            assert_eq!(result.items[2].priority, Some("low".to_string()));
4112
4113            // Verify sort_info is populated
4114            let sort_info = result.sort_info.unwrap();
4115            assert_eq!(sort_info.sort_by, Some("priority".to_string()));
4116            assert!(sort_info.available_sorts.contains(&"priority".into()));
4117        }
4118
4119        #[tokio::test]
4120        async fn test_get_issues_sort_by_title() {
4121            let server = MockServer::start_async().await;
4122
4123            server.mock(|when, then| {
4124                when.method(GET).path("/list/12345/task");
4125                then.status(200).json_body(serde_json::json!({
4126                    "tasks": [
4127                        {
4128                            "id": "1", "name": "Charlie",
4129                            "status": {"status": "open", "type": "open"},
4130                            "tags": [], "assignees": [],
4131                            "url": "https://app.clickup.com/t/1"
4132                        },
4133                        {
4134                            "id": "2", "name": "Alpha",
4135                            "status": {"status": "open", "type": "open"},
4136                            "tags": [], "assignees": [],
4137                            "url": "https://app.clickup.com/t/2"
4138                        },
4139                        {
4140                            "id": "3", "name": "Bravo",
4141                            "status": {"status": "open", "type": "open"},
4142                            "tags": [], "assignees": [],
4143                            "url": "https://app.clickup.com/t/3"
4144                        }
4145                    ]
4146                }));
4147            });
4148
4149            let client = create_test_client(&server);
4150
4151            let result = client
4152                .get_issues(IssueFilter {
4153                    sort_by: Some("title".to_string()),
4154                    sort_order: Some("asc".to_string()),
4155                    ..Default::default()
4156                })
4157                .await
4158                .unwrap();
4159            assert_eq!(result.items[0].title, "Alpha");
4160            assert_eq!(result.items[1].title, "Bravo");
4161            assert_eq!(result.items[2].title, "Charlie");
4162        }
4163
4164        #[tokio::test]
4165        async fn test_get_statuses_category_mapping() {
4166            let server = MockServer::start_async().await;
4167
4168            server.mock(|when, then| {
4169                when.method(GET).path("/list/12345");
4170                then.status(200).json_body(serde_json::json!({
4171                    "statuses": [
4172                        {"status": "Backlog", "type": "custom", "color": "#aaa", "orderindex": 0},
4173                        {"status": "To Do", "type": "open", "color": "#bbb", "orderindex": 1},
4174                        {"status": "In Progress", "type": "custom", "color": "#ccc", "orderindex": 2},
4175                        {"status": "In Review", "type": "custom", "color": "#ddd", "orderindex": 3},
4176                        {"status": "Done", "type": "closed", "color": "#eee", "orderindex": 4},
4177                        {"status": "Cancelled", "type": "custom", "color": "#fff", "orderindex": 5},
4178                        {"status": "Archived", "type": "custom", "color": "#000", "orderindex": 6}
4179                    ]
4180                }));
4181            });
4182
4183            let client = create_test_client(&server);
4184            let statuses = client.get_statuses().await.unwrap().items;
4185
4186            assert_eq!(statuses.len(), 7);
4187            assert_eq!(statuses[0].name, "Backlog");
4188            assert_eq!(statuses[0].category, "backlog");
4189            assert_eq!(statuses[1].name, "To Do");
4190            assert_eq!(statuses[1].category, "todo");
4191            assert_eq!(statuses[2].name, "In Progress");
4192            assert_eq!(statuses[2].category, "in_progress");
4193            assert_eq!(statuses[3].name, "In Review");
4194            assert_eq!(statuses[3].category, "in_progress");
4195            assert_eq!(statuses[4].name, "Done");
4196            assert_eq!(statuses[4].category, "done");
4197            assert_eq!(statuses[5].name, "Cancelled");
4198            assert_eq!(statuses[5].category, "cancelled");
4199            assert_eq!(statuses[6].name, "Archived");
4200            assert_eq!(statuses[6].category, "cancelled");
4201        }
4202
4203        #[tokio::test]
4204        async fn test_get_issues_state_category_filter() {
4205            let server = MockServer::start_async().await;
4206
4207            // Mock for get_statuses (called by stateCategory filter)
4208            server.mock(|when, then| {
4209                when.method(GET).path("/list/12345").query_param_exists("!");
4210                then.status(200).json_body(serde_json::json!({
4211                    "statuses": [
4212                        {"status": "Backlog", "type": "custom"},
4213                        {"status": "To Do", "type": "open"},
4214                        {"status": "In Progress", "type": "custom"},
4215                        {"status": "Done", "type": "closed"}
4216                    ]
4217                }));
4218            });
4219
4220            // This exact path mock for list info (no query params)
4221            server.mock(|when, then| {
4222                when.method(GET).path("/list/12345");
4223                then.status(200).json_body(serde_json::json!({
4224                    "statuses": [
4225                        {"status": "Backlog", "type": "custom"},
4226                        {"status": "To Do", "type": "open"},
4227                        {"status": "In Progress", "type": "custom"},
4228                        {"status": "Done", "type": "closed"}
4229                    ]
4230                }));
4231            });
4232
4233            server.mock(|when, then| {
4234                when.method(GET).path("/list/12345/task");
4235                then.status(200).json_body(serde_json::json!({
4236                    "tasks": [
4237                        {
4238                            "id": "1", "name": "Backlog task",
4239                            "status": {"status": "Backlog", "type": "custom"},
4240                            "tags": [], "assignees": [],
4241                            "url": "https://app.clickup.com/t/1"
4242                        },
4243                        {
4244                            "id": "2", "name": "In progress task",
4245                            "status": {"status": "In Progress", "type": "custom"},
4246                            "tags": [], "assignees": [],
4247                            "url": "https://app.clickup.com/t/2"
4248                        },
4249                        {
4250                            "id": "3", "name": "Todo task",
4251                            "status": {"status": "To Do", "type": "open"},
4252                            "tags": [], "assignees": [],
4253                            "url": "https://app.clickup.com/t/3"
4254                        }
4255                    ]
4256                }));
4257            });
4258
4259            let client = create_test_client(&server);
4260
4261            // Filter by in_progress category
4262            let issues = client
4263                .get_issues(IssueFilter {
4264                    state_category: Some("in_progress".to_string()),
4265                    ..Default::default()
4266                })
4267                .await
4268                .unwrap()
4269                .items;
4270            assert_eq!(issues.len(), 1);
4271            assert_eq!(issues[0].title, "In progress task");
4272
4273            // Filter by backlog category
4274            let issues = client
4275                .get_issues(IssueFilter {
4276                    state_category: Some("backlog".to_string()),
4277                    ..Default::default()
4278                })
4279                .await
4280                .unwrap()
4281                .items;
4282            assert_eq!(issues.len(), 1);
4283            assert_eq!(issues[0].title, "Backlog task");
4284        }
4285
4286        #[tokio::test]
4287        async fn test_get_issue_attachments_maps_all_fields() {
4288            let server = MockServer::start();
4289
4290            let task_json = serde_json::json!({
4291                "id": "abc123",
4292                "name": "Test",
4293                "status": {"status": "open", "type": "open"},
4294                "tags": [], "assignees": [],
4295                "url": "https://app.clickup.com/t/abc123",
4296                "date_created": "1704067200000",
4297                "date_updated": "1704067200000",
4298                "attachments": [
4299                    {
4300                        "id": "att-1",
4301                        "title": "screen.png",
4302                        "url": "https://attachments.clickup.com/abc/screen.png",
4303                        "size": "12345",
4304                        "extension": "png",
4305                        "mimetype": "image/png",
4306                        "date": "1704067200000",
4307                        "user": {"id": 7, "username": "uploader"}
4308                    }
4309                ]
4310            });
4311
4312            server.mock(|when, then| {
4313                when.method(GET).path("/task/abc123");
4314                then.status(200).json_body(task_json);
4315            });
4316
4317            let client = create_test_client(&server);
4318            let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
4319            assert_eq!(assets.len(), 1);
4320            let a = &assets[0];
4321            assert_eq!(a.id, "att-1");
4322            assert_eq!(a.filename, "screen.png");
4323            assert_eq!(a.mime_type.as_deref(), Some("image/png"));
4324            assert_eq!(a.size, Some(12345));
4325            assert_eq!(a.author.as_deref(), Some("uploader"));
4326        }
4327
4328        #[tokio::test]
4329        async fn test_get_issue_attachments_empty_when_none() {
4330            let server = MockServer::start();
4331
4332            let task_json = serde_json::json!({
4333                "id": "abc123",
4334                "name": "Test",
4335                "status": {"status": "open", "type": "open"},
4336                "tags": [], "assignees": [],
4337                "url": "https://app.clickup.com/t/abc123",
4338                "date_created": "1704067200000",
4339                "date_updated": "1704067200000"
4340            });
4341
4342            server.mock(|when, then| {
4343                when.method(GET).path("/task/abc123");
4344                then.status(200).json_body(task_json);
4345            });
4346
4347            let client = create_test_client(&server);
4348            let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
4349            assert!(assets.is_empty());
4350        }
4351
4352        #[tokio::test]
4353        async fn test_download_attachment_fetches_bytes() {
4354            let server = MockServer::start();
4355
4356            let task_json = serde_json::json!({
4357                "id": "abc123",
4358                "name": "Test",
4359                "status": {"status": "open", "type": "open"},
4360                "tags": [], "assignees": [],
4361                "url": "https://app.clickup.com/t/abc123",
4362                "date_created": "1704067200000",
4363                "date_updated": "1704067200000",
4364                "attachments": [
4365                    {
4366                        "id": "att-1",
4367                        "title": "log.txt",
4368                        "url": format!("{}/download/att-1", server.base_url()),
4369                    }
4370                ]
4371            });
4372
4373            server.mock(|when, then| {
4374                when.method(GET).path("/task/abc123");
4375                then.status(200).json_body(task_json);
4376            });
4377            server.mock(|when, then| {
4378                when.method(GET).path("/download/att-1");
4379                then.status(200).body("hello world");
4380            });
4381
4382            let client = create_test_client(&server);
4383            let bytes = client
4384                .download_attachment("CU-abc123", "att-1")
4385                .await
4386                .unwrap();
4387            assert_eq!(bytes, b"hello world");
4388        }
4389
4390        #[tokio::test]
4391        async fn test_download_attachment_not_found() {
4392            let server = MockServer::start();
4393
4394            server.mock(|when, then| {
4395                when.method(GET).path("/task/abc123");
4396                then.status(200).json_body(serde_json::json!({
4397                    "id": "abc123", "name": "Test",
4398                    "status": {"status": "open", "type": "open"},
4399                    "tags": [], "assignees": [],
4400                    "url": "https://app.clickup.com/t/abc123",
4401                    "date_created": "1704067200000",
4402                    "date_updated": "1704067200000",
4403                    "attachments": []
4404                }));
4405            });
4406
4407            let client = create_test_client(&server);
4408            let err = client
4409                .download_attachment("CU-abc123", "missing")
4410                .await
4411                .unwrap_err();
4412            assert!(matches!(err, Error::NotFound(_)));
4413        }
4414
4415        // ===== Custom status support — regression tests for #288 =====
4416        //
4417        // Before this change the executor schema constrained the `state`
4418        // field to enum ["open", "closed"], so ClickUp custom statuses
4419        // like "in progress" / "review" were unreachable via the MCP
4420        // layer. The new `status` field forwards the literal status
4421        // name; it is validated against the list's configured statuses
4422        // (case-insensitive) and the canonical case from the list is
4423        // sent in the PUT body.
4424
4425        fn list_with_statuses() -> serde_json::Value {
4426            serde_json::json!({
4427                "statuses": [
4428                    {"status": "to do", "type": "open"},
4429                    {"status": "in progress", "type": "custom"},
4430                    {"status": "review", "type": "custom"},
4431                    {"status": "complete", "type": "closed"}
4432                ]
4433            })
4434        }
4435
4436        #[tokio::test]
4437        async fn test_update_issue_status_sets_custom_status() {
4438            let server = MockServer::start();
4439
4440            server.mock(|when, then| {
4441                when.method(GET).path("/list/12345");
4442                then.status(200).json_body(list_with_statuses());
4443            });
4444
4445            server.mock(|when, then| {
4446                when.method(PUT)
4447                    .path("/task/abc123")
4448                    .body_includes("\"status\":\"in progress\"");
4449                then.status(200).json_body(sample_task_json());
4450            });
4451
4452            server.mock(|when, then| {
4453                when.method(GET).path("/task/abc123");
4454                then.status(200).json_body(sample_task_json());
4455            });
4456
4457            let client = create_test_client(&server);
4458            let result = client
4459                .update_issue(
4460                    "CU-abc123",
4461                    UpdateIssueInput {
4462                        status: Some("in progress".to_string()),
4463                        ..Default::default()
4464                    },
4465                )
4466                .await;
4467            assert!(result.is_ok(), "got {:?}", result.err());
4468        }
4469
4470        #[tokio::test]
4471        async fn test_update_issue_status_case_insensitive_match() {
4472            let server = MockServer::start();
4473
4474            server.mock(|when, then| {
4475                when.method(GET).path("/list/12345");
4476                then.status(200).json_body(list_with_statuses());
4477            });
4478
4479            // Input "REVIEW", list has "review" → PUT body sends canonical "review".
4480            server.mock(|when, then| {
4481                when.method(PUT)
4482                    .path("/task/abc123")
4483                    .body_includes("\"status\":\"review\"");
4484                then.status(200).json_body(sample_task_json());
4485            });
4486
4487            server.mock(|when, then| {
4488                when.method(GET).path("/task/abc123");
4489                then.status(200).json_body(sample_task_json());
4490            });
4491
4492            let client = create_test_client(&server);
4493            let result = client
4494                .update_issue(
4495                    "CU-abc123",
4496                    UpdateIssueInput {
4497                        status: Some("REVIEW".to_string()),
4498                        ..Default::default()
4499                    },
4500                )
4501                .await;
4502            assert!(result.is_ok(), "got {:?}", result.err());
4503        }
4504
4505        #[tokio::test]
4506        async fn test_update_issue_status_unknown_fails_with_valid_list() {
4507            let server = MockServer::start();
4508
4509            server.mock(|when, then| {
4510                when.method(GET).path("/list/12345");
4511                then.status(200).json_body(list_with_statuses());
4512            });
4513
4514            let put_mock = server.mock(|when, then| {
4515                when.method(PUT).path("/task/abc123");
4516                then.status(200).json_body(sample_task_json());
4517            });
4518
4519            let client = create_test_client(&server);
4520            let err = client
4521                .update_issue(
4522                    "CU-abc123",
4523                    UpdateIssueInput {
4524                        status: Some("released-to-prod".to_string()),
4525                        ..Default::default()
4526                    },
4527                )
4528                .await
4529                .expect_err("unknown status must fail before PUT");
4530            let msg = format!("{err:?}");
4531            assert!(msg.contains("Unknown ClickUp status"), "msg: {msg}");
4532            assert!(msg.contains("released-to-prod"), "msg: {msg}");
4533            assert!(msg.contains("in progress"), "list of valids missing: {msg}");
4534            put_mock.assert_calls(0);
4535        }
4536
4537        #[tokio::test]
4538        async fn test_update_issue_status_overrides_state_when_both_set() {
4539            let server = MockServer::start();
4540
4541            // List endpoint is only hit by validate_status_name (for
4542            // `status`) — NOT by resolve_status (`state`); pin that with
4543            // a single mock that's allowed to be called once.
4544            let list_mock = server.mock(|when, then| {
4545                when.method(GET).path("/list/12345");
4546                then.status(200).json_body(list_with_statuses());
4547            });
4548
4549            server.mock(|when, then| {
4550                when.method(PUT)
4551                    .path("/task/abc123")
4552                    // status wins → "in progress", not "to do" (open) /
4553                    // "complete" (closed).
4554                    .body_includes("\"status\":\"in progress\"");
4555                then.status(200).json_body(sample_task_json());
4556            });
4557
4558            server.mock(|when, then| {
4559                when.method(GET).path("/task/abc123");
4560                then.status(200).json_body(sample_task_json());
4561            });
4562
4563            let client = create_test_client(&server);
4564            let result = client
4565                .update_issue(
4566                    "CU-abc123",
4567                    UpdateIssueInput {
4568                        state: Some("closed".to_string()),
4569                        status: Some("in progress".to_string()),
4570                        ..Default::default()
4571                    },
4572                )
4573                .await;
4574            assert!(result.is_ok(), "got {:?}", result.err());
4575            list_mock.assert_calls(1);
4576        }
4577
4578        #[tokio::test]
4579        async fn test_update_issue_state_path_unchanged_when_status_absent() {
4580            // Regression guard: the legacy `state: "closed"` path must
4581            // keep working through resolve_status (no behavior change
4582            // for callers that never set `status`).
4583            let server = MockServer::start();
4584
4585            server.mock(|when, then| {
4586                when.method(GET).path("/list/12345");
4587                then.status(200).json_body(list_with_statuses());
4588            });
4589
4590            server.mock(|when, then| {
4591                when.method(PUT)
4592                    .path("/task/abc123")
4593                    .body_includes("\"status\":\"complete\"");
4594                then.status(200).json_body(sample_task_json());
4595            });
4596
4597            server.mock(|when, then| {
4598                when.method(GET).path("/task/abc123");
4599                then.status(200).json_body(sample_task_json());
4600            });
4601
4602            let client = create_test_client(&server);
4603            let result = client
4604                .update_issue(
4605                    "CU-abc123",
4606                    UpdateIssueInput {
4607                        state: Some("closed".to_string()),
4608                        ..Default::default()
4609                    },
4610                )
4611                .await;
4612            assert!(result.is_ok(), "got {:?}", result.err());
4613        }
4614
4615        #[tokio::test]
4616        async fn test_update_issue_status_open_keyword_hints_at_state_field() {
4617            // Regression for review feedback on #290: when a caller
4618            // accidentally passes "open" / "closed" via `status` instead
4619            // of `state`, the error must point them at the right field
4620            // instead of just listing valid custom statuses.
4621            let server = MockServer::start();
4622
4623            server.mock(|when, then| {
4624                when.method(GET).path("/list/12345");
4625                then.status(200).json_body(list_with_statuses());
4626            });
4627
4628            let put_mock = server.mock(|when, then| {
4629                when.method(PUT).path("/task/abc123");
4630                then.status(200).json_body(sample_task_json());
4631            });
4632
4633            let client = create_test_client(&server);
4634            for keyword in ["open", "Opened", "CLOSED"] {
4635                let err = client
4636                    .update_issue(
4637                        "CU-abc123",
4638                        UpdateIssueInput {
4639                            status: Some(keyword.to_string()),
4640                            ..Default::default()
4641                        },
4642                    )
4643                    .await
4644                    .expect_err("open/closed via `status` must fail");
4645                let msg = format!("{err:?}");
4646                assert!(msg.contains("state` field"), "keyword={keyword} msg={msg}");
4647            }
4648            put_mock.assert_calls(0);
4649        }
4650
4651        #[tokio::test]
4652        async fn test_update_issue_status_unknown_truncates_large_status_list() {
4653            // Some workspaces have 50+ statuses; the error message must
4654            // not dump all of them verbatim. Truncates at 10 with
4655            // "…and N more" suffix.
4656            let server = MockServer::start();
4657
4658            let many_statuses: Vec<serde_json::Value> = (0..15)
4659                .map(|i| serde_json::json!({"status": format!("status-{i:02}"), "type": "custom"}))
4660                .collect();
4661
4662            server.mock(|when, then| {
4663                when.method(GET).path("/list/12345");
4664                then.status(200)
4665                    .json_body(serde_json::json!({"statuses": many_statuses}));
4666            });
4667
4668            let client = create_test_client(&server);
4669            let err = client
4670                .update_issue(
4671                    "CU-abc123",
4672                    UpdateIssueInput {
4673                        status: Some("nonexistent".to_string()),
4674                        ..Default::default()
4675                    },
4676                )
4677                .await
4678                .expect_err("must fail");
4679            let msg = format!("{err:?}");
4680            assert!(msg.contains("status-00"), "preview missing first: {msg}");
4681            assert!(msg.contains("status-09"), "preview missing 10th: {msg}");
4682            assert!(
4683                !msg.contains("status-10"),
4684                "11th status leaked past truncation: {msg}"
4685            );
4686            assert!(
4687                msg.contains("…and 5 more"),
4688                "truncation suffix missing: {msg}"
4689            );
4690        }
4691
4692        // ===== Assignees on PUT /task — regression tests for #287 =====
4693        //
4694        // Prior to the fix, `UpdateTaskRequest` did not include an
4695        // `assignees` field at all, so any `assignees` value passed via
4696        // `UpdateIssueInput` was silently dropped — ClickUp returned
4697        // 200 with no actual change. The tests below pin the PUT body
4698        // shape ClickUp actually expects (`{add: [u64], rem: [u64]}`)
4699        // and the diff computation.
4700
4701        fn team_payload(members: serde_json::Value) -> serde_json::Value {
4702            serde_json::json!({
4703                "teams": [
4704                    {
4705                        "id": "9876",
4706                        "members": members,
4707                    }
4708                ]
4709            })
4710        }
4711
4712        #[tokio::test]
4713        async fn test_update_issue_assignees_resolves_email_and_sends_diff() {
4714            let server = MockServer::start();
4715
4716            // Workspace lookup — used to resolve `m.kitaev@meteora.pro`.
4717            server.mock(|when, then| {
4718                when.method(GET).path("/team");
4719                then.status(200).json_body(team_payload(serde_json::json!([
4720                    {"user": {"id": 94519669, "username": "m.kitaev",
4721                              "email": "m.kitaev@meteora.pro"}},
4722                    {"user": {"id": 11111, "username": "other",
4723                              "email": "other@meteora.pro"}}
4724                ])));
4725            });
4726
4727            // Current task — bot has no assignees, will add user 94519669.
4728            server.mock(|when, then| {
4729                when.method(GET).path("/task/abc123");
4730                then.status(200).json_body(sample_task_no_assignees_json());
4731            });
4732
4733            // PUT body must contain `assignees: {add: [94519669]}`.
4734            server.mock(|when, then| {
4735                when.method(PUT)
4736                    .path("/task/abc123")
4737                    .body_includes("\"assignees\":{\"add\":[94519669]")
4738                    .body_excludes("\"rem\":");
4739                then.status(200).json_body(sample_task_no_assignees_json());
4740            });
4741
4742            let client = create_test_client_with_team(&server);
4743            let result = client
4744                .update_issue(
4745                    "CU-abc123",
4746                    UpdateIssueInput {
4747                        assignees: Some(vec!["m.kitaev@meteora.pro".to_string()]),
4748                        ..Default::default()
4749                    },
4750                )
4751                .await;
4752            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4753        }
4754
4755        #[tokio::test]
4756        async fn test_update_issue_assignees_accepts_numeric_id_without_lookup() {
4757            let server = MockServer::start();
4758
4759            // No /team mock — must NOT be hit for numeric inputs.
4760            server.mock(|when, then| {
4761                when.method(GET).path("/task/abc123");
4762                then.status(200).json_body(sample_task_no_assignees_json());
4763            });
4764
4765            server.mock(|when, then| {
4766                when.method(PUT)
4767                    .path("/task/abc123")
4768                    .body_includes("\"assignees\":{\"add\":[42]");
4769                then.status(200).json_body(sample_task_no_assignees_json());
4770            });
4771
4772            let client = create_test_client(&server);
4773            let result = client
4774                .update_issue(
4775                    "CU-abc123",
4776                    UpdateIssueInput {
4777                        assignees: Some(vec!["42".to_string()]),
4778                        ..Default::default()
4779                    },
4780                )
4781                .await;
4782            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4783        }
4784
4785        #[tokio::test]
4786        async fn test_update_issue_assignees_diff_add_and_rem() {
4787            let server = MockServer::start();
4788
4789            // Current = [1, 2]; new = [2, 3] → add=[3], rem=[1].
4790            server.mock(|when, then| {
4791                when.method(GET).path("/task/abc123");
4792                then.status(200).json_body(serde_json::json!({
4793                    "id": "abc123", "name": "T",
4794                    "status": {"status": "open", "type": "open"},
4795                    "tags": [],
4796                    "assignees": [
4797                        {"id": 1, "username": "u1"},
4798                        {"id": 2, "username": "u2"}
4799                    ],
4800                    "url": "https://app.clickup.com/t/abc123",
4801                    "date_created": "1704067200000",
4802                    "date_updated": "1704067200000"
4803                }));
4804            });
4805
4806            server.mock(|when, then| {
4807                when.method(PUT)
4808                    .path("/task/abc123")
4809                    .body_includes("\"add\":[3]")
4810                    .body_includes("\"rem\":[1]");
4811                then.status(200).json_body(sample_task_no_assignees_json());
4812            });
4813
4814            let client = create_test_client(&server);
4815            let result = client
4816                .update_issue(
4817                    "CU-abc123",
4818                    UpdateIssueInput {
4819                        assignees: Some(vec!["2".to_string(), "3".to_string()]),
4820                        ..Default::default()
4821                    },
4822                )
4823                .await;
4824            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4825        }
4826
4827        #[tokio::test]
4828        async fn test_update_issue_assignees_empty_input_clears_all() {
4829            let server = MockServer::start();
4830
4831            // Current has [1, 2]; new = [] → add=[], rem=[1, 2].
4832            server.mock(|when, then| {
4833                when.method(GET).path("/task/abc123");
4834                then.status(200).json_body(serde_json::json!({
4835                    "id": "abc123", "name": "T",
4836                    "status": {"status": "open", "type": "open"},
4837                    "tags": [],
4838                    "assignees": [
4839                        {"id": 1, "username": "u1"},
4840                        {"id": 2, "username": "u2"}
4841                    ],
4842                    "url": "https://app.clickup.com/t/abc123",
4843                    "date_created": "1704067200000",
4844                    "date_updated": "1704067200000"
4845                }));
4846            });
4847
4848            server.mock(|when, then| {
4849                when.method(PUT)
4850                    .path("/task/abc123")
4851                    .body_includes("\"rem\":[1,2]")
4852                    .body_excludes("\"add\":");
4853                then.status(200).json_body(sample_task_no_assignees_json());
4854            });
4855
4856            let client = create_test_client(&server);
4857            let result = client
4858                .update_issue(
4859                    "CU-abc123",
4860                    UpdateIssueInput {
4861                        assignees: Some(vec![]),
4862                        ..Default::default()
4863                    },
4864                )
4865                .await;
4866            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4867        }
4868
4869        #[tokio::test]
4870        async fn test_update_issue_assignees_none_leaves_field_untouched() {
4871            let server = MockServer::start();
4872
4873            // PUT body must NOT contain "assignees" — None means leave alone.
4874            // Re-fetch GET still happens.
4875            server.mock(|when, then| {
4876                when.method(PUT)
4877                    .path("/task/abc123")
4878                    .body_excludes("\"assignees\"");
4879                then.status(200).json_body(sample_task_no_assignees_json());
4880            });
4881
4882            server.mock(|when, then| {
4883                when.method(GET).path("/task/abc123");
4884                then.status(200).json_body(sample_task_no_assignees_json());
4885            });
4886
4887            let client = create_test_client(&server);
4888            let result = client
4889                .update_issue(
4890                    "CU-abc123",
4891                    UpdateIssueInput {
4892                        title: Some("renamed".to_string()),
4893                        // assignees: None
4894                        ..Default::default()
4895                    },
4896                )
4897                .await;
4898            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4899        }
4900
4901        #[tokio::test]
4902        async fn test_update_issue_assignees_unknown_email_fails_clearly() {
4903            let server = MockServer::start();
4904
4905            server.mock(|when, then| {
4906                when.method(GET).path("/team");
4907                then.status(200).json_body(team_payload(serde_json::json!([
4908                    {"user": {"id": 1, "username": "u1", "email": "u1@x.com"}}
4909                ])));
4910            });
4911
4912            // PUT must NOT be called — resolution should fail first.
4913            let put_mock = server.mock(|when, then| {
4914                when.method(PUT).path("/task/abc123");
4915                then.status(200).json_body(sample_task_no_assignees_json());
4916            });
4917
4918            let client = create_test_client(&server);
4919            let err = client
4920                .update_issue(
4921                    "CU-abc123",
4922                    UpdateIssueInput {
4923                        assignees: Some(vec!["nobody@nowhere.com".to_string()]),
4924                        ..Default::default()
4925                    },
4926                )
4927                .await
4928                .expect_err("unresolvable email must fail");
4929            assert!(
4930                format!("{err:?}").contains("Cannot resolve assignee"),
4931                "unexpected error: {err:?}"
4932            );
4933            put_mock.assert_calls(0);
4934        }
4935
4936        #[tokio::test]
4937        async fn test_update_issue_assignees_no_change_omits_field() {
4938            let server = MockServer::start();
4939
4940            // Current=[1]; new=[1] → diff empty → assignees field omitted.
4941            server.mock(|when, then| {
4942                when.method(GET).path("/task/abc123");
4943                then.status(200).json_body(serde_json::json!({
4944                    "id": "abc123", "name": "T",
4945                    "status": {"status": "open", "type": "open"},
4946                    "tags": [],
4947                    "assignees": [{"id": 1, "username": "u1"}],
4948                    "url": "https://app.clickup.com/t/abc123",
4949                    "date_created": "1704067200000",
4950                    "date_updated": "1704067200000"
4951                }));
4952            });
4953
4954            server.mock(|when, then| {
4955                when.method(PUT)
4956                    .path("/task/abc123")
4957                    .body_excludes("\"assignees\"");
4958                then.status(200).json_body(sample_task_no_assignees_json());
4959            });
4960
4961            let client = create_test_client(&server);
4962            let result = client
4963                .update_issue(
4964                    "CU-abc123",
4965                    UpdateIssueInput {
4966                        assignees: Some(vec!["1".to_string()]),
4967                        ..Default::default()
4968                    },
4969                )
4970                .await;
4971            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
4972        }
4973
4974        #[tokio::test]
4975        async fn test_create_issue_assignees_resolves_and_sends_flat_array() {
4976            let server = MockServer::start();
4977
4978            // Lookup by email.
4979            server.mock(|when, then| {
4980                when.method(GET).path("/team");
4981                then.status(200).json_body(team_payload(serde_json::json!([
4982                    {"user": {"id": 555, "username": "u",
4983                              "email": "u@example.com"}}
4984                ])));
4985            });
4986
4987            // POST body must contain `assignees: [555]` (flat, no diff).
4988            server.mock(|when, then| {
4989                when.method(POST)
4990                    .path("/list/12345/task")
4991                    .body_includes("\"assignees\":[555]");
4992                then.status(200).json_body(sample_task_json());
4993            });
4994
4995            let client = create_test_client(&server);
4996            let result = client
4997                .create_issue(CreateIssueInput {
4998                    title: "New".to_string(),
4999                    assignees: vec!["u@example.com".to_string()],
5000                    ..Default::default()
5001                })
5002                .await;
5003            assert!(result.is_ok(), "expected ok, got {:?}", result.err());
5004        }
5005
5006        fn sample_task_no_assignees_json() -> serde_json::Value {
5007            serde_json::json!({
5008                "id": "abc123", "name": "Test Task",
5009                "status": {"status": "open", "type": "open"},
5010                "tags": [], "assignees": [],
5011                "url": "https://app.clickup.com/t/abc123",
5012                "date_created": "1704067200000",
5013                "date_updated": "1704067200000"
5014            })
5015        }
5016    }
5017}