Skip to content
Merged
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
160 changes: 107 additions & 53 deletions src/types/events-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,8 @@
*/

import type { Endpoints } from "@octokit/types";
import { emitterEventNames } from "@octokit/webhooks";
import { z } from "zod";

/** All webhook event names from \@octokit/webhooks */
type WebhookEventName = (typeof emitterEventNames)[number];

/** Extract action union from webhook event names (compile-time, inspectable) */
type ActionsFor<TPrefix extends string> = WebhookEventName extends infer E
? E extends `${TPrefix}.${infer Action}`
? Action
: never
: never;

/** Extract actions array at runtime (needed for Zod) */
const actionsFor = <T extends string>(prefix: T): ActionsFor<T>[] => {
const prefixDot = `${prefix}.`;
return emitterEventNames
.filter(n => n.startsWith(prefixDot))
.map(n => n.slice(prefixDot.length)) as ActionsFor<T>[];
};

/** Inspectable action types - hover to see union */
export type PullRequestAction = ActionsFor<"pull_request">;
export type IssuesAction = ActionsFor<"issues">;
export type ReleaseAction = ActionsFor<"release">;
export type WorkflowRunAction = ActionsFor<"workflow_run">;
export type IssueCommentAction = ActionsFor<"issue_comment">;
export type PullRequestReviewAction = ActionsFor<"pull_request_review">;
export type PullRequestReviewCommentAction =
ActionsFor<"pull_request_review_comment">;
export type WatchAction = ActionsFor<"watch">;

/** Create action schema from Octokit-derived actions */
function actionSchema<T extends string>(prefix: T, eventType: string) {
const actions = actionsFor(prefix);
return z
.string()
.refine(
(action): action is ActionsFor<T> =>
(actions as string[]).includes(action),
{ message: `Unknown ${eventType} action` }
);
}

/**
* Base event structure from Octokit's official types
* We use this as the foundation and only refine the payload types
Expand All @@ -74,11 +32,52 @@ const BaseEventSchema = z.object({
}),
});

/** Create action schema with known actions */
function actionSchema<T extends readonly string[]>(
actions: T,
eventType: string
) {
return z
.string()
.refine(
(action): action is T[number] =>
(actions as readonly string[]).includes(action),
{
error: issue =>
`Unknown ${eventType} action: "${String(issue.input)}". Expected one of: ${actions.join(", ")}`,
}
);
}

/** PR actions for Events API (includes `merged` which webhooks sends as `closed` with merged flag) */
const PR_ACTIONS = [
"assigned",
"unassigned",
"labeled",
"unlabeled",
"opened",
"edited",
"closed",
"reopened",
"synchronize",
"converted_to_draft",
"locked",
"unlocked",
"milestoned",
"demilestoned",
"ready_for_review",
"review_requested",
"review_request_removed",
"auto_merge_enabled",
"auto_merge_disabled",
"merged",
] as const;

