| 1 | //! Milestones: goals of a workspace, with an optional due date, that its |
| 2 | //! work items and its repositories' pull requests are gathered under. A |
| 3 | //! work item or a pull request is in at most one, by number (`milestone` |
| 4 | //! on its row). |
| 5 | |
| 6 | use g1t_contracts::access::{self, Capability}; |
| 7 | use g1t_contracts::events::{PullEvent, WorkItemEvent}; |
| 8 | use g1t_contracts::time::rfc3339; |
| 9 | use g1t_contracts::work::*; |
| 10 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 11 | use g1t_kit::now_ms; |
| 12 | use serde::Deserialize; |
| 13 | use worker::Result; |
| 14 | use worker::wasm_bindgen::JsValue; |
| 15 | |
| 16 | use super::labels::Labeled; |
| 17 | use super::rows::{ITEM_COLUMNS, ItemRow, workspace_of}; |
| 18 | use crate::rows::{PULL_COLUMNS, PullRow}; |
| 19 | use crate::Work; |
| 20 | |
| 21 | macro_rules! check { |
| 22 | ($outcome:expr) => { |
| 23 | match $outcome { |
| 24 | Outcome::Ok(value) => value, |
| 25 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 26 | } |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | #[derive(Deserialize)] |
| 31 | struct MilestoneRow { |
| 32 | number: u32, |
| 33 | title: String, |
| 34 | description: String, |
| 35 | due_on: Option<String>, |
| 36 | state: State, |
| 37 | created_at: String, |
| 38 | updated_at: String, |
| 39 | closed_at: Option<String>, |
| 40 | #[serde(default)] |
| 41 | open_items: u32, |
| 42 | #[serde(default)] |
| 43 | closed_items: u32, |
| 44 | #[serde(default)] |
| 45 | origin_repo_id: Option<String>, |
| 46 | } |
| 47 | |
| 48 | impl From<MilestoneRow> for Milestone { |
| 49 | fn from(row: MilestoneRow) -> Self { |
| 50 | Milestone { |
| 51 | number: row.number, |
| 52 | title: row.title, |
| 53 | description: row.description, |
| 54 | due_on: row.due_on, |
| 55 | state: row.state, |
| 56 | open_items: row.open_items, |
| 57 | closed_items: row.closed_items, |
| 58 | created_at: row.created_at, |
| 59 | updated_at: row.updated_at, |
| 60 | closed_at: row.closed_at, |
| 61 | origin_repo_id: row.origin_repo_id, |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// The pull requests of a workspace's (`?1`) repositories. |
| 67 | const WORKSPACE_PULLS: &str = "pulls.repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?1)"; |
| 68 | |
| 69 | /// A workspace's milestones with how many items are open and closed in |
| 70 | /// each. `?1` is its slug. |
| 71 | const MILESTONES: &str = "SELECT milestones.*, |
| 72 | (SELECT count(*) FROM work_items WHERE work_items.workspace = ?1 AND work_items.milestone = milestones.number |
| 73 | AND work_items.state = 'open') |
| 74 | + (SELECT count(*) FROM pulls WHERE {WORKSPACE_PULLS} AND pulls.milestone = milestones.number |
| 75 | AND pulls.status IN ('draft', 'open')) AS open_items, |
| 76 | (SELECT count(*) FROM work_items WHERE work_items.workspace = ?1 AND work_items.milestone = milestones.number |
| 77 | AND work_items.state = 'closed') |
| 78 | + (SELECT count(*) FROM pulls WHERE {WORKSPACE_PULLS} AND pulls.milestone = milestones.number |
| 79 | AND pulls.status IN ('merged', 'closed')) AS closed_items |
| 80 | FROM milestones WHERE workspace = ?1"; |
| 81 | |
| 82 | fn milestones_sql() -> String { |
| 83 | MILESTONES.replace("{WORKSPACE_PULLS}", WORKSPACE_PULLS) |
| 84 | } |
| 85 | |
| 86 | /// Open ones first, soonest due first and those without a date after; |
| 87 | /// then closed ones, most recently closed first. |
| 88 | const ORDER: &str = "ORDER BY state = 'closed', CASE WHEN state = 'open' THEN due_on IS NULL END, |
| 89 | CASE WHEN state = 'open' THEN due_on END, CASE WHEN state = 'closed' THEN closed_at END DESC, number"; |
| 90 | |
| 91 | const MAX_LISTED: u32 = 200; |
| 92 | |
| 93 | fn tidy_title(title: &str) -> Result<String, &'static str> { |
| 94 | let title = title.trim(); |
| 95 | if title.is_empty() { |
| 96 | Err("A milestone needs a title.") |
| 97 | } else if title.chars().count() > MAX_MILESTONE_TITLE_CHARS { |
| 98 | Err("A milestone's title can be at most 100 characters.") |
| 99 | } else { |
| 100 | Ok(title.to_owned()) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// A due date as given: `Some(None)` clears it. |
| 105 | fn due(given: Option<&str>) -> Result<Option<Option<String>>, &'static str> { |
| 106 | match given.map(str::trim) { |
| 107 | None => Ok(None), |
| 108 | Some("") => Ok(Some(None)), |
| 109 | Some(value) => match tidy_due_on(value) { |
| 110 | Some(day) => Ok(Some(Some(day))), |
| 111 | None => Err("A due date is a day, written YYYY-MM-DD."), |
| 112 | }, |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// The sentence for the conversation when an item moves between milestones. |
| 117 | pub(crate) fn milestone_note(before: Option<&MilestoneRef>, after: Option<&MilestoneRef>) -> Option<String> { |
| 118 | match (before, after) { |
| 119 | (Some(old), Some(new)) if old.number == new.number => None, |
| 120 | (Some(old), Some(new)) => Some(format!("moved this from the {} milestone to {}", old.title, new.title)), |
| 121 | (None, Some(new)) => Some(format!("added this to the {} milestone", new.title)), |
| 122 | (Some(old), None) => Some(format!("removed this from the {} milestone", old.title)), |
| 123 | (None, None) => None, |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | impl Work { |
| 128 | async fn milestones_in(&self, workspace: &str, number: Option<u32>, state: Option<State>) -> Result<Vec<Milestone>> { |
| 129 | let state = crate::state_name(state).map_or(JsValue::NULL, JsValue::from); |
| 130 | let number = crate::optional_number(number); |
| 131 | let rows = self |
| 132 | .db |
| 133 | .prepare(format!( |
| 134 | "{} AND (?2 IS NULL OR number = ?2) AND (?3 IS NULL OR state = ?3) {ORDER} LIMIT {MAX_LISTED}", |
| 135 | milestones_sql() |
| 136 | )) |
| 137 | .bind(&[workspace.into(), number, state])? |
| 138 | .all() |
| 139 | .await?; |
| 140 | Ok(rows.results::<MilestoneRow>()?.into_iter().map(Milestone::from).collect()) |
| 141 | } |
| 142 | |
| 143 | pub(crate) async fn milestone_by_number(&self, workspace: &str, number: u32) -> Result<Option<Milestone>> { |
| 144 | Ok(self.milestones_in(workspace, Some(number), None).await?.pop()) |
| 145 | } |
| 146 | |
| 147 | pub(crate) async fn list_milestones(&self, a: ListMilestonesArgs) -> Result<Outcome<Vec<Milestone>>> { |
| 148 | let workspace = workspace_of(&a.workspace, None); |
| 149 | if !self.workspace_visible(&a.viewer, &workspace).await? { |
| 150 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 151 | } |
| 152 | Ok(Outcome::Ok(self.milestones_in(&workspace, None, a.state).await?)) |
| 153 | } |
| 154 | |
| 155 | /// One milestone with the work items and pull requests in it the viewer |
| 156 | /// may see, newest first. |
| 157 | pub(crate) async fn get_milestone(&self, a: WorkItemViewArgs) -> Result<Outcome<MilestoneDetail>> { |
| 158 | let workspace = workspace_of(&a.workspace, None); |
| 159 | if !self.workspace_visible(&a.viewer, &workspace).await? { |
| 160 | return Ok(Outcome::fail(FailureCode::NotFound, "Milestone not found.")); |
| 161 | } |
| 162 | let Some(milestone) = self.milestone_by_number(&workspace, a.number).await? else { |
| 163 | return Ok(Outcome::fail(FailureCode::NotFound, "Milestone not found.")); |
| 164 | }; |
| 165 | let key = [JsValue::from(workspace.as_str()), JsValue::from(a.number)]; |
| 166 | let results = self |
| 167 | .db |
| 168 | .batch(vec![ |
| 169 | self.db |
| 170 | .prepare(format!( |
| 171 | "SELECT {ITEM_COLUMNS} FROM work_items WHERE workspace = ?1 AND milestone = ?2 |
| 172 | ORDER BY number DESC LIMIT 500" |
| 173 | )) |
| 174 | .bind(&key)?, |
| 175 | self.db |
| 176 | .prepare(format!( |
| 177 | "SELECT {PULL_COLUMNS} FROM pulls WHERE {WORKSPACE_PULLS} AND milestone = ?2 |
| 178 | ORDER BY created_at DESC LIMIT 500" |
| 179 | )) |
| 180 | .bind(&key)?, |
| 181 | ]) |
| 182 | .await?; |
| 183 | let items = match results.first() { |
| 184 | Some(rows) => rows |
| 185 | .results::<ItemRow>()? |
| 186 | .into_iter() |
| 187 | .filter_map(ItemRow::held) |
| 188 | .filter(|held| held.may(a.viewer.as_ref(), Capability::Read)) |
| 189 | .map(|held| held.item) |
| 190 | .collect(), |
| 191 | None => Vec::new(), |
| 192 | }; |
| 193 | let pulls = match results.get(1) { |
| 194 | Some(rows) => rows.results::<PullRow>()?.into_iter().map(Pull::from).collect(), |
| 195 | None => Vec::new(), |
| 196 | }; |
| 197 | let pulls = self.readable_pulls(&a.viewer, pulls).await?; |
| 198 | Ok(Outcome::Ok(MilestoneDetail { milestone, items, pulls })) |
| 199 | } |
| 200 | |
| 201 | /// Creates a milestone, or changes the fields given of one. |
| 202 | pub(crate) async fn save_milestone(&self, a: SaveMilestoneArgs) -> Result<Outcome<Milestone>> { |
| 203 | let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?); |
| 204 | let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message)); |
| 205 | let title = match a.title.as_deref().map(tidy_title) { |
| 206 | Some(Err(message)) => return invalid(message), |
| 207 | Some(Ok(title)) => Some(title), |
| 208 | None => None, |
| 209 | }; |
| 210 | let description = a.description.as_deref().map(str::trim).map(str::to_owned); |
| 211 | if description.as_ref().is_some_and(|text| text.chars().count() > MAX_MILESTONE_DESCRIPTION_CHARS) { |
| 212 | return invalid("A milestone's description can be at most 4,000 characters."); |
| 213 | } |
| 214 | let due_on = match due(a.due_on.as_deref()) { |
| 215 | Ok(due_on) => due_on, |
| 216 | Err(message) => return invalid(message), |
| 217 | }; |
| 218 | // Titles are unique in a workspace, whatever their case. |
| 219 | if let Some(title) = &title { |
| 220 | let clash = self |
| 221 | .db |
| 222 | .prepare( |
| 223 | "SELECT number AS n FROM milestones |
| 224 | WHERE workspace = ?1 AND lower(title) = lower(?2) AND (?3 IS NULL OR number != ?3)", |
| 225 | ) |
| 226 | .bind(&[workspace.as_str().into(), title.as_str().into(), crate::optional_number(a.number)])? |
| 227 | .first::<crate::rows::NumberRow>(None) |
| 228 | .await?; |
| 229 | if clash.is_some() { |
| 230 | return Ok(Outcome::fail( |
| 231 | FailureCode::Conflict, |
| 232 | format!("A milestone named {title} already exists."), |
| 233 | )); |
| 234 | } |
| 235 | } |
| 236 | let now = rfc3339(now_ms()); |
| 237 | let number = match a.number { |
| 238 | None => { |
| 239 | let Some(title) = title else { |
| 240 | return invalid("A milestone needs a title."); |
| 241 | }; |
| 242 | let state = crate::state_name(a.state).unwrap_or("open"); |
| 243 | let created = self |
| 244 | .db |
| 245 | .prepare( |
| 246 | "INSERT INTO milestones |
| 247 | (workspace, number, title, description, due_on, state, created_at, updated_at, closed_at) |
| 248 | SELECT ?1, COALESCE(MAX(number), 0) + 1, ?2, ?3, ?4, ?5, ?6, ?6, |
| 249 | CASE WHEN ?5 = 'closed' THEN ?6 END |
| 250 | FROM milestones WHERE workspace = ?1 |
| 251 | RETURNING number AS n", |
| 252 | ) |
| 253 | .bind(&[ |
| 254 | workspace.as_str().into(), |
| 255 | title.as_str().into(), |
| 256 | description.unwrap_or_default().into(), |
| 257 | due_on.flatten().map_or(JsValue::NULL, JsValue::from), |
| 258 | state.into(), |
| 259 | now.as_str().into(), |
| 260 | ])? |
| 261 | .first::<crate::rows::NumberRow>(None) |
| 262 | .await?; |
| 263 | match created { |
| 264 | Some(row) => row.n, |
| 265 | None => return Ok(Outcome::fail(FailureCode::Conflict, "The milestone could not be created.")), |
| 266 | } |
| 267 | } |
| 268 | Some(number) => { |
| 269 | if self.milestone_by_number(&workspace, number).await?.is_none() { |
| 270 | return Ok(Outcome::fail(FailureCode::NotFound, "Milestone not found.")); |
| 271 | } |
| 272 | let state = crate::state_name(a.state); |
| 273 | self.db |
| 274 | .prepare( |
| 275 | "UPDATE milestones |
| 276 | SET title = COALESCE(?3, title), description = COALESCE(?4, description), |
| 277 | due_on = CASE WHEN ?5 THEN ?6 ELSE due_on END, |
| 278 | closed_at = CASE WHEN ?7 IS NULL OR ?7 = state THEN closed_at |
| 279 | WHEN ?7 = 'closed' THEN ?8 ELSE NULL END, |
| 280 | state = COALESCE(?7, state), updated_at = ?8 |
| 281 | WHERE workspace = ?1 AND number = ?2", |
| 282 | ) |
| 283 | .bind(&[ |
| 284 | workspace.as_str().into(), |
| 285 | number.into(), |
| 286 | crate::optional(&title), |
| 287 | crate::optional(&description), |
| 288 | due_on.is_some().into(), |
| 289 | due_on.flatten().map_or(JsValue::NULL, JsValue::from), |
| 290 | state.map_or(JsValue::NULL, JsValue::from), |
| 291 | now.as_str().into(), |
| 292 | ])? |
| 293 | .run() |
| 294 | .await?; |
| 295 | number |
| 296 | } |
| 297 | }; |
| 298 | Ok(match self.milestone_by_number(&workspace, number).await? { |
| 299 | Some(milestone) => Outcome::Ok(milestone), |
| 300 | None => Outcome::fail(FailureCode::NotFound, "Milestone not found."), |
| 301 | }) |
| 302 | } |
| 303 | |
| 304 | /// Removes a milestone. What was in it is in none afterwards. |
| 305 | pub(crate) async fn delete_milestone(&self, a: DeleteMilestoneArgs) -> Result<Outcome<bool>> { |
| 306 | let workspace = check!(self.labelled_workspace(&a.actor, &a.workspace).await?); |
| 307 | if self.milestone_by_number(&workspace, a.number).await?.is_none() { |
| 308 | return Ok(Outcome::fail(FailureCode::NotFound, "Milestone not found.")); |
| 309 | } |
| 310 | let key = [JsValue::from(workspace.as_str()), JsValue::from(a.number)]; |
| 311 | self.db |
| 312 | .batch(vec![ |
| 313 | self.db.prepare("DELETE FROM milestones WHERE workspace = ?1 AND number = ?2").bind(&key)?, |
| 314 | self.db.prepare("UPDATE work_items SET milestone = NULL WHERE workspace = ?1 AND milestone = ?2").bind(&key)?, |
| 315 | self.db |
| 316 | .prepare(format!("UPDATE pulls SET milestone = NULL WHERE {WORKSPACE_PULLS} AND milestone = ?2")) |
| 317 | .bind(&key)?, |
| 318 | ]) |
| 319 | .await?; |
| 320 | Ok(Outcome::Ok(true)) |
| 321 | } |
| 322 | |
| 323 | /// The milestone of this number, as an item would name it, if the |
| 324 | /// workspace has it. `Some(None)` for 0, which names none. |
| 325 | pub(crate) async fn milestone_ref(&self, workspace: &str, number: u32) -> Result<Outcome<Option<MilestoneRef>>> { |
| 326 | if number == 0 { |
| 327 | return Ok(Outcome::Ok(None)); |
| 328 | } |
| 329 | Ok(match self.milestone_by_number(workspace, number).await? { |
| 330 | Some(milestone) => Outcome::Ok(Some(MilestoneRef { number, title: milestone.title })), |
| 331 | None => Outcome::fail(FailureCode::Invalid, format!("There is no milestone #{number}.")), |
| 332 | }) |
| 333 | } |
| 334 | |
| 335 | /// Puts a work item or a pull request in its workspace's milestone |
| 336 | /// numbered `number`, or with 0 in none, and says so: in its |
| 337 | /// conversation, and as `demilestoned` from the old one and |
| 338 | /// `milestoned` into the new. Needs the Triage role. |
| 339 | pub(crate) async fn set_milestone(&self, actor: &User, labeled: &Labeled, number: u32) -> Result<Outcome<()>> { |
| 340 | let workspace = labeled.workspace(); |
| 341 | if !labeled.may(actor, Capability::Triage) { |
| 342 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::Triage, &workspace))); |
| 343 | } |
| 344 | let after = check!(self.milestone_ref(&workspace, number).await?); |
| 345 | let before = labeled.milestone().cloned(); |
| 346 | if before.as_ref().map(|m| m.number) == after.as_ref().map(|m| m.number) { |
| 347 | return Ok(Outcome::Ok(())); |
| 348 | } |
| 349 | let (table, id) = labeled.row(); |
| 350 | self.db |
| 351 | .prepare(format!("UPDATE {table} SET milestone = ?, updated_at = ? WHERE id = ?")) |
| 352 | .bind(&[crate::optional_number(after.as_ref().map(|m| m.number)), rfc3339(now_ms()).into(), id.into()])? |
| 353 | .run() |
| 354 | .await?; |
| 355 | if let Some(text) = milestone_note(before.as_ref(), after.as_ref()) { |
| 356 | self.note_on(labeled.on(), actor.into(), &text).await?; |
| 357 | } |
| 358 | for (what, milestone) in [("demilestoned", before), ("milestoned", after)] { |
| 359 | let Some(milestone) = milestone else { continue }; |
| 360 | let milestone = Some(milestone); |
| 361 | match labeled { |
| 362 | Labeled::Item(held) => { |
| 363 | self.publish_item(labeled.kind(what), held, Some(actor), WorkItemEvent { milestone, ..Self::item_event(held) }) |
| 364 | .await?; |
| 365 | } |
| 366 | Labeled::Pull(repo, pull) => { |
| 367 | self.publish(labeled.kind(what), &repo.id, actor, PullEvent { milestone, ..Self::pull_event(pull) }) |
| 368 | .await?; |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | Ok(Outcome::Ok(())) |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | #[cfg(test)] |
| 377 | mod tests { |
| 378 | use super::*; |
| 379 | |
| 380 | fn named(number: u32, title: &str) -> MilestoneRef { |
| 381 | MilestoneRef { number, title: title.into() } |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn moving_between_milestones_is_said_once() { |
| 386 | assert_eq!(milestone_note(None, Some(&named(1, "Launch"))).as_deref(), Some("added this to the Launch milestone")); |
| 387 | assert_eq!( |
| 388 | milestone_note(Some(&named(1, "Launch")), None).as_deref(), |
| 389 | Some("removed this from the Launch milestone") |
| 390 | ); |
| 391 | assert_eq!( |
| 392 | milestone_note(Some(&named(1, "Launch")), Some(&named(2, "1.1"))).as_deref(), |
| 393 | Some("moved this from the Launch milestone to 1.1") |
| 394 | ); |
| 395 | assert_eq!(milestone_note(Some(&named(1, "Launch")), Some(&named(1, "Launch"))), None); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn titles_and_due_dates_are_checked() { |
| 400 | assert_eq!(tidy_title(" Launch ").unwrap(), "Launch"); |
| 401 | assert!(tidy_title("").is_err()); |
| 402 | assert!(tidy_title(&"x".repeat(101)).is_err()); |
| 403 | assert_eq!(due(None), Ok(None)); |
| 404 | assert_eq!(due(Some("")), Ok(Some(None))); |
| 405 | assert_eq!(due(Some("2026-10-14")), Ok(Some(Some("2026-10-14".into())))); |
| 406 | assert!(due(Some("next week")).is_err()); |
| 407 | } |
| 408 | } |