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.

408 linesCodeBlameRaw
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
6use g1t_contracts::access::{self, Capability};
7use g1t_contracts::events::{PullEvent, WorkItemEvent};
8use g1t_contracts::time::rfc3339;
9use g1t_contracts::work::*;
10use g1t_contracts::{FailureCode, Outcome, User};
11use g1t_kit::now_ms;
12use serde::Deserialize;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use super::labels::Labeled;
17use super::rows::{ITEM_COLUMNS, ItemRow, workspace_of};
18use crate::rows::{PULL_COLUMNS, PullRow};
19use crate::Work;
20
21macro_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)]
31struct 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
48impl 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.
67const 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.
71const 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
82fn 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.
88const 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
91const MAX_LISTED: u32 = 200;
92
93fn 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.
105fn 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.
117pub(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
127impl 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)]
377mod 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}