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:
@@ -275,6 +275,33 @@ export class SaveFilterInput {
|
||||
criteria!: SavedFilterCriteriaInput;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateWebhookInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string;
|
||||
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events!: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateWebhookInput {
|
||||
@Field(() => ID) @IsUUID() webhookId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
url?: string;
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events?: string[];
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class RankIssueInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
|
||||
@@ -170,6 +170,42 @@ export class GitIntegrationGql {
|
||||
webhookPath!: string;
|
||||
}
|
||||
|
||||
@ObjectType('WebhookDelivery')
|
||||
export class WebhookDeliveryGql {
|
||||
@Field() at!: string;
|
||||
@Field() event!: string;
|
||||
@Field(() => Int, { nullable: true, description: 'HTTP status' })
|
||||
status!: number | null;
|
||||
@Field() ok!: boolean;
|
||||
@Field(() => String, { nullable: true }) error!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('Webhook', {
|
||||
description:
|
||||
'Where the project tells another system what happened (signed POST, HMAC-SHA256)',
|
||||
})
|
||||
export class WebhookGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field() url!: string;
|
||||
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||
events!: string[];
|
||||
@Field() active!: boolean;
|
||||
@Field(() => UserRefGql) createdBy!: UserRefGql;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => WebhookDeliveryGql, { nullable: true })
|
||||
lastDelivery!: WebhookDeliveryGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('WebhookCreated')
|
||||
export class WebhookCreatedGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field({
|
||||
description: 'Signs every delivery. Shown only now: keep it.',
|
||||
})
|
||||
secret!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||
export class IssueParentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { WebhooksService } from '@task-stream/api/tracker/application';
|
||||
import { CreateWebhookInput, UpdateWebhookInput } from './inputs';
|
||||
import { WebhookCreatedGql, WebhookDeliveryGql, WebhookGql } from './types';
|
||||
|
||||
/** Outgoing webhooks of a project (owners and admins). */
|
||||
@Resolver(() => WebhookGql)
|
||||
export class WebhookResolver {
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Query(() => [WebhookGql])
|
||||
webhooks(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.webhooksService.list(me.id, projectId);
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'Event names a webhook can subscribe to (also family.* and *)',
|
||||
})
|
||||
webhookEvents() {
|
||||
return this.webhooksService.events();
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookCreatedGql)
|
||||
createWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateWebhookInput,
|
||||
) {
|
||||
return this.webhooksService.create(
|
||||
me.id,
|
||||
input.projectId,
|
||||
input.url,
|
||||
input.events,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookGql)
|
||||
updateWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateWebhookInput,
|
||||
) {
|
||||
const { webhookId, url, events, active } = input;
|
||||
return this.webhooksService.update(me.id, webhookId, {
|
||||
...(url != null && { url }),
|
||||
...(events != null && { events }),
|
||||
...(active != null && { active }),
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||
) {
|
||||
await this.webhooksService.delete(me.id, webhookId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookDeliveryGql, {
|
||||
description: 'Send a ping now and tell how it went',
|
||||
})
|
||||
testWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||
) {
|
||||
return this.webhooksService.test(me.id, webhookId);
|
||||
}
|
||||
}
|
||||
@@ -731,6 +731,29 @@ export class SaveFilterDto {
|
||||
criteria!: SavedFilterCriteriaDto;
|
||||
}
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty({ example: 'https://chat.example.org/hooks/taskstream' })
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['issue.status_changed', 'release.*'],
|
||||
description: 'Event names (GET /api/webhook-events), family.* or *',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events!: string[];
|
||||
}
|
||||
|
||||
export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
||||
@ApiPropertyOptional({ description: 'Paused webhooks get nothing' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export class RankIssueDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
|
||||
@@ -167,6 +167,33 @@ export class GitIntegrationDto {
|
||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
||||
}
|
||||
|
||||
export class WebhookDeliveryDto {
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
@ApiProperty({ example: 'issue.created' }) event!: string;
|
||||
@ApiProperty({ type: Number, nullable: true, description: 'HTTP status' })
|
||||
status!: number | null;
|
||||
@ApiProperty() ok!: boolean;
|
||||
@ApiProperty({ type: String, nullable: true }) error!: string | null;
|
||||
}
|
||||
|
||||
export class WebhookDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty() url!: string;
|
||||
@ApiProperty({ type: [String] }) events!: string[];
|
||||
@ApiProperty() active!: boolean;
|
||||
@ApiProperty({ type: UserRefDto }) createdBy!: UserRefDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: WebhookDeliveryDto, nullable: true })
|
||||
lastDelivery!: WebhookDeliveryDto | null;
|
||||
}
|
||||
|
||||
export class WebhookCreatedDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ description: 'Signs every delivery. Shown only now.' })
|
||||
secret!: string;
|
||||
}
|
||||
|
||||
export class GitDeliveryResultDto {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
|
||||
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import { WebhooksService } from '@task-stream/api/tracker/application';
|
||||
import { CreateWebhookDto, UpdateWebhookDto } from './dto/requests.dto';
|
||||
import {
|
||||
WebhookCreatedDto,
|
||||
WebhookDeliveryDto,
|
||||
WebhookDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const WEBHOOK = ApiParam({ name: 'webhook', format: 'uuid' });
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: a signed POST to another system when something happens
|
||||
* in the project. Header `X-TaskStream-Signature: sha256=<HMAC of the body>`.
|
||||
*/
|
||||
@ApiTags('Webhooks')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class WebhooksController {
|
||||
constructor(
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('webhook-events')
|
||||
@ApiOperation({ summary: 'Event names a webhook can subscribe to' })
|
||||
@ApiOkResponse({ type: [String] })
|
||||
events() {
|
||||
return this.webhooks.events();
|
||||
}
|
||||
|
||||
@Get('projects/:project/webhooks')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({ summary: "The project's webhooks (owners and admins)" })
|
||||
@ApiOkResponse({ type: [WebhookDto] })
|
||||
@ApiReadErrors()
|
||||
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.webhooks.list(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Post('projects/:project/webhooks')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: 'Add a webhook',
|
||||
description: 'The answer carries the secret, the only time it is shown.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: WebhookCreatedDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateWebhookDto,
|
||||
) {
|
||||
return this.webhooks.create(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
body.url,
|
||||
body.events,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('outgoing-webhooks/:webhook')
|
||||
@WEBHOOK
|
||||
@ApiOperation({ summary: 'Change the URL or the events, pause or resume' })
|
||||
@ApiOkResponse({ type: WebhookDto })
|
||||
@ApiCommandErrors()
|
||||
update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
@Body() body: UpdateWebhookDto,
|
||||
) {
|
||||
return this.webhooks.update(me.id, webhook, body);
|
||||
}
|
||||
|
||||
@Delete('outgoing-webhooks/:webhook')
|
||||
@WEBHOOK
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a webhook' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
) {
|
||||
await this.webhooks.delete(me.id, webhook);
|
||||
}
|
||||
|
||||
@Post('outgoing-webhooks/:webhook/test')
|
||||
@WEBHOOK
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Send a ping now and tell how it went' })
|
||||
@ApiOkResponse({ type: WebhookDeliveryDto })
|
||||
@ApiCommandErrors()
|
||||
test(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
) {
|
||||
return this.webhooks.test(me.id, webhook);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||
import { WebhooksController } from './http/webhooks.controller';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -54,6 +56,7 @@ import { GitResolver } from './graphql/git.resolver';
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
GitController,
|
||||
WebhooksController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -66,6 +69,7 @@ import { GitResolver } from './graphql/git.resolver';
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
WebhookResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
Reference in New Issue
Block a user