1use 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#[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 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 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 pub fn base_url(&self) -> &str {
66 &self.base_url
67 }
68
69 pub fn board_id(&self) -> &str {
71 &self.board_id
72 }
73
74 pub fn http_client(&self) -> &reqwest::Client {
76 &self.client
77 }
78
79 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 if matches!(filter.sort_order.as_deref(), Some("asc")) {
247 tasks.sort_by_key(|task| task.timestamp);
248 }
249
250 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
933fn 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 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
957const 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
971fn 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
990fn 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 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('&', "&")
1230 .replace('<', "<")
1231 .replace('>', ">")
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 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 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 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}