Skip to content
File

Blob: src/worker/api/public/webhooks/payload.ts

typescript161 lines
1import {
2 BranchName,
3 CommitSha,
4 type WebhookProvider as WebhookProviderValue,
5 type BranchName as BranchNameValue,
6 type CommitSha as CommitShaValue,
7} from "@/contracts";
8import { type WebhookTriggerPayload } from "@/worker/contracts";
9import { normalizeRepositoryUrl } from "@/worker/validation";
10import { HttpError } from "@/worker/http";
11 
12export type ParsedJsonObject = Record<string, unknown>;
13 
14const textDecoder = new TextDecoder();
15 
16export const parseJsonObject = (body: Uint8Array): ParsedJsonObject => {
17 try {
18 const parsed = JSON.parse(textDecoder.decode(body)) as unknown;
19 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
20 throw new Error("not an object");
21 }
22 
23 return parsed as ParsedJsonObject;
24 } catch (error) {
25 throw new HttpError(400, "invalid_json", "Webhook body must be valid JSON.", error);
26 }
27};
28 
29export const requireRecord = (value: unknown, fieldName: string): ParsedJsonObject => {
30 if (!value || typeof value !== "object" || Array.isArray(value)) {
31 throw new HttpError(400, "invalid_request", `Webhook payload field ${fieldName} is invalid.`);
32 }
33 
34 return value as ParsedJsonObject;
35};
36 
37export const requireString = (value: unknown, fieldName: string): string => {
38 if (typeof value !== "string" || value.length === 0) {
39 throw new HttpError(400, "invalid_request", `Webhook payload field ${fieldName} is invalid.`);
40 }
41 
42 return value;
43};
44 
45export const decodeCommitSha = (value: unknown, fieldName: string): CommitShaValue | null => {
46 if (value === null || value === undefined) {
47 return null;
48 }
49 
50 if (typeof value !== "string" || value.length === 0) {
51 throw new HttpError(400, "invalid_request", `Webhook payload field ${fieldName} is invalid.`);
52 }
53 
54 if (/^0+$/u.test(value)) {
55 return null;
56 }
57 
58 try {
59 return CommitSha.assertDecode(value);
60 } catch {
61 throw new HttpError(400, "invalid_request", `Webhook payload field ${fieldName} is invalid.`);
62 }
63};
64 
65export const decodeBranchFromRef = (ref: string | null): BranchNameValue | null => {
66 if (ref === null) {
67 return null;
68 }
69 
70 if (!ref.startsWith("refs/heads/")) {
71 return null;
72 }
73 
74 const branch = ref.slice("refs/heads/".length);
75 try {
76 return BranchName.assertDecode(branch);
77 } catch {
78 throw new HttpError(400, "invalid_request", "Webhook branch ref is invalid.");
79 }
80};
81 
82const normalizePushStylePayload = (input: {
83 provider: Extract<WebhookProviderValue, "github" | "gitea">;
84 payload: ParsedJsonObject;
85 eventName: string;
86 resolveRepositoryUrl: (repository: ParsedJsonObject) => string;
87}): WebhookTriggerPayload => {
88 const repository = requireRecord(input.payload.repository, "repository");
89 const isPush = input.eventName === "push";
90 const isPing = input.eventName === "ping";
91 const ref = input.payload.ref === undefined ? null : requireString(input.payload.ref, "ref");
92 
93 return {
94 provider: input.provider,
95 deliveryId: "",
96 eventKind: isPing ? "ping" : isPush ? "push" : "other",
97 eventName: input.eventName,
98 repoUrl: input.resolveRepositoryUrl(repository),
99 ref,
100 branch: decodeBranchFromRef(ref),
101 commitSha: isPush ? decodeCommitSha(input.payload.after, "after") : null,
102 beforeSha: isPush ? decodeCommitSha(input.payload.before, "before") : null,
103 };
104};
105 
106const normalizeGitHubPayload = (payload: ParsedJsonObject, eventName: string): WebhookTriggerPayload =>
107 normalizePushStylePayload({
108 provider: "github",
109 payload,
110 eventName,
111 resolveRepositoryUrl: (repository) => {
112 const fullName = requireString(repository.full_name, "repository.full_name");
113 return normalizeRepositoryUrl(`https://github.com/${fullName}`);
114 },
115 });
116 
117const normalizeGitLabPayload = (payload: ParsedJsonObject, eventName: string): WebhookTriggerPayload => {
118 const project = requireRecord(payload.project, "project");
119 const repository = payload.repository === undefined ? null : requireRecord(payload.repository, "repository");
120 const repoUrlValue =
121 project.git_http_url ?? project.http_url ?? (repository === null ? undefined : repository.git_http_url);
122 const repoUrl = normalizeRepositoryUrl(requireString(repoUrlValue, "project.git_http_url"));
123 const ref = payload.ref === undefined ? null : requireString(payload.ref, "ref");
124 const objectKind = payload.object_kind === undefined ? null : requireString(payload.object_kind, "object_kind");
125 const payloadEventName = payload.event_name === undefined ? null : requireString(payload.event_name, "event_name");
126 // GitLab system hooks and test-system-hook payloads are not push or ping
127 // events in v1, so they stay in the generic non-push bucket.
128 const isPush = eventName === "Push Hook" && objectKind === "push" && payloadEventName === "push";
129 const commitSha = isPush ? decodeCommitSha(payload.checkout_sha ?? payload.after, "checkout_sha") : null;
130 
131 return {
132 provider: "gitlab",
133 deliveryId: "",
134 eventKind: isPush ? "push" : "other",
135 eventName,
136 repoUrl,
137 ref,
138 branch: decodeBranchFromRef(ref),
139 commitSha,
140 beforeSha: isPush ? decodeCommitSha(payload.before, "before") : null,
141 };
142};
143 
144const normalizeGiteaPayload = (payload: ParsedJsonObject, eventName: string): WebhookTriggerPayload =>
145 normalizePushStylePayload({
146 provider: "gitea",
147 payload,
148 eventName,
149 resolveRepositoryUrl: (repository) =>
150 normalizeRepositoryUrl(requireString(repository.clone_url, "repository.clone_url")),
151 });
152 
153export const webhookPayloadNormalizers = {
154 github: normalizeGitHubPayload,
155 gitlab: normalizeGitLabPayload,
156 gitea: normalizeGiteaPayload,
157} as const satisfies Record<
158 WebhookProviderValue,
159 (payload: ParsedJsonObject, eventName: string) => WebhookTriggerPayload
160>;