Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parentb9904f9Browse files
3 files+91−50/3 viewed
+10−0
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.%');
+46−4
9090 .filter(|slug| !slug.is_empty())
9191 }
9292
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+
93110 /// What is sent to check a webhook works.
94111 pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
95112 json!({
163180 let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()];
164181 assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]);
165182 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());
168187 }
169188
170189 #[test]
273292 assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
274293 assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
275294 // 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" }));
277296 other.repo_id = None;
278297 assert_eq!(workspace_scoped(&other), None);
279298 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
289308 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
290309 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
291310 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);
293335 }
294336
295337 #[test]
+35−1
5353 id: String,
5454 scope: String,
5555 workspace: String,
56+ #[serde(default)]
57+ repo_id: Option<String>,
5658 repo: Option<String>,
5759 url: String,
5860 events: String,
580582 {
581583 self.remember(id, namespace, name).await?;
582584 }
585+ if let Some((workspace, repo_ids)) = deliver::item_scoped(event) {
586+ return self.on_item_event(event, &workspace, &repo_ids).await;
587+ }
583588 let Some(repo_id) = event.repo_id.as_deref().or_else(|| event.data["repoId"].as_str()) else {
584589 // About no repository: a workspace's own package, for its
585590 // workspace's webhooks.
633638 self.send_all(event, hooks, Some((repo_id, name.as_ref()))).await
634639 }
635640
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+
636667 /// 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.
638670 async fn send_all(&self, event: &Event, hooks: Vec<HookRow>, repo: Option<(&str, Option<&NameRow>)>) -> Result<()> {
639671 let wanted: Vec<&HookRow> = hooks.iter().filter(|hook| deliver::wants(&hook.events(), &event.kind)).collect();
640672 if wanted.is_empty() {
650682 None => None,
651683 };
652684 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);
653687 let full_name = repo.map(|(_, name)| {
654688 hook.repo
655689 .clone()

Comments

Sign in to comment.