| 1 | //! Moving repositories' issues into their workspaces as work items: the |
| 2 | //! part of migrations/0049_work_items.sql that needs to know which |
| 3 | //! workspace each repository is in, which only repos does. |
| 4 | //! |
| 5 | //! The job pages through repos' repositories (`repo_creators`, then |
| 6 | //! `visibility`), recording each in `repo_workspaces`, a few pages a run, |
| 7 | //! resuming where it stopped (`work_jobs.cursor`). Once every page is in, |
| 8 | //! one batch ([`finish_sql`]) gives every moved issue its workspace and a |
| 9 | //! number above every number its workspace's repositories used, in the |
| 10 | //! order the issues were opened; starts each workspace's sequence after |
| 11 | //! them; merges the repositories' labels into the workspace's by name |
| 12 | //! (the first made keeps its color and description); renumbers their |
| 13 | //! milestones in the workspace and moves what was in each; and empties the |
| 14 | //! per-repository counters. Then it announces where each issue went as |
| 15 | //! `work.migrated`, a page at a time, for the services that kept issue |
| 16 | //! numbers. It runs from the cron trigger and from `migrate_work_items`, |
| 17 | //! and does nothing once done. |
| 18 | //! |
| 19 | //! After it, `repo_workspaces` is kept by events: repo.created, |
| 20 | //! repo.renamed and repo.transferred ([`TRANSFERRED`]), |
| 21 | //! repo.visibility_changed, and workspace.renamed and workspace.deleted |
| 22 | //! ([`RENAMED`], [`DELETED`]). |
| 23 | |
| 24 | use g1t_contracts::events::WorkMigrated; |
| 25 | use g1t_contracts::repos::{AllIdsArgs, CreatorPage, DeletedRepoPage, RepoVisibility, VisibilityArgs}; |
| 26 | use g1t_contracts::time::rfc3339; |
| 27 | use g1t_contracts::work::*; |
| 28 | use g1t_kit::now_ms; |
| 29 | use serde::Deserialize; |
| 30 | use worker::Result; |
| 31 | use worker::wasm_bindgen::JsValue; |
| 32 | |
| 33 | use crate::Work; |
| 34 | use crate::optional; |
| 35 | use crate::rows::NumberRow; |
| 36 | |
| 37 | /// Repositories asked for at a time, and pages read in one run. |
| 38 | const PAGE: u32 = 100; |
| 39 | const PAGES_PER_RUN: u32 = 10; |
| 40 | /// Moved issues announced in one `work.migrated` event: well under a |
| 41 | /// queue message's size limit. |
| 42 | const ANNOUNCED_PER_EVENT: u32 = 250; |
| 43 | |
| 44 | /// Records a repository (`?1`) as in workspace `?2` at path `?3`, |
| 45 | /// private (`?4`) or not, and names its links by that path. |
| 46 | pub(crate) const RECORD_REPO: [&str; 2] = [ |
| 47 | super::links::REMEMBER_REPO, |
| 48 | "UPDATE work_item_repos SET path = ?3 WHERE repo_id = ?1 AND path IS NOT ?3", |
| 49 | ]; |
| 50 | |
| 51 | /// The workspace an issue whose repository repos no longer knows was in, |
| 52 | /// from what else this database kept about that repository (`o.repo_id`). |
| 53 | const FALLBACK_WORKSPACE: &str = "COALESCE( |
| 54 | (SELECT a.workspace FROM agent_runs a WHERE a.repo_id = o.repo_id ORDER BY a.created_at DESC LIMIT 1), |
| 55 | (SELECT e.workspace FROM rule_evaluations e WHERE e.repo_id = o.repo_id ORDER BY e.id DESC LIMIT 1), |
| 56 | (SELECT s.workspace FROM rulesets s WHERE s.repo_id = o.repo_id LIMIT 1))"; |
| 57 | |
| 58 | /// What finishing does, in one batch, `?1` the time (see the module's |
| 59 | /// notes): [`place_sql`], each workspace's floor, and the job done. |
| 60 | pub(crate) fn finish_sql() -> Vec<String> { |
| 61 | let mut statements = place_sql(); |
| 62 | statements.push("UPDATE work_jobs SET done_at = ?1, cursor = NULL WHERE name = 'work_items' AND done_at IS NULL".to_owned()); |
| 63 | statements |
| 64 | } |
| 65 | |
| 66 | /// Placing what the repositories recorded in `repo_workspaces` kept for |
| 67 | /// themselves, in one batch, `?1` the time: their moved issues' workspaces |
| 68 | /// and numbers, labels, milestones and numbers become their workspaces'. |
| 69 | /// Only what is still theirs is moved, and then dropped, so run again it |
| 70 | /// changes nothing; a repository recorded later (one deleted while the job |
| 71 | /// ran, then restored, or the job for those, [`DELETED_REPOS_JOB`]) has |
| 72 | /// its own moved then, and nothing of anyone else's twice. |
| 73 | pub(crate) fn place_sql() -> Vec<String> { |
| 74 | let recorded = "SELECT repo_id FROM repo_workspaces"; |
| 75 | // A repository's milestones are still its own: not moved yet. |
| 76 | let unmoved = "SELECT repo_id FROM repo_milestones WHERE repo_id IN (SELECT repo_id FROM repo_workspaces)"; |
| 77 | vec![ |
| 78 | // Each moved issue's workspace: its repository's. |
| 79 | format!( |
| 80 | "UPDATE work_items SET workspace = ( |
| 81 | SELECT lower(COALESCE(rw.workspace, {FALLBACK_WORKSPACE})) |
| 82 | FROM work_item_repos o LEFT JOIN repo_workspaces rw ON rw.repo_id = o.repo_id |
| 83 | WHERE o.item_id = work_items.id AND o.role = 'origin') |
| 84 | WHERE workspace IS NULL AND number IS NULL" |
| 85 | ), |
| 86 | // Numbered above every number its workspace used, oldest first. |
| 87 | "UPDATE work_items SET number = n.number |
| 88 | FROM ( |
| 89 | SELECT i.id AS id, f.floor + ROW_NUMBER() OVER (PARTITION BY i.workspace ORDER BY i.created_at, i.id) AS number |
| 90 | FROM work_items i JOIN ( |
| 91 | SELECT w.workspace AS workspace, MAX( |
| 92 | COALESCE((SELECT MAX(c.last) FROM counters c JOIN repo_workspaces rw ON rw.repo_id = c.repo_id |
| 93 | WHERE rw.workspace = w.workspace), 0), |
| 94 | COALESCE((SELECT wc.last FROM work_counters wc WHERE wc.workspace = w.workspace), 0), |
| 95 | COALESCE((SELECT MAX(x.number) FROM work_items x WHERE x.workspace = w.workspace), 0), |
| 96 | COALESCE((SELECT MAX(p.number) FROM pulls p JOIN repo_workspaces rw ON rw.repo_id = p.repo_id |
| 97 | WHERE rw.workspace = w.workspace), 0)) AS floor |
| 98 | FROM (SELECT DISTINCT workspace FROM work_items WHERE number IS NULL AND workspace IS NOT NULL) w |
| 99 | ) f ON f.workspace = i.workspace |
| 100 | WHERE i.number IS NULL AND i.workspace IS NOT NULL |
| 101 | ) n |
| 102 | WHERE work_items.id = n.id" |
| 103 | .to_owned(), |
| 104 | // Each workspace's sequence goes on after everything it used. |
| 105 | "INSERT INTO work_counters (workspace, last) |
| 106 | SELECT rw.workspace, MAX(c.last) FROM counters c JOIN repo_workspaces rw ON rw.repo_id = c.repo_id |
| 107 | GROUP BY rw.workspace |
| 108 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)" |
| 109 | .to_owned(), |
| 110 | "INSERT INTO work_counters (workspace, last) |
| 111 | SELECT workspace, MAX(number) FROM work_items WHERE workspace IS NOT NULL AND number IS NOT NULL |
| 112 | GROUP BY workspace |
| 113 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)" |
| 114 | .to_owned(), |
| 115 | // Labels merge by name, whatever its case (names are lowercase as |
| 116 | // work items carry them); the first made keeps its color and words. |
| 117 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 118 | SELECT rw.workspace, lower(l.name), l.color, l.description, l.created_at |
| 119 | FROM repo_labels l JOIN repo_workspaces rw ON rw.repo_id = l.repo_id |
| 120 | WHERE true ORDER BY l.created_at, l.repo_id |
| 121 | ON CONFLICT (workspace, name) DO UPDATE |
| 122 | SET color = excluded.color, description = excluded.description, created_at = excluded.created_at |
| 123 | WHERE excluded.created_at < labels.created_at" |
| 124 | .to_owned(), |
| 125 | // Milestones are numbered on after the workspace's own, oldest |
| 126 | // first, and what was in each moves with it. |
| 127 | "INSERT INTO milestone_moves (repo_id, old_number, workspace, number) |
| 128 | SELECT m.repo_id, m.number, rw.workspace, |
| 129 | COALESCE((SELECT MAX(x.number) FROM milestones x WHERE x.workspace = rw.workspace), 0) |
| 130 | + ROW_NUMBER() OVER (PARTITION BY rw.workspace ORDER BY m.created_at, m.repo_id, m.number) |
| 131 | FROM repo_milestones m JOIN repo_workspaces rw ON rw.repo_id = m.repo_id |
| 132 | WHERE NOT EXISTS (SELECT 1 FROM milestone_moves mm WHERE mm.repo_id = m.repo_id AND mm.old_number = m.number)" |
| 133 | .to_owned(), |
| 134 | "INSERT INTO milestones (workspace, number, origin_repo_id, title, description, due_on, state, created_at, updated_at, closed_at) |
| 135 | SELECT mm.workspace, mm.number, m.repo_id, m.title, m.description, m.due_on, m.state, m.created_at, m.updated_at, m.closed_at |
| 136 | FROM milestone_moves mm JOIN repo_milestones m ON m.repo_id = mm.repo_id AND m.number = mm.old_number |
| 137 | WHERE true |
| 138 | ON CONFLICT DO NOTHING" |
| 139 | .to_owned(), |
| 140 | // Only what was in a milestone of a repository moving now: a |
| 141 | // repository with none keeps what its work items say. |
| 142 | format!( |
| 143 | "UPDATE work_items SET milestone = ( |
| 144 | SELECT mm.number FROM milestone_moves mm JOIN work_item_repos o ON o.repo_id = mm.repo_id |
| 145 | WHERE o.item_id = work_items.id AND o.role = 'origin' AND mm.old_number = work_items.milestone) |
| 146 | WHERE milestone IS NOT NULL |
| 147 | AND id IN (SELECT item_id FROM work_item_repos WHERE legacy_number IS NOT NULL AND role = 'origin' |
| 148 | AND repo_id IN ({unmoved}))" |
| 149 | ), |
| 150 | format!( |
| 151 | "UPDATE pulls SET milestone = ( |
| 152 | SELECT mm.number FROM milestone_moves mm WHERE mm.repo_id = pulls.repo_id AND mm.old_number = pulls.milestone) |
| 153 | WHERE milestone IS NOT NULL AND repo_id IN ({unmoved})" |
| 154 | ), |
| 155 | // Every number they used is below the workspace's floor, and its |
| 156 | // sequence goes on above it. |
| 157 | "UPDATE work_counters SET floor = MAX(floor, |
| 158 | COALESCE((SELECT MAX(c.last) FROM counters c JOIN repo_workspaces rw ON rw.repo_id = c.repo_id |
| 159 | WHERE rw.workspace = work_counters.workspace), 0)) |
| 160 | WHERE true" |
| 161 | .to_owned(), |
| 162 | RAISE_FLOORS.to_owned(), |
| 163 | "UPDATE work_counters SET last = floor WHERE last < floor".to_owned(), |
| 164 | // What the repositories kept for themselves is the workspace's now. |
| 165 | format!("DELETE FROM counters WHERE repo_id IN ({recorded})"), |
| 166 | format!("DELETE FROM repo_labels WHERE repo_id IN ({recorded})"), |
| 167 | format!("DELETE FROM repo_milestones WHERE repo_id IN ({recorded})"), |
| 168 | ] |
| 169 | } |
| 170 | |
| 171 | /// The job, after the first, that records repositories deleted but still |
| 172 | /// restorable ([`Work::migrate_deleted_repos`]): the first job's paging |
| 173 | /// left them out, and their work items with no path, private, and their |
| 174 | /// labels and old numbers unmerged. |
| 175 | pub(crate) const DELETED_REPOS_JOB: &str = "deleted_repos"; |
| 176 | |
| 177 | /// The job after that ([`Work::mark_deleted_repos`]): marks each |
| 178 | /// repository deleted but not purged as deleted in `repo_workspaces` |
| 179 | /// ([`super::links::MARK_DELETED`]), for those deleted before that was |
| 180 | /// kept. Run again, it changes nothing. |
| 181 | pub(crate) const DELETED_MARKS_JOB: &str = "deleted_marks"; |
| 182 | |
| 183 | /// Every label a workspace's work items or pull requests carry that the |
| 184 | /// workspace does not have, as a label (gray, no description) made at |
| 185 | /// `?1`: one a repository had that was never merged. Run again, it adds |
| 186 | /// nothing. |
| 187 | pub(crate) const CARRIED_LABELS: [&str; 2] = [ |
| 188 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 189 | SELECT DISTINCT i.workspace, j.value, 'ededed', '', ?1 FROM work_items i, json_each(i.labels) j |
| 190 | WHERE i.workspace IS NOT NULL AND j.value <> '' |
| 191 | ON CONFLICT DO NOTHING", |
| 192 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 193 | SELECT DISTINCT rw.workspace, j.value, 'ededed', '', ?1 FROM pulls p JOIN repo_workspaces rw ON rw.repo_id = p.repo_id, json_each(p.labels) j |
| 194 | WHERE j.value <> '' |
| 195 | ON CONFLICT DO NOTHING", |
| 196 | ]; |
| 197 | |
| 198 | /// A workspace renamed (`g1t_kit::rename`, `?1` its slug now, `?2` before): |
| 199 | /// its work items, labels, milestones, numbers and repositories follow. |
| 200 | pub(crate) const RENAMED: &[&str] = &[ |
| 201 | "UPDATE OR IGNORE work_items SET workspace = ?1 WHERE workspace = ?2", |
| 202 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 203 | SELECT ?1, name, color, description, created_at FROM labels WHERE workspace = ?2 |
| 204 | ON CONFLICT DO NOTHING", |
| 205 | "DELETE FROM labels WHERE workspace = ?2", |
| 206 | "UPDATE OR IGNORE milestones SET workspace = ?1 WHERE workspace = ?2", |
| 207 | "UPDATE milestone_moves SET workspace = ?1 WHERE workspace = ?2", |
| 208 | "INSERT INTO work_counters (workspace, last) SELECT ?1, last FROM work_counters WHERE workspace = ?2 |
| 209 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)", |
| 210 | "DELETE FROM work_counters WHERE workspace = ?2", |
| 211 | "UPDATE repo_workspaces SET workspace = ?1, path = ?1 || substr(path, length(?2) + 1) |
| 212 | WHERE workspace = ?2 AND substr(path, 1, length(?2) + 1) = ?2 || '/'", |
| 213 | "UPDATE work_item_repos SET path = ?1 || substr(path, length(?2) + 1) WHERE substr(path, 1, length(?2) + 1) = ?2 || '/'", |
| 214 | ]; |
| 215 | |
| 216 | /// A repository renamed or transferred (`g1t_kit::transfer`: `?1` its path |
| 217 | /// now, `?3` its workspace now, `?5` its id): where it is now, and in the |
| 218 | /// workspace it moved to, numbers go on after its pull requests' and its |
| 219 | /// old issues', so a new one never takes a number it has. Those numbers |
| 220 | /// were never that workspace's, so its floor rises above them too: `#N` |
| 221 | /// read in the workspace's other repositories names none of them |
| 222 | /// (refs.rs). A rename (`?4`, its workspace before, the same) changes no |
| 223 | /// numbers. Its work items stay in their own workspace, linked to it. |
| 224 | pub(crate) const TRANSFERRED: &[&str] = &[ |
| 225 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES (?5, lower(?3), ?1, 1) |
| 226 | ON CONFLICT (repo_id) DO UPDATE SET workspace = lower(?3), path = ?1", |
| 227 | "UPDATE work_item_repos SET path = ?1 WHERE repo_id = ?5", |
| 228 | "INSERT INTO work_counters (workspace, last, floor) |
| 229 | SELECT lower(?3), MAX(n), MAX(n) FROM ( |
| 230 | SELECT MAX(number) AS n FROM pulls WHERE repo_id = ?5 |
| 231 | UNION ALL SELECT MAX(legacy_number) FROM work_item_repos WHERE repo_id = ?5) |
| 232 | WHERE lower(?3) <> lower(?4) |
| 233 | HAVING MAX(n) IS NOT NULL |
| 234 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last), floor = MAX(work_counters.floor, excluded.floor)", |
| 235 | ]; |
| 236 | |
| 237 | /// Raises each workspace's floor (`work_counters.floor`) to the highest |
| 238 | /// number any of its repositories used on its own: a pull request opened |
| 239 | /// before work items moved (by when the job finished, or `?1`, now, while |
| 240 | /// it finishes) or an old issue's number. Run again, it changes nothing. |
| 241 | /// migrations/0052_work_floors.sql does the same. |
| 242 | pub(crate) const RAISE_FLOORS: &str = "UPDATE work_counters SET floor = MAX(floor, |
| 243 | COALESCE((SELECT MAX(p.number) FROM pulls p JOIN repo_workspaces rw ON rw.repo_id = p.repo_id |
| 244 | WHERE rw.workspace = work_counters.workspace |
| 245 | AND p.created_at <= COALESCE((SELECT done_at FROM work_jobs WHERE name = 'work_items'), ?1)), 0), |
| 246 | COALESCE((SELECT MAX(l.legacy_number) FROM work_item_repos l JOIN repo_workspaces rw ON rw.repo_id = l.repo_id |
| 247 | WHERE rw.workspace = work_counters.workspace), 0)) |
| 248 | WHERE true"; |
| 249 | |
| 250 | /// A workspace deleted (`g1t_kit::deleted`, `?1` its slug): its work |
| 251 | /// items go, with everything kept for them, and its labels, milestones |
| 252 | /// and numbers. |
| 253 | pub(crate) const DELETED: &[&str] = &[ |
| 254 | "UPDATE pulls SET item_id = NULL WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 255 | "DELETE FROM comments WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 256 | "DELETE FROM reactions WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 257 | "DELETE FROM claims WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 258 | "DELETE FROM decisions WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 259 | "UPDATE sessions SET item_id = NULL WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 260 | "DELETE FROM session_links WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 261 | "DELETE FROM work_item_repos WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 262 | "DELETE FROM work_items WHERE workspace = ?1", |
| 263 | "DELETE FROM labels WHERE workspace = ?1", |
| 264 | "DELETE FROM milestones WHERE workspace = ?1", |
| 265 | "DELETE FROM work_counters WHERE workspace = ?1", |
| 266 | "DELETE FROM repo_workspaces WHERE workspace = ?1", |
| 267 | ]; |
| 268 | |
| 269 | /// The default labels for workspace `?1` (name `?2`, color `?3`, |
| 270 | /// description `?4`, at `?5`), unless it has labels already. |
| 271 | const SEED_LABEL: &str = "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at) |
| 272 | SELECT ?1, ?2, ?3, ?4, ?5 WHERE NOT EXISTS (SELECT 1 FROM labels WHERE workspace = ?1 AND created_at < ?5)"; |
| 273 | |
| 274 | #[derive(Deserialize)] |
| 275 | #[serde(rename_all = "camelCase")] |
| 276 | struct Created { |
| 277 | repo_id: String, |
| 278 | namespace: String, |
| 279 | name: String, |
| 280 | #[serde(default = "private")] |
| 281 | is_private: bool, |
| 282 | } |
| 283 | |
| 284 | fn private() -> bool { |
| 285 | true |
| 286 | } |
| 287 | |
| 288 | #[derive(Deserialize)] |
| 289 | struct JobRow { |
| 290 | #[serde(default)] |
| 291 | cursor: Option<String>, |
| 292 | #[serde(default)] |
| 293 | done_at: Option<String>, |
| 294 | } |
| 295 | |
| 296 | impl Work { |
| 297 | /// `migrate_work_items`, and the cron trigger: see the module's notes. |
| 298 | pub(crate) async fn migrate_work_items(&self) -> Result<WorkMigration> { |
| 299 | let job = self |
| 300 | .db |
| 301 | .prepare("SELECT cursor, done_at FROM work_jobs WHERE name = 'work_items'") |
| 302 | .first::<JobRow>(None) |
| 303 | .await?; |
| 304 | let Some(job) = job else { |
| 305 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); |
| 306 | }; |
| 307 | if job.done_at.is_some() { |
| 308 | let deleted = self.migrate_deleted_repos().await?; |
| 309 | if !deleted.done { |
| 310 | return Ok(deleted); |
| 311 | } |
| 312 | return self.mark_deleted_repos().await; |
| 313 | } |
| 314 | let mut cursor = job.cursor; |
| 315 | let mut recorded = 0; |
| 316 | for _ in 0..PAGES_PER_RUN { |
| 317 | let page: CreatorPage = |
| 318 | g1t_kit::call(&self.repos, "repo_creators", &AllIdsArgs { after: cursor.clone(), limit: PAGE }).await?; |
| 319 | recorded += self.record_page(&page).await?; |
| 320 | match page.next { |
| 321 | Some(next) => { |
| 322 | self.db |
| 323 | .prepare("UPDATE work_jobs SET cursor = ? WHERE name = 'work_items'") |
| 324 | .bind(&[next.as_str().into()])? |
| 325 | .run() |
| 326 | .await?; |
| 327 | cursor = Some(next); |
| 328 | } |
| 329 | None => return self.finish(recorded).await, |
| 330 | } |
| 331 | } |
| 332 | Ok(WorkMigration { done: false, repos: recorded, ..WorkMigration::default() }) |
| 333 | } |
| 334 | |
| 335 | /// The job after the first ([`DELETED_REPOS_JOB`]): records each |
| 336 | /// repository deleted but not purged, a few pages a run, then places |
| 337 | /// what it kept ([`place_sql`]) and gives its work items' labels to |
| 338 | /// the workspace ([`CARRIED_LABELS`]). Done once; run again, it |
| 339 | /// changes nothing. |
| 340 | async fn migrate_deleted_repos(&self) -> Result<WorkMigration> { |
| 341 | let job = self |
| 342 | .db |
| 343 | .prepare("SELECT cursor, done_at FROM work_jobs WHERE name = ?") |
| 344 | .bind(&[DELETED_REPOS_JOB.into()])? |
| 345 | .first::<JobRow>(None) |
| 346 | .await?; |
| 347 | let Some(job) = job.filter(|job| job.done_at.is_none()) else { |
| 348 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); |
| 349 | }; |
| 350 | let mut cursor = job.cursor; |
| 351 | let mut recorded = 0; |
| 352 | for _ in 0..PAGES_PER_RUN { |
| 353 | let page: DeletedRepoPage = |
| 354 | g1t_kit::call(&self.repos, "deleted_repos", &AllIdsArgs { after: cursor.clone(), limit: PAGE }).await?; |
| 355 | let repos: Vec<(String, String, String, bool)> = page |
| 356 | .repos |
| 357 | .iter() |
| 358 | .map(|repo| (repo.id.clone(), repo.namespace.clone(), repo.name.clone(), repo.is_private)) |
| 359 | .collect(); |
| 360 | recorded += self.record_repos(&repos).await?; |
| 361 | // Recording one says it is there; these are deleted. |
| 362 | self.mark_deleted(&page).await?; |
| 363 | match page.next { |
| 364 | Some(next) => { |
| 365 | self.db |
| 366 | .prepare("UPDATE work_jobs SET cursor = ? WHERE name = ?") |
| 367 | .bind(&[next.as_str().into(), DELETED_REPOS_JOB.into()])? |
| 368 | .run() |
| 369 | .await?; |
| 370 | cursor = Some(next); |
| 371 | } |
| 372 | None => { |
| 373 | self.place(true).await?; |
| 374 | self.db |
| 375 | .prepare("UPDATE work_jobs SET done_at = ?, cursor = NULL WHERE name = ? AND done_at IS NULL") |
| 376 | .bind(&[rfc3339(now_ms()).into(), DELETED_REPOS_JOB.into()])? |
| 377 | .run() |
| 378 | .await?; |
| 379 | return Ok(WorkMigration { done: true, repos: recorded, ..WorkMigration::default() }); |
| 380 | } |
| 381 | } |
| 382 | } |
| 383 | Ok(WorkMigration { done: false, repos: recorded, ..WorkMigration::default() }) |
| 384 | } |
| 385 | |
| 386 | /// The job after [`Self::migrate_deleted_repos`] ([`DELETED_MARKS_JOB`]): |
| 387 | /// marks each repository deleted but not purged as deleted, a few |
| 388 | /// pages a run. Done once; run again, it changes nothing. |
| 389 | async fn mark_deleted_repos(&self) -> Result<WorkMigration> { |
| 390 | let job = self |
| 391 | .db |
| 392 | .prepare("SELECT cursor, done_at FROM work_jobs WHERE name = ?") |
| 393 | .bind(&[DELETED_MARKS_JOB.into()])? |
| 394 | .first::<JobRow>(None) |
| 395 | .await?; |
| 396 | let Some(job) = job.filter(|job| job.done_at.is_none()) else { |
| 397 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); |
| 398 | }; |
| 399 | let mut cursor = job.cursor; |
| 400 | let mut marked = 0; |
| 401 | for _ in 0..PAGES_PER_RUN { |
| 402 | let page: DeletedRepoPage = |
| 403 | g1t_kit::call(&self.repos, "deleted_repos", &AllIdsArgs { after: cursor.clone(), limit: PAGE }).await?; |
| 404 | marked += self.mark_deleted(&page).await?; |
| 405 | let done = page.next.is_none(); |
| 406 | self.db |
| 407 | .prepare("UPDATE work_jobs SET cursor = ?, done_at = ? WHERE name = ? AND done_at IS NULL") |
| 408 | .bind(&[ |
| 409 | optional(&page.next), |
| 410 | if done { rfc3339(now_ms()).into() } else { JsValue::NULL }, |
| 411 | DELETED_MARKS_JOB.into(), |
| 412 | ])? |
| 413 | .run() |
| 414 | .await?; |
| 415 | if done { |
| 416 | return Ok(WorkMigration { done: true, repos: marked, ..WorkMigration::default() }); |
| 417 | } |
| 418 | cursor = page.next; |
| 419 | } |
| 420 | Ok(WorkMigration { done: false, repos: marked, ..WorkMigration::default() }) |
| 421 | } |
| 422 | |
| 423 | /// Marks a page of deleted repositories as deleted where they are recorded. |
| 424 | async fn mark_deleted(&self, page: &DeletedRepoPage) -> Result<u32> { |
| 425 | if page.repos.is_empty() { |
| 426 | return Ok(0); |
| 427 | } |
| 428 | let now = rfc3339(now_ms()); |
| 429 | let statements = page |
| 430 | .repos |
| 431 | .iter() |
| 432 | .map(|repo| self.db.prepare(super::links::MARK_DELETED).bind(&[repo.id.as_str().into(), now.as_str().into()])) |
| 433 | .collect::<Result<Vec<_>>>()?; |
| 434 | self.db.batch(statements).await?; |
| 435 | Ok(page.repos.len() as u32) |
| 436 | } |
| 437 | |
| 438 | /// Records repositories (id, workspace, name, private) where they are. |
| 439 | async fn record_repos(&self, repos: &[(String, String, String, bool)]) -> Result<u32> { |
| 440 | if repos.is_empty() { |
| 441 | return Ok(0); |
| 442 | } |
| 443 | let mut statements = Vec::new(); |
| 444 | for (id, namespace, name, private) in repos { |
| 445 | let path = format!("{namespace}/{name}"); |
| 446 | let binds = [id.as_str().into(), namespace.to_lowercase().into(), path.as_str().into(), u32::from(*private).into()]; |
| 447 | for sql in RECORD_REPO { |
| 448 | statements.push(self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)])?); |
| 449 | } |
| 450 | } |
| 451 | self.db.batch(statements).await?; |
| 452 | Ok(repos.len() as u32) |
| 453 | } |
| 454 | |
| 455 | /// [`place_sql`], and with `carried`, [`CARRIED_LABELS`], in one batch. |
| 456 | async fn place(&self, carried: bool) -> Result<()> { |
| 457 | let now = rfc3339(now_ms()); |
| 458 | let mut sql = place_sql(); |
| 459 | if carried { |
| 460 | sql.extend(CARRIED_LABELS.iter().map(|sql| (*sql).to_owned())); |
| 461 | } |
| 462 | let statements = sql |
| 463 | .iter() |
| 464 | .map(|sql| { |
| 465 | let binds = [now.as_str().into()]; |
| 466 | self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)]) |
| 467 | }) |
| 468 | .collect::<Result<Vec<_>>>()?; |
| 469 | self.db.batch(statements).await?; |
| 470 | Ok(()) |
| 471 | } |
| 472 | |
| 473 | /// `repo.restored`: where the repository is again, and, once work items |
| 474 | /// have moved, whatever of its own is still to place (a repository |
| 475 | /// deleted while the job ran). |
| 476 | pub(super) async fn repo_restored(&self, data: &serde_json::Value) -> Result<()> { |
| 477 | let Ok(restored) = serde_json::from_value::<Created>(data.clone()) else { |
| 478 | return Ok(()); |
| 479 | }; |
| 480 | self.record_repos(&[(restored.repo_id, restored.namespace, restored.name, restored.is_private)]).await?; |
| 481 | if self.items_moved().await? { |
| 482 | self.place(false).await?; |
| 483 | } |
| 484 | Ok(()) |
| 485 | } |
| 486 | |
| 487 | /// Records one page of repositories: where each is, and whether it is |
| 488 | /// private. |
| 489 | async fn record_page(&self, page: &CreatorPage) -> Result<u32> { |
| 490 | if page.repos.is_empty() { |
| 491 | return Ok(0); |
| 492 | } |
| 493 | let paths: Vec<String> = page.repos.iter().map(|repo| format!("{}/{}", repo.namespace, repo.name)).collect(); |
| 494 | let seen: Vec<RepoVisibility> = g1t_kit::call(&self.repos, "visibility", &VisibilityArgs { paths: paths.clone() }).await?; |
| 495 | let mut statements = Vec::new(); |
| 496 | for (repo, path) in page.repos.iter().zip(&paths) { |
| 497 | let private = seen.iter().find(|known| known.path.eq_ignore_ascii_case(path)).is_none_or(|known| known.is_private); |
| 498 | let binds = [ |
| 499 | repo.id.as_str().into(), |
| 500 | repo.namespace.to_lowercase().into(), |
| 501 | path.as_str().into(), |
| 502 | u32::from(private).into(), |
| 503 | ]; |
| 504 | for sql in RECORD_REPO { |
| 505 | statements.push(self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)])?); |
| 506 | } |
| 507 | } |
| 508 | self.db.batch(statements).await?; |
| 509 | Ok(page.repos.len() as u32) |
| 510 | } |
| 511 | |
| 512 | /// Numbers every moved issue in its workspace and moves labels and |
| 513 | /// milestones, in one batch, then announces where each issue went. |
| 514 | async fn finish(&self, recorded: u32) -> Result<WorkMigration> { |
| 515 | let now = rfc3339(now_ms()); |
| 516 | let before = self.unnumbered().await?; |
| 517 | let statements = finish_sql() |
| 518 | .iter() |
| 519 | .map(|sql| { |
| 520 | let binds = [now.as_str().into()]; |
| 521 | self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)]) |
| 522 | }) |
| 523 | .collect::<Result<Vec<_>>>()?; |
| 524 | self.db.batch(statements).await?; |
| 525 | let unplaced = self.unnumbered().await?; |
| 526 | if unplaced > 0 { |
| 527 | worker::console_warn!("{unplaced} issues were not moved: their repositories are gone, and nothing says where they were"); |
| 528 | } |
| 529 | self.announce_moves().await?; |
| 530 | Ok(WorkMigration { done: true, repos: recorded, numbered: before.saturating_sub(unplaced), unplaced }) |
| 531 | } |
| 532 | |
| 533 | async fn unnumbered(&self) -> Result<u32> { |
| 534 | Ok(self |
| 535 | .db |
| 536 | .prepare("SELECT count(*) AS n FROM work_items WHERE number IS NULL") |
| 537 | .first::<NumberRow>(None) |
| 538 | .await? |
| 539 | .map_or(0, |row| row.n)) |
| 540 | } |
| 541 | |
| 542 | /// `work.migrated`, a page at a time. |
| 543 | async fn announce_moves(&self) -> Result<()> { |
| 544 | let mut after = None; |
| 545 | let mut page = 0; |
| 546 | loop { |
| 547 | let found = self.legacy_item_map(LegacyItemMapArgs { after: after.clone(), limit: ANNOUNCED_PER_EVENT }).await?; |
| 548 | page += 1; |
| 549 | let done = found.next.is_none(); |
| 550 | let data = serde_json::to_value(WorkMigrated { page, items: found.items, done })?; |
| 551 | self.publish_on("work.migrated", None, None, data).await?; |
| 552 | match found.next { |
| 553 | Some(next) => after = Some(next), |
| 554 | None => return Ok(()), |
| 555 | } |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | /// `repo.created`: where the repository is, and for a workspace's |
| 560 | /// first, the default labels. |
| 561 | pub(super) async fn repo_created(&self, data: &serde_json::Value) -> Result<()> { |
| 562 | let Ok(created) = serde_json::from_value::<Created>(data.clone()) else { |
| 563 | return Ok(()); |
| 564 | }; |
| 565 | let workspace = created.namespace.to_lowercase(); |
| 566 | let path = format!("{}/{}", created.namespace, created.name); |
| 567 | let binds = [ |
| 568 | created.repo_id.as_str().into(), |
| 569 | workspace.as_str().into(), |
| 570 | path.as_str().into(), |
| 571 | u32::from(created.is_private).into(), |
| 572 | ]; |
| 573 | let mut statements = RECORD_REPO |
| 574 | .iter() |
| 575 | .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)])) |
| 576 | .collect::<Result<Vec<_>>>()?; |
| 577 | let now = rfc3339(now_ms()); |
| 578 | for (name, color, description) in DEFAULT_LABELS { |
| 579 | statements.push(self.db.prepare(SEED_LABEL).bind(&[ |
| 580 | workspace.as_str().into(), |
| 581 | name.into(), |
| 582 | color.into(), |
| 583 | description.into(), |
| 584 | now.as_str().into(), |
| 585 | ])?); |
| 586 | } |
| 587 | self.db.batch(statements).await?; |
| 588 | Ok(()) |
| 589 | } |
| 590 | |
| 591 | /// `repo.visibility_changed`: whether its work items are public. |
| 592 | pub(super) async fn repo_visibility(&self, data: &serde_json::Value) -> Result<()> { |
| 593 | let (Some(repo_id), Some(private)) = (data["repoId"].as_str(), data["isPrivate"].as_bool()) else { |
| 594 | return Ok(()); |
| 595 | }; |
| 596 | self.db |
| 597 | .prepare("UPDATE repo_workspaces SET private = ? WHERE repo_id = ?") |
| 598 | .bind(&[u32::from(private).into(), repo_id.into()])? |
| 599 | .run() |
| 600 | .await?; |
| 601 | Ok(()) |
| 602 | } |
| 603 | |
| 604 | /// `legacy_item_map`: a page of where repositories' old issues went. |
| 605 | pub(crate) async fn legacy_item_map(&self, a: LegacyItemMapArgs) -> Result<LegacyItemPage> { |
| 606 | let limit = a.limit.clamp(1, 500); |
| 607 | let items = self |
| 608 | .db |
| 609 | .prepare( |
| 610 | "SELECT r.repo_id, r.legacy_number, i.workspace, i.number, i.id AS item_id |
| 611 | FROM work_item_repos r JOIN work_items i ON i.id = r.item_id |
| 612 | WHERE r.legacy_number IS NOT NULL AND i.number IS NOT NULL AND (?1 IS NULL OR i.id > ?1) |
| 613 | ORDER BY i.id LIMIT ?2", |
| 614 | ) |
| 615 | .bind(&[optional(&a.after), limit.into()])? |
| 616 | .all() |
| 617 | .await? |
| 618 | .results::<MigratedItem>()?; |
| 619 | let next = (items.len() as u32 == limit).then(|| items.last().map(|item| item.item_id.clone())).flatten(); |
| 620 | Ok(LegacyItemPage { items, next, moved: self.items_moved().await? }) |
| 621 | } |
| 622 | } |