diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md index 59e98f7a3f..9a813ef3db 100644 --- a/packages/docs/src/content/docs/concepts/automations.md +++ b/packages/docs/src/content/docs/concepts/automations.md @@ -52,14 +52,16 @@ 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: +An event automation selects its events with filters. Filters such as `repo` and `pullRequest` select one resource. Fewer filters run it for more resources: ```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, 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. +Automations that were saved with an exact resource keep it. Junior does not change it when you edit them. + +Each plugin decides which events can run for every resource, 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. 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/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index 959ffc079d..c3f8cd71d2 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -326,8 +326,10 @@ repository automation to one environment or one workflow. For example, an automation on `getsentry/toolkit` for `deployment.failed` can match `environment: "production"` and `workflowPath: ".github/workflows/deploy.yml"`. -An event automation can also run for every repository, with no identifier. -Deployment events (`deployment.created`, `deployment.queued`, +An event automation selects one item with filters instead: `repo` and +`issue` or `pullRequest`, `repo` and `tag`, or `repo`, `commitSha`, and +`environment`. Lookup tools return these filters as `match`. With fewer filters, +it runs for more items. Deployment events (`deployment.created`, `deployment.queued`, `deployment.pending`, `deployment.in_progress`, `deployment.succeeded`, `deployment.failed`, `deployment.error`) can run that way with no filter. Release, issue, and pull request events need a `repo` filter, because they diff --git a/packages/docs/src/content/docs/extend/linear-plugin.md b/packages/docs/src/content/docs/extend/linear-plugin.md index bb79feba06..ff03822a65 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -185,14 +185,14 @@ Create the watch or event automation before the issue arrives. Junior does not r Identifiers are normalized to uppercase. Prefer team-scoped event automations for monitor workflows that create many new issues. -An event automation can also run for every team, with no identifier. -`issue.created` can run that way only with a `teamKey` or an +An event automation selects a team or an issue with filters instead of an +identifier. `issue.created` needs a `teamKey` or an `issueIdentifier` filter, because an issue carries its title and description and a team can be private. Junior saves both in uppercase. The filter does not check that the creator can see the team. See [Automations](/concepts/automations/). -Optional `match` values come from the resource type. For Linear issue and team events, `teamKey` is the uppercase team key such as `SRE`. Junior drops events that do not match before it wakes the agent. Prefer a team identifier for “all new issues in SRE”. Use `match.teamKey` when an issue-scoped watch or event automation needs an extra team guard. +Optional `match` values come from the resource type. For Linear issue and team events, `teamKey` is the uppercase team key such as `SRE`. Junior drops events that do not match before it wakes the agent. For “all new issues in SRE”, a watch uses the team identifier and an event automation uses `match.teamKey`. ## Verify diff --git a/packages/docs/src/content/docs/extend/sentry-plugin.md b/packages/docs/src/content/docs/extend/sentry-plugin.md index b520d4df24..d6be0d7e45 100644 --- a/packages/docs/src/content/docs/extend/sentry-plugin.md +++ b/packages/docs/src/content/docs/extend/sentry-plugin.md @@ -230,8 +230,8 @@ The alert resolved. Junior publishes each state once for each alert incident. -An event automation can also run for every project in the Sentry org, with no -identifier. `issue.created`, `metric_alert.critical`, `metric_alert.warning`, +An event automation selects Sentry events with filters instead of an +identifier, and can run for every project in the Sentry org. `issue.created`, `metric_alert.critical`, `metric_alert.warning`, and `metric_alert.resolved` can run that way with no filter. Use the `projects` filter to limit one to some projects. Junior saves project slugs in lowercase. Junior does not check that the creator can see the projects, so a diff --git a/packages/docs/src/content/docs/extend/vercel-plugin.md b/packages/docs/src/content/docs/extend/vercel-plugin.md index 1e98d6bb65..7314555282 100644 --- a/packages/docs/src/content/docs/extend/vercel-plugin.md +++ b/packages/docs/src/content/docs/extend/vercel-plugin.md @@ -154,9 +154,9 @@ The deployment was canceled. -An event automation can also run for every project, with no identifier. +An event automation selects deployments with filters instead of an identifier. `deployment.succeeded`, `deployment.error`, and `deployment.canceled` can run -that way with no filter. Use the `projectId` or `target` filter to limit one. +for every project with no filter. Use the `projectId` or `target` filter to limit one. Junior does not check that the creator can see the projects, so a broad automation can post private project names in its channel. See [Automations](/concepts/automations/). diff --git a/packages/junior-evals/evals/guardian/scheduled-work.eval.ts b/packages/junior-evals/evals/guardian/scheduled-work.eval.ts index 497489cde8..4174e3c2ba 100644 --- a/packages/junior-evals/evals/guardian/scheduled-work.eval.ts +++ b/packages/junior-evals/evals/guardian/scheduled-work.eval.ts @@ -204,8 +204,6 @@ describeEval("Guardian Scheduled Work Snapshots", guardianEvals, (it) => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#208", - resourceType: "issue", label: "GitHub issue getsentry/junior#208", events: ["issue.reopened"], }, @@ -232,10 +230,9 @@ describeEval("Guardian Scheduled Work Snapshots", guardianEvals, (it) => { trigger: { type: "event", namespace: "github", - resourceType: "pull_request", - identifier: "getsentry/junior#691", label: "getsentry/junior#691", events: ["pull_request.review.changes_requested"], + match: { repo: "getsentry/junior", pullRequest: 691 }, }, outcomes: [ { action: "send_message", destination: "current_conversation" }, diff --git a/packages/junior-evals/evals/integration/coding/event-automations/credentials.eval.ts b/packages/junior-evals/evals/integration/coding/event-automations/credentials.eval.ts index 7b6edaad90..7e1572af5d 100644 --- a/packages/junior-evals/evals/integration/coding/event-automations/credentials.eval.ts +++ b/packages/junior-evals/evals/integration/coding/event-automations/credentials.eval.ts @@ -66,7 +66,7 @@ describe("Event automation credentials", () => { type: "event", events: ["pull_request.review.changes_requested"], namespace: "github", - identifier: "getsentry/junior#691", + match: { repo: "getsentry/junior", pullRequest: 691 }, }, }); expect([undefined, "creator"]).toContain(credentialMode(creates[0])); diff --git a/packages/junior-evals/evals/integration/coding/event-automations/management.eval.ts b/packages/junior-evals/evals/integration/coding/event-automations/management.eval.ts index 4059a87cb4..1ba2499ca7 100644 --- a/packages/junior-evals/evals/integration/coding/event-automations/management.eval.ts +++ b/packages/junior-evals/evals/integration/coding/event-automations/management.eval.ts @@ -106,9 +106,8 @@ describe("Event automation management", () => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#691", - resourceType: "pull_request", events: ["pull_request.review.changes_requested"], + match: { repo: "getsentry/junior", pullRequest: 691 }, }, }); const names = conversation.toolCalls.map((call) => call.name); @@ -143,8 +142,7 @@ describe("Event automation management", () => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#208", - resourceType: "issue", + match: { repo: "getsentry/junior", issue: 208 }, }, }); expect(new Set(triggerEvents(creates[0]!))).toEqual( @@ -180,13 +178,18 @@ describe("Event automation management", () => { const creates = completedToolCalls("createAutomation", conversation); expect(creates).toHaveLength(1); expect(creates[0]!.input).toMatchObject({ - trigger: { - type: "event", - namespace: "github", - identifier: "getsentry/junior", - resourceType: "repository", - }, + trigger: { type: "event", namespace: "github" }, + }); + expect(toolOutput(creates[0]!)).toMatchObject({ + automation: { trigger: { match: { repo: "getsentry/junior" } } }, }); + expect( + ( + toolOutput(creates[0]!) as { + automation: { trigger: { match: object } }; + } + ).automation.trigger.match, + ).not.toHaveProperty("issue"); expect(new Set(triggerEvents(creates[0]!))).toEqual( new Set(["issue.closed", "issue.reopened"]), ); @@ -272,7 +275,7 @@ describe("Event automation management", () => { automationId: id, trigger: { type: "event", events: ["issue.reopened"] }, }); - // The update can omit identifier and resourceType to keep the saved ones. + // A saved trigger with an identifier keeps it when the tools edit it. expect(toolOutput(updates[0]!)).toMatchObject({ automation: { id, diff --git a/packages/junior-github/src/events/deployment.ts b/packages/junior-github/src/events/deployment.ts index 8d6fd861a6..bc196a6798 100644 --- a/packages/junior-github/src/events/deployment.ts +++ b/packages/junior-github/src/events/deployment.ts @@ -2,7 +2,12 @@ import { eventMatchFieldsSchema, type SubscribableResource, } from "@sentry/junior-plugin-api"; -import { gitHubCommitMatchFields, gitHubRepoMatchFields } from "./scope.js"; +import { + gitHubCommitMatchFields, + gitHubCommitShaDataSchema, + gitHubRepoDataSchema, + gitHubRepoMatchFields, +} from "./scope.js"; export const GITHUB_DEPLOYMENT_EVENTS = [ "deployment.created", @@ -89,8 +94,14 @@ export function gitHubDeploymentSourceSubscribable(input: { repo: string; }): SubscribableResource | undefined { if (!input.canSubscribe) return undefined; + const environment = input.environment?.trim().toLowerCase(); return { ...gitHubDeploymentSourceResource(input), + match: { + repo: gitHubRepoDataSchema.parse(input.repo), + commitSha: gitHubCommitShaDataSchema.parse(input.commitSha), + ...(environment ? { environment } : undefined), + }, suggestedEvents: GITHUB_DEPLOYMENT_SUGGESTED_EVENTS, supportedEvents: [...GITHUB_DEPLOYMENT_EVENTS], type: "deployment_source", diff --git a/packages/junior-github/src/events/issue.ts b/packages/junior-github/src/events/issue.ts index ffe678c3e9..42bdf861dc 100644 --- a/packages/junior-github/src/events/issue.ts +++ b/packages/junior-github/src/events/issue.ts @@ -3,7 +3,7 @@ import { type SubscribableResource, } from "@sentry/junior-plugin-api"; import { gitHubCommentMatchFields } from "./comment.js"; -import { gitHubRepoMatchFields } from "./scope.js"; +import { gitHubRepoDataSchema, gitHubRepoMatchFields } from "./scope.js"; export const GITHUB_ISSUE_EVENTS = [ "issue.comment.created", @@ -63,6 +63,10 @@ export function gitHubIssueSubscribable(input: { if (!input.canSubscribe) return undefined; return { ...gitHubIssueResource(input), + match: { + repo: gitHubRepoDataSchema.parse(input.repo), + issue: input.number, + }, suggestedEvents: GITHUB_ISSUE_SUGGESTED_EVENTS, supportedEvents: GITHUB_ISSUE_EVENTS, type: "issue", diff --git a/packages/junior-github/src/events/pull-request.ts b/packages/junior-github/src/events/pull-request.ts index 5f719e7cb3..d82f24b2e8 100644 --- a/packages/junior-github/src/events/pull-request.ts +++ b/packages/junior-github/src/events/pull-request.ts @@ -3,7 +3,11 @@ import { type SubscribableResource, } from "@sentry/junior-plugin-api"; import { gitHubCommentMatchFields } from "./comment.js"; -import { gitHubCommitMatchFields, gitHubRepoMatchFields } from "./scope.js"; +import { + gitHubCommitMatchFields, + gitHubRepoDataSchema, + gitHubRepoMatchFields, +} from "./scope.js"; export const GITHUB_PULL_REQUEST_EVENTS = [ "pull_request.checks.failed", @@ -230,6 +234,10 @@ export function gitHubPullRequestSubscribable(input: { ); return { ...gitHubPullRequestResource(input), + match: { + repo: gitHubRepoDataSchema.parse(input.repo), + pullRequest: input.number, + }, ...(suggestedEvents.length > 0 ? { suggestedEvents: [...suggestedEvents] } : undefined), diff --git a/packages/junior-github/src/events/release.ts b/packages/junior-github/src/events/release.ts index b802075738..fe6ddb970e 100644 --- a/packages/junior-github/src/events/release.ts +++ b/packages/junior-github/src/events/release.ts @@ -3,7 +3,7 @@ import { type SubscribableResource, } from "@sentry/junior-plugin-api"; import { z } from "zod"; -import { gitHubRepoMatchFields } from "./scope.js"; +import { gitHubRepoDataSchema, gitHubRepoMatchFields } from "./scope.js"; export const GITHUB_RELEASE_EVENTS = ["release.published"] as const; @@ -69,8 +69,13 @@ export function gitHubReleaseSourceSubscribable(input: { tag?: string; }): SubscribableResource | undefined { if (!input.canSubscribe) return undefined; + const tag = input.tag?.trim(); return { ...gitHubReleaseSourceResource(input), + match: { + repo: gitHubRepoDataSchema.parse(input.repo), + ...(tag ? { tag: gitHubReleaseTagDataSchema.parse(tag) } : undefined), + }, suggestedEvents: GITHUB_RELEASE_SUGGESTED_EVENTS, supportedEvents: [...GITHUB_RELEASE_EVENTS], type: "release_source", diff --git a/packages/junior-github/src/events/repository.ts b/packages/junior-github/src/events/repository.ts index cc44960f0d..fa2024774c 100644 --- a/packages/junior-github/src/events/repository.ts +++ b/packages/junior-github/src/events/repository.ts @@ -1,4 +1,5 @@ import type { SubscribableResource } from "@sentry/junior-plugin-api"; +import { gitHubRepoDataSchema } from "./scope.js"; import { GITHUB_DEPLOYMENT_EVENTS } from "./deployment.js"; import { GITHUB_ISSUE_EVENTS, GITHUB_ISSUE_SUGGESTED_EVENTS } from "./issue.js"; import { @@ -50,6 +51,7 @@ export function gitHubRepositorySubscribable(input: { if (!input.canSubscribe) return undefined; return { ...gitHubRepositoryResource(input), + match: { repo: gitHubRepoDataSchema.parse(input.repo) }, suggestedEvents: [...GITHUB_REPOSITORY_SUGGESTED_EVENTS], supportedEvents: [...GITHUB_REPOSITORY_EVENTS], type: "repository", diff --git a/packages/junior-github/tests/event-data.test.ts b/packages/junior-github/tests/event-data.test.ts index 3ed1bda1e3..5fd709553d 100644 --- a/packages/junior-github/tests/event-data.test.ts +++ b/packages/junior-github/tests/event-data.test.ts @@ -9,6 +9,11 @@ import { } from "@sentry/junior-testing/event-filters"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { githubPlugin } from "../src/index"; +import { gitHubDeploymentSourceSubscribable } from "../src/events/deployment"; +import { gitHubIssueSubscribable } from "../src/events/issue"; +import { gitHubPullRequestSubscribable } from "../src/events/pull-request"; +import { gitHubReleaseSourceSubscribable } from "../src/events/release"; +import { gitHubRepositorySubscribable } from "../src/events/repository"; import type { GitHubDb } from "../src/db/database"; import { gitHubMergeConflictEvents } from "../src/webhooks/merge-conflict"; import { normalizeGitHubEvents } from "../src/webhooks/events"; @@ -316,6 +321,59 @@ function gitHubIdentifierMatch(event: { : undefined; } +/** Scopes that differ from `BASE_SCOPE` in each part of an identifier. */ +const SIBLING_SCOPE: FixtureScope = { + ...BASE_SCOPE, + checkSha: "C".repeat(40), + deploymentId: 3002, + environment: " Staging EU ", + issue: 13, + pullRequest: 947, + tag: "Release/Ü1", +}; +// A tag that differs from the sibling tag only in a non-ASCII letter. +const LOWERCASE_TAG_SCOPE: FixtureScope = { ...BASE_SCOPE, tag: "Release/ü1" }; +const OTHER_REPO_SCOPE: FixtureScope = { + ...BASE_SCOPE, + repo: "getsentry/sentry", +}; + +/** + * Build the subscribable resources that GitHub tools return for one scope, + * with the values as the GitHub API reports them. + */ +function scopeSubscribables(scope: FixtureScope) { + const canSubscribe = true; + const { repo } = scope; + return { + issues: [ + gitHubIssueSubscribable({ canSubscribe, number: scope.issue, repo }), + gitHubRepositorySubscribable({ canSubscribe, repo }), + ], + others: [ + gitHubPullRequestSubscribable({ + canSubscribe, + number: scope.pullRequest, + repo, + }), + gitHubRepositorySubscribable({ canSubscribe, repo }), + gitHubDeploymentSourceSubscribable({ + canSubscribe, + commitSha: scope.deploymentSha, + environment: scope.environment, + repo, + }), + gitHubDeploymentSourceSubscribable({ + canSubscribe, + commitSha: scope.deploymentSha, + repo, + }), + gitHubReleaseSourceSubscribable({ canSubscribe, repo, tag: scope.tag }), + gitHubReleaseSourceSubscribable({ canSubscribe, repo }), + ], + }; +} + describe("GitHub event data", () => { it("declares filter events that match the data the webhooks set", async () => { const events = [ @@ -356,36 +414,21 @@ describe("GitHub event data", () => { expect(scopeValues("tag")).toEqual(new Set(["v1.2.0"])); }); - it("turns each identifier into filters that select only its events", async () => { + it("turns each identifier and each subscribable match into filters that select only its events", async () => { const events = [ ...(await publish(fixtureDeliveries(BASE_SCOPE), "base")), // The same repository and commit, with other numbers, another // environment, and a tag with a slash and a non-ASCII letter. + ...(await publish(fixtureDeliveries(SIBLING_SCOPE), "sibling")), ...(await publish( - fixtureDeliveries({ - ...BASE_SCOPE, - checkSha: "C".repeat(40), - deploymentId: 3002, - environment: " Staging EU ", - issue: 13, - pullRequest: 947, - tag: "Release/Ü1", - }), - "sibling", - )), - // A tag that differs from the one above only in a non-ASCII letter. - ...(await publish( - [releaseDelivery(BASE_SCOPE.repo, "Release/ü1")], + [releaseDelivery(LOWERCASE_TAG_SCOPE.repo, LOWERCASE_TAG_SCOPE.tag)], "lowercase-tag", )), // The same numbers in another repository. - ...(await publish( - fixtureDeliveries({ ...BASE_SCOPE, repo: "getsentry/sentry" }), - "other-repo", - )), + ...(await publish(fixtureDeliveries(OTHER_REPO_SCOPE), "other-repo")), ...mergeConflictEvents(BASE_SCOPE), - ...mergeConflictEvents({ ...BASE_SCOPE, pullRequest: 947 }), - ...mergeConflictEvents({ ...BASE_SCOPE, repo: "getsentry/sentry" }), + ...mergeConflictEvents(SIBLING_SCOPE), + ...mergeConflictEvents(OTHER_REPO_SCOPE), ]; expect( @@ -396,6 +439,33 @@ describe("GitHub event data", () => { toMatch: gitHubIdentifierMatch, }), ).toEqual([]); + // An event automation copies `match` from the tool result, and a Watch + // copies `identifier`. Both must select the same events. Core compares + // identifiers after `normalizeIdentifier`. + const normalize = githubPlugin().events!.normalizeIdentifier!; + const byIdentifier = (key: "issues" | "others") => + new Map( + [BASE_SCOPE, SIBLING_SCOPE, LOWERCASE_TAG_SCOPE, OTHER_REPO_SCOPE] + .flatMap((scope) => scopeSubscribables(scope)[key]) + .map((resource) => [ + normalize(resource!.identifier), + resource!.match, + ]), + ); + const issues = byIdentifier("issues"); + const others = byIdentifier("others"); + + expect( + identifierMatchMismatches({ + resourceTypes: githubPlugin().events?.resourceTypes ?? [], + events, + matches: eventMatches, + toMatch: ({ eventType, identifier }) => + (eventType.startsWith("issue.") ? issues : others).get( + normalize(identifier), + ), + }), + ).toEqual([]); // Tags keep their text: no URI encoding, and only ASCII letters change. expect(new Set(events.flatMap((event) => event.data?.tag ?? []))).toEqual( new Set(["v1.2.0", "release/Ü1", "release/ü1"]), diff --git a/packages/junior-github/tests/get-deployment.test.ts b/packages/junior-github/tests/get-deployment.test.ts index f27e1dac58..012bf6b453 100644 --- a/packages/junior-github/tests/get-deployment.test.ts +++ b/packages/junior-github/tests/get-deployment.test.ts @@ -66,6 +66,11 @@ describe("getDeployment", () => { }, subscribable: { identifier: `deployment-source:getsentry/junior-prod:production:${COMMIT_SHA}`, + match: { + repo: "getsentry/junior-prod", + commitSha: COMMIT_SHA, + environment: "production", + }, suggestedEvents: [ "deployment.succeeded", "deployment.failed", diff --git a/packages/junior-github/tests/get-pull-request.test.ts b/packages/junior-github/tests/get-pull-request.test.ts index 64a3b05555..5f73344a6f 100644 --- a/packages/junior-github/tests/get-pull-request.test.ts +++ b/packages/junior-github/tests/get-pull-request.test.ts @@ -77,6 +77,7 @@ describe("getPullRequest", () => { subscribable: { label: "GitHub PR getsentry/junior#691", identifier: "getsentry/junior#691", + match: { repo: "getsentry/junior", pullRequest: 691 }, type: "pull_request", }, }); diff --git a/packages/junior-github/tests/get-release.test.ts b/packages/junior-github/tests/get-release.test.ts index 84688ded3e..5380ca0a28 100644 --- a/packages/junior-github/tests/get-release.test.ts +++ b/packages/junior-github/tests/get-release.test.ts @@ -143,6 +143,7 @@ describe("getRelease", () => { tag: "0.129.0", subscribable: { identifier: "release-source:getsentry/junior:0.129.0", + match: { repo: "getsentry/junior", tag: "0.129.0" }, }, }); }); diff --git a/packages/junior-plugin-api/src/events.ts b/packages/junior-plugin-api/src/events.ts index c1e6d92070..82377a63ed 100644 --- a/packages/junior-plugin-api/src/events.ts +++ b/packages/junior-plugin-api/src/events.ts @@ -514,6 +514,12 @@ export const subscribableResourceSchema = z .object({ identifier: z.string().min(1), label: z.string().min(1), + /** + * Filters that select the events of this resource in an event automation + * trigger, such as `{ repo, pullRequest }`. A Watch uses `identifier`. + * Missing when event automations cannot use this resource. + */ + match: eventMatchSchema.optional(), namespace: z.string().min(1), suggestedEvents: z.array(eventTypeSchema).optional(), supportedEvents: z.array(eventTypeSchema), diff --git a/packages/junior-vercel/src/events/deployment.ts b/packages/junior-vercel/src/events/deployment.ts index b8bb9fec1d..caa7f31d62 100644 --- a/packages/junior-vercel/src/events/deployment.ts +++ b/packages/junior-vercel/src/events/deployment.ts @@ -100,6 +100,13 @@ export function vercelDeploymentSubscribable(input: { if (input.commitSha && !input.target) return undefined; return { ...vercelDeploymentResource(input), + match: { + projectId: input.projectId, + ...(input.target ? { target: input.target } : undefined), + ...(input.commitSha + ? { commitSha: input.commitSha.toLowerCase() } + : undefined), + }, suggestedEvents: VERCEL_DEPLOYMENT_SUGGESTED_EVENTS, supportedEvents: [...VERCEL_DEPLOYMENT_EVENTS], type: "deployment", diff --git a/packages/junior-vercel/tests/webhook.test.ts b/packages/junior-vercel/tests/webhook.test.ts index 223548d67b..f7af920709 100644 --- a/packages/junior-vercel/tests/webhook.test.ts +++ b/packages/junior-vercel/tests/webhook.test.ts @@ -10,6 +10,10 @@ import { } from "@sentry/junior-testing/event-filters"; import { afterEach, describe, expect, it, vi } from "vitest"; import { vercelPlugin } from "../src"; +import { + vercelDeploymentSubscribable, + type VercelDeploymentTarget, +} from "../src/events/deployment"; import { createVercelWebhookRoute } from "../src/webhooks/handler"; import { normalizeVercelEvents } from "../src/webhooks/events"; @@ -89,7 +93,7 @@ describe("Vercel webhook events", () => { ).toEqual([]); }); - it("turns each identifier into filters that select only its events", () => { + it("turns each identifier and each subscribable match into filters that select only its events", () => { // Another target, another commit, and another project. const deployments = [ ["prj_junior", "production", COMMIT_SHA], @@ -132,6 +136,33 @@ describe("Vercel webhook events", () => { toMatch, }), ).toEqual([]); + + // An event automation copies `match` from the tool result, and a Watch + // copies `identifier`. Both must select the same events. + const subscribables = new Map( + deployments + .flatMap(([projectId, target, commitSha]) => [ + { projectId: projectId! }, + { projectId: projectId!, target: target as VercelDeploymentTarget }, + { + projectId: projectId!, + target: target as VercelDeploymentTarget, + commitSha: commitSha!.toUpperCase(), + }, + ]) + .map((input) => + vercelDeploymentSubscribable({ ...input, canSubscribe: true }), + ) + .map((resource) => [resource!.identifier, resource!.match]), + ); + expect( + identifierMatchMismatches({ + resourceTypes: vercelPlugin().events?.resourceTypes ?? [], + events, + matches: eventMatches, + toMatch: ({ identifier }) => subscribables.get(identifier), + }), + ).toEqual([]); }); it.each([ diff --git a/packages/junior/src/chat/automations/tool-support.ts b/packages/junior/src/chat/automations/tool-support.ts index 12cce63b00..b227a16886 100644 --- a/packages/junior/src/chat/automations/tool-support.ts +++ b/packages/junior/src/chat/automations/tool-support.ts @@ -6,6 +6,7 @@ import { compactEventAutomation, compactEventAutomationResultSchema, eventAutomationToolResult, + registeredEventAutomationTriggerChangesSchema, registeredEventAutomationTriggerSchema, } from "@/chat/event-automations/tool-support"; import type { EventCatalog } from "@/chat/events/catalog"; @@ -40,34 +41,54 @@ export const automationSearchResultSchema = juniorToolOutputSchema }) .strict(); +const scheduleTriggerSchema = z + .object({ + type: z.literal("schedule"), + schedule: scheduleIntentSchema.describe( + "When the automation runs. The scheduler computes the exact next run from this intent and the server clock.", + ), + }) + .strict(); + /** - * Build the trigger input of the create and update tools: a schedule, or the - * events of an enabled plugin. + * Build the trigger input of the create tool: a schedule, or the events of an + * enabled plugin. */ export function automationTriggerSchema(catalog: EventCatalog) { return z.discriminatedUnion("type", [ - z - .object({ - type: z.literal("schedule"), - schedule: scheduleIntentSchema.describe( - "When the automation runs. The scheduler computes the exact next run from this intent and the server clock.", - ), - }) - .strict(), + scheduleTriggerSchema, registeredEventAutomationTriggerSchema(catalog).extend({ type: z.literal("event"), }), ]); } +/** + * Build the trigger input of the update tool. An event trigger changes its + * filters key by key. + */ +export function automationTriggerChangesSchema(catalog: EventCatalog) { + return z.discriminatedUnion("type", [ + scheduleTriggerSchema, + registeredEventAutomationTriggerChangesSchema(catalog).extend({ + type: z.literal("event"), + }), + ]); +} + /** Automation trigger input after parsing. */ export type AutomationTriggerInput = z.output< ReturnType >; +/** Automation trigger changes after parsing. */ +export type AutomationTriggerChangesInput = z.output< + ReturnType +>; + /** Return the Automation kind of a trigger input. */ export function triggerKind( - trigger: AutomationTriggerInput, + trigger: Pick, ): TaskCandidate["kind"] { return trigger.type === "schedule" ? "scheduled" : "event"; } diff --git a/packages/junior/src/chat/automations/tools/create.ts b/packages/junior/src/chat/automations/tools/create.ts index c210fef679..b20698d82a 100644 --- a/packages/junior/src/chat/automations/tools/create.ts +++ b/packages/junior/src/chat/automations/tools/create.ts @@ -46,7 +46,10 @@ import { import { scheduledAutomationAttributes } from "@/chat/scheduled-automations/telemetry"; import { getConversationAccess } from "@/chat/scheduled-automations/tool-support"; import { SCHEDULED_AUTOMATION_SYSTEM_ACTOR } from "@/chat/scheduled-automations/types"; -import { requireSlackChannelMessageScope } from "@/chat/slack/channel-message-events"; +import { + requireSlackChannelMessageScope, + slackChannelMessageTrigger, +} from "@/chat/slack/channel-message-events"; import { resolveTaskTitle, SHORT_TITLE_MAX_LENGTH, @@ -254,7 +257,10 @@ export function createAutomationTool( }); const eventTrigger = input.trigger.type === "event" - ? requireEventAutomationTrigger(catalog, input.trigger) + ? requireEventAutomationTrigger( + catalog, + slackChannelMessageTrigger(input.trigger, destination.channelId), + ) : undefined; const toolCallId = options.toolCallId?.trim(); if (!toolCallId) { diff --git a/packages/junior/src/chat/automations/tools/update.ts b/packages/junior/src/chat/automations/tools/update.ts index 4b90185ffa..077f2217c0 100644 --- a/packages/junior/src/chat/automations/tools/update.ts +++ b/packages/junior/src/chat/automations/tools/update.ts @@ -20,15 +20,16 @@ import { import { automationToolResult, automationToolResultSchema, - automationTriggerSchema, + automationTriggerChangesSchema, triggerKind, - type AutomationTriggerInput, + type AutomationTriggerChangesInput, } from "@/chat/automations/tool-support"; import { resolveDestinationVisibility } from "@/chat/conversations/destination-visibility"; import { getConversationStore, getDb } from "@/chat/db"; import type { AutomationDestination } from "@/chat/automations/creator"; import { sameDestination } from "@/chat/destination"; import { editEventAutomation } from "@/chat/event-automations/edit"; +import { changedEventMatch } from "@/chat/event-automations/tool-support"; import { saveEventAutomation } from "@/chat/event-automations/store"; import type { EventCatalog } from "@/chat/events/catalog"; import { logInfo } from "@/chat/logging"; @@ -54,7 +55,7 @@ import { juniorAutomationRuns } from "@/db/schema/automation-runs"; type UpdateInput = { title?: string; instruction?: string; - trigger?: AutomationTriggerInput; + trigger?: AutomationTriggerChangesInput; outcomes?: z.output[]; credentialMode?: "creator" | "system"; destination?: string; @@ -230,19 +231,23 @@ async function updateEvent( { title: input.title, instruction: input.instruction, - // An omitted identifier or resource type keeps the saved one. Only an - // explicit null makes the trigger run for every resource. + // The tools cannot set an identifier, so a saved one stays. A filter + // that is left out keeps its saved value. Another namespace starts a + // new trigger. trigger: trigger ? { ...trigger, - identifier: - trigger.identifier === undefined - ? task.trigger.identifier - : (trigger.identifier ?? undefined), - resourceType: - trigger.resourceType === undefined - ? task.trigger.resourceType - : (trigger.resourceType ?? undefined), + ...(trigger.namespace === task.trigger.namespace + ? { + identifier: task.trigger.identifier, + resourceType: task.trigger.resourceType, + } + : undefined), + match: changedEventMatch( + task.trigger, + trigger.namespace, + trigger.match, + ), } : undefined, outcomes: input.outcomes, @@ -266,7 +271,7 @@ export function createUpdateAutomationTool( context: AutomationToolContext, catalog: EventCatalog, ) { - const trigger = automationTriggerSchema(catalog); + const trigger = automationTriggerChangesSchema(catalog); const inputSchema = z .object({ automationId: z @@ -280,7 +285,7 @@ export function createUpdateAutomationTool( trigger: trigger .nullable() .describe( - "Complete replacement trigger of the same type. For an event trigger, omit identifier or resourceType to keep the saved one. Omit or use null to keep the trigger.", + "Replacement trigger of the same type. For an event trigger, match changes only the filters that it names. Omit or use null to keep the trigger.", ) .optional(), outcomes: z diff --git a/packages/junior/src/chat/event-automations/edit.ts b/packages/junior/src/chat/event-automations/edit.ts index b0480fb195..fcce22544d 100644 --- a/packages/junior/src/chat/event-automations/edit.ts +++ b/packages/junior/src/chat/event-automations/edit.ts @@ -7,7 +7,10 @@ import { } from "@/chat/automations/edit-rules"; import type { EventCatalog } from "@/chat/events/catalog"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; -import { requireSlackChannelMessageScope } from "@/chat/slack/channel-message-events"; +import { + requireSlackChannelMessageScope, + slackChannelMessageTrigger, +} from "@/chat/slack/channel-message-events"; import { requireEventAutomationTrigger } from "./tool-support"; import type { EventAutomation } from "./types"; import type { z } from "zod"; @@ -36,20 +39,24 @@ export async function editEventAutomation( ): Promise { let trigger = current.trigger; if (input.trigger !== undefined) { + const inputTrigger = slackChannelMessageTrigger( + input.trigger, + current.destination.channelId, + ); // An unchanged selector may contain a now-unsupported match field. Keep it. if ( - changesEventAutomationTrigger(current.trigger, input.trigger) || - current.trigger.resourceType !== input.trigger.resourceType + changesEventAutomationTrigger(current.trigger, inputTrigger) || + current.trigger.resourceType !== inputTrigger.resourceType ) { try { - trigger = requireEventAutomationTrigger(catalog, input.trigger); + trigger = requireEventAutomationTrigger(catalog, inputTrigger); } catch (error) { if (error instanceof ToolInputError) throw new AutomationEditError(error.message, "trigger"); throw error; } } else { - trigger = { ...current.trigger, label: input.trigger.label }; + trigger = { ...current.trigger, label: inputTrigger.label }; } } const changesExecution = diff --git a/packages/junior/src/chat/event-automations/tool-support.ts b/packages/junior/src/chat/event-automations/tool-support.ts index 1f3b164e36..dbac9215b9 100644 --- a/packages/junior/src/chat/event-automations/tool-support.ts +++ b/packages/junior/src/chat/event-automations/tool-support.ts @@ -1,5 +1,7 @@ import { + eventMatchSchema, eventTriggerText, + type EventMatch, type EventTriggerDescription, type ObjectAnnotation, } from "@sentry/junior-plugin-api"; @@ -8,17 +10,13 @@ import { z } from "zod"; import { fallbackShortTitle } from "@/chat/services/short-title"; import { getDashboardTaskLink } from "@/chat/dashboard-link"; import type { StoredEventAutomation } from "@/chat/event-automations/store"; -import { - EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH, - type EventAutomation, -} from "@/chat/event-automations/types"; +import type { EventAutomation } from "@/chat/event-automations/types"; import { eventNamespaceSchema, automationEventCatalog, describeRegisteredEventTrigger, registeredEventTypeSchema, registeredEventMatchSchema, - registeredResourceTypeSchema, requireEventIdentifier, requireValidEventTrigger, type EventCatalog, @@ -88,29 +86,55 @@ export function registeredEventAutomationTriggerSchema(catalog: EventCatalog) { return z .object({ namespace: eventNamespaceSchema(plugins), - identifier: z - .string() - .trim() - .min(1) - .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH) - .nullable() - .describe( - 'Exact resource identifier. Use null, with a null resourceType, to run for every resource of the namespace. Only events whose searchEventTypes reach is "all" or { anyOf } can run that way, and match must set one of the anyOf filters. When creating, omit it to run for every resource. When updating, omit it to keep the saved identifier.', - ) - .optional(), - resourceType: registeredResourceTypeSchema(plugins) - .nullable() - .describe( - "Resource type of the identifier. Use null when identifier is null. When updating, omit it to keep the saved resource type.", - ) - .optional(), label: z.string().trim().min(1).max(500), events: z.array(registeredEventTypeSchema(plugins)).min(1), - match: registeredEventMatchSchema(), + match: registeredEventMatchSchema().describe( + "Filters that select the events, from the matchFields of the namespace. Use the match of a subscribable tool result to select one resource, such as { repo, pullRequest } for one pull request. Leave it out to run for every resource, when the reach of each event allows that. A single value must equal the event data. A list means the event value may be any one entry. For a field with list: true, the event value is a list, and it matches when it includes the value or any entry.", + ), }) .strict(); } +/** + * Filter changes of an update. A value sets the filter, and null removes it. + * A filter that is left out keeps its saved value, so an update cannot make + * a trigger broader by leaving out a filter. + */ +const eventMatchChangesSchema = z + .record(z.string(), z.union([eventMatchSchema.valueType, z.null()])) + .optional() + .describe( + "Filter changes, from the matchFields of the namespace. A value sets the filter, null removes it, and a filter that is left out keeps its saved value. When the namespace changes, the saved filters are dropped.", + ); + +/** Build the event trigger that the update tool accepts. */ +export function registeredEventAutomationTriggerChangesSchema( + catalog: EventCatalog, +) { + return registeredEventAutomationTriggerSchema(catalog).extend({ + match: eventMatchChangesSchema, + }); +} + +/** + * Apply filter changes to the saved trigger, key by key. A change to another + * namespace starts from no filters. + */ +export function changedEventMatch( + saved: Pick, + namespace: string, + changes: z.output, +): EventMatch | undefined { + const match: EventMatch = { + ...(namespace === saved.namespace ? saved.match : undefined), + }; + for (const [key, value] of Object.entries(changes ?? {})) { + if (value === null) delete match[key]; + else match[key] = value; + } + return Object.keys(match).length > 0 ? match : undefined; +} + /** * Check a new event-automation trigger with the enabled plugins and return * it as it is saved. It names a resource type and an identifier, or neither. diff --git a/packages/junior/src/chat/slack/channel-message-events.ts b/packages/junior/src/chat/slack/channel-message-events.ts index 2f36c153f1..7de4ef9ae5 100644 --- a/packages/junior/src/chat/slack/channel-message-events.ts +++ b/packages/junior/src/chat/slack/channel-message-events.ts @@ -25,6 +25,12 @@ const MESSAGE_POSTED_EVENT = "message.posted"; const CHANNEL_ID_PATTERN = /^[CG][A-Z0-9]{2,}$/; const CHANNEL_MESSAGE_MATCH_FIELDS = eventMatchFieldsSchema.parse({ + channelId: { + kind: "string", + description: + "Slack channel ID of the message. It is always the channel where the automation posts.", + events: [MESSAGE_POSTED_EVENT], + }, userId: { kind: "string", description: @@ -51,10 +57,10 @@ export function slackChannelMessageEvents(): EventRegistrationInput { }, ], events: { - // A channel automation stays in its own channel, so it needs the - // channel identifier until a channel filter can replace it. + // A channel automation stays in its own channel. + // `slackChannelMessageTrigger` sets the `channelId` filter. [MESSAGE_POSTED_EVENT]: { - reach: "resource", + reach: { anyOf: ["channelId"] }, guidance: "A person or bot posted a new top-level message in this channel. A bot post runs the automation only when match names the bot in userId. A reply goes in that message's thread. If the instructions do not apply to this message, end with no action.", }, @@ -69,11 +75,33 @@ export function slackChannelMessageEvents(): EventRegistrationInput { }; } +/** + * Set the `channelId` filter of a Slack channel-message trigger to the + * channel where the automation posts. A trigger that names another channel, + * or a saved trigger with an identifier, stays as it is for + * `requireSlackChannelMessageScope` to check. + */ +export function slackChannelMessageTrigger< + T extends { + identifier?: string | null; + match?: EventAutomation["trigger"]["match"]; + namespace: string; + }, +>(trigger: T, channelId: string): T { + if (trigger.namespace !== SLACK_EVENT_NAMESPACE) return trigger; + if (trigger.identifier != null || trigger.match?.channelId !== undefined) { + return trigger; + } + return { ...trigger, match: { ...trigger.match, channelId } }; +} + /** * Bind a Slack channel-message automation to its own channel. * - * Resolve `current_channel` to the destination channel and reject other - * channels, so private channel text cannot reach another channel. + * The trigger names the channel with a `channelId` filter. A saved trigger + * can name it with an identifier instead, where `current_channel` means the + * destination channel. Reject other channels, so private channel text cannot + * reach another channel. */ export function requireSlackChannelMessageScope( automation: EventAutomation, @@ -85,19 +113,25 @@ export function requireSlackChannelMessageScope( "A Slack channel-message automation works only in a channel, not in a DM.", ); } - const identifier = - automation.trigger.identifier === SLACK_CURRENT_CHANNEL_IDENTIFIER - ? channelId - : automation.trigger.identifier; - if (identifier !== channelId) { + const { identifier, match } = automation.trigger; + const named = + identifier === undefined + ? match?.channelId + : identifier === SLACK_CURRENT_CHANNEL_IDENTIFIER + ? channelId + : identifier; + if (named !== channelId) { throw new ToolInputError( - `A Slack channel-message automation can watch only the channel where it posts. Use identifier "${SLACK_CURRENT_CHANNEL_IDENTIFIER}", or create the automation in the channel to watch.`, + "A Slack channel-message automation can watch only the channel where it posts. Leave out the channelId filter, or create the automation in the channel to watch.", ); } return { ...automation, credentialMode: "system", - trigger: { ...automation.trigger, identifier }, + trigger: { + ...automation.trigger, + ...(identifier === undefined ? undefined : { identifier: channelId }), + }, }; } @@ -140,8 +174,8 @@ export function slackChannelMessageEvent(input: { * Decide whether a channel-message Event may run one automation. * * A message runs only automations that post in its own channel, so private - * channel text cannot reach another channel. The trigger identifier already - * names that channel, and this check keeps the rule when it does not. + * channel text cannot reach another channel. The trigger already names that + * channel, and this check keeps the rule when it does not. * A bot post runs only automations whose match names its author in `userId`. * Bots can post often and would use up the run limit of every other * automation in the channel. diff --git a/packages/junior/src/chat/tools/search-event-types.ts b/packages/junior/src/chat/tools/search-event-types.ts index cb2f3c7220..235cfe3d09 100644 --- a/packages/junior/src/chat/tools/search-event-types.ts +++ b/packages/junior/src/chat/tools/search-event-types.ts @@ -105,7 +105,7 @@ export function createSearchEventTypesTool(catalog: EventCatalog) { readOnlyHint: true, }, description: - 'Search the event types currently enabled without creating anything. Use only when the user asks what events are supported or the required resource type or event name is unclear. Includes plugin resources and core Workspace snapshot events. namespaces gives the reach of each event. With reach "all" or { anyOf }, an event can start an event automation for every resource of its namespace: leave out identifier and resourceType, and narrow it with match filters from that namespace. For { anyOf }, set at least one of those filters. With reach "resource", the automation needs an identifier. Use watchEvents for temporary updates in the current conversation; use createAutomation with an event trigger for durable automation. This tool does not watch a resource, create an automation, or list concrete resources.', + 'Search the event types currently enabled without creating anything. Use only when the user asks what events are supported or the required resource type or event name is unclear. Includes plugin resources and core Workspace snapshot events. namespaces gives the reach of each event. An event automation selects its events with match filters from the matchFields of its namespace. With reach "all", match can be left out to run for every resource. With { anyOf }, match must set at least one of those filters. With reach "resource", only watchEvents can use the event. Use watchEvents for temporary updates in the current conversation; use createAutomation with an event trigger for durable automation. This tool does not watch a resource, create an automation, or list concrete resources.', inputSchema: z .object({ query: z diff --git a/packages/junior/tests/fixtures/event-automations.ts b/packages/junior/tests/fixtures/event-automations.ts index dc0bdfb94c..951631b3ee 100644 --- a/packages/junior/tests/fixtures/event-automations.ts +++ b/packages/junior/tests/fixtures/event-automations.ts @@ -1,42 +1,24 @@ +import { githubPlugin } from "@sentry/junior-github"; +import { vi } from "vitest"; +import { createApp, defineJuniorPlugins } from "@/app"; import { createAutomationTool } from "@/chat/automations/tools/create"; import { automationToolContext } from "@/chat/automations/tool-access"; import { createSlackSource, type SlackActor } from "@sentry/junior-plugin-api"; import { getSqlExecutor } from "@/chat/db"; -import { eventCatalogEntry, type EventCatalog } from "@/chat/events/catalog"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; import { upsertIdentity } from "@/chat/identities/sql"; import { readActorIdentity } from "@/chat/plugins/viewer"; import type { ToolRuntimeContext } from "@/chat/tools/types"; export const teamId = `TEVENT${Date.now()}`; -export const EVENT_CATALOG: EventCatalog = { - github: eventCatalogEntry({ - resourceTypes: [ - { - type: "pull_request", - supportedEvents: [ - "pull_request.review.changes_requested", - "pull_request.review.commented", - ], - }, - { - type: "review_target", - supportedEvents: [ - "pull_request.review.changes_requested", - "pull_request.review.commented", - ], - }, - ], - events: { - "pull_request.review.changes_requested": { reach: "resource" }, - "pull_request.review.commented": { reach: "resource" }, - }, - normalizeIdentifier: (identifier: string) => identifier.toLowerCase(), - }), - sentry: eventCatalogEntry({ - resourceTypes: [{ type: "issue", supportedEvents: ["issue.closed"] }], - events: { "issue.closed": { reach: "resource" } }, - }), -}; +/** + * Enable the GitHub plugin, so the tools and ingest use the same event + * catalog. Call `vi.unstubAllEnvs()` after the test. + */ +export async function useGitHubEvents(): Promise { + vi.stubEnv("GITHUB_WEBHOOK_SECRET", "test-secret"); + await createApp({ plugins: defineJuniorPlugins([githubPlugin()]) }); +} /** Build the source and actor for an Automation tool call. */ export function context( @@ -107,6 +89,7 @@ export function changesRequestedEvent(eventKey: string) { namespace: "github", identifier: "getsentry/junior#1174", trustedSummary: "A reviewer requested changes.", + data: { repo: "getsentry/junior", pullRequest: 1174 }, }; } @@ -118,17 +101,16 @@ export async function createTask( taskContext = context(), ) { return (await execute( - createAutomationTool(automationToolContext(taskContext), EVENT_CATALOG), + createAutomationTool(automationToolContext(taskContext), getEventCatalog()), { instruction, outcomes: [], trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "pull_request", label: "GitHub PR getsentry/junior#1174", events, + match: { repo: "getsentry/junior", pullRequest: 1174 }, }, }, toolCallId ?? instruction, diff --git a/packages/junior/tests/integration/any-resource-event-automations.test.ts b/packages/junior/tests/integration/any-resource-event-automations.test.ts index 5a324ae13b..a2411ff768 100644 --- a/packages/junior/tests/integration/any-resource-event-automations.test.ts +++ b/packages/junior/tests/integration/any-resource-event-automations.test.ts @@ -97,12 +97,17 @@ describe("event automations with no identifier", () => { await expect(create(trigger, "no-repo")).rejects.toThrow( 'This GitHub trigger cannot be saved. This trigger needs a "repo" filter (match.repo), because "pull_request.opened" cannot run for every resource.', ); + // Filters select one resource. The tools take no identifier. await expect( create( - { ...trigger, identifier: "getsentry/junior#1", match: {} }, - "half-resource", + { + ...trigger, + identifier: "getsentry/junior#1", + resourceType: "pull_request", + }, + "identifier-input", ), - ).rejects.toThrow("Set both resourceType and identifier"); + ).rejects.toThrow('Unrecognized keys: "identifier", "resourceType"'); // Event data has a lowercase repo, so the saved filter does too. const created = await create( { ...trigger, match: { repo: "getsentry/Junior" } }, @@ -205,50 +210,91 @@ describe("event automations with no identifier", () => { "About: New pull requests in getsentry/junior", ); - // An update that leaves out identifier and resourceType keeps them, - // so it cannot make a trigger broad by omission. + // An update changes only the filters that it names, so it cannot + // make a trigger broad by leaving one out. const one = await create( { ...trigger, - identifier: "getsentry/junior#7", - resourceType: "pull_request", label: "getsentry/junior#7", + match: { repo: "getsentry/junior", pullRequest: 7 }, }, "one-pull-request", ); - const update = (next: Record, toolCallId: string) => + const update = ( + automationId: string, + next: Record, + toolCallId: string, + ) => execute( createUpdateAutomationTool(automationToolContext(context()), catalog), - { - automationId: one.automation.id, - trigger: { type: "event", ...next }, - }, + { automationId, trigger: { type: "event", ...next } }, toolCallId, ) as Promise<{ automation: { trigger: Record }; }>; const edited = { ...trigger, label: "Opened getsentry/junior#7" }; - await expect(update(edited, "keep-identifier")).resolves.toMatchObject({ + await expect( + update(one.automation.id, edited, "keep-filters"), + ).resolves.toMatchObject({ automation: { - trigger: { - identifier: "getsentry/junior#7", - resourceType: "pull_request", - }, + trigger: { match: { repo: "getsentry/junior", pullRequest: 7 } }, }, }); - // Only explicit nulls drop them, and then the trigger needs a repo. - const broad = { ...edited, identifier: null, resourceType: null }; - await expect(update(broad, "drop-identifier")).rejects.toThrow( - 'This trigger needs a "repo" filter (match.repo)', - ); + // Null removes a filter. The pull request events still need a repo. await expect( update( - { ...broad, match: { repo: "getsentry/junior" } }, - "drop-identifier-with-repo", + one.automation.id, + { ...edited, match: { repo: null } }, + "drop-repo", ), + ).rejects.toThrow('This trigger needs a "repo" filter (match.repo)'); + const repoWide = await update( + one.automation.id, + { ...edited, match: { pullRequest: null } }, + "drop-pull-request", + ); + expect(repoWide.automation.trigger.match).toEqual({ + repo: "getsentry/junior", + }); + + // A saved trigger with an identifier keeps running and keeps its + // identifier when the tools edit it. + await fixture.sql + .db() + .insert(juniorAutomations) + .values({ + ...row!, + id: "evt_saved_identifier", + identifier: "getsentry/junior#9", + record: { + ...record, + trigger: { + ...record.trigger, + identifier: "getsentry/junior#9", + resourceType: "pull_request", + match: undefined, + }, + }, + }); + await expect( + update("evt_saved_identifier", edited, "edit-saved-identifier"), ).resolves.toMatchObject({ - automation: { trigger: { identifier: null, resourceType: null } }, + automation: { + trigger: { + identifier: "getsentry/junior#9", + resourceType: "pull_request", + }, + }, }); + expect( + await ingestAll( + opened("github:opened-9", "getsentry/junior").map((event) => ({ + ...event, + identifier: event.identifier.replace("#7", "#9"), + data: { ...event.data, pullRequest: 9 }, + })), + ), + ).toBe(3); } finally { vi.unstubAllEnvs(); } diff --git a/packages/junior/tests/integration/event-automation-blocking.test.ts b/packages/junior/tests/integration/event-automation-blocking.test.ts index 56f27c0da0..cba07f566c 100644 --- a/packages/junior/tests/integration/event-automation-blocking.test.ts +++ b/packages/junior/tests/integration/event-automation-blocking.test.ts @@ -4,7 +4,6 @@ import { createDeleteAutomationTool } from "@/chat/automations/tools/delete"; import { automationToolContext } from "@/chat/automations/tool-access"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { createApp, defineJuniorPlugins } from "@/app"; import { markDispatchBlocked } from "@/chat/agent-dispatch/store"; import { getConversationStore } from "@/chat/db"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; @@ -12,14 +11,13 @@ import { ingestEventAutomations } from "@/chat/event-automations/ingest"; import { getEventAutomation } from "@/chat/event-automations/store"; import { botConfig } from "@/chat/config"; import { disconnectStateAdapter } from "@/chat/state/adapter"; -import { githubPlugin } from "@sentry/junior-github"; import { getEventCatalog } from "@/chat/events/runtime-catalog"; import { juniorAutomations } from "@/db/schema/automations"; import { changesRequestedEvent, context, createTask, - EVENT_CATALOG, + useGitHubEvents, execute, teamId, } from "../fixtures/event-automations"; @@ -62,9 +60,11 @@ describe("event automation blocking", () => { source: "local", nowMs: Date.now(), }); + await useGitHubEvents(); }); afterEach(async () => { + vi.unstubAllEnvs(); await fixture.sql .db() .delete(juniorAutomations) @@ -84,7 +84,7 @@ describe("event automation blocking", () => { execute( createUpdateAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: automation.id, @@ -138,7 +138,7 @@ describe("event automation blocking", () => { execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: automation.id, @@ -227,7 +227,7 @@ describe("event automation blocking", () => { await execute( createUpdateAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: automation.id, @@ -243,7 +243,7 @@ describe("event automation blocking", () => { execute( createDeleteAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), { automationId, @@ -263,65 +263,52 @@ describe("event automation blocking", () => { }, 30_000); it("counts each event once, however many resources it is published for", async () => { - vi.stubEnv("GITHUB_WEBHOOK_SECRET", "test-secret"); - await createApp({ plugins: defineJuniorPlugins([githubPlugin()]) }); - try { - const { automation } = (await execute( - createAutomationTool( - automationToolContext(context()), - getEventCatalog(), - ), - { - instruction: "Summarize the failed deployment.", - trigger: { - type: "event", + const { automation } = (await execute( + createAutomationTool(automationToolContext(context()), getEventCatalog()), + { + instruction: "Summarize the failed deployment.", + trigger: { + type: "event", + namespace: "github", + label: "Failed deployments", + events: ["deployment.failed"], + }, + }, + "any-deployment", + )) as { automation: { id: string } }; + // A trigger with no identifier gets every copy of an event. Repeated + // copies must not fill the run list and block it early. + const copies = 12; + const occur = async (eventKey: string, nowMs: number) => { + let dispatched = 0; + for (let index = 0; index < copies; index += 1) { + const result = await ingestEventAutomations( + { + eventKey, + eventType: "deployment.failed", + occurredAtMs: nowMs, namespace: "github", - label: "Failed deployments", - events: ["deployment.failed"], + identifier: `deployment-source:getsentry/repo-${index}:abc`, + trustedSummary: "A deployment failed.", }, - }, - "any-deployment", - )) as { automation: { id: string } }; - // A trigger with no identifier gets every copy of an event. Repeated - // copies must not fill the run list and block it early. - const copies = 12; - const occur = async (eventKey: string, nowMs: number) => { - let dispatched = 0; - for (let index = 0; index < copies; index += 1) { - const result = await ingestEventAutomations( - { - eventKey, - eventType: "deployment.failed", - occurredAtMs: nowMs, - namespace: "github", - identifier: `deployment-source:getsentry/repo-${index}:abc`, - trustedSummary: "A deployment failed.", - }, - { nowMs, queue, teamId }, - ); - dispatched += result.dispatched; - } - return dispatched; - }; - const nowMs = Date.now(); - for ( - let index = 0; - index < botConfig.maxEventAutomationRuns; - index += 1 - ) { - expect(await occur(`github:deploy-${index}`, nowMs)).toBe(1); + { nowMs, queue, teamId }, + ); + dispatched += result.dispatched; } - const db = fixture.sql.db(); - expect(await getEventAutomation(db, automation.id)).toMatchObject({ - status: "active", - }); - // The limit is the same as for a trigger with an identifier. - expect(await occur("github:deploy-over", nowMs)).toBe(0); - expect(await getEventAutomation(db, automation.id)).toMatchObject({ - status: "blocked", - }); - } finally { - vi.unstubAllEnvs(); + return dispatched; + }; + const nowMs = Date.now(); + for (let index = 0; index < botConfig.maxEventAutomationRuns; index += 1) { + expect(await occur(`github:deploy-${index}`, nowMs)).toBe(1); } + const db = fixture.sql.db(); + expect(await getEventAutomation(db, automation.id)).toMatchObject({ + status: "active", + }); + // The limit is the same as for a trigger with an identifier. + expect(await occur("github:deploy-over", nowMs)).toBe(0); + expect(await getEventAutomation(db, automation.id)).toMatchObject({ + status: "blocked", + }); }, 30_000); }); diff --git a/packages/junior/tests/integration/event-automation-management.test.ts b/packages/junior/tests/integration/event-automation-management.test.ts index 09e6d17309..819a665c95 100644 --- a/packages/junior/tests/integration/event-automation-management.test.ts +++ b/packages/junior/tests/integration/event-automation-management.test.ts @@ -7,12 +7,13 @@ import { context, createTask, execute, - EVENT_CATALOG, + useGitHubEvents, teamId, } from "../fixtures/event-automations"; import { getConversationEventStore, getConversationStore } from "@/chat/db"; import { loadPendingMessageCards } from "@/chat/conversations/pending-cards"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; import { eq } from "drizzle-orm"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { getEventAutomation } from "@/chat/event-automations/store"; @@ -44,9 +45,11 @@ describe("event automation management", () => { source: "local", nowMs: Date.now(), }); + await useGitHubEvents(); }); afterEach(async () => { + vi.unstubAllEnvs(); const db = fixture.sql.db(); await db .delete(juniorAutomations) @@ -78,7 +81,7 @@ describe("event automation management", () => { automationToolContext( context("U999", "C123", "public", "1700000000.200000"), ), - EVENT_CATALOG, + getEventCatalog(), ), {}, )) as { @@ -99,7 +102,7 @@ describe("event automation management", () => { (await execute( createSearchAutomationsTool( automationToolContext(context("U999", "COTHER")), - EVENT_CATALOG, + getEventCatalog(), ), input, )) as { automations: Array<{ id: string }> } @@ -116,7 +119,7 @@ describe("event automation management", () => { execute( createRunAutomationNowTool( automationToolContext(context("U999", "COTHER")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id }, ), @@ -125,7 +128,7 @@ describe("event automation management", () => { execute( createUpdateAutomationTool( automationToolContext(context("U999", "COTHER")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, ...input }, ); @@ -150,7 +153,7 @@ describe("event automation management", () => { await execute( createDeleteAutomationTool( automationToolContext(context("U999", "COTHER")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id }, ); @@ -171,7 +174,7 @@ describe("event automation management", () => { execute( createUpdateAutomationTool( automationToolContext(context("U999", "COTHER", "public")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -213,7 +216,7 @@ describe("event automation management", () => { execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -226,15 +229,13 @@ describe("event automation management", () => { await execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "review_target", label: "Updated GitHub PR label", events: ["pull_request.review.changes_requested"], }, @@ -246,24 +247,23 @@ describe("event automation management", () => { credentialMode: "creator", trigger: { label: "Updated GitHub PR label", - resourceType: "review_target", + match: { repo: "getsentry/junior", pullRequest: 1174 }, }, }); await execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1176", - resourceType: "pull_request", label: "GitHub PR getsentry/junior#1176", events: ["pull_request.review.changes_requested"], + match: { pullRequest: 1176 }, }, }, ); @@ -271,13 +271,13 @@ describe("event automation management", () => { await getEventAutomation(fixture.sql.db(), created.automation.id), ).toMatchObject({ credentialMode: "system", - trigger: { identifier: "getsentry/junior#1176" }, + trigger: { match: { repo: "getsentry/junior", pullRequest: 1176 } }, }); await execute( createUpdateAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -289,7 +289,7 @@ describe("event automation management", () => { await execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -304,7 +304,7 @@ describe("event automation management", () => { await execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -328,7 +328,7 @@ describe("event automation management", () => { const deleted = await execute( createDeleteAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -368,7 +368,7 @@ describe("event automation management", () => { execute( createUpdateAutomationTool( automationToolContext(context("U999")), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -379,7 +379,7 @@ describe("event automation management", () => { const listed = (await execute( createSearchAutomationsTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), {}, )) as { diff --git a/packages/junior/tests/integration/event-automations.test.ts b/packages/junior/tests/integration/event-automations.test.ts index 47e2f4e3c2..b7810f1f67 100644 --- a/packages/junior/tests/integration/event-automations.test.ts +++ b/packages/junior/tests/integration/event-automations.test.ts @@ -6,7 +6,7 @@ import { context, createTask, execute, - EVENT_CATALOG, + useGitHubEvents, teamId, } from "../fixtures/event-automations"; import { getConversationEventStore, getConversationStore } from "@/chat/db"; @@ -17,9 +17,11 @@ import { commitAssistantMessage } from "@/chat/task-execution/assistant-message" import { setDashboardConversationLinkOptions } from "@/chat/dashboard-link"; import { sendSlackReply } from "@/chat/slack/reply"; import { getCapturedSlackApiCalls } from "../msw/handlers/slack-api"; +import { mockTitleModel } from "../fixtures/title-model"; import { createJuniorApi } from "@/api"; import { conversationDetailReportSchema } from "@/api/schema"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; import { asc, eq } from "drizzle-orm"; import { getDispatchRecord, @@ -89,9 +91,11 @@ describe("event automations", () => { source: "local", nowMs: Date.now(), }); + await useGitHubEvents(); }); afterEach(async () => { + vi.unstubAllEnvs(); const db = fixture.sql.db(); await db .delete(juniorAutomations) @@ -103,6 +107,7 @@ describe("event automations", () => { it("delivers the current automation card and retains its reference in web history", async () => { const conversationId = "local:automation-card"; + mockTitleModel("Review fixes."); const links = setDashboardConversationLinkOptions({ baseURL: "https://junior.example.com", }); @@ -124,7 +129,7 @@ describe("event automations", () => { const updated = await execute( createUpdateAutomationTool( automationToolContext(taskContext), - EVENT_CATALOG, + getEventCatalog(), ), { automationId: created.automation.id, @@ -134,8 +139,6 @@ describe("event automations", () => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "pull_request", label: "Review feedback", events: ["pull_request.review.commented"], }, @@ -170,7 +173,8 @@ describe("event automations", () => { key: created.automation.id, label: "Review fixes.", title: "Review fixes.", - trigger: "Review feedback · pull request review commented", + trigger: + "Review feedback · pull request review commented · filters: repo is getsentry/junior and pullRequest is 1174", url: `https://junior.example.com/automations/${created.automation.id}`, }), ]); @@ -205,7 +209,8 @@ describe("event automations", () => { key: "trigger", label: "When", type: "string", - value: "Review feedback · pull request review commented", + value: + "Review feedback · pull request review commented · filters: repo is getsentry/junior and pullRequest is 1174", long: true, }, ], @@ -263,6 +268,7 @@ describe("event automations", () => { identifier: "getsentry/junior#1174", trustedSummary: "A reviewer requested changes.", data: { + repo: "getsentry/junior", pullRequest: 1174, reviewUrl: "https://github.com/getsentry/junior/pull/1174#pullrequestreview-123", @@ -418,6 +424,7 @@ describe("event automations", () => { Verified details (use these values as given): \`\`\`json { + "repo": "getsentry/junior", "pullRequest": 1174, "reviewUrl": "https://github.com/getsentry/junior/pull/1174#pullrequestreview-123" } @@ -449,7 +456,7 @@ describe("event automations", () => { automationToolContext( context("U123", channelId, sourceVisibility, "1700000000.000100"), ), - EVENT_CATALOG, + getEventCatalog(), ), { instruction: "Address the requested changes.", @@ -459,10 +466,9 @@ describe("event automations", () => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "pull_request", label: "GitHub PR getsentry/junior#1174", events: ["pull_request.review.changes_requested"], + match: { repo: "getsentry/junior", pullRequest: 1174 }, }, }, `dispatch-access-${channelId}`, @@ -476,6 +482,7 @@ describe("event automations", () => { namespace: "github", identifier: "getsentry/junior#1174", trustedSummary: "A reviewer requested changes.", + data: { repo: "getsentry/junior", pullRequest: 1174 }, }, { queue, teamId }, ); @@ -524,28 +531,28 @@ describe("event automations", () => { it("rejects event types that the plugin did not register", async () => { await expect( execute( - createAutomationTool(automationToolContext(context()), EVENT_CATALOG), + createAutomationTool( + automationToolContext(context()), + getEventCatalog(), + ), { instruction: "Handle issue closure.", trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "issue", - label: "GitHub issue getsentry/junior#1174", - events: ["issue.closed"], + label: "GitHub messages", + events: ["message.posted"], + match: { repo: "getsentry/junior" }, }, }, "unsupported-event", ), - ).rejects.toThrow( - 'This github issue trigger cannot be saved. "issue" is not an enabled resource type. Use one of: "pull_request", "review_target".', - ); + ).rejects.toThrow(/"message\.posted" is not an event for this source/); const listed = (await execute( createSearchAutomationsTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ), {}, )) as { @@ -557,7 +564,7 @@ describe("event automations", () => { it("keeps tool schemas aligned with normalization and storage limits", () => { const createTool = createAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ); const createProperties = ( createTool.inputSchema as { properties?: Record } @@ -569,10 +576,9 @@ describe("event automations", () => { trigger: { type: "event", namespace: "github", - identifier: "getsentry/junior#1174", - resourceType: "pull_request", label: "GitHub PR getsentry/junior#1174", events: ["pull_request.review.changes_requested"], + match: { repo: "getsentry/junior", pullRequest: 1174 }, }, }; expect( @@ -592,14 +598,15 @@ describe("event automations", () => { ...createInput, trigger: { ...createInput.trigger, - identifier: "x".repeat(301), + identifier: "getsentry/junior#1174", + resourceType: "pull_request", }, }), - ).toThrow(/identifier/); + ).toThrow(/Unrecognized keys: "identifier", "resourceType"/); const updateTool = createUpdateAutomationTool( automationToolContext(context()), - EVENT_CATALOG, + getEventCatalog(), ); const updateProperties = ( updateTool.inputSchema as { properties?: Record } @@ -627,6 +634,7 @@ describe("event automations", () => { namespace: "github", identifier: "getsentry/junior#1174", trustedSummary: "A pull request review was submitted.", + data: { repo: "getsentry/junior", pullRequest: 1174 }, }; await expect( @@ -653,23 +661,25 @@ describe("event automations", () => { expect(queue.sentRecords()).toHaveLength(2); }); - it("matches events against the plugin's canonical resource identifier", async () => { + it("saves filters in the case of event data", async () => { const created = (await execute( - createAutomationTool(automationToolContext(context()), EVENT_CATALOG), + createAutomationTool(automationToolContext(context()), getEventCatalog()), { instruction: "Address the requested changes.", trigger: { type: "event", namespace: "github", - identifier: "GetSentry/Junior#1174", - resourceType: "pull_request", label: "GitHub PR getsentry/junior#1174", events: ["pull_request.review.changes_requested"], + match: { repo: "GetSentry/Junior", pullRequest: 1174 }, }, }, - "mixed-case-identifier", - )) as { automation: { id: string; trigger: { identifier: string } } }; - expect(created.automation.trigger.identifier).toBe("getsentry/junior#1174"); + "mixed-case-filter", + )) as { automation: { id: string; trigger: { match: unknown } } }; + expect(created.automation.trigger.match).toEqual({ + repo: "getsentry/junior", + pullRequest: 1174, + }); await expect( ingestEventAutomations( @@ -680,6 +690,7 @@ describe("event automations", () => { namespace: "github", identifier: "getsentry/junior#1174", trustedSummary: "A reviewer requested changes.", + data: { repo: "getsentry/junior", pullRequest: 1174 }, }, { queue, teamId }, ), @@ -704,6 +715,7 @@ describe("event automations", () => { namespace: "github", identifier: "getsentry/junior#1174", trustedSummary: "A reviewer requested changes.", + data: { repo: "getsentry/junior", pullRequest: 1174 }, }, { queue, teamId }, ), diff --git a/packages/junior/tests/integration/scheduled-automation-tools.test.ts b/packages/junior/tests/integration/scheduled-automation-tools.test.ts index 3de1e703c2..a78341c512 100644 --- a/packages/junior/tests/integration/scheduled-automation-tools.test.ts +++ b/packages/junior/tests/integration/scheduled-automation-tools.test.ts @@ -2474,8 +2474,6 @@ describe("Scheduled automation tool wiring via createTools", () => { trigger: { type: "event", namespace: "slack", - identifier: "C123", - resourceType: "channel", label: "#general", events: ["message.posted"], }, diff --git a/packages/junior/tests/integration/slack/channel-message-automations.test.ts b/packages/junior/tests/integration/slack/channel-message-automations.test.ts index f37a854d3f..05df4dc12e 100644 --- a/packages/junior/tests/integration/slack/channel-message-automations.test.ts +++ b/packages/junior/tests/integration/slack/channel-message-automations.test.ts @@ -38,15 +38,10 @@ const TEAM_ID = "T123"; let fixture: LocalJuniorSqlFixture; let queue: ConversationWorkQueueTestAdapter; -function trigger( - identifier = "current_channel", - match?: Record, -) { +function trigger(match?: Record) { return { type: "event" as const, namespace: "slack", - identifier, - resourceType: "channel", label: "New messages in #discuss-integration", events: ["message.posted"], ...(match ? { match } : undefined), @@ -137,7 +132,7 @@ describe("Slack channel-message automations", () => { await getEventAutomation(fixture.sql.db(), automation.id), ).toMatchObject({ credentialMode: "system", - trigger: { identifier: "C123", namespace: "slack" }, + trigger: { match: { channelId: "C123" }, namespace: "slack" }, }); // A thread reply, a bot post, and a message in another channel do not run it. @@ -212,7 +207,7 @@ describe("Slack channel-message automations", () => { outcomes: [ { action: "send_message", destination: "current_conversation" }, ], - trigger: trigger("current_channel", { userId: "U777" }), + trigger: trigger({ userId: "U777" }), }, )) as { automation: { id: string } }; @@ -258,29 +253,11 @@ describe("Slack channel-message automations", () => { instruction: "Summarize new messages.", title: "Summarize secrets", outcomes: [], - trigger: trigger("C999"), + trigger: trigger({ channelId: "C999" }), }, ), ).rejects.toThrow("can watch only the channel where it posts"); - // A trigger with no identifier would read every channel. - const { - identifier: _identifier, - resourceType: _type, - ...anyChannel - } = trigger(); - await expect( - execute( - createAutomationTool(automationToolContext(channelContext), catalog), - { - instruction: "Summarize new messages.", - title: "Summarize every channel", - outcomes: [], - trigger: anyChannel, - }, - ), - ).rejects.toThrow("This source cannot run for every resource."); - // A message runs only automations that post in its own channel, even // when a saved row names that channel and posts somewhere else. const { automation } = (await execute( @@ -303,6 +280,47 @@ describe("Slack channel-message automations", () => { expect((await postChannelMessage({ ts: "1712345.0006" })).status).toBe(200); expect(queue.sentRecords()).toEqual([]); + // An update cannot drop the channel filter. + const { automation: scoped } = (await execute( + createAutomationTool(automationToolContext(channelContext), catalog), + { + instruction: "Summarize new messages.", + title: "Summarize scoped messages", + outcomes: [], + trigger: trigger(), + }, + "create-scoped", + )) as { automation: { id: string } }; + await execute( + createUpdateAutomationTool( + automationToolContext(channelContext), + catalog, + ), + { + automationId: scoped.id, + trigger: { ...trigger(), match: { channelId: null } }, + }, + "drop-channel", + ); + expect(await getEventAutomation(fixture.sql.db(), scoped.id)).toMatchObject( + { trigger: { match: { channelId: "C123" } } }, + ); + + // A trigger saved with the channel as its identifier keeps running. + await fixture.sql + .db() + .update(juniorAutomations) + .set({ + identifier: "C123", + record: sql`jsonb_set(jsonb_set(${juniorAutomations.record} #- '{trigger,match}', '{trigger,identifier}', '"C123"'), '{trigger,resourceType}', '"channel"')`, + }) + .where(eq(juniorAutomations.id, scoped.id)); + const saved = await getEventAutomation(fixture.sql.db(), scoped.id); + expect(saved?.trigger).toMatchObject({ identifier: "C123" }); + expect(saved?.trigger.match).toBeUndefined(); + expect((await postChannelMessage({ ts: "1712345.0007" })).status).toBe(200); + expect(queue.sentRecords()).toHaveLength(1); + // A DM is not a channel, so the automation could never run there. await expect( execute(