1use futures::TryStreamExt as _;
2use serde::Deserialize;
3use serde_json::{Map, Value, json};
4
5use crate::api::client::LIST_PAGE;
6use crate::api::issues::UserRef;
7use crate::api::projects::{self, WorkItemType};
8use crate::api::{Client, Projection};
9use crate::error::{Result, YtError};
10use crate::fields::{choices, closest, same, usage};
11
12#[derive(Clone, Debug, Deserialize)]
13pub struct DurationValue {
14 #[serde(default)]
15 pub minutes: i64,
16 #[serde(default)]
17 pub presentation: String,
18}
19
20#[derive(Clone, Debug, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct IssueId {
23 pub id_readable: String,
24}
25
26#[derive(Clone, Debug, Deserialize)]
27pub struct WorkItem {
28 pub id: String,
29 #[serde(default)]
30 pub date: i64,
31 pub duration: Option<DurationValue>,
32 #[serde(rename = "type")]
33 pub work_type: Option<WorkItemType>,
34 pub text: Option<String>,
35 pub author: Option<UserRef>,
36 pub issue: Option<IssueId>,
37 #[serde(default)]
38 pub created: i64,
39}
40
41impl Projection for WorkItem {
42 const FIELDS: &'static str = "id,date,duration(minutes,presentation),type(id,name),text,author(login,fullName),issue(idReadable),created";
43}
44
45impl WorkItem {
46 pub fn minutes(&self) -> i64 {
47 self.duration
48 .as_ref()
49 .map_or(0, |duration| duration.minutes)
50 }
51
52 pub fn presentation(&self) -> String {
53 match &self.duration {
54 Some(duration) if !duration.presentation.is_empty() => duration.presentation.clone(),
55 _ => crate::time::hours_minutes(self.minutes()),
56 }
57 }
58}
59
60#[derive(Clone, Debug, Default, PartialEq)]
62pub struct WorkItemChange {
63 pub date: Option<i64>,
64 pub duration: Option<String>,
65 pub type_id: Option<String>,
66 pub text: Option<String>,
67}
68
69impl WorkItemChange {
70 pub fn is_empty(&self) -> bool {
71 *self == Self::default()
72 }
73
74 pub fn body(&self) -> Value {
75 let mut body = Map::new();
76 if let Some(date) = self.date {
77 body.insert("date".into(), json!(date));
78 }
79 if let Some(duration) = &self.duration {
80 body.insert("duration".into(), json!({"presentation": duration}));
81 }
82 if let Some(type_id) = &self.type_id {
84 body.insert("type".into(), json!({"id": type_id}));
85 }
86 if let Some(text) = &self.text {
87 body.insert("text".into(), json!(text));
88 }
89 Value::Object(body)
90 }
91}
92
93pub async fn create(client: &Client, issue: &str, change: &WorkItemChange) -> Result<WorkItem> {
94 client
95 .post(
96 &format!("issues/{issue}/timeTracking/workItems"),
97 &change.body(),
98 )
99 .await
100}
101
102pub async fn update(
103 client: &Client,
104 issue: &str,
105 item: &str,
106 change: &WorkItemChange,
107) -> Result<WorkItem> {
108 check_item(item)?;
109 client
110 .post(
111 &format!("issues/{issue}/timeTracking/workItems/{item}"),
112 &change.body(),
113 )
114 .await
115}
116
117pub async fn delete(client: &Client, issue: &str, item: &str) -> Result<()> {
118 check_item(item)?;
119 client
120 .delete(&format!("issues/{issue}/timeTracking/workItems/{item}"))
121 .await
122}
123
124#[derive(Clone, Debug, Default)]
126pub struct ListFilter {
127 pub query: Option<String>,
128 pub start: Option<String>,
129 pub end: Option<String>,
130 pub author: Option<String>,
131}
132
133pub async fn list(client: &Client, filter: &ListFilter) -> Result<Vec<WorkItem>> {
135 let mut parameters = Vec::new();
136 for (key, value) in [
137 ("query", &filter.query),
138 ("startDate", &filter.start),
139 ("endDate", &filter.end),
140 ("author", &filter.author),
141 ] {
142 if let Some(value) = value {
143 parameters.push((key.to_string(), value.clone()));
144 }
145 }
146 let mut items: Vec<WorkItem> = client
147 .paginate("workItems".to_string(), parameters, LIST_PAGE)
148 .try_collect()
149 .await?;
150 sort_newest_first(&mut items);
151 Ok(items)
152}
153
154pub fn sort_newest_first(items: &mut [WorkItem]) {
155 items.sort_by(|a, b| b.date.cmp(&a.date).then(b.created.cmp(&a.created)));
156}
157
158pub async fn types(client: &Client, project: Option<&str>) -> Result<Vec<WorkItemType>> {
160 let Some(project) = project else {
161 return instance_types(client).await;
162 };
163 match projects::time_tracking(client, project).await {
164 Ok(settings) if !settings.enabled => Err(usage(format!(
165 "time tracking is turned off in project {project}"
166 ))),
167 Ok(settings) => Ok(settings.work_item_types),
168 Err(error) if forbidden(&error) => instance_types(client).await,
170 Err(error) => Err(error),
171 }
172}
173
174async fn instance_types(client: &Client) -> Result<Vec<WorkItemType>> {
175 client
176 .get_all("admin/timeTrackingSettings/workItemTypes")
177 .await
178}
179
180pub fn forbidden(error: &miette::Report) -> bool {
182 matches!(
183 error.downcast_ref::<YtError>(),
184 Some(YtError::Api { status: 403, .. })
185 )
186}
187
188pub fn resolve_type<'a>(types: &'a [WorkItemType], name: &str) -> Result<&'a WorkItemType> {
190 if let Some(found) = types
191 .iter()
192 .find(|candidate| same(&candidate.name, name) || candidate.id == name)
193 {
194 return Ok(found);
195 }
196 let names: Vec<String> = types
197 .iter()
198 .map(|candidate| candidate.name.clone())
199 .collect();
200 let mut message = format!("there is no work item type '{name}'");
201 if let Some(close) = closest(name, names.iter().map(String::as_str)) {
202 message.push_str(&format!(" (did you mean '{close}'?)"));
203 } else if names.is_empty() {
204 message.push_str("; the project has no work item types");
205 } else {
206 message.push_str(&format!("; types: {}", choices(&names)));
207 }
208 Err(usage(message))
209}
210
211pub fn check_item(id: &str) -> Result<()> {
213 if id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
214 && id.chars().any(|c| c.is_ascii_alphanumeric())
215 {
216 Ok(())
217 } else {
218 Err(usage(format!(
219 "'{id}' is not a work item ID; `yt time list` shows them"
220 )))
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use serde_json::json;
227 use wiremock::matchers::{body_json, method, path, query_param};
228 use wiremock::{Mock, MockServer, ResponseTemplate};
229
230 use super::*;
231 use crate::api::test_client;
232
233 fn item(id: &str, date: i64, created: i64) -> serde_json::Value {
234 json!({"id": id, "date": date, "duration": {"minutes": 90, "presentation": "1h 30m"}, "type": null, "text": null, "author": {"login": "jane", "fullName": "Jane"}, "issue": {"idReadable": "WEB-1"}, "created": created})
235 }
236
237 fn named_types() -> Vec<WorkItemType> {
238 ["Izstrāde", "Testing"]
239 .iter()
240 .enumerate()
241 .map(|(index, name)| WorkItemType {
242 id: format!("85-{index}"),
243 name: (*name).into(),
244 })
245 .collect()
246 }
247
248 #[test]
249 fn types_match_in_any_case_and_script() {
250 assert_eq!(resolve_type(&named_types(), "IZSTRĀDE").unwrap().id, "85-0");
251 assert_eq!(
252 resolve_type(&named_types(), "85-1").unwrap().name,
253 "Testing"
254 );
255 let error = resolve_type(&named_types(), "Testng")
256 .unwrap_err()
257 .to_string();
258 assert!(error.contains("did you mean 'Testing'"), "{error}");
259 }
260
261 #[test]
262 fn changes_send_only_what_is_given() {
263 let change = WorkItemChange {
264 duration: Some("2h".into()),
265 type_id: Some("85-0".into()),
266 ..WorkItemChange::default()
267 };
268 assert_eq!(
269 change.body(),
270 json!({"duration": {"presentation": "2h"}, "type": {"id": "85-0"}})
271 );
272 assert!(WorkItemChange::default().is_empty());
273 assert!(!change.is_empty());
274 }
275
276 #[test]
277 fn newest_days_come_first_then_newest_created() {
278 let mut items: Vec<WorkItem> = [item("a", 1, 10), item("b", 3, 5), item("c", 3, 7)]
279 .into_iter()
280 .map(|value| serde_json::from_value(value).unwrap())
281 .collect();
282 sort_newest_first(&mut items);
283 let ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect();
284 assert_eq!(ids, ["c", "b", "a"]);
285 }
286
287 #[test]
288 fn item_ids_cannot_steer_paths() {
289 for id in ["", "..", "../x", "270_1"] {
290 assert!(check_item(id).is_err(), "{id:?}");
291 }
292 assert!(check_item("270-1").is_ok());
293 }
294
295 #[tokio::test]
296 async fn list_reads_every_page_with_the_filters_and_sorts() {
297 let server = MockServer::start().await;
298 let full: Vec<serde_json::Value> = (0..crate::api::client::LIST_PAGE)
299 .map(|n| item(&format!("old-{n}"), 1_000, n as i64))
300 .collect();
301 Mock::given(method("GET"))
302 .and(path("/api/workItems"))
303 .and(query_param("author", "jane"))
304 .and(query_param("startDate", "2026-10-01"))
305 .and(query_param("query", "project: {WEB}"))
306 .and(query_param("$skip", "0"))
307 .respond_with(ResponseTemplate::new(200).set_body_json(full))
308 .mount(&server)
309 .await;
310 Mock::given(method("GET"))
311 .and(path("/api/workItems"))
312 .and(query_param(
313 "$skip",
314 crate::api::client::LIST_PAGE.to_string(),
315 ))
316 .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("new", 2_000, 0)])))
317 .mount(&server)
318 .await;
319 let filter = ListFilter {
320 query: Some("project: {WEB}".into()),
321 start: Some("2026-10-01".into()),
322 end: None,
323 author: Some("jane".into()),
324 };
325 let items = list(&test_client(&server.uri()), &filter).await.unwrap();
326 assert_eq!(items.len(), crate::api::client::LIST_PAGE + 1);
327 assert_eq!(items[0].id, "new");
328 }
329
330 #[tokio::test]
331 async fn a_project_with_time_tracking_off_has_no_types() {
332 let server = MockServer::start().await;
333 Mock::given(method("GET"))
334 .and(path("/api/admin/projects/WEB/timeTrackingSettings"))
335 .respond_with(
336 ResponseTemplate::new(200)
337 .set_body_json(json!({"enabled": false, "workItemTypes": []})),
338 )
339 .mount(&server)
340 .await;
341 let error = types(&test_client(&server.uri()), Some("WEB"))
342 .await
343 .unwrap_err()
344 .to_string();
345 assert!(
346 error.contains("time tracking is turned off in project WEB"),
347 "{error}"
348 );
349 }
350
351 #[tokio::test]
352 async fn hidden_project_settings_fall_back_to_the_instance_types() {
353 let server = MockServer::start().await;
354 Mock::given(method("GET"))
355 .and(path("/api/admin/projects/WEB/timeTrackingSettings"))
356 .respond_with(
357 ResponseTemplate::new(403).set_body_json(
358 json!({"error": "Forbidden", "error_description": "Access denied"}),
359 ),
360 )
361 .mount(&server)
362 .await;
363 Mock::given(method("GET"))
364 .and(path("/api/admin/timeTrackingSettings/workItemTypes"))
365 .respond_with(
366 ResponseTemplate::new(200)
367 .set_body_json(json!([{"id": "85-0", "name": "Development"}])),
368 )
369 .mount(&server)
370 .await;
371 let found = types(&test_client(&server.uri()), Some("WEB"))
372 .await
373 .unwrap();
374 assert_eq!(found[0].name, "Development");
375 }
376
377 #[tokio::test]
378 async fn items_are_created_updated_and_deleted_on_the_issue() {
379 let server = MockServer::start().await;
380 Mock::given(method("POST"))
381 .and(path("/api/issues/WEB-1/timeTracking/workItems"))
382 .and(body_json(
383 json!({"date": 1_791_590_400_000_i64, "duration": {"presentation": "1h 30m"}}),
384 ))
385 .respond_with(ResponseTemplate::new(200).set_body_json(item(
386 "270-1",
387 1_791_590_400_000,
388 1,
389 )))
390 .expect(1)
391 .mount(&server)
392 .await;
393 Mock::given(method("POST"))
394 .and(path("/api/issues/WEB-1/timeTracking/workItems/270-1"))
395 .and(body_json(json!({"text": "Fixed"})))
396 .respond_with(ResponseTemplate::new(200).set_body_json(item(
397 "270-1",
398 1_791_590_400_000,
399 1,
400 )))
401 .expect(1)
402 .mount(&server)
403 .await;
404 Mock::given(method("DELETE"))
405 .and(path("/api/issues/WEB-1/timeTracking/workItems/270-1"))
406 .respond_with(ResponseTemplate::new(200))
407 .expect(1)
408 .mount(&server)
409 .await;
410 let client = test_client(&server.uri());
411 let created = create(
412 &client,
413 "WEB-1",
414 &WorkItemChange {
415 date: Some(1_791_590_400_000),
416 duration: Some("1h 30m".into()),
417 ..WorkItemChange::default()
418 },
419 )
420 .await
421 .unwrap();
422 assert_eq!(created.minutes(), 90);
423 update(
424 &client,
425 "WEB-1",
426 "270-1",
427 &WorkItemChange {
428 text: Some("Fixed".into()),
429 ..WorkItemChange::default()
430 },
431 )
432 .await
433 .unwrap();
434 delete(&client, "WEB-1", "270-1").await.unwrap();
435 }
436}