/**
* Pull Request Event Payload
*/
export const PullRequestPayloadSchema = z.object({
action: actionSchema("pull_request", "PullRequestEvent"),
action: actionSchema(PR_ACTIONS, "PullRequestEvent"),
number: z.number().optional(),
pull_request: z
.object({
Expand All @@ -103,11 +102,33 @@ export interface PullRequestEvent extends BaseGitHubEvent {
payload: PullRequestPayload;
}

/** Issue actions for Events API */
const ISSUES_ACTIONS = [
"opened",
"edited",
"deleted",
"transferred",
"pinned",
"unpinned",
"closed",
"reopened",
"assigned",
"unassigned",
"labeled",
"unlabeled",
"locked",
"unlocked",
"milestoned",
"demilestoned",
"typed",
"untyped",
] as const;

/**
* Issues Event Payload
*/
export const IssuesPayloadSchema = z.object({
action: actionSchema("issues", "IssuesEvent"),
action: actionSchema(ISSUES_ACTIONS, "IssuesEvent"),
issue: z
.object({
number: z.number(),
Expand Down Expand Up @@ -149,11 +170,22 @@ export interface PushEvent extends BaseGitHubEvent {
payload: PushPayload;
}

/** Release actions */
const RELEASE_ACTIONS = [
"published",
"unpublished",
"created",
"edited",
"deleted",
"prereleased",
"released",
] as const;

/**
* Release Event Payload
*/
export const ReleasePayloadSchema = z.object({
action: actionSchema("release", "ReleaseEvent"),
action: actionSchema(RELEASE_ACTIONS, "ReleaseEvent"),
release: z
.object({
tag_name: z.string(),
Expand All @@ -173,11 +205,14 @@ export interface ReleaseEvent extends BaseGitHubEvent {
payload: ReleasePayload;
}

/** Workflow run actions */
const WORKFLOW_RUN_ACTIONS = ["requested", "in_progress", "completed"] as const;

/**
* Workflow Run Event Payload
*/
export const WorkflowRunPayloadSchema = z.object({
action: actionSchema("workflow_run", "WorkflowRunEvent"),
action: actionSchema(WORKFLOW_RUN_ACTIONS, "WorkflowRunEvent"),
workflow_run: z
.object({
name: z.string(),
Expand All @@ -195,11 +230,14 @@ export interface WorkflowRunEvent extends BaseGitHubEvent {
payload: WorkflowRunPayload;
}

/** Issue comment actions */
const ISSUE_COMMENT_ACTIONS = ["created", "edited", "deleted"] as const;

/**
* Issue Comment Event Payload
*/
export const IssueCommentPayloadSchema = z.object({
action: actionSchema("issue_comment", "IssueCommentEvent"),
action: actionSchema(ISSUE_COMMENT_ACTIONS, "IssueCommentEvent"),
issue: z
.object({
number: z.number(),
Expand All @@ -223,11 +261,14 @@ export interface IssueCommentEvent extends BaseGitHubEvent {
payload: IssueCommentPayload;
}

/** PR Review actions for Events API */
const PR_REVIEW_ACTIONS = ["submitted", "edited", "dismissed"] as const;

/**
* Pull Request Review Event Payload
*/
export const PullRequestReviewPayloadSchema = z.object({
action: actionSchema("pull_request_review", "PullRequestReviewEvent"),
action: actionSchema(PR_REVIEW_ACTIONS, "PullRequestReviewEvent"),
pull_request: z
.object({
number: z.number(),
Expand Down Expand Up @@ -290,12 +331,15 @@ export interface DeleteEvent extends BaseGitHubEvent {
payload: DeletePayload;
}

/** PR review comment actions */
const PR_REVIEW_COMMENT_ACTIONS = ["created", "edited", "deleted"] as const;

/**
* Pull Request Review Comment Event Payload (code review comments)
*/
export const PullRequestReviewCommentPayloadSchema = z.object({
action: actionSchema(
"pull_request_review_comment",
PR_REVIEW_COMMENT_ACTIONS,
"PullRequestReviewCommentEvent"
),
pull_request: z
Expand Down Expand Up @@ -328,11 +372,14 @@ export interface PullRequestReviewCommentEvent extends BaseGitHubEvent {
payload: PullRequestReviewCommentPayload;
}

/** Watch actions */
const WATCH_ACTIONS = ["started"] as const;

/**
* Watch Event Payload (stars)
*/
export const WatchPayloadSchema = z.object({
action: actionSchema("watch", "WatchEvent"),
action: actionSchema(WATCH_ACTIONS, "WatchEvent"),
});

export type WatchPayload = z.infer<typeof WatchPayloadSchema>;
Expand Down Expand Up @@ -466,11 +513,18 @@ export function validateGitHubEvent(event: unknown): GitHubEvent | null {
return null;
}

const eventId = (event as Record<string, unknown>)?.id;
const rawEventId = (event as Record<string, unknown>)?.id;
const eventId = typeof rawEventId === "string" ? rawEventId : "unknown";
const payload = (event as Record<string, unknown>)?.payload as
| Record<string, unknown>
| undefined;
const rawAction = payload?.action;
const action = typeof rawAction === "string" ? rawAction : undefined;

console.error(
// eslint-disable-next-line @typescript-eslint/no-base-to-string
`GitHub event validation failed for ${String(eventType ?? "unknown")} (ID: ${String(eventId ?? "unknown")}):`,
`GitHub event validation failed for ${eventType} (ID: ${eventId})` +
(action ? ` with action="${action}"` : "") +
`:`,
result.error.format()
);
return null;
Expand Down