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