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