diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md
index 919bced9a2..836a3f7149 100644
--- a/packages/docs/src/content/docs/concepts/automations.md
+++ b/packages/docs/src/content/docs/concepts/automations.md
@@ -61,6 +61,8 @@ 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.
+
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.
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.
diff --git a/packages/junior-dashboard/e2e/automation-editor.spec.ts b/packages/junior-dashboard/e2e/automation-editor.spec.ts
index bd161c3f48..b193ca76d1 100644
--- a/packages/junior-dashboard/e2e/automation-editor.spec.ts
+++ b/packages/junior-dashboard/e2e/automation-editor.spec.ts
@@ -283,7 +283,8 @@ test("makes an earlier version active from version history", async ({
kind: "scheduled",
version: 1,
createdAt: "2026-07-28T16:00:00.000Z",
- editedBy: null,
+ // A version that Junior saved names Junior as its editor.
+ editedBy: "junior",
definition,
},
];
@@ -318,6 +319,11 @@ test("makes an earlier version active from version history", async ({
// The newest version is compared with the one before it.
await expect(page.getByText("Compared with version 1")).toBeVisible();
await expect(page.getByText("Added: Post a shorter summary")).toBeAttached();
+ await expect(
+ versionList
+ .getByRole("button", { name: /^Version 1/ })
+ .getByText(/^Junior · /),
+ ).toBeVisible();
await screenshot(page, "automation-versions");
await versionList.getByRole("button", { name: /^Version 1/ }).click();
await expect(page).toHaveURL(/[?&]version=1(?:&|$)/);
diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationOverview.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationOverview.tsx
index 0687cdace6..e349e0d002 100644
--- a/packages/junior-dashboard/src/client/pages/automations/AutomationOverview.tsx
+++ b/packages/junior-dashboard/src/client/pages/automations/AutomationOverview.tsx
@@ -92,6 +92,11 @@ function AutomationSettings(props: { automation: AutomationSummary }) {
{automation.trigger.events.join(", ")}
+ {automation.trigger.notes?.map((note) => (
+
+ {note}
+
+ ))}
)}
diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsTab.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsTab.tsx
index 8df410bc34..27d13eefb0 100644
--- a/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsTab.tsx
+++ b/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsTab.tsx
@@ -476,6 +476,7 @@ function diffLines(before: string, after: string): DiffLine[] {
}
function versionEditor(version: ListedAutomationVersion): string {
+ if (version.editedBy === "junior") return "Junior";
return version.editedBy
? (version.editedBy.fullName ??
version.editedBy.userName ??
diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts
index b1a7734cc6..81147ad88d 100644
--- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts
+++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts
@@ -3021,6 +3021,9 @@ function mockTasks(): AutomationSummary[] {
events: ["issue closed"],
filters: ["labels includes bug", "repository is api or web"],
problems: [],
+ notes: [
+ "Kept its exact issue because no filters select the same events.",
+ ],
},
status: "active",
},
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 9935a980d4..182694fec9 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
@@ -180,11 +180,15 @@ describe("Event automation management", () => {
const creates = completedToolCalls("createEventAutomation", conversation);
expect(creates).toHaveLength(1);
- expect(creates[0]!.input).toMatchObject({
- trigger: {
- namespace: "github",
- identifier: "getsentry/junior",
- resourceType: "repository",
+ // Check the saved trigger, not the call input. A repository identifier
+ // and a repo filter save the same trigger.
+ expect(toolOutput(creates[0]!)).toMatchObject({
+ automation: {
+ trigger: {
+ namespace: "github",
+ identifier: null,
+ match: { repo: "getsentry/junior" },
+ },
},
});
expect(new Set(triggerEvents(creates[0]!))).toEqual(
@@ -268,13 +272,18 @@ describe("Event automation management", () => {
removal,
);
expect(updates).toHaveLength(1);
- expect(updates[0]!.input).toMatchObject({
- automationId: id,
- trigger: {
- events: ["issue.reopened"],
- identifier: "getsentry/junior#208",
- namespace: "github",
- resourceType: "issue",
+ expect(updates[0]!.input).toMatchObject({ automationId: id });
+ // Check the saved trigger, not the call input. The update may omit the
+ // identifier to keep it, and the issue identifier is saved as filters.
+ expect(toolOutput(updates[0]!)).toMatchObject({
+ automation: {
+ id,
+ trigger: {
+ events: ["issue.reopened"],
+ identifier: null,
+ match: { repo: "getsentry/junior", issue: 208 },
+ namespace: "github",
+ },
},
});
expect(
diff --git a/packages/junior-github/src/events/identifier-match.ts b/packages/junior-github/src/events/identifier-match.ts
new file mode 100644
index 0000000000..8e9e837bb6
--- /dev/null
+++ b/packages/junior-github/src/events/identifier-match.ts
@@ -0,0 +1,117 @@
+import type { EventMatch } from "@sentry/junior-plugin-api";
+import { gitHubDeploymentSourceResource } from "./deployment.js";
+import { gitHubIssueResource } from "./issue.js";
+import { gitHubPullRequestResource } from "./pull-request.js";
+import { gitHubReleaseSourceResource } from "./release.js";
+import { gitHubRepositoryResource } from "./repository.js";
+
+const REPO = String.raw`[^\s/:#]+\/[^\s/:#]+`;
+const DEPLOYMENT_SOURCE = new RegExp(
+ String.raw`^deployment-source:(${REPO})(?::([^\s:]+))?:([0-9a-f]{40})$`,
+);
+const RELEASE_SOURCE = new RegExp(
+ String.raw`^release-source:(${REPO})(?::([^\s:]+))?$`,
+);
+const NUMBERED = new RegExp(String.raw`^(${REPO})#(\d+)$`);
+const REPOSITORY = new RegExp(String.raw`^${REPO}$`);
+
+function decoded(value: string): string | undefined {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return undefined;
+ }
+}
+
+/** Filters for one identifier, before the check that the plugin builds it. */
+function identifierFilters(
+ identifier: string,
+ eventType: string,
+): { match: EventMatch; built: string } | undefined {
+ const deployment = DEPLOYMENT_SOURCE.exec(identifier);
+ if (deployment) {
+ if (!eventType.startsWith("deployment.")) return undefined;
+ const [, repo, encodedEnvironment, commitSha] = deployment;
+ const environment =
+ encodedEnvironment === undefined
+ ? undefined
+ : decoded(encodedEnvironment);
+ if (encodedEnvironment !== undefined && !environment) return undefined;
+ return {
+ match: {
+ repo: repo!,
+ commitSha: commitSha!,
+ ...(environment ? { environment } : undefined),
+ },
+ built: gitHubDeploymentSourceResource({
+ repo: repo!,
+ commitSha: commitSha!,
+ environment,
+ }).identifier,
+ };
+ }
+ const release = RELEASE_SOURCE.exec(identifier);
+ if (release) {
+ if (eventType !== "release.published") return undefined;
+ const [, repo, encodedTag] = release;
+ const tag = encodedTag === undefined ? undefined : decoded(encodedTag);
+ if (encodedTag !== undefined && !tag) return undefined;
+ return {
+ match: { repo: repo!, ...(tag ? { tag } : undefined) },
+ built: gitHubReleaseSourceResource({ repo: repo!, tag }).identifier,
+ };
+ }
+ const numbered = NUMBERED.exec(identifier);
+ if (numbered) {
+ const repo = numbered[1]!;
+ const number = Number(numbered[2]);
+ if (eventType.startsWith("issue.")) {
+ return {
+ match: { repo, issue: number },
+ built: gitHubIssueResource({ repo, number }).identifier,
+ };
+ }
+ if (eventType.startsWith("pull_request.")) {
+ return {
+ match: { repo, pullRequest: number },
+ built: gitHubPullRequestResource({ repo, number }).identifier,
+ };
+ }
+ return undefined;
+ }
+ if (
+ REPOSITORY.test(identifier) &&
+ (eventType.startsWith("deployment.") ||
+ eventType.startsWith("issue.") ||
+ eventType.startsWith("pull_request."))
+ ) {
+ return {
+ match: { repo: identifier },
+ built: gitHubRepositoryResource({ repo: identifier }).identifier,
+ };
+ }
+ return undefined;
+}
+
+/**
+ * Turn a saved GitHub identifier into the filters that select the same
+ * events of one type. Event data has the same lowercase values as
+ * identifiers. Environment and tag parts are URI-encoded in identifiers, so
+ * they are decoded. `owner/repo#n` names an issue on issue events and a pull
+ * request on the others.
+ *
+ * The filters must rebuild the same identifier with the builder that the
+ * webhooks use. Otherwise the identifier never got events, such as a tag that
+ * was not encoded, and filters could match events that it never got.
+ */
+export function gitHubIdentifierMatch(input: {
+ identifier: string;
+ eventType: string;
+}): EventMatch | undefined {
+ const filters = identifierFilters(input.identifier, input.eventType);
+ // Saved identifiers went through the plugin's `normalizeIdentifier`.
+ if (!filters || filters.built.toLowerCase() !== input.identifier) {
+ return undefined;
+ }
+ return filters.match;
+}
diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts
index e432e8108e..fe6088b89f 100644
--- a/packages/junior-github/src/plugin.ts
+++ b/packages/junior-github/src/plugin.ts
@@ -17,6 +17,7 @@ import {
import { createGitHubTools } from "./tools.js";
import { createGitHubWebhookRoute } from "./webhooks/handler.js";
import { gitHubEventDeclarations } from "./events/declarations.js";
+import { gitHubIdentifierMatch } from "./events/identifier-match.js";
import {
loadPullRequestCommitPages,
loadRepositoryRole,
@@ -207,6 +208,7 @@ export function githubPlugin(
),
isEnabled: () => Boolean(readEnv("GITHUB_WEBHOOK_SECRET")),
normalizeIdentifier: (identifier) => identifier.toLowerCase(),
+ identifierMatch: gitHubIdentifierMatch,
},
manifest: {
name: "github",
diff --git a/packages/junior-github/src/webhooks/check-suite.ts b/packages/junior-github/src/webhooks/check-suite.ts
index f026d1a925..954929f935 100644
--- a/packages/junior-github/src/webhooks/check-suite.ts
+++ b/packages/junior-github/src/webhooks/check-suite.ts
@@ -451,7 +451,7 @@ export function normalizeCheckSuiteEvents(
/** Identifiers and event types one completed check suite may publish. */
export function parseCheckSuitePublishTargets(body: unknown):
| {
- data: { repo: string };
+ data: { repo: string; pullRequest?: number };
eventTypes: string[];
identifiers: string[];
}
@@ -465,18 +465,25 @@ export function parseCheckSuitePublishTargets(body: unknown):
const identifiers = new Set([
gitHubRepositoryResource({ repo }).identifier,
]);
- for (const pullRequest of parsed.data.check_suite.pull_requests) {
- if (!isCheckSuiteRepositoryPullRequest(pullRequest, repository.id))
- continue;
+ const pullRequests = parsed.data.check_suite.pull_requests.filter(
+ (pullRequest) =>
+ isCheckSuiteRepositoryPullRequest(pullRequest, repository.id),
+ );
+ for (const pullRequest of pullRequests) {
identifiers.add(
gitHubPullRequestResource({ number: pullRequest.number, repo })
.identifier,
);
}
return {
- // Every copy sets this repo, so a broad trigger for another repo adds
- // no lazy keys.
- data: { repo: gitHubRepoDataSchema.parse(repo) },
+ // Every copy sets this repo, and this pull request when there is one,
+ // so a trigger for another repo or pull request adds no lazy keys.
+ data: {
+ repo: gitHubRepoDataSchema.parse(repo),
+ ...(pullRequests.length === 1
+ ? { pullRequest: pullRequests[0]!.number }
+ : undefined),
+ },
eventTypes: [eventType],
identifiers: [...identifiers],
};
diff --git a/packages/junior-github/src/webhooks/pull-request-match-data.ts b/packages/junior-github/src/webhooks/pull-request-match-data.ts
index d5565f51be..ff40f512b0 100644
--- a/packages/junior-github/src/webhooks/pull-request-match-data.ts
+++ b/packages/junior-github/src/webhooks/pull-request-match-data.ts
@@ -257,11 +257,17 @@ export async function withGitHubPullRequestMatchData(args: {
};
// A match decision needs every value that a filter uses.
+ const pullRequests = new Set(numbers.values());
const keys =
(await args.neededMatchKeys?.({
- // Every copy sets this repo, so a broad trigger for another repo adds
- // no lazy keys.
- data: { repo: gitHubRepoDataSchema.parse(context.repositoryFullName) },
+ // Every copy sets this repo, and this pull request when there is one,
+ // so a trigger for another repo or pull request adds no lazy keys.
+ data: {
+ repo: gitHubRepoDataSchema.parse(context.repositoryFullName),
+ ...(pullRequests.size === 1
+ ? { pullRequest: [...pullRequests][0]! }
+ : undefined),
+ },
eventTypes: [...new Set(pullRequestEvents.map((e) => e.eventType))],
identifiers: [...new Set(pullRequestEvents.map((e) => e.identifier))],
})) ?? [];
diff --git a/packages/junior-github/tests/event-data.test.ts b/packages/junior-github/tests/event-data.test.ts
index f140ebcaea..00d28a3d78 100644
--- a/packages/junior-github/tests/event-data.test.ts
+++ b/packages/junior-github/tests/event-data.test.ts
@@ -1,8 +1,4 @@
-import {
- eventMatches,
- type EventInput,
- type EventMatch,
-} from "@sentry/junior-plugin-api";
+import { eventMatches, type EventInput } from "@sentry/junior-plugin-api";
import {
filterEventMismatches,
identifierMatchMismatches,
@@ -252,56 +248,20 @@ async function publish(
expect(event.data).toMatchObject(data);
}
}
+ // When every copy is for one pull request, core gets its number, so a
+ // trigger for another pull request skips the lookups.
+ const numbers = [
+ ...new Set(published.map((event) => event.data?.pullRequest)),
+ ];
+ const onePullRequest = numbers.length === 1 ? numbers[0] : undefined;
+ expect(known.map((data) => data.pullRequest)).toEqual(
+ known.map(() => onePullRequest),
+ );
events.push(...published);
}
return events;
}
-/**
- * Turn a saved GitHub identifier into filters, the way a saved trigger is
- * converted. Saved identifiers are lowercase. The environment and tag parts are
- * URI-encoded, so conversion decodes them. `owner/repo#n` names an issue on
- * issue events and a pull request on the others.
- */
-function gitHubIdentifierMatch(event: {
- eventType: string;
- identifier: string;
-}): EventMatch | undefined {
- const { eventType } = event;
- // Saved identifiers went through the plugin's `normalizeIdentifier`.
- const identifier = event.identifier.toLowerCase();
- const deployment =
- /^deployment-source:([^:]+\/[^:]+)(?::([^:]+))?:([0-9a-f]{40})$/.exec(
- identifier,
- );
- if (deployment) {
- const [, repo, environment, commitSha] = deployment;
- return {
- repo: repo!,
- commitSha: commitSha!,
- ...(environment
- ? { environment: decodeURIComponent(environment) }
- : undefined),
- };
- }
- const release = /^release-source:([^:]+\/[^:]+)(?::(.+))?$/.exec(identifier);
- if (release) {
- const [, repo, tag] = release;
- return {
- repo: repo!,
- ...(tag ? { tag: decodeURIComponent(tag) } : undefined),
- };
- }
- const numbered = /^([^#]+\/[^#]+)#(\d+)$/.exec(identifier);
- if (numbered) {
- const key = eventType.startsWith("issue.") ? "issue" : "pullRequest";
- return { repo: numbered[1]!, [key]: Number(numbered[2]) };
- }
- return /^[^/:#]+\/[^/:#]+$/.test(identifier)
- ? { repo: identifier }
- : undefined;
-}
-
describe("GitHub event data", () => {
it("declares filter events that match the data the webhooks set", async () => {
const events = await publish(fixtureDeliveries(BASE_SCOPE), "delivery");
@@ -373,9 +333,33 @@ describe("GitHub event data", () => {
resourceTypes: githubPlugin().events?.resourceTypes ?? [],
events,
matches: eventMatches,
- toMatch: gitHubIdentifierMatch,
+ // Saved identifiers went through the plugin's `normalizeIdentifier`.
+ toMatch: ({ eventType, identifier }) =>
+ githubPlugin().events?.identifierMatch?.({
+ eventType,
+ identifier: identifier.toLowerCase(),
+ }),
}),
).toEqual([]);
+ // An identifier that the webhooks never build gets no filters, so a
+ // trigger that never ran does not start to run.
+ const sha = "a".repeat(40);
+ expect(
+ [
+ ["release-source:getsentry/junior:release/1.0", "release.published"],
+ [
+ `deployment-source:getsentry/junior:prod/eu:${sha}`,
+ "deployment.failed",
+ ],
+ ["getsentry/junior#007", "pull_request.opened"],
+ ["getsentry/junior#007", "issue.opened"],
+ ].map(([identifier, eventType]) =>
+ githubPlugin().events?.identifierMatch?.({
+ identifier: identifier!,
+ eventType: eventType!,
+ }),
+ ),
+ ).toEqual([undefined, undefined, undefined, undefined]);
// 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/webhook-outcomes.test.ts b/packages/junior-github/tests/webhook-outcomes.test.ts
index 50420d0014..704fe38e38 100644
--- a/packages/junior-github/tests/webhook-outcomes.test.ts
+++ b/packages/junior-github/tests/webhook-outcomes.test.ts
@@ -1340,8 +1340,9 @@ describe("GitHub webhook events", () => {
},
}),
).toEqual({
- // Core uses the repo to skip broad triggers for other repositories.
- data: { repo: "getsentry/junior" },
+ // Core uses the repo and the one pull request to skip triggers for
+ // other repositories or pull requests.
+ data: { repo: "getsentry/junior", pullRequest: 10 },
eventTypes: ["pull_request.checks.recovered"],
identifiers: ["getsentry/junior", "getsentry/junior#10"],
});
diff --git a/packages/junior-linear/src/events/identifier-match.ts b/packages/junior-linear/src/events/identifier-match.ts
new file mode 100644
index 0000000000..1b781752f2
--- /dev/null
+++ b/packages/junior-linear/src/events/identifier-match.ts
@@ -0,0 +1,32 @@
+import type { EventMatch } from "@sentry/junior-plugin-api";
+import {
+ LINEAR_ISSUE_EVENTS,
+ linearIssueResource,
+ linearTeamResource,
+} from "./issue.js";
+
+/**
+ * Turn a saved Linear identifier into the filters that select the same issue
+ * events. Issue and team identifiers share one form, so the shape decides:
+ * an issue identifier is `TEAM-123`, and a team key has no `-`.
+ */
+export function linearIdentifierMatch(input: {
+ identifier: string;
+ eventType: string;
+}): EventMatch | undefined {
+ if (!(LINEAR_ISSUE_EVENTS as readonly string[]).includes(input.eventType))
+ return undefined;
+ const { identifier } = input;
+ // Saved identifiers went through the plugin's `normalizeIdentifier`.
+ if (/^[^\s-]+-\d+$/.test(identifier)) {
+ return linearIssueResource({ identifier }).identifier === identifier
+ ? { issueIdentifier: identifier }
+ : undefined;
+ }
+ if (/^[^\s-]+$/.test(identifier)) {
+ return linearTeamResource({ teamKey: identifier }).identifier === identifier
+ ? { teamKey: identifier }
+ : undefined;
+ }
+ return undefined;
+}
diff --git a/packages/junior-linear/src/plugin.ts b/packages/junior-linear/src/plugin.ts
index 716d7f5165..2bb5f8c2b6 100644
--- a/packages/junior-linear/src/plugin.ts
+++ b/packages/junior-linear/src/plugin.ts
@@ -9,6 +9,7 @@ import {
LINEAR_ISSUE_EVENTS,
LINEAR_ISSUE_MATCH_FIELDS,
} from "./events/issue.js";
+import { linearIdentifierMatch } from "./events/identifier-match.js";
import { createLinearWebhookRoute } from "./webhooks/handler.js";
import { linearWebhookSecret } from "./webhooks/secret.js";
@@ -193,6 +194,7 @@ export function linearPlugin(): PluginRegistration {
),
isEnabled: () => Boolean(linearWebhookSecret()),
normalizeIdentifier: (identifier) => identifier.toUpperCase(),
+ identifierMatch: linearIdentifierMatch,
},
manifest: {
// Optional shared read account. It has no effect until an admin
diff --git a/packages/junior-linear/tests/webhook.test.ts b/packages/junior-linear/tests/webhook.test.ts
index bca1b120c2..7a70c57500 100644
--- a/packages/junior-linear/tests/webhook.test.ts
+++ b/packages/junior-linear/tests/webhook.test.ts
@@ -1,9 +1,5 @@
import { createHmac } from "node:crypto";
-import {
- eventMatches,
- type EventInput,
- type EventMatch,
-} from "@sentry/junior-plugin-api";
+import { eventMatches, type EventInput } from "@sentry/junior-plugin-api";
import {
filterEventMismatches,
identifierMatchMismatches,
@@ -117,13 +113,12 @@ describe("Linear webhook events", () => {
body.data.team.key = teamKey!;
return normalizeLinearEvents({ body, linearEvent: "Issue" });
});
- const toMatch = (event: { identifier: string }): EventMatch => {
- // Saved identifiers went through the plugin's `normalizeIdentifier`.
- const identifier = event.identifier.toUpperCase();
- return /-\d+$/.test(identifier)
- ? { issueIdentifier: identifier }
- : { teamKey: identifier };
- };
+ // Saved identifiers went through the plugin's `normalizeIdentifier`.
+ const toMatch = (event: { eventType: string; identifier: string }) =>
+ linearPlugin().events?.identifierMatch?.({
+ eventType: event.eventType,
+ identifier: event.identifier.toUpperCase(),
+ });
expect(
identifierMatchMismatches({
@@ -133,6 +128,16 @@ describe("Linear webhook events", () => {
toMatch,
}),
).toEqual([]);
+ // Saved identifiers are uppercase. Another identifier never got events,
+ // so it gets no filters and does not start to run.
+ expect(
+ ["eng-7", "eng"].map((identifier) =>
+ linearPlugin().events?.identifierMatch?.({
+ eventType: "issue.created",
+ identifier,
+ }),
+ ),
+ ).toEqual([undefined, undefined]);
});
it("normalizes a created issue for the issue and team", () => {
diff --git a/packages/junior-plugin-api/src/events.ts b/packages/junior-plugin-api/src/events.ts
index c1e6d92070..0e63c80f13 100644
--- a/packages/junior-plugin-api/src/events.ts
+++ b/packages/junior-plugin-api/src/events.ts
@@ -430,6 +430,24 @@ export const pluginEventsSchema = z
normalizeIdentifier: z
.function({ input: [z.string()], output: z.string() })
.optional(),
+ /**
+ * Turn a normalized identifier into the filters that select the same
+ * events of one type: every event of that type that goes to the
+ * identifier, and no other. Return nothing when no filters can do that,
+ * for example when the event can leave out a scope value or does not go
+ * to this kind of identifier. Core then keeps the identifier, so a
+ * trigger never gets broader.
+ *
+ * Temporary: it exists only to move event automation triggers off
+ * identifiers. Slice D of getsentry/junior#2362 removes it with the
+ * identifier column.
+ */
+ identifierMatch: z
+ .function({
+ input: [z.object({ identifier: z.string(), eventType: z.string() })],
+ output: eventMatchSchema.optional(),
+ })
+ .optional(),
})
.strict()
.superRefine((registration, context) => {
diff --git a/packages/junior-vercel/src/events/identifier-match.ts b/packages/junior-vercel/src/events/identifier-match.ts
new file mode 100644
index 0000000000..e5c80c8b2c
--- /dev/null
+++ b/packages/junior-vercel/src/events/identifier-match.ts
@@ -0,0 +1,40 @@
+import type { EventMatch } from "@sentry/junior-plugin-api";
+import {
+ VERCEL_DEPLOYMENT_EVENTS,
+ VERCEL_DEPLOYMENT_TARGETS,
+ type VercelDeploymentTarget,
+} from "./deployment.js";
+
+function isTarget(value: string): value is VercelDeploymentTarget {
+ return (VERCEL_DEPLOYMENT_TARGETS as readonly string[]).includes(value);
+}
+
+/**
+ * Turn a saved Vercel identifier into the filters that select the same
+ * deployment events: `projectId`, then `target`, then `commitSha`, one for
+ * each part of `projectId[:target[:commitSha]]`.
+ *
+ * Each part must have the form that `vercelDeploymentResource` builds: a
+ * known target and a full lowercase commit SHA. Otherwise the identifier
+ * never got events, and filters could match events that it never got.
+ */
+export function vercelIdentifierMatch(input: {
+ identifier: string;
+ eventType: string;
+}): EventMatch | undefined {
+ if (
+ !(VERCEL_DEPLOYMENT_EVENTS as readonly string[]).includes(input.eventType)
+ )
+ return undefined;
+ const [projectId, target, commitSha, ...rest] = input.identifier.split(":");
+ if (!projectId || rest.length > 0) return undefined;
+ if (target !== undefined && !isTarget(target)) return undefined;
+ if (commitSha !== undefined && !/^[0-9a-f]{40}$/.test(commitSha)) {
+ return undefined;
+ }
+ return {
+ projectId,
+ ...(target ? { target } : undefined),
+ ...(commitSha ? { commitSha } : undefined),
+ };
+}
diff --git a/packages/junior-vercel/src/index.ts b/packages/junior-vercel/src/index.ts
index ccc6b5f43d..2d44a3e953 100644
--- a/packages/junior-vercel/src/index.ts
+++ b/packages/junior-vercel/src/index.ts
@@ -17,6 +17,7 @@ import {
VERCEL_DEPLOYMENT_SUGGESTED_EVENTS,
} from "./events/deployment.js";
import { createVercelWebhookRoute } from "./webhooks/handler.js";
+import { vercelIdentifierMatch } from "./events/identifier-match.js";
import { vercelWebhookSecret } from "./webhooks/secret.js";
/** Register Vercel runtime metadata, tools, and signed webhook ingress. */
@@ -42,6 +43,7 @@ export function vercelPlugin(): PluginRegistration {
]),
),
isEnabled: () => Boolean(vercelWebhookSecret()),
+ identifierMatch: vercelIdentifierMatch,
},
manifest: {
apiHeaders: {
diff --git a/packages/junior-vercel/tests/webhook.test.ts b/packages/junior-vercel/tests/webhook.test.ts
index 223548d67b..7cd6349c53 100644
--- a/packages/junior-vercel/tests/webhook.test.ts
+++ b/packages/junior-vercel/tests/webhook.test.ts
@@ -1,9 +1,5 @@
import { createHmac } from "node:crypto";
-import {
- eventMatches,
- type EventInput,
- type EventMatch,
-} from "@sentry/junior-plugin-api";
+import { eventMatches, type EventInput } from "@sentry/junior-plugin-api";
import {
filterEventMismatches,
identifierMatchMismatches,
@@ -115,14 +111,8 @@ describe("Vercel webhook events", () => {
return normalizeVercelEvents({ body });
}),
);
- const toMatch = ({ identifier }: { identifier: string }): EventMatch => {
- const [projectId, target, commitSha] = identifier.split(":");
- return {
- projectId: projectId!,
- ...(target ? { target } : undefined),
- ...(commitSha ? { commitSha } : undefined),
- };
- };
+ const toMatch = vercelPlugin().events?.identifierMatch;
+ if (!toMatch) throw new Error("Vercel identifiers must convert to filters");
expect(
identifierMatchMismatches({
@@ -132,6 +122,17 @@ describe("Vercel webhook events", () => {
toMatch,
}),
).toEqual([]);
+ // An identifier that the webhooks never build gets no filters, so a
+ // trigger that never ran does not start to run.
+ expect(
+ [
+ "prj_1:prod",
+ `prj_1:production:${"A".repeat(40)}`,
+ `prj_1:production:${"a".repeat(40)}:extra`,
+ ].map((identifier) =>
+ toMatch({ eventType: "deployment.succeeded", identifier }),
+ ),
+ ).toEqual([undefined, undefined, undefined]);
});
it.each([
diff --git a/packages/junior/src/api/schema/automation.ts b/packages/junior/src/api/schema/automation.ts
index 5aef107181..e3005e2817 100644
--- a/packages/junior/src/api/schema/automation.ts
+++ b/packages/junior/src/api/schema/automation.ts
@@ -89,6 +89,8 @@ export const eventTriggerDescriptionSchema = z
filters: z.array(z.string().min(1)),
/** Why the trigger cannot work. The trigger is available when empty. */
problems: z.array(z.string().min(1)),
+ /** Facts about a trigger that still works, such as a kept identifier. */
+ notes: z.array(z.string().min(1)).min(1).optional(),
})
.strict() satisfies z.ZodType;
@@ -250,8 +252,14 @@ const automationDefinitionFields = {
const automationVersionFields = {
version: z.number().int().positive(),
createdAt: z.string().datetime(),
- /** Null for versions saved by the upgrade, because the editor is unknown. */
- editedBy: eventAutomationPrincipalSchema.nullable(),
+ /**
+ * The person who saved the version, or `"junior"` when Junior changed the
+ * trigger itself. Null for versions saved by the upgrade, because the
+ * editor is unknown.
+ */
+ editedBy: z
+ .union([eventAutomationPrincipalSchema, z.literal("junior")])
+ .nullable(),
};
const scheduledAutomationVersionSchema = z
diff --git a/packages/junior/src/chat/event-automations/README.md b/packages/junior/src/chat/event-automations/README.md
index b2913ba7e4..88e090dd05 100644
--- a/packages/junior/src/chat/event-automations/README.md
+++ b/packages/junior/src/chat/event-automations/README.md
@@ -11,7 +11,9 @@ route handles. Thread replies and Slackbot posts do not publish. Other bot posts
publish with `postedByBot`, but they run only automations whose `match` names
the bot in `userId`. Bots can post often and would use up the run limit of
every other automation in the channel. A Watch cannot select these events. An automation can watch only the channel where it posts, so
-private channel text stays in that channel. Runs always use system credentials,
+private channel text stays in that channel. Its trigger has no identifier
+and a `channelId` filter for that channel. Ingest checks the channel of the
+Event against the destination again. Runs always use system credentials,
because anyone in the channel writes the Event text. Each run replies in the
thread of the source message.
@@ -34,8 +36,44 @@ identifier must have the same identifier in its row. A row with no identifier
must have a trigger that the enabled catalog accepts, so a plugin change that
narrows an event also stops old broad rows. Core logs and skips a row that does
not parse or that the gate rejects, so one bad row cannot stop ingest for its
-namespace. The update tool keeps the saved identifier and resource type when
-they are left out. Only an explicit `null` removes them.
+namespace. The update tool keeps the saved identifier, resource type, and
+filters when they are left out. Only an explicit `null` removes the
+identifier, and only an empty `match` removes the filters.
+
+## Triggers with an identifier
+
+Tools and the dashboard save a trigger with an identifier as a trigger with no
+identifier when its plugin can turn the identifier into filters
+(`identifierMatch`). For example, `getsentry/junior#7` on pull request events
+becomes `repo` and `pullRequest` filters. `trigger-conversion.ts` owns the
+rule. A trigger converts only when the plugin gives the same filters for every
+selected event, each saved filter matches the identifier's value, and the
+result passes the check for a new trigger. A saved list filter that has the
+identifier's value becomes that one value. A conversion never makes a trigger
+broader.
+
+A write is rejected when a saved filter never matches the identifier's value,
+because the trigger could never run. In other cases, the trigger keeps its
+identifier and saves the plain reason in `identifierReason`. An example is a
+Sentry identifier. The Sentry plugin reads its org from each webhook URL and
+has no saved org for the install, so it cannot check the org in an identifier.
+A wrong org would make a dead trigger run, so the plugin gives no
+`identifierMatch`. Each edit that changes the trigger sets
+the reason again. The trigger description shows the reason as a note, not a
+problem, in the dashboard, Slack details, and `listEventAutomations`.
+
+The update tool changes `match` key by key. A value sets a filter, `null`
+removes it, and a filter that the update leaves out keeps its saved value.
+After a conversion the scope lives in `match`, so replacing the whole object
+would make the trigger broader. The dashboard sends the whole trigger from
+its form, which shows every saved filter.
+
+Saved rows keep their identifiers until a later change converts them. Saved
+versions keep their old triggers, and the version schema still reads
+`identifier` and `resourceType`. The version schema also reads
+`editedBy: "junior"` for a version that Junior saves when it changes a trigger
+itself. Nothing writes it yet. The reader comes first, so a release that
+writes it can roll back to this one and keep version history readable.
`searchEventTypes` exposes the same enabled plugin catalog used by
watches and event automations. Create and update tools accept only
diff --git a/packages/junior/src/chat/event-automations/tool-support.ts b/packages/junior/src/chat/event-automations/tool-support.ts
index 6caca2fb6e..5d581dabd6 100644
--- a/packages/junior/src/chat/event-automations/tool-support.ts
+++ b/packages/junior/src/chat/event-automations/tool-support.ts
@@ -28,6 +28,7 @@ import {
requireValidEventTrigger,
type EventCatalog,
} from "@/chat/events/catalog";
+import { triggerWithoutIdentifier } from "@/chat/event-automations/trigger-conversion";
import { juniorToolOutputSchema } from "@/chat/tool-support/structured-result";
import { ToolInputError } from "@/chat/tools/execution/tool-input-error";
import type { ToolRuntimeContext } from "@/chat/tools/types";
@@ -51,6 +52,8 @@ const compactEventAutomationResultSchema = z
match: z.record(z.string(), z.unknown()).optional(),
available: z.boolean(),
problems: z.array(z.string().min(1)).min(1).optional(),
+ /** Facts about a trigger that still works, such as a kept identifier. */
+ notes: z.array(z.string().min(1)).min(1).optional(),
})
.strict(),
credentialMode: z.enum(["system", "creator"]),
@@ -108,7 +111,7 @@ export function registeredEventAutomationTriggerSchema(catalog: EventCatalog) {
.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.',
+ '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. When the plugin can, the identifier is saved as filters that select the same events, so the result shows identifier null and those filters.',
)
.optional(),
resourceType: registeredResourceTypeSchema(plugins)
@@ -127,8 +130,12 @@ export function registeredEventAutomationTriggerSchema(catalog: EventCatalog) {
/**
* 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.
- * The identifier gets its plugin's convention. Events and list values lose
- * repeats.
+ * The identifier gets its plugin's convention. A trigger with an identifier is
+ * saved with no identifier when its plugin can turn the identifier into
+ * filters that select the same events. Otherwise it is saved with the reason
+ * that it keeps the identifier, so each edit sets the reason again. A trigger
+ * whose filter never matches the events of its identifier is rejected,
+ * because it would never run. Events and list values lose repeats.
*/
export function requireEventAutomationTrigger(
catalog: EventCatalog,
@@ -154,7 +161,7 @@ export function requireEventAutomationTrigger(
{ namespace: input.namespace, resourceType, events, match: input.match },
"event_automation",
);
- return {
+ const trigger: EventAutomation["trigger"] = {
namespace: input.namespace,
...(identifier !== undefined && resourceType !== undefined
? {
@@ -170,6 +177,13 @@ export function requireEventAutomationTrigger(
events,
...(match ? { match } : undefined),
};
+ const conversion = triggerWithoutIdentifier(catalog, trigger);
+ if (!conversion.converted && conversion.conflict) {
+ throw new ToolInputError(
+ `The "${conversion.conflict}" filter never matches the events of identifier "${trigger.identifier}", so this trigger would never run. Change or remove the "${conversion.conflict}" filter.`,
+ );
+ }
+ return conversion.trigger;
}
/** Require the active Slack authority used for event-automation management. */
@@ -243,20 +257,31 @@ export async function writableEventAutomation(
return task;
}
+/** A saved trigger in words, with notes about how Junior saved it. */
+export type EventAutomationTriggerDescription = EventTriggerDescription & {
+ /**
+ * Plain facts about a trigger that still works, such as a kept
+ * identifier. Missing when there are none.
+ */
+ notes?: string[];
+};
+
/**
* Describe a saved trigger with the enabled plugins. Its problems say why it
* cannot work: a plugin can remove or rename a resource type, event, or filter
* after the trigger was saved. Resource types with `automation: false` never
- * dispatch event automations.
+ * dispatch event automations. Its notes do not stop it from working.
*/
export function describeEventAutomationTrigger(
trigger: EventAutomation["trigger"],
catalog: EventCatalog,
-): EventTriggerDescription {
- return describeRegisteredEventTrigger(
- automationEventCatalog(catalog),
- trigger,
- );
+): EventAutomationTriggerDescription {
+ return {
+ ...describeRegisteredEventTrigger(automationEventCatalog(catalog), trigger),
+ ...(trigger.identifierReason
+ ? { notes: [trigger.identifierReason] }
+ : undefined),
+ };
}
/** Project an event automation into the bounded tool-result shape. */
@@ -265,7 +290,10 @@ export function compactEventAutomation(
catalog: EventCatalog,
requesterSlackUserId?: string,
) {
- const { problems } = describeEventAutomationTrigger(task.trigger, catalog);
+ const { problems, notes } = describeEventAutomationTrigger(
+ task.trigger,
+ catalog,
+ );
return compactEventAutomationResultSchema.parse({
id: task.id,
status: task.status,
@@ -282,6 +310,7 @@ export function compactEventAutomation(
...(task.trigger.match ? { match: task.trigger.match } : undefined),
available: problems.length === 0,
...(problems.length > 0 ? { problems } : undefined),
+ ...(notes ? { notes } : undefined),
},
credentialMode: task.credentialMode,
isCreator: task.createdBy.slackUserId === requesterSlackUserId,
diff --git a/packages/junior/src/chat/event-automations/trigger-conversion.ts b/packages/junior/src/chat/event-automations/trigger-conversion.ts
new file mode 100644
index 0000000000..3a08b656e9
--- /dev/null
+++ b/packages/junior/src/chat/event-automations/trigger-conversion.ts
@@ -0,0 +1,123 @@
+/**
+ * Turn event automation triggers with an identifier into triggers with no
+ * identifier that select the same events.
+ *
+ * Plugins own their identifier formats through `identifierMatch`. Core owns
+ * the rule: a trigger converts only when its plugin gives one set of filters
+ * for every selected event, the filters agree with the saved filters, and the
+ * result passes the same check as a new trigger. Otherwise the trigger keeps
+ * its identifier and a plain reason, so it never gets broader.
+ */
+import {
+ stableEventMatchKey,
+ type EventMatch,
+} from "@sentry/junior-plugin-api";
+import {
+ automationEventCatalog,
+ registeredEventTriggerProblems,
+ type EventCatalog,
+} from "@/chat/events/catalog";
+import type { EventAutomation } from "./types";
+
+type EventAutomationTrigger = EventAutomation["trigger"];
+
+/**
+ * A trigger with no identifier, or the trigger with its identifier and the
+ * reason that it keeps it. `conflict` names a saved filter that never matches
+ * the events of the identifier, so the trigger can never run.
+ */
+export type TriggerConversion =
+ | { converted: true; trigger: EventAutomationTrigger }
+ | {
+ converted: false;
+ trigger: EventAutomationTrigger;
+ reason: string;
+ conflict?: string;
+ };
+
+/**
+ * Return whether every event of the identifier matches the saved filter
+ * value. The identifier gives one exact value. A saved list matches it when
+ * the list has that value.
+ */
+function savedValueMatches(
+ saved: EventMatch[string],
+ scope: EventMatch[string],
+): boolean {
+ if (
+ stableEventMatchKey({ value: saved }) ===
+ stableEventMatchKey({ value: scope })
+ ) {
+ return true;
+ }
+ return (
+ Array.isArray(saved) &&
+ !Array.isArray(scope) &&
+ (saved as readonly unknown[]).includes(scope)
+ );
+}
+
+/** Why the trigger keeps its identifier, in words for the people who own it. */
+function keptReason(trigger: EventAutomationTrigger, why: string): string {
+ const resource = trigger.resourceType?.replaceAll("_", " ") ?? "resource";
+ return `Kept its exact ${resource} because ${why}.`;
+}
+
+/**
+ * Return the trigger with no identifier that selects the same events as this
+ * trigger. When no such trigger exists, return the trigger with its
+ * identifier and the reason. A trigger with no identifier stays as it is.
+ */
+export function triggerWithoutIdentifier(
+ catalog: EventCatalog,
+ trigger: EventAutomationTrigger,
+): TriggerConversion {
+ const {
+ identifier,
+ resourceType: _resourceType,
+ identifierReason: _identifierReason,
+ ...rest
+ } = trigger;
+ if (identifier === undefined) return { converted: true, trigger };
+ const keep = (why: string, conflict?: string): TriggerConversion => {
+ const reason = keptReason(trigger, why);
+ return {
+ converted: false,
+ trigger: { ...trigger, identifierReason: reason },
+ reason,
+ ...(conflict ? { conflict } : undefined),
+ };
+ };
+ const plugins = automationEventCatalog(catalog);
+ const source = plugins[trigger.namespace];
+ if (!source) return keep("its plugin was not enabled when Junior checked it");
+ const toMatch = source.identifierMatch;
+ if (!toMatch)
+ return keep("its plugin cannot turn the identifier into filters");
+ let scope: EventMatch | undefined;
+ for (const eventType of trigger.events) {
+ const match = toMatch({ identifier, eventType });
+ if (!match) return keep("no filters select the same events");
+ if (scope && stableEventMatchKey(scope) !== stableEventMatchKey(match)) {
+ return keep("its events need different filters");
+ }
+ scope = match;
+ }
+ for (const [key, value] of Object.entries(scope ?? {})) {
+ const saved = trigger.match?.[key];
+ if (saved !== undefined && !savedValueMatches(saved, value)) {
+ return keep(`its ${key} filter never matches its events`, key);
+ }
+ }
+ const match = { ...trigger.match, ...scope };
+ const problems = registeredEventTriggerProblems(plugins, {
+ namespace: trigger.namespace,
+ events: trigger.events,
+ match,
+ });
+ if (problems.some((problem) => problem.reason === "identifier_required")) {
+ return keep("these events can only run for one resource");
+ }
+ if (problems.length > 0) return keep("no filters select the same events");
+ return { converted: true, trigger: { ...rest, match } };
+}
diff --git a/packages/junior/src/chat/event-automations/types.ts b/packages/junior/src/chat/event-automations/types.ts
index fa5e3841f7..c5c6480d59 100644
--- a/packages/junior/src/chat/event-automations/types.ts
+++ b/packages/junior/src/chat/event-automations/types.ts
@@ -38,6 +38,12 @@ export const eventAutomationTriggerSchema = z
.max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH)
.optional(),
resourceType: z.string().min(1).optional(),
+ /**
+ * Why the trigger keeps its identifier instead of filters, in plain
+ * words. Junior sets it when it cannot turn the identifier into filters
+ * that select the same events. A trigger with no identifier has none.
+ */
+ identifierReason: z.string().min(1).max(500).optional(),
})
.strict()
.refine(
@@ -49,6 +55,15 @@ export const eventAutomationTriggerSchema = z
"Set both a resource type and an identifier, or leave out both to run for every resource.",
path: ["identifier"],
},
+ )
+ .refine(
+ (trigger) =>
+ trigger.identifierReason === undefined ||
+ trigger.identifier !== undefined,
+ {
+ message: "Only a trigger with an identifier can keep a reason for it.",
+ path: ["identifierReason"],
+ },
);
/** Event automations target a Slack channel or DM, never a message thread. */
diff --git a/packages/junior/src/chat/events/README.md b/packages/junior/src/chat/events/README.md
index f68493dd15..fed63beb80 100644
--- a/packages/junior/src/chat/events/README.md
+++ b/packages/junior/src/chat/events/README.md
@@ -121,6 +121,12 @@ Watches route events back into an existing conversation.
automation trigger with no identifier can select only events with reach
`"all"` or `{ anyOf }`, and must set one of the `anyOf` filters. Core merges
the filters of every resource type once, when it builds the catalog.
+- A source may declare `identifierMatch`. It turns one identifier and one
+ event type into the filters that select every event of that type that goes
+ to the identifier, and no other. It returns nothing when no filters can do
+ that. Core uses it to save event automation triggers with no identifier
+ (`event-automations/trigger-conversion.ts`). Watches keep their identifier.
+ It is temporary. Slice D of #2362 removes it with the identifier column.
- A match field may declare `case: "lower"` or `case: "upper"`. Core changes a
filter value to that case when it saves a trigger or a Watch, so a value such
as `getsentry/Junior` still matches lowercase event data. The plugin decides
@@ -182,10 +188,17 @@ Watches route events back into an existing conversation.
filters that keep a trigger narrow when the event carries private text or
makes Junior act. Use `"resource"` when a trigger must name one resource.
Add `guidance` there when the agent needs a rule for this event.
-7. When `data` needs a lookup, call `neededMatchKeys` with the values that the
+7. Make `identifierMatch` return the filters for each identifier that gets
+ the event, and nothing for identifiers that do not get it. Build the
+ identifier again from the filters with the shared builder, and return
+ nothing when it differs. Use `identifierMatchMismatches` from
+ `@sentry/junior-testing` in the webhook test to prove that the filters
+ match the same events. Add identifiers that do not rebuild, such as a tag
+ that is not encoded, and check that they give no filters.
+8. When `data` needs a lookup, call `neededMatchKeys` with the values that the
plugin already has, such as `repo`. Core then skips triggers with no
identifier whose filters on those values fail.
-8. Extend the plugin's webhook test with a delivery for the new event.
+9. Extend the plugin's webhook test with a delivery for the new event.
## Timer Watches
diff --git a/packages/junior/src/chat/events/catalog.ts b/packages/junior/src/chat/events/catalog.ts
index e9128fd20d..a7fddafaaa 100644
--- a/packages/junior/src/chat/events/catalog.ts
+++ b/packages/junior/src/chat/events/catalog.ts
@@ -38,7 +38,7 @@ export type EventResourceTypeLimits = {
/** One source as a plugin or core registers it, before the catalog adds merged filters. */
export type EventRegistrationInput = Pick<
PluginEvents,
- "events" | "resourceTypes" | "normalizeIdentifier"
+ "events" | "resourceTypes" | "normalizeIdentifier" | "identifierMatch"
> & {
/** Name people see for the source, such as GitHub. Defaults to the namespace. */
displayName?: string;
diff --git a/packages/junior/src/chat/slack/automation-details.ts b/packages/junior/src/chat/slack/automation-details.ts
index 9309bca798..8866a572f9 100644
--- a/packages/junior/src/chat/slack/automation-details.ts
+++ b/packages/junior/src/chat/slack/automation-details.ts
@@ -85,6 +85,12 @@ function renderAutomationDetails(
long: true,
},
);
+ if (trigger.notes) {
+ fields.push({
+ ...textField("note", "Note", trigger.notes.join(" ")),
+ long: true,
+ });
+ }
if (trigger.problems.length > 0) {
fields.push({
...textField(
diff --git a/packages/junior/src/chat/slack/channel-message-events.ts b/packages/junior/src/chat/slack/channel-message-events.ts
index f64b60b5d7..0a39810a84 100644
--- a/packages/junior/src/chat/slack/channel-message-events.ts
+++ b/packages/junior/src/chat/slack/channel-message-events.ts
@@ -25,6 +25,11 @@ 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. A channel automation always names the channel where it posts.",
+ },
userId: {
kind: "string",
description:
@@ -52,13 +57,18 @@ 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.
+ // channel filter. `requireSlackChannelMessageScope` sets it to the
+ // channel where the automation posts.
[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.",
},
},
+ identifierMatch: ({ identifier, eventType }) =>
+ eventType === MESSAGE_POSTED_EVENT && CHANNEL_ID_PATTERN.test(identifier)
+ ? { channelId: identifier }
+ : undefined,
limits: {
[CHANNEL_RESOURCE_TYPE]: {
watch: false,
@@ -72,8 +82,10 @@ export function slackChannelMessageEvents(): EventRegistrationInput {
/**
* 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 the identifier `current_channel`, the
+ * channel ID, or a `channelId` filter. Reject other channels, so private
+ * channel text cannot reach another channel. Save the trigger with no
+ * identifier and a `channelId` filter for the channel where it posts.
*/
export function requireSlackChannelMessageScope(
automation: EventAutomation,
@@ -85,11 +97,17 @@ 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,
+ resourceType: _resourceType,
+ identifierReason: _identifierReason,
+ ...trigger
+ } = automation.trigger;
+ const named = [
+ identifier === SLACK_CURRENT_CHANNEL_IDENTIFIER ? channelId : identifier,
+ trigger.match?.channelId,
+ ].filter((value) => value !== undefined);
+ if (named.length === 0 || named.some((value) => value !== 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.`,
);
@@ -97,7 +115,7 @@ export function requireSlackChannelMessageScope(
return {
...automation,
credentialMode: "system",
- trigger: { ...automation.trigger, identifier },
+ trigger: { ...trigger, match: { ...trigger.match, channelId } },
};
}
@@ -140,7 +158,7 @@ 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
+ * channel text cannot reach another channel. The `channelId` filter 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
diff --git a/packages/junior/src/chat/tools/list-event-automations.ts b/packages/junior/src/chat/tools/list-event-automations.ts
index 8d970d950e..328115234b 100644
--- a/packages/junior/src/chat/tools/list-event-automations.ts
+++ b/packages/junior/src/chat/tools/list-event-automations.ts
@@ -26,7 +26,7 @@ export function createListEventAutomationsTool(
readOnlyHint: true,
},
description:
- "List event automations for the current destination, not one thread. Each result says whether the requester is the creator (isCreator); only the creator can change its credential use. Warn when trigger.available is false and explain trigger.problems: the automation stays stored, but a plugin no longer supports its resource type, an event, or a filter. Update the trigger to fix it. Public automations can also be updated or deleted by id from another destination in the same workspace.",
+ "List event automations for the current destination, not one thread. Each result says whether the requester is the creator (isCreator); only the creator can change its credential use. Warn when trigger.available is false and explain trigger.problems: the automation stays stored, but a plugin no longer supports its resource type, an event, or a filter. Update the trigger to fix it. trigger.notes are facts about a trigger that still works, such as why it kept its exact identifier; mention them only when relevant. Public automations can also be updated or deleted by id from another destination in the same workspace.",
inputSchema: z.object({}).strict(),
outputSchema: eventAutomationListToolResultSchema,
async execute() {
diff --git a/packages/junior/src/chat/tools/update-event-automation.ts b/packages/junior/src/chat/tools/update-event-automation.ts
index 58c540e2f5..da9b391144 100644
--- a/packages/junior/src/chat/tools/update-event-automation.ts
+++ b/packages/junior/src/chat/tools/update-event-automation.ts
@@ -9,6 +9,7 @@ import {
import { requireRequesterMention } from "@/chat/automations/requester-mention";
import { automationRevision } from "@/chat/automations/revision";
import { editEventAutomation } from "@/chat/event-automations/edit";
+import { eventMatchSchema, type EventMatch } from "@sentry/junior-plugin-api";
import { z } from "zod";
import { getDb } from "@/chat/db";
import {
@@ -27,6 +28,31 @@ import { zodTool } from "@/chat/tool-support/zod-tool";
import { ToolInputError } from "@/chat/tools/execution/tool-input-error";
import type { ToolRuntimeContext } from "@/chat/tools/types";
+/**
+ * Filter changes for an update. A value sets the filter, and null removes
+ * it. A filter that is not named keeps its saved value, so an update cannot
+ * drop the filters that limit a trigger by leaving them out.
+ */
+const matchChangesSchema = z
+ .record(z.string(), z.union([eventMatchSchema.valueType, z.null()]))
+ .describe(
+ "Filter changes. A value sets the filter, null removes it, and a filter that is left out keeps its saved value. Use the matchFields of the resource type. A single value must equal the event data. A list means the event value may be any one entry.",
+ );
+
+/** Apply filter changes to the saved filters, key by key. */
+function changedMatch(
+ saved: EventMatch | undefined,
+ changes: z.output | undefined,
+): EventMatch | undefined {
+ if (changes === undefined) return saved;
+ const match: EventMatch = { ...saved };
+ 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;
+}
+
/** Create the core tool that updates an event automation. */
export function createUpdateEventAutomationTool(
context: ToolRuntimeContext,
@@ -42,13 +68,14 @@ export function createUpdateEventAutomationTool(
},
executionMode: "sequential",
description:
- "Update the instruction, registered trigger, credential use, or status of an event automation.",
+ "Update the instruction, registered trigger, credential use, or status of an event automation. In trigger, leaving out identifier, resourceType, or match keeps the saved value, and only null removes the identifier. match changes only the filters that it names: a value sets that filter, null removes it, and a filter that it leaves out keeps its saved value.",
inputSchema: z
.object({
automationId: z.string().min(1),
title: automationTitleSchema.optional(),
instruction: automationInstructionToolSchema.nullable().optional(),
trigger: registeredEventAutomationTriggerSchema(catalog)
+ .extend({ match: matchChangesSchema.optional() })
.nullable()
.optional(),
outcomes: z
@@ -139,11 +166,16 @@ export function createUpdateEventAutomationTool(
{
title: input.title,
instruction: input.instruction ?? undefined,
- // An omitted identifier or resource type keeps the saved one.
- // Only an explicit null makes the trigger run for every resource.
+ // An omitted identifier, resource type, or filter keeps the saved
+ // one, so a trigger cannot get broader by omission. Only an
+ // explicit null drops the identifier or a filter.
trigger: input.trigger
? {
...input.trigger,
+ match: changedMatch(
+ current.trigger.match,
+ input.trigger.match,
+ ),
identifier:
input.trigger.identifier === undefined
? current.trigger.identifier
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 1a479ef16c..c693b1e2a2 100644
--- a/packages/junior/tests/integration/any-resource-event-automations.test.ts
+++ b/packages/junior/tests/integration/any-resource-event-automations.test.ts
@@ -201,17 +201,77 @@ 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.
- const one = await create(
- {
- ...trigger,
- identifier: "getsentry/junior#7",
- resourceType: "pull_request",
- label: "getsentry/junior#7",
- },
- "one-pull-request",
+ // A new trigger with an identifier is saved as the filters that select
+ // the same events, so the tools stop writing identifiers.
+ const onePullRequestTrigger = {
+ ...trigger,
+ identifier: "getsentry/junior#7",
+ resourceType: "pull_request",
+ label: "getsentry/junior#7",
+ };
+ const one = await create(onePullRequestTrigger, "one-pull-request");
+ const onePullRequest = {
+ identifier: null,
+ resourceType: null,
+ match: { repo: "getsentry/junior", pullRequest: 7 },
+ };
+ expect(one.automation.trigger).toMatchObject(onePullRequest);
+ // A saved list that has the identifier's value becomes that value. A
+ // filter that never matches the identifier would never run, so the
+ // write fails and names the filter.
+ await expect(
+ create(
+ { ...onePullRequestTrigger, match: { pullRequest: [7, 8] } },
+ "list-filter",
+ ),
+ ).resolves.toMatchObject({ automation: { trigger: onePullRequest } });
+ await expect(
+ create(
+ { ...onePullRequestTrigger, match: { pullRequest: 5 } },
+ "conflicting-filter",
+ ),
+ ).rejects.toThrow(
+ 'The "pullRequest" filter never matches the events of identifier "getsentry/junior#7"',
);
+
+ // The converted trigger runs for its pull request and not for another
+ // one in the same repository.
+ const dispatchedFor = async (pullRequest: number) => {
+ const before = queue.sentRecords().length;
+ for (const identifier of [
+ `getsentry/junior#${pullRequest}`,
+ "getsentry/junior",
+ ]) {
+ await ingestEventAutomations(
+ {
+ eventKey: `github:one-pull-request-${pullRequest}`,
+ eventType: "pull_request.opened",
+ occurredAtMs: Date.now(),
+ namespace: "github",
+ identifier,
+ trustedSummary: "A pull request was opened.",
+ data: { pullRequest, repo: "getsentry/junior" },
+ },
+ { queue, teamId },
+ );
+ }
+ let count = 0;
+ for (const sentRecord of queue.sentRecords().slice(before)) {
+ const dispatched = await getDispatchRecord(
+ sentRecord.conversationId.replace(/^agent-dispatch:/, ""),
+ );
+ if (dispatched?.metadata?.eventAutomationId === one.automation.id) {
+ count++;
+ }
+ }
+ return count;
+ };
+ expect(await dispatchedFor(7)).toBe(1);
+ expect(await dispatchedFor(8)).toBe(0);
+
+ // An update changes only the filters that it names. A filter that it
+ // leaves out keeps its saved value, so the scope stays. Only null
+ // removes a filter.
const update = (next: Record, toolCallId: string) =>
execute(
createUpdateEventAutomationTool(context(), catalog),
@@ -221,29 +281,32 @@ describe("event automations with no identifier", () => {
automation: { trigger: Record };
}>;
const edited = { ...trigger, label: "Opened getsentry/junior#7" };
- await expect(update(edited, "keep-identifier")).resolves.toMatchObject({
- automation: {
- trigger: {
- identifier: "getsentry/junior#7",
- resourceType: "pull_request",
- },
- },
+ await expect(update(edited, "keep-filters")).resolves.toMatchObject({
+ automation: { trigger: onePullRequest },
});
- // 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)',
- );
+ const notDraft = { ...onePullRequest.match, isDraft: false };
await expect(
- update(
- { ...broad, match: { repo: "getsentry/junior" } },
- "drop-identifier-with-repo",
- ),
+ update({ ...edited, match: { isDraft: false } }, "add-filter"),
).resolves.toMatchObject({
- automation: { trigger: { identifier: null, resourceType: null } },
+ automation: { trigger: { match: notDraft } },
+ });
+ await expect(
+ update({ ...edited, match: {} }, "no-filter-change"),
+ ).resolves.toMatchObject({
+ automation: { trigger: { match: notDraft } },
+ });
+ await expect(
+ update({ ...edited, match: { repo: null } }, "drop-repo"),
+ ).rejects.toThrow('This trigger needs a "repo" filter (match.repo)');
+ const repoOnly = await update(
+ { ...edited, match: { pullRequest: null, isDraft: null } },
+ "drop-filters",
+ );
+ expect(repoOnly.automation.trigger.match).toEqual({
+ repo: "getsentry/junior",
});
} finally {
vi.unstubAllEnvs();
}
- });
+ }, 20_000);
});
diff --git a/packages/junior/tests/integration/api/automation-edits.test.ts b/packages/junior/tests/integration/api/automation-edits.test.ts
index 15d44a763d..99a6e469da 100644
--- a/packages/junior/tests/integration/api/automation-edits.test.ts
+++ b/packages/junior/tests/integration/api/automation-edits.test.ts
@@ -544,7 +544,7 @@ describe("Automation edit API", () => {
expect(
versions.map(({ version, editedBy, definition }) => [
version,
- editedBy?.slackUserId,
+ (editedBy as { slackUserId: string } | null)?.slackUserId,
definition.instruction,
definition.title,
definition.credentialMode,
@@ -635,10 +635,10 @@ describe("Automation edit API", () => {
expect(
afterReader.versions
.slice(0, 2)
- .map(({ version, editedBy }) => [version, editedBy?.slackUserId]),
- ).toEqual([
- [9, "U456"],
- [8, "U123"],
+ .map(({ version, editedBy }) => [version, editedBy]),
+ ).toMatchObject([
+ [9, { slackUserId: "U456" }],
+ [8, { slackUserId: "U123" }],
]);
expect(afterReader.activeVersion).toBe(9);
expect(
@@ -899,9 +899,8 @@ describe("Automation edit API", () => {
trigger,
});
expect(response.status).toBe(200);
- expect(await response.json()).toMatchObject({
- trigger: { ...trigger, identifier: "getsentry/junior#42" },
- });
+ // The identifier is saved as filters that select the same events.
+ expect((await response.json()).trigger).not.toHaveProperty("identifier");
// Version history shows each saved trigger in the shared words.
const { versions } = automationVersionListSchema.parse(
await (await app.request(`${url}/versions`)).json(),
@@ -918,7 +917,11 @@ describe("Automation edit API", () => {
).toEqual([
{
events: ["pull request merged"],
- filters: ["headBranch is main"],
+ filters: [
+ "repo is getsentry/junior",
+ "headBranch is main",
+ "pullRequest is 42",
+ ],
problems: [],
},
saved,
@@ -946,9 +949,9 @@ describe("Automation edit API", () => {
expect(await summary.json()).toMatchObject({
trigger: {
filters: ["authorUsername is dcramer"],
- problems: [
+ problems: expect.arrayContaining([
expect.stringContaining('Filter "authorUsername" does not exist'),
- ],
+ ]),
},
});
expect(await unavailableIds()).toEqual([id]);
diff --git a/packages/junior/tests/integration/event-automations.test.ts b/packages/junior/tests/integration/event-automations.test.ts
index 0cae0b8200..def33d6e49 100644
--- a/packages/junior/tests/integration/event-automations.test.ts
+++ b/packages/junior/tests/integration/event-automations.test.ts
@@ -725,8 +725,15 @@ describe("event automations", () => {
expect(listed.automations.map((task) => task.id)).toEqual([
created.automation.id,
]);
+ // This source cannot turn identifiers into filters. The trigger still
+ // works, so the reason is a note and not a problem.
expect(listed.automations[0]).toMatchObject({
- trigger: { available: true },
+ trigger: {
+ available: true,
+ notes: [
+ "Kept its exact pull request because its plugin cannot turn the identifier into filters.",
+ ],
+ },
});
const otherChannel = (await execute(
createListEventAutomationsTool(context("U999", "COTHER"), EVENT_CATALOG),
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 85bca193e4..44e850439b 100644
--- a/packages/junior/tests/integration/slack/channel-message-automations.test.ts
+++ b/packages/junior/tests/integration/slack/channel-message-automations.test.ts
@@ -129,13 +129,14 @@ describe("Slack channel-message automations", () => {
},
)) as { automation: { id: string } };
- // The trigger binds to the channel, and runs never get creator credentials.
- expect(
- await getEventAutomation(fixture.sql.db(), automation.id),
- ).toMatchObject({
+ // The trigger binds to the channel with a filter, and runs never get
+ // creator credentials.
+ const created = await getEventAutomation(fixture.sql.db(), automation.id);
+ expect(created).toMatchObject({
credentialMode: "system",
- trigger: { identifier: "C123", namespace: "slack" },
+ trigger: { namespace: "slack", match: { channelId: "C123" } },
});
+ expect(created?.trigger.identifier).toBeUndefined();
// A thread reply, a bot post, and a message in another channel do not run it.
expect(
@@ -206,6 +207,25 @@ describe("Slack channel-message automations", () => {
trigger: trigger("current_channel", { userId: "U777" }),
},
)) as { automation: { id: string } };
+ // A row saved before channel filters names the channel with its
+ // identifier, and it still runs for its own channel.
+ const created = await getEventAutomation(fixture.sql.db(), automation.id);
+ const { match, ...unfiltered } = created!.trigger;
+ await fixture.sql
+ .db()
+ .update(juniorAutomations)
+ .set({
+ identifier: "C123",
+ record: sql`jsonb_set(${juniorAutomations.record}, '{trigger}', ${JSON.stringify(
+ {
+ ...unfiltered,
+ identifier: "C123",
+ resourceType: "channel",
+ match: { userId: match?.userId },
+ },
+ )}::jsonb)`,
+ })
+ .where(eq(juniorAutomations.id, automation.id));
// A post from another bot does not run it.
await postChannelMessage({
@@ -251,7 +271,7 @@ describe("Slack channel-message automations", () => {
}),
).rejects.toThrow("can watch only the channel where it posts");
- // A trigger with no identifier would read every channel.
+ // A trigger with no identifier must name its own channel in a filter.
const {
identifier: _identifier,
resourceType: _type,
@@ -264,7 +284,15 @@ describe("Slack channel-message automations", () => {
outcomes: [],
trigger: anyChannel,
}),
- ).rejects.toThrow("This source cannot run for every resource.");
+ ).rejects.toThrow('This trigger needs a "channelId" filter');
+ await expect(
+ execute(createEventAutomationTool(channelContext, catalog), {
+ instruction: "Summarize new messages.",
+ title: "Summarize another channel",
+ outcomes: [],
+ trigger: { ...anyChannel, match: { channelId: "C999" } },
+ }),
+ ).rejects.toThrow("can watch only the channel where it posts");
// A message runs only automations that post in its own channel, even
// when a saved row names that channel and posts somewhere else.
diff --git a/packages/junior/tests/unit/events/catalog.test.ts b/packages/junior/tests/unit/events/catalog.test.ts
index 72e9b5f01c..19063f3c7f 100644
--- a/packages/junior/tests/unit/events/catalog.test.ts
+++ b/packages/junior/tests/unit/events/catalog.test.ts
@@ -1,3 +1,4 @@
+import type { EventMatch } from "@sentry/junior-plugin-api";
import { describe, expect, it } from "vitest";
import {
CORE_EVENT_NAMESPACE,
@@ -138,4 +139,53 @@ describe("event catalog", () => {
).problems,
).toEqual(["The plugin for this source is not enabled."]);
});
+
+ it("converts an identifier only when every event gets the same filters", () => {
+ const fields = { kind: "string", description: "Item id." } as const;
+ const items: EventCatalog = {
+ demo: eventCatalogEntry({
+ resourceTypes: [
+ {
+ type: "item",
+ supportedEvents: ["first.opened", "second.opened"],
+ matchFields: { first: fields, second: fields },
+ },
+ ],
+ events: {
+ "first.opened": { reach: "all" },
+ "second.opened": { reach: "all" },
+ },
+ identifierMatch: ({ identifier, eventType }): EventMatch =>
+ eventType === "first.opened"
+ ? { first: identifier }
+ : { second: identifier },
+ }),
+ };
+ const trigger = (events: string[]) =>
+ requireEventAutomationTrigger(items, {
+ namespace: "demo",
+ resourceType: "item",
+ identifier: "x",
+ label: "Item x",
+ events,
+ });
+
+ expect(trigger(["first.opened"])).toEqual({
+ namespace: "demo",
+ label: "Item x",
+ events: ["first.opened"],
+ match: { first: "x" },
+ });
+ // One set of filters cannot select the same events for both, so the
+ // trigger keeps its identifier and says why.
+ expect(trigger(["first.opened", "second.opened"])).toEqual({
+ namespace: "demo",
+ resourceType: "item",
+ identifier: "x",
+ identifierReason:
+ "Kept its exact item because its events need different filters.",
+ label: "Item x",
+ events: ["first.opened", "second.opened"],
+ });
+ });
});