Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

607 linesCodeBlameRaw
1//! The plan (`g1t_contracts::work::plan`): where each open work item of a
2//! workspace stands, its place there, and what to take next
3//! (migrations/0056_plan.sql). `plan_work_item` moves one, `get_plan`
4//! reads a workspace's within its bounds, `next_work_item` picks what is
5//! ready, and `configure_plan` sets Now's cap. `list_work_items` with a
6//! `horizon` reads one horizon in the plan's order (reading.rs).
7
8use g1t_contracts::access::{self, Capability};
9use g1t_contracts::events::WorkItemEvent;
10use g1t_contracts::time::rfc3339;
11use g1t_contracts::work::*;
12use g1t_contracts::{FailureCode, Outcome, User, Viewer};
13use g1t_kit::now_ms;
14use serde::Deserialize;
15use worker::Result;
16use worker::wasm_bindgen::JsValue;
17
18use super::rows::{Held, ITEM_COLUMNS, ItemRow, no_item, workspace_of};
19use crate::{Work, optional};
20
21/// An open work item's horizon, with NULL (rows from before the plan, and
22/// ones opened since) read as Triage.
23pub(crate) const HORIZON: &str = "COALESCE(work_items.horizon, 'triage')";
24
25/// The plan's order within a horizon: urgent first, then by rank, those
26/// with none last, newest first among them. Triage has no ranks, so it is
27/// newest first.
28pub(crate) const PLAN_ORDER: &str = "work_items.urgent DESC, work_items.rank IS NULL, work_items.rank, work_items.number DESC";
29
30/// The open, numbered work items of workspace `?1` a viewer may see: all
31/// of them for a member (`?2` = 1), else those filed in a public
32/// repository or one of `?3` (a JSON array of repository ids they have a
33/// role on). With `?4`, only those linked to that repository.
34const OPEN_VISIBLE: &str = "work_items.workspace = ?1 AND work_items.state = 'open' AND work_items.number IS NOT NULL
35 AND (?4 IS NULL OR EXISTS (SELECT 1 FROM work_item_repos r WHERE r.item_id = work_items.id AND r.repo_id = ?4))
36 AND (?2 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
37 WHERE o.item_id = work_items.id AND o.role = 'origin'
38 AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?3)))))";
39
40/// What is ready to take, given `?5` (now, RFC 3339) and `?6` (the
41/// viewer's username, lowercase): nobody holds a live claim on it, nothing
42/// it is blocked by is open, and it is assigned to nobody or to them.
43const READY: &str = "NOT EXISTS (SELECT 1 FROM claims c WHERE c.item_id = work_items.id AND c.state = 'active' AND c.expires_at > ?5)
44 AND NOT EXISTS (SELECT 1 FROM json_each(work_items.blocked_by) j JOIN work_items b ON b.id = j.value WHERE b.state = 'open')
45 AND (json_array_length(work_items.assignees) = 0
46 OR EXISTS (SELECT 1 FROM json_each(work_items.assignees) WHERE lower(json_each.value) = ?6))";
47
48fn open_items(condition: &str, limit: u32) -> String {
49 format!("SELECT {ITEM_COLUMNS} FROM work_items WHERE {OPEN_VISIBLE} AND {condition} ORDER BY {PLAN_ORDER} LIMIT {limit}")
50}
51
52/// How many open work items each horizon holds.
53const COUNTS: &str = "SELECT COALESCE(work_items.horizon, 'triage') AS horizon, count(*) AS count FROM work_items
54 WHERE {OPEN_VISIBLE} GROUP BY 1";
55
56/// Later, by the repository each work item is filed in.
57const LATER_GROUPS: &str = "SELECT (SELECT COALESCE(rw.path, o.path) FROM work_item_repos o
58 LEFT JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
59 WHERE o.item_id = work_items.id AND o.role = 'origin') AS repo, count(*) AS count
60 FROM work_items WHERE {OPEN_VISIBLE} AND COALESCE(work_items.horizon, 'triage') = 'later'
61 GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 50";
62
63/// The ranks on horizon `?2` of workspace `?1` around a place, leaving out
64/// work item `?3`: the last one ahead of `?4` (none: no bound) and the
65/// first one after `?5`.
66const NEIGHBOURS: &str = "SELECT
67 (SELECT max(rank) FROM work_items WHERE workspace = ?1 AND state = 'open' AND COALESCE(horizon, 'triage') = ?2
68 AND id <> ?3 AND rank IS NOT NULL AND (?4 IS NULL OR rank < ?4)) AS ahead,
69 (SELECT min(rank) FROM work_items WHERE workspace = ?1 AND state = 'open' AND COALESCE(horizon, 'triage') = ?2
70 AND id <> ?3 AND rank IS NOT NULL AND (?5 IS NULL OR rank > ?5)) AS behind";
71
72#[derive(Deserialize)]
73struct Neighbours {
74 ahead: Option<String>,
75 behind: Option<String>,
76}
77
78#[derive(Deserialize)]
79struct Count {
80 horizon: Option<String>,
81 count: u32,
82}
83
84#[derive(Deserialize)]
85struct Group {
86 repo: Option<String>,
87 count: u32,
88}
89
90/// Who a viewer is in a workspace, for the queries above: whether they
91/// are a member, and the repositories they were given a role on.
92fn standing(viewer: Option<&User>, workspace: &str) -> Result<(JsValue, JsValue)> {
93 let member = viewer.and_then(|user| access::workspace_role(user, workspace)).is_some();
94 let granted: Vec<&str> = viewer
95 .map(|user| user.grants.iter().filter(|grant| grant.workspace.eq_ignore_ascii_case(workspace)).map(|grant| grant.repo_id.as_str()).collect())
96 .unwrap_or_default();
97 Ok((u32::from(member).into(), serde_json::to_string(&granted)?.into()))
98}
99
100/// What placing a work item asks for, once read: its horizon after, and
101/// where on it.
102#[derive(Debug, PartialEq, Eq)]
103pub(crate) enum Place<'a> {
104 /// Leave its rank as it is.
105 Stay,
106 /// Last on the horizon.
107 Bottom,
108 Top,
109 /// Just before the work item of this rank.
110 Before(&'a str),
111 /// Just after the work item of this rank.
112 After(&'a str),
113}
114
115/// What a move says in the work item's conversation, if anything: a new
116/// horizon, and a change to urgent.
117pub(crate) fn move_note(from: Horizon, to: Horizon, urgent_before: bool, urgent: bool) -> Option<String> {
118 let mut parts = Vec::new();
119 if from != to {
120 parts.push(format!("moved this from {} to {}", from.label(), to.label()));
121 }
122 match (urgent_before, urgent) {
123 (false, true) => parts.push("marked this urgent".to_owned()),
124 (true, false) => parts.push("said this is no longer urgent".to_owned()),
125 _ => {}
126 }
127 (!parts.is_empty()).then(|| parts.join(" and "))
128}
129
130/// A budget as given: a positive number of dollars up to the most.
131pub(crate) fn valid_budget(budget: Option<f64>) -> std::result::Result<Option<f64>, String> {
132 match budget {
133 None => Ok(None),
134 Some(dollars) if dollars.is_finite() && dollars > 0.0 && dollars <= MAX_BUDGET_USD => Ok(Some((dollars * 100.0).round() / 100.0)),
135 Some(_) => Err(format!("A budget is a number of dollars above 0 and up to {MAX_BUDGET_USD}.")),
136 }
137}
138
139impl Work {
140 /// How many work items Now holds before workspace `workspace`'s plan
141 /// says it is over.
142 async fn now_cap(&self, workspace: &str) -> Result<u32> {
143 #[derive(Deserialize)]
144 struct Cap {
145 now_cap: u32,
146 }
147 Ok(self
148 .db
149 .prepare("SELECT now_cap FROM plan_settings WHERE workspace = ?")
150 .bind(&[workspace.into()])?
151 .first::<Cap>(None)
152 .await?
153 .map_or(DEFAULT_NOW_CAP, |row| row.now_cap))
154 }
155
156 /// The open work item numbered `number`, to place another next to.
157 async fn anchor(&self, held: &Held, number: u32) -> Result<Outcome<Held>> {
158 if number == held.item.number {
159 return Ok(Outcome::fail(FailureCode::Invalid, "A work item can't be placed next to itself."));
160 }
161 match self.item(&held.item.workspace, number).await? {
162 Some(anchor) if anchor.item.state == State::Open => Ok(Outcome::Ok(anchor)),
163 Some(_) => Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is closed, so it has no place in the plan."))),
164 None => Ok(Outcome::fail(FailureCode::NotFound, format!("#{number} is not a work item of {}.", held.item.workspace))),
165 }
166 }
167
168 /// The rank `place` gives work item `held` on `horizon`.
169 async fn rank_for(&self, held: &Held, horizon: Horizon, place: &Place<'_>) -> Result<Option<String>> {
170 if horizon == Horizon::Triage {
171 return Ok(None);
172 }
173 if *place == Place::Stay {
174 // A rank it already has there; one with none goes last.
175 if held.item.horizon == horizon {
176 if let Some(rank) = self.stored_rank(&held.item.id).await? {
177 return Ok(Some(rank));
178 }
179 }
180 return Box::pin(self.rank_for(held, horizon, &Place::Bottom)).await;
181 }
182 // With no bound given, `ahead` is the last rank there and `behind`
183 // the first.
184 let (upper, lower): (JsValue, JsValue) = match place {
185 Place::Before(rank) => ((*rank).into(), JsValue::NULL),
186 Place::After(rank) => (JsValue::NULL, (*rank).into()),
187 _ => (JsValue::NULL, JsValue::NULL),
188 };
189 let found = self
190 .db
191 .prepare(NEIGHBOURS)
192 .bind(&[held.item.workspace.as_str().into(), horizon.as_str().into(), held.item.id.as_str().into(), upper, lower])?
193 .first::<Neighbours>(None)
194 .await?
195 .unwrap_or(Neighbours { ahead: None, behind: None });
196 Ok(Some(match place {
197 Place::Before(rank) => rank_between(found.ahead.as_deref(), Some(rank)),
198 Place::After(rank) => rank_between(Some(rank), found.behind.as_deref()),
199 Place::Top => rank_between(None, found.behind.as_deref()),
200 Place::Bottom | Place::Stay => rank_between(found.ahead.as_deref(), None),
201 }))
202 }
203
204 async fn stored_rank(&self, id: &str) -> Result<Option<String>> {
205 #[derive(Deserialize)]
206 struct Rank {
207 rank: Option<String>,
208 }
209 Ok(self.db.prepare("SELECT rank FROM work_items WHERE id = ?").bind(&[id.into()])?.first::<Rank>(None).await?.and_then(|row| row.rank))
210 }
211
212 /// Gives `anchor` a rank, last on its horizon, if it has none, so
213 /// another can be placed next to it. Returns its rank.
214 async fn ranked(&self, anchor: &Held) -> Result<String> {
215 if let Some(rank) = self.stored_rank(&anchor.item.id).await? {
216 return Ok(rank);
217 }
218 let rank = self.rank_for(anchor, anchor.item.horizon, &Place::Bottom).await?.unwrap_or_else(|| rank_between(None, None));
219 self.db
220 .prepare("UPDATE work_items SET rank = ? WHERE id = ? AND rank IS NULL")
221 .bind(&[rank.as_str().into(), anchor.item.id.as_str().into()])?
222 .run()
223 .await?;
224 Ok(rank)
225 }
226
227 // --- RPC methods ----------------------------------------------------------
228
229 pub(crate) async fn plan_work_item(&self, a: PlanWorkItemArgs) -> Result<Outcome<WorkItem>> {
230 let workspace = a.workspace.trim().to_lowercase();
231 let held = check!(self.item_for(&Some(a.actor.clone()), &workspace, a.number, Capability::Read).await?);
232 if held.item.state == State::Closed {
233 return Ok(Outcome::fail(FailureCode::Conflict, "This work item is closed, so it has no place in the plan. Reopen it first."));
234 }
235 let positions = usize::from(a.before.is_some()) + usize::from(a.after.is_some()) + usize::from(a.top);
236 if positions > 1 {
237 return Ok(Outcome::fail(FailureCode::Invalid, "Say one of before, after or top."));
238 }
239 let budget = match a.budget_usd {
240 Some(budget) => match valid_budget(budget) {
241 Ok(budget) => Some(budget),
242 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
243 },
244 None => None,
245 };
246 // Next to another: on that one's horizon.
247 let anchor = match a.before.or(a.after) {
248 Some(number) => Some(check!(self.anchor(&held, number).await?)),
249 None => None,
250 };
251 let from = held.item.horizon;
252 // Urgent is first in Now.
253 let mut horizon = a.horizon.or(anchor.as_ref().map(|anchor| anchor.item.horizon)).unwrap_or(from);
254 if a.urgent == Some(true) {
255 if a.horizon.is_some_and(|asked| asked != Horizon::Now) {
256 return Ok(Outcome::fail(FailureCode::Invalid, "An urgent work item is in Now."));
257 }
258 horizon = Horizon::Now;
259 }
260 if let (Some(anchor), Some(asked)) = (&anchor, a.horizon) {
261 if anchor.item.horizon != asked {
262 return Ok(Outcome::fail(
263 FailureCode::Invalid,
264 format!("#{} is in {}, not {}.", anchor.item.number, anchor.item.horizon.label(), asked.label()),
265 ));
266 }
267 }
268 if horizon == Horizon::Triage && positions > 0 {
269 return Ok(Outcome::fail(FailureCode::Invalid, "Triage is in the order work items came in, so nothing is placed in it."));
270 }
271 // Who may: the Triage role to plan; the right to manage milestones
272 // for what the workspace commits to.
273 check!(held.allowed(Some(&a.actor), Capability::Triage));
274 let reranked = positions > 0 || horizon != from;
275 let commits = (horizon.committed() && reranked) || (from.committed() && horizon != from) || a.urgent.is_some_and(|urgent| urgent != held.item.urgent);
276 if commits && !access::can_item(Some(&a.actor), &workspace, None, Capability::ManageLabels) {
277 return Ok(Outcome::fail(
278 FailureCode::Forbidden,
279 format!("Deciding what is in Now takes the right to manage milestones. {}", access::needs_on_item(Capability::ManageLabels, &workspace)),
280 ));
281 }
282 let anchor_rank = match &anchor {
283 Some(anchor) => Some(self.ranked(anchor).await?),
284 None => None,
285 };
286 let place = match (&anchor_rank, a.before.is_some(), a.top) {
287 (Some(rank), true, _) => Place::Before(rank),
288 (Some(rank), false, _) => Place::After(rank),
289 (None, _, true) => Place::Top,
290 (None, _, false) if horizon != from => Place::Bottom,
291 _ => Place::Stay,
292 };
293 let rank = self.rank_for(&held, horizon, &place).await?;
294 let urgent = a.urgent.unwrap_or(held.item.urgent) && horizon == Horizon::Now;
295 let appetite = a.appetite.unwrap_or(held.item.appetite);
296 let budget = budget.unwrap_or(held.item.budget_usd);
297 let now = rfc3339(now_ms());
298 let planned_at = if reranked { Some(now.clone()) } else { held.item.planned_at.clone() };
299 self.db
300 .prepare(
301 "UPDATE work_items SET horizon = ?, rank = ?, appetite = ?, budget_usd = ?, urgent = ?, planned_at = ?
302 WHERE id = ? AND state = 'open'",
303 )
304 .bind(&[
305 horizon.as_str().into(),
306 optional(&rank),
307 optional(&appetite.map(|appetite| appetite.as_str().to_owned())),
308 budget.map_or(JsValue::NULL, JsValue::from),
309 u32::from(urgent).into(),
310 optional(&planned_at),
311 held.item.id.as_str().into(),
312 ])?
313 .run()
314 .await?;
315 let Some(after) = self.item_by_id(&held.item.id).await? else {
316 return Ok(no_item());
317 };
318 self.publish_item(
319 "work_item.planned",
320 &after,
321 Some(&a.actor),
322 WorkItemEvent {
323 horizon: Some(horizon),
324 from_horizon: (horizon != from).then_some(from),
325 appetite: after.item.appetite,
326 urgent: Some(urgent),
327 ..Self::item_event(&after)
328 },
329 )
330 .await?;
331 if let Some(note) = move_note(from, horizon, held.item.urgent, urgent) {
332 self.note_item(&after.item.id, &a.actor, &note).await?;
333 }
334 Ok(Outcome::Ok(after.shown(Some(&a.actor))))
335 }
336
337 pub(crate) async fn get_plan(&self, a: GetPlanArgs) -> Result<Outcome<Plan>> {
338 let workspace = workspace_of(&a.workspace, a.repo.as_ref());
339 if workspace.is_empty() {
340 return Ok(Outcome::fail(FailureCode::Invalid, "Say which workspace."));
341 }
342 let repo_id = match &a.repo {
343 Some(path) => {
344 let repo = check!(self.repo(path, &a.viewer).await?);
345 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
346 return Ok(Outcome::Ok(Plan { workspace, now_cap: self.now_cap(&repo.namespace.to_lowercase()).await?, ..Plan::default() }));
347 }
348 Some(repo.id)
349 }
350 None => None,
351 };
352 let (member, granted) = standing(a.viewer.as_ref(), &workspace)?;
353 let bound = |extra: Option<JsValue>| {
354 let mut values = vec![workspace.as_str().into(), member.clone(), granted.clone(), optional(&repo_id)];
355 values.extend(extra);
356 values
357 };
358 let shown = if a.all { crate::LIST_PAGE } else { NEXT_SHOWN };
359 let statements = vec![
360 self.db.prepare(open_items(&format!("{HORIZON} = 'now'"), crate::LIST_PAGE)).bind(&bound(None))?,
361 self.db.prepare(open_items(&format!("{HORIZON} = 'next'"), shown)).bind(&bound(None))?,
362 self.db.prepare(COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?,
363 self.db.prepare(LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?,
364 ];
365 let results = self.timing.db(4, self.db.batch(statements)).await?;
366 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
367 let now = shown_rows(rows(0)?.results::<ItemRow>()?, &a.viewer);
368 let next = shown_rows(rows(1)?.results::<ItemRow>()?, &a.viewer);
369 let counts = rows(2)?.results::<Count>()?;
370 let total = |horizon: Horizon| {
371 counts.iter().filter(|row| row.horizon.as_deref().and_then(Horizon::parse).unwrap_or_default() == horizon).map(|row| row.count).sum()
372 };
373 let later = rows(3)?.results::<Group>()?.into_iter().map(|group| PlanGroup { repo: group.repo, count: group.count }).collect();
374 Ok(Outcome::Ok(Plan {
375 now_cap: self.now_cap(&workspace).await?,
376 now,
377 next,
378 next_total: total(Horizon::Next),
379 triage_total: total(Horizon::Triage),
380 later_total: total(Horizon::Later),
381 later,
382 set_aside_total: total(Horizon::SetAside),
383 workspace,
384 }))
385 }
386
387 pub(crate) async fn next_work_item(&self, a: NextWorkItemArgs) -> Result<Outcome<Option<WorkItem>>> {
388 let workspace = workspace_of(&a.workspace, a.repo.as_ref());
389 let repo_id = match &a.repo {
390 Some(path) => {
391 let repo = check!(self.repo(path, &a.viewer).await?);
392 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
393 return Ok(Outcome::Ok(None));
394 }
395 Some(repo.id)
396 }
397 None => None,
398 };
399 let (member, granted) = standing(a.viewer.as_ref(), &workspace)?;
400 let username = a.viewer.as_ref().map(|user| user.username.to_lowercase()).unwrap_or_default();
401 let sql = format!(
402 "SELECT {ITEM_COLUMNS} FROM work_items WHERE {OPEN_VISIBLE} AND {HORIZON} IN ('now', 'next') AND {READY}
403 ORDER BY CASE {HORIZON} WHEN 'now' THEN 0 ELSE 1 END, {PLAN_ORDER} LIMIT 20"
404 );
405 let rows = self
406 .db
407 .prepare(sql)
408 .bind(&[
409 workspace.as_str().into(),
410 member,
411 granted,
412 optional(&repo_id),
413 rfc3339(now_ms()).into(),
414 username.into(),
415 ])?
416 .all()
417 .await?
418 .results::<ItemRow>()?;
419 Ok(Outcome::Ok(shown_rows(rows, &a.viewer).into_iter().next()))
420 }
421
422 pub(crate) async fn configure_plan(&self, a: ConfigurePlanArgs) -> Result<Outcome<Plan>> {
423 let workspace = a.workspace.trim().to_lowercase();
424 if !access::can_item(Some(&a.actor), &workspace, None, Capability::ManageLabels) {
425 return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::ManageLabels, &workspace)));
426 }
427 if !(1..=MAX_NOW_CAP).contains(&a.now_cap) {
428 return Ok(Outcome::fail(FailureCode::Invalid, format!("Now's cap is a number from 1 to {MAX_NOW_CAP}.")));
429 }
430 self.db
431 .prepare(
432 "INSERT INTO plan_settings (workspace, now_cap, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4)
433 ON CONFLICT (workspace) DO UPDATE SET now_cap = excluded.now_cap, updated_at = excluded.updated_at,
434 updated_by = excluded.updated_by",
435 )
436 .bind(&[workspace.as_str().into(), a.now_cap.into(), rfc3339(now_ms()).into(), a.actor.id.as_str().into()])?
437 .run()
438 .await?;
439 Ok(Outcome::Ok(Plan { workspace, now_cap: a.now_cap, ..Plan::default() }))
440 }
441}
442
443/// The rows a viewer may see, as they are shown them.
444fn shown_rows(rows: Vec<ItemRow>, viewer: &Viewer) -> Vec<WorkItem> {
445 rows.into_iter()
446 .filter_map(ItemRow::held)
447 .filter(|held| held.may(viewer.as_ref(), Capability::Read))
448 .map(|held| held.shown(viewer.as_ref()))
449 .collect()
450}
451
452#[cfg(test)]
453mod tests {
454 use rusqlite::{Connection, ToSql};
455
456 use super::super::rows::ITEM_COLUMNS;
457 use super::*;
458 use crate::migrated::database;
459
460 /// acme's open work items 1 to 6 (2 private to rep_secret), 7 closed.
461 fn acme() -> Connection {
462 let db = database();
463 db.execute_batch(
464 "INSERT INTO repo_workspaces (repo_id, workspace, path, private)
465 VALUES ('rep_web', 'acme', 'acme/web', 0), ('rep_secret', 'acme', 'acme/secret', 1);
466 INSERT INTO work_items (id, workspace, number, title, body, labels, author_id, author_name, created_at, updated_at, state)
467 VALUES ('wki_1', 'acme', 1, 'One', '', '[]', 'usr_1', 'ana', '', '', 'open'),
468 ('wki_2', 'acme', 2, 'Two', '', '[]', 'usr_1', 'ana', '', '', 'open'),
469 ('wki_3', 'acme', 3, 'Three', '', '[]', 'usr_1', 'ana', '', '', 'open'),
470 ('wki_4', 'acme', 4, 'Four', '', '[]', 'usr_1', 'ana', '', '', 'open'),
471 ('wki_5', 'acme', 5, 'Five', '', '[]', 'usr_1', 'ana', '', '', 'open'),
472 ('wki_6', 'acme', 6, 'Six', '', '[]', 'usr_1', 'ana', '', '', 'open'),
473 ('wki_7', 'acme', 7, 'Seven', '', '[]', 'usr_1', 'ana', '', '', 'closed');
474 INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at)
475 VALUES ('wki_1', 'rep_web', 'origin', 'acme/web', ''), ('wki_2', 'rep_secret', 'origin', 'acme/secret', ''),
476 ('wki_3', 'rep_web', 'origin', 'acme/web', ''), ('wki_4', 'rep_web', 'origin', 'acme/web', ''),
477 ('wki_5', 'rep_web', 'origin', 'acme/web', ''), ('wki_6', 'rep_web', 'origin', 'acme/web', ''),
478 ('wki_7', 'rep_web', 'origin', 'acme/web', '');",
479 )
480 .unwrap();
481 db
482 }
483
484 fn place(db: &Connection, number: u32, horizon: &str, rank: Option<&str>) {
485 db.execute("UPDATE work_items SET horizon = ?1, rank = ?2 WHERE number = ?3", rusqlite::params![horizon, rank, number]).unwrap();
486 }
487
488 /// The numbers on `horizon`, in the plan's order, as a member or not.
489 fn on(db: &Connection, horizon: &str, member: bool) -> Vec<u32> {
490 let sql = open_items(&format!("{HORIZON} = ?5"), 100).replace("{ITEM_COLUMNS}", ITEM_COLUMNS);
491 let values: [&dyn ToSql; 5] = [&"acme", &u32::from(member), &"[]", &None::<String>, &horizon];
492 let mut statement = db.prepare(&sql).unwrap();
493 statement.query_map(&values[..], |row| row.get::<_, u32>("number")).unwrap().map(Result::unwrap).collect()
494 }
495
496 #[test]
497 fn open_work_items_start_in_triage_newest_first() {
498 let db = acme();
499 assert_eq!(on(&db, "triage", true), [6, 5, 4, 3, 2, 1]);
500 // Someone outside sees only what is filed in a public repository.
501 assert_eq!(on(&db, "triage", false), [6, 5, 4, 3, 1]);
502 }
503
504 #[test]
505 fn a_horizon_reads_urgent_first_then_by_rank() {
506 let db = acme();
507 place(&db, 1, "next", Some("V"));
508 place(&db, 3, "next", Some("F"));
509 place(&db, 4, "next", None);
510 place(&db, 5, "now", Some("k"));
511 place(&db, 6, "now", Some("x"));
512 db.execute("UPDATE work_items SET urgent = 1 WHERE number = 6", []).unwrap();
513 assert_eq!(on(&db, "next", true), [3, 1, 4]);
514 assert_eq!(on(&db, "now", true), [6, 5]);
515 assert_eq!(on(&db, "triage", true), [2]);
516 }
517
518 /// The ranks around a place, as `rank_for` reads them.
519 fn neighbours(db: &Connection, horizon: &str, leaving_out: &str, upper: Option<&str>, lower: Option<&str>) -> (Option<String>, Option<String>) {
520 db.query_row(NEIGHBOURS, rusqlite::params!["acme", horizon, leaving_out, upper, lower], |row| Ok((row.get(0)?, row.get(1)?))).unwrap()
521 }
522
523 #[test]
524 fn a_place_is_found_between_its_neighbours() {
525 let db = acme();
526 place(&db, 1, "next", Some("F"));
527 place(&db, 3, "next", Some("V"));
528 place(&db, 4, "next", Some("k"));
529 // Before #3 (V): after F.
530 assert_eq!(neighbours(&db, "next", "wki_5", Some("V"), None), (Some("F".into()), Some("F".into())));
531 // After #3: ahead of k.
532 assert_eq!(neighbours(&db, "next", "wki_5", None, Some("V")).1, Some("k".into()));
533 // Moving #4 itself to the bottom: it is left out.
534 assert_eq!(neighbours(&db, "next", "wki_4", None, None).0, Some("V".into()));
535 let rank = rank_between(Some("F"), Some("V"));
536 place(&db, 5, "next", Some(&rank));
537 assert_eq!(on(&db, "next", true), [1, 5, 3, 4]);
538 }
539
540 #[test]
541 fn what_is_ready_leaves_out_claimed_blocked_and_others_work() {
542 let db = acme();
543 place(&db, 1, "next", Some("a"));
544 place(&db, 3, "next", Some("b"));
545 place(&db, 4, "now", Some("c"));
546 place(&db, 5, "next", Some("d"));
547 place(&db, 6, "next", Some("e"));
548 // #4 in Now is claimed; #1 is blocked by open #2; #3 is someone else's.
549 db.execute_batch(
550 "INSERT INTO claims (id, item_id, actor_id, actor_name, state, ttl_minutes, created_at, expires_at)
551 VALUES ('clm_1', 'wki_4', 'usr_2', 'bo', 'active', 30, '', '2999-01-01T00:00:00Z');
552 UPDATE work_items SET blocked_by = '[\"wki_2\"]' WHERE number = 1;
553 UPDATE work_items SET assignees = '[\"bo\"]' WHERE number = 3;",
554 )
555 .unwrap();
556 let sql = format!(
557 "SELECT number FROM work_items WHERE {OPEN_VISIBLE} AND {HORIZON} IN ('now', 'next') AND {READY}
558 ORDER BY CASE {HORIZON} WHEN 'now' THEN 0 ELSE 1 END, {PLAN_ORDER}"
559 );
560 let ready = |who: &str| -> Vec<u32> {
561 let values: [&dyn ToSql; 6] = [&"acme", &1, &"[]", &None::<String>, &"2026-10-19T00:00:00Z", &who];
562 let mut statement = db.prepare(&sql).unwrap();
563 statement.query_map(&values[..], |row| row.get::<_, u32>(0)).unwrap().map(Result::unwrap).collect()
564 };
565 assert_eq!(ready("ana"), [5, 6]);
566 assert_eq!(ready("bo"), [3, 5, 6]);
567 // The claim expires: #4, in Now, comes first.
568 db.execute("UPDATE claims SET expires_at = '2026-10-18T00:00:00Z'", []).unwrap();
569 assert_eq!(ready("ana"), [4, 5, 6]);
570 }
571
572 #[test]
573 fn counts_and_later_groups_cover_every_horizon() {
574 let db = acme();
575 place(&db, 1, "later", Some("a"));
576 place(&db, 3, "later", Some("b"));
577 place(&db, 4, "set_aside", Some("c"));
578 let values: [&dyn ToSql; 4] = [&"acme", &1, &"[]", &None::<String>];
579 let mut counts: Vec<(String, u32)> = db
580 .prepare(&COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE))
581 .unwrap()
582 .query_map(&values[..], |row| Ok((row.get(0)?, row.get(1)?)))
583 .unwrap()
584 .map(Result::unwrap)
585 .collect();
586 counts.sort();
587 assert_eq!(counts, [("later".into(), 2), ("set_aside".into(), 1), ("triage".into(), 3)]);
588 let groups: Vec<(Option<String>, u32)> = db
589 .prepare(&LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE))
590 .unwrap()
591 .query_map(&values[..], |row| Ok((row.get(0)?, row.get(1)?)))
592 .unwrap()
593 .map(Result::unwrap)
594 .collect();
595 assert_eq!(groups, [(Some("acme/web".into()), 2)]);
596 }
597
598 #[test]
599 fn a_move_says_what_changed() {
600 assert_eq!(move_note(Horizon::Triage, Horizon::Next, false, false).as_deref(), Some("moved this from Triage to Next"));
601 assert_eq!(move_note(Horizon::Next, Horizon::Now, false, true).as_deref(), Some("moved this from Next to Now and marked this urgent"));
602 assert_eq!(move_note(Horizon::Next, Horizon::Next, false, false), None);
603 assert_eq!(valid_budget(Some(12.345)), Ok(Some(12.35)));
604 assert!(valid_budget(Some(0.0)).is_err());
605 assert!(valid_budget(Some(f64::NAN)).is_err());
606 }
607}