Skip to main content

devboy_yougile/
client.rs

1//! YouGile API client.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use devboy_core::{
9    Comment, CreateIssueInput, CustomFieldValue, Error, Issue, IssueFilter, IssueProvider,
10    MergeRequestProvider, Pagination, PipelineProvider, Provider, ProviderResult, Result, SortInfo,
11    SortOrder, UpdateIssueInput, User,
12};
13use reqwest::{Response, StatusCode};
14use secrecy::{ExposeSecret, SecretString};
15use serde::{Serialize, de::DeserializeOwned};
16use serde_json::{Value, json};
17use tokio::sync::Mutex;
18use tokio::time::{Instant, sleep_until};
19use tracing::warn;
20
21use crate::DEFAULT_YOUGILE_URL;
22use crate::types::{
23    YouGileChatMessage, YouGileColumn, YouGileListResponse, YouGilePaging, YouGileTask, YouGileUser,
24};
25
26/// YouGile client used by the workspace provider layer.
27#[derive(Clone)]
28pub struct YouGileClient {
29    base_url: String,
30    board_id: String,
31    token: SecretString,
32    client: reqwest::Client,
33    rate_limiter: Arc<YouGileRateLimiter>,
34    user_cache: Arc<Mutex<HashMap<String, User>>>,
35}
36
37const YOUGILE_REQUEST_INTERVAL: Duration = Duration::from_millis(1200);
38
39impl YouGileClient {
40    /// Create a new YouGile client with the default API base URL.
41    pub fn new(board_id: impl Into<String>, token: SecretString) -> Self {
42        Self::with_base_url(DEFAULT_YOUGILE_URL, board_id, token)
43    }
44
45    /// Create a new YouGile client with a custom base URL.
46    pub fn with_base_url(
47        base_url: impl Into<String>,
48        board_id: impl Into<String>,
49        token: SecretString,
50    ) -> Self {
51        Self {
52            base_url: base_url.into().trim_end_matches('/').to_string(),
53            board_id: board_id.into(),
54            token,
55            client: reqwest::Client::builder()
56                .user_agent("devboy-tools")
57                .build()
58                .expect("Failed to create HTTP client"),
59            rate_limiter: Arc::new(YouGileRateLimiter::new(YOUGILE_REQUEST_INTERVAL)),
60            user_cache: Arc::new(Mutex::new(HashMap::new())),
61        }
62    }
63
64    /// Effective YouGile API base URL.
65    pub fn base_url(&self) -> &str {
66        &self.base_url
67    }
68
69    /// Default board scope attached to this client.
70    pub fn board_id(&self) -> &str {
71        &self.board_id
72    }
73
74    /// Shared request client.
75    pub fn http_client(&self) -> &reqwest::Client {
76        &self.client
77    }
78
79    /// API token accessor for internal use.
80    pub fn token(&self) -> &SecretString {
81        &self.token
82    }
83
84    async fn get_json<T: DeserializeOwned>(
85        &self,
86        path: &str,
87        query: &[(&str, String)],
88    ) -> Result<T> {
89        let url = format!("{}/{}", self.base_url, path.trim_start_matches('/'));
90        self.rate_limiter.acquire().await;
91        let response = self
92            .client
93            .get(&url)
94            .bearer_auth(self.token.expose_secret())
95            .query(query)
96            .send()
97            .await
98            .map_err(map_reqwest_error)?;
99        parse_json_response(response).await
100    }
101
102    async fn post_json<T: DeserializeOwned, B: Serialize>(
103        &self,
104        path: &str,
105        body: &B,
106    ) -> Result<T> {
107        let url = format!("{}/{}", self.base_url, path.trim_start_matches('/'));
108        self.rate_limiter.acquire().await;
109        let response = self
110            .client
111            .post(&url)
112            .bearer_auth(self.token.expose_secret())
113            .json(body)
114            .send()
115            .await
116            .map_err(map_reqwest_error)?;
117        parse_json_response(response).await
118    }
119
120    async fn put_json<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
121        let url = format!("{}/{}", self.base_url, path.trim_start_matches('/'));
122        self.rate_limiter.acquire().await;
123        let response = self
124            .client
125            .put(&url)
126            .bearer_auth(self.token.expose_secret())
127            .json(body)
128            .send()
129            .await
130            .map_err(map_reqwest_error)?;
131        parse_json_response(response).await
132    }
133
134    async fn list_board_columns(&self) -> Result<Vec<YouGileColumn>> {
135        let mut offset = 0_u32;
136        let mut columns = Vec::new();
137
138        for page_no in 0..=MAX_PAGES {
139            if page_no == MAX_PAGES {
140                return Err(exhausted_pages(MAX_PAGES, "/columns"));
141            }
142            let page: YouGileListResponse<YouGileColumn> = self
143                .get_json(
144                    "/columns",
145                    &[
146                        ("boardId", self.board_id.clone()),
147                        ("limit", "1000".to_string()),
148                        ("offset", offset.to_string()),
149                    ],
150                )
151                .await?;
152            columns.extend(
153                page.content
154                    .into_iter()
155                    .filter(|column| !column.deleted && column.board_id == self.board_id),
156            );
157            if !page.paging.next {
158                break;
159            }
160            offset = advance_offset(offset, &page.paging, "/columns")?;
161        }
162
163        Ok(columns)
164    }
165
166    async fn list_tasks_for_column(
167        &self,
168        column_id: &str,
169        filter: &IssueFilter,
170    ) -> Result<Vec<YouGileTask>> {
171        let mut offset = 0_u32;
172        let mut tasks = Vec::new();
173
174        for page_no in 0..=MAX_PAGES {
175            if page_no == MAX_PAGES {
176                return Err(exhausted_pages(MAX_PAGES, "/task-list"));
177            }
178            let mut query = vec![
179                ("columnId", column_id.to_string()),
180                ("limit", "1000".to_string()),
181                ("offset", offset.to_string()),
182            ];
183
184            if let Some(search) = &filter.search {
185                query.push(("title", search.clone()));
186            }
187            if let Some(assignee) = &filter.assignee {
188                query.push(("assignedTo", assignee.clone()));
189            }
190            if matches!(filter.state.as_deref(), Some("all")) {
191                query.push(("includeDeleted", "true".to_string()));
192            }
193
194            let page: YouGileListResponse<YouGileTask> =
195                self.get_json("/task-list", &query).await?;
196            tasks.extend(page.content);
197            if !page.paging.next {
198                break;
199            }
200            offset = advance_offset(offset, &page.paging, "/task-list")?;
201        }
202
203        Ok(tasks)
204    }
205
206    async fn list_board_tasks(&self, filter: &IssueFilter) -> Result<Vec<YouGileTask>> {
207        let columns = self.list_board_columns().await?;
208        let column_titles: HashMap<String, String> = columns
209            .iter()
210            .map(|column| (column.id.clone(), column.title.clone()))
211            .collect();
212
213        let mut tasks = Vec::new();
214        for column in &columns {
215            tasks.extend(self.list_tasks_for_column(&column.id, filter).await?);
216        }
217
218        tasks.retain(|task| {
219            if task.deleted && !matches!(filter.state.as_deref(), Some("all")) {
220                return false;
221            }
222
223            match filter.state.as_deref() {
224                Some("open") | Some("opened") => !is_closed_task(task),
225                Some("closed") => is_closed_task(task),
226                _ => true,
227            }
228        });
229
230        if let Some(state_category) = filter.state_category.as_deref() {
231            tasks.retain(|task| {
232                matches_state_category(
233                    task,
234                    state_category,
235                    task.column_id
236                        .as_ref()
237                        .and_then(|column_id| column_titles.get(column_id).map(String::as_str)),
238                )
239            });
240        }
241
242        tasks.sort_by_key(|task| std::cmp::Reverse(task.timestamp));
243
244        // Validated up front in `get_issues`, so by here the field is known
245        // to be one of the two timestamp aliases.
246        if matches!(filter.sort_order.as_deref(), Some("asc")) {
247            tasks.sort_by_key(|task| task.timestamp);
248        }
249
250        // Make sure column mapping covers every task we return.
251        tasks.retain(|task| {
252            task.column_id
253                .as_ref()
254                .is_none_or(|column_id| column_titles.contains_key(column_id))
255        });
256
257        Ok(tasks)
258    }
259
260    async fn resolve_task_id(&self, key: &str) -> Result<String> {
261        if let Some(raw_id) = key.strip_prefix("yougile#") {
262            return Ok(raw_id.to_string());
263        }
264        if looks_like_uuid(key) {
265            return Ok(key.to_string());
266        }
267
268        let tasks = self.list_board_tasks(&IssueFilter::default()).await?;
269        tasks
270            .into_iter()
271            .find(|task| task_matches_key(task, key))
272            .map(|task| task.id)
273            .ok_or_else(|| Error::NotFound(format!("YouGile task '{key}' not found")))
274    }
275
276    async fn resolve_status_update(&self, status: &str) -> Result<serde_json::Map<String, Value>> {
277        let columns = self.list_board_columns().await?;
278        let column = columns
279            .into_iter()
280            .find(|column| column.title.eq_ignore_ascii_case(status))
281            .ok_or_else(|| {
282                Error::InvalidData(format!(
283                    "YouGile status '{status}' does not match any column title on board {}",
284                    self.board_id
285                ))
286            })?;
287
288        let mut body = serde_json::Map::new();
289        body.insert("columnId".to_string(), json!(column.id));
290
291        match map_status_category_from_name(&column.title).as_str() {
292            "done" => {
293                body.insert("completed".to_string(), json!(true));
294                body.insert("archived".to_string(), json!(false));
295                body.insert("deleted".to_string(), json!(false));
296            }
297            "cancelled" => {
298                body.insert("completed".to_string(), json!(false));
299                body.insert("archived".to_string(), json!(true));
300                body.insert("deleted".to_string(), json!(false));
301            }
302            _ => {
303                body.insert("completed".to_string(), json!(false));
304                body.insert("archived".to_string(), json!(false));
305                body.insert("deleted".to_string(), json!(false));
306            }
307        }
308
309        Ok(body)
310    }
311
312    async fn load_board_hierarchy(&self) -> Result<Vec<YouGileTask>> {
313        self.list_board_tasks(&IssueFilter {
314            state: Some("all".to_string()),
315            ..IssueFilter::default()
316        })
317        .await
318    }
319
320    async fn rewrite_parent_subtasks(&self, parent_id: &str, subtasks: Vec<String>) -> Result<()> {
321        let _: Value = self
322            .put_json(
323                &format!("/tasks/{parent_id}"),
324                &json!({
325                    "subtasks": subtasks
326                }),
327            )
328            .await?;
329        Ok(())
330    }
331
332    async fn get_issue_with_relations(
333        &self,
334        task_id: &str,
335        relation_tasks: &[YouGileTask],
336    ) -> Result<Issue> {
337        let task: YouGileTask = self.get_json(&format!("/tasks/{task_id}"), &[]).await?;
338        let columns = self.list_board_columns().await?;
339        let column_titles: HashMap<String, String> = columns
340            .iter()
341            .map(|column| (column.id.clone(), column.title.clone()))
342            .collect();
343        let task_by_id = build_task_index(relation_tasks);
344        let parent_by_child = build_parent_index(relation_tasks);
345        Ok(self
346            .map_issue(&task, &column_titles, &task_by_id, &parent_by_child)
347            .await)
348    }
349
350    async fn sync_parent_link(
351        &self,
352        child_id: &str,
353        desired_parent_key: Option<&str>,
354    ) -> Result<Vec<YouGileTask>> {
355        let mut tasks = self.load_board_hierarchy().await?;
356        let task_by_id = build_task_index(&tasks);
357        let parent_by_child = build_parent_index(&tasks);
358        let current_parent = parent_by_child.get(child_id).map(|task| task.id.clone());
359
360        let desired_parent = match desired_parent_key {
361            Some(parent_key)
362                if !parent_key.trim().is_empty() && !parent_key.eq_ignore_ascii_case("none") =>
363            {
364                Some(
365                    resolve_task_id_from_tasks(&tasks, parent_key)
366                        .or_else(|| {
367                            if looks_like_uuid(parent_key) {
368                                Some(parent_key.to_string())
369                            } else {
370                                None
371                            }
372                        })
373                        .ok_or_else(|| {
374                            Error::NotFound(format!(
375                                "YouGile parent task '{}' not found on board {}",
376                                parent_key, self.board_id
377                            ))
378                        })?,
379                )
380            }
381            _ => None,
382        };
383
384        if current_parent == desired_parent {
385            apply_parent_link_to_tasks(&mut tasks, child_id, desired_parent.as_deref());
386            return Ok(tasks);
387        }
388
389        if let Some(old_parent_id) = current_parent {
390            let old_parent = task_by_id.get(&old_parent_id).ok_or_else(|| {
391                Error::NotFound(format!(
392                    "YouGile parent task '{}' not found while detaching child '{}'",
393                    old_parent_id, child_id
394                ))
395            })?;
396            let new_subtasks: Vec<String> = old_parent
397                .subtask_ids
398                .iter()
399                .filter(|id| id.as_str() != child_id)
400                .cloned()
401                .collect();
402            self.rewrite_parent_subtasks(&old_parent_id, new_subtasks)
403                .await?;
404        }
405
406        if let Some(new_parent_id) = desired_parent.as_deref() {
407            let new_parent = task_by_id.get(new_parent_id).ok_or_else(|| {
408                Error::NotFound(format!(
409                    "YouGile parent task '{}' not found while attaching child '{}'",
410                    new_parent_id, child_id
411                ))
412            })?;
413            let mut subtasks = new_parent.subtask_ids.clone();
414            if !subtasks.iter().any(|id| id == child_id) {
415                subtasks.push(child_id.to_string());
416            }
417            self.rewrite_parent_subtasks(new_parent_id, subtasks)
418                .await?;
419        }
420
421        apply_parent_link_to_tasks(&mut tasks, child_id, desired_parent.as_deref());
422        Ok(tasks)
423    }
424
425    async fn default_column_id(&self) -> Result<Option<String>> {
426        let columns = self.list_board_columns().await?;
427        Ok(columns.into_iter().next().map(|column| column.id))
428    }
429
430    async fn resolve_user(&self, id: &str) -> User {
431        if let Some(cached) = self.user_cache.lock().await.get(id).cloned() {
432            return cached;
433        }
434
435        let user = match self
436            .get_json::<YouGileUser>(&format!("/users/{id}"), &[])
437            .await
438        {
439            Ok(user) => map_yougile_user(user),
440            Err(error) => {
441                warn!(
442                    user_id = id,
443                    ?error,
444                    "Failed to hydrate YouGile user; falling back to id-only user"
445                );
446                minimal_user(id)
447            }
448        };
449
450        self.user_cache
451            .lock()
452            .await
453            .insert(id.to_string(), user.clone());
454        user
455    }
456
457    async fn resolve_users_by_id(&self, ids: &[String]) -> HashMap<String, User> {
458        let mut users = HashMap::new();
459        for id in ids {
460            users.insert(id.clone(), self.resolve_user(id).await);
461        }
462        users
463    }
464
465    async fn map_issue(
466        &self,
467        task: &YouGileTask,
468        columns: &HashMap<String, String>,
469        task_by_id: &HashMap<String, &YouGileTask>,
470        parent_by_child: &HashMap<String, &YouGileTask>,
471    ) -> Issue {
472        let status = task
473            .column_id
474            .as_ref()
475            .and_then(|column_id| columns.get(column_id))
476            .cloned();
477        let state = if is_closed_task(task) {
478            "closed"
479        } else {
480            "open"
481        }
482        .to_string();
483        let status_category = Some(map_status_category(task, status.as_deref()));
484        let mut user_ids = task.assigned_ids.clone();
485        if let Some(author_id) = &task.created_by {
486            user_ids.push(author_id.clone());
487        }
488        let users = self.resolve_users_by_id(&user_ids).await;
489        let updated_at = Some(epoch_millis_to_rfc3339(task.timestamp));
490
491        Issue {
492            key: task_display_key(task),
493            title: task.title.clone(),
494            description: task.description.clone(),
495            state,
496            status,
497            status_category,
498            source: "yougile".to_string(),
499            priority: None,
500            labels: Vec::new(),
501            author: task
502                .created_by
503                .as_ref()
504                .and_then(|id| users.get(id).cloned()),
505            assignees: task
506                .assigned_ids
507                .iter()
508                .filter_map(|id| users.get(id).cloned())
509                .collect(),
510            url: None,
511            created_at: updated_at.clone(),
512            updated_at,
513            attachments_count: None,
514            parent: parent_by_child
515                .get(&task.id)
516                .map(|parent| task_display_key(parent)),
517            subtasks: task
518                .subtask_ids
519                .iter()
520                .filter_map(|id| task_by_id.get(id))
521                .map(|subtask| map_related_issue(subtask, columns))
522                .collect(),
523            custom_fields: map_custom_fields(task),
524        }
525    }
526
527    async fn map_comment(&self, message: &YouGileChatMessage) -> Comment {
528        Comment {
529            id: message.id.to_string(),
530            body: message.text.clone(),
531            author: Some(self.resolve_user(&message.from_user_id).await),
532            created_at: Some(epoch_millis_to_rfc3339(message.id)),
533            updated_at: Some(epoch_millis_to_rfc3339(message.edit_timestamp)),
534            position: None,
535        }
536    }
537}
538
539fn validate_generic_create_fields(input: &CreateIssueInput) -> Result<()> {
540    if !input.labels.is_empty() {
541        return Err(Error::InvalidData(
542            "YouGile create_issue does not support generic labels. Use provider-specific sticker ids via custom_fields instead.".to_string(),
543        ));
544    }
545    if input.priority.is_some() {
546        return Err(Error::InvalidData(
547            "YouGile create_issue does not support generic priority. Use a provider-specific priority sticker via custom_fields instead.".to_string(),
548        ));
549    }
550    Ok(())
551}
552
553fn validate_generic_update_fields(input: &UpdateIssueInput) -> Result<()> {
554    if input.labels.is_some() {
555        return Err(Error::InvalidData(
556            "YouGile update_issue does not support generic labels. Use provider-specific sticker ids via custom_fields instead.".to_string(),
557        ));
558    }
559    if input.priority.is_some() {
560        return Err(Error::InvalidData(
561            "YouGile update_issue does not support generic priority. Use a provider-specific priority sticker via custom_fields instead.".to_string(),
562        ));
563    }
564    Ok(())
565}
566
567#[async_trait]
568impl IssueProvider for YouGileClient {
569    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
570        validate_sort_by(filter.sort_by.as_deref())?;
571        validate_assignee_filter(filter.assignee.as_deref())?;
572        let columns = self.list_board_columns().await?;
573        let column_titles: HashMap<String, String> = columns
574            .iter()
575            .map(|column| (column.id.clone(), column.title.clone()))
576            .collect();
577
578        let all_tasks = self.list_board_tasks(&filter).await?;
579        let task_by_id = build_task_index(&all_tasks);
580        let parent_by_child = build_parent_index(&all_tasks);
581        let total = all_tasks.len() as u32;
582        let offset = filter.offset.unwrap_or(0) as usize;
583        let limit = filter.limit.unwrap_or(20) as usize;
584
585        let page_tasks: Vec<&YouGileTask> = if offset >= all_tasks.len() {
586            Vec::new()
587        } else {
588            all_tasks.iter().skip(offset).take(limit).collect()
589        };
590        let has_more = (offset + page_tasks.len()) < total as usize;
591        let mut items = Vec::with_capacity(page_tasks.len());
592        for task in page_tasks {
593            items.push(
594                self.map_issue(task, &column_titles, &task_by_id, &parent_by_child)
595                    .await,
596            );
597        }
598
599        let mut result = ProviderResult::new(items).with_pagination(Pagination {
600            offset: offset as u32,
601            limit: limit as u32,
602            total: Some(total),
603            has_more,
604            next_cursor: None,
605        });
606        result.sort_info = Some(SortInfo {
607            sort_by: Some(
608                filter
609                    .sort_by
610                    .as_deref()
611                    .unwrap_or("created_at")
612                    .to_string(),
613            ),
614            sort_order: if matches!(filter.sort_order.as_deref(), Some("asc")) {
615                SortOrder::Asc
616            } else {
617                SortOrder::Desc
618            },
619            available_sorts: vec!["created_at".to_string(), "updated_at".to_string()],
620        });
621        Ok(result)
622    }
623
624    async fn get_issue(&self, key: &str) -> Result<Issue> {
625        let task_id = self.resolve_task_id(key).await?;
626        let task: YouGileTask = self.get_json(&format!("/tasks/{task_id}"), &[]).await?;
627        let columns = self.list_board_columns().await?;
628        let column_titles: HashMap<String, String> = columns
629            .iter()
630            .map(|column| (column.id.clone(), column.title.clone()))
631            .collect();
632        let relation_tasks = self
633            .list_board_tasks(&IssueFilter {
634                state: Some("all".to_string()),
635                ..IssueFilter::default()
636            })
637            .await?;
638        let task_by_id = build_task_index(&relation_tasks);
639        let parent_by_child = build_parent_index(&relation_tasks);
640        Ok(self
641            .map_issue(&task, &column_titles, &task_by_id, &parent_by_child)
642            .await)
643    }
644
645    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
646        validate_generic_create_fields(&input)?;
647        if input.issue_type.is_some() {
648            warn!("YouGile create_issue ignores issue_type");
649        }
650        let parent_key = input.parent.clone();
651
652        let column_id = self.default_column_id().await?;
653
654        let mut body = serde_json::Map::new();
655        body.insert("title".to_string(), json!(input.title));
656        if let Some(description) = input.description {
657            body.insert("description".to_string(), json!(description));
658        }
659        if let Some(column_id) = column_id {
660            body.insert("columnId".to_string(), json!(column_id));
661        }
662        if !input.assignees.is_empty() {
663            body.insert("assigned".to_string(), json!(input.assignees));
664        }
665        if let Some(stickers) = custom_fields_to_stickers(input.custom_fields)? {
666            body.insert("stickers".to_string(), stickers);
667        }
668
669        let created: Value = self.post_json("/tasks", &body).await?;
670        let task_id = created.get("id").and_then(|v| v.as_str()).ok_or_else(|| {
671            Error::InvalidData("YouGile create_issue response missing id".to_string())
672        })?;
673        if let Some(parent_key) = parent_key.as_deref() {
674            let relation_tasks = self.sync_parent_link(task_id, Some(parent_key)).await?;
675            return self
676                .get_issue_with_relations(task_id, &relation_tasks)
677                .await;
678        }
679        self.get_issue(&format!("yougile#{task_id}")).await
680    }
681
682    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
683        validate_generic_update_fields(&input)?;
684        let task_id = self.resolve_task_id(key).await?;
685        let parent_id = input.parent_id.clone();
686        let mut body = serde_json::Map::new();
687
688        if let Some(title) = input.title {
689            body.insert("title".to_string(), json!(title));
690        }
691        if let Some(description) = input.description {
692            body.insert("description".to_string(), json!(description));
693        }
694        if let Some(assignees) = input.assignees {
695            body.insert("assigned".to_string(), json!(assignees));
696        }
697        if let Some(status) = input.status.as_deref() {
698            body.extend(self.resolve_status_update(status).await?);
699        } else if let Some(state) = input.state.as_deref() {
700            match state {
701                "open" | "opened" => {
702                    body.insert("completed".to_string(), json!(false));
703                    body.insert("archived".to_string(), json!(false));
704                    body.insert("deleted".to_string(), json!(false));
705                }
706                "closed" => {
707                    body.insert("completed".to_string(), json!(true));
708                    body.insert("archived".to_string(), json!(false));
709                    body.insert("deleted".to_string(), json!(false));
710                }
711                _ => {}
712            }
713        }
714        if let Some(stickers) = custom_fields_to_stickers(input.custom_fields)? {
715            body.insert("stickers".to_string(), stickers);
716        }
717
718        if !body.is_empty() {
719            let _: Value = self.put_json(&format!("/tasks/{task_id}"), &body).await?;
720        }
721        if let Some(parent_id) = parent_id.as_deref() {
722            let relation_tasks = self.sync_parent_link(&task_id, Some(parent_id)).await?;
723            return self
724                .get_issue_with_relations(&task_id, &relation_tasks)
725                .await;
726        }
727        self.get_issue(&format!("yougile#{task_id}")).await
728    }
729
730    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
731        let task_id = self.resolve_task_id(issue_key).await?;
732        let messages: YouGileListResponse<YouGileChatMessage> = self
733            .get_json(
734                &format!("/chats/{task_id}/messages"),
735                &[
736                    ("limit", "1000".to_string()),
737                    ("offset", "0".to_string()),
738                    ("includeSystem", "false".to_string()),
739                ],
740            )
741            .await?;
742        let mut comments = Vec::new();
743        for message in messages
744            .content
745            .into_iter()
746            .filter(|message| !message.deleted)
747        {
748            comments.push(self.map_comment(&message).await);
749        }
750
751        Ok(ProviderResult::new(comments).with_pagination(Pagination {
752            offset: messages.paging.offset,
753            limit: messages.paging.limit,
754            total: None,
755            has_more: messages.paging.next,
756            next_cursor: None,
757        }))
758    }
759
760    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
761        let task_id = self.resolve_task_id(issue_key).await?;
762        let response: Value = self
763            .post_json(
764                &format!("/chats/{task_id}/messages"),
765                &json!({
766                    "text": body,
767                    "textHtml": format!("<p>{}</p>", escape_html(body)),
768                    "label": ""
769                }),
770            )
771            .await?;
772        let message_id = response.get("id").and_then(|v| v.as_u64()).ok_or_else(|| {
773            Error::InvalidData("YouGile add_comment response missing id".to_string())
774        })?;
775        let messages: YouGileListResponse<YouGileChatMessage> = self
776            .get_json(
777                &format!("/chats/{task_id}/messages"),
778                &[
779                    ("limit", "1000".to_string()),
780                    ("offset", "0".to_string()),
781                    ("includeSystem", "false".to_string()),
782                ],
783            )
784            .await?;
785        let message = messages
786            .content
787            .into_iter()
788            .find(|message| message.id == message_id)
789            .ok_or_else(|| {
790                Error::NotFound(format!(
791                    "YouGile comment '{}' not found after creation",
792                    message_id
793                ))
794            })?;
795        Ok(self.map_comment(&message).await)
796    }
797
798    fn provider_name(&self) -> &'static str {
799        "yougile"
800    }
801}
802
803#[async_trait]
804impl MergeRequestProvider for YouGileClient {
805    fn provider_name(&self) -> &'static str {
806        "yougile"
807    }
808}
809
810#[async_trait]
811impl PipelineProvider for YouGileClient {
812    fn provider_name(&self) -> &'static str {
813        "yougile"
814    }
815}
816
817#[async_trait]
818impl Provider for YouGileClient {
819    async fn get_current_user(&self) -> Result<User> {
820        let user: YouGileUser = self.get_json("/users/me", &[]).await?;
821        Ok(map_yougile_user(user))
822    }
823}
824
825#[derive(Debug)]
826struct YouGileRateLimiter {
827    state: Mutex<YouGileRateLimiterState>,
828    interval: Duration,
829}
830
831#[derive(Debug)]
832struct YouGileRateLimiterState {
833    ready_at: Instant,
834}
835
836impl YouGileRateLimiter {
837    fn new(interval: Duration) -> Self {
838        Self {
839            state: Mutex::new(YouGileRateLimiterState {
840                ready_at: Instant::now(),
841            }),
842            interval,
843        }
844    }
845
846    async fn acquire(&self) {
847        let wait_until = {
848            let mut state = self.state.lock().await;
849            let now = Instant::now();
850            let wait_until = state.ready_at.max(now);
851            state.ready_at = wait_until + self.interval;
852            wait_until
853        };
854
855        if wait_until > Instant::now() {
856            sleep_until(wait_until).await;
857        }
858    }
859}
860
861fn map_reqwest_error(error: reqwest::Error) -> Error {
862    if error.is_timeout() {
863        Error::Timeout
864    } else if error.is_connect() {
865        Error::Network(error.to_string())
866    } else {
867        Error::Http(error.to_string())
868    }
869}
870
871async fn parse_json_response<T: DeserializeOwned>(response: Response) -> Result<T> {
872    let status = response.status();
873    let retry_after = response
874        .headers()
875        .get(reqwest::header::RETRY_AFTER)
876        .and_then(|value| value.to_str().ok())
877        .and_then(|value| value.parse::<u64>().ok());
878    let body = response.text().await.map_err(map_reqwest_error)?;
879    if !status.is_success() {
880        let message = if body.trim().is_empty() {
881            status
882                .canonical_reason()
883                .unwrap_or("request failed")
884                .to_string()
885        } else {
886            body
887        };
888        return Err(match status {
889            StatusCode::UNAUTHORIZED => Error::Unauthorized(message),
890            StatusCode::FORBIDDEN => Error::Forbidden(message),
891            StatusCode::NOT_FOUND => Error::NotFound(message),
892            StatusCode::TOO_MANY_REQUESTS => Error::RateLimited { retry_after },
893            s if s.is_server_error() => Error::ServerError {
894                status: s.as_u16(),
895                message,
896            },
897            _ => Error::Api {
898                status: status.as_u16(),
899                message,
900            },
901        });
902    }
903
904    serde_json::from_str(&body).map_err(Error::from)
905}
906
907fn task_display_key(task: &YouGileTask) -> String {
908    task.id_task_project
909        .clone()
910        .or_else(|| task.id_task_common.clone())
911        .unwrap_or_else(|| format!("yougile#{}", task.id))
912}
913
914fn task_matches_key(task: &YouGileTask, key: &str) -> bool {
915    task.id == key
916        || task_display_key(task) == key
917        || task.id_task_common.as_deref() == Some(key)
918        || task.id_task_project.as_deref() == Some(key)
919        || format!("yougile#{}", task.id) == key
920}
921
922fn resolve_task_id_from_tasks(tasks: &[YouGileTask], key: &str) -> Option<String> {
923    tasks
924        .iter()
925        .find(|task| task_matches_key(task, key))
926        .map(|task| task.id.clone())
927}
928
929fn is_closed_task(task: &YouGileTask) -> bool {
930    task.completed || task.archived || task.deleted
931}
932
933/// Advances the paging cursor, refusing to spin forever.
934///
935/// The loops that call this keep going while the server says `next: true`,
936/// stepping by the page size the server reports back. A `limit` of 0 would
937/// leave the offset unchanged and turn that into an infinite request loop —
938/// a hang is far worse than a surfaced error, so bail out instead.
939fn advance_offset(offset: u32, paging: &YouGilePaging, endpoint: &str) -> Result<u32> {
940    if paging.limit == 0 {
941        return Err(Error::InvalidData(format!(
942            "YouGile {endpoint} reported more pages but a page size of 0; refusing to loop forever"
943        )));
944    }
945    // `saturating_add` would pin at u32::MAX and keep re-requesting the same
946    // offset forever, so treat an offset that cannot advance as a protocol
947    // error rather than silently spinning.
948    offset.checked_add(paging.limit).ok_or_else(|| {
949        Error::InvalidData(format!(
950            "YouGile {endpoint} paged past the maximum offset ({offset} + {}); \
951             refusing to loop forever",
952            paging.limit
953        ))
954    })
955}
956
957/// Upper bound on pages fetched from one endpoint in a single call.
958///
959/// A server that always answers `next: true` would otherwise keep the loop
960/// running indefinitely even while the offset advances normally. At the 1000
961/// items per page this client requests, the cap is far above any real board.
962const MAX_PAGES: usize = 1_000;
963
964fn exhausted_pages(pages: usize, endpoint: &str) -> Error {
965    Error::InvalidData(format!(
966        "YouGile {endpoint} still reported more pages after {pages} requests; \
967         refusing to loop forever"
968    ))
969}
970
971/// Maps the cross-provider `sort_by` value onto the single timestamp YouGile
972/// exposes on a task.
973///
974/// A YouGile task carries exactly one `timestamp`, which this provider
975/// surfaces as both `created_at` and `updated_at`. Both sort fields are
976/// therefore accepted and resolve to the same ordering; anything else is
977/// refused by name rather than silently ignored, so a caller never receives
978/// arbitrarily-ordered results believing the sort was applied.
979fn validate_sort_by(sort_by: Option<&str>) -> Result<()> {
980    match sort_by.map(str::trim) {
981        None | Some("") | Some("created_at") | Some("created") | Some("updated_at")
982        | Some("updated") => Ok(()),
983        Some(other) => Err(Error::InvalidData(format!(
984            "unsupported sort_by '{other}' for YouGile; expected one of: created_at, updated_at \
985             (both order by the task's single timestamp)"
986        ))),
987    }
988}
989
990/// YouGile's `assignedTo` filter matches on the user's **id**, not a name or
991/// email. The shared tool schema describes `assignee` as a username, so a
992/// caller following it would get an empty result set that is indistinguishable
993/// from "this person has no tasks". Fail with an explanation instead.
994fn validate_assignee_filter(assignee: Option<&str>) -> Result<()> {
995    let Some(value) = assignee.map(str::trim).filter(|s| !s.is_empty()) else {
996        return Ok(());
997    };
998
999    // Reject only what an id can never be — an email or a multi-word name.
1000    // Ids are otherwise treated as opaque: requiring a UUID here would refuse
1001    // legitimate identifiers if YouGile ever issues another shape.
1002    if value.contains('@') || value.split_whitespace().count() > 1 {
1003        return Err(Error::InvalidData(format!(
1004            "YouGile filters assignees by user id, and '{value}' looks like a name or email; \
1005             pass the user's id instead"
1006        )));
1007    }
1008    Ok(())
1009}
1010
1011fn matches_state_category(
1012    task: &YouGileTask,
1013    state_category: &str,
1014    status_name: Option<&str>,
1015) -> bool {
1016    map_status_category(task, status_name) == state_category
1017}
1018
1019fn map_status_category_from_name(status_name: &str) -> String {
1020    let normalized = status_name
1021        .trim()
1022        .to_ascii_lowercase()
1023        .replace(['-', '_'], " ");
1024
1025    if normalized.contains("backlog") || normalized.contains("icebox") {
1026        return "backlog".to_string();
1027    }
1028    if matches!(
1029        normalized.as_str(),
1030        "todo" | "to do" | "open" | "new" | "incoming" | "planned" | "plan"
1031    ) || normalized.contains("to do")
1032        || normalized.contains("todo")
1033    {
1034        return "todo".to_string();
1035    }
1036    if normalized.contains("review")
1037        || normalized.contains("progress")
1038        || normalized.contains("doing")
1039        || normalized.contains("active")
1040        || normalized.contains("develop")
1041        || normalized.contains("test")
1042        || normalized.contains("qa")
1043        || normalized.contains("verify")
1044        || normalized.contains("ready")
1045        || normalized.contains("wait")
1046    {
1047        return "in_progress".to_string();
1048    }
1049    if normalized.contains("done")
1050        || normalized.contains("complete")
1051        || normalized.contains("resolved")
1052        || normalized.contains("closed")
1053        || normalized.contains("shipped")
1054    {
1055        return "done".to_string();
1056    }
1057    if normalized.contains("cancel")
1058        || normalized.contains("archive")
1059        || normalized.contains("reject")
1060        || normalized.contains("wontfix")
1061        || normalized.contains("won't")
1062    {
1063        return "cancelled".to_string();
1064    }
1065
1066    "in_progress".to_string()
1067}
1068
1069fn map_status_category(task: &YouGileTask, status_name: Option<&str>) -> String {
1070    if task.deleted || task.archived {
1071        return "cancelled".to_string();
1072    }
1073    if task.completed {
1074        return "done".to_string();
1075    }
1076    map_status_category_from_name(status_name.unwrap_or_default())
1077}
1078
1079fn minimal_user(id: &str) -> User {
1080    User {
1081        id: id.to_string(),
1082        username: id.to_string(),
1083        name: None,
1084        email: None,
1085        avatar_url: None,
1086    }
1087}
1088
1089fn map_yougile_user(user: YouGileUser) -> User {
1090    User {
1091        id: user.id,
1092        username: user.email.clone(),
1093        name: Some(user.real_name),
1094        email: Some(user.email),
1095        avatar_url: None,
1096    }
1097}
1098
1099fn map_custom_fields(task: &YouGileTask) -> HashMap<String, CustomFieldValue> {
1100    let mut fields = HashMap::new();
1101
1102    if let Some(Value::Object(stickers)) = &task.stickers {
1103        for (id, value) in stickers {
1104            fields.insert(
1105                id.clone(),
1106                CustomFieldValue {
1107                    name: None,
1108                    value: value.clone(),
1109                    display: value.as_str().map(ToOwned::to_owned),
1110                },
1111            );
1112        }
1113    }
1114
1115    fields
1116}
1117
1118fn map_related_issue(task: &YouGileTask, columns: &HashMap<String, String>) -> Issue {
1119    let status = task
1120        .column_id
1121        .as_ref()
1122        .and_then(|column_id| columns.get(column_id))
1123        .cloned();
1124    let updated_at = Some(epoch_millis_to_rfc3339(task.timestamp));
1125
1126    Issue {
1127        key: task_display_key(task),
1128        title: task.title.clone(),
1129        description: task.description.clone(),
1130        state: if is_closed_task(task) {
1131            "closed"
1132        } else {
1133            "open"
1134        }
1135        .to_string(),
1136        status: status.clone(),
1137        status_category: Some(map_status_category(task, status.as_deref())),
1138        source: "yougile".to_string(),
1139        priority: None,
1140        labels: Vec::new(),
1141        author: None,
1142        assignees: Vec::new(),
1143        url: None,
1144        created_at: updated_at.clone(),
1145        updated_at,
1146        attachments_count: None,
1147        parent: None,
1148        subtasks: Vec::new(),
1149        custom_fields: map_custom_fields(task),
1150    }
1151}
1152
1153fn build_task_index(tasks: &[YouGileTask]) -> HashMap<String, &YouGileTask> {
1154    tasks.iter().map(|task| (task.id.clone(), task)).collect()
1155}
1156
1157fn build_parent_index(tasks: &[YouGileTask]) -> HashMap<String, &YouGileTask> {
1158    let mut parents = HashMap::new();
1159    for task in tasks {
1160        for child_id in &task.subtask_ids {
1161            parents.insert(child_id.clone(), task);
1162        }
1163    }
1164    parents
1165}
1166
1167fn apply_parent_link_to_tasks(tasks: &mut [YouGileTask], child_id: &str, parent_id: Option<&str>) {
1168    for task in tasks.iter_mut() {
1169        task.subtask_ids.retain(|id| id != child_id);
1170    }
1171
1172    if let Some(parent_id) = parent_id {
1173        if let Some(parent) = tasks.iter_mut().find(|task| task.id == parent_id) {
1174            if !parent.subtask_ids.iter().any(|id| id == child_id) {
1175                parent.subtask_ids.push(child_id.to_string());
1176            }
1177        }
1178    }
1179}
1180
1181fn epoch_millis_to_rfc3339(timestamp: u64) -> String {
1182    let secs = (timestamp / 1000) as i64;
1183    let millis = (timestamp % 1000) as u32;
1184    let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, millis * 1_000_000)
1185        .unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH);
1186    dt.to_rfc3339()
1187}
1188
1189fn looks_like_uuid(value: &str) -> bool {
1190    let bytes = value.as_bytes();
1191    bytes.len() == 36
1192        && bytes.iter().enumerate().all(|(idx, b)| match idx {
1193            8 | 13 | 18 | 23 => *b == b'-',
1194            _ => b.is_ascii_hexdigit(),
1195        })
1196}
1197
1198fn custom_fields_to_stickers(custom_fields: Option<Value>) -> Result<Option<Value>> {
1199    let Some(value) = custom_fields else {
1200        return Ok(None);
1201    };
1202    let Value::Object(map) = value else {
1203        return Err(Error::InvalidData(
1204            "YouGile custom_fields must be a JSON object keyed by sticker id".to_string(),
1205        ));
1206    };
1207
1208    let mut stickers = serde_json::Map::new();
1209    for (key, value) in map {
1210        let sticker_value = match value {
1211            Value::String(s) => Value::String(s),
1212            Value::Number(n) => Value::String(n.to_string()),
1213            Value::Bool(b) => Value::String(b.to_string()),
1214            Value::Null => Value::String("-".to_string()),
1215            other => {
1216                return Err(Error::InvalidData(format!(
1217                    "YouGile custom_fields[{key}] must be a scalar value, got {other}"
1218                )));
1219            }
1220        };
1221        stickers.insert(key, sticker_value);
1222    }
1223
1224    Ok(Some(Value::Object(stickers)))
1225}
1226
1227fn escape_html(value: &str) -> String {
1228    value
1229        .replace('&', "&amp;")
1230        .replace('<', "&lt;")
1231        .replace('>', "&gt;")
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    use httpmock::Method::{GET, POST, PUT};
1238    use httpmock::MockServer;
1239
1240    #[test]
1241    fn advance_offset_refuses_to_spin_on_a_zero_page_size() {
1242        let ok = YouGilePaging {
1243            limit: 50,
1244            offset: 0,
1245            next: true,
1246        };
1247        assert_eq!(advance_offset(100, &ok, "/task-list").unwrap(), 150);
1248
1249        // limit == 0 would leave the offset unchanged and loop forever.
1250        let stuck = YouGilePaging {
1251            limit: 0,
1252            offset: 0,
1253            next: true,
1254        };
1255        let err = advance_offset(100, &stuck, "/task-list").unwrap_err();
1256        assert!(
1257            matches!(&err, Error::InvalidData(m) if m.contains("/task-list")),
1258            "unexpected error: {err:?}"
1259        );
1260
1261        // Saturating at u32::MAX would re-request the same offset forever.
1262        let huge = YouGilePaging {
1263            limit: 10,
1264            offset: 0,
1265            next: true,
1266        };
1267        let err = advance_offset(u32::MAX - 1, &huge, "/task-list").unwrap_err();
1268        assert!(
1269            matches!(&err, Error::InvalidData(m) if m.contains("maximum offset")),
1270            "unexpected error: {err:?}"
1271        );
1272    }
1273
1274    #[test]
1275    fn validate_sort_by_accepts_both_timestamp_aliases_and_names_the_rest() {
1276        for ok in [None, Some(""), Some("created_at"), Some("updated_at")] {
1277            validate_sort_by(ok).unwrap_or_else(|e| panic!("{ok:?} rejected: {e:?}"));
1278        }
1279        let err = validate_sort_by(Some("priority")).unwrap_err();
1280        assert!(
1281            matches!(&err, Error::InvalidData(m) if m.contains("priority") && m.contains("created_at")),
1282            "unexpected error: {err:?}"
1283        );
1284    }
1285
1286    #[test]
1287    fn validate_assignee_filter_rejects_names_that_would_silently_match_nothing() {
1288        validate_assignee_filter(None).unwrap();
1289        validate_assignee_filter(Some("11111111-2222-3333-4444-555555555555")).unwrap();
1290
1291        // Opaque non-UUID ids must still pass — only names/emails are refused.
1292        validate_assignee_filter(Some("usr_12345")).unwrap();
1293
1294        for bad in ["alice@example.com", "Alice Doe"] {
1295            let err = validate_assignee_filter(Some(bad)).unwrap_err();
1296            assert!(
1297                matches!(&err, Error::InvalidData(m) if m.contains(bad) && m.contains("user id")),
1298                "unexpected error for {bad}: {err:?}"
1299            );
1300        }
1301    }
1302
1303    #[test]
1304    fn new_uses_default_api_url() {
1305        let client = YouGileClient::new("board-1", SecretString::from("token".to_owned()));
1306        assert_eq!(client.base_url(), DEFAULT_YOUGILE_URL);
1307        assert_eq!(client.board_id(), "board-1");
1308    }
1309
1310    #[test]
1311    fn with_base_url_trims_trailing_slash() {
1312        let client = YouGileClient::with_base_url(
1313            "https://example.invalid/api-v2/",
1314            "board-2",
1315            SecretString::from("token".to_owned()),
1316        );
1317        assert_eq!(client.base_url(), "https://example.invalid/api-v2");
1318        assert_eq!(client.board_id(), "board-2");
1319    }
1320
1321    #[tokio::test]
1322    async fn get_issue_fetches_task_by_uuid() {
1323        let server = MockServer::start_async().await;
1324        server
1325            .mock_async(|when, then| {
1326                when.method(GET).path("/api-v2/tasks/task-1");
1327                then.status(200).json_body_obj(&serde_json::json!({
1328                    "id": "task-1",
1329                    "title": "Implement provider",
1330                    "timestamp": 1710000000000_u64,
1331                    "columnId": "col-1",
1332                    "description": "read path",
1333                    "completed": false,
1334                    "archived": false,
1335                    "deleted": false,
1336                    "assigned": ["user-1"],
1337                    "createdBy": "user-2",
1338                    "idTaskProject": "DEV-484",
1339                    "stickers": {
1340                        "severity": "high"
1341                    }
1342                }));
1343            })
1344            .await;
1345        server
1346            .mock_async(|when, then| {
1347                when.method(GET)
1348                    .path("/api-v2/columns")
1349                    .query_param("boardId", "board-1")
1350                    .query_param("limit", "1000")
1351                    .query_param("offset", "0");
1352                then.status(200).json_body_obj(&serde_json::json!({
1353                    "paging": { "limit": 1000, "offset": 0, "next": false },
1354                    "content": [
1355                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
1356                    ]
1357                }));
1358            })
1359            .await;
1360        server
1361            .mock_async(|when, then| {
1362                when.method(GET)
1363                    .path("/api-v2/task-list")
1364                    .query_param("columnId", "col-1")
1365                    .query_param("limit", "1000")
1366                    .query_param("offset", "0")
1367                    .query_param("includeDeleted", "true");
1368                then.status(200).json_body_obj(&serde_json::json!({
1369                    "paging": { "limit": 1000, "offset": 0, "next": false },
1370                    "content": [
1371                        {
1372                            "id": "parent-1",
1373                            "title": "Parent task",
1374                            "timestamp": 1709999999000_u64,
1375                            "columnId": "col-1",
1376                            "completed": false,
1377                            "archived": false,
1378                            "deleted": false,
1379                            "idTaskProject": "DEV-100",
1380                            "subtasks": ["task-1"]
1381                        },
1382                        {
1383                            "id": "task-1",
1384                            "title": "Implement provider",
1385                            "timestamp": 1710000000000_u64,
1386                            "columnId": "col-1",
1387                            "description": "read path",
1388                            "completed": false,
1389                            "archived": false,
1390                            "deleted": false,
1391                            "assigned": ["user-1"],
1392                            "createdBy": "user-2",
1393                            "idTaskProject": "DEV-484",
1394                            "stickers": {
1395                                "severity": "high"
1396                            }
1397                        }
1398                    ]
1399                }));
1400            })
1401            .await;
1402        server
1403            .mock_async(|when, then| {
1404                when.method(GET).path("/api-v2/users/user-1");
1405                then.status(200).json_body_obj(&serde_json::json!({
1406                    "id": "user-1",
1407                    "email": "assignee@example.com",
1408                    "realName": "Assignee User"
1409                }));
1410            })
1411            .await;
1412        server
1413            .mock_async(|when, then| {
1414                when.method(GET).path("/api-v2/users/user-2");
1415                then.status(200).json_body_obj(&serde_json::json!({
1416                    "id": "user-2",
1417                    "email": "author@example.com",
1418                    "realName": "Author User"
1419                }));
1420            })
1421            .await;
1422
1423        let client = YouGileClient::with_base_url(
1424            format!("{}/api-v2", server.base_url()),
1425            "board-1",
1426            SecretString::from("token".to_owned()),
1427        );
1428        let issue = client.get_issue("yougile#task-1").await.unwrap();
1429        assert_eq!(issue.key, "DEV-484");
1430        assert_eq!(issue.status.as_deref(), Some("To Do"));
1431        assert_eq!(issue.state, "open");
1432        assert_eq!(issue.status_category.as_deref(), Some("todo"));
1433        assert_eq!(
1434            issue.author.as_ref().unwrap().username,
1435            "author@example.com"
1436        );
1437        assert_eq!(
1438            issue.author.as_ref().unwrap().name.as_deref(),
1439            Some("Author User")
1440        );
1441        assert_eq!(issue.assignees[0].username, "assignee@example.com");
1442        assert_eq!(issue.parent.as_deref(), Some("DEV-100"));
1443        assert_eq!(issue.updated_at.as_deref(), issue.created_at.as_deref());
1444        assert_eq!(
1445            issue.custom_fields["severity"].display.as_deref(),
1446            Some("high")
1447        );
1448    }
1449
1450    #[tokio::test]
1451    async fn get_issues_lists_tasks_across_board_columns() {
1452        let server = MockServer::start_async().await;
1453        server
1454            .mock_async(|when, then| {
1455                when.method(GET)
1456                    .path("/api-v2/columns")
1457                    .query_param("boardId", "board-1")
1458                    .query_param("limit", "1000")
1459                    .query_param("offset", "0");
1460                then.status(200).json_body_obj(&serde_json::json!({
1461                    "paging": { "limit": 1000, "offset": 0, "next": false },
1462                    "content": [
1463                        { "id": "col-1", "title": "To Do", "boardId": "board-1" },
1464                        { "id": "col-2", "title": "Done", "boardId": "board-1" }
1465                    ]
1466                }));
1467            })
1468            .await;
1469        server
1470            .mock_async(|when, then| {
1471                when.method(GET)
1472                    .path("/api-v2/task-list")
1473                    .query_param("columnId", "col-1")
1474                    .query_param("limit", "1000")
1475                    .query_param("offset", "0");
1476                then.status(200).json_body_obj(&serde_json::json!({
1477                    "paging": { "limit": 1000, "offset": 0, "next": false },
1478                    "content": [
1479                        {
1480                            "id": "task-1",
1481                            "title": "Open task",
1482                            "timestamp": 1710000000000_u64,
1483                            "columnId": "col-1",
1484                            "completed": false,
1485                            "archived": false,
1486                            "deleted": false,
1487                            "idTaskProject": "DEV-1"
1488                        }
1489                    ]
1490                }));
1491            })
1492            .await;
1493        server
1494            .mock_async(|when, then| {
1495                when.method(GET)
1496                    .path("/api-v2/task-list")
1497                    .query_param("columnId", "col-2")
1498                    .query_param("limit", "1000")
1499                    .query_param("offset", "0");
1500                then.status(200).json_body_obj(&serde_json::json!({
1501                    "paging": { "limit": 1000, "offset": 0, "next": false },
1502                    "content": [
1503                        {
1504                            "id": "task-2",
1505                            "title": "Done task",
1506                            "timestamp": 1710000001000_u64,
1507                            "columnId": "col-2",
1508                            "completed": true,
1509                            "archived": false,
1510                            "deleted": false,
1511                            "idTaskProject": "DEV-2"
1512                        }
1513                    ]
1514                }));
1515            })
1516            .await;
1517
1518        let client = YouGileClient::with_base_url(
1519            format!("{}/api-v2", server.base_url()),
1520            "board-1",
1521            SecretString::from("token".to_owned()),
1522        );
1523        let issues = client.get_issues(IssueFilter::default()).await.unwrap();
1524        assert_eq!(issues.items.len(), 2);
1525        assert_eq!(issues.items[0].key, "DEV-2");
1526        assert_eq!(issues.items[1].key, "DEV-1");
1527        assert_eq!(issues.items[0].status_category.as_deref(), Some("done"));
1528        assert_eq!(issues.items[1].status_category.as_deref(), Some("todo"));
1529        assert_eq!(issues.pagination.unwrap().total, Some(2));
1530    }
1531
1532    #[tokio::test]
1533    async fn get_issues_maps_parent_and_subtasks_from_board_hierarchy() {
1534        let server = MockServer::start_async().await;
1535        server
1536            .mock_async(|when, then| {
1537                when.method(GET)
1538                    .path("/api-v2/columns")
1539                    .query_param("boardId", "board-1")
1540                    .query_param("limit", "1000")
1541                    .query_param("offset", "0");
1542                then.status(200).json_body_obj(&serde_json::json!({
1543                    "paging": { "limit": 1000, "offset": 0, "next": false },
1544                    "content": [
1545                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
1546                    ]
1547                }));
1548            })
1549            .await;
1550        server
1551            .mock_async(|when, then| {
1552                when.method(GET)
1553                    .path("/api-v2/task-list")
1554                    .query_param("columnId", "col-1")
1555                    .query_param("limit", "1000")
1556                    .query_param("offset", "0");
1557                then.status(200).json_body_obj(&serde_json::json!({
1558                    "paging": { "limit": 1000, "offset": 0, "next": false },
1559                    "content": [
1560                        {
1561                            "id": "parent-1",
1562                            "title": "Epic",
1563                            "timestamp": 1710000002000_u64,
1564                            "columnId": "col-1",
1565                            "completed": false,
1566                            "archived": false,
1567                            "deleted": false,
1568                            "idTaskProject": "DEV-20",
1569                            "subtasks": ["child-1"]
1570                        },
1571                        {
1572                            "id": "child-1",
1573                            "title": "Child task",
1574                            "timestamp": 1710000001000_u64,
1575                            "columnId": "col-1",
1576                            "completed": false,
1577                            "archived": false,
1578                            "deleted": false,
1579                            "idTaskProject": "DEV-21"
1580                        }
1581                    ]
1582                }));
1583            })
1584            .await;
1585
1586        let client = YouGileClient::with_base_url(
1587            format!("{}/api-v2", server.base_url()),
1588            "board-1",
1589            SecretString::from("token".to_owned()),
1590        );
1591        let issues = client.get_issues(IssueFilter::default()).await.unwrap();
1592        let parent = issues
1593            .items
1594            .iter()
1595            .find(|issue| issue.key == "DEV-20")
1596            .unwrap();
1597        let child = issues
1598            .items
1599            .iter()
1600            .find(|issue| issue.key == "DEV-21")
1601            .unwrap();
1602
1603        assert_eq!(parent.subtasks.len(), 1);
1604        assert_eq!(parent.subtasks[0].key, "DEV-21");
1605        assert_eq!(child.parent.as_deref(), Some("DEV-20"));
1606        assert_eq!(child.updated_at.as_deref(), child.created_at.as_deref());
1607    }
1608
1609    #[tokio::test]
1610    async fn create_issue_posts_task_and_fetches_created_issue() {
1611        let server = MockServer::start_async().await;
1612        server
1613            .mock_async(|when, then| {
1614                when.method(GET)
1615                    .path("/api-v2/columns")
1616                    .query_param("boardId", "board-1")
1617                    .query_param("limit", "1000")
1618                    .query_param("offset", "0");
1619                then.status(200).json_body_obj(&serde_json::json!({
1620                    "paging": { "limit": 1000, "offset": 0, "next": false },
1621                    "content": [
1622                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
1623                    ]
1624                }));
1625            })
1626            .await;
1627        server
1628            .mock_async(|when, then| {
1629                when.method(GET)
1630                    .path("/api-v2/task-list")
1631                    .query_param("columnId", "col-1")
1632                    .query_param("limit", "1000")
1633                    .query_param("offset", "0")
1634                    .query_param("includeDeleted", "true");
1635                then.status(200).json_body_obj(&serde_json::json!({
1636                    "paging": { "limit": 1000, "offset": 0, "next": false },
1637                    "content": [
1638                        {
1639                            "id": "task-1",
1640                            "title": "Create task",
1641                            "timestamp": 1710000000000_u64,
1642                            "columnId": "col-1",
1643                            "description": "body",
1644                            "completed": false,
1645                            "archived": false,
1646                            "deleted": false,
1647                            "assigned": ["user-1"],
1648                            "idTaskProject": "DEV-10"
1649                        }
1650                    ]
1651                }));
1652            })
1653            .await;
1654        server
1655            .mock_async(|when, then| {
1656                when.method(POST).path("/api-v2/tasks");
1657                then.status(201).json_body_obj(&serde_json::json!({
1658                    "id": "task-1"
1659                }));
1660            })
1661            .await;
1662        server
1663            .mock_async(|when, then| {
1664                when.method(GET).path("/api-v2/tasks/task-1");
1665                then.status(200).json_body_obj(&serde_json::json!({
1666                    "id": "task-1",
1667                    "title": "Create task",
1668                    "timestamp": 1710000000000_u64,
1669                    "columnId": "col-1",
1670                    "description": "body",
1671                    "completed": false,
1672                    "archived": false,
1673                    "deleted": false,
1674                    "assigned": ["user-1"],
1675                    "idTaskProject": "DEV-10"
1676                }));
1677            })
1678            .await;
1679
1680        let client = YouGileClient::with_base_url(
1681            format!("{}/api-v2", server.base_url()),
1682            "board-1",
1683            SecretString::from("token".to_owned()),
1684        );
1685        let issue = client
1686            .create_issue(CreateIssueInput {
1687                title: "Create task".to_string(),
1688                description: Some("body".to_string()),
1689                assignees: vec!["user-1".to_string()],
1690                ..Default::default()
1691            })
1692            .await
1693            .unwrap();
1694        assert_eq!(issue.key, "DEV-10");
1695        assert_eq!(issue.status.as_deref(), Some("To Do"));
1696    }
1697
1698    #[tokio::test]
1699    async fn create_issue_with_parent_updates_parent_subtasks() {
1700        let server = MockServer::start_async().await;
1701        server
1702            .mock_async(|when, then| {
1703                when.method(GET)
1704                    .path("/api-v2/columns")
1705                    .query_param("boardId", "board-1")
1706                    .query_param("limit", "1000")
1707                    .query_param("offset", "0");
1708                then.status(200).json_body_obj(&serde_json::json!({
1709                    "paging": { "limit": 1000, "offset": 0, "next": false },
1710                    "content": [
1711                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
1712                    ]
1713                }));
1714            })
1715            .await;
1716        server
1717            .mock_async(|when, then| {
1718                when.method(POST).path("/api-v2/tasks");
1719                then.status(201).json_body_obj(&serde_json::json!({
1720                    "id": "task-2"
1721                }));
1722            })
1723            .await;
1724        server
1725            .mock_async(|when, then| {
1726                when.method(GET)
1727                    .path("/api-v2/task-list")
1728                    .query_param("columnId", "col-1")
1729                    .query_param("limit", "1000")
1730                    .query_param("offset", "0")
1731                    .query_param("includeDeleted", "true");
1732                then.status(200).json_body_obj(&serde_json::json!({
1733                    "paging": { "limit": 1000, "offset": 0, "next": false },
1734                    "content": [
1735                        {
1736                            "id": "parent-1",
1737                            "title": "Parent task",
1738                            "timestamp": 1710000000000_u64,
1739                            "columnId": "col-1",
1740                            "completed": false,
1741                            "archived": false,
1742                            "deleted": false,
1743                            "idTaskProject": "DEV-700",
1744                            "subtasks": ["task-2"]
1745                        },
1746                        {
1747                            "id": "task-2",
1748                            "title": "New child",
1749                            "timestamp": 1710000001000_u64,
1750                            "columnId": "col-1",
1751                            "completed": false,
1752                            "archived": false,
1753                            "deleted": false,
1754                            "idTaskProject": "DEV-701"
1755                        }
1756                    ]
1757                }));
1758            })
1759            .await;
1760        server
1761            .mock_async(|when, then| {
1762                when.method(PUT)
1763                    .path("/api-v2/tasks/parent-1")
1764                    .json_body_obj(&serde_json::json!({
1765                        "subtasks": ["task-2"]
1766                    }));
1767                then.status(200).json_body_obj(&serde_json::json!({
1768                    "id": "parent-1"
1769                }));
1770            })
1771            .await;
1772        server
1773            .mock_async(|when, then| {
1774                when.method(GET).path("/api-v2/tasks/task-2");
1775                then.status(200).json_body_obj(&serde_json::json!({
1776                    "id": "task-2",
1777                    "title": "New child",
1778                    "timestamp": 1710000001000_u64,
1779                    "columnId": "col-1",
1780                    "completed": false,
1781                    "archived": false,
1782                    "deleted": false,
1783                    "idTaskProject": "DEV-701"
1784                }));
1785            })
1786            .await;
1787
1788        let client = YouGileClient::with_base_url(
1789            format!("{}/api-v2", server.base_url()),
1790            "board-1",
1791            SecretString::from("token".to_owned()),
1792        );
1793        let issue = client
1794            .create_issue(CreateIssueInput {
1795                title: "New child".to_string(),
1796                parent: Some("DEV-700".to_string()),
1797                ..Default::default()
1798            })
1799            .await
1800            .unwrap();
1801
1802        assert_eq!(issue.key, "DEV-701");
1803        assert_eq!(issue.parent.as_deref(), Some("DEV-700"));
1804    }
1805
1806    #[tokio::test]
1807    async fn update_issue_maps_status_to_column_and_refetches() {
1808        let server = MockServer::start_async().await;
1809        server
1810            .mock_async(|when, then| {
1811                when.method(GET)
1812                    .path("/api-v2/columns")
1813                    .query_param("boardId", "board-1")
1814                    .query_param("limit", "1000")
1815                    .query_param("offset", "0");
1816                then.status(200).json_body_obj(&serde_json::json!({
1817                    "paging": { "limit": 1000, "offset": 0, "next": false },
1818                    "content": [
1819                        { "id": "col-1", "title": "To Do", "boardId": "board-1" },
1820                        { "id": "col-2", "title": "Done", "boardId": "board-1" }
1821                    ]
1822                }));
1823            })
1824            .await;
1825        server
1826            .mock_async(|when, then| {
1827                when.method(GET)
1828                    .path("/api-v2/task-list")
1829                    .query_param("columnId", "col-1")
1830                    .query_param("limit", "1000")
1831                    .query_param("offset", "0")
1832                    .query_param("includeDeleted", "true");
1833                then.status(200).json_body_obj(&serde_json::json!({
1834                    "paging": { "limit": 1000, "offset": 0, "next": false },
1835                    "content": [
1836                        {
1837                            "id": "task-1",
1838                            "title": "Updated task",
1839                            "timestamp": 1710000000000_u64,
1840                            "columnId": "col-2",
1841                            "completed": false,
1842                            "archived": false,
1843                            "deleted": false,
1844                            "idTaskProject": "DEV-11"
1845                        }
1846                    ]
1847                }));
1848            })
1849            .await;
1850        server
1851            .mock_async(|when, then| {
1852                when.method(GET)
1853                    .path("/api-v2/task-list")
1854                    .query_param("columnId", "col-2")
1855                    .query_param("limit", "1000")
1856                    .query_param("offset", "0")
1857                    .query_param("includeDeleted", "true");
1858                then.status(200).json_body_obj(&serde_json::json!({
1859                    "paging": { "limit": 1000, "offset": 0, "next": false },
1860                    "content": [
1861                        {
1862                            "id": "task-1",
1863                            "title": "Updated task",
1864                            "timestamp": 1710000000000_u64,
1865                            "columnId": "col-2",
1866                            "completed": false,
1867                            "archived": false,
1868                            "deleted": false,
1869                            "idTaskProject": "DEV-11"
1870                        }
1871                    ]
1872                }));
1873            })
1874            .await;
1875        server
1876            .mock_async(|when, then| {
1877                when.method(PUT)
1878                    .path("/api-v2/tasks/task-1")
1879                    .json_body_obj(&serde_json::json!({
1880                        "title": "Updated task",
1881                        "columnId": "col-2",
1882                        "completed": true,
1883                        "archived": false,
1884                        "deleted": false
1885                    }));
1886                then.status(200).json_body_obj(&serde_json::json!({
1887                    "id": "task-1"
1888                }));
1889            })
1890            .await;
1891        server
1892            .mock_async(|when, then| {
1893                when.method(GET).path("/api-v2/tasks/task-1");
1894                then.status(200).json_body_obj(&serde_json::json!({
1895                    "id": "task-1",
1896                    "title": "Updated task",
1897                    "timestamp": 1710000000000_u64,
1898                    "columnId": "col-2",
1899                    "completed": false,
1900                    "archived": false,
1901                    "deleted": false,
1902                    "idTaskProject": "DEV-11"
1903                }));
1904            })
1905            .await;
1906
1907        let client = YouGileClient::with_base_url(
1908            format!("{}/api-v2", server.base_url()),
1909            "board-1",
1910            SecretString::from("token".to_owned()),
1911        );
1912        let issue = client
1913            .update_issue(
1914                "yougile#task-1",
1915                UpdateIssueInput {
1916                    title: Some("Updated task".to_string()),
1917                    status: Some("Done".to_string()),
1918                    ..Default::default()
1919                },
1920            )
1921            .await
1922            .unwrap();
1923        assert_eq!(issue.key, "DEV-11");
1924        assert_eq!(issue.status.as_deref(), Some("Done"));
1925    }
1926
1927    #[tokio::test]
1928    async fn update_issue_status_reopens_task_when_moved_to_active_column() {
1929        let server = MockServer::start_async().await;
1930        server
1931            .mock_async(|when, then| {
1932                when.method(GET)
1933                    .path("/api-v2/columns")
1934                    .query_param("boardId", "board-1")
1935                    .query_param("limit", "1000")
1936                    .query_param("offset", "0");
1937                then.status(200).json_body_obj(&serde_json::json!({
1938                    "paging": { "limit": 1000, "offset": 0, "next": false },
1939                    "content": [
1940                        { "id": "col-1", "title": "To Do", "boardId": "board-1" },
1941                        { "id": "col-2", "title": "Done", "boardId": "board-1" }
1942                    ]
1943                }));
1944            })
1945            .await;
1946        server
1947            .mock_async(|when, then| {
1948                when.method(GET)
1949                    .path("/api-v2/task-list")
1950                    .query_param("columnId", "col-1")
1951                    .query_param("limit", "1000")
1952                    .query_param("offset", "0")
1953                    .query_param("includeDeleted", "true");
1954                then.status(200).json_body_obj(&serde_json::json!({
1955                    "paging": { "limit": 1000, "offset": 0, "next": false },
1956                    "content": [
1957                        {
1958                            "id": "task-1",
1959                            "title": "Reopened task",
1960                            "timestamp": 1710000000000_u64,
1961                            "columnId": "col-1",
1962                            "completed": false,
1963                            "archived": false,
1964                            "deleted": false,
1965                            "idTaskProject": "DEV-12"
1966                        }
1967                    ]
1968                }));
1969            })
1970            .await;
1971        server
1972            .mock_async(|when, then| {
1973                when.method(GET)
1974                    .path("/api-v2/task-list")
1975                    .query_param("columnId", "col-2")
1976                    .query_param("limit", "1000")
1977                    .query_param("offset", "0")
1978                    .query_param("includeDeleted", "true");
1979                then.status(200).json_body_obj(&serde_json::json!({
1980                    "paging": { "limit": 1000, "offset": 0, "next": false },
1981                    "content": []
1982                }));
1983            })
1984            .await;
1985        server
1986            .mock_async(|when, then| {
1987                when.method(PUT)
1988                    .path("/api-v2/tasks/task-1")
1989                    .json_body_obj(&serde_json::json!({
1990                        "columnId": "col-1",
1991                        "completed": false,
1992                        "archived": false,
1993                        "deleted": false
1994                    }));
1995                then.status(200).json_body_obj(&serde_json::json!({
1996                    "id": "task-1"
1997                }));
1998            })
1999            .await;
2000        server
2001            .mock_async(|when, then| {
2002                when.method(GET).path("/api-v2/tasks/task-1");
2003                then.status(200).json_body_obj(&serde_json::json!({
2004                    "id": "task-1",
2005                    "title": "Reopened task",
2006                    "timestamp": 1710000000000_u64,
2007                    "columnId": "col-1",
2008                    "completed": false,
2009                    "archived": false,
2010                    "deleted": false,
2011                    "idTaskProject": "DEV-12"
2012                }));
2013            })
2014            .await;
2015
2016        let client = YouGileClient::with_base_url(
2017            format!("{}/api-v2", server.base_url()),
2018            "board-1",
2019            SecretString::from("token".to_owned()),
2020        );
2021        let issue = client
2022            .update_issue(
2023                "yougile#task-1",
2024                UpdateIssueInput {
2025                    status: Some("To Do".to_string()),
2026                    ..Default::default()
2027                },
2028            )
2029            .await
2030            .unwrap();
2031        assert_eq!(issue.key, "DEV-12");
2032        assert_eq!(issue.state, "open");
2033        assert_eq!(issue.status.as_deref(), Some("To Do"));
2034    }
2035
2036    #[tokio::test]
2037    async fn update_issue_state_closed_clears_cancelled_flags() {
2038        let server = MockServer::start_async().await;
2039        server
2040            .mock_async(|when, then| {
2041                when.method(GET)
2042                    .path("/api-v2/columns")
2043                    .query_param("boardId", "board-1")
2044                    .query_param("limit", "1000")
2045                    .query_param("offset", "0");
2046                then.status(200).json_body_obj(&serde_json::json!({
2047                    "paging": { "limit": 1000, "offset": 0, "next": false },
2048                    "content": [
2049                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
2050                    ]
2051                }));
2052            })
2053            .await;
2054        server
2055            .mock_async(|when, then| {
2056                when.method(GET)
2057                    .path("/api-v2/task-list")
2058                    .query_param("columnId", "col-1")
2059                    .query_param("limit", "1000")
2060                    .query_param("offset", "0")
2061                    .query_param("includeDeleted", "true");
2062                then.status(200).json_body_obj(&serde_json::json!({
2063                    "paging": { "limit": 1000, "offset": 0, "next": false },
2064                    "content": [
2065                        {
2066                            "id": "task-1",
2067                            "title": "Updated task",
2068                            "timestamp": 1710000000000_u64,
2069                            "columnId": "col-1",
2070                            "completed": false,
2071                            "archived": true,
2072                            "deleted": false,
2073                            "idTaskProject": "DEV-11"
2074                        }
2075                    ]
2076                }));
2077            })
2078            .await;
2079        server
2080            .mock_async(|when, then| {
2081                when.method(PUT)
2082                    .path("/api-v2/tasks/task-1")
2083                    .json_body_obj(&serde_json::json!({
2084                        "completed": true,
2085                        "archived": false,
2086                        "deleted": false
2087                    }));
2088                then.status(200).json_body_obj(&serde_json::json!({
2089                    "id": "task-1"
2090                }));
2091            })
2092            .await;
2093        server
2094            .mock_async(|when, then| {
2095                when.method(GET).path("/api-v2/tasks/task-1");
2096                then.status(200).json_body_obj(&serde_json::json!({
2097                    "id": "task-1",
2098                    "title": "Updated task",
2099                    "timestamp": 1710000000000_u64,
2100                    "columnId": "col-1",
2101                    "completed": true,
2102                    "archived": false,
2103                    "deleted": false,
2104                    "idTaskProject": "DEV-11"
2105                }));
2106            })
2107            .await;
2108
2109        let client = YouGileClient::with_base_url(
2110            format!("{}/api-v2", server.base_url()),
2111            "board-1",
2112            SecretString::from("token".to_owned()),
2113        );
2114        let issue = client
2115            .update_issue(
2116                "yougile#task-1",
2117                UpdateIssueInput {
2118                    state: Some("closed".to_string()),
2119                    ..Default::default()
2120                },
2121            )
2122            .await
2123            .unwrap();
2124        assert_eq!(issue.state, "closed");
2125    }
2126
2127    #[tokio::test]
2128    async fn update_issue_parent_id_attaches_task_to_parent() {
2129        let server = MockServer::start_async().await;
2130        server
2131            .mock_async(|when, then| {
2132                when.method(GET)
2133                    .path("/api-v2/task-list")
2134                    .query_param("columnId", "col-1")
2135                    .query_param("limit", "1000")
2136                    .query_param("offset", "0")
2137                    .query_param("includeDeleted", "true");
2138                then.status(200).json_body_obj(&serde_json::json!({
2139                    "paging": { "limit": 1000, "offset": 0, "next": false },
2140                    "content": [
2141                        {
2142                            "id": "parent-1",
2143                            "title": "Parent task",
2144                            "timestamp": 1710000000000_u64,
2145                            "columnId": "col-1",
2146                            "completed": false,
2147                            "archived": false,
2148                            "deleted": false,
2149                            "idTaskProject": "DEV-800"
2150                        },
2151                        {
2152                            "id": "task-1",
2153                            "title": "Child task",
2154                            "timestamp": 1710000001000_u64,
2155                            "columnId": "col-1",
2156                            "completed": false,
2157                            "archived": false,
2158                            "deleted": false,
2159                            "idTaskProject": "DEV-801"
2160                        }
2161                    ]
2162                }));
2163            })
2164            .await;
2165        server
2166            .mock_async(|when, then| {
2167                when.method(PUT)
2168                    .path("/api-v2/tasks/parent-1")
2169                    .json_body_obj(&serde_json::json!({
2170                        "subtasks": ["task-1"]
2171                    }));
2172                then.status(200).json_body_obj(&serde_json::json!({
2173                    "id": "parent-1"
2174                }));
2175            })
2176            .await;
2177        server
2178            .mock_async(|when, then| {
2179                when.method(GET).path("/api-v2/tasks/task-1");
2180                then.status(200).json_body_obj(&serde_json::json!({
2181                    "id": "task-1",
2182                    "title": "Child task",
2183                    "timestamp": 1710000001000_u64,
2184                    "columnId": "col-1",
2185                    "completed": false,
2186                    "archived": false,
2187                    "deleted": false,
2188                    "idTaskProject": "DEV-801"
2189                }));
2190            })
2191            .await;
2192        server
2193            .mock_async(|when, then| {
2194                when.method(GET)
2195                    .path("/api-v2/columns")
2196                    .query_param("boardId", "board-1")
2197                    .query_param("limit", "1000")
2198                    .query_param("offset", "0");
2199                then.status(200).json_body_obj(&serde_json::json!({
2200                    "paging": { "limit": 1000, "offset": 0, "next": false },
2201                    "content": [
2202                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
2203                    ]
2204                }));
2205            })
2206            .await;
2207
2208        let client = YouGileClient::with_base_url(
2209            format!("{}/api-v2", server.base_url()),
2210            "board-1",
2211            SecretString::from("token".to_owned()),
2212        );
2213        let issue = client
2214            .update_issue(
2215                "yougile#task-1",
2216                UpdateIssueInput {
2217                    parent_id: Some("DEV-800".to_string()),
2218                    ..Default::default()
2219                },
2220            )
2221            .await
2222            .unwrap();
2223
2224        assert_eq!(issue.key, "DEV-801");
2225        assert_eq!(issue.parent.as_deref(), Some("DEV-800"));
2226    }
2227
2228    #[tokio::test]
2229    async fn update_issue_parent_id_none_detaches_task_from_parent() {
2230        let server = MockServer::start_async().await;
2231        server
2232            .mock_async(|when, then| {
2233                when.method(GET)
2234                    .path("/api-v2/task-list")
2235                    .query_param("columnId", "col-1")
2236                    .query_param("limit", "1000")
2237                    .query_param("offset", "0")
2238                    .query_param("includeDeleted", "true");
2239                then.status(200).json_body_obj(&serde_json::json!({
2240                    "paging": { "limit": 1000, "offset": 0, "next": false },
2241                    "content": [
2242                        {
2243                            "id": "parent-1",
2244                            "title": "Parent task",
2245                            "timestamp": 1710000000000_u64,
2246                            "columnId": "col-1",
2247                            "completed": false,
2248                            "archived": false,
2249                            "deleted": false,
2250                            "idTaskProject": "DEV-900",
2251                            "subtasks": ["task-1"]
2252                        },
2253                        {
2254                            "id": "task-1",
2255                            "title": "Child task",
2256                            "timestamp": 1710000001000_u64,
2257                            "columnId": "col-1",
2258                            "completed": false,
2259                            "archived": false,
2260                            "deleted": false,
2261                            "idTaskProject": "DEV-901"
2262                        }
2263                    ]
2264                }));
2265            })
2266            .await;
2267        server
2268            .mock_async(|when, then| {
2269                when.method(PUT)
2270                    .path("/api-v2/tasks/parent-1")
2271                    .json_body_obj(&serde_json::json!({
2272                        "subtasks": []
2273                    }));
2274                then.status(200).json_body_obj(&serde_json::json!({
2275                    "id": "parent-1"
2276                }));
2277            })
2278            .await;
2279        server
2280            .mock_async(|when, then| {
2281                when.method(GET).path("/api-v2/tasks/task-1");
2282                then.status(200).json_body_obj(&serde_json::json!({
2283                    "id": "task-1",
2284                    "title": "Child task",
2285                    "timestamp": 1710000001000_u64,
2286                    "columnId": "col-1",
2287                    "completed": false,
2288                    "archived": false,
2289                    "deleted": false,
2290                    "idTaskProject": "DEV-901"
2291                }));
2292            })
2293            .await;
2294        server
2295            .mock_async(|when, then| {
2296                when.method(GET)
2297                    .path("/api-v2/columns")
2298                    .query_param("boardId", "board-1")
2299                    .query_param("limit", "1000")
2300                    .query_param("offset", "0");
2301                then.status(200).json_body_obj(&serde_json::json!({
2302                    "paging": { "limit": 1000, "offset": 0, "next": false },
2303                    "content": [
2304                        { "id": "col-1", "title": "To Do", "boardId": "board-1" }
2305                    ]
2306                }));
2307            })
2308            .await;
2309
2310        let client = YouGileClient::with_base_url(
2311            format!("{}/api-v2", server.base_url()),
2312            "board-1",
2313            SecretString::from("token".to_owned()),
2314        );
2315        let issue = client
2316            .update_issue(
2317                "yougile#task-1",
2318                UpdateIssueInput {
2319                    parent_id: Some("none".to_string()),
2320                    ..Default::default()
2321                },
2322            )
2323            .await
2324            .unwrap();
2325
2326        assert_eq!(issue.key, "DEV-901");
2327        assert_eq!(issue.parent, None);
2328    }
2329
2330    #[tokio::test]
2331    async fn create_issue_rejects_generic_labels_and_priority() {
2332        let client = YouGileClient::new("board-1", SecretString::from("token".to_owned()));
2333
2334        let err = client
2335            .create_issue(CreateIssueInput {
2336                title: "Task".to_string(),
2337                labels: vec!["bug".to_string()],
2338                priority: Some("high".to_string()),
2339                ..Default::default()
2340            })
2341            .await
2342            .unwrap_err();
2343
2344        assert!(matches!(err, Error::InvalidData(msg) if msg.contains("generic labels")));
2345    }
2346
2347    #[tokio::test]
2348    async fn update_issue_rejects_generic_labels_and_priority() {
2349        let client = YouGileClient::new("board-1", SecretString::from("token".to_owned()));
2350
2351        let err = client
2352            .update_issue(
2353                "yougile#task-1",
2354                UpdateIssueInput {
2355                    labels: Some(vec!["bug".to_string()]),
2356                    priority: Some("high".to_string()),
2357                    ..Default::default()
2358                },
2359            )
2360            .await
2361            .unwrap_err();
2362
2363        assert!(matches!(err, Error::InvalidData(msg) if msg.contains("generic labels")));
2364    }
2365
2366    #[tokio::test]
2367    async fn get_comments_reads_task_chat_messages() {
2368        let server = MockServer::start_async().await;
2369        server
2370            .mock_async(|when, then| {
2371                when.method(GET)
2372                    .path("/api-v2/chats/task-1/messages")
2373                    .query_param("limit", "1000")
2374                    .query_param("offset", "0")
2375                    .query_param("includeSystem", "false");
2376                then.status(200).json_body_obj(&serde_json::json!({
2377                    "paging": { "limit": 1000, "offset": 0, "next": false },
2378                    "content": [
2379                        {
2380                            "id": 1710000000000_u64,
2381                            "fromUserId": "user-1",
2382                            "text": "First comment",
2383                            "textHtml": "<p>First comment</p>",
2384                            "label": "",
2385                            "editTimestamp": 1710000000000_u64,
2386                            "reactions": {}
2387                        }
2388                    ]
2389                }));
2390            })
2391            .await;
2392        server
2393            .mock_async(|when, then| {
2394                when.method(GET).path("/api-v2/users/user-1");
2395                then.status(200).json_body_obj(&serde_json::json!({
2396                    "id": "user-1",
2397                    "email": "commenter@example.com",
2398                    "realName": "Comment Author"
2399                }));
2400            })
2401            .await;
2402
2403        let client = YouGileClient::with_base_url(
2404            format!("{}/api-v2", server.base_url()),
2405            "board-1",
2406            SecretString::from("token".to_owned()),
2407        );
2408        let comments = client.get_comments("yougile#task-1").await.unwrap();
2409        assert_eq!(comments.items.len(), 1);
2410        assert_eq!(comments.items[0].body, "First comment");
2411        assert_eq!(comments.items[0].author.as_ref().unwrap().id, "user-1");
2412        assert_eq!(
2413            comments.items[0].author.as_ref().unwrap().username,
2414            "commenter@example.com"
2415        );
2416    }
2417
2418    #[tokio::test]
2419    async fn add_comment_posts_chat_message_and_returns_created_comment() {
2420        let server = MockServer::start_async().await;
2421        server
2422            .mock_async(|when, then| {
2423                when.method(POST).path("/api-v2/chats/task-1/messages");
2424                then.status(201).json_body_obj(&serde_json::json!({
2425                    "id": 1710000000001_u64
2426                }));
2427            })
2428            .await;
2429        server
2430            .mock_async(|when, then| {
2431                when.method(GET)
2432                    .path("/api-v2/chats/task-1/messages")
2433                    .query_param("limit", "1000")
2434                    .query_param("offset", "0")
2435                    .query_param("includeSystem", "false");
2436                then.status(200).json_body_obj(&serde_json::json!({
2437                    "paging": { "limit": 1000, "offset": 0, "next": false },
2438                    "content": [
2439                        {
2440                            "id": 1710000000001_u64,
2441                            "fromUserId": "user-2",
2442                            "text": "Hello",
2443                            "textHtml": "<p>Hello</p>",
2444                            "label": "",
2445                            "editTimestamp": 1710000000001_u64,
2446                            "reactions": {}
2447                        }
2448                    ]
2449                }));
2450            })
2451            .await;
2452        server
2453            .mock_async(|when, then| {
2454                when.method(GET).path("/api-v2/users/user-2");
2455                then.status(200).json_body_obj(&serde_json::json!({
2456                    "id": "user-2",
2457                    "email": "writer@example.com",
2458                    "realName": "Comment Writer"
2459                }));
2460            })
2461            .await;
2462
2463        let client = YouGileClient::with_base_url(
2464            format!("{}/api-v2", server.base_url()),
2465            "board-1",
2466            SecretString::from("token".to_owned()),
2467        );
2468        let comment = IssueProvider::add_comment(&client, "yougile#task-1", "Hello")
2469            .await
2470            .unwrap();
2471        assert_eq!(comment.body, "Hello");
2472        assert_eq!(comment.author.as_ref().unwrap().id, "user-2");
2473        assert_eq!(
2474            comment.author.as_ref().unwrap().name.as_deref(),
2475            Some("Comment Writer")
2476        );
2477    }
2478
2479    #[tokio::test]
2480    async fn rate_limiter_spaces_requests() {
2481        let limiter = YouGileRateLimiter::new(Duration::from_millis(25));
2482        let start = std::time::Instant::now();
2483
2484        limiter.acquire().await;
2485        limiter.acquire().await;
2486        limiter.acquire().await;
2487
2488        assert!(
2489            start.elapsed() >= Duration::from_millis(45),
2490            "expected spaced acquires, got {:?}",
2491            start.elapsed()
2492        );
2493    }
2494
2495    #[test]
2496    fn map_status_category_uses_column_name_heuristics() {
2497        let task = YouGileTask {
2498            id: "task-1".to_string(),
2499            title: "Task".to_string(),
2500            timestamp: 0,
2501            column_id: Some("col-1".to_string()),
2502            description: None,
2503            archived: false,
2504            completed: false,
2505            deleted: false,
2506            subtask_ids: Vec::new(),
2507            assigned_ids: Vec::new(),
2508            created_by: None,
2509            id_task_common: None,
2510            id_task_project: None,
2511            stickers: None,
2512        };
2513
2514        assert_eq!(map_status_category(&task, Some("Backlog")), "backlog");
2515        assert_eq!(map_status_category(&task, Some("To Do")), "todo");
2516        assert_eq!(
2517            map_status_category(&task, Some("Code Review")),
2518            "in_progress"
2519        );
2520        assert_eq!(map_status_category(&task, Some("Done")), "done");
2521        assert_eq!(map_status_category(&task, Some("Cancelled")), "cancelled");
2522    }
2523
2524    #[tokio::test]
2525    async fn parse_json_response_preserves_retry_after_on_http_429() {
2526        let server = MockServer::start_async().await;
2527        server
2528            .mock_async(|when, then| {
2529                when.method(GET).path("/api-v2/test-rate-limit");
2530                then.status(429)
2531                    .header("Retry-After", "7")
2532                    .body("Too Many Requests");
2533            })
2534            .await;
2535
2536        let response = reqwest::Client::new()
2537            .get(format!("{}/api-v2/test-rate-limit", server.base_url()))
2538            .send()
2539            .await
2540            .unwrap();
2541        let err = parse_json_response::<Value>(response).await.unwrap_err();
2542
2543        assert!(matches!(
2544            err,
2545            Error::RateLimited {
2546                retry_after: Some(7)
2547            }
2548        ));
2549    }
2550}