diff --git a/.gitignore b/.gitignore
index 7d4070eb7..bf05f48e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -130,6 +130,7 @@ packages/web/app/environment-*.mjs
packages/web/app/src/gql/*.ts
packages/web/app/src/gql/*.json
packages/web/app/src/graphql/*.ts
+packages/web/docs/public/feed.xml
# Changelog
packages/web/app/src/components/ui/changelog/generated-changelog.ts
@@ -142,6 +143,4 @@ deployment/utils/contour.types.ts
schema.graphql
resolvers.generated.ts
-# generated by tsup
-configs/tsup/dev.config.*.mjs
-packages/web/docs/public/feed.xml
+docker/docker-compose.override.yml
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..53d1c14db
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v22
diff --git a/README.md b/README.md
index a346487a2..37c337fc8 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,11 @@
+
+
+
+
+
+
+
+
# GraphQL Hive
GraphQL Hive provides all the tools the get visibility of your GraphQL architecture at all stages,
@@ -51,10 +59,11 @@ GraphQL Hive helps you get a global overview of the usage of your GraphQL API wi
### Integrations
-GraphQL Hive is well integrated with **Slack** and most **CI/CD** systems to get you up and running
-as smoothly as possible!
+GraphQL Hive is well integrated with **Slack**, **MS Teams** and most **CI/CD** systems to get you
+up and running as smoothly as possible!
-GraphQL Hive can notify your team when schema changes occur, either via Slack or a custom webhook.
+GraphQL Hive can notify your team when schema changes occur, either via Slack, MS Teams or a custom
+webhook.
Also, the Hive CLI allows integration of the schema checks mechanism to all CI/CD systems (GitHub,
BitBucket, Azure, and others). The same applies to schema publishing and operations checks.
diff --git a/codegen.mts b/codegen.mts
index 1fbf26bc6..c5401b80e 100644
--- a/codegen.mts
+++ b/codegen.mts
@@ -140,6 +140,7 @@ const config: CodegenConfig = {
AlertChannel: '../modules/alerts/module.graphql.mappers#AlertChannelMapper',
AlertSlackChannel: '../modules/alerts/module.graphql.mappers#AlertSlackChannelMapper',
AlertWebhookChannel: '../modules/alerts/module.graphql.mappers#AlertWebhookChannelMapper',
+ TeamsWebhookChannel: '../modules/alerts/module.graphql.mappers#TeamsWebhookChannelMapper',
Alert: '../modules/alerts/module.graphql.mappers#AlertMapper',
AdminQuery: '../modules/admin/module.graphql.mappers#AdminQueryMapper',
AdminStats: '../modules/admin/module.graphql.mappers#AdminStatsMapper',
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 6f23ad19d..1a0060271 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -5,8 +5,9 @@
Developing Hive locally requires you to have the following software installed locally:
- Node.js 21 (or `nvm` or `fnm`)
-- pnpm v8
-- Docker
+- pnpm v9
+- Docker version 26.1.1 or later(previous versions will not work correctly on arm64)
+- make sure these ports are free: 5432, 6379, 9000, 9001, 8123, 9092, 8081, 8082, 9644, 3567, 7043
## Setup Instructions
diff --git a/package.json b/package.json
index e76ea4846..a279d8cc0 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"build:web": "pnpm prebuild && pnpm turbo build --filter=./packages/web/* --color",
"cargo:fix": "bash ./scripts/fix-symbolic-link.sh",
"docker:build": "docker buildx bake -f docker/docker.hcl --load build",
+ "docker:override-up": "docker compose -f ./docker/docker-compose.override.yml up -d --remove-orphans",
"env:sync": "tsx scripts/sync-env-files.ts",
"generate": "pnpm --filter @hive/storage db:generate && pnpm graphql:generate",
"graphql:generate": "graphql-codegen --config codegen.mts",
@@ -33,7 +34,7 @@
"lint:env-template": "tsx scripts/check-env-template.ts",
"lint:fix": "pnpm lint --fix",
"lint:prettier": "prettier --cache --check .",
- "local:setup": "docker-compose -f ./docker/docker-compose.dev.yml up -d --remove-orphans && pnpm --filter @hive/migrations db:init",
+ "local:setup": "docker compose -f ./docker/docker-compose.dev.yml up -d --remove-orphans && pnpm --filter @hive/migrations db:init",
"postinstall": "node ./scripts/patch-manifests.js && pnpm env:sync && node ./scripts/turborepo-cleanup.js && pnpm cargo:fix",
"pre-commit": "exit 0 && lint-staged",
"prebuild": "rimraf deploy-tmp && rimraf packages/**/deploy-tmp",
diff --git a/packages/libraries/cli/examples/single.with.mutation.graphql b/packages/libraries/cli/examples/single.with.mutation.graphql
new file mode 100644
index 000000000..0692d095c
--- /dev/null
+++ b/packages/libraries/cli/examples/single.with.mutation.graphql
@@ -0,0 +1,19 @@
+type Query {
+ foo: Int!
+ bar: String
+ test: String
+ last: Boolean
+ fooTwo: Int!
+}
+
+type Mutation {
+ addFooAnother(foo: Int!): Int!
+ addBar(bar: String): String
+ addTest(test: String): String
+}
+
+schema {
+ query: Query
+ mutation: Mutation
+}
+# test 3
diff --git a/packages/migrations/src/actions/2024.06.11T10-10-00.ms-teams-webhook.ts b/packages/migrations/src/actions/2024.06.11T10-10-00.ms-teams-webhook.ts
new file mode 100644
index 000000000..b4f1f2ec5
--- /dev/null
+++ b/packages/migrations/src/actions/2024.06.11T10-10-00.ms-teams-webhook.ts
@@ -0,0 +1,8 @@
+import { type MigrationExecutor } from '../pg-migrator';
+
+export default {
+ name: '2024.06.11T10-10-00.ms-teams-webhook.ts',
+ run: ({ sql }) => sql`
+ ALTER TYPE alert_channel_type ADD VALUE 'MSTEAMS_WEBHOOK';
+ `,
+} satisfies MigrationExecutor;
diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts
index cf3edfa52..76b7c9557 100644
--- a/packages/migrations/src/run-pg-migrations.ts
+++ b/packages/migrations/src/run-pg-migrations.ts
@@ -63,6 +63,7 @@ import migration_2024_01_12_01T00_00_00_contracts from './actions/2024.01.26T00.
import migration_2024_01_12_01T00_00_00_schema_check_pagination_index_update from './actions/2024.01.26T00.00.01.schema-check-pagination-index-update';
import migration_2024_02_19_01T00_00_00_schema_check_store_breaking_change_metadata from './actions/2024.02.19T00.00.01.schema-check-store-breaking-change-metadata';
import migration_2024_04_09T10_10_00_check_approval_comment from './actions/2024.04.09T10-10-00.check-approval-comment';
+import migration_2024_06_11T10_10_00_ms_teams_webhook from './actions/2024.06.11T10-10-00.ms-teams-webhook';
import { runMigrations } from './pg-migrator';
export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string }) =>
@@ -134,5 +135,6 @@ export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string })
migration_2024_01_12_01T00_00_00_schema_check_pagination_index_update,
migration_2024_02_19_01T00_00_00_schema_check_store_breaking_change_metadata,
migration_2024_04_09T10_10_00_check_approval_comment,
+ migration_2024_06_11T10_10_00_ms_teams_webhook,
],
});
diff --git a/packages/services/api/package.json b/packages/services/api/package.json
index 263a40b22..f4418b25e 100644
--- a/packages/services/api/package.json
+++ b/packages/services/api/package.json
@@ -62,6 +62,7 @@
"slonik": "30.4.4",
"supertokens-node": "15.2.1",
"tslib": "2.6.3",
+ "vitest": "1.6.0",
"zod": "3.23.8",
"zod-validation-error": "3.3.0"
}
diff --git a/packages/services/api/src/modules/alerts/index.ts b/packages/services/api/src/modules/alerts/index.ts
index 3c386cb63..dfcb8df39 100644
--- a/packages/services/api/src/modules/alerts/index.ts
+++ b/packages/services/api/src/modules/alerts/index.ts
@@ -1,4 +1,5 @@
import { createModule } from 'graphql-modules';
+import { TeamsCommunicationAdapter } from './providers/adapters/msteams';
import { SlackCommunicationAdapter } from './providers/adapters/slack';
import { WebhookCommunicationAdapter } from './providers/adapters/webhook';
import { AlertsManager } from './providers/alerts-manager';
@@ -10,5 +11,10 @@ export const alertsModule = createModule({
dirname: __dirname,
typeDefs,
resolvers,
- providers: [AlertsManager, SlackCommunicationAdapter, WebhookCommunicationAdapter],
+ providers: [
+ AlertsManager,
+ SlackCommunicationAdapter,
+ WebhookCommunicationAdapter,
+ TeamsCommunicationAdapter,
+ ],
});
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 9e349a1b9..c34397be6 100644
--- a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts
+++ b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts
@@ -3,4 +3,5 @@ import type { Alert, AlertChannel } from '../../shared/entities';
export type AlertChannelMapper = AlertChannel;
export type AlertSlackChannelMapper = AlertChannel;
export type AlertWebhookChannelMapper = AlertChannel;
+export type TeamsWebhookChannelMapper = AlertChannel;
export type AlertMapper = Alert;
diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts
index c4a304985..85fd65180 100644
--- a/packages/services/api/src/modules/alerts/module.graphql.ts
+++ b/packages/services/api/src/modules/alerts/module.graphql.ts
@@ -16,6 +16,7 @@ export default gql`
enum AlertChannelType {
SLACK
WEBHOOK
+ MSTEAMS_WEBHOOK
}
enum AlertType {
@@ -140,6 +141,13 @@ export default gql`
endpoint: String!
}
+ type TeamsWebhookChannel 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/providers/adapters/common.ts b/packages/services/api/src/modules/alerts/providers/adapters/common.ts
index fb525ee40..c8ef280ee 100644
--- a/packages/services/api/src/modules/alerts/providers/adapters/common.ts
+++ b/packages/services/api/src/modules/alerts/providers/adapters/common.ts
@@ -8,6 +8,12 @@ import type {
Target,
} from '../../../../shared/entities';
+interface NotificationIntegrations {
+ slack: {
+ token: string | null | undefined;
+ };
+}
+
export interface SchemaChangeNotificationInput {
event: {
organization: Pick;
@@ -25,11 +31,7 @@ export interface SchemaChangeNotificationInput {
};
alert: Alert;
channel: AlertChannel;
- integrations: {
- slack: {
- token: string;
- };
- };
+ integrations: NotificationIntegrations;
}
export interface ChannelConfirmationInput {
@@ -39,11 +41,7 @@ export interface ChannelConfirmationInput {
project: Pick;
};
channel: AlertChannel;
- integrations: {
- slack: {
- token: string;
- };
- };
+ integrations: NotificationIntegrations;
}
export interface CommunicationAdapter {
@@ -59,9 +57,13 @@ export function quotesTransformer(msg: string, symbols = '**') {
const findSingleQuotes = /'([^']+)'/gim;
const findDoubleQuotes = /"([^"]+)"/gim;
- function transformm(_: string, value: string) {
+ function transform(_: string, value: string) {
return `${symbols}${value}${symbols}`;
}
- return msg.replace(findSingleQuotes, transformm).replace(findDoubleQuotes, transformm);
+ return msg.replace(findSingleQuotes, transform).replace(findDoubleQuotes, transform);
}
+
+export const createMDLink = ({ text, url }: { text: string; url: string }) => {
+ return `[${text}](${url})`;
+};
diff --git a/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts b/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts
new file mode 100644
index 000000000..cb0791034
--- /dev/null
+++ b/packages/services/api/src/modules/alerts/providers/adapters/msteams.spec.ts
@@ -0,0 +1,255 @@
+import { AlertChannel } from 'packages/services/api/src/shared/entities';
+import { vi } from 'vitest';
+import { SchemaChangeType } from '@hive/storage';
+import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './common';
+import { TeamsCommunicationAdapter } from './msteams';
+
+const logger = {
+ child: () => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ }),
+};
+
+const appBaseUrl = 'app-base-url';
+const webhookUrl = 'webhook-url';
+
+describe('TeamsCommunicationAdapter', () => {
+ describe('sendSchemaChangeNotification', () => {
+ it('should send schema change notification', 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,
+ breakingChangeSchemaCoordinate: 'Mutation.addFoo',
+ },
+ {
+ id: 'id-2',
+ type: 'FIELD_REMOVED',
+ approvalMetadata: null,
+ criticality: 'BREAKING',
+ message: "Field 'foo3' was removed from object type 'Query'",
+ meta: {
+ typeName: 'Query',
+ removedFieldName: 'foo3',
+ isRemovedFieldDeprecated: false,
+ typeType: 'object type',
+ },
+ path: 'Query.foo3',
+ isSafeBasedOnUsage: false,
+ reason:
+ 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.',
+ usageStatistics: null,
+ breakingChangeSchemaCoordinate: 'Query.foo3',
+ },
+ {
+ id: 'id-3',
+ 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,
+ breakingChangeSchemaCoordinate: null,
+ },
+ {
+ id: 'id-4',
+ type: 'FIELD_ADDED',
+ approvalMetadata: null,
+ criticality: 'NON_BREAKING',
+ message: "Field 'foo4' was added to object type 'Query'",
+ meta: {
+ typeName: 'Query',
+ addedFieldName: 'foo4',
+ typeType: 'object type',
+ },
+ path: 'Query.foo4',
+ isSafeBasedOnUsage: false,
+ reason: null,
+ usageStatistics: null,
+ breakingChangeSchemaCoordinate: null,
+ },
+ ] as Array;
+ const messages = [] as string[];
+ 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',
+ name: '',
+ },
+ project: {
+ id: 'project-id',
+ cleanId: 'project-clean-id',
+ name: 'project-name',
+ },
+ target: {
+ id: 'target-id',
+ cleanId: '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 TeamsCommunicationAdapter(logger as any, appBaseUrl);
+ const sendTeamsMessageSpy = vi.spyOn(adapter as any, 'sendTeamsMessage');
+
+ await adapter.sendSchemaChangeNotification(input);
+
+ expect(sendTeamsMessageSpy.mock.calls[0]).toMatchInlineSnapshot(`
+ [
+ webhook-url,
+ π Hi, I found *4 changes* in project [project-name](app-base-url/org-clean-id/project-clean-id), target [target-name](app-base-url/org-clean-id/project-clean-id/target-clean-id) ([view details](app-base-url/org-clean-id/project-clean-id/target-clean-id/history/schema-id)):
+
+ ### Breaking changes
+ - Field \`addFoo\` was removed from object type \`Mutation\`
+ - Field \`foo3\` was removed from object type \`Query\`
+ ### Safe changes
+ - Field \`addFooT\` was added to object type \`Mutation\`
+ - Field \`foo4\` was added to object type \`Query\`
+ ,
+ [view full report](app-base-url/org-clean-id/project-clean-id/target-clean-id/history/schema-id),
+ ]
+ `);
+ });
+
+ // Add more test cases here if needed
+ });
+});
+describe('sendChannelConfirmation', () => {
+ it('should send channel confirmation', async () => {
+ const appBaseUrl = 'app-base-url';
+ const webhookUrl = 'webhook-url';
+ const input = {
+ event: {
+ organization: {
+ id: 'org-id',
+ cleanId: 'org-clean-id',
+ },
+ project: {
+ id: 'project-id',
+ cleanId: 'project-clean-id',
+ name: 'project-name',
+ },
+ kind: 'created',
+ },
+ channel: {
+ webhookEndpoint: webhookUrl,
+ },
+ } as ChannelConfirmationInput;
+ const adapter = new TeamsCommunicationAdapter(logger as any, appBaseUrl);
+ const sendTeamsMessageSpy = vi.spyOn(adapter as any, 'sendTeamsMessage');
+ await adapter.sendChannelConfirmation(input);
+ expect(sendTeamsMessageSpy.mock.calls[0]).toMatchInlineSnapshot(`
+ [
+ webhook-url,
+ π Hi! I'm the notification π.
+ I will send here notifications about your [project-name](app-base-url/org-clean-id/project-clean-id) project.,
+ ]
+ `);
+
+ input.event.kind = 'deleted';
+ await adapter.sendChannelConfirmation(input);
+ expect(sendTeamsMessageSpy.mock.calls[1]).toMatchInlineSnapshot(`
+ [
+ webhook-url,
+ π Hi! I'm the notification π.
+ I will no longer send here notifications about your [project-name](app-base-url/org-clean-id/project-clean-id) project.,
+ ]
+ `);
+ });
+
+ describe('sendTeamsMessage', () => {
+ const adapter = new TeamsCommunicationAdapter(logger as any, appBaseUrl);
+
+ beforeAll(() => {
+ // @ts-expect-error mocking fetch
+ global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ statusText: 'OK',
+ }),
+ );
+ });
+
+ it('sends a message under 27k characters', async () => {
+ const message = 'Short message';
+ await adapter.sendTeamsMessage('http://example.com/webhook', message);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining(message),
+ }),
+ );
+ });
+
+ it('truncates and appends link for messages over 27k characters', async () => {
+ const longMessage = 'a'.repeat(27001);
+ const link = 'http://example.com/fullReport';
+ await adapter.sendTeamsMessage('http://example.com/webhook', longMessage, link);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('... message truncated. ' + link),
+ }),
+ );
+ });
+
+ 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.sendTeamsMessage('http://example.com/webhook', 'Test message'),
+ ).rejects.toThrow('Failed to send Microsoft Teams message: Bad Request');
+ });
+ });
+});
diff --git a/packages/services/api/src/modules/alerts/providers/adapters/msteams.ts b/packages/services/api/src/modules/alerts/providers/adapters/msteams.ts
new file mode 100644
index 000000000..8b986577b
--- /dev/null
+++ b/packages/services/api/src/modules/alerts/providers/adapters/msteams.ts
@@ -0,0 +1,209 @@
+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';
+
+@Injectable()
+export class TeamsCommunicationAdapter implements CommunicationAdapter {
+ private logger: Logger;
+
+ constructor(
+ logger: Logger,
+ @Inject(WEB_APP_URL) private appBaseUrl: string,
+ ) {
+ this.logger = logger.child({ service: 'TeamsCommunicationAdapter' });
+ }
+
+ async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) {
+ this.logger.debug(
+ `Sending Schema Change Notifications over Microsoft Teams (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(`Microsoft Teams 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.cleanId}/${input.event.project.cleanId}`,
+ });
+ const targetLink = createMDLink({
+ text: input.event.target.name,
+ url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}/${input.event.target.cleanId}`,
+ });
+ const changeUrl = `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}/${input.event.target.cleanId}/history/${input.event.schema.id}`;
+ const viewLink = createMDLink({
+ text: 'view details',
+ url: changeUrl,
+ });
+
+ const message = input.event.initial
+ ? `π Hi, I received your *first* schema in project ${projectLink}, target ${targetLink} (${viewLink}):`
+ : `π Hi, I found *${totalChanges} ${this.pluralize(
+ 'change',
+ totalChanges,
+ )}* in project ${projectLink}, target ${targetLink} (${viewLink}):`;
+
+ const attachmentsText = input.event.initial
+ ? ''
+ : createAttachmentsText(input.event.changes, input.event.messages);
+
+ await this.sendTeamsMessage(
+ webhookUrl,
+ `${message}\n\n${attachmentsText}`,
+ createMDLink({
+ text: 'view full report',
+ url: changeUrl,
+ }),
+ );
+ } catch (error) {
+ this.logger.error(`Failed to send Microsoft Teams notification`, error);
+ }
+ }
+
+ async sendChannelConfirmation(input: ChannelConfirmationInput) {
+ this.logger.debug(
+ `Sending Channel Confirmation over Microsoft Teams (organization=%s, project=%s, channel=%s)`,
+ input.event.organization.id,
+ input.event.project.id,
+ );
+
+ const webhookUrl = input.channel.webhookEndpoint;
+
+ if (!webhookUrl) {
+ this.logger.debug(`Microsoft Teams Integration is not available`);
+ return;
+ }
+
+ const actionMessage =
+ input.event.kind === 'created'
+ ? `I will send here notifications`
+ : `I will no longer send here notifications`;
+
+ try {
+ const projectLink = createMDLink({
+ text: input.event.project.name,
+ url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}`,
+ });
+
+ const message = [
+ `π Hi! I'm the notification π.`,
+ `${actionMessage} about your ${projectLink} project.`,
+ ].join('\n');
+
+ await this.sendTeamsMessage(webhookUrl, message);
+ } catch (error) {
+ this.logger.error(`Failed to send Microsoft Teams notification`, error);
+ }
+ }
+
+ private pluralize(word: string, num: number): string {
+ return word + (num > 1 ? 's' : '');
+ }
+
+ /**
+ * message gets truncated to max 27k characters-max payload size for Microsoft Teams is 28 KB
+ */
+ async sendTeamsMessage(webhookUrl: string, message: string, fullReportMdLink?: string) {
+ if (message.length > 27000) {
+ message = message.slice(0, 27000) + `\n\n... message truncated. ${fullReportMdLink ?? ''}`;
+ }
+
+ const payload = {
+ '@type': 'MessageCard',
+ '@context': 'http://schema.org/extensions',
+ summary: 'Notification',
+ themeColor: '0076D7',
+ sections: [
+ {
+ activityTitle: 'Notification',
+ text: message,
+ markdown: true,
+ },
+ ],
+ };
+
+ const response = await fetch(webhookUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to send Microsoft Teams message: ${response.statusText}`);
+ }
+ }
+}
+
+function createAttachmentsText(
+ changes: readonly SchemaChangeType[],
+ messages: readonly string[],
+): string {
+ const breakingChanges = changes.filter(
+ change => change.criticality === CriticalityLevel.Breaking,
+ );
+ const safeChanges = changes.filter(change => change.criticality !== CriticalityLevel.Breaking);
+
+ let text = '';
+
+ if (breakingChanges.length) {
+ text += renderChangeList({
+ color: '#E74C3B',
+ title: 'Breaking changes',
+ changes: breakingChanges,
+ });
+ }
+
+ if (safeChanges.length) {
+ text += renderChangeList({
+ color: '#23B99A',
+ title: 'Safe changes',
+ changes: safeChanges,
+ });
+ }
+
+ if (messages.length) {
+ text += `### Other changes\n${messages.map(message => slackCoderize(message)).join('\n')}\n`;
+ }
+
+ return text;
+}
+
+function renderChangeList({
+ changes,
+ title,
+}: {
+ color: string;
+ 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}\n`;
+}
diff --git a/packages/services/api/src/modules/alerts/providers/adapters/slack.ts b/packages/services/api/src/modules/alerts/providers/adapters/slack.ts
index bba0576eb..a48987d5e 100644
--- a/packages/services/api/src/modules/alerts/providers/adapters/slack.ts
+++ b/packages/services/api/src/modules/alerts/providers/adapters/slack.ts
@@ -7,6 +7,7 @@ import { WEB_APP_URL } from '../../../shared/providers/tokens';
import {
ChannelConfirmationInput,
CommunicationAdapter,
+ createMDLink,
SchemaChangeNotificationInput,
slackCoderize,
} from './common';
@@ -43,15 +44,15 @@ export class SlackCommunicationAdapter implements CommunicationAdapter {
const client = new WebClient(input.integrations.slack.token, {});
const totalChanges = input.event.changes.length + input.event.messages.length;
- const projectLink = this.createLink({
+ const projectLink = createMDLink({
text: input.event.project.name,
url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}`,
});
- const targetLink = this.createLink({
+ const targetLink = createMDLink({
text: input.event.target.name,
url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}/${input.event.target.cleanId}`,
});
- const viewLink = this.createLink({
+ const viewLink = createMDLink({
text: 'view details',
url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}/${input.event.target.cleanId}/history/${input.event.schema.id}`,
});
@@ -82,6 +83,9 @@ export class SlackCommunicationAdapter implements CommunicationAdapter {
}
}
+ /**
+ * triggered when a channel is created or deleted
+ */
async sendChannelConfirmation(input: ChannelConfirmationInput) {
this.logger.debug(
`Sending Channel Confirmation over Slack (organization=%s, project=%s, channel=%s)`,
@@ -90,7 +94,7 @@ export class SlackCommunicationAdapter implements CommunicationAdapter {
input.channel.slackChannel,
);
- const token = input.integrations.slack.token;
+ const token = input.integrations?.slack.token;
if (!token) {
this.logger.debug(`Slack Integration is not available`);
@@ -103,7 +107,7 @@ export class SlackCommunicationAdapter implements CommunicationAdapter {
: `I will no longer send here notifications`;
try {
- const projectLink = this.createLink({
+ const projectLink = createMDLink({
text: input.event.project.name,
url: `${this.appBaseUrl}/${input.event.organization.cleanId}/${input.event.project.cleanId}`,
});
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 e8db14d4f..293463642 100644
--- a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts
+++ b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts
@@ -12,7 +12,8 @@ import { ProjectManager } from '../../project/providers/project-manager';
import { Logger } from '../../shared/providers/logger';
import type { ProjectSelector } from '../../shared/providers/storage';
import { Storage } from '../../shared/providers/storage';
-import { SchemaChangeNotificationInput } from './adapters/common';
+import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './adapters/common';
+import { TeamsCommunicationAdapter } from './adapters/msteams';
import { SlackCommunicationAdapter } from './adapters/slack';
import { WebhookCommunicationAdapter } from './adapters/webhook';
@@ -29,6 +30,7 @@ export class AlertsManager {
private slackIntegrationManager: SlackIntegrationManager,
private slack: SlackCommunicationAdapter,
private webhook: WebhookCommunicationAdapter,
+ private teamsWebhook: TeamsCommunicationAdapter,
private organizationManager: OrganizationManager,
private projectManager: ProjectManager,
private storage: Storage,
@@ -185,6 +187,7 @@ export class AlertsManager {
channel: channels.find(channel => channel.id === alert.channelId)!,
};
});
+ console.log('pairs:', pairs);
const slackToken = await this.slackIntegrationManager.getToken({
organization: event.organization.id,
@@ -195,8 +198,9 @@ export class AlertsManager {
const integrations: SchemaChangeNotificationInput['integrations'] = {
slack: {
- token: slackToken!,
+ token: slackToken,
},
+ // ms Teams is integrated via webhook. Webhook contains the token in itself, so we don't have any other token for it
};
// Let's not leak any data :)
@@ -237,6 +241,14 @@ export class AlertsManager {
integrations,
});
}
+ if (channel.type === 'MSTEAMS_WEBHOOK') {
+ return this.teamsWebhook.sendSchemaChangeNotification({
+ event: safeEvent,
+ alert,
+ channel,
+ integrations,
+ });
+ }
return this.webhook.sendSchemaChangeNotification({
event: safeEvent,
@@ -265,34 +277,42 @@ export class AlertsManager {
}),
]);
+ const channelConfirmationContext: ChannelConfirmationInput = {
+ event: {
+ kind: input.kind,
+ organization: {
+ id: organization.id,
+ cleanId: organization.cleanId,
+ name: organization.name,
+ },
+ project: {
+ id: project.id,
+ cleanId: project.cleanId,
+ name: project.name,
+ },
+ },
+ channel,
+ integrations: {
+ slack: {
+ token: null,
+ },
+ },
+ };
+
if (channel.type === 'SLACK') {
const slackToken = await this.slackIntegrationManager.getToken({
organization: organization.id,
project: project.id,
context: IntegrationsAccessContext.ChannelConfirmation,
});
+ if (!slackToken) {
+ throw new Error(`Slack token was not found for channel "${channel.id}"`);
+ }
- await this.slack.sendChannelConfirmation({
- event: {
- kind: input.kind,
- organization: {
- id: organization.id,
- cleanId: organization.cleanId,
- name: organization.name,
- },
- project: {
- id: project.id,
- cleanId: project.cleanId,
- name: project.name,
- },
- },
- channel,
- integrations: {
- slack: {
- token: slackToken!,
- },
- },
- });
+ channelConfirmationContext.integrations.slack.token = slackToken;
+ await this.slack.sendChannelConfirmation(channelConfirmationContext);
+ } else if (channel.type === 'MSTEAMS_WEBHOOK') {
+ await this.teamsWebhook.sendChannelConfirmation(channelConfirmationContext);
} else {
await this.webhook.sendChannelConfirmation();
}
diff --git a/packages/services/api/src/modules/alerts/resolvers.ts b/packages/services/api/src/modules/alerts/resolvers.ts
index 0def9690d..91e99392a 100644
--- a/packages/services/api/src/modules/alerts/resolvers.ts
+++ b/packages/services/api/src/modules/alerts/resolvers.ts
@@ -217,4 +217,12 @@ export const resolvers: AlertsModule.Resolvers = {
return channel.webhookEndpoint!;
},
},
+ TeamsWebhookChannel: {
+ __isTypeOf(channel) {
+ return channel.type === 'MSTEAMS_WEBHOOK';
+ },
+ endpoint(channel) {
+ return channel.webhookEndpoint!;
+ },
+ },
};
diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts
index 932160950..51675daf2 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 = 'SLACK' | 'WEBHOOK';
+export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK';
export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS';
export type schema_policy_resource = 'ORGANIZATION' | 'PROJECT';
export type user_role = 'ADMIN' | 'MEMBER';
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 3853695cf..f1a040575 100644
--- a/packages/web/app/src/components/project/alerts/channels-table.tsx
+++ b/packages/web/app/src/components/project/alerts/channels-table.tsx
@@ -1,6 +1,6 @@
import { Checkbox, Table, Tag, TBody, Td, Tr } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
-import { AlertChannelType } from '@/gql/graphql';
+import { AlertChannelType, ChannelsTable_AlertChannelFragmentFragment } from '@/gql/graphql';
export const ChannelsTable_AlertChannelFragment = graphql(`
fragment ChannelsTable_AlertChannelFragment on AlertChannel {
@@ -13,9 +13,18 @@ export const ChannelsTable_AlertChannelFragment = graphql(`
... on AlertWebhookChannel {
endpoint
}
+ ... on TeamsWebhookChannel {
+ endpoint
+ }
}
`);
+const colorMap = {
+ [AlertChannelType.Slack]: 'green' as const,
+ [AlertChannelType.Webhook]: 'yellow' as const,
+ [AlertChannelType.MsteamsWebhook]: 'orange' as const,
+};
+
export function ChannelsTable(props: {
channels: FragmentType[];
isChecked: (channelId: string) => boolean;
@@ -23,6 +32,20 @@ export function ChannelsTable(props: {
}) {
const channels = useFragment(ChannelsTable_AlertChannelFragment, props.channels);
+ const renderChannelEndpoint = (channel: ChannelsTable_AlertChannelFragmentFragment) => {
+ if (channel.__typename === 'AlertSlackChannel') {
+ return channel.channel;
+ }
+ if (
+ channel.__typename === 'AlertWebhookChannel' ||
+ channel.__typename === 'TeamsWebhookChannel'
+ ) {
+ return channel.endpoint;
+ }
+
+ return '';
+ };
+
return (
@@ -36,18 +59,12 @@ export function ChannelsTable(props: {
checked={props.isChecked(channel.id)}
/>
- | {channel.name} |
-
- {channel.__typename === 'AlertSlackChannel'
- ? channel.channel
- : channel.__typename === 'AlertWebhookChannel'
- ? channel.endpoint
- : ''}
+ | {channel.name} |
+
+ {renderChannelEndpoint(channel)}
|
-
-
- {channel.type}
-
+ |
+ {channel.type}
|
))}
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 80b024e1d..2d8609eb0 100644
--- a/packages/web/app/src/components/project/alerts/create-channel.tsx
+++ b/packages/web/app/src/components/project/alerts/create-channel.tsx
@@ -61,8 +61,8 @@ export const CreateChannelModal = ({
),
endpoint: Yup.string()
.url()
- .when('type', ([type], schema) =>
- type === AlertChannelType.Webhook ? schema.required('Must enter endpoint') : schema,
+ .when('type', ([_type], schema) =>
+ isWebhookLike ? schema.required('Must enter endpoint') : schema,
),
}),
async onSubmit(values) {
@@ -73,8 +73,7 @@ export const CreateChannelModal = ({
name: values.name,
type: values.type,
slack: values.type === AlertChannelType.Slack ? { channel: values.slackChannel } : null,
- webhook:
- values.type === AlertChannelType.Webhook ? { endpoint: values.endpoint } : null,
+ webhook: isWebhookLike ? { endpoint: values.endpoint } : null,
},
});
if (error) {
@@ -88,6 +87,9 @@ export const CreateChannelModal = ({
}
},
});
+ const isWebhookLike = [AlertChannelType.Webhook, AlertChannelType.MsteamsWebhook].includes(
+ values.type,
+ );
return (
@@ -132,12 +134,13 @@ export const CreateChannelModal = ({
options={[
{ value: AlertChannelType.Slack, name: 'Slack' },
{ value: AlertChannelType.Webhook, name: 'Webhook' },
+ { value: AlertChannelType.MsteamsWebhook, name: 'MS Teams Webhook' },
]}
/>
{touched.type && errors.type && {errors.type}
}
- {values.type === AlertChannelType.Webhook && (
+ {isWebhookLike && (
)}
- Hive will send alerts to your endpoint.
+ {values.endpoint ? (
+ Hive will send alerts to your endpoint.
+ ) : (
+
+ Follow this guide to set up an incoming webhook connector in MS Teams
+
+ )}
)}
@@ -205,7 +214,7 @@ export const CreateChannelModal = ({