| 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::{WorkItemEvent, WorkMigrated}; |
| 25 | use g1t_contracts::repos::{AllIdsArgs, CreatorPage, Repo, RepoVisibility, VisibilityArgs}; |
| 26 | use g1t_contracts::time::rfc3339; |
| 27 | use g1t_contracts::work::*; |
| 28 | use g1t_contracts::User; |
| 29 | use g1t_kit::now_ms; |
| 30 | use serde::Deserialize; |
| 31 | use worker::Result; |
| 32 | |
| 33 | use crate::Work; |
| 34 | use crate::items::{Held, path_of}; |
| 35 | use crate::rows::{NumberRow, ValueRow}; |
| 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. |
| 41 | const ANNOUNCED_PER_EVENT: u32 = 500; |
| 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 | crate::items::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). Each statement changes nothing run again, and those that |
| 59 | /// would are guarded by the job not being done yet. |
| 60 | pub(crate) fn finish_sql() -> Vec<String> { |
| 61 | let pending = "(SELECT done_at FROM work_jobs WHERE name = 'work_items') IS NULL"; |
| 62 | vec![ |
| 63 | // Each moved issue's workspace: its repository's. |
| 64 | format!( |
| 65 | "UPDATE work_items SET workspace = ( |
| 66 | SELECT lower(COALESCE(rw.workspace, {FALLBACK_WORKSPACE})) |
| 67 | FROM work_item_repos o LEFT JOIN repo_workspaces rw ON rw.repo_id = o.repo_id |
| 68 | WHERE o.item_id = work_items.id AND o.role = 'origin') |
| 69 | WHERE workspace IS NULL AND number IS NULL" |
| 70 | ), |
| 71 | // Numbered above every number its workspace used, oldest first. |
| 72 | "UPDATE work_items SET number = n.number |
| 73 | FROM ( |
| 74 | SELECT i.id AS id, f.floor + ROW_NUMBER() OVER (PARTITION BY i.workspace ORDER BY i.created_at, i.id) AS number |
| 75 | FROM work_items i JOIN ( |
| 76 | SELECT w.workspace AS workspace, MAX( |
| 77 | COALESCE((SELECT MAX(c.last) FROM counters c JOIN repo_workspaces rw ON rw.repo_id = c.repo_id |
| 78 | WHERE rw.workspace = w.workspace), 0), |
| 79 | COALESCE((SELECT wc.last FROM work_counters wc WHERE wc.workspace = w.workspace), 0), |
| 80 | COALESCE((SELECT MAX(x.number) FROM work_items x WHERE x.workspace = w.workspace), 0), |
| 81 | COALESCE((SELECT MAX(p.number) FROM pulls p JOIN repo_workspaces rw ON rw.repo_id = p.repo_id |
| 82 | WHERE rw.workspace = w.workspace), 0)) AS floor |
| 83 | FROM (SELECT DISTINCT workspace FROM work_items WHERE number IS NULL AND workspace IS NOT NULL) w |
| 84 | ) f ON f.workspace = i.workspace |
| 85 | WHERE i.number IS NULL AND i.workspace IS NOT NULL |
| 86 | ) n |
| 87 | WHERE work_items.id = n.id" |
| 88 | .to_owned(), |
| 89 | // Each workspace's sequence goes on after everything it used. |
| 90 | "INSERT INTO work_counters (workspace, last) |
| 91 | SELECT rw.workspace, MAX(c.last) FROM counters c JOIN repo_workspaces rw ON rw.repo_id = c.repo_id |
| 92 | GROUP BY rw.workspace |
| 93 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)" |
| 94 | .to_owned(), |
| 95 | "INSERT INTO work_counters (workspace, last) |
| 96 | SELECT workspace, MAX(number) FROM work_items WHERE workspace IS NOT NULL AND number IS NOT NULL |
| 97 | GROUP BY workspace |
| 98 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)" |
| 99 | .to_owned(), |
| 100 | // Labels merge by name; the first made keeps its color and words. |
| 101 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 102 | SELECT rw.workspace, l.name, l.color, l.description, l.created_at |
| 103 | FROM repo_labels l JOIN repo_workspaces rw ON rw.repo_id = l.repo_id |
| 104 | WHERE true ORDER BY l.created_at, l.repo_id |
| 105 | ON CONFLICT (workspace, name) DO UPDATE |
| 106 | SET color = excluded.color, description = excluded.description, created_at = excluded.created_at |
| 107 | WHERE excluded.created_at < labels.created_at" |
| 108 | .to_owned(), |
| 109 | // Milestones are numbered on after the workspace's own, oldest |
| 110 | // first, and what was in each moves with it. |
| 111 | format!( |
| 112 | "INSERT INTO milestone_moves (repo_id, old_number, workspace, number) |
| 113 | SELECT m.repo_id, m.number, rw.workspace, |
| 114 | COALESCE((SELECT MAX(x.number) FROM milestones x WHERE x.workspace = rw.workspace), 0) |
| 115 | + ROW_NUMBER() OVER (PARTITION BY rw.workspace ORDER BY m.created_at, m.repo_id, m.number) |
| 116 | FROM repo_milestones m JOIN repo_workspaces rw ON rw.repo_id = m.repo_id |
| 117 | WHERE {pending} |
| 118 | AND NOT EXISTS (SELECT 1 FROM milestone_moves mm WHERE mm.repo_id = m.repo_id AND mm.old_number = m.number)" |
| 119 | ), |
| 120 | "INSERT INTO milestones (workspace, number, origin_repo_id, title, description, due_on, state, created_at, updated_at, closed_at) |
| 121 | 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 |
| 122 | FROM milestone_moves mm JOIN repo_milestones m ON m.repo_id = mm.repo_id AND m.number = mm.old_number |
| 123 | WHERE true |
| 124 | ON CONFLICT DO NOTHING" |
| 125 | .to_owned(), |
| 126 | format!( |
| 127 | "UPDATE work_items SET milestone = ( |
| 128 | SELECT mm.number FROM milestone_moves mm JOIN work_item_repos o ON o.repo_id = mm.repo_id |
| 129 | WHERE o.item_id = work_items.id AND o.role = 'origin' AND mm.old_number = work_items.milestone) |
| 130 | WHERE milestone IS NOT NULL AND {pending} |
| 131 | AND id IN (SELECT item_id FROM work_item_repos WHERE legacy_number IS NOT NULL)" |
| 132 | ), |
| 133 | format!( |
| 134 | "UPDATE pulls SET milestone = ( |
| 135 | SELECT mm.number FROM milestone_moves mm WHERE mm.repo_id = pulls.repo_id AND mm.old_number = pulls.milestone) |
| 136 | WHERE milestone IS NOT NULL AND {pending}" |
| 137 | ), |
| 138 | // What the repositories kept for themselves is the workspace's now. |
| 139 | format!("DELETE FROM counters WHERE {pending}"), |
| 140 | format!("DELETE FROM repo_labels WHERE {pending}"), |
| 141 | format!("DELETE FROM repo_milestones WHERE {pending}"), |
| 142 | "UPDATE work_jobs SET done_at = ?1, cursor = NULL WHERE name = 'work_items' AND done_at IS NULL".to_owned(), |
| 143 | ] |
| 144 | } |
| 145 | |
| 146 | /// A workspace renamed (`g1t_kit::rename`, `?1` its slug now, `?2` before): |
| 147 | /// its work items, labels, milestones, numbers and repositories follow. |
| 148 | pub(crate) const RENAMED: &[&str] = &[ |
| 149 | "UPDATE OR IGNORE work_items SET workspace = ?1 WHERE workspace = ?2", |
| 150 | "INSERT INTO labels (workspace, name, color, description, created_at) |
| 151 | SELECT ?1, name, color, description, created_at FROM labels WHERE workspace = ?2 |
| 152 | ON CONFLICT DO NOTHING", |
| 153 | "DELETE FROM labels WHERE workspace = ?2", |
| 154 | "UPDATE OR IGNORE milestones SET workspace = ?1 WHERE workspace = ?2", |
| 155 | "UPDATE milestone_moves SET workspace = ?1 WHERE workspace = ?2", |
| 156 | "INSERT INTO work_counters (workspace, last) SELECT ?1, last FROM work_counters WHERE workspace = ?2 |
| 157 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)", |
| 158 | "DELETE FROM work_counters WHERE workspace = ?2", |
| 159 | "UPDATE repo_workspaces SET workspace = ?1, path = ?1 || substr(path, length(?2) + 1) |
| 160 | WHERE workspace = ?2 AND substr(path, 1, length(?2) + 1) = ?2 || '/'", |
| 161 | "UPDATE work_item_repos SET path = ?1 || substr(path, length(?2) + 1) WHERE substr(path, 1, length(?2) + 1) = ?2 || '/'", |
| 162 | ]; |
| 163 | |
| 164 | /// A repository renamed or transferred (`g1t_kit::transfer`: `?1` its path |
| 165 | /// now, `?3` its workspace now, `?5` its id): where it is now, and in the |
| 166 | /// workspace it moved to, numbers go on after its pull requests', so a new |
| 167 | /// one never takes a number it has. Its work items stay in their own |
| 168 | /// workspace, linked to it. |
| 169 | pub(crate) const TRANSFERRED: &[&str] = &[ |
| 170 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES (?5, ?3, ?1, 1) |
| 171 | ON CONFLICT (repo_id) DO UPDATE SET workspace = ?3, path = ?1", |
| 172 | "UPDATE work_item_repos SET path = ?1 WHERE repo_id = ?5", |
| 173 | "INSERT INTO work_counters (workspace, last) |
| 174 | SELECT ?3, MAX(number) FROM pulls WHERE repo_id = ?5 GROUP BY repo_id |
| 175 | ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last, excluded.last)", |
| 176 | ]; |
| 177 | |
| 178 | /// A workspace deleted (`g1t_kit::deleted`, `?1` its slug): its work |
| 179 | /// items go, with everything kept for them, and its labels, milestones |
| 180 | /// and numbers. |
| 181 | pub(crate) const DELETED: &[&str] = &[ |
| 182 | "UPDATE pulls SET item_id = NULL WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 183 | "DELETE FROM comments WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 184 | "DELETE FROM reactions WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 185 | "DELETE FROM claims WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 186 | "DELETE FROM decisions WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 187 | "UPDATE sessions SET item_id = NULL WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 188 | "DELETE FROM session_links WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 189 | "DELETE FROM work_item_repos WHERE item_id IN (SELECT id FROM work_items WHERE workspace = ?1)", |
| 190 | "DELETE FROM work_items WHERE workspace = ?1", |
| 191 | "DELETE FROM labels WHERE workspace = ?1", |
| 192 | "DELETE FROM milestones WHERE workspace = ?1", |
| 193 | "DELETE FROM work_counters WHERE workspace = ?1", |
| 194 | "DELETE FROM repo_workspaces WHERE workspace = ?1", |
| 195 | ]; |
| 196 | |
| 197 | /// The default labels for workspace `?1` (name `?2`, color `?3`, |
| 198 | /// description `?4`, at `?5`), unless it has labels already. |
| 199 | const SEED_LABEL: &str = "INSERT OR IGNORE INTO labels (workspace, name, color, description, created_at) |
| 200 | SELECT ?1, ?2, ?3, ?4, ?5 WHERE NOT EXISTS (SELECT 1 FROM labels WHERE workspace = ?1 AND created_at < ?5)"; |
| 201 | |
| 202 | #[derive(Deserialize)] |
| 203 | #[serde(rename_all = "camelCase")] |
| 204 | struct Created { |
| 205 | repo_id: String, |
| 206 | namespace: String, |
| 207 | name: String, |
| 208 | #[serde(default = "private")] |
| 209 | is_private: bool, |
| 210 | } |
| 211 | |
| 212 | fn private() -> bool { |
| 213 | true |
| 214 | } |
| 215 | |
| 216 | #[derive(Deserialize)] |
| 217 | struct JobRow { |
| 218 | #[serde(default)] |
| 219 | cursor: Option<String>, |
| 220 | #[serde(default)] |
| 221 | done_at: Option<String>, |
| 222 | } |
| 223 | |
| 224 | impl Work { |
| 225 | /// `migrate_work_items`, and the cron trigger: see the module's notes. |
| 226 | pub(crate) async fn migrate_work_items(&self) -> Result<WorkMigration> { |
| 227 | let job = self |
| 228 | .db |
| 229 | .prepare("SELECT cursor, done_at FROM work_jobs WHERE name = 'work_items'") |
| 230 | .first::<JobRow>(None) |
| 231 | .await?; |
| 232 | let Some(job) = job else { |
| 233 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); |
| 234 | }; |
| 235 | if job.done_at.is_some() { |
| 236 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); |
| 237 | } |
| 238 | let mut cursor = job.cursor; |
| 239 | let mut recorded = 0; |
| 240 | for _ in 0..PAGES_PER_RUN { |
| 241 | let page: CreatorPage = |
| 242 | g1t_kit::call(&self.repos, "repo_creators", &AllIdsArgs { after: cursor.clone(), limit: PAGE }).await?; |
| 243 | recorded += self.record_page(&page).await?; |
| 244 | match page.next { |
| 245 | Some(next) => { |
| 246 | self.db |
| 247 | .prepare("UPDATE work_jobs SET cursor = ? WHERE name = 'work_items'") |
| 248 | .bind(&[next.as_str().into()])? |
| 249 | .run() |
| 250 | .await?; |
| 251 | cursor = Some(next); |
| 252 | } |
| 253 | None => return self.finish(recorded).await, |
| 254 | } |
| 255 | } |
| 256 | Ok(WorkMigration { done: false, repos: recorded, ..WorkMigration::default() }) |
| 257 | } |
| 258 | |
| 259 | /// Records one page of repositories: where each is, and whether it is |
| 260 | /// private. |
| 261 | async fn record_page(&self, page: &CreatorPage) -> Result<u32> { |
| 262 | if page.repos.is_empty() { |
| 263 | return Ok(0); |
| 264 | } |
| 265 | let paths: Vec<String> = page.repos.iter().map(|repo| format!("{}/{}", repo.namespace, repo.name)).collect(); |
| 266 | let seen: Vec<RepoVisibility> = g1t_kit::call(&self.repos, "visibility", &VisibilityArgs { paths: paths.clone() }).await?; |
| 267 | let mut statements = Vec::new(); |
| 268 | for (repo, path) in page.repos.iter().zip(&paths) { |
| 269 | let private = seen.iter().find(|known| known.path.eq_ignore_ascii_case(path)).is_none_or(|known| known.is_private); |
| 270 | let binds = [ |
| 271 | repo.id.as_str().into(), |
| 272 | repo.namespace.to_lowercase().into(), |
| 273 | path.as_str().into(), |
| 274 | u32::from(private).into(), |
| 275 | ]; |
| 276 | for sql in RECORD_REPO { |
| 277 | statements.push(self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)])?); |
| 278 | } |
| 279 | } |
| 280 | self.db.batch(statements).await?; |
| 281 | Ok(page.repos.len() as u32) |
| 282 | } |
| 283 | |
| 284 | /// Numbers every moved issue in its workspace and moves labels and |
| 285 | /// milestones, in one batch, then announces where each issue went. |
| 286 | async fn finish(&self, recorded: u32) -> Result<WorkMigration> { |
| 287 | let now = rfc3339(now_ms()); |
| 288 | let before = self.unnumbered().await?; |
| 289 | let statements = finish_sql() |
| 290 | .iter() |
| 291 | .map(|sql| { |
| 292 | let binds = [now.as_str().into()]; |
| 293 | self.db.prepare(sql).bind(&binds[..g1t_kit::rename::parameters(sql)]) |
| 294 | }) |
| 295 | .collect::<Result<Vec<_>>>()?; |
| 296 | self.db.batch(statements).await?; |
| 297 | let unplaced = self.unnumbered().await?; |
| 298 | if unplaced > 0 { |
| 299 | worker::console_warn!("{unplaced} issues were not moved: their repositories are gone, and nothing says where they were"); |
| 300 | } |
| 301 | self.announce_moves().await?; |
| 302 | Ok(WorkMigration { done: true, repos: recorded, numbered: before.saturating_sub(unplaced), unplaced }) |
| 303 | } |
| 304 | |
| 305 | async fn unnumbered(&self) -> Result<u32> { |
| 306 | Ok(self |
| 307 | .db |
| 308 | .prepare("SELECT count(*) AS n FROM work_items WHERE number IS NULL") |
| 309 | .first::<NumberRow>(None) |
| 310 | .await? |
| 311 | .map_or(0, |row| row.n)) |
| 312 | } |
| 313 | |
| 314 | /// `work.migrated`, a page at a time. |
| 315 | async fn announce_moves(&self) -> Result<()> { |
| 316 | let mut after = None; |
| 317 | let mut page = 0; |
| 318 | loop { |
| 319 | let found = self.legacy_item_map(LegacyItemMapArgs { after: after.clone(), limit: ANNOUNCED_PER_EVENT }).await?; |
| 320 | page += 1; |
| 321 | let done = found.next.is_none(); |
| 322 | let data = serde_json::to_value(WorkMigrated { page, items: found.items, done })?; |
| 323 | self.publish_on("work.migrated", None, None, data).await?; |
| 324 | match found.next { |
| 325 | Some(next) => after = Some(next), |
| 326 | None => return Ok(()), |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | /// `repo.created`: where the repository is, and for a workspace's |
| 332 | /// first, the default labels. |
| 333 | pub(crate) async fn repo_created(&self, data: &serde_json::Value) -> Result<()> { |
| 334 | let Ok(created) = serde_json::from_value::<Created>(data.clone()) else { |
| 335 | return Ok(()); |
| 336 | }; |
| 337 | let workspace = created.namespace.to_lowercase(); |
| 338 | let path = format!("{}/{}", created.namespace, created.name); |
| 339 | let binds = [ |
| 340 | created.repo_id.as_str().into(), |
| 341 | workspace.as_str().into(), |
| 342 | path.as_str().into(), |
| 343 | u32::from(created.is_private).into(), |
| 344 | ]; |
| 345 | let mut statements = RECORD_REPO |
| 346 | .iter() |
| 347 | .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)])) |
| 348 | .collect::<Result<Vec<_>>>()?; |
| 349 | let now = rfc3339(now_ms()); |
| 350 | for (name, color, description) in DEFAULT_LABELS { |
| 351 | statements.push(self.db.prepare(SEED_LABEL).bind(&[ |
| 352 | workspace.as_str().into(), |
| 353 | name.into(), |
| 354 | color.into(), |
| 355 | description.into(), |
| 356 | now.as_str().into(), |
| 357 | ])?); |
| 358 | } |
| 359 | self.db.batch(statements).await?; |
| 360 | Ok(()) |
| 361 | } |
| 362 | |
| 363 | /// `repo.visibility_changed`: whether its work items are public. |
| 364 | pub(crate) async fn repo_visibility(&self, data: &serde_json::Value) -> Result<()> { |
| 365 | let (Some(repo_id), Some(private)) = (data["repoId"].as_str(), data["isPrivate"].as_bool()) else { |
| 366 | return Ok(()); |
| 367 | }; |
| 368 | self.db |
| 369 | .prepare("UPDATE repo_workspaces SET private = ? WHERE repo_id = ?") |
| 370 | .bind(&[u32::from(private).into(), repo_id.into()])? |
| 371 | .run() |
| 372 | .await?; |
| 373 | Ok(()) |
| 374 | } |
| 375 | |
| 376 | /// Links `repo` to a work item it now has a pull request for, as |
| 377 | /// touched, unless it is linked already, and says so. |
| 378 | pub(crate) async fn touch_item(&self, held: &Held, repo: &Repo, actor: &User) -> Result<()> { |
| 379 | let linked = self |
| 380 | .db |
| 381 | .prepare(crate::closing::PART_SQL) |
| 382 | .bind(&[held.item.id.as_str().into(), repo.id.as_str().into(), rfc3339(now_ms()).into(), actor.id.as_str().into()])? |
| 383 | .first::<ValueRow>(None) |
| 384 | .await?; |
| 385 | if linked.is_none() { |
| 386 | return Ok(()); |
| 387 | } |
| 388 | let Some(after) = self.item_by_id(&held.item.id).await? else { |
| 389 | return Ok(()); |
| 390 | }; |
| 391 | self.publish_item( |
| 392 | "work_item.linked", |
| 393 | &after, |
| 394 | Some(actor), |
| 395 | WorkItemEvent { repo: Some(path_of(repo)), role: Some(LinkRole::Touched), ..Self::item_event(&after) }, |
| 396 | ) |
| 397 | .await |
| 398 | } |
| 399 | } |