Webhooks hear of work items in every repository they link
A work item's events, and comments on it, go to its workspace's webhooks and to those of every repository it is linked to, each told of its own repository. Webhooks that asked for issue.* events ask for the work_item.* events that replaced them.
- G1T-Session:
- ses_01m566357hejh9yeqjbetx23f3
3 files+91−50/3 viewed
| 1 | + | -- Issues became work items (g1t #57): a webhook that asked for issue.* | |
| 2 | + | -- events asks for the work_item.* events that replaced them, in the same | |
| 3 | + | -- place in its list. Deliveries already made stay as they were sent; they | |
| 4 | + | -- are forgotten after a fortnight. | |
| 5 | + | UPDATE hooks | |
| 6 | + | SET events = ( | |
| 7 | + | SELECT json_group_array(CASE WHEN value LIKE 'issue.%' THEN 'work_item.' || substr(value, 7) ELSE value END) | |
| 8 | + | FROM json_each(hooks.events) | |
| 9 | + | ) | |
| 10 | + | WHERE EXISTS (SELECT 1 FROM json_each(hooks.events) WHERE value LIKE 'issue.%'); |
| 90 | 90 | .filter(|slug| !slug.is_empty()) | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | + | /// For an event about a work item (`work_item.*`, or a comment on one): | |
| 94 | + | /// its workspace, and every repository it is linked to. It goes to the | |
| 95 | + | /// workspace's webhooks and to those of each of those repositories. | |
| 96 | + | pub fn item_scoped(event: &Event) -> Option<(String, Vec<String>)> { | |
| 97 | + | let about_item = event.kind.starts_with("work_item.") | |
| 98 | + | || (event.kind.starts_with("comment.") && event.data["itemId"].as_str().is_some_and(|id| !id.is_empty())); | |
| 99 | + | if !about_item { | |
| 100 | + | return None; | |
| 101 | + | } | |
| 102 | + | let workspace = event.data["workspace"].as_str().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty())?; | |
| 103 | + | let repo_ids = event.data["repoIds"] | |
| 104 | + | .as_array() | |
| 105 | + | .map(|ids| ids.iter().filter_map(Value::as_str).filter(|id| !id.is_empty()).map(str::to_owned).collect()) | |
| 106 | + | .unwrap_or_default(); | |
| 107 | + | Some((workspace, repo_ids)) | |
| 108 | + | } | |
| 109 | + | ||
| 93 | 110 | /// What is sent to check a webhook works. | |
| 94 | 111 | pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value { | |
| 95 | 112 | json!({ | |
| ⋯ | |||
| 163 | 180 | let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()]; | |
| 164 | 181 | assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]); | |
| 165 | 182 | assert!(tidy_events(&["agent.asked".to_owned()]).is_err()); | |
| 166 | − | assert!(wants(&["*".to_owned()], "issue.opened")); | |
| 167 | − | assert!(!wants(&["git.push".to_owned()], "issue.opened")); | |
| 183 | + | assert!(wants(&["*".to_owned()], "work_item.opened")); | |
| 184 | + | assert!(!wants(&["git.push".to_owned()], "work_item.opened")); | |
| 185 | + | // Issues became work items: their old names are no events. | |
| 186 | + | assert!(tidy_events(&["issue.opened".to_owned()]).is_err()); | |
| 168 | 187 | } | |
| 169 | 188 | ||
| 170 | 189 | #[test] | |
| ⋯ | |||
| 273 | 292 | assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme")); | |
| 274 | 293 | assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent"); | |
| 275 | 294 | // Other events without a repository are not workspace events. | |
| 276 | − | let mut other = repo_event("issue.opened", json!({ "workspace": "acme" })); | |
| 295 | + | let mut other = repo_event("pull.opened", json!({ "workspace": "acme" })); | |
| 277 | 296 | other.repo_id = None; | |
| 278 | 297 | assert_eq!(workspace_scoped(&other), None); | |
| 279 | 298 | let mut nameless = repo_event("package.deleted", json!({ "workspace": "" })); | |
| ⋯ | |||
| 289 | 308 | assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web")); | |
| 290 | 309 | assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web")); | |
| 291 | 310 | assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None); | |
| 292 | − | assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None); | |
| 311 | + | assert_eq!(named("work_item.opened", json!({ "namespace": "acme", "name": "web" })), None); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[test] | |
| 315 | + | fn work_item_events_go_to_its_workspace_and_every_linked_repository() { | |
| 316 | + | let opened = repo_event( | |
| 317 | + | "work_item.opened", | |
| 318 | + | json!({ "itemId": "wki_1", "workspace": "Acme", "number": 12, "repoIds": ["rep_1", "rep_2"] }), | |
| 319 | + | ); | |
| 320 | + | assert_eq!(item_scoped(&opened), Some(("acme".to_owned(), vec!["rep_1".to_owned(), "rep_2".to_owned()]))); | |
| 321 | + | // Filed in the workspace, linked nowhere yet. | |
| 322 | + | let mut alone = repo_event("work_item.opened", json!({ "itemId": "wki_2", "workspace": "acme", "number": 13 })); | |
| 323 | + | alone.repo_id = None; | |
| 324 | + | assert_eq!(item_scoped(&alone), Some(("acme".to_owned(), vec![]))); | |
| 325 | + | // A comment on a work item goes as the work item does; one on a | |
| 326 | + | // pull request goes by its repository. | |
| 327 | + | let commented = repo_event( | |
| 328 | + | "comment.created", | |
| 329 | + | json!({ "commentId": "cmt_1", "number": 12, "itemId": "wki_1", "workspace": "acme", "repoIds": ["rep_2"] }), | |
| 330 | + | ); | |
| 331 | + | assert_eq!(item_scoped(&commented), Some(("acme".to_owned(), vec!["rep_2".to_owned()]))); | |
| 332 | + | let on_pull = repo_event("comment.created", json!({ "commentId": "cmt_2", "repoId": "rep_1", "number": 3, "pullId": "pul_1" })); | |
| 333 | + | assert_eq!(item_scoped(&on_pull), None); | |
| 334 | + | assert_eq!(item_scoped(&repo_event("pull.opened", json!({ "workspace": "acme" }))), None); | |
| 293 | 335 | } | |
| 294 | 336 | ||
| 295 | 337 | #[test] | |
| 53 | 53 | id: String, | |
| 54 | 54 | scope: String, | |
| 55 | 55 | workspace: String, | |
| 56 | + | #[serde(default)] | |
| 57 | + | repo_id: Option<String>, | |
| 56 | 58 | repo: Option<String>, | |
| 57 | 59 | url: String, | |
| 58 | 60 | events: String, | |
| ⋯ | |||
| 580 | 582 | { | |
| 581 | 583 | self.remember(id, namespace, name).await?; | |
| 582 | 584 | } | |
| 585 | + | if let Some((workspace, repo_ids)) = deliver::item_scoped(event) { | |
| 586 | + | return self.on_item_event(event, &workspace, &repo_ids).await; | |
| 587 | + | } | |
| 583 | 588 | let Some(repo_id) = event.repo_id.as_deref().or_else(|| event.data["repoId"].as_str()) else { | |
| 584 | 589 | // About no repository: a workspace's own package, for its | |
| 585 | 590 | // workspace's webhooks. | |
| ⋯ | |||
| 633 | 638 | self.send_all(event, hooks, Some((repo_id, name.as_ref()))).await | |
| 634 | 639 | } | |
| 635 | 640 | ||
| 641 | + | /// An event about a work item: to its workspace's webhooks, and to | |
| 642 | + | /// those of every repository it is linked to. Each repository's | |
| 643 | + | /// webhooks are told of their own repository; the workspace's, of | |
| 644 | + | /// where it was filed, when it was filed in one. | |
| 645 | + | async fn on_item_event(&self, event: &Event, workspace: &str, repo_ids: &[String]) -> Result<()> { | |
| 646 | + | let hooks = self | |
| 647 | + | .db | |
| 648 | + | .prepare( | |
| 649 | + | "SELECT * FROM hooks WHERE active = 1 | |
| 650 | + | AND ((scope = 'workspace' AND workspace = ?1) OR (scope = 'repo' AND repo_id IN (SELECT value FROM json_each(?2))))", | |
| 651 | + | ) | |
| 652 | + | .bind(&[workspace.into(), serde_json::to_string(repo_ids)?.into()])? | |
| 653 | + | .all() | |
| 654 | + | .await? | |
| 655 | + | .results::<HookRow>()?; | |
| 656 | + | if hooks.is_empty() { | |
| 657 | + | return Ok(()); | |
| 658 | + | } | |
| 659 | + | let origin = event.repo_id.as_deref().filter(|id| repo_ids.iter().any(|linked| linked == id)); | |
| 660 | + | let name = match origin { | |
| 661 | + | Some(id) => self.repo_name(id, &[workspace.to_owned()]).await?, | |
| 662 | + | None => None, | |
| 663 | + | }; | |
| 664 | + | self.send_all(event, hooks, origin.map(|id| (id, name.as_ref()))).await | |
| 665 | + | } | |
| 666 | + | ||
| 636 | 667 | /// Sends `event` to each of `hooks` that wants it. `repo` is the | |
| 637 | − | /// repository it is about, and its name when known. | |
| 668 | + | /// repository it is about, and its name when known; a repository's | |
| 669 | + | /// webhook is told of its own. | |
| 638 | 670 | async fn send_all(&self, event: &Event, hooks: Vec<HookRow>, repo: Option<(&str, Option<&NameRow>)>) -> Result<()> { | |
| 639 | 671 | let wanted: Vec<&HookRow> = hooks.iter().filter(|hook| deliver::wants(&hook.events(), &event.kind)).collect(); | |
| 640 | 672 | if wanted.is_empty() { | |
| ⋯ | |||
| 650 | 682 | None => None, | |
| 651 | 683 | }; | |
| 652 | 684 | for hook in wanted { | |
| 685 | + | let own = hook.repo_id.as_deref().filter(|_| hook.scope == "repo").map(|id| (id, None)); | |
| 686 | + | let repo = own.or(repo); | |
| 653 | 687 | let full_name = repo.map(|(_, name)| { | |
| 654 | 688 | hook.repo | |
| 655 | 689 | .clone() | |
Comments
Sign in to comment.