| 114 | 114 | | |
| 115 | 115 | | /// Secrets with how many places each was found. |
| 116 | 116 | | /// Rewrites the work items kept by the old issue numbers of their |
| 117 | | − | /// repositories to their numbers in the workspace, `?1` a page of |
| 118 | | − | /// `work.migrated`'s items as JSON. Each new number is above every old one |
| 119 | | − | /// of its workspace, so a page heard twice changes nothing more. |
| 117 | + | /// repositories to their numbers in the workspace, keeping each item's id |
| 118 | + | /// and workspace with it, `?1` a page of `work.migrated`'s items as JSON. |
| 119 | + | /// Each new number is above every old one of its workspace, so a page heard |
| 120 | + | /// twice changes nothing more; a row already on its new number from before |
| 121 | + | /// the ids were kept (migration 0009) gets them. |
| 120 | 122 | | pub const WORK_MIGRATED: [&str; 6] = [ |
| 121 | | − | "UPDATE updates SET item = m.number FROM moved_items m WHERE updates.repo_id = m.repo_id AND updates.item = m.legacy", |
| 122 | | − | "UPDATE update_pulls SET item = m.number FROM moved_items m WHERE update_pulls.repo_id = m.repo_id AND update_pulls.item = m.legacy", |
| 123 | | − | "UPDATE upgrades SET item = m.number FROM moved_items m WHERE upgrades.repo_id = m.repo_id AND upgrades.item = m.legacy", |
| 124 | | − | "UPDATE code_alerts SET item = m.number FROM moved_items m WHERE code_alerts.repo_id = m.repo_id AND code_alerts.item = m.legacy", |
| 125 | | − | "UPDATE alert_fixes SET item = m.number FROM moved_items m WHERE alert_fixes.repo_id = m.repo_id AND alert_fixes.item = m.legacy", |
| 123 | + | "UPDATE updates SET item = m.number, item_id = m.item_id, item_workspace = m.workspace FROM moved_items m |
| 124 | + | WHERE updates.repo_id = m.repo_id AND (updates.item = m.legacy OR (updates.item = m.number AND updates.item_id IS NULL))", |
| 125 | + | "UPDATE update_pulls SET item = m.number, item_id = m.item_id, item_workspace = m.workspace FROM moved_items m |
| 126 | + | WHERE update_pulls.repo_id = m.repo_id AND (update_pulls.item = m.legacy OR (update_pulls.item = m.number AND update_pulls.item_id IS NULL))", |
| 127 | + | "UPDATE upgrades SET item = m.number, item_id = m.item_id, item_workspace = m.workspace FROM moved_items m |
| 128 | + | WHERE upgrades.repo_id = m.repo_id AND (upgrades.item = m.legacy OR (upgrades.item = m.number AND upgrades.item_id IS NULL))", |
| 129 | + | "UPDATE code_alerts SET item = m.number, item_id = m.item_id, item_workspace = m.workspace FROM moved_items m |
| 130 | + | WHERE code_alerts.repo_id = m.repo_id AND (code_alerts.item = m.legacy OR (code_alerts.item = m.number AND code_alerts.item_id IS NULL))", |
| 131 | + | "UPDATE alert_fixes SET item = m.number, item_id = m.item_id, item_workspace = m.workspace FROM moved_items m |
| 132 | + | WHERE alert_fixes.repo_id = m.repo_id AND (alert_fixes.item = m.legacy OR (alert_fixes.item = m.number AND alert_fixes.item_id IS NULL))", |
| 126 | 133 | | // Only an update that needed code changes named a work item; the |
| 127 | 134 | | // other steps name pull requests, which kept their numbers. |
| 128 | 135 | | "UPDATE alert_activity SET number = m.number FROM moved_items m |
| ⋯ |
| 132 | 139 | | /// `work.migrated`'s items (`?1`), as the rows [`WORK_MIGRATED`] reads. |
| 133 | 140 | | const MOVED_ITEMS: &str = "WITH moved_items AS ( |
| 134 | 141 | | SELECT json_extract(value, '$.repoId') AS repo_id, json_extract(value, '$.legacyNumber') AS legacy, |
| 135 | | − | json_extract(value, '$.number') AS number |
| 142 | + | json_extract(value, '$.number') AS number, json_extract(value, '$.itemId') AS item_id, |
| 143 | + | json_extract(value, '$.workspace') AS workspace |
| 136 | 144 | | FROM json_each(?1))"; |
| 137 | 145 | | |
| 146 | + | /// The work item a row names: its number, and, from `work.migrated`, its |
| 147 | + | /// id and the workspace it is in. |
| 148 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
| 149 | + | pub struct LinkedItem { |
| 150 | + | pub number: u32, |
| 151 | + | pub item_id: Option<String>, |
| 152 | + | pub workspace: Option<String>, |
| 153 | + | } |
| 154 | + | |
| 155 | + | impl LinkedItem { |
| 156 | + | pub fn new(number: Option<i64>, item_id: Option<&str>, workspace: Option<&str>) -> Option<Self> { |
| 157 | + | number.map(|n| LinkedItem { number: n.max(0) as u32, item_id: item_id.map(str::to_owned), workspace: workspace.map(str::to_owned) }) |
| 158 | + | } |
| 159 | + | |
| 160 | + | /// The workspace to find it in: the one it was kept with, else the |
| 161 | + | /// repository's now (`repo_workspace`), for a row from before. |
| 162 | + | pub fn workspace_or<'a>(&'a self, repo_workspace: &'a str) -> &'a str { |
| 163 | + | self.workspace.as_deref().unwrap_or(repo_workspace) |
| 164 | + | } |
| 165 | + | |
| 166 | + | /// Whether the item found by its number is this one: with its id kept, |
| 167 | + | /// only the item of that id is. |
| 168 | + | pub fn is(&self, found_id: &str) -> bool { |
| 169 | + | self.item_id.as_deref().is_none_or(|id| id == found_id) |
| 170 | + | } |
| 171 | + | } |
| 172 | + | |
| 138 | 173 | | /// One of [`WORK_MIGRATED`], ready to run. |
| 139 | 174 | | pub fn work_migrated_sql(statement: &str) -> String { |
| 140 | 175 | | format!("{MOVED_ITEMS} |
| ⋯ |
| 254 | 289 | | pub branch: Option<String>, |
| 255 | 290 | | pub pull: Option<i64>, |
| 256 | 291 | | pub item: Option<i64>, |
| 292 | + | #[serde(default)] |
| 293 | + | pub item_id: Option<String>, |
| 294 | + | #[serde(default)] |
| 295 | + | pub item_workspace: Option<String>, |
| 257 | 296 | | pub error: Option<String>, |
| 258 | 297 | | pub updated_at: String, |
| 259 | 298 | | } |
| 260 | 299 | | |
| 261 | 300 | | impl UpdateRow { |
| 301 | + | pub fn linked_item(&self) -> Option<LinkedItem> { |
| 302 | + | LinkedItem::new(self.item, self.item_id.as_deref(), self.item_workspace.as_deref()) |
| 303 | + | } |
| 304 | + | |
| 262 | 305 | | pub fn state(&self) -> UpdateState { |
| 263 | 306 | | UpdateState::parse(&self.state).unwrap_or(UpdateState::Failed) |
| 264 | 307 | | } |
| ⋯ |
| 337 | 380 | | #[derive(Deserialize)] |
| 338 | 381 | | pub struct UpgradeRow { |
| 339 | 382 | | pub item: i64, |
| 383 | + | #[serde(default)] |
| 384 | + | pub item_id: Option<String>, |
| 385 | + | #[serde(default)] |
| 386 | + | pub item_workspace: Option<String>, |
| 387 | + | } |
| 388 | + | |
| 389 | + | impl UpgradeRow { |
| 390 | + | pub fn linked_item(&self) -> Option<LinkedItem> { |
| 391 | + | LinkedItem::new(Some(self.item), self.item_id.as_deref(), self.item_workspace.as_deref()) |
| 392 | + | } |
| 340 | 393 | | } |
| 341 | 394 | | |
| 342 | 395 | | #[derive(Deserialize)] |
| ⋯ |
| 1154 | 1207 | | /// The work item opened for a package before security updates, if any. |
| 1155 | 1208 | | pub async fn upgrade(&self, repo_id: &str, ecosystem: &str, package: &str) -> Result<Option<UpgradeRow>> { |
| 1156 | 1209 | | self.db |
| 1157 | | − | .prepare("SELECT item FROM upgrades WHERE repo_id = ? AND ecosystem = ? AND package = ?") |
| 1210 | + | .prepare("SELECT item, item_id, item_workspace FROM upgrades WHERE repo_id = ? AND ecosystem = ? AND package = ?") |
| 1158 | 1211 | | .bind(&[repo_id.into(), ecosystem.into(), package.into()])? |
| 1159 | 1212 | | .first::<UpgradeRow>(None) |
| 1160 | 1213 | | .await |
| ⋯ |
| 1290 | 1343 | | } |
| 1291 | 1344 | | |
| 1292 | 1345 | | #[test] |
| 1346 | + | fn work_migrated_keeps_each_items_id_and_workspace() { |
| 1347 | + | let db = rusqlite::Connection::open_in_memory().unwrap(); |
| 1348 | + | let (before, after): (Vec<_>, Vec<_>) = migrations().into_iter().partition(|(name, _)| name.as_str() < "0009"); |
| 1349 | + | for (name, sql) in &before { |
| 1350 | + | db.execute_batch(sql).unwrap_or_else(|error| panic!("{name}: {error}")); |
| 1351 | + | } |
| 1352 | + | // rep_1's update is still on its old number; rep_2's upgrade was |
| 1353 | + | // rewritten by a page heard before ids were kept, and the catch-up |
| 1354 | + | // was done. |
| 1355 | + | db.execute_batch( |
| 1356 | + | "INSERT INTO updates (repo_id, ecosystem, package, target, state, pull, item, requested_at, updated_at) |
| 1357 | + | VALUES ('rep_1', 'npm', 'a', '2', 'needs_code', NULL, 5, '', ''); |
| 1358 | + | INSERT INTO upgrades (repo_id, ecosystem, package, item, target, opened_at) VALUES ('rep_2', 'npm', 'b', 40, '2', ''); |
| 1359 | + | UPDATE work_catch_up SET page = 3, done_at = 'x';", |
| 1360 | + | ) |
| 1361 | + | .unwrap(); |
| 1362 | + | for (name, sql) in &after { |
| 1363 | + | db.execute_batch(sql).unwrap_or_else(|error| panic!("{name}: {error}")); |
| 1364 | + | } |
| 1365 | + | // The catch-up reads the map again, for the ids of rows already moved. |
| 1366 | + | let done: Option<String> = db.query_row("SELECT done_at FROM work_catch_up", [], |row| row.get(0)).unwrap(); |
| 1367 | + | assert_eq!(done, None); |
| 1368 | + | let page = serde_json::json!([ |
| 1369 | + | { "repoId": "rep_1", "legacyNumber": 5, "workspace": "acme", "number": 41, "itemId": "iss_5" }, |
| 1370 | + | { "repoId": "rep_2", "legacyNumber": 3, "workspace": "acme", "number": 40, "itemId": "iss_3" }, |
| 1371 | + | ]) |
| 1372 | + | .to_string(); |
| 1373 | + | for _ in 0..2 { |
| 1374 | + | for statement in WORK_MIGRATED { |
| 1375 | + | db.execute(&work_migrated_sql(statement), [&page]).unwrap(); |
| 1376 | + | } |
| 1377 | + | } |
| 1378 | + | let linked = |sql: &str| { |
| 1379 | + | db.query_row(sql, [], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<String>>(2)?))) |
| 1380 | + | .unwrap() |
| 1381 | + | }; |
| 1382 | + | assert_eq!(linked("SELECT item, item_id, item_workspace FROM updates"), (41, Some("iss_5".into()), Some("acme".into()))); |
| 1383 | + | assert_eq!(linked("SELECT item, item_id, item_workspace FROM upgrades"), (40, Some("iss_3".into()), Some("acme".into()))); |
| 1384 | + | } |
| 1385 | + | |
| 1386 | + | #[test] |
| 1387 | + | fn a_linked_item_is_found_where_it_was_kept() { |
| 1388 | + | let kept = LinkedItem::new(Some(41), Some("iss_5"), Some("acme")).unwrap(); |
| 1389 | + | // The repository moved to another workspace: its item did not. |
| 1390 | + | assert_eq!(kept.workspace_or("globex"), "acme"); |
| 1391 | + | assert!(kept.is("iss_5")); |
| 1392 | + | // Another item that has the number now is not it. |
| 1393 | + | assert!(!kept.is("wki_9")); |
| 1394 | + | let before = LinkedItem::new(Some(41), None, None).unwrap(); |
| 1395 | + | assert_eq!(before.workspace_or("globex"), "globex"); |
| 1396 | + | assert!(before.is("wki_9")); |
| 1397 | + | } |
| 1398 | + | |
| 1399 | + | #[test] |
| 1293 | 1400 | | fn only_real_secrets_in_history_are_open() { |
| 1294 | 1401 | | let counts = secret_counts( |
| 1295 | 1402 | | [("open", false, 2), ("open", true, 3), ("blocked", false, 1), ("blocked", true, 4), ("allowed", true, 5), ("resolved", false, 6)] |