feat: outgoing webhooks

Owners and admins can point a project at other systems: a signed JSON
POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints
change, filtered by event or family. Deliveries are retried a couple of
times, the last one is shown, and a ping can be sent from the settings.
Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:36:19 +02:00
parent 046d77c813
commit d43fc620e9
37 changed files with 3660 additions and 28 deletions

View File

@@ -0,0 +1,64 @@
import { inject, Injectable } from '@angular/core';
import {
Webhook,
WebhookDelivery,
WebhooksGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CreateWebhookDocument,
DeleteWebhookDocument,
TestWebhookDocument,
UpdateWebhookDocument,
WebhookEventsDocument,
WebhooksDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlWebhooksGateway extends WebhooksGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Webhook[]> {
return this.gql
.query(WebhooksDocument, { projectId })
.pipe(map((d) => d.webhooks as Webhook[]));
}
events(): Observable<string[]> {
return this.gql
.query(WebhookEventsDocument, {})
.pipe(map((d) => d.webhookEvents));
}
create(
projectId: string,
url: string,
events: string[],
): Observable<{ id: string; secret: string }> {
return this.gql
.mutate(CreateWebhookDocument, { input: { projectId, url, events } })
.pipe(map((d) => d.createWebhook));
}
update(
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Observable<Webhook> {
return this.gql
.mutate(UpdateWebhookDocument, { input: { webhookId, ...changes } })
.pipe(map((d) => d.updateWebhook as Webhook));
}
remove(webhookId: string): Observable<void> {
return this.gql
.mutate(DeleteWebhookDocument, { webhookId })
.pipe(map(() => undefined));
}
test(webhookId: string): Observable<WebhookDelivery> {
return this.gql
.mutate(TestWebhookDocument, { webhookId })
.pipe(map((d) => d.testWebhook));
}
}

View File

@@ -75,6 +75,13 @@ export type CreateSprintInput = {
startDate: string;
};
export type CreateWebhookInput = {
/** Event names, family.* or * */
events: Array<string>;
projectId: string;
url: string;
};
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
@@ -240,6 +247,13 @@ export type UpdateSprintInput = {
startDate?: string | null | undefined;
};
export type UpdateWebhookInput = {
active?: boolean | null | undefined;
events?: Array<string> | null | undefined;
url?: string | null | undefined;
webhookId: string;
};
export type WorkflowStatusInput = {
archived?: boolean;
category: StatusCategory;
@@ -1656,6 +1670,106 @@ export type MyProjectsChangedSubscription = {
};
};
export type WebhookFieldsFragment = {
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
};
export type WebhookDeliveryFieldsFragment = {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
};
export type WebhooksQueryVariables = Exact<{
projectId: string;
}>;
export type WebhooksQuery = {
webhooks: Array<{
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
}>;
};
export type WebhookEventsQueryVariables = Exact<{ [key: string]: never }>;
export type WebhookEventsQuery = { webhookEvents: Array<string> };
export type CreateWebhookMutationVariables = Exact<{
input: CreateWebhookInput;
}>;
export type CreateWebhookMutation = {
createWebhook: { id: string; secret: string };
};
export type UpdateWebhookMutationVariables = Exact<{
input: UpdateWebhookInput;
}>;
export type UpdateWebhookMutation = {
updateWebhook: {
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
};
};
export type DeleteWebhookMutationVariables = Exact<{
webhookId: string;
}>;
export type DeleteWebhookMutation = { deleteWebhook: boolean };
export type TestWebhookMutationVariables = Exact<{
webhookId: string;
}>;
export type TestWebhookMutation = {
testWebhook: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
};
};
export const UserRefFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -2484,6 +2598,111 @@ export const SprintFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<SprintFieldsFragment, unknown>;
export const WebhookDeliveryFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<WebhookDeliveryFieldsFragment, unknown>;
export const WebhookFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
export const GitIntegrationDocument = {
kind: 'Document',
definitions: [
@@ -9995,3 +10214,450 @@ export const MyProjectsChangedDocument = {
MyProjectsChangedSubscription,
MyProjectsChangedSubscriptionVariables
>;
export const WebhooksDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Webhooks' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'webhooks' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<WebhooksQuery, WebhooksQueryVariables>;
export const WebhookEventsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'WebhookEvents' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'webhookEvents' } },
],
},
},
],
} as unknown as DocumentNode<WebhookEventsQuery, WebhookEventsQueryVariables>;
export const CreateWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CreateWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CreateWebhookInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'createWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CreateWebhookMutation,
CreateWebhookMutationVariables
>;
export const UpdateWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UpdateWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UpdateWebhookInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'updateWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
UpdateWebhookMutation,
UpdateWebhookMutationVariables
>;
export const DeleteWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'webhookId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteWebhookMutation,
DeleteWebhookMutationVariables
>;
export const TestWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'TestWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'testWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'webhookId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<TestWebhookMutation, TestWebhookMutationVariables>;

View File

@@ -0,0 +1,54 @@
fragment WebhookFields on Webhook {
id
url
events
active
createdAt
createdBy {
...UserRefFields
}
lastDelivery {
...WebhookDeliveryFields
}
}
fragment WebhookDeliveryFields on WebhookDelivery {
at
event
status
ok
error
}
query Webhooks($projectId: ID!) {
webhooks(projectId: $projectId) {
...WebhookFields
}
}
query WebhookEvents {
webhookEvents
}
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
secret
}
}
mutation UpdateWebhook($input: UpdateWebhookInput!) {
updateWebhook(input: $input) {
...WebhookFields
}
}
mutation DeleteWebhook($webhookId: ID!) {
deleteWebhook(webhookId: $webhookId)
}
mutation TestWebhook($webhookId: ID!) {
testWebhook(webhookId: $webhookId) {
...WebhookDeliveryFields
}
}

View File

@@ -13,6 +13,7 @@ import {
SavedFiltersGateway,
SprintsGateway,
GitGateway,
WebhooksGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -30,6 +31,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -46,5 +48,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
{ provide: GitGateway, useClass: GraphqlGitGateway },
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
]);
}