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,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'));
}
}