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:
@@ -36,6 +36,10 @@ export * from './lib/ports/my-views.repository';
|
||||
export * from './lib/saved-filters/pins';
|
||||
export * from './lib/ports/pins.repository';
|
||||
export * from './lib/pins.service';
|
||||
export * from './lib/alerts/alert-payloads';
|
||||
export * from './lib/alerts/alerts';
|
||||
export * from './lib/ports/alert-integrations.repository';
|
||||
export * from './lib/alerts.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
86
libs/api/tracker/application/src/lib/alerts.service.ts
Normal file
86
libs/api/tracker/application/src/lib/alerts.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { alertOf } from './alerts/alert-payloads';
|
||||
import {
|
||||
AlertIntegration,
|
||||
AlertOutcome,
|
||||
AlertSettings,
|
||||
DisableAlertIntegrationCommand,
|
||||
GetAlertIntegrationQuery,
|
||||
ReceiveAlertCommand,
|
||||
RotateAlertTokenCommand,
|
||||
SaveAlertIntegrationCommand,
|
||||
} from './alerts/alerts';
|
||||
import { AlertIntegrationsRepository } from './ports/alert-integrations.repository';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
|
||||
/** What a monitoring tool sent to a project's alert URL. */
|
||||
export interface AlertDelivery {
|
||||
/** Project id or key, from the URL. */
|
||||
project: string;
|
||||
token: string;
|
||||
body: unknown;
|
||||
headers: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/** Alerts → bugs: the project's secret URL, and what arrives through it. */
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
get(viewerId: string, projectId: string): Promise<AlertIntegration> {
|
||||
return this.queryBus.execute(
|
||||
new GetAlertIntegrationQuery(viewerId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
save(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
settings: AlertSettings,
|
||||
): Promise<AlertIntegration> {
|
||||
return this.commands.execute(
|
||||
new SaveAlertIntegrationCommand(actorId, projectId, settings),
|
||||
);
|
||||
}
|
||||
|
||||
rotateToken(actorId: string, projectId: string): Promise<AlertIntegration> {
|
||||
return this.commands.execute(
|
||||
new RotateAlertTokenCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
disable(actorId: string, projectId: string): Promise<void> {
|
||||
return this.commands.execute(
|
||||
new DisableAlertIntegrationCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Lets an alert in only with the project's current token. */
|
||||
async receive(delivery: AlertDelivery): Promise<AlertOutcome> {
|
||||
const project =
|
||||
(await this.findProjects.findById(delivery.project)) ??
|
||||
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
||||
const config = project ? await this.integrations.find(project.id) : null;
|
||||
// One answer for an unknown project, the integration off or a bad token.
|
||||
if (!project || !config || !sameToken(config.token, delivery.token))
|
||||
throw new ForbiddenError('Unknown project, alerts off or bad token');
|
||||
const alert = alertOf(delivery.body, delivery.headers);
|
||||
if (!alert) return { action: 'ignored', issueKey: null };
|
||||
return this.commands.execute(new ReceiveAlertCommand(project.id, alert));
|
||||
}
|
||||
}
|
||||
|
||||
function sameToken(expected: string, given: string): boolean {
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(given ?? '');
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { alertOf } from './alert-payloads';
|
||||
|
||||
describe('alertOf', () => {
|
||||
it('reads a Sentry issue (internal integration), resolved too', () => {
|
||||
const body = {
|
||||
action: 'created',
|
||||
data: {
|
||||
issue: {
|
||||
id: '42',
|
||||
title: 'TypeError: x is undefined',
|
||||
culprit: 'app/checkout.ts',
|
||||
permalink: 'https://sentry.io/organizations/acme/issues/42/',
|
||||
level: 'error',
|
||||
},
|
||||
},
|
||||
};
|
||||
const alert = alertOf(body, { 'sentry-hook-resource': 'issue' });
|
||||
expect(alert).toMatchObject({
|
||||
source: 'sentry',
|
||||
fingerprint: 'sentry:42',
|
||||
title: 'TypeError: x is undefined',
|
||||
url: 'https://sentry.io/organizations/acme/issues/42/',
|
||||
resolved: false,
|
||||
});
|
||||
expect(alert?.details).toContain('app/checkout.ts');
|
||||
expect(
|
||||
alertOf(
|
||||
{ ...body, action: 'resolved' },
|
||||
{ 'sentry-hook-resource': 'issue' },
|
||||
)?.resolved,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a Sentry issue alert and the legacy webhook', () => {
|
||||
expect(
|
||||
alertOf(
|
||||
{
|
||||
action: 'triggered',
|
||||
data: {
|
||||
event: { issue_id: '7', title: 'Boom', web_url: 'https://s.io/7' },
|
||||
triggered_rule: 'Errores nuevos',
|
||||
},
|
||||
},
|
||||
{ 'sentry-hook-resource': 'event_alert' },
|
||||
),
|
||||
).toMatchObject({ fingerprint: 'sentry:7', title: 'Boom' });
|
||||
expect(
|
||||
alertOf({
|
||||
id: '99',
|
||||
project_name: 'web',
|
||||
message: 'Boom',
|
||||
url: 'https://s.io/99',
|
||||
event: { title: 'ReferenceError: y' },
|
||||
}),
|
||||
).toMatchObject({ fingerprint: 'sentry:99', title: 'ReferenceError: y' });
|
||||
});
|
||||
|
||||
it('reads Uptime Kuma: down opens, up resolves', () => {
|
||||
const down = {
|
||||
heartbeat: { status: 0, msg: 'timeout', time: '2026-10-17 10:00' },
|
||||
monitor: { id: 3, name: 'API', url: 'https://api.acme.dev' },
|
||||
msg: '[API] [🔴 Down] timeout',
|
||||
};
|
||||
expect(alertOf(down)).toMatchObject({
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: 'uptime-kuma:3',
|
||||
title: 'API no responde',
|
||||
resolved: false,
|
||||
});
|
||||
expect(alertOf({ ...down, heartbeat: { status: 1 } })?.resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('reads Alertmanager and Grafana', () => {
|
||||
expect(
|
||||
alertOf({
|
||||
status: 'firing',
|
||||
groupKey: '{}:{alertname="HighLatency"}',
|
||||
alerts: [
|
||||
{
|
||||
labels: { alertname: 'HighLatency', service: 'api' },
|
||||
annotations: { summary: 'p95 por encima de 2 s' },
|
||||
generatorURL: 'https://grafana.acme.dev/alerting/1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
source: 'alertmanager',
|
||||
fingerprint: 'alertmanager:{}:{alertname="HighLatency"}',
|
||||
title: 'p95 por encima de 2 s',
|
||||
resolved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a plain title, and nothing without one', () => {
|
||||
expect(
|
||||
alertOf({ title: 'Cola atascada', fingerprint: 'queue', resolved: true }),
|
||||
).toMatchObject({
|
||||
source: 'generic',
|
||||
fingerprint: 'generic:queue',
|
||||
resolved: true,
|
||||
});
|
||||
expect(alertOf({ title: 'Cola atascada' })?.fingerprint).toBe(
|
||||
'generic:cola atascada',
|
||||
);
|
||||
expect(alertOf({ ping: true })).toBeNull();
|
||||
expect(alertOf('nonsense')).toBeNull();
|
||||
});
|
||||
|
||||
it('only links http(s) addresses', () => {
|
||||
expect(alertOf({ title: 'x', url: 'javascript:alert(1)' })?.url).toBeNull();
|
||||
});
|
||||
});
|
||||
199
libs/api/tracker/application/src/lib/alerts/alert-payloads.ts
Normal file
199
libs/api/tracker/application/src/lib/alerts/alert-payloads.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* What monitoring tools send when something breaks, read into one shape.
|
||||
* Each tool has its own payload; none of them is trusted beyond being text.
|
||||
*/
|
||||
|
||||
export type AlertSource = 'sentry' | 'uptime-kuma' | 'alertmanager' | 'generic';
|
||||
|
||||
export interface Alert {
|
||||
source: AlertSource;
|
||||
/**
|
||||
* The same problem gives the same fingerprint every time: repeats go to
|
||||
* the ticket it opened instead of opening another.
|
||||
*/
|
||||
fingerprint: string;
|
||||
title: string;
|
||||
/** Markdown for the ticket's description. */
|
||||
details: string;
|
||||
url: string | null;
|
||||
/** The tool says it is over (back up, resolved). */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_DETAILS = 4000;
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
const obj = (value: unknown): Json =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Json)
|
||||
: {};
|
||||
const str = (value: unknown): string =>
|
||||
typeof value === 'string'
|
||||
? value.trim()
|
||||
: typeof value === 'number'
|
||||
? String(value)
|
||||
: '';
|
||||
/** An http(s) link, or null: anything else is not offered as a link. */
|
||||
const link = (value: unknown): string | null => {
|
||||
const text = str(value);
|
||||
return /^https?:\/\/\S+$/i.test(text) ? text.slice(0, 500) : null;
|
||||
};
|
||||
|
||||
/** The alert in a payload, or null when it is not one (a test ping…). */
|
||||
export function alertOf(
|
||||
body: unknown,
|
||||
headers: Record<string, string | undefined> = {},
|
||||
): Alert | null {
|
||||
const payload = obj(body);
|
||||
const alert =
|
||||
sentry(payload, headers) ??
|
||||
uptimeKuma(payload) ??
|
||||
alertmanager(payload) ??
|
||||
generic(payload);
|
||||
if (!alert?.title || !alert.fingerprint) return null;
|
||||
return {
|
||||
...alert,
|
||||
title: alert.title.replace(/\s+/g, ' ').slice(0, MAX_TITLE),
|
||||
details: alert.details.slice(0, MAX_DETAILS),
|
||||
fingerprint: alert.fingerprint.slice(0, 300),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentry: an internal integration (header `sentry-hook-resource`: `issue`
|
||||
* or `event_alert`) or the legacy webhooks plugin (`project_name`, `event`).
|
||||
*/
|
||||
function sentry(
|
||||
payload: Json,
|
||||
headers: Record<string, string | undefined>,
|
||||
): Alert | null {
|
||||
const resource = str(headers['sentry-hook-resource']);
|
||||
const data = obj(payload['data']);
|
||||
if (resource === 'issue') {
|
||||
const issue = obj(data['issue']);
|
||||
const id = str(issue['id']);
|
||||
if (!id) return null;
|
||||
const action = str(payload['action']);
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(issue['title']),
|
||||
details: lines([
|
||||
str(issue['culprit']) && `En \`${str(issue['culprit'])}\``,
|
||||
str(issue['level']) && `Nivel: ${str(issue['level'])}`,
|
||||
]),
|
||||
url: link(issue['permalink'] ?? issue['web_url']),
|
||||
resolved: action === 'resolved',
|
||||
};
|
||||
}
|
||||
if (resource === 'event_alert') {
|
||||
const event = obj(data['event']);
|
||||
const id = str(event['issue_id']) || str(event['event_id']);
|
||||
if (!id) return null;
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(event['title']) || str(event['message']),
|
||||
details: lines([
|
||||
str(event['culprit']) && `En \`${str(event['culprit'])}\``,
|
||||
str(event['level']) && `Nivel: ${str(event['level'])}`,
|
||||
str(data['triggered_rule']) && `Regla: ${str(data['triggered_rule'])}`,
|
||||
]),
|
||||
url: link(event['web_url'] ?? event['url']),
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
if ('project_name' in payload && 'event' in payload) {
|
||||
const event = obj(payload['event']);
|
||||
const id = str(payload['id']) || str(event['event_id']);
|
||||
if (!id) return null;
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(event['title']) || str(payload['message']),
|
||||
details: lines([
|
||||
str(payload['culprit']) && `En \`${str(payload['culprit'])}\``,
|
||||
str(payload['level']) && `Nivel: ${str(payload['level'])}`,
|
||||
str(payload['project_name']) &&
|
||||
`Proyecto de Sentry: ${str(payload['project_name'])}`,
|
||||
]),
|
||||
url: link(payload['url']),
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Uptime Kuma's webhook: `monitor` and `heartbeat` (status 0 down, 1 up). */
|
||||
function uptimeKuma(payload: Json): Alert | null {
|
||||
const monitor = obj(payload['monitor']);
|
||||
const heartbeat = obj(payload['heartbeat']);
|
||||
if (!('monitor' in payload) || !('heartbeat' in payload)) return null;
|
||||
const name = str(monitor['name']);
|
||||
const id = str(monitor['id']) || name;
|
||||
if (!id) return null;
|
||||
const up = heartbeat['status'] === 1;
|
||||
return {
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: `uptime-kuma:${id}`,
|
||||
title: `${name || 'Un monitor'} no responde`,
|
||||
details: lines([
|
||||
str(heartbeat['msg']) && `Respuesta: ${str(heartbeat['msg'])}`,
|
||||
str(monitor['url']) && `Vigila: ${str(monitor['url'])}`,
|
||||
str(heartbeat['time']) && `Desde: ${str(heartbeat['time'])}`,
|
||||
]),
|
||||
url: link(monitor['url']),
|
||||
resolved: up,
|
||||
};
|
||||
}
|
||||
|
||||
/** Prometheus Alertmanager and Grafana: `status` and `alerts[]`. */
|
||||
function alertmanager(payload: Json): Alert | null {
|
||||
if (!Array.isArray(payload['alerts'])) return null;
|
||||
const first = obj(payload['alerts'][0]);
|
||||
const labels = obj(first['labels']);
|
||||
const annotations = obj(first['annotations']);
|
||||
const key = str(payload['groupKey']) || str(first['fingerprint']);
|
||||
if (!key) return null;
|
||||
return {
|
||||
source: 'alertmanager',
|
||||
fingerprint: `alertmanager:${key}`,
|
||||
title:
|
||||
str(annotations['summary']) ||
|
||||
str(labels['alertname']) ||
|
||||
str(payload['title']),
|
||||
details: lines([
|
||||
str(annotations['description']) || str(payload['message']),
|
||||
Object.keys(labels).length > 0 &&
|
||||
`Etiquetas: ${Object.entries(labels)
|
||||
.map(([k, v]) => `\`${k}=${str(v)}\``)
|
||||
.join(' ')}`,
|
||||
]),
|
||||
url: link(first['generatorURL'] ?? payload['externalURL']),
|
||||
resolved: str(payload['status']) === 'resolved',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything else that says what broke: `title` (or `message`), and
|
||||
* optionally `fingerprint`, `description`, `url` and `resolved`.
|
||||
*/
|
||||
function generic(payload: Json): Alert | null {
|
||||
const title = str(payload['title']) || str(payload['message']);
|
||||
if (!title) return null;
|
||||
return {
|
||||
source: 'generic',
|
||||
fingerprint: `generic:${str(payload['fingerprint']) || title.toLowerCase()}`,
|
||||
title,
|
||||
details: str(payload['description']),
|
||||
url: link(payload['url']),
|
||||
resolved:
|
||||
payload['resolved'] === true || str(payload['status']) === 'resolved',
|
||||
};
|
||||
}
|
||||
|
||||
function lines(parts: (string | false | undefined)[]): string {
|
||||
return parts.filter((part): part is string => !!part).join('\n\n');
|
||||
}
|
||||
220
libs/api/tracker/application/src/lib/alerts/alerts.spec.ts
Normal file
220
libs/api/tracker/application/src/lib/alerts/alerts.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
CqrsModule,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
AlertIntegrationConfig,
|
||||
AlertIntegrationsRepository,
|
||||
AlertTrace,
|
||||
} from '../ports/alert-integrations.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { Alert } from './alert-payloads';
|
||||
import { ALERT_HANDLERS, ReceiveAlertCommand, REPEAT_QUIET_MS } from './alerts';
|
||||
|
||||
class InMemoryAlerts extends AlertIntegrationsRepository {
|
||||
configs = new Map<string, AlertIntegrationConfig>();
|
||||
traces = new Map<string, AlertTrace>();
|
||||
async find(projectId: string) {
|
||||
return this.configs.get(projectId) ?? null;
|
||||
}
|
||||
async save(config: AlertIntegrationConfig) {
|
||||
this.configs.set(config.projectId, config);
|
||||
}
|
||||
async remove(projectId: string) {
|
||||
this.configs.delete(projectId);
|
||||
}
|
||||
async trace(projectId: string, fingerprint: string) {
|
||||
return this.traces.get(`${projectId}:${fingerprint}`) ?? null;
|
||||
}
|
||||
async saveTrace(trace: AlertTrace) {
|
||||
this.traces.set(`${trace.projectId}:${trace.fingerprint}`, trace);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tickets alerts open, and what TaskStream says on them. */
|
||||
const issues = new Map<string, IssueReadModel>();
|
||||
const created: CreateIssueCommand[] = [];
|
||||
const comments: AddCommentCommand[] = [];
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
class FakeCreate implements ICommandHandler<CreateIssueCommand> {
|
||||
async execute(command: CreateIssueCommand) {
|
||||
created.push(command);
|
||||
const id = `i${created.length}`;
|
||||
const key = `WEB-${created.length}`;
|
||||
issues.set(id, {
|
||||
id,
|
||||
key,
|
||||
statusCategory: StatusCategory.TODO,
|
||||
trashedAt: null,
|
||||
} as IssueReadModel);
|
||||
return { id, key, version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(AddCommentCommand)
|
||||
class FakeComment implements ICommandHandler<AddCommentCommand> {
|
||||
async execute(command: AddCommentCommand) {
|
||||
comments.push(command);
|
||||
return { id: 'c', version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const alert = (over: Partial<Alert> = {}): Alert => ({
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: 'uptime-kuma:3',
|
||||
title: 'API no responde',
|
||||
details: 'timeout',
|
||||
url: 'https://api.acme.dev',
|
||||
resolved: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
issues.clear();
|
||||
created.length = 0;
|
||||
comments.length = 0;
|
||||
const repo = new InMemoryAlerts();
|
||||
await repo.save({
|
||||
projectId: 'p1',
|
||||
token: 't',
|
||||
typeId: null,
|
||||
priority: IssuePriority.HIGH,
|
||||
createdAt: '2026-10-01T00:00:00Z',
|
||||
});
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...ALERT_HANDLERS,
|
||||
FakeCreate,
|
||||
FakeComment,
|
||||
CommandRunner,
|
||||
{ provide: AlertIntegrationsRepository, useValue: repo },
|
||||
{
|
||||
provide: FindProjectsRepository,
|
||||
useValue: {
|
||||
findById: async () => ({
|
||||
id: 'p1',
|
||||
settings: {
|
||||
issueTypes: [
|
||||
{ id: 'task', icon: 'task', archived: false },
|
||||
{ id: 'bug', icon: 'bug', archived: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: { findById: async (id: string) => issues.get(id) ?? null },
|
||||
},
|
||||
{ provide: WriteAccess, useValue: {} },
|
||||
{ provide: ReadAccess, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
const bus = moduleRef.get(CommandBus);
|
||||
return {
|
||||
repo,
|
||||
receive: (a: Alert) => bus.execute(new ReceiveAlertCommand('p1', a)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('alerts → bugs', () => {
|
||||
it('opens a bug as TaskStream, with the bug type and the priority set', async () => {
|
||||
const { receive } = await setup();
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'created',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
const [command] = created;
|
||||
expect(command.actorId).toBe(SYSTEM_ACTOR_ID);
|
||||
expect(command.type).toBe('bug');
|
||||
expect(command.details.priority).toBe(IssuePriority.HIGH);
|
||||
expect(command.details.labels).toEqual(['alerta']);
|
||||
expect(command.details.description).toContain('Uptime Kuma');
|
||||
});
|
||||
|
||||
it('sends repeats to the open bug, telling it at most every half hour', async () => {
|
||||
const { receive, repo } = await setup();
|
||||
await receive(alert());
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'repeated',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
// Just told when it opened: this repeat stays quiet.
|
||||
expect(comments).toHaveLength(0);
|
||||
const trace = await repo.trace('p1', 'uptime-kuma:3');
|
||||
if (!trace) throw new Error('no trace');
|
||||
expect(trace.count).toBe(2);
|
||||
await repo.saveTrace({
|
||||
...trace,
|
||||
notedAt: new Date(Date.now() - REPEAT_QUIET_MS - 1000).toISOString(),
|
||||
});
|
||||
await receive(alert());
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].body).toContain('3 veces');
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says on the bug when it is resolved, and ignores one with nothing open', async () => {
|
||||
const { receive } = await setup();
|
||||
expect(await receive(alert({ resolved: true }))).toEqual({
|
||||
action: 'ignored',
|
||||
issueKey: null,
|
||||
});
|
||||
await receive(alert());
|
||||
expect(await receive(alert({ resolved: true }))).toEqual({
|
||||
action: 'resolved',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
expect(comments[0].body).toContain('se ha resuelto');
|
||||
});
|
||||
|
||||
it('opens a new bug when the old one is closed, naming it', async () => {
|
||||
const { receive } = await setup();
|
||||
await receive(alert());
|
||||
issues.set('i1', {
|
||||
...(issues.get('i1') as IssueReadModel),
|
||||
statusCategory: StatusCategory.DONE,
|
||||
});
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'created',
|
||||
issueKey: 'WEB-2',
|
||||
});
|
||||
expect(created[1].details.description).toContain('Ya pasó antes: WEB-1');
|
||||
});
|
||||
|
||||
it('sends a repeat to a bug not yet in the read model', async () => {
|
||||
const { receive } = await setup();
|
||||
await receive(alert());
|
||||
issues.clear();
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'repeated',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens one bug when the same alert arrives twice at once', async () => {
|
||||
const { receive } = await setup();
|
||||
const both = await Promise.all([receive(alert()), receive(alert())]);
|
||||
expect(both.map((o) => o.action).sort()).toEqual(['created', 'repeated']);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
357
libs/api/tracker/application/src/lib/alerts/alerts.ts
Normal file
357
libs/api/tracker/application/src/lib/alerts/alerts.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
AlertIntegrationConfig,
|
||||
AlertIntegrationsRepository,
|
||||
} from '../ports/alert-integrations.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { Alert, AlertSource } from './alert-payloads';
|
||||
|
||||
/*
|
||||
* Alerts → bugs: monitoring tools (Sentry, Uptime Kuma, Grafana…) call a
|
||||
* project's secret URL; a new problem opens a bug, a repeat goes to the bug
|
||||
* it opened while that one is open, and «resolved» is said on it. Nobody's
|
||||
* ticket: TaskStream opens and comments them.
|
||||
*/
|
||||
|
||||
export interface AlertIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for whoever manages the project. */
|
||||
token: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
}
|
||||
|
||||
export interface AlertSettings {
|
||||
typeId?: string | null;
|
||||
priority?: IssuePriority;
|
||||
}
|
||||
|
||||
export type AlertOutcome =
|
||||
| { action: 'created' | 'repeated' | 'resolved'; issueKey: string }
|
||||
| { action: 'ignored'; issueKey: null };
|
||||
|
||||
/** Repeats tell the ticket at most this often. */
|
||||
export const REPEAT_QUIET_MS = 30 * 60_000;
|
||||
|
||||
const SOURCE_NAME: Record<AlertSource, string> = {
|
||||
sentry: 'Sentry',
|
||||
'uptime-kuma': 'Uptime Kuma',
|
||||
alertmanager: 'Alertmanager',
|
||||
generic: 'la alerta',
|
||||
};
|
||||
|
||||
const OFF: AlertIntegration = {
|
||||
enabled: false,
|
||||
token: null,
|
||||
typeId: null,
|
||||
priority: IssuePriority.HIGH,
|
||||
};
|
||||
|
||||
const newToken = () => randomBytes(24).toString('hex');
|
||||
|
||||
/** Turns it on, or changes what it opens (the token stays). */
|
||||
export class SaveAlertIntegrationCommand extends Command<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly settings: AlertSettings,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** A new URL; the old one stops working at once. */
|
||||
export class RotateAlertTokenCommand extends Command<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableAlertIntegrationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** An alert already let in (its token checked by the caller). */
|
||||
export class ReceiveAlertCommand extends Command<AlertOutcome> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly alert: Alert,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAlertIntegrationQuery extends Query<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** What owners and admins see: the token included. */
|
||||
const view = (config: AlertIntegrationConfig): AlertIntegration => ({
|
||||
enabled: true,
|
||||
token: config.token,
|
||||
typeId: config.typeId,
|
||||
priority: config.priority,
|
||||
});
|
||||
|
||||
@CommandHandler(SaveAlertIntegrationCommand)
|
||||
export class SaveAlertIntegrationCommandHandler
|
||||
implements ICommandHandler<SaveAlertIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: SaveAlertIntegrationCommand,
|
||||
): Promise<AlertIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const { typeId, priority } = command.settings;
|
||||
if (typeId) {
|
||||
const project = await this.findProjects.findById(command.projectId);
|
||||
assertThat(
|
||||
!!project?.settings.issueTypes.some(
|
||||
(t) => t.id === typeId && !t.archived,
|
||||
),
|
||||
`Unknown ticket type: ${typeId}`,
|
||||
);
|
||||
}
|
||||
const current = await this.integrations.find(command.projectId);
|
||||
const config: AlertIntegrationConfig = {
|
||||
projectId: command.projectId,
|
||||
token: current?.token ?? newToken(),
|
||||
typeId: typeId === undefined ? (current?.typeId ?? null) : typeId,
|
||||
priority: priority ?? current?.priority ?? IssuePriority.HIGH,
|
||||
createdAt: current?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
await this.integrations.save(config);
|
||||
return view(config);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RotateAlertTokenCommand)
|
||||
export class RotateAlertTokenCommandHandler
|
||||
implements ICommandHandler<RotateAlertTokenCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RotateAlertTokenCommand): Promise<AlertIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const current = await this.integrations.find(command.projectId);
|
||||
if (!current)
|
||||
throw new NotFoundError('Alert integration', command.projectId);
|
||||
const rotated = { ...current, token: newToken() };
|
||||
await this.integrations.save(rotated);
|
||||
return view(rotated);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DisableAlertIntegrationCommand)
|
||||
export class DisableAlertIntegrationCommandHandler
|
||||
implements ICommandHandler<DisableAlertIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DisableAlertIntegrationCommand): Promise<void> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
await this.integrations.remove(command.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ReceiveAlertCommand)
|
||||
export class ReceiveAlertCommandHandler
|
||||
implements ICommandHandler<ReceiveAlertCommand>
|
||||
{
|
||||
/** One alert of a project after another: two at once open one ticket. */
|
||||
private readonly queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly commands: CommandRunner,
|
||||
) {}
|
||||
|
||||
execute(command: ReceiveAlertCommand): Promise<AlertOutcome> {
|
||||
const previous = this.queues.get(command.projectId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => null).then(() => this.receive(command));
|
||||
this.queues.set(command.projectId, next);
|
||||
void next.finally(() => {
|
||||
if (this.queues.get(command.projectId) === next)
|
||||
this.queues.delete(command.projectId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
private async receive({
|
||||
projectId,
|
||||
alert,
|
||||
}: ReceiveAlertCommand): Promise<AlertOutcome> {
|
||||
const config = await this.integrations.find(projectId);
|
||||
if (!config) throw new NotFoundError('Alert integration', projectId);
|
||||
const now = new Date().toISOString();
|
||||
const trace = await this.integrations.trace(projectId, alert.fingerprint);
|
||||
const opened = trace ? await this.findIssues.findById(trace.issueId) : null;
|
||||
// Not in the read model yet: it was opened a moment ago, so it is open.
|
||||
const open = !trace
|
||||
? null
|
||||
: !opened
|
||||
? { id: trace.issueId, key: trace.issueKey }
|
||||
: !opened.trashedAt &&
|
||||
opened.statusCategory !== StatusCategory.DONE &&
|
||||
opened.statusCategory !== StatusCategory.CANCELLED
|
||||
? opened
|
||||
: null;
|
||||
const source = SOURCE_NAME[alert.source];
|
||||
|
||||
if (trace && open) {
|
||||
if (alert.resolved) {
|
||||
await this.comment(open.id, `✅ Según ${source}, se ha resuelto.`);
|
||||
await this.integrations.saveTrace({ ...trace, notedAt: now });
|
||||
return { action: 'resolved', issueKey: open.key };
|
||||
}
|
||||
const count = trace.count + 1;
|
||||
const quiet = Date.now() - Date.parse(trace.notedAt) < REPEAT_QUIET_MS;
|
||||
if (!quiet)
|
||||
await this.comment(
|
||||
open.id,
|
||||
`🔁 Ha vuelto a pasar: ${count} veces desde ${trace.firstAt.slice(0, 16).replace('T', ' ')} (UTC).`,
|
||||
);
|
||||
await this.integrations.saveTrace({
|
||||
...trace,
|
||||
count,
|
||||
lastAt: now,
|
||||
notedAt: quiet ? trace.notedAt : now,
|
||||
});
|
||||
return { action: 'repeated', issueKey: open.key };
|
||||
}
|
||||
// Over before anyone looked, or about a ticket already closed.
|
||||
if (alert.resolved) return { action: 'ignored', issueKey: null };
|
||||
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
const types = (project?.settings.issueTypes ?? []).filter(
|
||||
(t) => !t.archived,
|
||||
);
|
||||
const type =
|
||||
types.find((t) => t.id === config.typeId) ??
|
||||
types.find((t) => t.icon === 'bug') ??
|
||||
types[0];
|
||||
if (!type) throw new NotFoundError('Ticket type', config.typeId ?? 'bug');
|
||||
const description = [
|
||||
alert.details,
|
||||
alert.url && `[Ver en ${source}](${alert.url})`,
|
||||
opened && `Ya pasó antes: ${opened.key}.`,
|
||||
`_Abierto por una alerta de ${source}._`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const result: CommandResult = await this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
projectId,
|
||||
type.id,
|
||||
alert.title,
|
||||
{ description, priority: config.priority, labels: ['alerta'] },
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
),
|
||||
);
|
||||
await this.integrations.saveTrace({
|
||||
projectId,
|
||||
fingerprint: alert.fingerprint,
|
||||
issueId: result.id,
|
||||
issueKey: result.key ?? result.id,
|
||||
count: 1,
|
||||
firstAt: now,
|
||||
lastAt: now,
|
||||
notedAt: now,
|
||||
});
|
||||
return { action: 'created', issueKey: result.key ?? result.id };
|
||||
}
|
||||
|
||||
private comment(issueId: string, body: string): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(SYSTEM_ACTOR_ID, issueId, body),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAlertIntegrationQuery)
|
||||
export class GetAlertIntegrationQueryHandler
|
||||
implements IQueryHandler<GetAlertIntegrationQuery, AlertIntegration>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAlertIntegrationQuery): Promise<AlertIntegration> {
|
||||
const role = await this.access.assertSeesWholeProject(
|
||||
query.projectId,
|
||||
query.viewerId,
|
||||
);
|
||||
const config = await this.integrations.find(query.projectId);
|
||||
if (!config) return OFF;
|
||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||
return { ...view(config), token: manages ? config.token : null };
|
||||
}
|
||||
}
|
||||
|
||||
export const ALERT_HANDLERS = [
|
||||
SaveAlertIntegrationCommandHandler,
|
||||
RotateAlertTokenCommandHandler,
|
||||
DisableAlertIntegrationCommandHandler,
|
||||
ReceiveAlertCommandHandler,
|
||||
GetAlertIntegrationQueryHandler,
|
||||
];
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentFactory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
@@ -16,10 +19,17 @@ export class AddCommentCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForReporter(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// TaskStream itself (alerts that repeat) is no member; only internal
|
||||
// commands act as it, never a request from outside.
|
||||
const issue =
|
||||
command.actorId === SYSTEM_ACTOR_ID
|
||||
? await this.access.issue(command.issueId)
|
||||
: (await this.access.issueForReporter(command.issueId, command.actorId))
|
||||
.issue;
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${issue.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IssuePriority } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** A project's door for alerts: who knows the token opens bugs. */
|
||||
export interface AlertIntegrationConfig {
|
||||
projectId: string;
|
||||
token: string;
|
||||
/** Ticket type to open; null: the project's first bug type. */
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Which ticket a problem opened, and how often it came back. */
|
||||
export interface AlertTrace {
|
||||
projectId: string;
|
||||
fingerprint: string;
|
||||
issueId: string;
|
||||
issueKey: string;
|
||||
count: number;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
/** Last time the ticket was told (so repeats do not flood it). */
|
||||
notedAt: string;
|
||||
}
|
||||
|
||||
export abstract class AlertIntegrationsRepository {
|
||||
abstract find(projectId: string): Promise<AlertIntegrationConfig | null>;
|
||||
abstract save(config: AlertIntegrationConfig): Promise<void>;
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
abstract trace(
|
||||
projectId: string,
|
||||
fingerprint: string,
|
||||
): Promise<AlertTrace | null>;
|
||||
abstract saveTrace(trace: AlertTrace): Promise<void>;
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { PinsService } from './pins.service';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
@@ -81,6 +83,7 @@ const SERVICES = [
|
||||
NotificationsService,
|
||||
SavedFiltersService,
|
||||
PinsService,
|
||||
AlertsService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -112,6 +115,7 @@ const SERVICES = [
|
||||
...SAVED_FILTERS,
|
||||
...MY_VIEWS,
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
AlertIntegrationConfig,
|
||||
AlertIntegrationsRepository,
|
||||
AlertTrace,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels } from './read-model.schemas';
|
||||
|
||||
type Stored<T> = T & { _id?: unknown };
|
||||
|
||||
@Injectable()
|
||||
export class MongoAlertIntegrationsRepository extends AlertIntegrationsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.AlertIntegration)
|
||||
private readonly configs: Model<Stored<AlertIntegrationConfig>>,
|
||||
@InjectModel(ReadModels.AlertTrace)
|
||||
private readonly traces: Model<Stored<AlertTrace>>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async find(projectId: string): Promise<AlertIntegrationConfig | null> {
|
||||
const doc = await this.configs
|
||||
.findOne({ projectId })
|
||||
.lean<Stored<AlertIntegrationConfig>>();
|
||||
if (!doc) return null;
|
||||
const { _id: _ignored, ...config } = doc;
|
||||
return config;
|
||||
}
|
||||
|
||||
async save(config: AlertIntegrationConfig): Promise<void> {
|
||||
await this.configs.updateOne(
|
||||
{ projectId: config.projectId },
|
||||
{ $set: config },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
// The traces stay: turned on again, repeats still find their bug.
|
||||
await this.configs.deleteOne({ projectId });
|
||||
}
|
||||
|
||||
async trace(
|
||||
projectId: string,
|
||||
fingerprint: string,
|
||||
): Promise<AlertTrace | null> {
|
||||
const doc = await this.traces
|
||||
.findOne({ projectId, fingerprint })
|
||||
.lean<Stored<AlertTrace>>();
|
||||
if (!doc) return null;
|
||||
const { _id: _ignored, ...trace } = doc;
|
||||
return trace;
|
||||
}
|
||||
|
||||
async saveTrace(trace: AlertTrace): Promise<void> {
|
||||
await this.traces.updateOne(
|
||||
{ projectId: trace.projectId, fingerprint: trace.fingerprint },
|
||||
{ $set: trace },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,8 @@ export const ReadModels = {
|
||||
SavedFilter: 'SavedFilter',
|
||||
MyView: 'MyView',
|
||||
PinnedIssue: 'PinnedIssue',
|
||||
AlertIntegration: 'AlertIntegration',
|
||||
AlertTrace: 'AlertTrace',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -339,6 +341,25 @@ export const PinnedIssueSchema =
|
||||
SchemaFactory.createForClass(PinnedIssueDocument);
|
||||
PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'alert_integrations' })
|
||||
class AlertIntegrationDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) projectId!: string;
|
||||
@Prop({ type: String }) token!: string;
|
||||
}
|
||||
export const AlertIntegrationSchema = SchemaFactory.createForClass(
|
||||
AlertIntegrationDocument,
|
||||
);
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'alert_traces' })
|
||||
class AlertTraceDocument {
|
||||
@Prop({ type: String }) projectId!: string;
|
||||
@Prop({ type: String }) fingerprint!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
}
|
||||
export const AlertTraceSchema =
|
||||
SchemaFactory.createForClass(AlertTraceDocument);
|
||||
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -442,6 +463,8 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
{ name: ReadModels.MyView, schema: MyViewSchema },
|
||||
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
|
||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SavedFiltersRepository,
|
||||
MyViewsRepository,
|
||||
PinsRepository,
|
||||
AlertIntegrationsRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -80,6 +81,7 @@ import { WebPushSender } from './push/web-push.sender';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -157,6 +159,10 @@ const PORTS = [
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
|
||||
{ provide: PinsRepository, useClass: MongoPinsRepository },
|
||||
{
|
||||
provide: AlertIntegrationsRepository,
|
||||
useClass: MongoAlertIntegrationsRepository,
|
||||
},
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -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