Skip to content

g1t/services/work/src/milestones.rs

391 lines17,151 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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