Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
44d5c28
Add Discord webhook alert channel migration
ben-m-klein Jun 22, 2026
f0bd62f
Support Discord webhook alert channel type
ben-m-klein Jun 22, 2026
5438740
Add Discord webhook GraphQL channel type
ben-m-klein Jun 22, 2026
3011340
Add Discord webhook notification adapter
ben-m-klein Jun 22, 2026
bfbfb55
Route alert notifications to Discord webhooks
ben-m-klein Jun 22, 2026
48779c6
Route metric alerts to Discord webhooks.
ben-m-klein Jun 22, 2026
e14466e
Add Discord webhook option to alert channel UI.
ben-m-klein Jun 23, 2026
f6ffbd8
Use blue tag color for Discord alert channels.
ben-m-klein Jun 30, 2026
2c71b7a
Update packages/services/api/src/modules/alerts/providers/adapters/di…
ben-m-klein Jun 30, 2026
396d0c9
Update packages/services/api/src/modules/alerts/resolvers/DiscordWebh…
ben-m-klein Jun 30, 2026
02b55ba
Add changeset for Discord alert channel support.
ben-m-klein Jun 30, 2026
f167a8b
Use shared HTTP client for Discord webhooks
ben-m-klein Jul 1, 2026
42846ac
Merge branch 'main' into feature/discord-webhook-alerts
ben-m-klein Jul 4, 2026
bdeeeb5
Polish Discord alert previews
ben-m-klein Jul 7, 2026
b1ad536
Update packages/migrations/src/actions/2026.06.22T20-10-00.discord-we…
ben-m-klein Jul 13, 2026
1e3d7f4
Rename Discord alert channel type to DISCORD
ben-m-klein Jul 13, 2026
e71e6ec
Merge upstream/main into feature/discord-webhook-alerts
ben-m-klein Jul 13, 2026
d85b378
Merge branch 'main' into feature/discord-webhook-alerts
ben-m-klein Jul 21, 2026
f6034eb
Make Discord enum migration idempotent
ben-m-klein Jul 21, 2026
7502c07
Fix Prettier formatting in Discord alert adapter
ben-m-klein Jul 21, 2026
47aacd6
Fix Prettier formatting in Discord adapter tests
ben-m-klein Jul 22, 2026
f074b07
Merge branch 'main' into feature/discord-webhook-alerts
jonathanawesome Jul 22, 2026
a76126d
Merge branch 'main' into feature/discord-webhook-alerts
ben-m-klein Jul 28, 2026
4b34385
Merge branch 'main' into feature/discord-webhook-alerts
ben-m-klein Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/discord-webhook-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hive': minor
---

Add Discord as a first-class alert channel with Discord webhook formatting for alert notifications.
Original file line number Diff line number Diff line change
@@ -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 IF NOT EXISTS 'DISCORD';
`,
} satisfies MigrationExecutor;
1 change: 1 addition & 0 deletions packages/migrations/src/run-pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT
import('./actions/2026.05.29T00-00-00.schema-log-target-id-nullability'),
import('./actions/2026.05.07T00-00-00.schema-version-promotion'),
import('./actions/2026.06.05T00-00-00.metric-alert-filter-shared-only'),
import('./actions/2026.06.22T20-10-00.discord-webhook'),
import('./actions/2026.06.25T00-00-00.failing-dangerous-change-types'),
]),
});
2 changes: 2 additions & 0 deletions packages/services/api/src/modules/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,5 +23,6 @@ export const alertsModule = createModule({
SlackCommunicationAdapter,
WebhookCommunicationAdapter,
TeamsCommunicationAdapter,
DiscordCommunicationAdapter,
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/services/api/src/modules/alerts/module.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default gql`
SLACK
WEBHOOK
MSTEAMS_WEBHOOK
DISCORD
}

enum AlertType {
Expand Down Expand Up @@ -156,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!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
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';

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();
});

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<SchemaChangeType>;

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 } = createAdapter();
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 } = createAdapter();
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 } = createAdapter();
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', () => {
it('sends a Discord webhook payload with embeds', async () => {
const { adapter, httpClient } = createAdapter();

await adapter.sendDiscordMessage('http://example.com/webhook', {
embeds: [
{
title: 'Notification',
description: 'Schema changed',
color: 0x2ecc71,
},
],
});

expect(httpClient.post).toHaveBeenCalledWith(
'http://example.com/webhook',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
},
json: {
username: 'GraphQL Hive',
embeds: [
{
title: 'Notification',
description: 'Schema changed',
color: 0x2ecc71,
},
],
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: [
{
title: 'a'.repeat(300),
description: 'b'.repeat(5000),
fields: [
{
name: 'c'.repeat(300),
value: 'd'.repeat(2000),
},
],
},
],
});

const body = httpClient.post.mock.calls[0][1].json;

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 () => {
const { adapter, httpClient } = createAdapter();
httpClient.post.mockRejectedValueOnce(
new Error('Failed to send Discord message: Bad Request'),
);

await expect(
adapter.sendDiscordMessage('http://example.com/webhook', {
embeds: [{ title: 'Test message' }],
}),
).rejects.toThrow('Failed to send Discord message: Bad Request');
});
});
});
Loading