From 2129ecf357a03bfb19234b56b8b673053539b3f2 Mon Sep 17 00:00:00 2001 From: VeldtJumper Date: Mon, 27 Jul 2026 12:14:31 -0500 Subject: [PATCH 1/2] feat: add Buildkite webhook support --- README.md | 13 + THIRD_PARTY_NOTICES.md | 12 + examples/buildkite/buildkite.headers.json | 3 + examples/buildkite/buildkite.json | 29 ++ src/provider/Buildkite.ts | 547 ++++++++++++++++++++++ src/provider/ProviderRegistry.ts | 10 + test/buildkite/buildkite-spec.ts | 392 ++++++++++++++++ test/examples/examples-spec.ts | 1 + test/provider/provider-registry-spec.ts | 7 +- web/public/providers/buildkite.svg | 1 + web/src/pages/index.astro | 3 +- web/test/provider-order.test.mjs | 6 + 12 files changed, 1022 insertions(+), 2 deletions(-) create mode 100644 examples/buildkite/buildkite.headers.json create mode 100644 examples/buildkite/buildkite.json create mode 100644 src/provider/Buildkite.ts create mode 100644 test/buildkite/buildkite-spec.ts create mode 100644 web/public/providers/buildkite.svg diff --git a/README.md b/README.md index 6968b13..8107801 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Basecamp 3](https://github.com/basecamp/bc3-api/blob/master/sections/webhooks.md) - `/basecamp` - [BitBucket](https://confluence.atlassian.com/bitbucket/manage-webhooks-735643732.html) - `/bitbucket` - [BitBucket Server](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html) - `/bitbucketserver` +- [Buildkite](https://buildkite.com/docs/apis/webhooks) - `/buildkite` - [CircleCI](https://circleci.com/docs/1.0/configuration/#notify) - `/circleci` - [Codacy](https://support.codacy.com/hc/en-us/articles/207280359-WebHook-Notifications) - `/codacy` - [Confluence](https://developer.atlassian.com/cloud/confluence/modules/webhook/) - `/confluence` @@ -59,6 +60,18 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Uptime Robot](https://blog.uptimerobot.com/web-hook-alert-contacts-new-feature/) - `/uptimerobot` - [Zendesk](https://developer.zendesk.com/api-reference/webhooks/webhooks-api/webhooks/) - `/zendesk` +### Buildkite setup + +Create a Buildkite webhook and use the generated `/buildkite` URL as its endpoint. Skyhook formats Pipelines build, +job, agent, ping, and blocked agent-registration events, Package Registries package events, and the documented Test +Engine `workflow.alarm` envelope. Other well-formed future Buildkite event families receive a bounded generic +notification instead of being silently dropped. + +Buildkite can authenticate deliveries with a plaintext token or an HMAC signature over the raw request body. +Skyhook's generated URL does not include or store the configured token, so Skyhook cannot authenticate either form; +all incoming values are treated as untrusted display data, links are limited to Buildkite hosts, and Discord mentions +are disabled. + ## Contributing If you wish to contribute, follow our [contributing guide](CONTRIBUTING.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 898bc4c..3c33a03 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,17 @@ # Third-party notices +## Simple Icons — Buildkite icon + +- File: `web/public/providers/buildkite.svg` +- Project: Simple Icons +- Source: https://github.com/simple-icons/simple-icons/blob/25d6e5b39bc55bc446e147700294628af1734f7e/icons/buildkite.svg +- Revision: `25d6e5b39bc55bc446e147700294628af1734f7e` +- Imported size: 478 bytes +- Imported SHA-256: `32d45e2a770e5198bd0b12da30a43c3f560a54dd182a970fb32949e91f807a3f` +- License: CC0 1.0 Universal; a copy is included at `web/public/providers/LICENSE.simple-icons.md` + +Buildkite is a trademark of Buildkite Pty Ltd. The icon is used only to identify the supported provider. + ## Simple Icons — Linear icon - File: `web/public/providers/linear.svg` diff --git a/examples/buildkite/buildkite.headers.json b/examples/buildkite/buildkite.headers.json new file mode 100644 index 0000000..c4420fd --- /dev/null +++ b/examples/buildkite/buildkite.headers.json @@ -0,0 +1,3 @@ +{ + "x-buildkite-event": "build.finished" +} diff --git a/examples/buildkite/buildkite.json b/examples/buildkite/buildkite.json new file mode 100644 index 0000000..b5fe818 --- /dev/null +++ b/examples/buildkite/buildkite.json @@ -0,0 +1,29 @@ +{ + "event": "build.finished", + "build": { + "id": "01908131-7d9f-495e-a17b-80ed31276810", + "web_url": "https://buildkite.com/acme-inc/my-pipeline/builds/27", + "number": 27, + "state": "passed", + "blocked": false, + "message": "Add Buildkite webhook support", + "commit": "a1b2c3d4e5f678901234567890abcdef12345678", + "branch": "main", + "source": "webhook", + "created_at": "2026-07-27T14:20:00.000Z", + "scheduled_at": "2026-07-27T14:20:01.000Z", + "started_at": "2026-07-27T14:20:05.000Z", + "finished_at": "2026-07-27T14:22:30.123Z" + }, + "pipeline": { + "id": "849411f9-9e6d-4739-a0d8-e247088e9b52", + "web_url": "https://buildkite.com/acme-inc/my-pipeline", + "name": "My Pipeline", + "slug": "my-pipeline", + "repository": "git@github.com:acme-inc/my-pipeline.git" + }, + "sender": { + "id": "8a7693f8-dbae-4783-9137-84090fce9045", + "name": "Buildkite User" + } +} diff --git a/src/provider/Buildkite.ts b/src/provider/Buildkite.ts new file mode 100644 index 0000000..5e64104 --- /dev/null +++ b/src/provider/Buildkite.ts @@ -0,0 +1,547 @@ +import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' +import { + DISCORD_EMBED_LIMITS, + DISCORD_MESSAGE_LIMITS, + fitLiteralEmbedFields, + SKYHOOK_FOOTER_TEXT, +} from '../util/DiscordEmbed.ts' +import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' +import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' +import { DirectParseProvider } from './BaseProvider.ts' + +const BUILDKITE_GREEN = 0x14cc80 +const BUILDKITE_BLUE = 0x2196f3 +const BUILDKITE_RED = 0xe53935 +const BUILDKITE_YELLOW = 0xf0b429 +const BUILDKITE_GRAY = 0x757575 +const MAX_URL_CHARACTERS = 2048 +const EVENT_PATTERN = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$/ + +interface ParsedEvent { + embed: Embed + status: string +} + +/** + * Converts Buildkite Pipelines and Package Registries webhooks, plus documented Test Engine workflow envelopes, + * into bounded Discord embeds. + * + * Buildkite can authenticate deliveries with a configured token or an HMAC over the raw request body. Skyhook's + * generated URL does not carry or store that token, so this provider cannot authenticate the delivery and treats + * all webhook content as untrusted display data. + * + * @see https://buildkite.com/docs/apis/webhooks + */ +export class Buildkite extends DirectParseProvider { + public constructor() { + super() + this.setEmbedColor(BUILDKITE_GREEN) + this.payload.username = 'Buildkite' + this.payload.allowed_mentions = { parse: [] } + } + + public getName(): string { + return 'Buildkite' + } + + public getPath(): string { + return 'buildkite' + } + + public async parseData(): Promise { + if (!isRecord(this.body)) { + this.nullifyPayload() + return + } + + const event = boundedEvent(this.body.event) + const headerValue = getHeaderValue(this.headers, 'x-buildkite-event') + const headerEvent = headerValue === undefined ? undefined : boundedEvent(headerValue) + if (event == null || (headerValue !== undefined && headerEvent !== event)) { + this.nullifyPayload() + return + } + + const parsed = this.parseEvent(event) + if (parsed == null) { + this.nullifyPayload() + return + } + + const author = senderAuthor(this.body.sender) + if (author != null) { + parsed.embed.author = author + } + parsed.embed.fields = fitEscapedFieldsWithinAggregateLimit(parsed.embed) + this.setEmbedColor(statusColor(parsed.status)) + this.addEmbed(parsed.embed) + } + + private parseEvent(event: string): ParsedEvent | null { + if (event.startsWith('build.')) { + return parseBuildEvent(event, this.body) + } + if (event.startsWith('job.')) { + return parseJobEvent(event, this.body) + } + if (event.startsWith('agent.')) { + return parseAgentEvent(event, this.body) + } + if (event === 'cluster_token.registration_blocked') { + return parseBlockedRegistrationEvent(this.body) + } + if (event === 'ping') { + return parsePingEvent(this.body) + } + if (event.startsWith('package.')) { + return parsePackageEvent(event, this.body) + } + if (event.startsWith('workflow.')) { + return parseWorkflowEvent(event, this.body) + } + return parseGenericEvent(event, this.body) + } +} + +function parseBuildEvent(event: string, body: Record): ParsedEvent | null { + if (!isRecord(body.build) || !isRecord(body.pipeline)) { + return null + } + + const pipelineName = scalarText(body.pipeline.name) + const buildNumber = positiveIntegerText(body.build.number) + const state = buildEventStatus(event, body.build) + if (pipelineName == null || buildNumber == null || state == null) { + return null + } + + const embed: Embed = { + title: literal(`${pipelineName} build #${buildNumber} ${statusLabel(state)}`, DISCORD_EMBED_LIMITS.title, true), + } + const description = scalarText(body.build.message) + if (description != null) { + embed.description = literal(description, DISCORD_EMBED_LIMITS.description, false) + } + setTrustedUrl(embed, body.build.web_url, body.pipeline.web_url) + embed.timestamp = buildTimestamp(event, body.build) ?? undefined + embed.fields = fitLiteralEmbedFields(embed, buildFields(body.build)) + + return { embed, status: state } +} + +function parseJobEvent(event: string, body: Record): ParsedEvent | null { + if (!isRecord(body.job) || !isRecord(body.build) || !isRecord(body.pipeline)) { + return null + } + + const pipelineName = scalarText(body.pipeline.name) + const buildNumber = positiveIntegerText(body.build.number) + const jobName = firstScalar(body.job.name, body.job.step_key, body.job.type) + const state = boundedToken(body.job.state) ?? eventAction(event) + if (pipelineName == null || buildNumber == null || jobName == null || state == null) { + return null + } + + const embed: Embed = { + title: literal( + `${pipelineName} build #${buildNumber}: ${jobName} ${jobStatusLabel(event, state)}`, + DISCORD_EMBED_LIMITS.title, + true, + ), + } + const promisedReason = scalarText(body.promised_exit_status_reason) + if (promisedReason != null) { + embed.description = literal(promisedReason, DISCORD_EMBED_LIMITS.description, false) + } + setTrustedUrl(embed, body.job.web_url, body.build.web_url, body.pipeline.web_url) + embed.timestamp = jobTimestamp(event, body.job) ?? undefined + embed.fields = fitLiteralEmbedFields(embed, jobFields(body.job, body.build)) + + return { embed, status: jobColorStatus(event, state) } +} + +function parseAgentEvent(event: string, body: Record): ParsedEvent | null { + if (!isRecord(body.agent)) { + return null + } + + const action = eventAction(event) + const agentName = scalarText(body.agent.name) + if (action == null || agentName == null) { + return null + } + + const embed: Embed = { + title: literal(`Agent ${statusLabel(action)}: ${agentName}`, DISCORD_EMBED_LIMITS.title, true), + } + setTrustedUrl(embed, body.agent.web_url) + embed.timestamp = agentTimestamp(action, body.agent) ?? undefined + embed.fields = fitLiteralEmbedFields(embed, agentFields(body.agent, body.blocked_ip)) + + return { embed, status: action } +} + +function parseBlockedRegistrationEvent(body: Record): ParsedEvent | null { + if (!isRecord(body.cluster_token)) { + return null + } + + const fields: EmbedField[] = [] + addField(fields, 'Agent token', firstScalar(body.cluster_token.description, body.cluster_token.name)) + addField(fields, 'Blocked IP', scalarText(body.blocked_ip)) + const embed: Embed = { title: 'Agent registration blocked' } + embed.fields = fitLiteralEmbedFields(embed, fields) + return { embed, status: 'blocked' } +} + +function parsePingEvent(body: Record): ParsedEvent | null { + if (!isRecord(body.service) || !isRecord(body.organization)) { + return null + } + + const fields: EmbedField[] = [] + addField(fields, 'Organization', firstScalar(body.organization.name, body.organization.slug)) + const embed: Embed = { title: 'Buildkite webhook settings updated' } + embed.fields = fitLiteralEmbedFields(embed, fields) + return { embed, status: 'connected' } +} + +function parsePackageEvent(event: string, body: Record): ParsedEvent | null { + if (!isRecord(body.package)) { + return null + } + + const action = eventAction(event) + const packageName = scalarText(body.package.name) + if (action == null || packageName == null) { + return null + } + + const embed: Embed = { + title: literal(`Package ${statusLabel(action)}: ${packageName}`, DISCORD_EMBED_LIMITS.title, true), + } + setTrustedUrl(embed, body.package.web_url) + embed.timestamp = firstTimestamp(body.package.created_at, body.created_at) ?? undefined + const fields: EmbedField[] = [] + addField(fields, 'Registry', nestedScalar(body.package.registry, 'name', 'slug')) + addField(fields, 'Organization', nestedScalar(body.package.organization, 'name', 'slug')) + embed.fields = fitLiteralEmbedFields(embed, fields) + return { embed, status: action } +} + +function parseWorkflowEvent(event: string, body: Record): ParsedEvent | null { + if (!isRecord(body.subject) || !isRecord(body.workflow_event)) { + return null + } + + const subjectName = firstScalar(body.subject.test_full_name, body.subject.name, body.subject.type) + const monitor = firstScalar(body.workflow_event.type, body.type) + if (subjectName == null || monitor == null) { + return null + } + + const embed: Embed = { + title: literal(`${humanizeWords(event)}: ${subjectName}`, DISCORD_EMBED_LIMITS.title, true), + } + setTrustedUrl(embed, body.subject.test_url, body.workflow_url) + embed.timestamp = firstTimestamp(body.timestamp, body.created_at) ?? undefined + const fields: EmbedField[] = [] + addField(fields, 'Monitor', humanizeWords(monitor)) + addField(fields, 'Location', scalarText(body.subject.test_location), false) + embed.fields = fitLiteralEmbedFields(embed, fields) + return { embed, status: eventAction(event) ?? 'workflow' } +} + +function parseGenericEvent(event: string, body: Record): ParsedEvent { + const embed: Embed = { + title: literal(humanizeWords(event), DISCORD_EMBED_LIMITS.title, true), + } + setTrustedUrl(embed, body.web_url, body.url) + embed.timestamp = firstTimestamp(body.timestamp, body.created_at) ?? undefined + return { embed, status: eventAction(event) ?? event } +} + +function buildFields(build: Record): EmbedField[] { + const fields: EmbedField[] = [] + addField(fields, 'Branch', scalarText(build.branch)) + const commit = boundedText(build.commit, 128, true) + addField(fields, 'Commit', commit == null ? null : commit.slice(0, 7)) + const source = boundedToken(build.source) + addField(fields, 'Source', source == null ? null : humanizeWords(source)) + return fields +} + +function jobFields(job: Record, build: Record): EmbedField[] { + const fields: EmbedField[] = [] + addField(fields, 'Branch', scalarText(build.branch)) + addField(fields, 'Exit status', safeIntegerText(job.exit_status)) + addField(fields, 'Promised exit status', safeIntegerText(job.promised_exit_status)) + addField(fields, 'Agent', nestedScalar(job.agent, 'name')) + return fields +} + +function agentFields(agent: Record, blockedIp: unknown): EmbedField[] { + const fields: EmbedField[] = [] + const connectionState = boundedToken(agent.connection_state) + addField(fields, 'State', connectionState == null ? null : humanizeWords(connectionState)) + addField(fields, 'Hostname', scalarText(agent.hostname)) + addField(fields, 'Queue', scalarText(agent.queue)) + addField(fields, 'Version', scalarText(agent.version)) + addField(fields, 'Blocked IP', scalarText(blockedIp)) + return fields +} + +function addField(fields: EmbedField[], name: string, value: string | null, inline = true): void { + if (value != null) { + fields.push({ name, value, inline }) + } +} + +function fitEscapedFieldsWithinAggregateLimit(embed: Embed): EmbedField[] { + let usedCharacters = + (embed.title?.length ?? 0) + + (embed.description?.length ?? 0) + + (embed.author?.name.length ?? 0) + + SKYHOOK_FOOTER_TEXT.length + const fields: EmbedField[] = [] + for (const field of embed.fields ?? []) { + const remainingValueCharacters = DISCORD_MESSAGE_LIMITS.embedCharacters - usedCharacters - field.name.length + if (remainingValueCharacters <= 0) { + break + } + const value = truncateText( + field.value, + Math.min(DISCORD_EMBED_LIMITS.fieldValue, remainingValueCharacters), + false, + ) + if (value.length === 0) { + continue + } + fields.push({ ...field, value }) + usedCharacters += field.name.length + value.length + } + return fields +} + +function senderAuthor(value: unknown): EmbedAuthor | null { + const name = isRecord(value) ? scalarText(value.name) : typeof value === 'string' ? scalarText(value) : null + return name == null ? null : { name: literal(name, DISCORD_EMBED_LIMITS.authorName, true) } +} + +function nestedScalar(value: unknown, ...keys: string[]): string | null { + if (!isRecord(value)) { + return null + } + for (const key of keys) { + const result = scalarText(value[key]) + if (result != null) { + return result + } + } + return null +} + +function buildTimestamp(event: string, build: Record): string | null { + const action = eventAction(event) + if (action === 'finished' || action === 'skipped') { + return firstTimestamp(build.finished_at, build.scheduled_at, build.created_at) + } + if (action === 'running' || action === 'started') { + return firstTimestamp(build.started_at, build.scheduled_at, build.created_at) + } + if (action === 'failing') { + return firstTimestamp(build.failing_at, build.started_at, build.created_at) + } + return firstTimestamp(build.scheduled_at, build.created_at) +} + +function jobTimestamp(event: string, job: Record): string | null { + const action = eventAction(event) + if (action === 'finished') { + return firstTimestamp(job.finished_at, job.started_at, job.created_at) + } + if (action === 'started') { + return firstTimestamp(job.started_at, job.scheduled_at, job.created_at) + } + if (action === 'promised_exit_status') { + return firstTimestamp(job.promised_exit_status_at, job.started_at, job.created_at) + } + return firstTimestamp(job.scheduled_at, job.created_at) +} + +function agentTimestamp(action: string, agent: Record): string | null { + const timestampByAction: Record = { + connected: agent.connected_at, + disconnected: agent.disconnected_at, + lost: agent.lost_at, + stopped: agent.stopped_at, + } + return firstTimestamp(timestampByAction[action], agent.created_at) +} + +function firstTimestamp(...values: unknown[]): string | null { + for (const value of values) { + const timestamp = canonicalizeIso8601Timestamp(value) + if (timestamp != null) { + return timestamp + } + } + return null +} + +function setTrustedUrl(embed: Embed, ...values: unknown[]): void { + for (const value of values) { + const url = trustedBuildkiteUrl(value) + if (url != null) { + embed.url = url + return + } + } +} + +function trustedBuildkiteUrl(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_URL_CHARACTERS) { + return null + } + try { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + (url.hostname !== 'buildkite.com' && !url.hostname.endsWith('.buildkite.com')) + ) { + return null + } + return url.href.length <= MAX_URL_CHARACTERS ? url.href : null + } catch { + return null + } +} + +function getHeaderValue(headers: unknown, name: string): unknown | undefined { + if (headers instanceof Headers) { + return headers.has(name) ? headers.get(name) : undefined + } + if (!isRecord(headers)) { + return undefined + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name) { + return value + } + } + return undefined +} + +function boundedEvent(value: unknown): string | null { + const event = boundedText(value, 128, true) + return event != null && EVENT_PATTERN.test(event) ? event : null +} + +function boundedToken(value: unknown): string | null { + const token = boundedText(value, 100, true) + return token != null && /^[A-Za-z][A-Za-z0-9_-]*$/.test(token) ? token : null +} + +function boundedText(value: unknown, maxLength: number, singleLine: boolean): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > maxLength * 2) { + return null + } + const text = cleanText(value, singleLine) + return text.length > 0 && text.length <= maxLength ? text : null +} + +function scalarText(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return null + } + if ( + typeof value === 'number' && + (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) + ) { + return null + } + const text = cleanText(String(value), false) + return text.length === 0 ? null : text +} + +function firstScalar(...values: unknown[]): string | null { + for (const value of values) { + const result = scalarText(value) + if (result != null) { + return result + } + } + return null +} + +function safeIntegerText(value: unknown): string | null { + return Number.isSafeInteger(value) ? String(value) : null +} + +function positiveIntegerText(value: unknown): string | null { + return Number.isSafeInteger(value) && Number(value) > 0 ? String(value) : null +} + +function literal(value: string, maxLength: number, singleLine: boolean): string { + return truncateText(escapeDiscordMarkdownLiteral(value), maxLength, singleLine) +} + +function eventAction(event: string): string | null { + const action = event.split('.').at(-1) + return action == null ? null : boundedToken(action) +} + +function statusLabel(status: string): string { + return humanizeWords(status).toLowerCase() +} + +function buildEventStatus(event: string, build: Record): string | null { + const action = eventAction(event) + if (event === 'build.finished') { + return build.blocked === true ? 'blocked' : (boundedToken(build.state) ?? action) + } + return action ?? boundedToken(build.state) +} + +function jobStatusLabel(event: string, state: string): string { + if (event === 'job.promised_exit_status') { + return 'promised failure' + } + return statusLabel(event === 'job.finished' ? state : (eventAction(event) ?? state)) +} + +function jobColorStatus(event: string, state: string): string { + if (event === 'job.promised_exit_status') { + return 'failing' + } + return event === 'job.finished' ? state : (eventAction(event) ?? state) +} + +function statusColor(status: string): number { + const normalized = status.toLowerCase() + if ( + [ + 'failed', + 'failing', + 'broken', + 'timed_out', + 'timing_out', + 'waiting_failed', + 'unblocked_failed', + 'alarm', + 'lost', + ].includes(normalized) + ) { + return BUILDKITE_RED + } + if (['scheduled', 'pending', 'waiting', 'blocked', 'blocked_failed', 'limited', 'stopping'].includes(normalized)) { + return BUILDKITE_YELLOW + } + if (['running', 'started', 'assigned', 'accepted'].includes(normalized)) { + return BUILDKITE_BLUE + } + if (['canceled', 'canceling', 'skipped', 'not_run', 'disconnected', 'stopped', 'archived'].includes(normalized)) { + return BUILDKITE_GRAY + } + return BUILDKITE_GREEN +} diff --git a/src/provider/ProviderRegistry.ts b/src/provider/ProviderRegistry.ts index dad1747..955f455 100644 --- a/src/provider/ProviderRegistry.ts +++ b/src/provider/ProviderRegistry.ts @@ -5,6 +5,7 @@ import { Basecamp } from './Basecamp.ts' import type { BaseProvider } from './BaseProvider.ts' import { BitBucketServer } from './BitBucketServer.ts' import { BitBucket } from './Bitbucket.ts' +import { Buildkite } from './Buildkite.ts' import { CircleCi } from './CircleCi.ts' import { Codacy } from './Codacy.ts' import { Confluence } from './Confluence.ts' @@ -131,6 +132,15 @@ const providerDefinitions: readonly ProviderDefinition[] = [ headers: 'bitbucketserver/bitbucketserver.headers.json', }, }, + { + path: 'buildkite', + name: 'Buildkite', + provider: Buildkite, + example: { + body: 'buildkite/buildkite.json', + headers: 'buildkite/buildkite.headers.json', + }, + }, { path: 'circleci', name: 'CircleCi', diff --git a/test/buildkite/buildkite-spec.ts b/test/buildkite/buildkite-spec.ts new file mode 100644 index 0000000..1efc8b2 --- /dev/null +++ b/test/buildkite/buildkite-spec.ts @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { loadProviderExample } from '../../src/ProviderExamples.ts' +import { Buildkite } from '../../src/provider/Buildkite.ts' +import { validateDiscordPayload } from '../../src/util/DiscordPayloadValidator.ts' +import { Tester } from '../Tester.ts' + +const sender = { + id: '8a7693f8-dbae-4783-9137-84090fce9045', + name: 'Buildkite User', +} + +const pipeline = { + id: '849411f9-9e6d-4739-a0d8-e247088e9b52', + web_url: 'https://buildkite.com/acme-inc/my-pipeline', + name: 'My Pipeline', + slug: 'my-pipeline', +} + +const build = { + id: '01908131-7d9f-495e-a17b-80ed31276810', + web_url: 'https://buildkite.com/acme-inc/my-pipeline/builds/27', + number: 27, + state: 'running', + blocked: false, + message: 'Build the next release', + commit: 'a1b2c3d4e5f678901234567890abcdef12345678', + branch: 'main', + source: 'webhook', + created_at: '2026-07-27T14:20:00.000Z', + scheduled_at: '2026-07-27T14:20:01.000Z', + started_at: '2026-07-27T14:20:05.000Z', +} + +describe('/POST buildkite', () => { + it('exposes provider metadata', () => { + const provider = new Buildkite() + + assert.equal(provider.getName(), 'Buildkite') + assert.equal(provider.getPath(), 'buildkite') + }) + + it('formats the canonical build fixture used by example delivery', async () => { + const example = loadProviderExample('buildkite') + const result = await Tester.testWithBody(new Buildkite(), example.body, example.headers, example.query) + + assert.notEqual(result, null) + assert.equal(result!.username, 'Buildkite') + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.equal(result!.embeds?.length, 1) + + const embed = result!.embeds![0] + assert.equal(embed.title, 'My Pipeline build \\#27 passed') + assert.equal(embed.url, 'https://buildkite.com/acme-inc/my-pipeline/builds/27') + assert.equal(embed.description, 'Add Buildkite webhook support') + assert.equal(embed.timestamp, '2026-07-27T14:22:30.123Z') + assert.equal(embed.color, 0x14cc80) + assert.deepEqual(embed.author, { name: 'Buildkite User' }) + assert.deepEqual(embed.fields, [ + { name: 'Branch', value: 'main', inline: true }, + { name: 'Commit', value: 'a1b2c3d', inline: true }, + { name: 'Source', value: 'Webhook', inline: true }, + ]) + }) + + it('formats build lifecycle events and blocked builds', async () => { + const running = await Tester.testWithBody( + new Buildkite(), + { event: 'build.running', build, pipeline, sender }, + { 'X-Buildkite-Event': 'build.running' }, + ) + assert.notEqual(running, null) + assert.equal(running!.embeds![0].title, 'My Pipeline build \\#27 running') + assert.equal(running!.embeds![0].timestamp, '2026-07-27T14:20:05.000Z') + + const failing = await Tester.testWithBody(new Buildkite(), { + event: 'build.failing', + build: { ...build, state: 'passed', failing_at: '2026-07-27T14:24:00Z' }, + pipeline, + sender, + }) + assert.notEqual(failing, null) + assert.equal(failing!.embeds![0].title, 'My Pipeline build \\#27 failing') + assert.equal(failing!.embeds![0].color, 0xe53935) + + const blocked = await Tester.testWithBody(new Buildkite(), { + event: 'build.finished', + build: { ...build, state: 'blocked', blocked: true, finished_at: '2026-07-27T14:25:00Z' }, + pipeline, + sender, + }) + assert.notEqual(blocked, null) + assert.equal(blocked!.embeds![0].title, 'My Pipeline build \\#27 blocked') + assert.equal(blocked!.embeds![0].color, 0xf0b429) + }) + + it('formats job events with build and execution details', async () => { + const result = await Tester.testWithBody(new Buildkite(), { + event: 'job.finished', + job: { + id: 'b63254c0-3271-4a98-8270-7cfbd6c2f14e', + type: 'script', + name: 'Test **suite**', + state: 'failed', + web_url: 'https://buildkite.com/acme-inc/my-pipeline/builds/27#b63254c0-3271-4a98-8270-7cfbd6c2f14e', + exit_status: 1, + soft_failed: false, + agent: { name: 'runner_1' }, + finished_at: '2026-07-27T14:23:00.500Z', + }, + build, + pipeline, + sender, + }) + assert.notEqual(result, null) + + const embed = result!.embeds![0] + assert.equal(embed.title, 'My Pipeline build \\#27: Test \\*\\*suite\\*\\* failed') + assert.equal( + embed.url, + 'https://buildkite.com/acme-inc/my-pipeline/builds/27#b63254c0-3271-4a98-8270-7cfbd6c2f14e', + ) + assert.equal(embed.timestamp, '2026-07-27T14:23:00.500Z') + assert.deepEqual(embed.fields, [ + { name: 'Branch', value: 'main', inline: true }, + { name: 'Exit status', value: '1', inline: true }, + { name: 'Agent', value: 'runner\\_1', inline: true }, + ]) + assert.equal(embed.color, 0xe53935) + }) + + it('accepts every documented Pipelines event', async () => { + const buildEvents: Record = { + 'build.scheduled': 'scheduled', + 'build.running': 'running', + 'build.failing': 'failing', + 'build.finished': 'passed', + 'build.skipped': 'skipped', + } + for (const [event, state] of Object.entries(buildEvents)) { + const result = await Tester.testWithBody( + new Buildkite(), + { event, build: { ...build, state, finished_at: '2026-07-27T14:23:00Z' }, pipeline, sender }, + { 'x-buildkite-event': event }, + ) + assert.notEqual(result, null, event) + } + + const jobEvents: Record = { + 'job.scheduled': 'scheduled', + 'job.started': 'running', + 'job.finished': 'passed', + 'job.activated': 'unblocked', + 'job.promised_exit_status': 'running', + } + for (const [event, state] of Object.entries(jobEvents)) { + const result = await Tester.testWithBody( + new Buildkite(), + { + event, + job: { + name: 'Test suite', + state, + promised_exit_status: event === 'job.promised_exit_status' ? 1 : undefined, + }, + build, + pipeline, + sender, + }, + { 'x-buildkite-event': event }, + ) + assert.notEqual(result, null, event) + } + + const agentEvents: Record = { + 'agent.connected': 'connected', + 'agent.lost': 'lost', + 'agent.disconnected': 'disconnected', + 'agent.stopping': 'stopping', + 'agent.stopped': 'stopped', + 'agent.blocked': 'never_connected', + } + for (const [event, connectionState] of Object.entries(agentEvents)) { + const result = await Tester.testWithBody( + new Buildkite(), + { + event, + agent: { name: 'runner-1', connection_state: connectionState }, + blocked_ip: event === 'agent.blocked' ? '203.0.113.10' : undefined, + sender, + }, + { 'x-buildkite-event': event }, + ) + assert.notEqual(result, null, event) + } + }) + + it('formats agent, blocked registration, ping, package, and the documented Test Engine alarm event', async () => { + const agentResult = await Tester.testWithBody(new Buildkite(), { + event: 'agent.blocked', + agent: { + name: 'runner_1', + connection_state: 'never_connected', + hostname: 'ci-host', + queue: 'default', + version: '3.99.0', + web_url: 'https://buildkite.com/organizations/acme-inc/clusters/cluster/queues/queue/agents/agent', + }, + blocked_ip: '203.0.113.10', + cluster_token: { description: 'Production agents' }, + sender, + }) + assert.equal(agentResult!.embeds![0].title, 'Agent blocked: runner\\_1') + assert.deepEqual(agentResult!.embeds![0].fields, [ + { name: 'State', value: 'Never connected', inline: true }, + { name: 'Hostname', value: 'ci-host', inline: true }, + { name: 'Queue', value: 'default', inline: true }, + { name: 'Version', value: '3.99.0', inline: true }, + { name: 'Blocked IP', value: '203.0.113.10', inline: true }, + ]) + + const tokenResult = await Tester.testWithBody(new Buildkite(), { + event: 'cluster_token.registration_blocked', + blocked_ip: '203.0.113.11', + cluster_token: { description: 'Production **agents**' }, + sender, + }) + assert.equal(tokenResult!.embeds![0].title, 'Agent registration blocked') + assert.deepEqual(tokenResult!.embeds![0].fields, [ + { name: 'Agent token', value: 'Production \\*\\*agents\\*\\*', inline: true }, + { name: 'Blocked IP', value: '203.0.113.11', inline: true }, + ]) + + const pingResult = await Tester.testWithBody(new Buildkite(), { + event: 'ping', + service: { provider: 'webhook' }, + organization: { name: 'Acme Inc', slug: 'acme-inc' }, + sender, + }) + assert.equal(pingResult!.embeds![0].title, 'Buildkite webhook settings updated') + assert.deepEqual(pingResult!.embeds![0].fields, [{ name: 'Organization', value: 'Acme Inc', inline: true }]) + + const packageResult = await Tester.testWithBody(new Buildkite(), { + event: 'package.created', + package: { + name: 'banana', + web_url: 'https://buildkite.com/organizations/acme-inc/packages/registries/my-registry/packages/pkg-1', + organization: { slug: 'acme-inc' }, + registry: { slug: 'my-registry' }, + }, + sender, + }) + assert.equal(packageResult!.embeds![0].title, 'Package created: banana') + assert.deepEqual(packageResult!.embeds![0].fields, [ + { name: 'Registry', value: 'my-registry', inline: true }, + { name: 'Organization', value: 'acme-inc', inline: true }, + ]) + + const workflowResult = await Tester.testWithBody(new Buildkite(), { + event: 'workflow.alarm', + subject: { + type: 'test', + test_full_name: 'Retries **forever**', + test_location: './spec/retry_spec.rb:22', + test_url: 'https://buildkite.com/organizations/acme-inc/analytics/suites/tests/test-id', + }, + workflow_event: { type: 'transition_count' }, + workflow_id: '0198a11d-9486-7ac5-a87a-d55d2642cd3f', + workflow_url: 'https://buildkite.com/organizations/acme-inc/analytics/suites/workflows/workflow-id', + }) + assert.equal(workflowResult!.embeds![0].title, 'Workflow alarm: Retries \\*\\*forever\\*\\*') + assert.deepEqual(workflowResult!.embeds![0].fields, [ + { name: 'Monitor', value: 'Transition count', inline: true }, + { name: 'Location', value: './spec/retry\\_spec.rb:22', inline: false }, + ]) + }) + + it('accepts future event families generically and only links trusted Buildkite URLs', async () => { + const result = await Tester.testWithBody( + new Buildkite(), + { + event: 'pipeline.archived', + web_url: 'https://evil.example/phishing', + sender: { name: 'Future **sender**' }, + }, + { 'x-buildkite-event': 'pipeline.archived' }, + ) + assert.notEqual(result, null) + assert.equal(result!.embeds![0].title, 'Pipeline archived') + assert.equal(result!.embeds![0].url, undefined) + assert.deepEqual(result!.embeds![0].author, { name: 'Future \\*\\*sender\\*\\*' }) + + const allowed = await Tester.testWithBody(new Buildkite(), { + event: 'pipeline.archived', + web_url: 'https://api.buildkite.com/v2/organizations/acme-inc/pipelines/my-pipeline', + sender: 'Webhook creator', + }) + assert.equal( + allowed!.embeds![0].url, + 'https://api.buildkite.com/v2/organizations/acme-inc/pipelines/my-pipeline', + ) + assert.deepEqual(allowed!.embeds![0].author, { name: 'Webhook creator' }) + + for (const webUrl of [ + 'http://buildkite.com/acme-inc/my-pipeline', + 'https://buildkite.com.evil.example/acme-inc/my-pipeline', + 'https://buildkite.com@evil.example/acme-inc/my-pipeline', + ]) { + const unsafe = await Tester.testWithBody(new Buildkite(), { + event: 'pipeline.archived', + web_url: webUrl, + }) + assert.equal(unsafe!.embeds![0].url, undefined, webUrl) + } + }) + + it('rejects malformed envelopes, missing family objects, and mismatched event headers', async () => { + for (const body of [ + null, + {}, + { event: '' }, + { event: 'Build.Finished' }, + { event: 'build.finished', build, sender }, + { event: 'build.finished', build: { ...build, number: 0 }, pipeline, sender }, + { event: 'job.started', build, pipeline, sender }, + { event: 'agent.connected', sender }, + { event: 'package.created', sender }, + { event: 'workflow.alarm' }, + ]) { + assert.equal(await Tester.testWithBody(new Buildkite(), body), null) + } + + assert.equal( + await Tester.testWithBody( + new Buildkite(), + { event: 'build.running', build, pipeline, sender }, + { 'x-buildkite-event': 'build.finished' }, + ), + null, + ) + assert.equal( + await Tester.testWithBody( + new Buildkite(), + { event: 'build.running', build, pipeline, sender }, + { 'x-buildkite-event': '' }, + ), + null, + ) + }) + + it('stays within Discord limits for long untrusted values', async () => { + const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) + const result = await Tester.testWithBody(new Buildkite(), { + event: 'job.promised_exit_status', + promised_exit_status_reason: longText, + job: { + id: 'job-id', + type: 'script', + name: longText, + state: 'running', + promised_exit_status: 1, + agent: { name: longText }, + web_url: `https://buildkite.com/${'😀'.repeat(500)}`, + }, + build: { ...build, branch: longText, message: longText }, + pipeline: { ...pipeline, name: longText }, + sender: { name: longText }, + }) + assert.notEqual(result, null) + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.equal(result!.embeds![0].color, 0xe53935) + + const embed = result!.embeds![0] + assert.ok((embed.title?.length ?? 0) <= 256) + assert.ok((embed.description?.length ?? 0) <= 4096) + assert.ok((embed.author?.name.length ?? 0) <= 256) + assert.ok((embed.fields?.length ?? 0) <= 25) + for (const field of embed.fields ?? []) { + assert.ok(field.name.length <= 256) + assert.ok(field.value.length <= 1024) + } + const aggregateLength = + (embed.title?.length ?? 0) + + (embed.description?.length ?? 0) + + (embed.author?.name.length ?? 0) + + (embed.footer?.text.length ?? 0) + + (embed.fields ?? []).reduce((total, field) => total + field.name.length + field.value.length, 0) + assert.ok(aggregateLength <= 6000) + assert.equal(aggregateLength, 6000) + assert.deepEqual(validateDiscordPayload(result!), []) + }) +}) diff --git a/test/examples/examples-spec.ts b/test/examples/examples-spec.ts index 82faef2..534c002 100644 --- a/test/examples/examples-spec.ts +++ b/test/examples/examples-spec.ts @@ -24,6 +24,7 @@ const expectedProviderPaths = [ 'basecamp', 'bitbucket', 'bitbucketserver', + 'buildkite', 'circleci', 'codacy', 'confluence', diff --git a/test/provider/provider-registry-spec.ts b/test/provider/provider-registry-spec.ts index 45fde4c..f52fd46 100644 --- a/test/provider/provider-registry-spec.ts +++ b/test/provider/provider-registry-spec.ts @@ -41,6 +41,11 @@ const expectedMetadata = [ headers: 'bitbucketserver/bitbucketserver.headers.json', }, }, + { + path: 'buildkite', + name: 'Buildkite', + example: { body: 'buildkite/buildkite.json', headers: 'buildkite/buildkite.headers.json' }, + }, { path: 'circleci', name: 'CircleCi', example: { body: 'circleci/circleci.json' } }, { path: 'codacy', name: 'Codacy', example: { body: 'codacy/codacy.json' } }, { path: 'confluence', name: 'Confluence', example: { body: 'confluence/confluence_page.json' } }, @@ -112,7 +117,7 @@ describe('ProviderRegistry', () => { const gitlab = providerRegistry.get('gitlab') assert.equal(gitlab?.name, 'GitLab') - assert.strictEqual(gitlab, providerRegistry.definitions[9]) + assert.strictEqual(gitlab, providerRegistry.definitions[10]) assert.equal(providerRegistry.get('not-registered'), undefined) }) diff --git a/web/public/providers/buildkite.svg b/web/public/providers/buildkite.svg new file mode 100644 index 0000000..3330f4a --- /dev/null +++ b/web/public/providers/buildkite.svg @@ -0,0 +1 @@ +Buildkite \ No newline at end of file diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 4aced2f..8224935 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -13,6 +13,7 @@ const providers: Provider[] = [ { name: 'Basecamp', slug: 'basecamp', color: '#e8e8e8' }, { name: 'Bitbucket', slug: 'bitbucket', color: '#2684ff' }, { name: 'Bitbucket Server', slug: 'bitbucketserver', color: '#2684ff' }, + { name: 'Buildkite', slug: 'buildkite', color: '#14cc80' }, { name: 'CircleCI', slug: 'circleci', color: '#e8e8e8' }, { name: 'Codacy', slug: 'codacy', color: '#21cc9a' }, { name: 'Confluence', slug: 'confluence', color: '#2684ff' }, @@ -58,7 +59,7 @@ const faqs = [ }, { q: 'Which services does skyhook support?', - a: `${providers.length} and counting — including Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, + a: `${providers.length} and counting — including Buildkite, Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, }, { q: 'Do I need to host a bot or write any code?', diff --git a/web/test/provider-order.test.mjs b/web/test/provider-order.test.mjs index 8d4af4a..6bd256f 100644 --- a/web/test/provider-order.test.mjs +++ b/web/test/provider-order.test.mjs @@ -23,6 +23,12 @@ test('supported providers are rendered alphabetically by display name', () => { assert.deepEqual(names, alphabetizedNames) assert.ok(names.includes('Linear'), 'Linear should appear in the supported-provider grid') assert.ok(existsSync(new URL('../public/providers/linear.svg', import.meta.url)), 'Linear should have a logo asset') + assert.ok(names.includes('Buildkite'), 'Buildkite should appear in the supported-provider grid') + assert.match(providerSection[1], /title="\/buildkite"/, 'Buildkite should use the /buildkite endpoint') + assert.ok( + existsSync(new URL('../public/providers/buildkite.svg', import.meta.url)), + 'Buildkite should have a logo asset', + ) assert.ok(names.includes('Square'), 'Square should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/square"/, 'Square should use the /square endpoint') assert.ok(names.includes('Azure DevOps'), 'Azure DevOps should appear in the supported-provider grid') From 654e2b955b4c97db91daa548495d4b29e1f48275 Mon Sep 17 00:00:00 2001 From: VeldtJumper Date: Mon, 27 Jul 2026 12:35:19 -0500 Subject: [PATCH 2/2] docs: remove Buildkite setup section --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 8107801..4468a63 100644 --- a/README.md +++ b/README.md @@ -60,18 +60,6 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Uptime Robot](https://blog.uptimerobot.com/web-hook-alert-contacts-new-feature/) - `/uptimerobot` - [Zendesk](https://developer.zendesk.com/api-reference/webhooks/webhooks-api/webhooks/) - `/zendesk` -### Buildkite setup - -Create a Buildkite webhook and use the generated `/buildkite` URL as its endpoint. Skyhook formats Pipelines build, -job, agent, ping, and blocked agent-registration events, Package Registries package events, and the documented Test -Engine `workflow.alarm` envelope. Other well-formed future Buildkite event families receive a bounded generic -notification instead of being silently dropped. - -Buildkite can authenticate deliveries with a plaintext token or an HMAC signature over the raw request body. -Skyhook's generated URL does not include or store the configured token, so Skyhook cannot authenticate either form; -all incoming values are treated as untrusted display data, links are limited to Buildkite hosts, and Discord mentions -are disabled. - ## Contributing If you wish to contribute, follow our [contributing guide](CONTRIBUTING.md).