feat: alerts open bugs, and repeats of the same problem go to that bug

Owners and admins turn on a secret URL per project (Settings →
Integrations), POST /api/webhooks/alerts/{project}/{token}, that Sentry,
Uptime Kuma, Prometheus Alertmanager, Grafana or any tool sending JSON
with a title can call. A new problem opens a bug as TaskStream (label
«alerta», chosen type and priority); while that bug is open, the same
fingerprint goes to it (a comment at most every 30 minutes) and
«resolved» is said on it; once it is closed, a new bug names the old
one. Alerts of a project are handled one at a time, so two at once open
one bug, and a bug not yet projected still counts as open. Unknown
project, alerts off or a bad token all answer 403.

TaskStream may now comment as the system actor, like it already opened
tickets for public proposals. Stored in Mongo (alert_integrations,
alert_traces), not in the event store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 12:36:55 +02:00
parent 42a21412d9
commit 034704e46f
34 changed files with 3248 additions and 6 deletions

View File

@@ -0,0 +1,166 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Post,
Put,
} from '@nestjs/common';
import {
ApiBody,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import {
AlertIntegration,
AlertsService,
} from '@task-stream/api/tracker/application';
import { alertWebhookPath } from '../common/alert-webhook';
import { AlertSettingsDto } from './dto/requests.dto';
import { AlertIntegrationDto, AlertOutcomeDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* Alerts → bugs: Sentry, Uptime Kuma, Grafana or anything that can call a
* URL opens bugs in the project; repeats of the same problem go to the bug
* it opened while that one is open.
*/
@ApiTags('Alerts')
@Controller()
export class AlertsController {
constructor(
private readonly alerts: AlertsService,
private readonly refs: Refs,
) {}
@Get('projects/:project/alert-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Whether alerts open bugs, and the secret URL (for admins)',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiReadErrors()
async integration(
@CurrentUser() me: User,
@Param('project') project: string,
) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(projectId, await this.alerts.get(me.id, projectId));
}
@Put('projects/:project/alert-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn alerts → bugs on, or change what it opens (owners, admins)',
description: 'The URL stays the same; rotate it with POST …/token.',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiCommandErrors()
async save(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: AlertSettingsDto,
) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(
projectId,
await this.alerts.save(me.id, projectId, {
typeId: body.typeId,
priority: body.priority,
}),
);
}
@Post('projects/:project/alert-integration/token')
@HttpCode(200)
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'A new secret URL; the old one stops working at once',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiCommandErrors()
async rotate(@CurrentUser() me: User, @Param('project') project: string) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(projectId, await this.alerts.rotateToken(me.id, projectId));
}
@Delete('projects/:project/alert-integration')
@HttpCode(204)
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Turn it off; the bugs it opened stay' })
@ApiNoContentResponse()
@ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) {
await this.alerts.disable(me.id, await this.refs.projectId(me.id, project));
}
@Public()
@Post('webhooks/alerts/:project/:token')
@HttpCode(200)
@PROJECT
@ApiParam({ name: 'token', description: 'The secret of the URL' })
@ApiOperation({
summary: 'Receive an alert (called by the monitoring tool)',
description:
'Understands Sentry (internal integration or webhooks plugin), Uptime Kuma, Prometheus Alertmanager and Grafana; anything else sends JSON with `title`, and optionally `fingerprint`, `description`, `url` and `resolved`. A new problem opens a bug as TaskStream; the same one again goes to that bug while it is open (a comment at most every 30 minutes); «resolved» is said on it.',
})
@ApiBody({
required: false,
schema: {
type: 'object',
example: {
title: 'La cola de correos está atascada',
fingerprint: 'mail-queue',
description: '1.200 mensajes esperando',
url: 'https://grafana.example.com/d/mail',
},
},
})
@ApiOkResponse({ type: AlertOutcomeDto })
@ApiForbiddenResponse({
description: 'Unknown project, alerts off or bad token',
})
receive(
@Param('project') project: string,
@Param('token') token: string,
@Body() body: unknown,
@Headers() headers: Record<string, string | undefined>,
) {
return this.alerts.receive({ project, token, body, headers });
}
}
function withPath(projectId: string, integration: AlertIntegration) {
const { token, ...rest } = integration;
return {
...rest,
webhookPath: token ? alertWebhookPath(projectId, token) : null,
};
}

View File

@@ -1538,6 +1538,24 @@ export class PushEndpointDto {
endpoint!: string;
}
// ---- Alerts → bugs ----------------------------------------------------------
export class AlertSettingsDto {
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Ticket type to open; null: the project's first bug type",
})
@IsOptional()
@IsString()
typeId?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
}
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {

View File

@@ -1366,3 +1366,36 @@ export class SharedDashboardDto {
@ApiProperty({ type: [ActivityDayDto], nullable: true })
activity!: ActivityDayDto[] | null;
}
// ---- Alerts → bugs ----------------------------------------------------------
export class AlertIntegrationDto {
@ApiProperty() enabled!: boolean;
@ApiProperty({
type: String,
nullable: true,
description:
'Path of the URL on this API, secret token included; only owners and admins see it',
example: '/api/webhooks/alerts/0b4c…/9f2e…',
})
webhookPath!: string | null;
@ApiProperty({
type: String,
nullable: true,
description: "Ticket type it opens; null: the project's first bug type",
})
typeId!: string | null;
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
priority!: IssuePriority;
}
export class AlertOutcomeDto {
@ApiProperty({
enum: ['created', 'repeated', 'resolved', 'ignored'],
description:
'created: a new bug; repeated: it went to the open bug; resolved: said on it; ignored: not an alert, or over with nothing open',
})
action!: string;
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
issueKey!: string | null;
}