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

@@ -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,

View File

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

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