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

@@ -10912,6 +10912,453 @@
"security": []
}
},
"/api/projects/{project}/alert-integration": {
"get": {
"operationId": "AlertsController_integration",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertIntegrationDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Whether alerts open bugs, and the secret URL (for admins)",
"tags": [
"Alerts"
]
},
"put": {
"description": "The URL stays the same; rotate it with POST …/token.",
"operationId": "AlertsController_save",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertSettingsDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertIntegrationDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn alerts → bugs on, or change what it opens (owners, admins)",
"tags": [
"Alerts"
]
},
"delete": {
"operationId": "AlertsController_disable",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn it off; the bugs it opened stay",
"tags": [
"Alerts"
]
}
},
"/api/projects/{project}/alert-integration/token": {
"post": {
"operationId": "AlertsController_rotate",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertIntegrationDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "A new secret URL; the old one stops working at once",
"tags": [
"Alerts"
]
}
},
"/api/webhooks/alerts/{project}/{token}": {
"post": {
"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.",
"operationId": "AlertsController_receive",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
},
{
"name": "token",
"required": true,
"in": "path",
"description": "The secret of the URL",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"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"
}
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertOutcomeDto"
}
}
}
},
"403": {
"description": "Unknown project, alerts off or bad token"
}
},
"summary": "Receive an alert (called by the monitoring tool)",
"tags": [
"Alerts"
],
"security": []
}
},
"/api/webhook-events": {
"get": {
"operationId": "WebhooksController_events",
@@ -17252,6 +17699,79 @@
"recorded"
]
},
"AlertIntegrationDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"webhookPath": {
"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…"
},
"typeId": {
"type": "string",
"nullable": true,
"description": "Ticket type it opens; null: the project's first bug type"
},
"priority": {
"allOf": [
{
"$ref": "#/components/schemas/IssuePriority"
}
]
}
},
"required": [
"enabled",
"webhookPath",
"typeId",
"priority"
]
},
"AlertSettingsDto": {
"type": "object",
"properties": {
"typeId": {
"type": "string",
"nullable": true,
"description": "Ticket type to open; null: the project's first bug type"
},
"priority": {
"allOf": [
{
"$ref": "#/components/schemas/IssuePriority"
}
]
}
}
},
"AlertOutcomeDto": {
"type": "object",
"properties": {
"action": {
"type": "string",
"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"
},
"issueKey": {
"type": "string",
"nullable": true,
"example": "WEB-42"
}
},
"required": [
"action",
"issueKey"
]
},
"WebhookDeliveryDto": {
"type": "object",
"properties": {

View File

@@ -45,6 +45,27 @@ type Aging {
thresholdDays: Float
}
"""Alerts → bugs: the secret URL monitoring tools call to open bugs"""
type AlertIntegration {
enabled: Boolean!
priority: IssuePriority!
"""Ticket type it opens; null: the project's first bug type"""
typeId: ID
"""
Path of the URL on this API, secret token included; only owners and admins see it
"""
webhookPath: String
}
input AlertSettingsInput {
priority: IssuePriority
"""Ticket type to open; null: the project's first bug type"""
typeId: ID
}
type AreaOption {
archived: Boolean!
id: String!
@@ -977,6 +998,9 @@ type Mutation {
deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean!
"""Turn it off; the bugs it opened stay"""
disableAlertIntegration(projectId: ID!): Boolean!
"""
A new ticket like this one: what it says, not where it stands (the key comes in the result)
"""
@@ -1029,9 +1053,17 @@ type Mutation {
"""My calendar URL stops working"""
revokeCalendarFeed: Boolean!
"""A new secret URL; the old one stops working at once"""
rotateAlertToken(projectId: ID!): AlertIntegration!
"""A new secret for a repository; the old one stops working"""
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
"""
Turn alerts → bugs on, or change the type and priority it opens (the URL stays)
"""
saveAlertIntegration(input: AlertSettingsInput!, projectId: ID!): AlertIntegration!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
@@ -1507,6 +1539,7 @@ type Query {
limit: Int! = 50
projectId: ID!
): [Activity!]!
alertIntegration(projectId: ID!): AlertIntegration!
automations(projectId: ID!): [Automation!]!
"""The project's link for clients, if any (owners and admins)"""

View File

@@ -1679,6 +1679,65 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects/{project}/alert-integration": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Whether alerts open bugs, and the secret URL (for admins) */
get: operations["AlertsController_integration"];
/**
* Turn alerts → bugs on, or change what it opens (owners, admins)
* @description The URL stays the same; rotate it with POST …/token.
*/
put: operations["AlertsController_save"];
post?: never;
/** Turn it off; the bugs it opened stay */
delete: operations["AlertsController_disable"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/alert-integration/token": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** A new secret URL; the old one stops working at once */
post: operations["AlertsController_rotate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/webhooks/alerts/{project}/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: operations["AlertsController_receive"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/webhook-events": {
parameters: {
query?: never;
@@ -3534,6 +3593,31 @@ export interface components {
/** @description Tickets that got something new */
recorded: number;
};
AlertIntegrationDto: {
enabled: boolean;
/**
* @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;
/** @description Ticket type it opens; null: the project's first bug type */
typeId: string | null;
priority: components["schemas"]["IssuePriority"];
};
AlertSettingsDto: {
/** @description Ticket type to open; null: the project's first bug type */
typeId?: string | null;
priority?: components["schemas"]["IssuePriority"];
};
AlertOutcomeDto: {
/**
* @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
* @enum {string}
*/
action: "created" | "repeated" | "resolved" | "ignored";
/** @example WEB-42 */
issueKey: string | null;
};
WebhookDeliveryDto: {
/** Format: date-time */
at: string;
@@ -11249,6 +11333,329 @@ export interface operations {
};
};
};
AlertsController_integration: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertIntegrationDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
AlertsController_save: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AlertSettingsDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertIntegrationDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
AlertsController_disable: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
AlertsController_rotate: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertIntegrationDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
AlertsController_receive: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description The secret of the URL */
token: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": Record<string, never>;
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertOutcomeDto"];
};
};
/** @description Unknown project, alerts off or bad token */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
WebhooksController_events: {
parameters: {
query?: never;

View File

@@ -36,6 +36,7 @@
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |

View File

@@ -265,6 +265,20 @@ stateDiagram-v2
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
y `alert_traces` con qué bug abrió cada huella), no en el event store.
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
@@ -740,6 +754,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |

View File

@@ -311,7 +311,7 @@ visualización de la plataforma:
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |

View File

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

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

View File

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

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

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

View 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,
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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,
};
}

View File

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

View File

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

View File

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

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