| 1 | //! Which events each subscribing service's queue is sent. |
| 2 | //! |
| 3 | //! The events service passes each batch on to one queue per subscriber |
| 4 | //! (`SUBSCRIBER_*` bindings). Each is sent only the types its consumer acts |
| 5 | //! on, so an event costs a queue write for the services that read it, not |
| 6 | //! for all of them. A consumer that starts acting on a new type adds it |
| 7 | //! here in the same change, or it never hears of it; the tests below and |
| 8 | //! beside each consumer check what they can. |
| 9 | //! |
| 10 | //! A pattern is an exact type (`git.push`), a family (`pull.*`, every type |
| 11 | //! that starts `pull.`), or `*`, every type. A binding not named here is |
| 12 | //! sent everything, so a new subscriber is never left out. |
| 13 | |
| 14 | /// What the shared helpers in `g1t_kit` act on (`rename`, `transfer`, |
| 15 | /// `lifecycle`, `deleted`, `user_deleted`): rows that follow a workspace |
| 16 | /// or repository, or go with it. Rare, so every subscriber is sent them. |
| 17 | const LIFECYCLE: [&str; 8] = [ |
| 18 | "workspace.renamed", |
| 19 | "workspace.deleted", |
| 20 | "repo.renamed", |
| 21 | "repo.transferred", |
| 22 | "repo.deleted", |
| 23 | "repo.restored", |
| 24 | "repo.purged", |
| 25 | "user.deleted", |
| 26 | ]; |
| 27 | |
| 28 | /// Each subscriber's binding and the types it is sent beside [`LIFECYCLE`]. |
| 29 | pub const ROUTES: &[(&str, &[&str])] = &[ |
| 30 | // Memory captured from merges and comments, mentions, pull request |
| 31 | // bases, runs stopped in archived repositories (work/src/lib.rs queue). |
| 32 | ( |
| 33 | "SUBSCRIBER_WORK", |
| 34 | &[ |
| 35 | "git.push", |
| 36 | "branch.renamed", |
| 37 | "repo.created", |
| 38 | "repo.archived", |
| 39 | "repo.default_branch_changed", |
| 40 | "pull.merged", |
| 41 | "comment.created", |
| 42 | ], |
| 43 | ), |
| 44 | // Pull requests g1t is seeing through, mentions, queued issues and the |
| 45 | // merge queue (runner/src/index.ts queue). |
| 46 | ( |
| 47 | "SUBSCRIBER_RUNNER", |
| 48 | &[ |
| 49 | "pull.opened", |
| 50 | "pull.ready", |
| 51 | "pull.updated", |
| 52 | "pull.mergeability", |
| 53 | "pull.mergecheck", |
| 54 | "pull.merge_requested", |
| 55 | "pull.merged", |
| 56 | "pull.closed", |
| 57 | "checks.completed", |
| 58 | "review.completed", |
| 59 | "queue.changed", |
| 60 | "comment.created", |
| 61 | "issue.opened", |
| 62 | "issue.updated", |
| 63 | "issue.closed", |
| 64 | "agent.asked", |
| 65 | "repo.archived", |
| 66 | ], |
| 67 | ), |
| 68 | // Push mirrors and write-back to linked trackers (integrations/src). |
| 69 | ("SUBSCRIBER_INTEGRATIONS", &["git.push", "issue.closed", "pull.opened"]), |
| 70 | // A hook may ask for any type, or for every one. |
| 71 | ("SUBSCRIBER_WEBHOOKS", &["*"]), |
| 72 | // Every event a workflow can run on (g1t_actions::events::github_events), |
| 73 | // and runs stopped in archived repositories. |
| 74 | ( |
| 75 | "SUBSCRIBER_ACTIONS", |
| 76 | &[ |
| 77 | "git.push", |
| 78 | "pull.*", |
| 79 | "issue.*", |
| 80 | "comment.*", |
| 81 | "release.*", |
| 82 | "deployment.created", |
| 83 | "deployment_status.created", |
| 84 | "review.completed", |
| 85 | "workflow.completed", |
| 86 | "repo.archived", |
| 87 | ], |
| 88 | ), |
| 89 | // Previews and production deploys (deployments/src/index.ts queue). |
| 90 | ( |
| 91 | "SUBSCRIBER_DEPLOYMENTS", |
| 92 | &[ |
| 93 | "git.push", |
| 94 | "branch.renamed", |
| 95 | "repo.default_branch_changed", |
| 96 | "pull.opened", |
| 97 | "pull.ready", |
| 98 | "pull.updated", |
| 99 | "pull.reopened", |
| 100 | "pull.closed", |
| 101 | "pull.merged", |
| 102 | "workspace.deleting", |
| 103 | "workspace.restored", |
| 104 | ], |
| 105 | ), |
| 106 | // Projects follow their repositories, and wake on activity |
| 107 | // (projects/src/index.ts, activity.ts). |
| 108 | ( |
| 109 | "SUBSCRIBER_PROJECTS", |
| 110 | &[ |
| 111 | "git.push", |
| 112 | "repo.*", |
| 113 | "issue.opened", |
| 114 | "issue.closed", |
| 115 | "issue.reopened", |
| 116 | "pull.opened", |
| 117 | "pull.updated", |
| 118 | "pull.merged", |
| 119 | "pull.closed", |
| 120 | "review.completed", |
| 121 | "comment.created", |
| 122 | "deployment.succeeded", |
| 123 | "deployment.failed", |
| 124 | "package.published", |
| 125 | "package.deleted", |
| 126 | "package.version_deleted", |
| 127 | "workspace.deleting", |
| 128 | "workspace.restored", |
| 129 | ], |
| 130 | ), |
| 131 | // Pull request working copies, workspaces' repositories, contributors |
| 132 | // shown as ghost (repos/src/lib.rs handle_event). |
| 133 | ( |
| 134 | "SUBSCRIBER_REPOS", |
| 135 | &[ |
| 136 | "pull.merged", |
| 137 | "pull.closed", |
| 138 | "pull.reopened", |
| 139 | "workspace.deleting", |
| 140 | "workspace.restored", |
| 141 | "user.deleting", |
| 142 | "user.restored", |
| 143 | ], |
| 144 | ), |
| 145 | // Usage rows that follow a renamed workspace or repository. |
| 146 | ("SUBSCRIBER_BILLING", &[]), |
| 147 | // Secret and dependency scans, and version update pull requests |
| 148 | // (security/src/lib.rs on_event). |
| 149 | ( |
| 150 | "SUBSCRIBER_SECURITY", |
| 151 | &[ |
| 152 | "git.push", |
| 153 | "pull.opened", |
| 154 | "pull.updated", |
| 155 | "pull.ready", |
| 156 | "pull.merged", |
| 157 | "pull.closed", |
| 158 | "checks.completed", |
| 159 | "comment.created", |
| 160 | "repo.created", |
| 161 | "repo.visibility_changed", |
| 162 | ], |
| 163 | ), |
| 164 | // What agents are told about a repository (context/src/index.ts). |
| 165 | ( |
| 166 | "SUBSCRIBER_CONTEXT", |
| 167 | &[ |
| 168 | "git.push", |
| 169 | "issue.opened", |
| 170 | "issue.updated", |
| 171 | "issue.closed", |
| 172 | "pull.ready", |
| 173 | "pull.merged", |
| 174 | "memory.changed", |
| 175 | ], |
| 176 | ), |
| 177 | // The site-wide search index (search/src/index.rs). |
| 178 | ("SUBSCRIBER_SEARCH", &["git.push", "repo.*", "issue.*", "pull.*", "user.*", "workspace.*"]), |
| 179 | // Composer packages read from repositories, and packages that follow |
| 180 | // their workspace, team or repository (packages/src/lib.rs on_event). |
| 181 | ( |
| 182 | "SUBSCRIBER_PACKAGES", |
| 183 | &[ |
| 184 | "git.push", |
| 185 | "repo.visibility_changed", |
| 186 | "workspace.deleting", |
| 187 | "workspace.restored", |
| 188 | "team.edited", |
| 189 | "team.deleted", |
| 190 | ], |
| 191 | ), |
| 192 | // Pages that cite code a merge or a push to the default branch |
| 193 | // changed become possibly out of date, and projects' docs folders shown |
| 194 | // in Docs are read again (docs/src/staleness.ts, repo-spaces.ts). |
| 195 | ("SUBSCRIBER_DOCS", &["git.push", "pull.merged"]), |
| 196 | // Agents' routines that run on events: a pull request ready for |
| 197 | // review or merged, checks or a deploy failing, an issue opened |
| 198 | // (agents/src/triggers.ts). |
| 199 | ( |
| 200 | "SUBSCRIBER_AGENTS", |
| 201 | &[ |
| 202 | "pull.opened", |
| 203 | "pull.ready", |
| 204 | "pull.merged", |
| 205 | "checks.completed", |
| 206 | "issue.opened", |
| 207 | "deployment.failed", |
| 208 | ], |
| 209 | ), |
| 210 | ]; |
| 211 | |
| 212 | /// Whether `pattern` (see the module's notes) matches the type `kind`. |
| 213 | fn matches(pattern: &str, kind: &str) -> bool { |
| 214 | match pattern.strip_suffix('*') { |
| 215 | Some(prefix) => kind.starts_with(prefix), |
| 216 | None => pattern == kind, |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | /// Whether the subscriber behind `binding` is sent events of type `kind`. |
| 221 | pub fn routed(binding: &str, kind: &str) -> bool { |
| 222 | match ROUTES.iter().find(|(name, _)| *name == binding) { |
| 223 | Some((_, patterns)) => { |
| 224 | LIFECYCLE.contains(&kind) || patterns.iter().any(|pattern| matches(pattern, kind)) |
| 225 | } |
| 226 | None => true, |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | #[cfg(test)] |
| 231 | mod tests { |
| 232 | use super::*; |
| 233 | |
| 234 | /// Types published on the bus that hooks are not offered. |
| 235 | const UNOFFERED: [&str; 28] = [ |
| 236 | "pull.mergecheck", |
| 237 | "pull.mergeability", |
| 238 | "deployment.review_requested", |
| 239 | "memory.changed", |
| 240 | "abuse.flagged", |
| 241 | "invite.created", |
| 242 | "invite.redeemed", |
| 243 | "waitlist.requested", |
| 244 | "user.updated", |
| 245 | "user.deleting", |
| 246 | "user.restored", |
| 247 | "user.deleted", |
| 248 | "user.email_added", |
| 249 | "workspace.updated", |
| 250 | "workspace.renamed", |
| 251 | "workspace.deleting", |
| 252 | "workspace.restored", |
| 253 | "workspace.deleted", |
| 254 | // Docs pages: no repository, so no repository's hooks. |
| 255 | "doc.page.created", |
| 256 | "doc.page.updated", |
| 257 | "doc.page.archived", |
| 258 | "doc.page.stale", |
| 259 | // Artifacts (folios): no repository either, and a folio may be private. |
| 260 | "folio.created", |
| 261 | "folio.updated", |
| 262 | "folio.trashed", |
| 263 | "folio.restored", |
| 264 | "folio.shared", |
| 265 | "folio.stale", |
| 266 | ]; |
| 267 | |
| 268 | fn published(kind: &str) -> bool { |
| 269 | crate::webhooks::EVENT_TYPES.contains(&kind) || UNOFFERED.contains(&kind) |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn every_exact_route_names_a_type_that_is_published() { |
| 274 | for (binding, patterns) in ROUTES { |
| 275 | for pattern in patterns.iter().chain(LIFECYCLE.iter()) { |
| 276 | if pattern.ends_with('*') { |
| 277 | let prefix = pattern.trim_end_matches('*'); |
| 278 | assert!( |
| 279 | prefix.is_empty() |
| 280 | || crate::webhooks::EVENT_TYPES.iter().chain(UNOFFERED.iter()).any(|kind| kind.starts_with(prefix)), |
| 281 | "{binding}: {pattern} matches nothing" |
| 282 | ); |
| 283 | } else { |
| 284 | assert!(published(pattern), "{binding}: {pattern} is not a type anything publishes"); |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn every_route_is_a_subscriber_binding_named_once() { |
| 292 | let mut names: Vec<&str> = ROUTES.iter().map(|(name, _)| *name).collect(); |
| 293 | assert!(names.iter().all(|name| name.starts_with("SUBSCRIBER_"))); |
| 294 | let count = names.len(); |
| 295 | names.sort_unstable(); |
| 296 | names.dedup(); |
| 297 | assert_eq!(names.len(), count); |
| 298 | assert_eq!(count, 15); |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn subscribers_hear_what_they_act_on_and_not_the_rest() { |
| 303 | assert!(routed("SUBSCRIBER_WEBHOOKS", "secret_scanning_alert.created")); |
| 304 | assert!(routed("SUBSCRIBER_WEBHOOKS", "anything.new")); |
| 305 | assert!(routed("SUBSCRIBER_ACTIONS", "pull.labeled")); |
| 306 | assert!(routed("SUBSCRIBER_ACTIONS", "release.published")); |
| 307 | assert!(!routed("SUBSCRIBER_ACTIONS", "check_run.completed")); |
| 308 | assert!(routed("SUBSCRIBER_RUNNER", "comment.created")); |
| 309 | assert!(!routed("SUBSCRIBER_RUNNER", "status.created")); |
| 310 | assert!(routed("SUBSCRIBER_BILLING", "workspace.renamed")); |
| 311 | assert!(!routed("SUBSCRIBER_BILLING", "git.push")); |
| 312 | assert!(routed("SUBSCRIBER_SEARCH", "user.updated")); |
| 313 | assert!(routed("SUBSCRIBER_REPOS", "user.deleting")); |
| 314 | assert!(!routed("SUBSCRIBER_REPOS", "git.push")); |
| 315 | assert!(routed("SUBSCRIBER_DOCS", "pull.merged")); |
| 316 | assert!(!routed("SUBSCRIBER_DOCS", "pull.opened")); |
| 317 | // Every subscriber follows what moves or removes a repository. |
| 318 | for (binding, _) in ROUTES { |
| 319 | for kind in LIFECYCLE { |
| 320 | assert!(routed(binding, kind), "{binding} misses {kind}"); |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | #[test] |
| 326 | fn a_subscriber_not_in_the_table_hears_everything() { |
| 327 | assert!(routed("SUBSCRIBER_NEW", "git.push")); |
| 328 | assert!(routed("SUBSCRIBER_NEW", "status.created")); |
| 329 | } |
| 330 | |
| 331 | #[test] |
| 332 | fn a_family_matches_only_its_own_types() { |
| 333 | assert!(matches("pull.*", "pull.opened")); |
| 334 | assert!(!matches("pull.*", "pulls.opened")); |
| 335 | assert!(!matches("pull.*", "pull")); |
| 336 | assert!(matches("*", "x")); |
| 337 | assert!(matches("git.push", "git.push")); |
| 338 | assert!(!matches("git.push", "git.pushed")); |
| 339 | } |
| 340 | } |