Skip to content
Closed
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/concepts/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion packages/junior-dashboard/e2e/automation-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
Expand Down Expand Up @@ -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(?:&|$)/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ function AutomationSettings(props: { automation: AutomationSummary }) {
{automation.trigger.events.join(", ")}
</p>
<TriggerFilters filters={automation.trigger.filters} />
{automation.trigger.notes?.map((note) => (
<p className="m-0 break-words" key={note}>
{note}
</p>
))}
</div>
)}
</AutomationFormSection>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down
3 changes: 3 additions & 0 deletions packages/junior-dashboard/src/mock-reporting/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
117 changes: 117 additions & 0 deletions packages/junior-github/src/events/identifier-match.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -207,6 +208,7 @@ export function githubPlugin(
),
isEnabled: () => Boolean(readEnv("GITHUB_WEBHOOK_SECRET")),
normalizeIdentifier: (identifier) => identifier.toLowerCase(),
identifierMatch: gitHubIdentifierMatch,
},
manifest: {
name: "github",
Expand Down
21 changes: 14 additions & 7 deletions packages/junior-github/src/webhooks/check-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -465,18 +465,25 @@ export function parseCheckSuitePublishTargets(body: unknown):
const identifiers = new Set<string>([
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],
};
Expand Down
12 changes: 9 additions & 3 deletions packages/junior-github/src/webhooks/pull-request-match-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))],
})) ?? [];
Expand Down
Loading
Loading