1use crate::backlog::{AcceptanceCriteriaProgress, BacklogItem, ItemId, Status, Workflow};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use crate::service::relations::{validate_dependencies, validate_parent};
7use crate::service::{
8 LabelMatch, SearchFilter, apply_effective_points, open_board, open_board_locked,
9 validate_sprint_assignment,
10};
11use crate::sprint::Sprint;
12use crate::storage::BacklogItemRepository;
13use chrono::{DateTime, Utc};
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Default, Clone)]
18pub struct NewItem {
19 pub points: Option<u32>,
21 pub labels: Vec<String>,
23 pub sprint: Option<String>,
25 pub body: String,
27 pub parent: Option<ItemId>,
29 pub depends_on: Vec<ItemId>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AddItemOutcome {
36 pub item: BacklogItem,
38 pub cycle_warning: bool,
40}
41
42pub async fn add_item(project_dir: &Path, title: &str, new: NewItem) -> Result<BacklogItem> {
48 Ok(add_item_with_outcome(project_dir, title, new).await?.item)
49}
50
51pub async fn add_item_with_outcome(
53 project_dir: &Path,
54 title: &str,
55 new: NewItem,
56) -> Result<AddItemOutcome> {
57 let (board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
58 let config_path = board_dir.join("config.toml");
59 let status = config
60 .columns
61 .first()
62 .map(Status::new)
63 .ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
64
65 let NewItem {
66 points,
67 labels,
68 sprint,
69 body,
70 parent,
71 depends_on,
72 } = new;
73 let sprint = match sprint {
74 Some(raw) => Some(validate_sprint_assignment(&repo, &raw).await?.to_string()),
75 None => None,
76 };
77
78 let id = repo.next_id(&config.project.key).await?;
79
80 let existing = repo.list().await?;
82 let rank = Rank::after(existing.last().map(|it| &it.rank));
83
84 let mut item = BacklogItem::new(id.clone(), title, status, rank, Utc::now())?;
85 if let Some(parent) = &parent {
86 validate_parent(&existing, &id, parent)?;
87 }
88 let cycle_warning = validate_dependencies(&existing, &id, &depends_on)?;
89
90 item.points = points;
91 item.labels = labels;
92 item.sprint = sprint;
93 item.body = body;
94 item.parent = parent;
95 item.depends_on = depends_on
96 .into_iter()
97 .fold(Vec::new(), |mut unique, dependency| {
98 if !unique.contains(&dependency) {
99 unique.push(dependency);
100 }
101 unique
102 });
103
104 repo.save(&item).await?;
105 repo.commit(&format!("pinto: add {}", item.id)).await?;
106 Ok(AddItemOutcome {
107 item,
108 cycle_warning,
109 })
110}
111
112#[derive(Debug, Default, Clone)]
120pub struct ListFilter {
121 pub roots_only: bool,
123 pub archived: bool,
125 pub status: Vec<String>,
127 pub sprint: Option<String>,
129 pub assignee: Option<String>,
131 pub labels: Vec<String>,
133 pub label_match: LabelMatch,
135 pub search: Option<SearchFilter>,
137 pub stale_before: Option<DateTime<Utc>>,
139}
140
141impl ListFilter {
142 fn matches(&self, item: &BacklogItem, sprints: &[Sprint]) -> bool {
144 if self.roots_only && item.parent.is_some() {
145 return false;
146 }
147 if !self.status.is_empty()
148 && !self
149 .status
150 .iter()
151 .any(|status| item.status.as_str() == status)
152 {
153 return false;
154 }
155 if let Some(sprint) = &self.sprint
156 && item.sprint.as_deref() != Some(sprint.as_str())
157 {
158 return false;
159 }
160 if let Some(assignee) = &self.assignee
161 && item.assignee.as_deref() != Some(assignee.as_str())
162 {
163 return false;
164 }
165 if !self.labels.is_empty() && !self.label_match.matches(&item.labels, &self.labels) {
166 return false;
167 }
168 if let Some(cutoff) = self.stale_before
169 && item.updated > cutoff
170 {
171 return false;
172 }
173 if let Some(search) = &self.search {
174 let sprint = item
175 .sprint
176 .as_deref()
177 .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
178 if !search.matches(item, sprint) {
179 return false;
180 }
181 }
182 true
183 }
184}
185
186pub async fn list_items(project_dir: &Path, filter: &ListFilter) -> Result<Vec<BacklogItem>> {
197 let (_board_dir, repo, config) = open_board(project_dir).await?;
198 for status in &filter.status {
199 if !config.columns.iter().any(|column| column == status) {
200 return Err(Error::UnknownStatus(status.clone()));
201 }
202 }
203 let mut items = if filter.archived {
204 repo.list_archived().await?
205 } else {
206 repo.list().await?
207 };
208 apply_effective_points(
209 &mut items,
210 config.points.aggregate_children,
211 &Status::new(&config.done_column),
212 );
213 let sprints = if filter.search.is_some() {
214 crate::storage::SprintRepository::list(&repo).await?
215 } else {
216 Vec::new()
217 };
218 let filtered: Vec<BacklogItem> = items
221 .into_iter()
222 .filter(|item| filter.matches(item, &sprints))
223 .collect();
224 Ok(crate::service::hierarchical(filtered))
225}
226
227pub async fn show_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
232 show_item_from_store(project_dir, id, false).await
233}
234
235pub async fn show_archived_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
237 show_item_from_store(project_dir, id, true).await
238}
239
240async fn show_item_from_store(
241 project_dir: &Path,
242 id: &ItemId,
243 archived: bool,
244) -> Result<BacklogItem> {
245 let (_board_dir, repo, config) = open_board(project_dir).await?;
246 let mut items = if archived {
247 repo.list_archived().await?
248 } else {
249 repo.list().await?
250 };
251 apply_effective_points(
252 &mut items,
253 config.points.aggregate_children,
254 &Status::new(&config.done_column),
255 );
256 items
257 .into_iter()
258 .find(|item| &item.id == id)
259 .ok_or_else(|| Error::NotFound(id.clone()))
260}
261
262pub async fn move_item(project_dir: &Path, id: &ItemId, to: &str) -> Result<BacklogItem> {
268 Ok(move_item_with_outcome(project_dir, id, to).await?.item)
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct MoveOutcome {
274 pub item: BacklogItem,
276 pub acceptance_criteria: AcceptanceCriteriaProgress,
278 pub entered_done_column: bool,
280}
281
282pub async fn move_item_with_outcome(
287 project_dir: &Path,
288 id: &ItemId,
289 to: &str,
290) -> Result<MoveOutcome> {
291 let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
292 let workflow = Workflow::new(config.columns.iter().map(Status::new));
293 let now = Utc::now();
294
295 let mut item = repo.load(id).await?;
296 let from_status = item.status.clone();
297 item.transition_to(Status::new(to), &workflow, now)?;
298
299 if item.status != from_status {
309 let others = repo.list().await?;
310 let collides = others.iter().any(|it| {
311 it.id != item.id
312 && it.status == item.status
313 && it.parent == item.parent
314 && it.rank == item.rank
315 });
316 if collides {
317 let backlog_max = others
318 .iter()
319 .filter(|it| it.id != item.id)
320 .map(|it| &it.rank)
321 .max();
322 item.rank = Rank::after(backlog_max);
323 }
324 }
325
326 let acceptance_criteria = AcceptanceCriteriaProgress::from_markdown(&item.body);
327 let entered_done_column = to == config.done_column;
328
329 repo.save(&item).await?;
330 repo.commit(&format!("pinto: update {}", item.id)).await?;
331 Ok(MoveOutcome {
332 item,
333 acceptance_criteria,
334 entered_done_column,
335 })
336}
337#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum RemoveOutcome {
340 Archived(PathBuf),
342 Deleted,
344}
345
346fn referencing_items(items: &[BacklogItem], target: &ItemId) -> String {
348 items
349 .iter()
350 .filter(|item| {
351 &item.id != target
352 && (item.parent.as_ref() == Some(target)
353 || item
354 .depends_on
355 .iter()
356 .any(|dependency| dependency == target))
357 })
358 .map(|item| item.id.to_string())
359 .collect::<Vec<_>>()
360 .join(", ")
361}
362
363pub async fn remove_item(
369 project_dir: &Path,
370 id: &ItemId,
371 permanent: bool,
372) -> Result<RemoveOutcome> {
373 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
374 if permanent {
375 let items = repo.list().await?;
376 if items.iter().any(|item| &item.id == id) {
377 let references = referencing_items(&items, id);
378 if !references.is_empty() {
379 return Err(Error::ReferencedItem {
380 item: id.clone(),
381 references,
382 });
383 }
384 }
385 BacklogItemRepository::delete(&repo, id).await?;
386 repo.commit(&format!("pinto: remove {id}")).await?;
387 Ok(RemoveOutcome::Deleted)
388 } else {
389 let dest = repo.archive(id).await?;
390 repo.commit(&format!("pinto: archive {id}")).await?;
391 Ok(RemoveOutcome::Archived(dest))
392 }
393}
394
395pub async fn restore_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
397 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
398 repo.restore(id).await?;
399 repo.commit(&format!("pinto: restore {id}")).await?;
400 repo.load(id).await
401}