From 44d5c28027ed03bc3947ed0b676f5cf2f38e95cf Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:16:50 -0700 Subject: [PATCH 01/15] Add Discord webhook alert channel migration Co-authored-by: Cursor --- .../src/actions/2026.06.22T20-10-00.discord-webhook.ts | 8 ++++++++ packages/migrations/src/run-pg-migrations.ts | 1 + 2 files changed, 9 insertions(+) create mode 100644 packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts diff --git a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts new file mode 100644 index 0000000000..869fdbff82 --- /dev/null +++ b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts @@ -0,0 +1,8 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.06.22T20-10-00.discord-webhook.ts', + run: ({ psql }) => psql` + ALTER TYPE alert_channel_type ADD VALUE 'DISCORD_WEBHOOK'; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 518decf0a2..9e4b1f17d1 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -190,5 +190,6 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT await import('./actions/2026.05.29T00-00-00.schema-log-target-id-nullability'), await import('./actions/2026.05.07T00-00-00.schema-version-promotion'), await import('./actions/2026.06.05T00-00-00.metric-alert-filter-shared-only'), + await import('./actions/2026.06.22T20-10-00.discord-webhook'), ], }); From f0bd62f75327d1414e0554248ea2a9779599a48a Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:49:34 -0700 Subject: [PATCH 02/15] Support Discord webhook alert channel type Co-authored-by: Cursor --- packages/services/api/src/modules/alerts/module.graphql.ts | 1 + .../src/modules/alerts/providers/metric-alert-rules-storage.ts | 2 +- packages/services/storage/src/db/types.ts | 2 +- packages/services/storage/src/index.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index 6c0083138d..ae6f84e1d9 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -25,6 +25,7 @@ export default gql` SLACK WEBHOOK MSTEAMS_WEBHOOK + DISCORD_WEBHOOK } enum AlertType { diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index 36ea8aa6fd..eef859840b 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -142,7 +142,7 @@ const METRIC_ALERT_INCIDENT_SELECT = psql` const AlertChannelRowSchema = zod.object({ id: zod.string(), projectId: zod.string(), - type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK']), + type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD_WEBHOOK']), name: zod.string(), createdAt: zod.string(), slackChannel: zod.string().nullable(), diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 703a4b989c..a9ae317c10 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -7,7 +7,7 @@ * */ -export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; +export type alert_channel_type = 'DISCORD_WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS'; export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT'; export type hive_subgraph_log_type = 'added' | 'changed' | 'removed' | 'unchanged'; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index e60dab7e49..174ff19439 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4553,7 +4553,7 @@ const OrganizationBillingModel = z.object({ const AlertChannelModel = z.object({ id: z.string(), name: z.string(), - type: z.enum(['MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), + type: z.enum(['DISCORD_WEBHOOK', 'MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), projectId: z.string(), createdAt: z.string(), slackChannel: z.string().nullable(), From 54387402c0e36dee4da3bd20c8f543d60a756582 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:08:31 -0700 Subject: [PATCH 03/15] Add Discord webhook GraphQL channel type Co-authored-by: Cursor --- .../api/src/modules/alerts/module.graphql.mappers.ts | 1 + .../services/api/src/modules/alerts/module.graphql.ts | 7 +++++++ .../modules/alerts/resolvers/DiscordWebhookChannel.ts | 10 ++++++++++ 3 files changed, 18 insertions(+) create mode 100644 packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts diff --git a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts index ff1d30feb7..27f82e254c 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts @@ -10,6 +10,7 @@ export type AlertChannelMapper = AlertChannel; export type AlertSlackChannelMapper = AlertChannel; export type AlertWebhookChannelMapper = AlertChannel; export type TeamsWebhookChannelMapper = AlertChannel; +export type DiscordWebhookChannelMapper = AlertChannel; export type AlertMapper = Alert; export type MetricAlertRuleMapper = MetricAlertRule; export type MetricAlertRuleIncidentMapper = MetricAlertIncident; diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index ae6f84e1d9..e87f6d1857 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -157,6 +157,13 @@ export default gql` endpoint: String! } + type DiscordWebhookChannel implements AlertChannel { + id: ID! + name: String! + type: AlertChannelType! + endpoint: String! + } + type Alert { id: ID! type: AlertType! diff --git a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts new file mode 100644 index 0000000000..bf57b39d83 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts @@ -0,0 +1,10 @@ +import type { DiscordWebhookChannelResolvers } from './../../../__generated__/types'; + +export const DiscordWebhookChannel: DiscordWebhookChannelResolvers = { + __isTypeOf: channel => { + return channel.type === 'DISCORD_WEBHOOK'; + }, + endpoint: async channel => { + return channel.webhookEndpoint!; + }, +}; From 30113401a16f2f18ac26459f542b66ded4d0cb64 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:01:36 -0700 Subject: [PATCH 04/15] Add Discord webhook notification adapter Co-authored-by: Cursor --- .../alerts/providers/adapters/discord.spec.ts | 296 ++++++++++++++++++ .../alerts/providers/adapters/discord.ts | 286 +++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts create mode 100644 packages/services/api/src/modules/alerts/providers/adapters/discord.ts diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts new file mode 100644 index 0000000000..e38bd44103 --- /dev/null +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts @@ -0,0 +1,296 @@ +import { AlertChannel } from 'packages/services/api/src/shared/entities'; +import { beforeEach, vi } from 'vitest'; +import { SchemaChangeType } from '@hive/storage'; +import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './common'; +import { DiscordCommunicationAdapter } from './discord'; + +const logger = { + child: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), +}; + +const appBaseUrl = 'app-base-url'; +const webhookUrl = 'webhook-url'; + +describe('DiscordCommunicationAdapter', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('sendSchemaChangeNotification', () => { + it('should send schema change notification as Discord embed', async () => { + const changes = [ + { + id: 'id-1', + type: 'FIELD_REMOVED', + approvalMetadata: null, + criticality: 'BREAKING', + message: "Field 'addFoo' was removed from object type 'Mutation'", + meta: { + typeName: 'Mutation', + removedFieldName: 'addFoo', + isRemovedFieldDeprecated: false, + typeType: 'object type', + }, + path: 'Mutation.addFoo', + isSafeBasedOnUsage: false, + reason: + 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.', + usageStatistics: null, + affectedAppDeployments: null, + breakingChangeSchemaCoordinate: 'Mutation.addFoo', + }, + { + id: 'id-2', + type: 'FIELD_ADDED', + approvalMetadata: null, + criticality: 'NON_BREAKING', + message: "Field 'addFooT' was added to object type 'Mutation'", + meta: { + typeName: 'Mutation', + addedFieldName: 'addFooT', + typeType: 'object type', + }, + path: 'Mutation.addFooT', + isSafeBasedOnUsage: false, + reason: null, + usageStatistics: null, + affectedAppDeployments: null, + breakingChangeSchemaCoordinate: null, + }, + ] as Array; + + const input = { + alert: { + id: 'alert-id', + type: 'SCHEMA_CHANGE_NOTIFICATIONS', + channelId: 'channel-id', + projectId: 'project-id', + organizationId: 'org-id', + createdAt: new Date().toISOString(), + targetId: 'target-id', + }, + integrations: { + slack: { + token: null, + }, + }, + event: { + organization: { + id: 'org-id', + cleanId: 'org-clean-id', + slug: 'org-clean-id', + name: '', + }, + project: { + id: 'project-id', + cleanId: 'project-clean-id', + slug: 'project-clean-id', + name: 'project-name', + }, + target: { + id: 'target-id', + cleanId: 'target-clean-id', + slug: 'target-clean-id', + name: 'target-name', + }, + changes, + messages: [], + initial: false, + errors: [], + schema: { + id: 'schema-id', + commit: 'commit', + valid: true, + }, + }, + channel: { + webhookEndpoint: webhookUrl, + } as AlertChannel, + } as SchemaChangeNotificationInput; + + const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendSchemaChangeNotification(input); + + expect(sendDiscordMessageSpy).toHaveBeenCalledWith(webhookUrl, { + embeds: [ + expect.objectContaining({ + title: 'Breaking schema changes detected', + url: 'app-base-url/org-clean-id/project-clean-id/target-clean-id/history/schema-id', + color: 0xe74c3c, + description: expect.stringContaining('### Breaking changes'), + fields: [ + { name: 'Project', value: 'project-name', inline: true }, + { name: 'Target', value: 'target-name', inline: true }, + { name: 'Commit', value: 'commit', inline: true }, + ], + }), + ], + }); + expect(sendDiscordMessageSpy.mock.calls[0]?.[1].embeds?.[0]?.description).toContain( + 'Field `addFoo` was removed from object type `Mutation`', + ); + expect(sendDiscordMessageSpy.mock.calls[0]?.[1].embeds?.[0]?.description).toContain( + '### Safe changes', + ); + }); + + it('should not send a notification without a webhook endpoint', async () => { + const input = { + event: { + organization: { id: 'org-id' }, + project: { id: 'project-id' }, + target: { id: 'target-id' }, + }, + channel: {}, + } as SchemaChangeNotificationInput; + const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendSchemaChangeNotification(input); + + expect(sendDiscordMessageSpy).not.toHaveBeenCalled(); + }); + }); + + describe('sendChannelConfirmation', () => { + it('should send channel confirmation', async () => { + const input = { + event: { + organization: { + id: 'org-id', + cleanId: 'org-clean-id', + slug: 'org-clean-id', + }, + project: { + id: 'project-id', + cleanId: 'project-clean-id', + slug: 'project-clean-id', + name: 'project-name', + }, + kind: 'created', + }, + channel: { + webhookEndpoint: webhookUrl, + }, + } as ChannelConfirmationInput; + const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendChannelConfirmation(input); + + expect(sendDiscordMessageSpy).toHaveBeenCalledWith(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: 0x5865f2, + description: + 'I will send notifications here about your [project-name](app-base-url/org-clean-id/project-clean-id) project.', + }, + ], + }); + + input.event.kind = 'deleted'; + await adapter.sendChannelConfirmation(input); + + expect(sendDiscordMessageSpy).toHaveBeenLastCalledWith(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: 0x5865f2, + description: + 'I will no longer send notifications here about your [project-name](app-base-url/org-clean-id/project-clean-id) project.', + }, + ], + }); + }); + }); + + describe('sendDiscordMessage', () => { + const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + + beforeEach(() => { + // @ts-expect-error mocking fetch + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + statusText: 'OK', + }), + ); + }); + + it('sends a Discord webhook payload with embeds', async () => { + await adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [ + { + title: 'Notification', + description: 'Schema changed', + color: 0x2ecc71, + }, + ], + }); + + expect(fetch).toHaveBeenCalledWith( + 'http://example.com/webhook', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: 'GraphQL Hive', + embeds: [ + { + title: 'Notification', + description: 'Schema changed', + color: 0x2ecc71, + }, + ], + allowed_mentions: { parse: [] }, + }), + }), + ); + }); + + it('truncates embed fields to Discord limits', async () => { + await adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [ + { + title: 'a'.repeat(300), + description: 'b'.repeat(5000), + fields: [ + { + name: 'c'.repeat(300), + value: 'd'.repeat(2000), + }, + ], + }, + ], + }); + + const body = JSON.parse( + (fetch as unknown as ReturnType).mock.calls[0][1].body as string, + ); + + expect(body.embeds[0].title).toHaveLength(256); + expect(body.embeds[0].description).toHaveLength(4096); + expect(body.embeds[0].fields[0].name).toHaveLength(256); + expect(body.embeds[0].fields[0].value).toHaveLength(1024); + }); + + it('handles failed send operation', async () => { + // @ts-expect-error types obviously don't account for the fact this is mocked + fetch.mockImplementationOnce(() => Promise.resolve({ ok: false, statusText: 'Bad Request' })); + + await expect( + adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [{ title: 'Test message' }], + }), + ).rejects.toThrow('Failed to send Discord message: Bad Request'); + }); + }); +}); diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts new file mode 100644 index 0000000000..599e70143a --- /dev/null +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts @@ -0,0 +1,286 @@ +import { Inject, Injectable } from 'graphql-modules'; +import { CriticalityLevel } from '@graphql-inspector/core'; +import { SchemaChangeType } from '@hive/storage'; +import { Logger } from '../../../shared/providers/logger'; +import { WEB_APP_URL } from '../../../shared/providers/tokens'; +import { + ChannelConfirmationInput, + CommunicationAdapter, + createMDLink, + SchemaChangeNotificationInput, + slackCoderize, +} from './common'; + +const DISCORD_RED = 0xe74c3c; +const DISCORD_GREEN = 0x2ecc71; +const DISCORD_BLUE = 0x5865f2; + +const DISCORD_MAX_EMBEDS = 10; +const DISCORD_MAX_TITLE_LENGTH = 256; +const DISCORD_MAX_DESCRIPTION_LENGTH = 4096; +const DISCORD_MAX_FIELDS = 25; +const DISCORD_MAX_FIELD_NAME_LENGTH = 256; +const DISCORD_MAX_FIELD_VALUE_LENGTH = 1024; + +type DiscordWebhookPayload = { + username?: string; + content?: string; + embeds?: DiscordEmbed[]; + allowed_mentions?: { + parse: string[]; + }; +}; + +type DiscordEmbed = { + title?: string; + description?: string; + url?: string; + color?: number; + fields?: DiscordEmbedField[]; +}; + +type DiscordEmbedField = { + name: string; + value: string; + inline?: boolean; +}; + +@Injectable() +export class DiscordCommunicationAdapter implements CommunicationAdapter { + private logger: Logger; + + constructor( + logger: Logger, + @Inject(WEB_APP_URL) private appBaseUrl: string, + ) { + this.logger = logger.child({ service: 'DiscordCommunicationAdapter' }); + } + + async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { + this.logger.debug( + `Sending Schema Change Notifications over Discord (organization=%s, project=%s, target=%s)`, + input.event.organization.id, + input.event.project.id, + input.event.target.id, + ); + + const webhookUrl = input.channel.webhookEndpoint; + + if (!webhookUrl) { + this.logger.debug(`Discord Integration is not available`); + return; + } + + try { + const totalChanges = input.event.changes.length + input.event.messages.length; + const projectLink = createMDLink({ + text: input.event.project.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}`, + }); + const targetLink = createMDLink({ + text: input.event.target.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}/${input.event.target.slug}`, + }); + const changeUrl = `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}/${input.event.target.slug}/history/${input.event.schema.id}`; + const viewLink = createMDLink({ + text: 'view details', + url: changeUrl, + }); + + const message = input.event.initial + ? `Hive received your first schema in project ${projectLink}, target ${targetLink} (${viewLink}).` + : `Hive found **${totalChanges} ${this.pluralize( + 'change', + totalChanges, + )}** in project ${projectLink}, target ${targetLink} (${viewLink}).`; + + const detailsText = input.event.initial + ? '' + : createDetailsText(input.event.changes, input.event.messages, input.event.errors); + + const hasBreakingChanges = input.event.changes.some( + change => change.criticality === CriticalityLevel.Breaking, + ); + const hasErrors = input.event.errors.length > 0; + + await this.sendDiscordMessage(webhookUrl, { + embeds: [ + { + title: input.event.initial + ? 'Schema published' + : hasBreakingChanges || hasErrors + ? 'Breaking schema changes detected' + : 'Schema changes detected', + url: changeUrl, + color: hasBreakingChanges || hasErrors ? DISCORD_RED : DISCORD_GREEN, + description: [message, detailsText].filter(Boolean).join('\n\n'), + fields: [ + { + name: 'Project', + value: input.event.project.name || input.event.project.slug, + inline: true, + }, + { + name: 'Target', + value: input.event.target.name || input.event.target.slug, + inline: true, + }, + { + name: 'Commit', + value: input.event.schema.commit, + inline: true, + }, + ], + }, + ], + }); + } catch (error) { + const errorText = + error instanceof Error + ? error.toString() + : typeof error === 'string' + ? error + : JSON.stringify(error); + this.logger.error(`Failed to send Discord notification (error=%s)`, errorText); + } + } + + async sendChannelConfirmation(input: ChannelConfirmationInput) { + this.logger.debug( + `Sending Channel Confirmation over Discord (organization=%s, project=%s, channel=%s)`, + input.event.organization.id, + input.event.project.id, + ); + + const webhookUrl = input.channel.webhookEndpoint; + + if (!webhookUrl) { + this.logger.debug(`Discord Integration is not available`); + return; + } + + const actionMessage = + input.event.kind === 'created' + ? `I will send notifications here` + : `I will no longer send notifications here`; + + try { + const projectLink = createMDLink({ + text: input.event.project.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}`, + }); + + await this.sendDiscordMessage(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: DISCORD_BLUE, + description: `${actionMessage} about your ${projectLink} project.`, + }, + ], + }); + } catch (error) { + const errorText = + error instanceof Error + ? error.toString() + : typeof error === 'string' + ? error + : JSON.stringify(error); + this.logger.error(`Failed to send Discord notification`, errorText); + } + } + + private pluralize(word: string, num: number): string { + return word + (num > 1 ? 's' : ''); + } + + async sendDiscordMessage(webhookUrl: string, payload: DiscordWebhookPayload) { + const normalizedPayload: DiscordWebhookPayload = { + username: 'GraphQL Hive', + ...payload, + allowed_mentions: payload.allowed_mentions ?? { parse: [] }, + embeds: payload.embeds?.slice(0, DISCORD_MAX_EMBEDS).map(limitEmbed), + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(normalizedPayload), + }); + + if (!response.ok) { + throw new Error(`Failed to send Discord message: ${response.statusText}`); + } + } +} + +function createDetailsText( + changes: readonly SchemaChangeType[], + messages: readonly string[], + errors: readonly { message: string }[], +): string { + const breakingChanges = changes.filter( + change => change.criticality === CriticalityLevel.Breaking, + ); + const safeChanges = changes.filter(change => change.criticality !== CriticalityLevel.Breaking); + + const sections: string[] = []; + + if (breakingChanges.length) { + sections.push(renderChangeList('Breaking changes', breakingChanges)); + } + + if (safeChanges.length) { + sections.push(renderChangeList('Safe changes', safeChanges)); + } + + if (messages.length) { + sections.push(`### Other changes\n${messages.map(message => slackCoderize(message)).join('\n')}`); + } + + if (errors.length) { + sections.push(`### Errors\n${errors.map(error => slackCoderize(error.message)).join('\n')}`); + } + + return sections.join('\n'); +} + +function renderChangeList(title: string, changes: readonly SchemaChangeType[]): string { + const text = changes + .map(change => { + let text = ` - ${change.message}`; + if (change.isSafeBasedOnUsage) { + text += ' (safe based on usage)'; + } + + return slackCoderize(text); + }) + .join('\n'); + + return `### ${title}\n${text}`; +} + +function limitEmbed(embed: DiscordEmbed): DiscordEmbed { + return { + ...embed, + title: embed.title ? truncate(embed.title, DISCORD_MAX_TITLE_LENGTH) : undefined, + description: embed.description + ? truncate(embed.description, DISCORD_MAX_DESCRIPTION_LENGTH) + : undefined, + fields: embed.fields?.slice(0, DISCORD_MAX_FIELDS).map(field => ({ + ...field, + name: truncate(field.name, DISCORD_MAX_FIELD_NAME_LENGTH), + value: truncate(field.value, DISCORD_MAX_FIELD_VALUE_LENGTH), + })), + }; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength - 3) + '...'; +} From bfbfb553961d2c3a6ced4b5f3aaf0f26c4cc3ddd Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:06:58 -0700 Subject: [PATCH 05/15] Route alert notifications to Discord webhooks Co-authored-by: Cursor --- packages/services/api/src/modules/alerts/index.ts | 2 ++ .../src/modules/alerts/providers/alerts-manager.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/services/api/src/modules/alerts/index.ts b/packages/services/api/src/modules/alerts/index.ts index c697b7834e..e925e14c7f 100644 --- a/packages/services/api/src/modules/alerts/index.ts +++ b/packages/services/api/src/modules/alerts/index.ts @@ -1,5 +1,6 @@ import { createModule } from 'graphql-modules'; import { SavedFiltersStorage } from '../saved-filters/providers/saved-filters-storage'; +import { DiscordCommunicationAdapter } from './providers/adapters/discord'; import { TeamsCommunicationAdapter } from './providers/adapters/msteams'; import { SlackCommunicationAdapter } from './providers/adapters/slack'; import { WebhookCommunicationAdapter } from './providers/adapters/webhook'; @@ -22,5 +23,6 @@ export const alertsModule = createModule({ SlackCommunicationAdapter, WebhookCommunicationAdapter, TeamsCommunicationAdapter, + DiscordCommunicationAdapter, ], }); diff --git a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts index ebf0ba2b8b..f39e8e1a30 100644 --- a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts +++ b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts @@ -10,6 +10,7 @@ import { Logger } from '../../shared/providers/logger'; import type { ProjectSelector } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './adapters/common'; +import { DiscordCommunicationAdapter } from './adapters/discord'; import { TeamsCommunicationAdapter } from './adapters/msteams'; import { SlackCommunicationAdapter } from './adapters/slack'; import { WebhookCommunicationAdapter } from './adapters/webhook'; @@ -28,6 +29,7 @@ export class AlertsManager { private slack: SlackCommunicationAdapter, private webhook: WebhookCommunicationAdapter, private teamsWebhook: TeamsCommunicationAdapter, + private discordWebhook: DiscordCommunicationAdapter, private organizationManager: OrganizationManager, private projectManager: ProjectManager, private storage: Storage, @@ -311,6 +313,14 @@ export class AlertsManager { integrations, }); } + if (channel.type === 'DISCORD_WEBHOOK') { + return this.discordWebhook.sendSchemaChangeNotification({ + event: safeEvent, + alert, + channel, + integrations, + }); + } return this.webhook.sendSchemaChangeNotification({ event: safeEvent, @@ -377,6 +387,8 @@ export class AlertsManager { await this.slack.sendChannelConfirmation(channelConfirmationContext); } else if (channel.type === 'MSTEAMS_WEBHOOK') { await this.teamsWebhook.sendChannelConfirmation(channelConfirmationContext); + } else if (channel.type === 'DISCORD_WEBHOOK') { + await this.discordWebhook.sendChannelConfirmation(channelConfirmationContext); } else { await this.webhook.sendChannelConfirmation(); } From 48779c6a331637c9de736340bc29086398ff7ac5 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:54:11 -0700 Subject: [PATCH 06/15] Route metric alerts to Discord webhooks. Co-authored-by: Cursor --- .../src/lib/metric-alert-notifier.ts | 65 ++++++++++++++++++- .../send-metric-alert-channel-notification.ts | 14 +++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts index cad8c550d9..ee467be20f 100644 --- a/packages/services/workflows/src/lib/metric-alert-notifier.ts +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -7,7 +7,7 @@ import { sendWebhook, type RequestBroker } from './webhooks/send-webhook.js'; export type AlertChannelRow = { id: string; - type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK'; + type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD_WEBHOOK'; name: string; slackChannel: string | null; webhookEndpoint: string | null; @@ -198,6 +198,61 @@ function severityColor(severity: NotificationEvent['rule']['severity']): string return SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.WARNING; } +export async function sendDiscordNotification(args: { + channel: AlertChannelRow; + event: NotificationEvent; + requestBroker: RequestBroker | null; + logger: Logger; + idempotencyKey: string; + attempt: number; + maxAttempts: number; +}) { + const { channel, event, logger } = args; + + if (!channel.webhookEndpoint) { + logger.warn({ channelId: channel.id }, 'Discord webhook endpoint not configured'); + return; + } + + const isFiring = event.state === 'firing'; + const emoji = isFiring ? '🔴' : '✅'; + const action = isFiring ? 'triggered' : 'resolved'; + const color = Number.parseInt(isFiring ? severityColor(event.rule.severity) : RESOLVED_COLOR, 16); + + const changeText = formatChangeText(event); + + const payload = { + username: 'GraphQL Hive', + allowed_mentions: { parse: [] }, + embeds: [ + { + title: truncate(`${emoji} ${event.rule.name} — ${action}`, 256), + color, + description: truncate(changeText, 4096), + fields: [ + { name: 'Type', value: event.rule.type, inline: true }, + { name: 'Severity', value: event.rule.severity, inline: true }, + { + name: 'Target', + value: truncate(`${event.targetSlug} in ${event.projectSlug}`, 1024), + inline: false, + }, + ], + }, + ], + }; + + await sendWebhook(logger, args.requestBroker, { + attempt: args.attempt, + maxAttempts: args.maxAttempts, + endpoint: channel.webhookEndpoint, + data: payload, + headers: { 'Idempotency-Key': args.idempotencyKey }, + }); + + logger.debug({ channelId: channel.id }, 'Discord notification sent'); +} + function formatChangeText(event: NotificationEvent): string { const { rule, currentValue, previousValue } = event; const unit = rule.type === 'LATENCY' ? 'ms' : rule.type === 'ERROR_RATE' ? '%' : ' requests'; @@ -246,3 +301,11 @@ function buildWebhookPayload(event: NotificationEvent) { organization: { slug: event.organizationSlug }, }; } + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength - 3) + '...'; +} diff --git a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts index 2182e9caa2..16f2e63d61 100644 --- a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts +++ b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts @@ -3,6 +3,7 @@ import { psql } from '@hive/postgres'; import { SpanKind, SpanStatusCode, trace } from '@hive/service-common'; import { defineTask, implementTask } from '../kit.js'; import { + sendDiscordNotification, sendSlackNotification, sendTeamsNotification, sendWebhookNotification, @@ -39,7 +40,7 @@ const HydrationRowSchema = z.object({ organizationId: z.string(), // channel channelId: z.string(), - channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK']), + channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD_WEBHOOK']), channelName: z.string(), slackChannel: z.string().nullable(), webhookEndpoint: z.string().nullable(), @@ -205,6 +206,17 @@ export const task = implementTask(SendMetricAlertChannelNotificationTask, async maxAttempts: helpers.job.max_attempts, }); break; + case 'DISCORD_WEBHOOK': + await sendDiscordNotification({ + channel, + event, + requestBroker: context.requestBroker, + logger, + idempotencyKey, + attempt: helpers.job.attempts, + maxAttempts: helpers.job.max_attempts, + }); + break; } // Record successful dispatch. ON CONFLICT covers the rare case where a From e14466e4fe7e4ccf7098db8271f9e3c2440a7490 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:17:15 -0700 Subject: [PATCH 07/15] Add Discord webhook option to alert channel UI. Co-authored-by: Cursor --- .../project/alerts/channels-table.tsx | 7 +++- .../project/alerts/create-channel.tsx | 41 ++++++++++++++----- .../target/alerts/alert-conditions-panel.tsx | 1 + .../web/app/src/pages/target-alerts-rules.tsx | 1 + 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/web/app/src/components/project/alerts/channels-table.tsx b/packages/web/app/src/components/project/alerts/channels-table.tsx index bdf749cd4b..c82fe0419e 100644 --- a/packages/web/app/src/components/project/alerts/channels-table.tsx +++ b/packages/web/app/src/components/project/alerts/channels-table.tsx @@ -17,6 +17,9 @@ export const ChannelsTable_AlertChannelFragment = graphql(` ... on TeamsWebhookChannel { endpoint } + ... on DiscordWebhookChannel { + endpoint + } } `); @@ -24,6 +27,7 @@ const colorMap = { [AlertChannelType.Slack]: 'green' as const, [AlertChannelType.Webhook]: 'yellow' as const, [AlertChannelType.MsteamsWebhook]: 'orange' as const, + [AlertChannelType.DiscordWebhook]: 'gray' as const, }; export function ChannelsTable(props: { @@ -39,7 +43,8 @@ export function ChannelsTable(props: { } if ( channel.__typename === 'AlertWebhookChannel' || - channel.__typename === 'TeamsWebhookChannel' + channel.__typename === 'TeamsWebhookChannel' || + channel.__typename === 'DiscordWebhookChannel' ) { return channel.endpoint; } diff --git a/packages/web/app/src/components/project/alerts/create-channel.tsx b/packages/web/app/src/components/project/alerts/create-channel.tsx index 008757005a..742b2ee356 100644 --- a/packages/web/app/src/components/project/alerts/create-channel.tsx +++ b/packages/web/app/src/components/project/alerts/create-channel.tsx @@ -87,9 +87,35 @@ export const CreateChannelModal = ({ } }, }); - const isWebhookLike = [AlertChannelType.Webhook, AlertChannelType.MsteamsWebhook].includes( - values.type, - ); + const isWebhookLike = [ + AlertChannelType.Webhook, + AlertChannelType.MsteamsWebhook, + AlertChannelType.DiscordWebhook, + ].includes(values.type); + + const endpointHelp = (() => { + if (values.endpoint) { + return

Hive will send alerts to your endpoint.

; + } + + if (values.type === AlertChannelType.MsteamsWebhook) { + return ( + + Follow this guide to set up an incoming webhook connector in MS Teams + + ); + } + + if (values.type === AlertChannelType.DiscordWebhook) { + return ( + + Follow this guide to set up a Discord webhook + + ); + } + + return

Hive will send alerts to your endpoint.

; + })(); return ( @@ -135,6 +161,7 @@ export const CreateChannelModal = ({ { value: AlertChannelType.Slack, name: 'Slack' }, { value: AlertChannelType.Webhook, name: 'Webhook' }, { value: AlertChannelType.MsteamsWebhook, name: 'MS Teams Webhook' }, + { value: AlertChannelType.DiscordWebhook, name: 'Discord Webhook' }, ]} /> {touched.type && errors.type &&
{errors.type}
} @@ -163,13 +190,7 @@ export const CreateChannelModal = ({ {mutation.data.addAlertChannel.error.inputErrors.webhookEndpoint} )} - {values.endpoint ? ( -

Hive will send alerts to your endpoint.

- ) : ( - - Follow this guide to set up an incoming webhook connector in MS Teams - - )} + {endpointHelp} )} diff --git a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx index 0ff0252a79..e5188e8b13 100644 --- a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx +++ b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx @@ -53,6 +53,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', + [AlertChannelType.DiscordWebhook]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string { diff --git a/packages/web/app/src/pages/target-alerts-rules.tsx b/packages/web/app/src/pages/target-alerts-rules.tsx index f11348c595..b80e192275 100644 --- a/packages/web/app/src/pages/target-alerts-rules.tsx +++ b/packages/web/app/src/pages/target-alerts-rules.tsx @@ -111,6 +111,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', + [AlertChannelType.DiscordWebhook]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string { From f6ffbd88ab0bc3c2bffe7d74b46eba922e2d8ad2 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:38:46 -0700 Subject: [PATCH 08/15] Use blue tag color for Discord alert channels. Co-authored-by: Cursor --- .../web/app/src/components/project/alerts/channels-table.tsx | 2 +- packages/web/app/src/components/v2/tag.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/app/src/components/project/alerts/channels-table.tsx b/packages/web/app/src/components/project/alerts/channels-table.tsx index c82fe0419e..678cb5a373 100644 --- a/packages/web/app/src/components/project/alerts/channels-table.tsx +++ b/packages/web/app/src/components/project/alerts/channels-table.tsx @@ -27,7 +27,7 @@ const colorMap = { [AlertChannelType.Slack]: 'green' as const, [AlertChannelType.Webhook]: 'yellow' as const, [AlertChannelType.MsteamsWebhook]: 'orange' as const, - [AlertChannelType.DiscordWebhook]: 'gray' as const, + [AlertChannelType.DiscordWebhook]: 'blue' as const, }; export function ChannelsTable(props: { diff --git a/packages/web/app/src/components/v2/tag.tsx b/packages/web/app/src/components/v2/tag.tsx index f567c38e71..99c2ca8943 100644 --- a/packages/web/app/src/components/v2/tag.tsx +++ b/packages/web/app/src/components/v2/tag.tsx @@ -2,6 +2,7 @@ import { ReactElement, ReactNode } from 'react'; import { cn } from '@/lib/utils'; const colors = { + blue: 'bg-blue-500/10 text-blue-500', green: 'bg-green-500/10 text-green-500', yellow: 'bg-yellow-500/10 text-yellow-500', gray: 'bg-neutral-10/10 text-neutral-10', From 2c71b7a7fa3159e22100534c3eecda2be7ce8ae6 Mon Sep 17 00:00:00 2001 From: ben-m-klein Date: Tue, 30 Jun 2026 13:54:13 -0700 Subject: [PATCH 09/15] Update packages/services/api/src/modules/alerts/providers/adapters/discord.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../api/src/modules/alerts/providers/adapters/discord.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts index 599e70143a..8fdb0eb4ef 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts @@ -191,7 +191,7 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { } private pluralize(word: string, num: number): string { - return word + (num > 1 ? 's' : ''); + return word + (num !== 1 ? 's' : ''); } async sendDiscordMessage(webhookUrl: string, payload: DiscordWebhookPayload) { From 396d0c90022ba9156c23be784a8b87654f1ad3c6 Mon Sep 17 00:00:00 2001 From: ben-m-klein Date: Tue, 30 Jun 2026 13:55:17 -0700 Subject: [PATCH 10/15] Update packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts index bf57b39d83..d022e2df7f 100644 --- a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts +++ b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts @@ -5,6 +5,6 @@ export const DiscordWebhookChannel: DiscordWebhookChannelResolvers = { return channel.type === 'DISCORD_WEBHOOK'; }, endpoint: async channel => { - return channel.webhookEndpoint!; + return channel.webhookEndpoint ?? ''; }, }; From 02b55bac18ca931bec141e03defe21ee06c83173 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:03:49 -0700 Subject: [PATCH 11/15] Add changeset for Discord alert channel support. Co-authored-by: Cursor --- .changeset/discord-webhook-alerts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/discord-webhook-alerts.md diff --git a/.changeset/discord-webhook-alerts.md b/.changeset/discord-webhook-alerts.md new file mode 100644 index 0000000000..5c4c75c96c --- /dev/null +++ b/.changeset/discord-webhook-alerts.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Add Discord as a first-class alert channel with Discord webhook formatting for alert notifications. From f167a8bb757f675bc5f3553d73afec7df661be5f Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:06:55 -0700 Subject: [PATCH 12/15] Use shared HTTP client for Discord webhooks --- .../alerts/providers/adapters/discord.spec.ts | 49 ++++++++++--------- .../alerts/providers/adapters/discord.ts | 30 ++++-------- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts index e38bd44103..1b2384e093 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts @@ -14,6 +14,15 @@ const logger = { const appBaseUrl = 'app-base-url'; const webhookUrl = 'webhook-url'; +function createAdapter() { + const httpClient = { + post: vi.fn().mockResolvedValue(undefined), + }; + const adapter = new DiscordCommunicationAdapter(logger as any, httpClient as any, appBaseUrl); + + return { adapter, httpClient }; +} + describe('DiscordCommunicationAdapter', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -111,7 +120,7 @@ describe('DiscordCommunicationAdapter', () => { } as AlertChannel, } as SchemaChangeNotificationInput; - const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const { adapter } = createAdapter(); const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); await adapter.sendSchemaChangeNotification(input); @@ -148,7 +157,7 @@ describe('DiscordCommunicationAdapter', () => { }, channel: {}, } as SchemaChangeNotificationInput; - const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const { adapter } = createAdapter(); const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); await adapter.sendSchemaChangeNotification(input); @@ -178,7 +187,7 @@ describe('DiscordCommunicationAdapter', () => { webhookEndpoint: webhookUrl, }, } as ChannelConfirmationInput; - const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); + const { adapter } = createAdapter(); const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); await adapter.sendChannelConfirmation(input); @@ -211,19 +220,9 @@ describe('DiscordCommunicationAdapter', () => { }); describe('sendDiscordMessage', () => { - const adapter = new DiscordCommunicationAdapter(logger as any, appBaseUrl); - - beforeEach(() => { - // @ts-expect-error mocking fetch - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - statusText: 'OK', - }), - ); - }); - it('sends a Discord webhook payload with embeds', async () => { + const { adapter, httpClient } = createAdapter(); + await adapter.sendDiscordMessage('http://example.com/webhook', { embeds: [ { @@ -234,14 +233,13 @@ describe('DiscordCommunicationAdapter', () => { ], }); - expect(fetch).toHaveBeenCalledWith( + expect(httpClient.post).toHaveBeenCalledWith( 'http://example.com/webhook', expect.objectContaining({ - method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + json: { username: 'GraphQL Hive', embeds: [ { @@ -251,12 +249,17 @@ describe('DiscordCommunicationAdapter', () => { }, ], allowed_mentions: { parse: [] }, - }), + }, + context: { + logger: expect.any(Object), + }, }), ); }); it('truncates embed fields to Discord limits', async () => { + const { adapter, httpClient } = createAdapter(); + await adapter.sendDiscordMessage('http://example.com/webhook', { embeds: [ { @@ -272,9 +275,7 @@ describe('DiscordCommunicationAdapter', () => { ], }); - const body = JSON.parse( - (fetch as unknown as ReturnType).mock.calls[0][1].body as string, - ); + const body = httpClient.post.mock.calls[0][1].json; expect(body.embeds[0].title).toHaveLength(256); expect(body.embeds[0].description).toHaveLength(4096); @@ -283,8 +284,8 @@ describe('DiscordCommunicationAdapter', () => { }); it('handles failed send operation', async () => { - // @ts-expect-error types obviously don't account for the fact this is mocked - fetch.mockImplementationOnce(() => Promise.resolve({ ok: false, statusText: 'Bad Request' })); + const { adapter, httpClient } = createAdapter(); + httpClient.post.mockRejectedValueOnce(new Error('Failed to send Discord message: Bad Request')); await expect( adapter.sendDiscordMessage('http://example.com/webhook', { diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts index 8fdb0eb4ef..5da90c41e3 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from 'graphql-modules'; import { CriticalityLevel } from '@graphql-inspector/core'; import { SchemaChangeType } from '@hive/storage'; +import { HttpClient } from '../../../shared/providers/http-client'; import { Logger } from '../../../shared/providers/logger'; import { WEB_APP_URL } from '../../../shared/providers/tokens'; import { @@ -51,6 +52,7 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { constructor( logger: Logger, + private httpClient: HttpClient, @Inject(WEB_APP_URL) private appBaseUrl: string, ) { this.logger = logger.child({ service: 'DiscordCommunicationAdapter' }); @@ -135,13 +137,7 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { ], }); } catch (error) { - const errorText = - error instanceof Error - ? error.toString() - : typeof error === 'string' - ? error - : JSON.stringify(error); - this.logger.error(`Failed to send Discord notification (error=%s)`, errorText); + this.logger.error('Failed to send Discord notification (error=%o)', error); } } @@ -180,13 +176,7 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { ], }); } catch (error) { - const errorText = - error instanceof Error - ? error.toString() - : typeof error === 'string' - ? error - : JSON.stringify(error); - this.logger.error(`Failed to send Discord notification`, errorText); + this.logger.error('Failed to send Discord notification (error=%o)', error); } } @@ -202,17 +192,15 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { embeds: payload.embeds?.slice(0, DISCORD_MAX_EMBEDS).map(limitEmbed), }; - const response = await fetch(webhookUrl, { - method: 'POST', + await this.httpClient.post(webhookUrl, { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(normalizedPayload), + json: normalizedPayload, + context: { + logger: this.logger, + }, }); - - if (!response.ok) { - throw new Error(`Failed to send Discord message: ${response.statusText}`); - } } } From bdeeeb5e4f4b8216db718d64abe22cd553ddf105 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:54:59 -0700 Subject: [PATCH 13/15] Polish Discord alert previews Co-authored-by: Cursor --- .../alerts/providers/adapters/discord.ts | 2 +- .../alerts/alert-notification-preview.tsx | 50 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts index 5da90c41e3..c7f8ea0f52 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts @@ -129,7 +129,7 @@ export class DiscordCommunicationAdapter implements CommunicationAdapter { }, { name: 'Commit', - value: input.event.schema.commit, + value: input.event.schema.commit || '—', inline: true, }, ], diff --git a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx index 2d22b39cc7..3a52e0f445 100644 --- a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx +++ b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx @@ -71,7 +71,7 @@ type PreviewProps = { direction: string; thresholdType: string; thresholdValue: string; - channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | null; + channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD_WEBHOOK' | null; targetSlug: string; projectSlug: string; }; @@ -261,6 +261,51 @@ function TeamsPreview(props: PreviewProps) { ); } +function DiscordPreview(props: PreviewProps) { + const colors = + SEVERITY_COLORS[props.severity as keyof typeof SEVERITY_COLORS] ?? SEVERITY_COLORS.WARNING; + const threshold = formatThreshold( + props.alertType, + props.direction, + props.thresholdValue, + props.thresholdType, + ); + + return ( +
+
Discord preview
+
+ {/* Discord embeds render the severity color as a vertical bar. */} +
+
+
+ 🔴 {props.alertName || 'Untitled alert'} — triggered +
+
+ {notificationMetricLabel(props.alertType, props.metricLabel)} {threshold} +
+
+
+ Type + {props.alertType} +
+
+ Severity + {props.severity} +
+
+ Target + + {props.targetSlug} in {props.projectSlug} + +
+
+
+
+
+ ); +} + export function AlertPreview(props: PreviewProps) { if (!props.channelType) { return ( @@ -281,6 +326,9 @@ export function AlertPreview(props: PreviewProps) { case 'MSTEAMS_WEBHOOK': preview = ; break; + case 'DISCORD_WEBHOOK': + preview = ; + break; default: return null; } From b1ad5369510cb45dbf27f910d81c3643271d9fd5 Mon Sep 17 00:00:00 2001 From: ben-m-klein Date: Mon, 13 Jul 2026 16:36:48 -0700 Subject: [PATCH 14/15] Update packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts Co-authored-by: jdolle <1841898+jdolle@users.noreply.github.com> --- .../src/actions/2026.06.22T20-10-00.discord-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts index 869fdbff82..046ddb07ef 100644 --- a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts +++ b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts @@ -3,6 +3,6 @@ import { type MigrationExecutor } from '../pg-migrator'; export default { name: '2026.06.22T20-10-00.discord-webhook.ts', run: ({ psql }) => psql` - ALTER TYPE alert_channel_type ADD VALUE 'DISCORD_WEBHOOK'; + ALTER TYPE alert_channel_type ADD VALUE IF NOT EXISTS 'DISCORD_WEBHOOK'; `, } satisfies MigrationExecutor; From 1e3d7f4fe18fd6f12721cff0795c92c8b1796d29 Mon Sep 17 00:00:00 2001 From: Ben Klein <200676210+ben-m-klein@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:45:49 -0700 Subject: [PATCH 15/15] Rename Discord alert channel type to DISCORD Align the enum with SLACK naming instead of DISCORD_WEBHOOK, as suggested in review. Co-authored-by: Cursor --- .../src/actions/2026.06.22T20-10-00.discord-webhook.ts | 2 +- packages/services/api/src/modules/alerts/module.graphql.ts | 2 +- .../api/src/modules/alerts/providers/alerts-manager.ts | 4 ++-- .../modules/alerts/providers/metric-alert-rules-storage.ts | 2 +- .../src/modules/alerts/resolvers/DiscordWebhookChannel.ts | 2 +- packages/services/storage/src/db/types.ts | 2 +- packages/services/storage/src/index.ts | 2 +- .../services/workflows/src/lib/metric-alert-notifier.ts | 2 +- .../src/tasks/send-metric-alert-channel-notification.ts | 4 ++-- .../app/src/components/project/alerts/channels-table.tsx | 2 +- .../app/src/components/project/alerts/create-channel.tsx | 6 +++--- .../src/components/target/alerts/alert-conditions-panel.tsx | 2 +- .../components/target/alerts/alert-notification-preview.tsx | 4 ++-- packages/web/app/src/pages/target-alerts-rules.tsx | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts index 046ddb07ef..2a8b23eb66 100644 --- a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts +++ b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts @@ -3,6 +3,6 @@ import { type MigrationExecutor } from '../pg-migrator'; export default { name: '2026.06.22T20-10-00.discord-webhook.ts', run: ({ psql }) => psql` - ALTER TYPE alert_channel_type ADD VALUE IF NOT EXISTS 'DISCORD_WEBHOOK'; + ALTER TYPE alert_channel_type ADD VALUE 'DISCORD'; `, } satisfies MigrationExecutor; diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index e87f6d1857..ada31655c4 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -25,7 +25,7 @@ export default gql` SLACK WEBHOOK MSTEAMS_WEBHOOK - DISCORD_WEBHOOK + DISCORD } enum AlertType { diff --git a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts index f39e8e1a30..57146734bd 100644 --- a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts +++ b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts @@ -313,7 +313,7 @@ export class AlertsManager { integrations, }); } - if (channel.type === 'DISCORD_WEBHOOK') { + if (channel.type === 'DISCORD') { return this.discordWebhook.sendSchemaChangeNotification({ event: safeEvent, alert, @@ -387,7 +387,7 @@ export class AlertsManager { await this.slack.sendChannelConfirmation(channelConfirmationContext); } else if (channel.type === 'MSTEAMS_WEBHOOK') { await this.teamsWebhook.sendChannelConfirmation(channelConfirmationContext); - } else if (channel.type === 'DISCORD_WEBHOOK') { + } else if (channel.type === 'DISCORD') { await this.discordWebhook.sendChannelConfirmation(channelConfirmationContext); } else { await this.webhook.sendChannelConfirmation(); diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index eef859840b..a6b8fb7312 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -142,7 +142,7 @@ const METRIC_ALERT_INCIDENT_SELECT = psql` const AlertChannelRowSchema = zod.object({ id: zod.string(), projectId: zod.string(), - type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD_WEBHOOK']), + type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD']), name: zod.string(), createdAt: zod.string(), slackChannel: zod.string().nullable(), diff --git a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts index d022e2df7f..5b5653ff35 100644 --- a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts +++ b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts @@ -2,7 +2,7 @@ import type { DiscordWebhookChannelResolvers } from './../../../__generated__/ty export const DiscordWebhookChannel: DiscordWebhookChannelResolvers = { __isTypeOf: channel => { - return channel.type === 'DISCORD_WEBHOOK'; + return channel.type === 'DISCORD'; }, endpoint: async channel => { return channel.webhookEndpoint ?? ''; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 75abdd627d..57b8fa0f73 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -7,7 +7,7 @@ * */ -export type alert_channel_type = 'DISCORD_WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; +export type alert_channel_type = 'DISCORD' | 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS'; export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT'; export type hive_subgraph_log_type = 'added' | 'changed' | 'removed' | 'unchanged'; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 2a1afee477..d8588ef391 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4575,7 +4575,7 @@ const OrganizationBillingModel = z.object({ const AlertChannelModel = z.object({ id: z.string(), name: z.string(), - type: z.enum(['DISCORD_WEBHOOK', 'MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), + type: z.enum(['DISCORD', 'MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), projectId: z.string(), createdAt: z.string(), slackChannel: z.string().nullable(), diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts index ee467be20f..613fd0f86c 100644 --- a/packages/services/workflows/src/lib/metric-alert-notifier.ts +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -7,7 +7,7 @@ import { sendWebhook, type RequestBroker } from './webhooks/send-webhook.js'; export type AlertChannelRow = { id: string; - type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD_WEBHOOK'; + type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD'; name: string; slackChannel: string | null; webhookEndpoint: string | null; diff --git a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts index 16f2e63d61..07f14926ff 100644 --- a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts +++ b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts @@ -40,7 +40,7 @@ const HydrationRowSchema = z.object({ organizationId: z.string(), // channel channelId: z.string(), - channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD_WEBHOOK']), + channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD']), channelName: z.string(), slackChannel: z.string().nullable(), webhookEndpoint: z.string().nullable(), @@ -206,7 +206,7 @@ export const task = implementTask(SendMetricAlertChannelNotificationTask, async maxAttempts: helpers.job.max_attempts, }); break; - case 'DISCORD_WEBHOOK': + case 'DISCORD': await sendDiscordNotification({ channel, event, diff --git a/packages/web/app/src/components/project/alerts/channels-table.tsx b/packages/web/app/src/components/project/alerts/channels-table.tsx index 678cb5a373..54b75fc98c 100644 --- a/packages/web/app/src/components/project/alerts/channels-table.tsx +++ b/packages/web/app/src/components/project/alerts/channels-table.tsx @@ -27,7 +27,7 @@ const colorMap = { [AlertChannelType.Slack]: 'green' as const, [AlertChannelType.Webhook]: 'yellow' as const, [AlertChannelType.MsteamsWebhook]: 'orange' as const, - [AlertChannelType.DiscordWebhook]: 'blue' as const, + [AlertChannelType.Discord]: 'blue' as const, }; export function ChannelsTable(props: { diff --git a/packages/web/app/src/components/project/alerts/create-channel.tsx b/packages/web/app/src/components/project/alerts/create-channel.tsx index 742b2ee356..9cc01b70ea 100644 --- a/packages/web/app/src/components/project/alerts/create-channel.tsx +++ b/packages/web/app/src/components/project/alerts/create-channel.tsx @@ -90,7 +90,7 @@ export const CreateChannelModal = ({ const isWebhookLike = [ AlertChannelType.Webhook, AlertChannelType.MsteamsWebhook, - AlertChannelType.DiscordWebhook, + AlertChannelType.Discord, ].includes(values.type); const endpointHelp = (() => { @@ -106,7 +106,7 @@ export const CreateChannelModal = ({ ); } - if (values.type === AlertChannelType.DiscordWebhook) { + if (values.type === AlertChannelType.Discord) { return ( Follow this guide to set up a Discord webhook @@ -161,7 +161,7 @@ export const CreateChannelModal = ({ { value: AlertChannelType.Slack, name: 'Slack' }, { value: AlertChannelType.Webhook, name: 'Webhook' }, { value: AlertChannelType.MsteamsWebhook, name: 'MS Teams Webhook' }, - { value: AlertChannelType.DiscordWebhook, name: 'Discord Webhook' }, + { value: AlertChannelType.Discord, name: 'Discord Webhook' }, ]} /> {touched.type && errors.type &&
{errors.type}
} diff --git a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx index e5188e8b13..78b4cb1a1c 100644 --- a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx +++ b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx @@ -53,7 +53,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', - [AlertChannelType.DiscordWebhook]: 'Discord', + [AlertChannelType.Discord]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string { diff --git a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx index 3a52e0f445..ab07ace142 100644 --- a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx +++ b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx @@ -71,7 +71,7 @@ type PreviewProps = { direction: string; thresholdType: string; thresholdValue: string; - channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD_WEBHOOK' | null; + channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD' | null; targetSlug: string; projectSlug: string; }; @@ -326,7 +326,7 @@ export function AlertPreview(props: PreviewProps) { case 'MSTEAMS_WEBHOOK': preview = ; break; - case 'DISCORD_WEBHOOK': + case 'DISCORD': preview = ; break; default: diff --git a/packages/web/app/src/pages/target-alerts-rules.tsx b/packages/web/app/src/pages/target-alerts-rules.tsx index b80e192275..b188b41d43 100644 --- a/packages/web/app/src/pages/target-alerts-rules.tsx +++ b/packages/web/app/src/pages/target-alerts-rules.tsx @@ -111,7 +111,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', - [AlertChannelType.DiscordWebhook]: 'Discord', + [AlertChannelType.Discord]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string {