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