From 89783a006973ac27d0106c893afaf16b7ca8e987 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:25:40 +0000 Subject: [PATCH] feat(events): Convert saved event automation triggers in the heartbeat Each heartbeat converts up to 50 saved triggers that still have an identifier, under the row lock, and records a version that names Junior. A row that cannot convert keeps its identifier and saves the reason. A dashboard edit that read the row before the conversion fails as a conflict that says Junior changed the trigger. Merge only after a release with #2407 and #2442 has run in production, because older releases cannot read rows with no identifier. Refs #2362 Co-Authored-By: David Cramer --- .../src/content/docs/concepts/automations.md | 2 +- packages/junior-dashboard/src/client/http.ts | 13 +- .../pages/automations/AutomationEditor.tsx | 24 ++- packages/junior/src/api/automations/routes.ts | 6 +- packages/junior/src/api/schema/automation.ts | 2 + .../src/chat/agent-dispatch/heartbeat.ts | 10 ++ packages/junior/src/chat/automations/edit.ts | 43 ++++- .../junior/src/chat/automations/revision.ts | 13 +- .../junior/src/chat/automations/versions.ts | 7 +- .../src/chat/event-automations/README.md | 18 +- .../src/chat/event-automations/store.ts | 55 +++++- .../event-automations/trigger-conversion.ts | 54 ++++++ .../any-resource-event-automations.test.ts | 157 +++++++++++++++++- 13 files changed, 376 insertions(+), 28 deletions(-) diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md index 836a3f7149..32c2dbdbf1 100644 --- a/packages/docs/src/content/docs/concepts/automations.md +++ b/packages/docs/src/content/docs/concepts/automations.md @@ -61,7 +61,7 @@ whenever a pull request opens in getsentry/junior, review it Each plugin decides which events can run this way, and each plugin page lists them. Some events need a filter. For example, GitHub issue, pull request, and release events need a `repo` filter, and Linear issue events need a `teamKey` or `issueIdentifier` filter. Junior names the missing filter when it cannot save the automation. Junior saves filter values in the case that event data uses, so `getsentry/Junior` matches `getsentry/junior`. 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. -When you name one exact resource, Junior saves it as the filters that select the same events. For example, one pull request becomes `repo` and `pullRequest` filters. An automation keeps its exact resource when filters would miss events or match more events, and its details say why. Junior does not save a trigger whose filter can never match its resource. When you update a trigger, `match` changes only the filters that it names: a value sets a filter, `null` removes it, and other filters keep their saved values. +When you name one exact resource, Junior saves it as the filters that select the same events. For example, one pull request becomes `repo` and `pullRequest` filters. Junior changes older automations the same way, and each change saves a new version that names Junior as the editor. An automation keeps its exact resource when filters would miss events or match more events, and its details say why. Junior does not save a trigger whose filter can never match its resource. When you update a trigger, `match` changes only the filters that it names: a value sets a filter, `null` removes it, and other filters keep their saved values. A required filter limits how much an automation covers and how noisy it is. It is not access control. Junior does not check that the person who creates the automation can see the repository, project, or team in the filter. An automation with no exact resource posts what it receives to its destination channel. For example, a broad Sentry trigger or GitHub deployment trigger can post private project or repository names in that channel. Create broad automations only in channels where everyone can see those resources. diff --git a/packages/junior-dashboard/src/client/http.ts b/packages/junior-dashboard/src/client/http.ts index 72c761cdaa..e25152a5c6 100644 --- a/packages/junior-dashboard/src/client/http.ts +++ b/packages/junior-dashboard/src/client/http.ts @@ -6,18 +6,25 @@ export class DashboardApiError extends Error { readonly status: number; readonly fields?: Record; readonly code?: string; + /** Who made the newer change on a conflict, when the API knows. */ + readonly changedBy?: string; readonly apiError?: string; constructor( path: string, status: number, apiError?: string, - detail?: { fields?: Record; code?: string }, + detail?: { + fields?: Record; + code?: string; + changedBy?: string; + }, ) { super(`${path} returned ${status}`); this.status = status; this.fields = detail?.fields; this.code = detail?.code; + this.changedBy = detail?.changedBy; if (apiError?.trim()) this.apiError = apiError.trim(); } } @@ -29,14 +36,17 @@ async function throwDashboardApiError( let apiError: string | undefined; let fields: Record | undefined; let code: string | undefined; + let changedBy: string | undefined; try { const body = (await response.json()) as { error?: unknown; code?: unknown; fields?: unknown; + changedBy?: unknown; }; if (typeof body.error === "string") apiError = body.error; if (typeof body.code === "string") code = body.code; + if (typeof body.changedBy === "string") changedBy = body.changedBy; if (body.fields && typeof body.fields === "object") { fields = Object.fromEntries( Object.entries(body.fields).filter( @@ -52,6 +62,7 @@ async function throwDashboardApiError( throw new DashboardApiError(path, response.status, apiError, { fields, code, + changedBy, }); } diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationEditor.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationEditor.tsx index 85925127f4..70fbe70196 100644 --- a/packages/junior-dashboard/src/client/pages/automations/AutomationEditor.tsx +++ b/packages/junior-dashboard/src/client/pages/automations/AutomationEditor.tsx @@ -114,6 +114,10 @@ export function AutomationEditor(props: { }); const conflict = save.error instanceof DashboardApiError && save.error.code === "conflict"; + const changedByJunior = + conflict && + save.error instanceof DashboardApiError && + save.error.changedBy === "junior"; const change = (value: Partial) => setDraft((current) => ({ ...current, ...value })); const fieldError = (key: string) => @@ -171,18 +175,22 @@ export function AutomationEditor(props: {

Your edits are still here.{" "} - {conflict - ? "Review the latest version before saving again." - : save.error instanceof DashboardApiError - ? save.error.apiError - : "Check the fields below, or try again."} + {changedByJunior + ? "It now uses filters that select the same events. Review the latest version before saving again." + : conflict + ? "Review the latest version before saving again." + : save.error instanceof DashboardApiError + ? save.error.apiError + : "Check the fields below, or try again."}

{conflict ? (