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

@@ -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;

View File

@@ -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). */

View File

@@ -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));
}
}

View File

@@ -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)
}

View File

@@ -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: [

View File

@@ -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 },

View File

@@ -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') {

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