| 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 | |
| 8 | use g1t_contracts::access::{self, Capability}; |
| 9 | use g1t_contracts::events::WorkItemEvent; |
| 10 | use g1t_contracts::time::rfc3339; |
| 11 | use g1t_contracts::work::*; |
| 12 | use g1t_contracts::{FailureCode, Outcome, User, Viewer}; |
| 13 | use g1t_kit::now_ms; |
| 14 | use serde::Deserialize; |
| 15 | use worker::Result; |
| 16 | use worker::wasm_bindgen::JsValue; |
| 17 | |
| 18 | use super::rows::{Held, ITEM_COLUMNS, ItemRow, no_item, workspace_of}; |
| 19 | use 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. |
| 23 | pub(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. |
| 28 | pub(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. |
| 34 | const 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. |
| 43 | const 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 | |
| 48 | fn 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. |
| 53 | const 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. |
| 57 | const 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`. |
| 66 | const 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 | /// What the record says of each work item in the JSON array of ids `?1`: |
| 73 | /// its hill's facts, when anything last happened on it, and where g1t |
| 74 | /// last said it stands. |
| 75 | pub(crate) const FACTS: &str = "SELECT w.number, |
| 76 | (SELECT count(*) FROM decisions d WHERE d.item_id = w.id |
| 77 | AND d.kind NOT IN ('claim', 'merge', 'integration_failure', 'integration_passed')) AS decisions, |
| 78 | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'draft') AS draft_pulls, |
| 79 | (SELECT count(*) FROM session_touches t JOIN sessions s ON s.id = t.session_id |
| 80 | WHERE s.item_id = w.id AND t.kind = 'commit') AS commits, |
| 81 | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'open') AS open_pulls, |
| 82 | (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'merged') AS merged_pulls, |
| 83 | (SELECT count(*) FROM work_item_repos r WHERE r.item_id = w.id AND r.landed_at IS NOT NULL) AS landed_targets, |
| 84 | max(w.updated_at, |
| 85 | COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.item_id = w.id), ''), |
| 86 | COALESCE((SELECT max(d.created_at) FROM decisions d WHERE d.item_id = w.id), ''), |
| 87 | COALESCE((SELECT max(p.updated_at) FROM pulls p WHERE p.item_id = w.id), '')) AS active_at, |
| 88 | w.standing, w.standing_at |
| 89 | FROM work_items w WHERE w.id IN (SELECT value FROM json_each(?1))"; |
| 90 | |
| 91 | #[derive(Deserialize)] |
| 92 | pub(crate) struct FactsRow { |
| 93 | pub number: u32, |
| 94 | #[serde(default)] |
| 95 | pub decisions: u32, |
| 96 | #[serde(default)] |
| 97 | pub draft_pulls: u32, |
| 98 | #[serde(default)] |
| 99 | pub commits: u32, |
| 100 | #[serde(default)] |
| 101 | pub open_pulls: u32, |
| 102 | #[serde(default)] |
| 103 | pub merged_pulls: u32, |
| 104 | #[serde(default)] |
| 105 | pub landed_targets: u32, |
| 106 | #[serde(default)] |
| 107 | pub active_at: Option<String>, |
| 108 | #[serde(default)] |
| 109 | pub standing: Option<String>, |
| 110 | #[serde(default)] |
| 111 | pub standing_at: Option<String>, |
| 112 | } |
| 113 | |
| 114 | impl FactsRow { |
| 115 | pub(crate) fn hill(&self) -> Hill { |
| 116 | hill_of(&HillFacts { |
| 117 | decisions: self.decisions, |
| 118 | draft_pulls: self.draft_pulls, |
| 119 | commits: self.commits, |
| 120 | open_pulls: self.open_pulls, |
| 121 | merged_pulls: self.merged_pulls, |
| 122 | landed_targets: self.landed_targets, |
| 123 | }) |
| 124 | } |
| 125 | |
| 126 | /// How it stands, quiet when nothing has happened since `quiet_before` |
| 127 | /// (RFC 3339). |
| 128 | pub(crate) fn progress(self, quiet_before: &str) -> Progress { |
| 129 | let hill = self.hill(); |
| 130 | let active_at = self.active_at.filter(|at| !at.is_empty()); |
| 131 | Progress { |
| 132 | number: self.number, |
| 133 | hill, |
| 134 | quiet: active_at.as_deref().is_some_and(|at| at < quiet_before), |
| 135 | active_at, |
| 136 | standing: self.standing, |
| 137 | standing_at: self.standing_at, |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /// The RFC 3339 time `days` before `now_ms`. |
| 143 | pub(crate) fn days_before(now_ms: u64, days: u32) -> String { |
| 144 | rfc3339(now_ms.saturating_sub(u64::from(days) * 86_400_000)) |
| 145 | } |
| 146 | |
| 147 | /// What closed from Now since `?5`, the most recently closed first. |
| 148 | const LANDED: &str = "SELECT {ITEM_COLUMNS} FROM work_items |
| 149 | WHERE work_items.workspace = ?1 AND work_items.state = 'closed' AND work_items.number IS NOT NULL |
| 150 | AND work_items.horizon = 'now' AND work_items.closed_at >= ?5 |
| 151 | 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)) |
| 152 | AND (?2 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id |
| 153 | WHERE o.item_id = work_items.id AND o.role = 'origin' |
| 154 | AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?3))))) |
| 155 | ORDER BY work_items.closed_at DESC LIMIT 10"; |
| 156 | |
| 157 | /// How many have waited in Triage since before `?5`, and how many were set |
| 158 | /// aside since `?6`. |
| 159 | const WAITING: &str = "SELECT |
| 160 | (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND COALESCE(work_items.horizon, 'triage') = 'triage' |
| 161 | AND work_items.created_at < ?5) AS triage_waiting, |
| 162 | (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND work_items.horizon = 'set_aside' |
| 163 | AND work_items.planned_at >= ?6) AS set_aside_recent"; |
| 164 | |
| 165 | #[derive(Deserialize)] |
| 166 | struct Waiting { |
| 167 | #[serde(default)] |
| 168 | triage_waiting: u32, |
| 169 | #[serde(default)] |
| 170 | set_aside_recent: u32, |
| 171 | } |
| 172 | |
| 173 | #[derive(Deserialize)] |
| 174 | struct Neighbours { |
| 175 | ahead: Option<String>, |
| 176 | behind: Option<String>, |
| 177 | } |
| 178 | |
| 179 | #[derive(Deserialize)] |
| 180 | struct Count { |
| 181 | horizon: Option<String>, |
| 182 | count: u32, |
| 183 | } |
| 184 | |
| 185 | #[derive(Deserialize)] |
| 186 | struct Group { |
| 187 | repo: Option<String>, |
| 188 | count: u32, |
| 189 | } |
| 190 | |
| 191 | /// Who a viewer is in a workspace, for the queries above: whether they |
| 192 | /// are a member, and the repositories they were given a role on. |
| 193 | fn standing(viewer: Option<&User>, workspace: &str) -> Result<(JsValue, JsValue)> { |
| 194 | let member = viewer.and_then(|user| access::workspace_role(user, workspace)).is_some(); |
| 195 | let granted: Vec<&str> = viewer |
| 196 | .map(|user| user.grants.iter().filter(|grant| grant.workspace.eq_ignore_ascii_case(workspace)).map(|grant| grant.repo_id.as_str()).collect()) |
| 197 | .unwrap_or_default(); |
| 198 | Ok((u32::from(member).into(), serde_json::to_string(&granted)?.into())) |
| 199 | } |
| 200 | |
| 201 | /// What placing a work item asks for, once read: its horizon after, and |
| 202 | /// where on it. |
| 203 | #[derive(Debug, PartialEq, Eq)] |
| 204 | pub(crate) enum Place<'a> { |
| 205 | /// Leave its rank as it is. |
| 206 | Stay, |
| 207 | /// Last on the horizon. |
| 208 | Bottom, |
| 209 | Top, |
| 210 | /// Just before the work item of this rank. |
| 211 | Before(&'a str), |
| 212 | /// Just after the work item of this rank. |
| 213 | After(&'a str), |
| 214 | } |
| 215 | |
| 216 | /// What a move says in the work item's conversation, if anything: a new |
| 217 | /// horizon, and a change to urgent. |
| 218 | pub(crate) fn move_note(from: Horizon, to: Horizon, urgent_before: bool, urgent: bool) -> Option<String> { |
| 219 | let mut parts = Vec::new(); |
| 220 | if from != to { |
| 221 | parts.push(format!("moved this from {} to {}", from.label(), to.label())); |
| 222 | } |
| 223 | match (urgent_before, urgent) { |
| 224 | (false, true) => parts.push("marked this urgent".to_owned()), |
| 225 | (true, false) => parts.push("said this is no longer urgent".to_owned()), |
| 226 | _ => {} |
| 227 | } |
| 228 | (!parts.is_empty()).then(|| parts.join(" and ")) |
| 229 | } |
| 230 | |
| 231 | /// A budget as given: a positive number of dollars up to the most. |
| 232 | pub(crate) fn valid_budget(budget: Option<f64>) -> std::result::Result<Option<f64>, String> { |
| 233 | match budget { |
| 234 | None => Ok(None), |
| 235 | Some(dollars) if dollars.is_finite() && dollars > 0.0 && dollars <= MAX_BUDGET_USD => Ok(Some((dollars * 100.0).round() / 100.0)), |
| 236 | Some(_) => Err(format!("A budget is a number of dollars above 0 and up to {MAX_BUDGET_USD}.")), |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | impl Work { |
| 241 | /// How many work items Now holds before workspace `workspace`'s plan |
| 242 | /// says it is over. |
| 243 | async fn now_cap(&self, workspace: &str) -> Result<u32> { |
| 244 | #[derive(Deserialize)] |
| 245 | struct Cap { |
| 246 | now_cap: u32, |
| 247 | } |
| 248 | Ok(self |
| 249 | .db |
| 250 | .prepare("SELECT now_cap FROM plan_settings WHERE workspace = ?") |
| 251 | .bind(&[workspace.into()])? |
| 252 | .first::<Cap>(None) |
| 253 | .await? |
| 254 | .map_or(DEFAULT_NOW_CAP, |row| row.now_cap)) |
| 255 | } |
| 256 | |
| 257 | /// The open work item numbered `number`, to place another next to. |
| 258 | async fn anchor(&self, held: &Held, number: u32) -> Result<Outcome<Held>> { |
| 259 | if number == held.item.number { |
| 260 | return Ok(Outcome::fail(FailureCode::Invalid, "A work item can't be placed next to itself.")); |
| 261 | } |
| 262 | match self.item(&held.item.workspace, number).await? { |
| 263 | Some(anchor) if anchor.item.state == State::Open => Ok(Outcome::Ok(anchor)), |
| 264 | Some(_) => Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is closed, so it has no place in the plan."))), |
| 265 | None => Ok(Outcome::fail(FailureCode::NotFound, format!("#{number} is not a work item of {}.", held.item.workspace))), |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | /// The rank `place` gives work item `held` on `horizon`. |
| 270 | async fn rank_for(&self, held: &Held, horizon: Horizon, place: &Place<'_>) -> Result<Option<String>> { |
| 271 | if horizon == Horizon::Triage { |
| 272 | return Ok(None); |
| 273 | } |
| 274 | if *place == Place::Stay { |
| 275 | // A rank it already has there; one with none goes last. |
| 276 | if held.item.horizon == horizon { |
| 277 | if let Some(rank) = self.stored_rank(&held.item.id).await? { |
| 278 | return Ok(Some(rank)); |
| 279 | } |
| 280 | } |
| 281 | return Box::pin(self.rank_for(held, horizon, &Place::Bottom)).await; |
| 282 | } |
| 283 | // With no bound given, `ahead` is the last rank there and `behind` |
| 284 | // the first. |
| 285 | let (upper, lower): (JsValue, JsValue) = match place { |
| 286 | Place::Before(rank) => ((*rank).into(), JsValue::NULL), |
| 287 | Place::After(rank) => (JsValue::NULL, (*rank).into()), |
| 288 | _ => (JsValue::NULL, JsValue::NULL), |
| 289 | }; |
| 290 | let found = self |
| 291 | .db |
| 292 | .prepare(NEIGHBOURS) |
| 293 | .bind(&[held.item.workspace.as_str().into(), horizon.as_str().into(), held.item.id.as_str().into(), upper, lower])? |
| 294 | .first::<Neighbours>(None) |
| 295 | .await? |
| 296 | .unwrap_or(Neighbours { ahead: None, behind: None }); |
| 297 | Ok(Some(match place { |
| 298 | Place::Before(rank) => rank_between(found.ahead.as_deref(), Some(rank)), |
| 299 | Place::After(rank) => rank_between(Some(rank), found.behind.as_deref()), |
| 300 | Place::Top => rank_between(None, found.behind.as_deref()), |
| 301 | Place::Bottom | Place::Stay => rank_between(found.ahead.as_deref(), None), |
| 302 | })) |
| 303 | } |
| 304 | |
| 305 | async fn stored_rank(&self, id: &str) -> Result<Option<String>> { |
| 306 | #[derive(Deserialize)] |
| 307 | struct Rank { |
| 308 | rank: Option<String>, |
| 309 | } |
| 310 | Ok(self.db.prepare("SELECT rank FROM work_items WHERE id = ?").bind(&[id.into()])?.first::<Rank>(None).await?.and_then(|row| row.rank)) |
| 311 | } |
| 312 | |
| 313 | /// Gives `anchor` a rank, last on its horizon, if it has none, so |
| 314 | /// another can be placed next to it. Returns its rank. |
| 315 | async fn ranked(&self, anchor: &Held) -> Result<String> { |
| 316 | if let Some(rank) = self.stored_rank(&anchor.item.id).await? { |
| 317 | return Ok(rank); |
| 318 | } |
| 319 | let rank = self.rank_for(anchor, anchor.item.horizon, &Place::Bottom).await?.unwrap_or_else(|| rank_between(None, None)); |
| 320 | self.db |
| 321 | .prepare("UPDATE work_items SET rank = ? WHERE id = ? AND rank IS NULL") |
| 322 | .bind(&[rank.as_str().into(), anchor.item.id.as_str().into()])? |
| 323 | .run() |
| 324 | .await?; |
| 325 | Ok(rank) |
| 326 | } |
| 327 | |
| 328 | // --- RPC methods ---------------------------------------------------------- |
| 329 | |
| 330 | pub(crate) async fn plan_work_item(&self, a: PlanWorkItemArgs) -> Result<Outcome<WorkItem>> { |
| 331 | let workspace = a.workspace.trim().to_lowercase(); |
| 332 | let held = check!(self.item_for(&Some(a.actor.clone()), &workspace, a.number, Capability::Read).await?); |
| 333 | if held.item.state == State::Closed { |
| 334 | return Ok(Outcome::fail(FailureCode::Conflict, "This work item is closed, so it has no place in the plan. Reopen it first.")); |
| 335 | } |
| 336 | let positions = usize::from(a.before.is_some()) + usize::from(a.after.is_some()) + usize::from(a.top); |
| 337 | if positions > 1 { |
| 338 | return Ok(Outcome::fail(FailureCode::Invalid, "Say one of before, after or top.")); |
| 339 | } |
| 340 | let budget = match a.budget_usd { |
| 341 | Some(budget) => match valid_budget(budget) { |
| 342 | Ok(budget) => Some(budget), |
| 343 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 344 | }, |
| 345 | None => None, |
| 346 | }; |
| 347 | // Next to another: on that one's horizon. |
| 348 | let anchor = match a.before.or(a.after) { |
| 349 | Some(number) => Some(check!(self.anchor(&held, number).await?)), |
| 350 | None => None, |
| 351 | }; |
| 352 | let from = held.item.horizon; |
| 353 | // Urgent is first in Now. |
| 354 | let mut horizon = a.horizon.or(anchor.as_ref().map(|anchor| anchor.item.horizon)).unwrap_or(from); |
| 355 | if a.urgent == Some(true) { |
| 356 | if a.horizon.is_some_and(|asked| asked != Horizon::Now) { |
| 357 | return Ok(Outcome::fail(FailureCode::Invalid, "An urgent work item is in Now.")); |
| 358 | } |
| 359 | horizon = Horizon::Now; |
| 360 | } |
| 361 | if let (Some(anchor), Some(asked)) = (&anchor, a.horizon) { |
| 362 | if anchor.item.horizon != asked { |
| 363 | return Ok(Outcome::fail( |
| 364 | FailureCode::Invalid, |
| 365 | format!("#{} is in {}, not {}.", anchor.item.number, anchor.item.horizon.label(), asked.label()), |
| 366 | )); |
| 367 | } |
| 368 | } |
| 369 | if horizon == Horizon::Triage && positions > 0 { |
| 370 | return Ok(Outcome::fail(FailureCode::Invalid, "Triage is in the order work items came in, so nothing is placed in it.")); |
| 371 | } |
| 372 | // Who may: the Triage role to plan; the right to manage milestones |
| 373 | // for what the workspace commits to. |
| 374 | check!(held.allowed(Some(&a.actor), Capability::Triage)); |
| 375 | let reranked = positions > 0 || horizon != from; |
| 376 | let commits = (horizon.committed() && reranked) || (from.committed() && horizon != from) || a.urgent.is_some_and(|urgent| urgent != held.item.urgent); |
| 377 | if commits && !access::can_item(Some(&a.actor), &workspace, None, Capability::ManageLabels) { |
| 378 | return Ok(Outcome::fail( |
| 379 | FailureCode::Forbidden, |
| 380 | format!("Deciding what is in Now takes the right to manage milestones. {}", access::needs_on_item(Capability::ManageLabels, &workspace)), |
| 381 | )); |
| 382 | } |
| 383 | let anchor_rank = match &anchor { |
| 384 | Some(anchor) => Some(self.ranked(anchor).await?), |
| 385 | None => None, |
| 386 | }; |
| 387 | let place = match (&anchor_rank, a.before.is_some(), a.top) { |
| 388 | (Some(rank), true, _) => Place::Before(rank), |
| 389 | (Some(rank), false, _) => Place::After(rank), |
| 390 | (None, _, true) => Place::Top, |
| 391 | (None, _, false) if horizon != from => Place::Bottom, |
| 392 | _ => Place::Stay, |
| 393 | }; |
| 394 | let rank = self.rank_for(&held, horizon, &place).await?; |
| 395 | let urgent = a.urgent.unwrap_or(held.item.urgent) && horizon == Horizon::Now; |
| 396 | let appetite = a.appetite.unwrap_or(held.item.appetite); |
| 397 | let budget = budget.unwrap_or(held.item.budget_usd); |
| 398 | let now = rfc3339(now_ms()); |
| 399 | let planned_at = if reranked { Some(now.clone()) } else { held.item.planned_at.clone() }; |
| 400 | self.db |
| 401 | .prepare( |
| 402 | "UPDATE work_items SET horizon = ?, rank = ?, appetite = ?, budget_usd = ?, urgent = ?, planned_at = ? |
| 403 | WHERE id = ? AND state = 'open'", |
| 404 | ) |
| 405 | .bind(&[ |
| 406 | horizon.as_str().into(), |
| 407 | optional(&rank), |
| 408 | optional(&appetite.map(|appetite| appetite.as_str().to_owned())), |
| 409 | budget.map_or(JsValue::NULL, JsValue::from), |
| 410 | u32::from(urgent).into(), |
| 411 | optional(&planned_at), |
| 412 | held.item.id.as_str().into(), |
| 413 | ])? |
| 414 | .run() |
| 415 | .await?; |
| 416 | let Some(after) = self.item_by_id(&held.item.id).await? else { |
| 417 | return Ok(no_item()); |
| 418 | }; |
| 419 | self.publish_item( |
| 420 | "work_item.planned", |
| 421 | &after, |
| 422 | Some(&a.actor), |
| 423 | WorkItemEvent { |
| 424 | horizon: Some(horizon), |
| 425 | from_horizon: (horizon != from).then_some(from), |
| 426 | appetite: after.item.appetite, |
| 427 | urgent: Some(urgent), |
| 428 | ..Self::item_event(&after) |
| 429 | }, |
| 430 | ) |
| 431 | .await?; |
| 432 | if let Some(note) = move_note(from, horizon, held.item.urgent, urgent) { |
| 433 | self.note_item(&after.item.id, &a.actor, ¬e).await?; |
| 434 | } |
| 435 | Ok(Outcome::Ok(after.shown(Some(&a.actor)))) |
| 436 | } |
| 437 | |
| 438 | pub(crate) async fn get_plan(&self, a: GetPlanArgs) -> Result<Outcome<Plan>> { |
| 439 | let workspace = workspace_of(&a.workspace, a.repo.as_ref()); |
| 440 | if workspace.is_empty() { |
| 441 | return Ok(Outcome::fail(FailureCode::Invalid, "Say which workspace.")); |
| 442 | } |
| 443 | let repo_id = match &a.repo { |
| 444 | Some(path) => { |
| 445 | let repo = check!(self.repo(path, &a.viewer).await?); |
| 446 | if !repo.namespace.eq_ignore_ascii_case(&workspace) { |
| 447 | return Ok(Outcome::Ok(Plan { workspace, now_cap: self.now_cap(&repo.namespace.to_lowercase()).await?, ..Plan::default() })); |
| 448 | } |
| 449 | Some(repo.id) |
| 450 | } |
| 451 | None => None, |
| 452 | }; |
| 453 | let (member, granted) = standing(a.viewer.as_ref(), &workspace)?; |
| 454 | let bound = |extra: Option<JsValue>| { |
| 455 | let mut values = vec![workspace.as_str().into(), member.clone(), granted.clone(), optional(&repo_id)]; |
| 456 | values.extend(extra); |
| 457 | values |
| 458 | }; |
| 459 | let shown = if a.all { crate::LIST_PAGE } else { NEXT_SHOWN }; |
| 460 | let now_ms = now_ms(); |
| 461 | let week = days_before(now_ms, WEEK_DAYS); |
| 462 | let mut waiting_values = bound(Some(days_before(now_ms, TRIAGE_NUDGE_DAYS).into())); |
| 463 | waiting_values.push(days_before(now_ms, 30).into()); |
| 464 | let statements = vec![ |
| 465 | self.db.prepare(open_items(&format!("{HORIZON} = 'now'"), crate::LIST_PAGE)).bind(&bound(None))?, |
| 466 | self.db.prepare(open_items(&format!("{HORIZON} = 'next'"), shown)).bind(&bound(None))?, |
| 467 | self.db.prepare(COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?, |
| 468 | self.db.prepare(LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?, |
| 469 | self.db.prepare(LANDED.replace("{ITEM_COLUMNS}", ITEM_COLUMNS)).bind(&bound(Some(week.as_str().into())))?, |
| 470 | self.db.prepare(WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&waiting_values)?, |
| 471 | ]; |
| 472 | let results = self.timing.db(6, self.db.batch(statements)).await?; |
| 473 | let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into())); |
| 474 | let now = shown_rows(rows(0)?.results::<ItemRow>()?, &a.viewer); |
| 475 | let next = shown_rows(rows(1)?.results::<ItemRow>()?, &a.viewer); |
| 476 | let counts = rows(2)?.results::<Count>()?; |
| 477 | let total = |horizon: Horizon| { |
| 478 | counts.iter().filter(|row| row.horizon.as_deref().and_then(Horizon::parse).unwrap_or_default() == horizon).map(|row| row.count).sum() |
| 479 | }; |
| 480 | let later = rows(3)?.results::<Group>()?.into_iter().map(|group| PlanGroup { repo: group.repo, count: group.count }).collect(); |
| 481 | let landed = shown_rows(rows(4)?.results::<ItemRow>()?, &a.viewer); |
| 482 | let waiting = rows(5)?.results::<Waiting>()?.into_iter().next().unwrap_or(Waiting { triage_waiting: 0, set_aside_recent: 0 }); |
| 483 | // How each in Now stands, in Now's order. |
| 484 | let ids: Vec<&str> = now.iter().map(|item| item.id.as_str()).collect(); |
| 485 | let mut facts: Vec<FactsRow> = if ids.is_empty() { |
| 486 | Vec::new() |
| 487 | } else { |
| 488 | self.db.prepare(FACTS).bind(&[serde_json::to_string(&ids)?.into()])?.all().await?.results::<FactsRow>()? |
| 489 | }; |
| 490 | let quiet_before = days_before(now_ms, QUIET_DAYS); |
| 491 | let progress = now |
| 492 | .iter() |
| 493 | .filter_map(|item| { |
| 494 | let at = facts.iter().position(|row| row.number == item.number)?; |
| 495 | Some(facts.swap_remove(at).progress(&quiet_before)) |
| 496 | }) |
| 497 | .collect(); |
| 498 | Ok(Outcome::Ok(Plan { |
| 499 | progress, |
| 500 | landed, |
| 501 | triage_waiting: waiting.triage_waiting, |
| 502 | set_aside_recent: waiting.set_aside_recent, |
| 503 | now_cap: self.now_cap(&workspace).await?, |
| 504 | now, |
| 505 | next, |
| 506 | next_total: total(Horizon::Next), |
| 507 | triage_total: total(Horizon::Triage), |
| 508 | later_total: total(Horizon::Later), |
| 509 | later, |
| 510 | set_aside_total: total(Horizon::SetAside), |
| 511 | workspace, |
| 512 | })) |
| 513 | } |
| 514 | |
| 515 | pub(crate) async fn next_work_item(&self, a: NextWorkItemArgs) -> Result<Outcome<Option<WorkItem>>> { |
| 516 | let workspace = workspace_of(&a.workspace, a.repo.as_ref()); |
| 517 | let repo_id = match &a.repo { |
| 518 | Some(path) => { |
| 519 | let repo = check!(self.repo(path, &a.viewer).await?); |
| 520 | if !repo.namespace.eq_ignore_ascii_case(&workspace) { |
| 521 | return Ok(Outcome::Ok(None)); |
| 522 | } |
| 523 | Some(repo.id) |
| 524 | } |
| 525 | None => None, |
| 526 | }; |
| 527 | let (member, granted) = standing(a.viewer.as_ref(), &workspace)?; |
| 528 | let username = a.viewer.as_ref().map(|user| user.username.to_lowercase()).unwrap_or_default(); |
| 529 | let sql = format!( |
| 530 | "SELECT {ITEM_COLUMNS} FROM work_items WHERE {OPEN_VISIBLE} AND {HORIZON} IN ('now', 'next') AND {READY} |
| 531 | ORDER BY CASE {HORIZON} WHEN 'now' THEN 0 ELSE 1 END, {PLAN_ORDER} LIMIT 20" |
| 532 | ); |
| 533 | let rows = self |
| 534 | .db |
| 535 | .prepare(sql) |
| 536 | .bind(&[ |
| 537 | workspace.as_str().into(), |
| 538 | member, |
| 539 | granted, |
| 540 | optional(&repo_id), |
| 541 | rfc3339(now_ms()).into(), |
| 542 | username.into(), |
| 543 | ])? |
| 544 | .all() |
| 545 | .await? |
| 546 | .results::<ItemRow>()?; |
| 547 | Ok(Outcome::Ok(shown_rows(rows, &a.viewer).into_iter().next())) |
| 548 | } |
| 549 | |
| 550 | pub(crate) async fn configure_plan(&self, a: ConfigurePlanArgs) -> Result<Outcome<Plan>> { |
| 551 | let workspace = a.workspace.trim().to_lowercase(); |
| 552 | if !access::can_item(Some(&a.actor), &workspace, None, Capability::ManageLabels) { |
| 553 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::ManageLabels, &workspace))); |
| 554 | } |
| 555 | if !(1..=MAX_NOW_CAP).contains(&a.now_cap) { |
| 556 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Now's cap is a number from 1 to {MAX_NOW_CAP}."))); |
| 557 | } |
| 558 | self.db |
| 559 | .prepare( |
| 560 | "INSERT INTO plan_settings (workspace, now_cap, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4) |
| 561 | ON CONFLICT (workspace) DO UPDATE SET now_cap = excluded.now_cap, updated_at = excluded.updated_at, |
| 562 | updated_by = excluded.updated_by", |
| 563 | ) |
| 564 | .bind(&[workspace.as_str().into(), a.now_cap.into(), rfc3339(now_ms()).into(), a.actor.id.as_str().into()])? |
| 565 | .run() |
| 566 | .await?; |
| 567 | Ok(Outcome::Ok(Plan { workspace, now_cap: a.now_cap, ..Plan::default() })) |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | /// The rows a viewer may see, as they are shown them. |
| 572 | fn shown_rows(rows: Vec<ItemRow>, viewer: &Viewer) -> Vec<WorkItem> { |
| 573 | rows.into_iter() |
| 574 | .filter_map(ItemRow::held) |
| 575 | .filter(|held| held.may(viewer.as_ref(), Capability::Read)) |
| 576 | .map(|held| held.shown(viewer.as_ref())) |
| 577 | .collect() |
| 578 | } |
| 579 | |
| 580 | #[cfg(test)] |
| 581 | mod tests { |
| 582 | use rusqlite::{Connection, ToSql}; |
| 583 | |
| 584 | use super::super::rows::ITEM_COLUMNS; |
| 585 | use super::*; |
| 586 | use crate::migrated::database; |
| 587 | |
| 588 | /// acme's open work items 1 to 6 (2 private to rep_secret), 7 closed. |
| 589 | fn acme() -> Connection { |
| 590 | let db = database(); |
| 591 | db.execute_batch( |
| 592 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) |
| 593 | VALUES ('rep_web', 'acme', 'acme/web', 0), ('rep_secret', 'acme', 'acme/secret', 1); |
| 594 | INSERT INTO work_items (id, workspace, number, title, body, labels, author_id, author_name, created_at, updated_at, state) |
| 595 | VALUES ('wki_1', 'acme', 1, 'One', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 596 | ('wki_2', 'acme', 2, 'Two', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 597 | ('wki_3', 'acme', 3, 'Three', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 598 | ('wki_4', 'acme', 4, 'Four', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 599 | ('wki_5', 'acme', 5, 'Five', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 600 | ('wki_6', 'acme', 6, 'Six', '', '[]', 'usr_1', 'ana', '', '', 'open'), |
| 601 | ('wki_7', 'acme', 7, 'Seven', '', '[]', 'usr_1', 'ana', '', '', 'closed'); |
| 602 | INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at) |
| 603 | VALUES ('wki_1', 'rep_web', 'origin', 'acme/web', ''), ('wki_2', 'rep_secret', 'origin', 'acme/secret', ''), |
| 604 | ('wki_3', 'rep_web', 'origin', 'acme/web', ''), ('wki_4', 'rep_web', 'origin', 'acme/web', ''), |
| 605 | ('wki_5', 'rep_web', 'origin', 'acme/web', ''), ('wki_6', 'rep_web', 'origin', 'acme/web', ''), |
| 606 | ('wki_7', 'rep_web', 'origin', 'acme/web', '');", |
| 607 | ) |
| 608 | .unwrap(); |
| 609 | db |
| 610 | } |
| 611 | |
| 612 | fn place(db: &Connection, number: u32, horizon: &str, rank: Option<&str>) { |
| 613 | db.execute("UPDATE work_items SET horizon = ?1, rank = ?2 WHERE number = ?3", rusqlite::params![horizon, rank, number]).unwrap(); |
| 614 | } |
| 615 | |
| 616 | /// The numbers on `horizon`, in the plan's order, as a member or not. |
| 617 | fn on(db: &Connection, horizon: &str, member: bool) -> Vec<u32> { |
| 618 | let sql = open_items(&format!("{HORIZON} = ?5"), 100).replace("{ITEM_COLUMNS}", ITEM_COLUMNS); |
| 619 | let values: [&dyn ToSql; 5] = [&"acme", &u32::from(member), &"[]", &None::<String>, &horizon]; |
| 620 | let mut statement = db.prepare(&sql).unwrap(); |
| 621 | statement.query_map(&values[..], |row| row.get::<_, u32>("number")).unwrap().map(Result::unwrap).collect() |
| 622 | } |
| 623 | |
| 624 | #[test] |
| 625 | fn open_work_items_start_in_triage_newest_first() { |
| 626 | let db = acme(); |
| 627 | assert_eq!(on(&db, "triage", true), [6, 5, 4, 3, 2, 1]); |
| 628 | // Someone outside sees only what is filed in a public repository. |
| 629 | assert_eq!(on(&db, "triage", false), [6, 5, 4, 3, 1]); |
| 630 | } |
| 631 | |
| 632 | #[test] |
| 633 | fn a_horizon_reads_urgent_first_then_by_rank() { |
| 634 | let db = acme(); |
| 635 | place(&db, 1, "next", Some("V")); |
| 636 | place(&db, 3, "next", Some("F")); |
| 637 | place(&db, 4, "next", None); |
| 638 | place(&db, 5, "now", Some("k")); |
| 639 | place(&db, 6, "now", Some("x")); |
| 640 | db.execute("UPDATE work_items SET urgent = 1 WHERE number = 6", []).unwrap(); |
| 641 | assert_eq!(on(&db, "next", true), [3, 1, 4]); |
| 642 | assert_eq!(on(&db, "now", true), [6, 5]); |
| 643 | assert_eq!(on(&db, "triage", true), [2]); |
| 644 | } |
| 645 | |
| 646 | /// The ranks around a place, as `rank_for` reads them. |
| 647 | fn neighbours(db: &Connection, horizon: &str, leaving_out: &str, upper: Option<&str>, lower: Option<&str>) -> (Option<String>, Option<String>) { |
| 648 | db.query_row(NEIGHBOURS, rusqlite::params!["acme", horizon, leaving_out, upper, lower], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() |
| 649 | } |
| 650 | |
| 651 | #[test] |
| 652 | fn a_place_is_found_between_its_neighbours() { |
| 653 | let db = acme(); |
| 654 | place(&db, 1, "next", Some("F")); |
| 655 | place(&db, 3, "next", Some("V")); |
| 656 | place(&db, 4, "next", Some("k")); |
| 657 | // Before #3 (V): after F. |
| 658 | assert_eq!(neighbours(&db, "next", "wki_5", Some("V"), None), (Some("F".into()), Some("F".into()))); |
| 659 | // After #3: ahead of k. |
| 660 | assert_eq!(neighbours(&db, "next", "wki_5", None, Some("V")).1, Some("k".into())); |
| 661 | // Moving #4 itself to the bottom: it is left out. |
| 662 | assert_eq!(neighbours(&db, "next", "wki_4", None, None).0, Some("V".into())); |
| 663 | let rank = rank_between(Some("F"), Some("V")); |
| 664 | place(&db, 5, "next", Some(&rank)); |
| 665 | assert_eq!(on(&db, "next", true), [1, 5, 3, 4]); |
| 666 | } |
| 667 | |
| 668 | #[test] |
| 669 | fn what_is_ready_leaves_out_claimed_blocked_and_others_work() { |
| 670 | let db = acme(); |
| 671 | place(&db, 1, "next", Some("a")); |
| 672 | place(&db, 3, "next", Some("b")); |
| 673 | place(&db, 4, "now", Some("c")); |
| 674 | place(&db, 5, "next", Some("d")); |
| 675 | place(&db, 6, "next", Some("e")); |
| 676 | // #4 in Now is claimed; #1 is blocked by open #2; #3 is someone else's. |
| 677 | db.execute_batch( |
| 678 | "INSERT INTO claims (id, item_id, actor_id, actor_name, state, ttl_minutes, created_at, expires_at) |
| 679 | VALUES ('clm_1', 'wki_4', 'usr_2', 'bo', 'active', 30, '', '2999-01-01T00:00:00Z'); |
| 680 | UPDATE work_items SET blocked_by = '[\"wki_2\"]' WHERE number = 1; |
| 681 | UPDATE work_items SET assignees = '[\"bo\"]' WHERE number = 3;", |
| 682 | ) |
| 683 | .unwrap(); |
| 684 | let sql = format!( |
| 685 | "SELECT number FROM work_items WHERE {OPEN_VISIBLE} AND {HORIZON} IN ('now', 'next') AND {READY} |
| 686 | ORDER BY CASE {HORIZON} WHEN 'now' THEN 0 ELSE 1 END, {PLAN_ORDER}" |
| 687 | ); |
| 688 | let ready = |who: &str| -> Vec<u32> { |
| 689 | let values: [&dyn ToSql; 6] = [&"acme", &1, &"[]", &None::<String>, &"2026-10-19T00:00:00Z", &who]; |
| 690 | let mut statement = db.prepare(&sql).unwrap(); |
| 691 | statement.query_map(&values[..], |row| row.get::<_, u32>(0)).unwrap().map(Result::unwrap).collect() |
| 692 | }; |
| 693 | assert_eq!(ready("ana"), [5, 6]); |
| 694 | assert_eq!(ready("bo"), [3, 5, 6]); |
| 695 | // The claim expires: #4, in Now, comes first. |
| 696 | db.execute("UPDATE claims SET expires_at = '2026-10-18T00:00:00Z'", []).unwrap(); |
| 697 | assert_eq!(ready("ana"), [4, 5, 6]); |
| 698 | } |
| 699 | |
| 700 | #[test] |
| 701 | fn counts_and_later_groups_cover_every_horizon() { |
| 702 | let db = acme(); |
| 703 | place(&db, 1, "later", Some("a")); |
| 704 | place(&db, 3, "later", Some("b")); |
| 705 | place(&db, 4, "set_aside", Some("c")); |
| 706 | let values: [&dyn ToSql; 4] = [&"acme", &1, &"[]", &None::<String>]; |
| 707 | let mut counts: Vec<(String, u32)> = db |
| 708 | .prepare(&COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)) |
| 709 | .unwrap() |
| 710 | .query_map(&values[..], |row| Ok((row.get(0)?, row.get(1)?))) |
| 711 | .unwrap() |
| 712 | .map(Result::unwrap) |
| 713 | .collect(); |
| 714 | counts.sort(); |
| 715 | assert_eq!(counts, [("later".into(), 2), ("set_aside".into(), 1), ("triage".into(), 3)]); |
| 716 | let groups: Vec<(Option<String>, u32)> = db |
| 717 | .prepare(&LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)) |
| 718 | .unwrap() |
| 719 | .query_map(&values[..], |row| Ok((row.get(0)?, row.get(1)?))) |
| 720 | .unwrap() |
| 721 | .map(Result::unwrap) |
| 722 | .collect(); |
| 723 | assert_eq!(groups, [(Some("acme/web".into()), 2)]); |
| 724 | } |
| 725 | |
| 726 | /// The facts `FACTS` reads for work item `number`. |
| 727 | fn facts(db: &Connection, number: u32) -> FactsRow { |
| 728 | let ids = format!("[\"wki_{number}\"]"); |
| 729 | db.query_row(FACTS, [ids], |row| { |
| 730 | Ok(FactsRow { |
| 731 | number: row.get("number")?, |
| 732 | decisions: row.get("decisions")?, |
| 733 | draft_pulls: row.get("draft_pulls")?, |
| 734 | commits: row.get("commits")?, |
| 735 | open_pulls: row.get("open_pulls")?, |
| 736 | merged_pulls: row.get("merged_pulls")?, |
| 737 | landed_targets: row.get("landed_targets")?, |
| 738 | active_at: row.get("active_at")?, |
| 739 | standing: row.get("standing")?, |
| 740 | standing_at: row.get("standing_at")?, |
| 741 | }) |
| 742 | }) |
| 743 | .unwrap() |
| 744 | } |
| 745 | |
| 746 | #[test] |
| 747 | fn the_record_says_how_far_along_each_is() { |
| 748 | let db = acme(); |
| 749 | db.execute_batch( |
| 750 | "UPDATE work_items SET horizon = 'now', updated_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3, 4); |
| 751 | INSERT INTO decisions (id, repo_id, item_id, kind, confidence, summary, created_at) |
| 752 | VALUES ('dec_1', 'rep_web', 'wki_3', 'proposal', 'approved', 'Rank by fractional keys', '2026-10-10T00:00:00Z'), |
| 753 | ('dec_2', 'rep_web', 'wki_1', 'claim', 'approved', 'Scope: tests', '2026-10-11T00:00:00Z'); |
| 754 | INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, state, started_at, last_seen_at, item_id) |
| 755 | VALUES ('ses_1', 'rep_web', 'usr_1', 'ana', 'claude_code', 'active', '2026-10-12T00:00:00Z', '2026-10-15T00:00:00Z', 'wki_4'); |
| 756 | INSERT INTO session_touches (session_id, repo_id, kind, value, at) VALUES ('ses_1', 'rep_web', 'commit', 'abc1234', '2026-10-15T00:00:00Z');", |
| 757 | ) |
| 758 | .unwrap(); |
| 759 | // A claim's note is not a decision; nothing else has happened on #1. |
| 760 | let one = facts(&db, 1); |
| 761 | assert_eq!((one.hill(), one.active_at.as_deref()), (Hill::FiguringOut, Some("2026-10-11T00:00:00Z"))); |
| 762 | assert_eq!(facts(&db, 3).hill(), Hill::Decided); |
| 763 | let four = facts(&db, 4); |
| 764 | assert_eq!((four.hill(), four.active_at.as_deref()), (Hill::Building, Some("2026-10-15T00:00:00Z"))); |
| 765 | // Quiet: nothing since before the line. |
| 766 | assert!(facts(&db, 3).progress("2026-10-14T00:00:00Z").quiet); |
| 767 | assert!(!facts(&db, 4).progress("2026-10-14T00:00:00Z").quiet); |
| 768 | } |
| 769 | |
| 770 | #[test] |
| 771 | fn the_plan_says_what_waits_too_long() { |
| 772 | let db = acme(); |
| 773 | db.execute_batch( |
| 774 | "UPDATE work_items SET created_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3); |
| 775 | UPDATE work_items SET created_at = '2026-10-18T00:00:00Z' WHERE number = 4; |
| 776 | UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-10-15T00:00:00Z' WHERE number = 5; |
| 777 | UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-08-01T00:00:00Z' WHERE number = 6;", |
| 778 | ) |
| 779 | .unwrap(); |
| 780 | let values: [&dyn ToSql; 6] = [&"acme", &1, &"[]", &None::<String>, &"2026-10-12T00:00:00Z", &"2026-09-20T00:00:00Z"]; |
| 781 | let (waiting, recent): (u32, u32) = db |
| 782 | .query_row(&WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE), &values[..], |row| Ok((row.get(0)?, row.get(1)?))) |
| 783 | .unwrap(); |
| 784 | // #1 and #3 (and #2, made with no date) in Triage since before the line; #5 set aside lately. |
| 785 | assert_eq!((waiting, recent), (3, 1)); |
| 786 | } |
| 787 | |
| 788 | #[test] |
| 789 | fn a_move_says_what_changed() { |
| 790 | assert_eq!(move_note(Horizon::Triage, Horizon::Next, false, false).as_deref(), Some("moved this from Triage to Next")); |
| 791 | assert_eq!(move_note(Horizon::Next, Horizon::Now, false, true).as_deref(), Some("moved this from Next to Now and marked this urgent")); |
| 792 | assert_eq!(move_note(Horizon::Next, Horizon::Next, false, false), None); |
| 793 | assert_eq!(valid_budget(Some(12.345)), Ok(Some(12.35))); |
| 794 | assert!(valid_budget(Some(0.0)).is_err()); |
| 795 | assert!(valid_budget(Some(f64::NAN)).is_err()); |
| 796 | } |
| 797 | } |