Skip to main content

yt/api/
agiles.rs

1use serde::Deserialize;
2use serde_json::{Map, Value, json};
3
4use crate::api::issues::{IssueRow, ProjectRef, UserRef};
5use crate::api::{Client, Projection};
6use crate::error::Result;
7use crate::fields::{choices, closest, same, usage};
8
9#[derive(Clone, Debug, Deserialize)]
10pub struct Named {
11    pub name: String,
12}
13
14#[derive(Clone, Debug, Deserialize)]
15pub struct SprintRef {
16    pub id: String,
17    pub name: String,
18}
19
20#[derive(Clone, Debug, Default, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct SprintsSettings {
23    #[serde(default)]
24    pub disable_sprints: bool,
25    #[serde(default)]
26    pub is_explicit: bool,
27}
28
29#[derive(Clone, Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Column {
32    #[serde(default)]
33    pub presentation: String,
34    #[serde(default)]
35    pub is_resolved: bool,
36    #[serde(default)]
37    pub field_values: Vec<Named>,
38}
39
40#[derive(Clone, Debug, Default, Deserialize)]
41pub struct ColumnSettings {
42    pub field: Option<Named>,
43    #[serde(default)]
44    pub columns: Vec<Column>,
45}
46
47#[derive(Clone, Debug, Deserialize)]
48pub struct SwimlaneSettings {
49    #[serde(default)]
50    pub enabled: bool,
51    pub field: Option<Named>,
52}
53
54#[derive(Clone, Debug, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct Board {
57    pub id: String,
58    pub name: String,
59    pub owner: Option<UserRef>,
60    #[serde(default)]
61    pub projects: Vec<ProjectRef>,
62    pub sprints_settings: Option<SprintsSettings>,
63    pub current_sprint: Option<SprintRef>,
64    pub column_settings: Option<ColumnSettings>,
65    pub swimlane_settings: Option<SwimlaneSettings>,
66}
67
68impl Projection for Board {
69    const FIELDS: &'static str = "id,name,owner(login,fullName),projects(shortName,name),sprintsSettings(disableSprints,isExplicit),currentSprint(id,name),columnSettings(field(name),columns(presentation,isResolved,fieldValues(name))),swimlaneSettings(enabled,field(name))";
70}
71
72impl Board {
73    pub fn sprints_enabled(&self) -> bool {
74        !self
75            .sprints_settings
76            .as_ref()
77            .is_some_and(|settings| settings.disable_sprints)
78    }
79}
80
81#[derive(Clone, Debug, Deserialize)]
82pub struct Sprint {
83    pub id: String,
84    pub name: String,
85    pub goal: Option<String>,
86    pub start: Option<i64>,
87    pub finish: Option<i64>,
88    #[serde(default)]
89    pub archived: bool,
90}
91
92impl Projection for Sprint {
93    const FIELDS: &'static str = "id,name,goal,start,finish,archived";
94}
95
96pub async fn boards(client: &Client) -> Result<Vec<Board>> {
97    client.get_all("agiles").await
98}
99
100pub fn find_board<'a>(boards: &'a [Board], name_or_id: &str) -> Result<&'a Board> {
101    find_named(
102        boards,
103        name_or_id,
104        |board| &board.id,
105        |board| &board.name,
106        |board| {
107            let projects: Vec<&str> = board
108                .projects
109                .iter()
110                .map(|project| project.short_name.as_str())
111                .collect();
112            if projects.is_empty() {
113                "no projects".to_string()
114            } else {
115                projects.join(", ")
116            }
117        },
118        "board",
119    )
120}
121
122/// A sprint's dates: `2026-10-12 – 2026-10-19`, `from 2026-10-12`, `until 2026-10-19`, or none.
123pub fn sprint_dates(sprint: &Sprint) -> Option<String> {
124    match (
125        sprint.start.map(crate::time::date),
126        sprint.finish.map(crate::time::date),
127    ) {
128        (Some(start), Some(finish)) => Some(format!("{start} – {finish}")),
129        (Some(start), None) => Some(format!("from {start}")),
130        (None, Some(finish)) => Some(format!("until {finish}")),
131        (None, None) => None,
132    }
133}
134
135pub async fn sprints(client: &Client, board: &str) -> Result<Vec<Sprint>> {
136    client.get_all(&format!("agiles/{board}/sprints")).await
137}
138
139pub async fn sprint(client: &Client, board: &str, sprint: &str) -> Result<Sprint> {
140    client
141        .get(&format!("agiles/{board}/sprints/{sprint}"), &[])
142        .await
143}
144
145pub fn find_sprint<'a>(sprints: &'a [Sprint], name_or_id: &str) -> Result<&'a Sprint> {
146    find_named(
147        sprints,
148        name_or_id,
149        |sprint| &sprint.id,
150        |sprint| &sprint.name,
151        |sprint| sprint_dates(sprint).unwrap_or_else(|| "no dates".to_string()),
152        "sprint",
153    )
154}
155
156/// An item by exact ID, else by its only case-insensitive name match.
157fn find_named<'a, T>(
158    items: &'a [T],
159    name_or_id: &str,
160    id: fn(&T) -> &str,
161    name: fn(&T) -> &str,
162    detail: fn(&T) -> String,
163    what: &str,
164) -> Result<&'a T> {
165    if let Some(found) = items.iter().find(|item| id(item) == name_or_id) {
166        return Ok(found);
167    }
168    let named: Vec<&T> = items
169        .iter()
170        .filter(|item| same(name(item), name_or_id))
171        .collect();
172    match named.as_slice() {
173        [one] => Ok(one),
174        [] => {
175            let names: Vec<String> = items.iter().map(|item| name(item).to_string()).collect();
176            let mut message = format!("there is no {what} '{name_or_id}'");
177            if let Some(close) = closest(name_or_id, names.iter().map(String::as_str)) {
178                message.push_str(&format!(" (did you mean '{close}'?)"));
179            } else if !names.is_empty() {
180                message.push_str(&format!("; {what}s: {}", choices(&names)));
181            }
182            Err(usage(message))
183        }
184        several => Err(usage(format!(
185            "{} {what}s are named '{name_or_id}'; pick one by ID: {}",
186            several.len(),
187            several
188                .iter()
189                .map(|item| format!("{} ({})", id(item), detail(item)))
190                .collect::<Vec<_>>()
191                .join(", ")
192        ))),
193    }
194}
195
196pub async fn sprint_issues(client: &Client, board: &str, sprint: &str) -> Result<Vec<IssueRow>> {
197    client
198        .get_all(&format!("agiles/{board}/sprints/{sprint}/issues"))
199        .await
200}
201
202/// A board column and its issues; `name: None` collects issues whose value is on no column.
203#[derive(Debug)]
204pub struct ColumnGroup<'a> {
205    pub name: Option<String>,
206    pub resolved: bool,
207    pub issues: Vec<&'a IssueRow>,
208}
209
210pub fn group_by_column<'a>(board: &Board, issues: &'a [IssueRow]) -> Vec<ColumnGroup<'a>> {
211    let settings = board.column_settings.clone().unwrap_or_default();
212    let field = settings.field.as_ref().map(|field| field.name.clone());
213    let mut groups: Vec<ColumnGroup<'a>> = settings
214        .columns
215        .iter()
216        .map(|column| ColumnGroup {
217            name: Some(column.presentation.clone()),
218            resolved: column.is_resolved,
219            issues: Vec::new(),
220        })
221        .collect();
222    let mut other = Vec::new();
223    for issue in issues {
224        let value = field.as_deref().and_then(|field| {
225            issue
226                .custom_fields
227                .iter()
228                .find(|candidate| same(&candidate.name, field))
229                .map(|candidate| candidate.display())
230        });
231        let column = value.as_deref().and_then(|value| {
232            settings.columns.iter().position(|column| {
233                column
234                    .field_values
235                    .iter()
236                    .any(|field_value| field_value.name == value)
237            })
238        });
239        match column {
240            Some(index) => groups[index].issues.push(issue),
241            None => other.push(issue),
242        }
243    }
244    if !other.is_empty() {
245        groups.push(ColumnGroup {
246            name: None,
247            resolved: false,
248            issues: other,
249        });
250    }
251    groups
252}
253
254pub fn sprint_body(
255    name: &str,
256    goal: Option<&str>,
257    start: Option<i64>,
258    finish: Option<i64>,
259) -> Value {
260    let mut body = Map::new();
261    body.insert("name".into(), json!(name));
262    if let Some(goal) = goal {
263        body.insert("goal".into(), json!(goal));
264    }
265    if let Some(start) = start {
266        body.insert("start".into(), json!(start));
267    }
268    if let Some(finish) = finish {
269        body.insert("finish".into(), json!(finish));
270    }
271    Value::Object(body)
272}
273
274pub async fn create_sprint(client: &Client, board: &str, body: &Value) -> Result<Sprint> {
275    client.post(&format!("agiles/{board}/sprints"), body).await
276}
277
278pub async fn add_issue(client: &Client, board: &str, sprint: &str, issue: &str) -> Result<()> {
279    client
280        .post_unit(
281            &format!("agiles/{board}/sprints/{sprint}/issues"),
282            &json!({"idReadable": issue}),
283        )
284        .await
285}
286
287#[derive(Deserialize)]
288struct EntityId {
289    id: String,
290}
291
292impl Projection for EntityId {
293    const FIELDS: &'static str = "id";
294}
295
296/// Removal takes the issue's entity ID; YouTrack does not find a sprint issue by its readable ID here.
297pub async fn remove_issue(client: &Client, board: &str, sprint: &str, issue: &str) -> Result<()> {
298    let entity: EntityId = client.get(&format!("issues/{issue}"), &[]).await?;
299    client
300        .delete(&format!(
301            "agiles/{board}/sprints/{sprint}/issues/{}",
302            entity.id
303        ))
304        .await
305}
306
307#[cfg(test)]
308mod tests {
309    use serde_json::json;
310    use wiremock::matchers::{body_json, method, path};
311    use wiremock::{Mock, MockServer, ResponseTemplate};
312
313    use super::*;
314    use crate::api::test_client;
315
316    fn board(id: &str, name: &str) -> Board {
317        serde_json::from_value(json!({
318            "id": id,
319            "name": name,
320            "projects": [{"shortName": "WEB", "name": "Web"}],
321            "sprintsSettings": {"disableSprints": true, "isExplicit": false},
322            "currentSprint": {"id": "111-0", "name": "First sprint"},
323            "columnSettings": {"field": {"name": "Stage"}, "columns": [
324                {"presentation": "Procesā", "isResolved": false, "fieldValues": [{"name": "Procesā"}]},
325                {"presentation": "Darīts", "isResolved": true, "fieldValues": [{"name": "Darīts"}]}
326            ]}
327        }))
328        .unwrap()
329    }
330
331    fn issue(id: &str, stage: Option<&str>) -> IssueRow {
332        serde_json::from_value(json!({
333            "idReadable": id,
334            "summary": id,
335            "project": {"shortName": "WEB", "name": "Web"},
336            "created": 0,
337            "updated": 0,
338            "resolved": null,
339            "customFields": stage.map(|stage| vec![json!({
340                "name": "Stage",
341                "$type": "SingleEnumIssueCustomField",
342                "projectCustomField": {"field": {"fieldType": {"id": "enum[1]"}}},
343                "value": {"name": stage, "$type": "EnumBundleElement"}
344            })]).unwrap_or_default()
345        }))
346        .unwrap()
347    }
348
349    #[test]
350    fn boards_match_by_id_or_name_in_any_case_and_script() {
351        let boards = [
352            board("110-0", "Web Kanban"),
353            board("110-1", "Mājaslapa Kanban Board"),
354        ];
355        assert_eq!(
356            find_board(&boards, "mājaslapa kanban board").unwrap().id,
357            "110-1"
358        );
359        assert_eq!(find_board(&boards, "110-0").unwrap().name, "Web Kanban");
360        let error = find_board(&boards, "Web Kanbn").unwrap_err().to_string();
361        assert!(error.contains("did you mean 'Web Kanban'"), "{error}");
362    }
363
364    #[test]
365    fn two_boards_with_one_name_ask_for_an_id() {
366        let boards = [board("110-0", "Team"), board("110-5", "team")];
367        let error = find_board(&boards, "Team").unwrap_err().to_string();
368        assert!(error.contains("110-0 (WEB), 110-5 (WEB)"), "{error}");
369    }
370
371    #[test]
372    fn two_sprints_with_one_name_show_their_dates() {
373        let sprints: Vec<Sprint> = serde_json::from_value(json!([
374            {"id": "111-0", "name": "Sprint", "goal": null, "start": 1_791_763_200_000_i64, "finish": 1_792_368_000_000_i64, "archived": false},
375            {"id": "111-1", "name": "Sprint", "goal": null, "start": null, "finish": null, "archived": true}
376        ]))
377        .unwrap();
378        let error = find_sprint(&sprints, "sprint").unwrap_err().to_string();
379        assert!(
380            error.contains("111-0 (2026-10-12 – 2026-10-19), 111-1 (no dates)"),
381            "{error}"
382        );
383    }
384
385    #[test]
386    fn issues_group_under_their_column_with_the_rest_under_other() {
387        let board = board("110-0", "Web");
388        let issues = [
389            issue("WEB-1", Some("Procesā")),
390            issue("WEB-2", Some("Darīts")),
391            issue("WEB-3", Some("Ideja")),
392            issue("WEB-4", None),
393        ];
394        let groups = group_by_column(&board, &issues);
395        let shape: Vec<(Option<&str>, bool, Vec<&str>)> = groups
396            .iter()
397            .map(|group| {
398                (
399                    group.name.as_deref(),
400                    group.resolved,
401                    group
402                        .issues
403                        .iter()
404                        .map(|issue| issue.id_readable.as_str())
405                        .collect(),
406                )
407            })
408            .collect();
409        assert_eq!(
410            shape,
411            [
412                (Some("Procesā"), false, vec!["WEB-1"]),
413                (Some("Darīts"), true, vec!["WEB-2"]),
414                (None, false, vec!["WEB-3", "WEB-4"]),
415            ]
416        );
417    }
418
419    #[test]
420    fn sprints_are_off_when_the_board_says_so() {
421        assert!(!board("110-0", "Web").sprints_enabled());
422    }
423
424    #[test]
425    fn sprint_bodies_leave_out_what_is_not_given() {
426        assert_eq!(
427            sprint_body("Sprint 1", None, Some(1), None),
428            json!({"name": "Sprint 1", "start": 1})
429        );
430    }
431
432    #[tokio::test]
433    async fn sprint_writes_use_the_sprint_paths() {
434        let server = MockServer::start().await;
435        Mock::given(method("POST"))
436            .and(path("/api/agiles/110-0/sprints"))
437            .and(body_json(json!({"name": "Sprint 1", "goal": "Ship"})))
438            .respond_with(ResponseTemplate::new(200).set_body_json(
439                json!({"id": "111-2", "name": "Sprint 1", "goal": "Ship", "start": null, "finish": null, "archived": false}),
440            ))
441            .expect(1)
442            .mount(&server)
443            .await;
444        Mock::given(method("POST"))
445            .and(path("/api/agiles/110-0/sprints/111-2/issues"))
446            .and(body_json(json!({"idReadable": "WEB-1"})))
447            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
448            .expect(1)
449            .mount(&server)
450            .await;
451        Mock::given(method("GET"))
452            .and(path("/api/issues/WEB-1"))
453            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "2-19"})))
454            .mount(&server)
455            .await;
456        Mock::given(method("DELETE"))
457            .and(path("/api/agiles/110-0/sprints/111-2/issues/2-19"))
458            .respond_with(ResponseTemplate::new(200))
459            .expect(1)
460            .mount(&server)
461            .await;
462        let client = test_client(&server.uri());
463        let sprint = create_sprint(
464            &client,
465            "110-0",
466            &sprint_body("Sprint 1", Some("Ship"), None, None),
467        )
468        .await
469        .unwrap();
470        assert_eq!(sprint.id, "111-2");
471        add_issue(&client, "110-0", "111-2", "WEB-1").await.unwrap();
472        remove_issue(&client, "110-0", "111-2", "WEB-1")
473            .await
474            .unwrap();
475    }
476}