| 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 | #[derive(Deserialize)] |
| 73 | struct Neighbours { |
| 74 | ahead: Option<String>, |
| 75 | behind: Option<String>, |
| 76 | } |
| 77 | |
| 78 | #[derive(Deserialize)] |
| 79 | struct Count { |
| 80 | horizon: Option<String>, |
| 81 | count: u32, |
| 82 | } |
| 83 | |
| 84 | #[derive(Deserialize)] |
| 85 | struct 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. |
| 92 | fn 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)] |
| 103 | pub(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. |
| 117 | pub(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. |
| 131 | pub(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 | |
| 139 | impl 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, ¬e).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. |
| 444 | fn 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)] |
| 453 | mod 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 | } |