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:
@@ -0,0 +1,3 @@
|
||||
/** Where monitoring tools send alerts: the token in the URL is the secret. */
|
||||
export const alertWebhookPath = (projectId: string, token: string) =>
|
||||
`/api/webhooks/alerts/${projectId}/${token}`;
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {
|
||||
AlertIntegration,
|
||||
AlertsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { alertWebhookPath } from '../common/alert-webhook';
|
||||
import { AlertSettingsInput } from './inputs';
|
||||
import { AlertIntegrationGql } from './types';
|
||||
|
||||
/** Alerts → bugs: the project's secret URL (owners and admins configure it). */
|
||||
@Resolver(() => AlertIntegrationGql)
|
||||
export class AlertResolver {
|
||||
constructor(private readonly alerts: AlertsService) {}
|
||||
|
||||
@Query(() => AlertIntegrationGql)
|
||||
async alertIntegration(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return withPath(projectId, await this.alerts.get(me.id, projectId));
|
||||
}
|
||||
|
||||
@Mutation(() => AlertIntegrationGql, {
|
||||
description:
|
||||
'Turn alerts → bugs on, or change the type and priority it opens (the URL stays)',
|
||||
})
|
||||
async saveAlertIntegration(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('input') input: AlertSettingsInput,
|
||||
) {
|
||||
return withPath(
|
||||
projectId,
|
||||
await this.alerts.save(me.id, projectId, {
|
||||
typeId: input.typeId,
|
||||
priority: input.priority,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => AlertIntegrationGql, {
|
||||
description: 'A new secret URL; the old one stops working at once',
|
||||
})
|
||||
async rotateAlertToken(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return withPath(projectId, await this.alerts.rotateToken(me.id, projectId));
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Turn it off; the bugs it opened stay',
|
||||
})
|
||||
async disableAlertIntegration(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
await this.alerts.disable(me.id, projectId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function withPath(projectId: string, integration: AlertIntegration) {
|
||||
const { token, ...rest } = integration;
|
||||
return {
|
||||
...rest,
|
||||
webhookPath: token ? alertWebhookPath(projectId, token) : null,
|
||||
};
|
||||
}
|
||||
@@ -880,3 +880,19 @@ export class ShareDashboardInput {
|
||||
@IsBoolean()
|
||||
renew?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AlertSettingsInput {
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: "Ticket type to open; null: the project's first bug type",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeId?: string | null;
|
||||
|
||||
@Field(() => IssuePriority, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
priority?: IssuePriority;
|
||||
}
|
||||
|
||||
@@ -1328,3 +1328,23 @@ export class SharedDashboardGql {
|
||||
@Field(() => [ActivityDayGql], { nullable: true })
|
||||
activity!: ActivityDayGql[] | null;
|
||||
}
|
||||
|
||||
@ObjectType('AlertIntegration', {
|
||||
description:
|
||||
'Alerts → bugs: the secret URL monitoring tools call to open bugs',
|
||||
})
|
||||
export class AlertIntegrationGql {
|
||||
@Field() enabled!: boolean;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Path of the URL on this API, secret token included; only owners and admins see it',
|
||||
})
|
||||
webhookPath!: string | null;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: "Ticket type it opens; null: the project's first bug type",
|
||||
})
|
||||
typeId!: string | null;
|
||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||
}
|
||||
|
||||
166
libs/api/tracker/presenters/src/lib/http/alerts.controller.ts
Normal file
166
libs/api/tracker/presenters/src/lib/http/alerts.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
import { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { AlertsController } from './http/alerts.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
import { AlertResolver } from './graphql/alert.resolver';
|
||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||
import { SharingResolver } from './graphql/sharing.resolver';
|
||||
import { WebhooksController } from './http/webhooks.controller';
|
||||
@@ -66,6 +68,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
GitController,
|
||||
AlertsController,
|
||||
WebhooksController,
|
||||
SharingController,
|
||||
TransferController,
|
||||
@@ -86,6 +89,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
AlertResolver,
|
||||
WebhookResolver,
|
||||
SharingResolver,
|
||||
AutomationResolver,
|
||||
|
||||
Reference in New Issue
Block a user