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,
|
||||
|
||||
@@ -316,6 +316,16 @@ export interface GitIntegration {
|
||||
repositories: GitRepository[];
|
||||
}
|
||||
|
||||
/** Alerts → bugs: the secret URL monitoring tools call. */
|
||||
export interface AlertIntegration {
|
||||
enabled: boolean;
|
||||
/** With its secret token; only for owners and admins. */
|
||||
webhookPath: string | null;
|
||||
/** Ticket type it opens; null: the project's first bug type. */
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
issueId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IssuePriority } from '../models/enums';
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
CalendarFeed,
|
||||
PushDeviceSubscription,
|
||||
CommandResult,
|
||||
AlertIntegration,
|
||||
Comment,
|
||||
Decision,
|
||||
Epic,
|
||||
@@ -247,6 +249,19 @@ export abstract class WebhooksGateway {
|
||||
}
|
||||
|
||||
/** The project's Git webhook (owners and admins configure it). */
|
||||
/** Alerts → bugs (owners and admins). */
|
||||
export abstract class AlertsGateway {
|
||||
abstract integration(projectId: string): Observable<AlertIntegration>;
|
||||
/** Turns it on, or changes what it opens; the URL stays. */
|
||||
abstract save(
|
||||
projectId: string,
|
||||
settings: { typeId: string | null; priority: IssuePriority },
|
||||
): Observable<AlertIntegration>;
|
||||
/** A new URL; the old one stops working. */
|
||||
abstract rotateToken(projectId: string): Observable<AlertIntegration>;
|
||||
abstract disable(projectId: string): Observable<void>;
|
||||
}
|
||||
|
||||
export abstract class GitGateway {
|
||||
abstract integration(projectId: string): Observable<GitIntegration>;
|
||||
/** A repository with its own secret (the first turns the integration on). */
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
AlertIntegration,
|
||||
AlertsGateway,
|
||||
IssuePriority,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
AlertIntegrationDocument,
|
||||
DisableAlertIntegrationDocument,
|
||||
RotateAlertTokenDocument,
|
||||
SaveAlertIntegrationDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlAlertsGateway extends AlertsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
integration(projectId: string): Observable<AlertIntegration> {
|
||||
return this.gql
|
||||
.query(AlertIntegrationDocument, { projectId })
|
||||
.pipe(map((d) => d.alertIntegration as AlertIntegration));
|
||||
}
|
||||
|
||||
save(
|
||||
projectId: string,
|
||||
settings: { typeId: string | null; priority: IssuePriority },
|
||||
): Observable<AlertIntegration> {
|
||||
return this.gql
|
||||
.mutate(SaveAlertIntegrationDocument, {
|
||||
projectId,
|
||||
input: {
|
||||
typeId: settings.typeId,
|
||||
priority: settings.priority as never,
|
||||
},
|
||||
})
|
||||
.pipe(map((d) => d.saveAlertIntegration as AlertIntegration));
|
||||
}
|
||||
|
||||
rotateToken(projectId: string): Observable<AlertIntegration> {
|
||||
return this.gql
|
||||
.mutate(RotateAlertTokenDocument, { projectId })
|
||||
.pipe(map((d) => d.rotateAlertToken as AlertIntegration));
|
||||
}
|
||||
|
||||
disable(projectId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(DisableAlertIntegrationDocument, { projectId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
fragment AlertIntegrationFields on AlertIntegration {
|
||||
enabled
|
||||
webhookPath
|
||||
typeId
|
||||
priority
|
||||
}
|
||||
|
||||
query AlertIntegration($projectId: ID!) {
|
||||
alertIntegration(projectId: $projectId) {
|
||||
...AlertIntegrationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation SaveAlertIntegration($projectId: ID!, $input: AlertSettingsInput!) {
|
||||
saveAlertIntegration(projectId: $projectId, input: $input) {
|
||||
...AlertIntegrationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation RotateAlertToken($projectId: ID!) {
|
||||
rotateAlertToken(projectId: $projectId) {
|
||||
...AlertIntegrationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DisableAlertIntegration($projectId: ID!) {
|
||||
disableAlertIntegration(projectId: $projectId)
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export type AddChecklistItemInput = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AlertSettingsInput = {
|
||||
priority?: IssuePriority | null | undefined;
|
||||
/** Ticket type to open; null: the project's first bug type */
|
||||
typeId?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type AreaOptionInput = {
|
||||
archived?: boolean;
|
||||
id: string;
|
||||
@@ -424,6 +430,61 @@ export type WorkflowStatusInput = {
|
||||
wipLimit?: number | null | undefined;
|
||||
};
|
||||
|
||||
export type AlertIntegrationFieldsFragment = {
|
||||
enabled: boolean;
|
||||
webhookPath: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
};
|
||||
|
||||
export type AlertIntegrationQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type AlertIntegrationQuery = {
|
||||
alertIntegration: {
|
||||
enabled: boolean;
|
||||
webhookPath: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
};
|
||||
};
|
||||
|
||||
export type SaveAlertIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
input: AlertSettingsInput;
|
||||
}>;
|
||||
|
||||
export type SaveAlertIntegrationMutation = {
|
||||
saveAlertIntegration: {
|
||||
enabled: boolean;
|
||||
webhookPath: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
};
|
||||
};
|
||||
|
||||
export type RotateAlertTokenMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type RotateAlertTokenMutation = {
|
||||
rotateAlertToken: {
|
||||
enabled: boolean;
|
||||
webhookPath: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
};
|
||||
};
|
||||
|
||||
export type DisableAlertIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type DisableAlertIntegrationMutation = {
|
||||
disableAlertIntegration: boolean;
|
||||
};
|
||||
|
||||
export type AutomationFieldsFragment = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -3123,6 +3184,28 @@ export type TestWebhookMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export const AlertIntegrationFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AlertIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'typeId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AlertIntegrationFieldsFragment, unknown>;
|
||||
export const UserRefFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -4459,6 +4542,286 @@ export const WebhookFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
|
||||
export const AlertIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'AlertIntegration' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'alertIntegration' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AlertIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'typeId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
AlertIntegrationQuery,
|
||||
AlertIntegrationQueryVariables
|
||||
>;
|
||||
export const SaveAlertIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'SaveAlertIntegration' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AlertSettingsInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'saveAlertIntegration' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AlertIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'typeId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
SaveAlertIntegrationMutation,
|
||||
SaveAlertIntegrationMutationVariables
|
||||
>;
|
||||
export const RotateAlertTokenDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RotateAlertToken' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'rotateAlertToken' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AlertIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'AlertIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'typeId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
RotateAlertTokenMutation,
|
||||
RotateAlertTokenMutationVariables
|
||||
>;
|
||||
export const DisableAlertIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'DisableAlertIntegration' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'disableAlertIntegration' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
DisableAlertIntegrationMutation,
|
||||
DisableAlertIntegrationMutationVariables
|
||||
>;
|
||||
export const AutomationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ReleasesGateway,
|
||||
SavedFiltersGateway,
|
||||
SprintsGateway,
|
||||
AlertsGateway,
|
||||
GitGateway,
|
||||
WebhooksGateway,
|
||||
TransferGateway,
|
||||
@@ -42,6 +43,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||
import { GraphqlAlertsGateway } from './adapters/graphql-alerts.gateway';
|
||||
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
||||
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
|
||||
@@ -78,6 +80,7 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||
{ provide: AlertsGateway, useClass: GraphqlAlertsGateway },
|
||||
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
||||
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { AlertIntegrationComponent } from '../ui/alert-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
@@ -141,6 +142,7 @@ function idFrom(name: string): string {
|
||||
PiCheckboxComponent,
|
||||
RouterLink,
|
||||
GitIntegrationComponent,
|
||||
AlertIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
TransferComponent,
|
||||
AutomationsComponent,
|
||||
@@ -798,6 +800,7 @@ function idFrom(name: string): string {
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-alert-integration [projectId]="project.id" />
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
}
|
||||
@case ('data') {
|
||||
|
||||
340
libs/web/tracker/presenters/src/lib/ui/alert-integration.ts
Normal file
340
libs/web/tracker/presenters/src/lib/ui/alert-integration.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
PiLabelDirective,
|
||||
PiSelectFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
AlertIntegration,
|
||||
AlertsGateway,
|
||||
IssuePriority,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { options, PRIORITY } from './copy';
|
||||
|
||||
/** The project's first bug type: what it opens when none is chosen. */
|
||||
const FIRST_BUG = '';
|
||||
|
||||
/**
|
||||
* Settings card for alerts → bugs: the secret URL monitoring tools call,
|
||||
* what it opens, and how to set it up in each tool.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-alert-integration',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiSelectFieldComponent, PiLabelDirective],
|
||||
styleUrl: './settings-card.scss',
|
||||
template: `
|
||||
<section class="ts-panel ts-block ts-alerts" aria-labelledby="alerts-title">
|
||||
<div>
|
||||
<h3 id="alerts-title">Alertas → bugs</h3>
|
||||
<p class="ts-muted">
|
||||
Sentry, Uptime Kuma, Grafana o cualquier herramienta que llame a una
|
||||
URL abre aquí un bug cuando algo falla. Si el mismo problema se
|
||||
repite, va al bug que abrió mientras siga abierto (con un comentario
|
||||
como mucho cada media hora); si se resuelve, lo dice en él.
|
||||
</p>
|
||||
</div>
|
||||
@if (integration(); as alerts) {
|
||||
@if (alerts.enabled) {
|
||||
@if (alerts.webhookPath) {
|
||||
<dl class="ts-alerts__facts">
|
||||
<dt>URL secreta</dt>
|
||||
<dd>
|
||||
<code>{{
|
||||
shown() ? url(alerts.webhookPath) : masked(alerts.webhookPath)
|
||||
}}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="shown.set(!shown())"
|
||||
>
|
||||
{{ shown() ? 'Ocultar' : 'Ver' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="copy(url(alerts.webhookPath))"
|
||||
>
|
||||
Copiar
|
||||
</button>
|
||||
</dd>
|
||||
</dl>
|
||||
}
|
||||
<div class="ts-alerts__settings">
|
||||
<pi-select-field
|
||||
[options]="typeOptions()"
|
||||
[value]="alerts.typeId ?? firstBug"
|
||||
(valueChange)="save({ typeId: $event || null })"
|
||||
>
|
||||
<pi-label>Abre un</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="priorityOptions"
|
||||
[value]="alerts.priority"
|
||||
(valueChange)="save({ priority: $any($event) })"
|
||||
>
|
||||
<pi-label>Con prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
</div>
|
||||
<details class="ts-alerts__how">
|
||||
<summary>Cómo conectarlo</summary>
|
||||
<dl>
|
||||
<dt>Sentry</dt>
|
||||
<dd>
|
||||
<strong
|
||||
>Settings → Developer Settings → Custom Integration</strong
|
||||
>
|
||||
(interna): pega la URL como «Webhook URL», marca
|
||||
<em>issue</em> en «Webhooks» y crea una regla de alerta que la
|
||||
avise. Un error nuevo abre un bug; si lo resuelves en Sentry, se
|
||||
dice en el bug.
|
||||
</dd>
|
||||
<dt>Uptime Kuma</dt>
|
||||
<dd>
|
||||
<strong>Ajustes → Notificaciones → Webhook</strong>, tipo
|
||||
<code>application/json</code>, y actívala en los monitores.
|
||||
Caído abre un bug; de nuevo arriba, lo dice en él.
|
||||
</dd>
|
||||
<dt>Grafana o Alertmanager</dt>
|
||||
<dd>
|
||||
Un <em>contact point</em> (o <em>receiver</em>) de tipo Webhook
|
||||
con la URL. Agrupa por alerta: cada grupo es un bug.
|
||||
</dd>
|
||||
<dt>Cualquier otra</dt>
|
||||
<dd>
|
||||
Un <code>POST</code> con JSON: <code>title</code> y, si quieres,
|
||||
<code>fingerprint</code> (lo que hace que dos avisos sean el
|
||||
mismo problema), <code>description</code>, <code>url</code> y
|
||||
<code>resolved</code>.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
@if (alerts.webhookPath) {
|
||||
<div class="ts-alerts__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ts-btn ts-btn--secondary"
|
||||
(click)="rotate()"
|
||||
>
|
||||
Cambiar la URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-btn ts-btn--ghost"
|
||||
(click)="disable()"
|
||||
>
|
||||
Desactivar
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-btn ts-btn--primary"
|
||||
(click)="save({})"
|
||||
>
|
||||
Activar
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.ts-alerts {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
}
|
||||
.ts-alerts__facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.375rem 1rem;
|
||||
margin: 0;
|
||||
dt {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
dd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ts-alerts__settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ts-alerts__how {
|
||||
font-size: 0.875rem;
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
dt {
|
||||
margin-top: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--ts-ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
.ts-alerts__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AlertIntegrationComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
|
||||
private readonly alerts = inject(AlertsGateway);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly integration = signal<AlertIntegration | null>(null);
|
||||
protected readonly shown = signal(false);
|
||||
protected readonly firstBug = FIRST_BUG;
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: FIRST_BUG, label: 'El primer tipo de bug' },
|
||||
...this.project
|
||||
.workflow()
|
||||
.settings.issueTypes.filter((t) => !t.archived)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
],
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.projectId();
|
||||
untracked(() =>
|
||||
this.alerts
|
||||
.integration(projectId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (alerts) => this.integration.set(alerts),
|
||||
error: () => this.toast.error('No se pudo leer la integración'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The API is served from the same origin as the web app. */
|
||||
protected url(path: string): string {
|
||||
return `${location.origin}${path}`;
|
||||
}
|
||||
|
||||
/** The URL without its secret, until asked to show it. */
|
||||
protected masked(path: string): string {
|
||||
return this.url(path.slice(0, path.lastIndexOf('/') + 1)) + '••••••••••••';
|
||||
}
|
||||
|
||||
protected save(change: {
|
||||
typeId?: string | null;
|
||||
priority?: IssuePriority;
|
||||
}): void {
|
||||
const current = this.integration();
|
||||
const wasOn = !!current?.enabled;
|
||||
this.alerts
|
||||
.save(this.projectId(), {
|
||||
typeId:
|
||||
change.typeId === undefined
|
||||
? (current?.typeId ?? null)
|
||||
: change.typeId,
|
||||
priority: change.priority ?? current?.priority ?? IssuePriority.HIGH,
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (alerts) => {
|
||||
this.integration.set(alerts);
|
||||
if (!wasOn) this.shown.set(true);
|
||||
this.toast.success(
|
||||
wasOn ? 'Guardado' : 'Activado: copia la URL en tu herramienta',
|
||||
);
|
||||
},
|
||||
error: () => this.toast.error('No se pudo guardar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected rotate(): void {
|
||||
if (
|
||||
!confirm(
|
||||
'La URL actual dejará de valer: habrá que cambiarla en cada herramienta. ¿Seguir?',
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.alerts
|
||||
.rotateToken(this.projectId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (alerts) => {
|
||||
this.integration.set(alerts);
|
||||
this.shown.set(true);
|
||||
this.toast.success('URL cambiada');
|
||||
},
|
||||
error: () => this.toast.error('No se pudo cambiar la URL'),
|
||||
});
|
||||
}
|
||||
|
||||
protected disable(): void {
|
||||
if (
|
||||
!confirm(
|
||||
'Las alertas dejarán de abrir bugs (los ya abiertos se quedan). ¿Desactivar?',
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.alerts
|
||||
.disable(this.projectId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.integration.update((alerts) =>
|
||||
alerts ? { ...alerts, enabled: false, webhookPath: null } : alerts,
|
||||
);
|
||||
this.shown.set(false);
|
||||
this.toast.success('Desactivado');
|
||||
},
|
||||
error: () => this.toast.error('No se pudo desactivar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected copy(text: string): void {
|
||||
navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => this.toast.success('Copiada'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user