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

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

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

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

View File

@@ -0,0 +1,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,
};
}