Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/concepts/watches.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ whenever a non-draft pull request opens in this repository, review it

Optional `match` values come from the resource type. Junior drops events that do not match before it wakes the agent. Use a match key only when that resource type lists it. For GitHub, `isDraft`, `headBranch`, `openerUsername`, `openerEmail`, `openerRole`, and `authors` work on `pull_request` and on `repository` watches that receive pull request events. `headBranch` is the branch GitHub sent on the webhook. `openerUsername` is who opened the pull request. On a pull request that Junior opened, it is the GitHub account linked to the person who asked Junior to open it. Junior reads that link when the event arrives, so it also works after the person links GitHub later. People who only asked for updates do not change it. When Junior does not know that person's GitHub account, the event has no opener, and filters on it do not match. `openerRole` is the opener's role on the repository, such as `admin`, `maintain`, `write`, `read`, or `none` for most bots. For example, match `openerRole: ["read", "triage", "none"]` to skip pull requests from people who can push. `authors` is a list of the GitHub logins of the commit authors and `Co-authored-by` people, bots included. A match value matches when the list includes it, so match `authors: "octocat"` for pull requests that octocat wrote or co-wrote. Junior loads the opener role and the authors only when a filter uses them or an event matches, and the agent sees them in the event. Check suites with no pull request still publish to the repository, so you can watch builds on `main`. For Linear, `teamKey` works on `issue` and `team` watches that receive issue events. If a listed value is missing on the event, Junior does not match.

Each event also carries the values of its scope, and each value is a match key. For GitHub, events carry `repo`, and `pullRequest`, `issue`, `commitSha`, `deploymentId`, or `tag` when the event has one. A check suite with no pull request has no `pullRequest`. For Sentry, events carry `projects`, and issue events also carry `issueId`. `projects` is a list: an issue has one project, and a metric alert has the projects of its rule. Sentry events have no `org` value, because one Sentry integration belongs to one org. For Vercel, events carry `projectId` and `deploymentId`. For Linear, issue events carry `issueIdentifier` and `teamKey`. The values use the same case as identifiers. GitHub and Sentry values are lowercase, Vercel ids keep their case, and Linear values are uppercase. A GitHub `tag` is the release tag as GitHub sent it, with only ASCII letters in lowercase, such as `release/1.0`.

## Limits

- Watches default to 14 days and cannot exceed 30 days.
Expand Down
6 changes: 3 additions & 3 deletions packages/docs/src/content/docs/extend/github-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,9 @@ every item of that kind in a repository with `owner/repo`. Deployment events can
target one commit with `deployment_source`, or every deployment in a repository
with `owner/repo`.

Deployment events carry `environment` and, when GitHub Actions made the
deployment for a job with `environment:`, `workflowId`, `workflowPath`, and
`workflowRunId`. Match `environment`, `workflowId`, or `workflowPath` to limit a
Deployment events carry `repo`, `commitSha`, `deploymentId`, `environment`,
and, when GitHub Actions made the deployment for a job with `environment:`,
`workflowId`, `workflowPath`, and `workflowRunId`. Match `environment`, `workflowId`, or `workflowPath` to limit a
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"`.
Expand Down
8 changes: 8 additions & 0 deletions packages/junior-github/src/events/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
eventMatchFieldsSchema,
type SubscribableResource,
} from "@sentry/junior-plugin-api";
import { gitHubCommitMatchFields, gitHubRepoMatchFields } from "./scope.js";

