From 2a7aca0f861223ef663a0755e150aa5e11e1173f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:03:10 +0000 Subject: [PATCH 1/3] feat(events): Let event automations run for any resource An event automation trigger can now leave out the identifier and the resource type. It then runs for every resource of its namespace that its filters match, such as any Sentry metric alert for one project. Each plugin declares in `anyResourceEvents` which events can run this way, and the filter an event needs. GitHub issue, pull request, and release events need `repo`. Linear issues need `teamKey`. Sentry and Vercel events and GitHub deployments need no filter. Slack channel messages keep their identifier. - Match `identifier = event.identifier or identifier is null` and load lazy filters for rows with no identifier. - Count each event key once in the run limit, so many copies of one event do not block the automation. - Give guidance by namespace and event, and reject plugins whose resource types disagree. - Run the shared trigger check for the dashboard list instead of a SQL copy. - Allow a null identifier for event rows. Refs #2362 Co-Authored-By: David Cramer --- .../src/content/docs/concepts/automations.md | 9 + .../e2e/automation-editor-fixture.ts | 4 + .../e2e/automation-editor.spec.ts | 34 + .../automations/AutomationEventFields.tsx | 122 +- .../automations/AutomationVersionsPage.tsx | 6 +- .../junior-github/src/events/any-resource.ts | 29 + packages/junior-github/src/plugin.ts | 2 + packages/junior-linear/src/plugin.ts | 8 + .../junior-plugin-api/src/event-triggers.ts | 187 +- packages/junior-plugin-api/src/events.ts | 134 +- packages/junior-sentry/src/index.ts | 8 + packages/junior-vercel/src/index.ts | 6 + .../0064_any_resource_event_triggers.sql | 2 + .../junior/migrations/meta/0064_snapshot.json | 3505 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + packages/junior/src/api/schema/automation.ts | 12 +- .../junior/src/chat/automations/collection.ts | 104 +- .../src/chat/automations/edit-schema.ts | 5 +- .../junior/src/chat/automations/editor.ts | 8 +- packages/junior/src/chat/automations/read.ts | 4 +- .../junior/src/chat/event-automations/edit.ts | 18 +- .../src/chat/event-automations/ingest.ts | 27 +- .../src/chat/event-automations/run-limit.ts | 47 +- .../src/chat/event-automations/store.ts | 49 +- .../chat/event-automations/tool-support.ts | 70 +- .../src/chat/event-automations/types.ts | 27 +- .../junior/src/chat/events/app-publisher.ts | 8 +- packages/junior/src/chat/events/catalog.ts | 25 +- .../junior/src/chat/events/notification.ts | 9 +- .../src/chat/slack/channel-message-events.ts | 4 + .../src/chat/tools/create-event-automation.ts | 24 +- .../src/chat/tools/search-event-types.ts | 28 +- .../src/chat/tools/update-event-automation.ts | 8 +- packages/junior/src/db/schema/automations.ts | 7 +- .../event-automation-match-data.test.ts | 86 + .../any-resource-event-automations.test.ts | 164 + .../integration/api/automation-edits.test.ts | 37 + .../event-automation-blocking.test.ts | 63 + .../slack/channel-message-automations.test.ts | 39 +- .../junior/tests/unit/events/catalog.test.ts | 42 +- .../junior/tests/unit/events/match.test.ts | 154 + .../unit/sandbox/snapshot-events.test.ts | 2 - .../junior/tests/unit/tools/events.test.ts | 7 + 43 files changed, 4885 insertions(+), 256 deletions(-) create mode 100644 packages/junior-github/src/events/any-resource.ts create mode 100644 packages/junior/migrations/0064_any_resource_event_triggers.sql create mode 100644 packages/junior/migrations/meta/0064_snapshot.json create mode 100644 packages/junior/tests/component/events/event-automation-match-data.test.ts create mode 100644 packages/junior/tests/integration/any-resource-event-automations.test.ts diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md index dfe94e1842..41c00234a1 100644 --- a/packages/docs/src/content/docs/concepts/automations.md +++ b/packages/docs/src/content/docs/concepts/automations.md @@ -52,6 +52,15 @@ whenever a new issue is created in Linear team SRE, investigate it and comment f when someone posts a new message in this channel, answer it if you can, otherwise tag me ``` +An event automation can also run for every resource of a plugin, with no exact resource. Filters then narrow it: + +```text +whenever any Sentry metric alert for project javascript goes critical, summarize it here +whenever a pull request opens in getsentry/junior, review it +``` + +Each plugin decides which events can run this way. Some events need one filter. For example, GitHub issue, pull request, and release events need a `repo` filter, because they carry text from repositories that can be private. Junior names the missing filter when it cannot save the automation. One event runs the automation once, even when the plugin publishes the event for more than one resource, such as each project of a metric alert. Slack channel-message automations always need their own channel. + A Slack channel-message automation runs for each new top-level message in the channel where you create it. It does not run for thread replies or for messages that mention Junior. It runs for a bot message, such as a Sentry alert, only when the automation names that bot's Slack user ID in `match.userId`. Junior replies in the thread of the message. Junior must be a member of the channel. The automation can watch only its own channel, and it always runs with Junior's own credentials, because anyone in the channel can write the message. Tell the channel that Junior reads every new message there. If the plugin is disabled, the automation remains visible but cannot receive events until a compatible plugin is enabled again. Each plugin page lists the resources and events it can publish. diff --git a/packages/junior-dashboard/e2e/automation-editor-fixture.ts b/packages/junior-dashboard/e2e/automation-editor-fixture.ts index 5fbada03dd..2330afa300 100644 --- a/packages/junior-dashboard/e2e/automation-editor-fixture.ts +++ b/packages/junior-dashboard/e2e/automation-editor-fixture.ts @@ -100,6 +100,10 @@ export async function mockAutomationEditor( events: ["issue.closed"], }, }, + anyResourceEvents: { + "issue.closed": { requiredFilter: "state" }, + "issue.opened": {}, + }, }, ], }), diff --git a/packages/junior-dashboard/e2e/automation-editor.spec.ts b/packages/junior-dashboard/e2e/automation-editor.spec.ts index 1bd8f4de7b..89be7b3b0d 100644 --- a/packages/junior-dashboard/e2e/automation-editor.spec.ts +++ b/packages/junior-dashboard/e2e/automation-editor.spec.ts @@ -165,6 +165,40 @@ test("keeps Event edits through validation, failed saves, and concurrent changes ]); }); +test("replaces an Event trigger with one for any resource", async ({ + page, + dashboard, +}) => { + const state = await mockAutomationEditor(page, "event"); + await page.goto(`${dashboard.baseURL}/automations/event-1/edit`); + await page.getByLabel("Resource type").selectOption("github:*"); + await page.getByRole("button", { name: "Replace trigger" }).click(); + await expect(page.getByLabel("Resource identifier")).toHaveCount(0); + await page.getByLabel("Resource name").fill("Closed issues"); + await page.getByRole("checkbox", { name: /^issue closed/ }).check(); + // The shared check names the filter that this event needs. + await expect( + page.getByText('This trigger needs a "state" filter'), + ).toBeVisible(); + await page.getByLabel("state", { exact: true }).selectOption("closed"); + await expect( + page.getByText('This trigger needs a "state" filter'), + ).toHaveCount(0); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page).toHaveURL(`${dashboard.baseURL}/automations/list`); + expect(state.writes).toEqual([ + expect.objectContaining({ + kind: "event", + trigger: { + namespace: "github", + label: "Closed issues", + events: ["issue.closed"], + match: { state: "closed" }, + }, + }), + ]); +}); + test("public non-creators edit settings without creator-only controls", async ({ page, dashboard, diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx index c67438890f..9d080c84a9 100644 --- a/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx +++ b/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx @@ -2,10 +2,12 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { describeEventTrigger, + eventTriggerChoices, eventMatchFilterText, eventTriggerProblems, eventTypeText, eventsWithoutMatchField, + type EventTriggerSource, } from "@sentry/junior-plugin-api"; import { automationEventCatalogSchema, @@ -36,6 +38,9 @@ import { fetchDashboardJson } from "../../http"; type Trigger = Extract["trigger"]; +// Select value for a trigger with no identifier, which runs for any resource. +const ANY_RESOURCE = "*"; + /** Render registered Event choices without discarding unsupported saved selectors. */ export function AutomationEventFields(props: { value: Trigger; @@ -58,17 +63,33 @@ export function AutomationEventFields(props: { const sourceResourceTypes = catalog.data?.filter((item) => item.namespace === props.value.namespace) ?? []; - const resource = sourceResourceTypes.find( - (item) => item.type === props.value.resourceType, - ); + const source: EventTriggerSource = { + resourceTypes: sourceResourceTypes, + anyResourceEvents: sourceResourceTypes[0]?.anyResourceEvents, + }; + const anyResource = props.value.resourceType === undefined; + const choices = eventTriggerChoices(source, props.value); const problems = catalog.data - ? eventTriggerProblems(sourceResourceTypes, props.value) + ? eventTriggerProblems(source, props.value) : []; - // A filter that a selected event does not set is fixable here. Other - // problems mean the catalog changed after the trigger was saved. + // A filter that a selected event does not set, or a filter that an event + // needs, is fixable here. Other problems mean the catalog changed after the + // trigger was saved. const unsupported = - !resource || problems.some((problem) => problem.reason !== "field_not_set"); - const matchFields = Object.entries(resource?.matchFields ?? {}); + !choices || + problems.some( + (problem) => + problem.reason !== "field_not_set" && + problem.reason !== "filter_required", + ); + const anyResourceNamespaces = [ + ...new Set( + catalog.data + ?.filter((item) => item.anyResourceEvents) + .map((item) => item.namespace), + ), + ]; + const matchFields = Object.entries(choices?.fields ?? {}); const shownMatchFields = matchFields.filter( ([key, field]) => props.value.match?.[key] !== undefined || @@ -97,10 +118,7 @@ export function AutomationEventFields(props: { Some saved values are not in the current catalog. You can edit other fields, or select a new resource type to replace the trigger. - + ) : null} @@ -112,9 +130,14 @@ export function AutomationEventFields(props: { value={ unsupported ? "" - : `${props.value.namespace}:${props.value.resourceType}` + : `${props.value.namespace}:${props.value.resourceType ?? ANY_RESOURCE}` } onChange={(e) => { + const [namespace, type] = e.target.value.split(":"); + if (type === ANY_RESOURCE && namespace) { + setReplacement({ namespace, label: "", events: [] }); + return; + } const next = catalog.data?.find( (item) => `${item.namespace}:${item.type}` === e.target.value, ); @@ -131,6 +154,14 @@ export function AutomationEventFields(props: { + {anyResourceNamespaces.map((namespace) => ( + + ))} {catalog.data?.map((item) => (