| 1 | //! Issues became work items (migrations/0013_work_items.sql): what this |
| 2 | //! service kept by a repository's issue number is rewritten to the work |
| 3 | //! item it became when the work service announces where each went |
| 4 | //! (`work.migrated`, a page of `MigratedItem`s at a time). |
| 5 | //! |
| 6 | //! Each page is recorded in `work_migrated`, then: |
| 7 | //! |
| 8 | //! - inbox threads `<repo_id>#<number>` become `item:<item_id>`, with the |
| 9 | //! work item's workspace and number; a person who already has the work |
| 10 | //! item's thread keeps that one, with the old one's history; |
| 11 | //! - subscriptions follow their threads the same way; |
| 12 | //! - the log's `issue.*` events become `work_item.*`, and its events about |
| 13 | //! an issue (comments on it, and `issue` / `otherIssue` in coordination |
| 14 | //! and pull request events) name the work item and its number. |
| 15 | //! |
| 16 | //! Every statement leaves what it already rewrote alone, so a page |
| 17 | //! delivered again, or out of order, changes nothing more. |
| 18 | |
| 19 | use g1t_contracts::events::{Event, WorkMigrated}; |
| 20 | use worker::{D1Database, D1PreparedStatement, Result}; |
| 21 | |
| 22 | /// The issue events of the log, by their old names. |
| 23 | pub const ISSUE_EVENTS: [&str; 9] = [ |
| 24 | "issue.opened", |
| 25 | "issue.updated", |
| 26 | "issue.assigned", |
| 27 | "issue.labeled", |
| 28 | "issue.unlabeled", |
| 29 | "issue.milestoned", |
| 30 | "issue.demilestoned", |
| 31 | "issue.closed", |
| 32 | "issue.reopened", |
| 33 | ]; |
| 34 | |
| 35 | /// What one page does, in order: `?1` the event's id, `?2` its items as |
| 36 | /// JSON, `?3` [`ISSUE_EVENTS`] as JSON. |
| 37 | pub const MOVED: [&str; 12] = [ |
| 38 | // The page, kept. |
| 39 | "INSERT OR REPLACE INTO work_migrated (repo_id, legacy_number, workspace, number, item_id, event_id) |
| 40 | SELECT json_extract(value, '$.repoId'), json_extract(value, '$.legacyNumber'), lower(json_extract(value, '$.workspace')), |
| 41 | json_extract(value, '$.number'), json_extract(value, '$.itemId'), ?1 |
| 42 | FROM json_each(?2)", |
| 43 | // Threads the work item's own events started before it had its number. |
| 44 | "UPDATE inbox_items SET number = m.number, workspace = m.workspace |
| 45 | FROM work_migrated m |
| 46 | WHERE m.event_id = ?1 AND inbox_items.thread = 'item:' || m.item_id AND inbox_items.number IS NOT m.number", |
| 47 | // Someone with both threads: the old one's history joins the new one. |
| 48 | "UPDATE inbox_activity SET item_id = n.id |
| 49 | FROM work_migrated m, inbox_items o, inbox_items n |
| 50 | WHERE m.event_id = ?1 AND o.thread = m.repo_id || '#' || m.legacy_number |
| 51 | AND n.username = o.username AND n.thread = 'item:' || m.item_id AND inbox_activity.item_id = o.id", |
| 52 | "UPDATE inbox_items AS n SET activity = n.activity + o.activity, created_at = min(n.created_at, o.created_at), |
| 53 | saved = max(n.saved, o.saved) |
| 54 | FROM work_migrated m, inbox_items o |
| 55 | WHERE m.event_id = ?1 AND o.thread = m.repo_id || '#' || m.legacy_number |
| 56 | AND n.username = o.username AND n.thread = 'item:' || m.item_id", |
| 57 | "DELETE FROM inbox_items WHERE id IN ( |
| 58 | SELECT o.id FROM work_migrated m |
| 59 | JOIN inbox_items o ON o.thread = m.repo_id || '#' || m.legacy_number |
| 60 | JOIN inbox_items n ON n.username = o.username AND n.thread = 'item:' || m.item_id |
| 61 | WHERE m.event_id = ?1)", |
| 62 | // The rest of the old threads become the work item's. |
| 63 | "UPDATE inbox_items SET thread = 'item:' || m.item_id, subject = 'item', number = m.number, workspace = m.workspace |
| 64 | FROM work_migrated m |
| 65 | WHERE m.event_id = ?1 AND inbox_items.thread = m.repo_id || '#' || m.legacy_number", |
| 66 | // Subscriptions: one chosen on the work item already stands. |
| 67 | "DELETE FROM inbox_subscriptions WHERE (thread, username) IN ( |
| 68 | SELECT o.thread, o.username FROM work_migrated m |
| 69 | JOIN inbox_subscriptions o ON o.thread = m.repo_id || '#' || m.legacy_number |
| 70 | JOIN inbox_subscriptions n ON n.username = o.username AND n.thread = 'item:' || m.item_id |
| 71 | WHERE m.event_id = ?1)", |
| 72 | "UPDATE inbox_subscriptions SET thread = 'item:' || m.item_id |
| 73 | FROM work_migrated m |
| 74 | WHERE m.event_id = ?1 AND inbox_subscriptions.thread = m.repo_id || '#' || m.legacy_number", |
| 75 | // The log. Each statement reads the page's repositories' events once, |
| 76 | // and finds each one's work item by its repository and old number. |
| 77 | "UPDATE events SET type = 'work_item.' || substr(type, 7), |
| 78 | data = json_set(json_remove(data, '$.issueId', '$.repoId'), |
| 79 | '$.itemId', (SELECT m.item_id FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), |
| 80 | '$.workspace', (SELECT m.workspace FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), |
| 81 | '$.repoIds', json_array(events.repo_id), |
| 82 | '$.number', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))) |
| 83 | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) |
| 84 | AND type IN (SELECT value FROM json_each(?3)) |
| 85 | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))", |
| 86 | // Comments on an issue: on no pull request, and naming no work item yet. |
| 87 | "UPDATE events SET |
| 88 | data = json_set(data, |
| 89 | '$.itemId', (SELECT m.item_id FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), |
| 90 | '$.workspace', (SELECT m.workspace FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), |
| 91 | '$.repoIds', json_array(events.repo_id), |
| 92 | '$.number', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))) |
| 93 | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) |
| 94 | AND type IN ('comment.created', 'comment.edited', 'comment.deleted') |
| 95 | AND json_extract(data, '$.pullId') IS NULL AND json_extract(data, '$.itemId') IS NULL |
| 96 | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))", |
| 97 | // What names an issue it is about, or overlaps with: `issue`, `otherIssue`. |
| 98 | "UPDATE events SET |
| 99 | data = json_set(json_remove(data, '$.issue'), |
| 100 | '$.item', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.issue'))) |
| 101 | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) |
| 102 | AND json_extract(data, '$.issue') IS NOT NULL |
| 103 | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.issue'))", |
| 104 | "UPDATE events SET |
| 105 | data = json_set(json_remove(data, '$.otherIssue'), |
| 106 | '$.otherItem', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.otherIssue'))) |
| 107 | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) |
| 108 | AND json_extract(data, '$.otherIssue') IS NOT NULL |
| 109 | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.otherIssue'))", |
| 110 | ]; |
| 111 | |
| 112 | /// How many of `?1`, `?2`, `?3` a statement reads: D1 takes no more values |
| 113 | /// than it has. |
| 114 | fn bound(sql: &str) -> usize { |
| 115 | (1..=3).rev().find(|n| sql.contains(&format!("?{n}"))).unwrap_or(0) |
| 116 | } |
| 117 | |
| 118 | /// The statements a `work.migrated` event from the work service calls for; |
| 119 | /// none for any other event. |
| 120 | pub fn moved(db: &D1Database, event: &Event) -> Result<Vec<D1PreparedStatement>> { |
| 121 | if event.kind != "work.migrated" || event.source != "work" { |
| 122 | return Ok(Vec::new()); |
| 123 | } |
| 124 | let Ok(page) = serde_json::from_value::<WorkMigrated>(event.data.clone()) else { |
| 125 | worker::console_error!("work.migrated {} could not be read", event.id); |
| 126 | return Ok(Vec::new()); |
| 127 | }; |
| 128 | if event.data["truncated"].as_bool() == Some(true) { |
| 129 | worker::console_error!("work.migrated {} (page {}) was cut short: some issue numbers are left as they were", event.id, page.page); |
| 130 | } |
| 131 | if page.items.is_empty() { |
| 132 | return Ok(Vec::new()); |
| 133 | } |
| 134 | let items = serde_json::to_string(&page.items)?; |
| 135 | let issue_events = serde_json::to_string(&ISSUE_EVENTS)?; |
| 136 | let values: [worker::wasm_bindgen::JsValue; 3] = [event.id.as_str().into(), items.as_str().into(), issue_events.as_str().into()]; |
| 137 | MOVED.iter().map(|sql| db.prepare(*sql).bind(&values[..bound(sql)])).collect() |
| 138 | } |
| 139 | |
| 140 | #[cfg(test)] |
| 141 | mod tests { |
| 142 | use super::*; |
| 143 | use crate::schema::{database_before, migrate_from}; |
| 144 | use rusqlite::{Connection, params}; |
| 145 | |
| 146 | const ITEM: &str = "INSERT INTO inbox_items (id, username, event_id, reason, severity, title, body, workspace, repo_id, repo, |
| 147 | subject, number, created_at, thread, updated_at, bumped, activity, read_at, saved) |
| 148 | VALUES (?1, ?2, ?3, 'author', 'info', 't', 'b', 'acme', ?4, 'acme/rocket', ?5, ?6, ?7, ?8, ?7, ?1, ?9, ?10, ?11)"; |
| 149 | const EVENT: &str = "INSERT INTO events (id, type, source, time, repo_id, data, seq) VALUES (?1, ?2, 'work', 't', ?3, ?4, ?5)"; |
| 150 | |
| 151 | /// One page: rep_1's issue 3 is acme#40, its issue 5 acme#41. |
| 152 | fn page() -> String { |
| 153 | serde_json::json!([ |
| 154 | { "repoId": "rep_1", "legacyNumber": 3, "workspace": "Acme", "number": 40, "itemId": "iss_3" }, |
| 155 | { "repoId": "rep_1", "legacyNumber": 5, "workspace": "Acme", "number": 41, "itemId": "iss_5" }, |
| 156 | ]) |
| 157 | .to_string() |
| 158 | } |
| 159 | |
| 160 | fn run(db: &Connection, event_id: &str, items: &str) { |
| 161 | let issue_events = serde_json::to_string(&ISSUE_EVENTS).unwrap(); |
| 162 | let values = [event_id, items, issue_events.as_str()]; |
| 163 | for sql in MOVED { |
| 164 | db.execute(sql, rusqlite::params_from_iter(&values[..bound(sql)])).unwrap_or_else(|error| panic!("{sql}: {error}")); |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | fn logged(db: &Connection, id: &str) -> (String, serde_json::Value) { |
| 169 | let (kind, data): (String, String) = |
| 170 | db.query_row("SELECT type, data FROM events WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); |
| 171 | (kind, serde_json::from_str(&data).unwrap()) |
| 172 | } |
| 173 | |
| 174 | type ItemState = (String, String, String, Option<u32>, String, u32, Option<String>); |
| 175 | |
| 176 | fn items(db: &Connection) -> Vec<ItemState> { |
| 177 | let mut statement = db |
| 178 | .prepare("SELECT id, username, thread, number, workspace, activity, read_at FROM inbox_items ORDER BY id") |
| 179 | .unwrap(); |
| 180 | statement |
| 181 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?))) |
| 182 | .unwrap() |
| 183 | .map(Result::unwrap) |
| 184 | .collect() |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn what_was_kept_by_issue_number_follows_the_work_item() { |
| 189 | let db = database_before("0013_work_items.sql"); |
| 190 | // ana: a thread on issue 3, and one on pull request 4. |
| 191 | db.execute(ITEM, params!["ntf_1", "ana", "evt_1", "rep_1", "issue", 3, "2026-01-01", "rep_1#3", 2, None::<String>, 0]).unwrap(); |
| 192 | db.execute(ITEM, params!["ntf_2", "ana", "evt_2", "rep_1", "pull", 4, "2026-01-02", "rep_1#4", 1, None::<String>, 0]).unwrap(); |
| 193 | // bo: a thread on issue 5, and the work item's own since, read, before it had its number. |
| 194 | db.execute(ITEM, params!["ntf_3", "bo", "evt_3", "rep_1", "issue", 5, "2026-01-01", "rep_1#5", 3, None::<String>, 1]).unwrap(); |
| 195 | db.execute(ITEM, params!["ntf_4", "bo", "evt_4", "rep_1", "item", None::<u32>, "2026-02-01", "item:iss_5", 1, "2026-02-02", 0]) |
| 196 | .unwrap(); |
| 197 | for (id, item, event) in [("act_1", "ntf_3", "evt_3"), ("act_2", "ntf_4", "evt_4")] { |
| 198 | db.execute( |
| 199 | "INSERT INTO inbox_activity (id, item_id, username, event_id, reason, severity, title, body, created_at) |
| 200 | VALUES (?1, ?2, 'bo', ?3, 'author', 'info', 't', 'b', 't')", |
| 201 | params![id, item, event], |
| 202 | ) |
| 203 | .unwrap(); |
| 204 | } |
| 205 | for (user, thread, state) in |
| 206 | [("ana", "rep_1#3", "subscribed"), ("ana", "rep_1#4", "ignored"), ("bo", "rep_1#5", "ignored"), ("bo", "item:iss_5", "subscribed")] |
| 207 | { |
| 208 | db.execute( |
| 209 | "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, created_at) VALUES (?1, ?2, 'rep_1', ?3, 't')", |
| 210 | params![user, thread, state], |
| 211 | ) |
| 212 | .unwrap(); |
| 213 | } |
| 214 | db.execute( |
| 215 | "INSERT INTO inbox_watching (username, repo_id, level, events, updated_at) |
| 216 | VALUES ('cy', 'rep_1', 'custom', '[\"issues\",\"deployments\"]', 't')", |
| 217 | [], |
| 218 | ) |
| 219 | .unwrap(); |
| 220 | let events = [ |
| 221 | ("evt_1", "issue.opened", r#"{"issueId":"iss_3","repoId":"rep_1","number":3}"#), |
| 222 | ("evt_2", "pull.opened", r#"{"repoId":"rep_1","number":4,"issue":3}"#), |
| 223 | ("evt_3", "comment.created", r#"{"commentId":"cmt_1","repoId":"rep_1","number":5}"#), |
| 224 | ("evt_5", "comment.created", r#"{"commentId":"cmt_2","repoId":"rep_1","number":4,"pullId":"pr_4"}"#), |
| 225 | ("evt_6", "work.overlap", r#"{"issue":5,"otherIssue":3}"#), |
| 226 | ("evt_7", "issue.closed", r#"{"issueId":"iss_9","repoId":"rep_1","number":9}"#), |
| 227 | ]; |
| 228 | for (seq, (id, kind, json)) in events.iter().enumerate() { |
| 229 | db.execute(EVENT, params![id, kind, "rep_1", json, seq as i64 + 1]).unwrap(); |
| 230 | } |
| 231 | |
| 232 | migrate_from(&db, "0013_work_items.sql"); |
| 233 | // What needs only names is done at once. |
| 234 | let subject: String = db.query_row("SELECT subject FROM inbox_items WHERE id = 'ntf_1'", [], |row| row.get(0)).unwrap(); |
| 235 | assert_eq!(subject, "item"); |
| 236 | let watched: String = db.query_row("SELECT events FROM inbox_watching WHERE username = 'cy'", [], |row| row.get(0)).unwrap(); |
| 237 | assert_eq!(watched, r#"["work_items","deployments"]"#); |
| 238 | // A subscription to a work item with no origin has no repository. |
| 239 | db.execute( |
| 240 | "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, created_at) VALUES ('dee', 'item:wki_9', NULL, 'subscribed', 't')", |
| 241 | [], |
| 242 | ) |
| 243 | .unwrap(); |
| 244 | |
| 245 | // The page, twice: the second time changes nothing. |
| 246 | run(&db, "evt_9", &page()); |
| 247 | let first = items(&db); |
| 248 | run(&db, "evt_9", &page()); |
| 249 | assert_eq!(items(&db), first); |
| 250 | assert_eq!( |
| 251 | first, |
| 252 | vec![ |
| 253 | ("ntf_1".into(), "ana".into(), "item:iss_3".into(), Some(40), "acme".into(), 2, None), |
| 254 | ("ntf_2".into(), "ana".into(), "rep_1#4".into(), Some(4), "acme".into(), 1, None), |
| 255 | // bo's old thread joined the work item's, with its history. |
| 256 | ("ntf_4".into(), "bo".into(), "item:iss_5".into(), Some(41), "acme".into(), 4, Some("2026-02-02".into())), |
| 257 | ] |
| 258 | ); |
| 259 | let moved: u32 = db.query_row("SELECT count(*) FROM inbox_activity WHERE item_id = 'ntf_4'", [], |row| row.get(0)).unwrap(); |
| 260 | assert_eq!(moved, 2); |
| 261 | let since: String = db.query_row("SELECT created_at FROM inbox_items WHERE id = 'ntf_4'", [], |row| row.get(0)).unwrap(); |
| 262 | assert_eq!(since, "2026-01-01"); |
| 263 | |
| 264 | let mut statement = db.prepare("SELECT username, thread, state FROM inbox_subscriptions ORDER BY username, thread").unwrap(); |
| 265 | let subscriptions: Vec<(String, String, String)> = |
| 266 | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect(); |
| 267 | assert_eq!( |
| 268 | subscriptions, |
| 269 | vec![ |
| 270 | ("ana".into(), "item:iss_3".into(), "subscribed".into()), |
| 271 | ("ana".into(), "rep_1#4".into(), "ignored".into()), |
| 272 | // What bo chose on the work item stands. |
| 273 | ("bo".into(), "item:iss_5".into(), "subscribed".into()), |
| 274 | ("dee".into(), "item:wki_9".into(), "subscribed".into()), |
| 275 | ] |
| 276 | ); |
| 277 | |
| 278 | // The log names the work items. |
| 279 | let (kind, opened) = logged(&db, "evt_1"); |
| 280 | assert_eq!(kind, "work_item.opened"); |
| 281 | assert_eq!(opened, serde_json::json!({ "itemId": "iss_3", "workspace": "acme", "number": 40, "repoIds": ["rep_1"] })); |
| 282 | assert_eq!(logged(&db, "evt_2").1, serde_json::json!({ "repoId": "rep_1", "number": 4, "item": 40 })); |
| 283 | let comment = logged(&db, "evt_3").1; |
| 284 | assert_eq!((comment["number"].as_u64(), comment["itemId"].as_str()), (Some(41), Some("iss_5"))); |
| 285 | assert_eq!(logged(&db, "evt_6").1, serde_json::json!({ "item": 41, "otherItem": 40 })); |
| 286 | // A pull request's comment, and an issue no page has named yet, stay. |
| 287 | assert_eq!(logged(&db, "evt_5").1["number"], 4); |
| 288 | assert_eq!(logged(&db, "evt_7").0, "issue.closed"); |
| 289 | } |
| 290 | } |