export const GITHUB_DEPLOYMENT_EVENTS = [
"deployment.created",
Expand All @@ -25,6 +26,13 @@ export const GITHUB_DEPLOYMENT_SUGGESTED_EVENTS = [
* events that do not.
*/
export const GITHUB_DEPLOYMENT_MATCH_FIELDS = eventMatchFieldsSchema.parse({
...gitHubRepoMatchFields(GITHUB_DEPLOYMENT_EVENTS),
...gitHubCommitMatchFields(GITHUB_DEPLOYMENT_EVENTS),
deploymentId: {
kind: "number",
description: "numeric GitHub deployment id, such as 3001",
events: [...GITHUB_DEPLOYMENT_EVENTS],
},
environment: {
kind: "string",
description: "lowercase deployment environment name, such as production",
Expand Down
24 changes: 17 additions & 7 deletions packages/junior-github/src/events/issue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { SubscribableResource } from "@sentry/junior-plugin-api";
import {
eventMatchFieldsSchema,
type SubscribableResource,
} from "@sentry/junior-plugin-api";
import { gitHubCommentMatchFields } from "./comment.js";
import { gitHubRepoMatchFields } from "./scope.js";

export const GITHUB_ISSUE_EVENTS = [
"issue.comment.created",
Expand All @@ -14,13 +18,19 @@ export const GITHUB_ISSUE_SUGGESTED_EVENTS = [
];

/**
* Exact values GitHub sets on issue comment event data. Only
* `issue.comment.created` sets them, so a trigger with these filters cannot
* select other events.
* Exact values GitHub sets on issue event data. Every issue event sets the
* repository and issue number. Only `issue.comment.created` sets the comment
* filters, so a trigger with them cannot select other events.
*/
export const GITHUB_ISSUE_COMMENT_MATCH_FIELDS = gitHubCommentMatchFields([
"issue.comment.created",
]);
export const GITHUB_ISSUE_MATCH_FIELDS = eventMatchFieldsSchema.parse({
...gitHubCommentMatchFields(["issue.comment.created"]),
...gitHubRepoMatchFields(GITHUB_ISSUE_EVENTS),
issue: {
kind: "number",
description: "issue number, such as 12",
events: GITHUB_ISSUE_EVENTS,
},
});

/** The identifier shape that `gitHubIssueResource` builds. */
export const GITHUB_ISSUE_IDENTIFIER = {
Expand Down
17 changes: 16 additions & 1 deletion packages/junior-github/src/events/pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type SubscribableResource,
} from "@sentry/junior-plugin-api";
import { gitHubCommentMatchFields } from "./comment.js";
import { gitHubCommitMatchFields, gitHubRepoMatchFields } from "./scope.js";

export const GITHUB_PULL_REQUEST_EVENTS = [
"pull_request.checks.failed",
Expand Down Expand Up @@ -107,14 +108,28 @@ const GITHUB_PULL_REQUEST_COMMENT_EVENTS = [
"pull_request.review_comment.created",
] as const satisfies readonly GitHubPullRequestEvent[];

/** Pull request events that a check suite result starts. */
const GITHUB_PULL_REQUEST_CHECK_EVENTS = [
"pull_request.checks.failed",
"pull_request.checks.recovered",
] as const satisfies readonly GitHubPullRequestEvent[];

/**
* Exact values GitHub may set on pull request event data. Missing keys do not
* match. The opener and author values load in
* `webhooks/pull-request-match-data.ts` for every pull request event. The
* pull request comment webhook has no head branch.
* pull request comment webhook has no head branch. A check suite result that
* GitHub links to no pull request has no `pullRequest`.
*/
export const GITHUB_PULL_REQUEST_MATCH_FIELDS = eventMatchFieldsSchema.parse({
...gitHubCommentMatchFields(GITHUB_PULL_REQUEST_COMMENT_EVENTS),
...gitHubRepoMatchFields(GITHUB_PULL_REQUEST_EVENTS),
...gitHubCommitMatchFields(GITHUB_PULL_REQUEST_CHECK_EVENTS),
pullRequest: {
kind: "number",
description: "pull request number, such as 946",
events: [...GITHUB_PULL_REQUEST_EVENTS],
},
authors: {
kind: "string",
list: true,
Expand Down
34 changes: 33 additions & 1 deletion packages/junior-github/src/events/release.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import type { SubscribableResource } from "@sentry/junior-plugin-api";
import {
eventMatchFieldsSchema,
type SubscribableResource,
} from "@sentry/junior-plugin-api";
import { z } from "zod";
import { gitHubRepoMatchFields } from "./scope.js";

export const GITHUB_RELEASE_EVENTS = ["release.published"] as const;

/**
* `tag` in event data: the trimmed tag with ASCII letters in lowercase. The
* release-source identifier has the URI-encoded tag, and the plugin
* lowercases identifiers. Encoding turns other characters into `%XX`
* escapes, so only ASCII letters change case there. This form is the same
* text, so `release/1.0` is not `release%2f1.0`, and `Ü` stays `Ü`. To turn
* an identifier into a `tag` filter, decode its tag part.
*/
export const gitHubReleaseTagDataSchema = z
.string()
.trim()
.min(1)
.transform((tag) =>
tag.replace(/[A-Z]+/g, (letters) => letters.toLowerCase()),
);

/** Exact values that every release event sets. */
export const GITHUB_RELEASE_MATCH_FIELDS = eventMatchFieldsSchema.parse({
...gitHubRepoMatchFields(GITHUB_RELEASE_EVENTS),
tag: {
kind: "string",
description:
"release tag with ASCII letters in lowercase, such as v1.2.0 or release/1.0",
events: [...GITHUB_RELEASE_EVENTS],
},
});

export const GITHUB_RELEASE_SUGGESTED_EVENTS = ["release.published"];

/** The identifier shape that `gitHubReleaseSourceResource` builds. */
Expand Down
47 changes: 47 additions & 0 deletions packages/junior-github/src/events/scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
eventMatchFieldsSchema,
type EventMatchFields,
} from "@sentry/junior-plugin-api";
import { z } from "zod";

// The plugin lowercases every identifier, so scope values in event data are
// lowercase too. A saved identifier can then become exact filters.

/**
* `repo` in event data: lowercase `owner/repo`. GitHub ignores case in
* repository names, so tools accept this form.
*/
export const gitHubRepoDataSchema = z.string().min(1).toLowerCase();

/** `commitSha` in event data: the full lowercase commit SHA. */
export const gitHubCommitShaDataSchema = z
.string()
.regex(/^[0-9a-f]{40}$/i)
.toLowerCase();

/** Filter on the repository of an event. */
export function gitHubRepoMatchFields(
events: readonly string[],
): EventMatchFields {
return eventMatchFieldsSchema.parse({
repo: {
kind: "string",
description:
"lowercase repository as owner/repo, such as getsentry/junior",
events,
},
});
}

/** Filter on the commit of an event. */
export function gitHubCommitMatchFields(
events: readonly string[],
): EventMatchFields {
return eventMatchFieldsSchema.parse({
commitSha: {
kind: "string",
description: "full lowercase Git commit SHA",
events,
},
});
}
8 changes: 5 additions & 3 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
GITHUB_DEPLOYMENT_SUGGESTED_EVENTS,
} from "./events/deployment.js";
import {
GITHUB_ISSUE_COMMENT_MATCH_FIELDS,
GITHUB_ISSUE_MATCH_FIELDS,
GITHUB_ISSUE_EVENTS,
GITHUB_ISSUE_IDENTIFIER,
GITHUB_ISSUE_SUGGESTED_EVENTS,
Expand All @@ -43,6 +43,7 @@ import {
} from "./events/pull-request.js";
import {
GITHUB_RELEASE_EVENTS,
GITHUB_RELEASE_MATCH_FIELDS,
GITHUB_RELEASE_SOURCE_IDENTIFIER,
GITHUB_RELEASE_SUGGESTED_EVENTS,
} from "./events/release.js";
Expand Down Expand Up @@ -168,7 +169,7 @@ export function githubPlugin(
identifier: GITHUB_ISSUE_IDENTIFIER,
supportedEvents: [...GITHUB_ISSUE_EVENTS],
suggestedEvents: [...GITHUB_ISSUE_SUGGESTED_EVENTS],
matchFields: GITHUB_ISSUE_COMMENT_MATCH_FIELDS,
matchFields: GITHUB_ISSUE_MATCH_FIELDS,
},
{
type: "pull_request",
Expand All @@ -185,6 +186,7 @@ export function githubPlugin(
identifier: GITHUB_RELEASE_SOURCE_IDENTIFIER,
supportedEvents: [...GITHUB_RELEASE_EVENTS],
suggestedEvents: [...GITHUB_RELEASE_SUGGESTED_EVENTS],
matchFields: GITHUB_RELEASE_MATCH_FIELDS,
},
{
type: "repository",
Expand All @@ -193,7 +195,7 @@ export function githubPlugin(
suggestedEvents: [...GITHUB_REPOSITORY_SUGGESTED_EVENTS],
matchFields: mergeEventMatchFields(
GITHUB_DEPLOYMENT_MATCH_FIELDS,
GITHUB_ISSUE_COMMENT_MATCH_FIELDS,
GITHUB_ISSUE_MATCH_FIELDS,
GITHUB_PULL_REQUEST_MATCH_FIELDS,
),
guidance: gitHubPullRequestEventGuidance(
Expand Down
109 changes: 77 additions & 32 deletions packages/junior-github/src/webhooks/check-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ import {
} from "../credential-support.js";
import { gitHubPullRequestResource } from "../events/pull-request.js";
import { gitHubRepositoryResource } from "../events/repository.js";
import { gitHubEventKey, pullRequestTargets } from "./event-targets.js";
import {
gitHubCommitShaDataSchema,
gitHubRepoDataSchema,
} from "../events/scope.js";
import {
definedValues,
gitHubEventKey,
pullRequestTargets,
} from "./event-targets.js";

/** Match keys that need a pull request API load on check suite events. */
const CHECK_SUITE_PULL_REQUEST_MATCH_KEYS = new Set([
Expand Down Expand Up @@ -179,6 +187,43 @@ function checkSuiteHeadBranch(
return branch ? branch : undefined;
}

/**
* Trusted data on every check suite event. `pullRequest` is missing when
* GitHub links the suite to no pull request, such as on the main branch.
* `commitSha` is missing when GitHub sends no full SHA. `suiteConclusion`
* stays as a detail, not a filter: the event type already tells failed from
* recovered.
*/
const checkSuiteEventDataSchema = z
.object({
repo: gitHubRepoDataSchema,
pullRequest: z.number().optional(),
commitSha: gitHubCommitShaDataSchema.optional(),
suiteConclusion: z.string().min(1),
isDraft: z.boolean().optional(),
openerUsername: z.string().min(1).optional(),
openerEmail: z.string().min(1).optional(),
headBranch: z.string().min(1).optional(),
checkSuiteId: z.number().optional(),
checkSuiteUrl: z.string().min(1).optional(),
appName: z.string().min(1).optional(),
latestCheckRunsCount: z.number().optional(),
// Only system-set handles. Check names come from workflow YAML, so they
// go in the untrusted text.
failingChecks: z
.array(
z
.object({
conclusion: z.string(),
htmlUrl: z.string().optional(),
checkRunId: z.number(),
})
.strict(),
)
.optional(),
})
.strict();

/** Build the trusted data and summary for one check-suite event. */
export function buildCheckSuiteEvent(args: {
appName?: string;
Expand Down Expand Up @@ -214,38 +259,38 @@ export function buildCheckSuiteEvent(args: {
? `${resource.label} checks failed${failingCount > 0 ? ` (${failingCount})` : ""}${shortSha ? ` for ${shortSha}` : ""}${branchSuffix}.`
: `${resource.label} check suite recovered${shortSha ? ` for ${shortSha}` : ""}${branchSuffix}.`;

const data: Record<string, unknown> = {
repo: args.repo,
scope: "check_suite",
suiteConclusion: args.suiteConclusion,
};
if (pullRequestNumber !== undefined) data.pullRequest = pullRequestNumber;
if (typeof args.isDraft === "boolean") data.isDraft = args.isDraft;
if (args.openerUsername) data.openerUsername = args.openerUsername;
if (args.openerEmail) data.openerEmail = args.openerEmail;
if (args.headBranch) data.headBranch = args.headBranch;
if (args.headSha) data.headSha = args.headSha;
if (args.checkSuiteId !== undefined) data.checkSuiteId = args.checkSuiteId;
if (args.checkSuiteId !== undefined && args.headSha) {
data.checkSuiteUrl = buildCheckSuiteUrl({
checkSuiteId: args.checkSuiteId,
headSha: args.headSha,
const data = checkSuiteEventDataSchema.parse(
definedValues({
repo: args.repo,
});
}
if (args.appName) data.appName = oneLineLabel(args.appName, 120);
if (args.latestCheckRunsCount !== undefined) {
data.latestCheckRunsCount = args.latestCheckRunsCount;
}
// Keep only system-controlled handles in trusted data. Check names come from
// workflow YAML and belong in untrustedText.
if (args.eventType === "pull_request.checks.failed" && failingCount > 0) {
data.failingChecks = failingChecks.map((check) => ({
conclusion: check.conclusion,
...(check.htmlUrl ? { htmlUrl: check.htmlUrl } : undefined),
checkRunId: check.checkRunId,
}));
}
pullRequest: pullRequestNumber,
// A short SHA counts as missing, so a commitSha filter does not match.
commitSha: gitHubCommitShaDataSchema.safeParse(args.headSha).data,
suiteConclusion: args.suiteConclusion,
isDraft: args.isDraft,
openerUsername: args.openerUsername,
openerEmail: args.openerEmail,
headBranch: args.headBranch,
checkSuiteId: args.checkSuiteId,
checkSuiteUrl:
args.checkSuiteId !== undefined && args.headSha
? buildCheckSuiteUrl({
checkSuiteId: args.checkSuiteId,
headSha: args.headSha,
repo: args.repo,
})
: undefined,
appName: args.appName ? oneLineLabel(args.appName, 120) : undefined,
latestCheckRunsCount: args.latestCheckRunsCount,
failingChecks:
args.eventType === "pull_request.checks.failed" && failingCount > 0
? failingChecks.map((check) => ({
conclusion: check.conclusion,
...(check.htmlUrl ? { htmlUrl: check.htmlUrl } : undefined),
checkRunId: check.checkRunId,
}))
: undefined,
}),
);

const untrustedParts =
args.eventType === "pull_request.checks.failed"
Expand Down
Loading
Loading