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
122pub 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
156fn 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#[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
296pub 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}