Skip to main content

yt/api/
work_items.rs

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/// What `time log` sets or `time edit` changes; `None` leaves a part out of the request.
61#[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        // YouTrack finds a work item type only by ID.
83        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/// Filters for `GET workItems`; `None` leaves a filter out.
125#[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
133/// Every matching work item, newest first; YouTrack returns them in creation order and cannot sort them.
134pub 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
158/// The work item types of a project, or of the instance without one.
159pub 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        // Some instances hide project settings from non-admins; the instance-wide list still names the types.
169        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
180/// YouTrack refused the request (403), as some instances do for project settings.
181pub fn forbidden(error: &miette::Report) -> bool {
182    matches!(
183        error.downcast_ref::<YtError>(),
184        Some(YtError::Api { status: 403, .. })
185    )
186}
187
188/// A type by name in any case, or by ID.
189pub 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
211/// Work item IDs (`270-1`) go into paths; anything else is rejected.
212pub 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}