feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints change, filtered by event or family. Deliveries are retried a couple of times, the last one is shown, and a ping can be sent from the settings. Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@ SMTP_URL=
|
|||||||
MAIL_FROM=
|
MAIL_FROM=
|
||||||
WEB_URL=http://localhost:4200
|
WEB_URL=http://localhost:4200
|
||||||
|
|
||||||
|
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE=false
|
||||||
|
|
||||||
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||||
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
NEXUS_TOKEN=
|
NEXUS_TOKEN=
|
||||||
|
|||||||
@@ -7871,6 +7871,534 @@
|
|||||||
],
|
],
|
||||||
"security": []
|
"security": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/webhook-events": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "WebhooksController_events",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Event names a webhook can subscribe to",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/webhooks": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "WebhooksController_list",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WebhookDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "The project's webhooks (owners and admins)",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "The answer carries the secret, the only time it is shown.",
|
||||||
|
"operationId": "WebhooksController_create",
|
||||||
|
"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/CreateWebhookDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/WebhookCreatedDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "Add a webhook",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/outgoing-webhooks/{webhook}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "WebhooksController_update",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "webhook",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateWebhookDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/WebhookDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "Change the URL or the events, pause or resume",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "WebhooksController_delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "webhook",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"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": "Delete a webhook",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/outgoing-webhooks/{webhook}/test": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "WebhooksController_test",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "webhook",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/WebhookDeliveryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "Send a ping now and tell how it went",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
@@ -10815,6 +11343,154 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"recorded"
|
"recorded"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"WebhookDeliveryDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "issue.created"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "HTTP status"
|
||||||
|
},
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"at",
|
||||||
|
"event",
|
||||||
|
"status",
|
||||||
|
"ok",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"WebhookDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"projectId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"createdBy": {
|
||||||
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"lastDelivery": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/WebhookDeliveryDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"projectId",
|
||||||
|
"url",
|
||||||
|
"events",
|
||||||
|
"active",
|
||||||
|
"createdBy",
|
||||||
|
"createdAt",
|
||||||
|
"lastDelivery"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"CreateWebhookDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "https://chat.example.org/hooks/taskstream"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"example": [
|
||||||
|
"issue.status_changed",
|
||||||
|
"release.*"
|
||||||
|
],
|
||||||
|
"description": "Event names (GET /api/webhook-events), family.* or *",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"url",
|
||||||
|
"events"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"WebhookCreatedDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Signs every delivery. Shown only now."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"secret"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UpdateWebhookDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "https://chat.example.org/hooks/taskstream"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"example": [
|
||||||
|
"issue.status_changed",
|
||||||
|
"release.*"
|
||||||
|
],
|
||||||
|
"description": "Event names (GET /api/webhook-events), family.* or *",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Paused webhooks get nothing"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ input CreateSprintInput {
|
|||||||
startDate: String!
|
startDate: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CreateWebhookInput {
|
||||||
|
"""Event names, family.* or *"""
|
||||||
|
events: [String!]!
|
||||||
|
projectId: ID!
|
||||||
|
url: String!
|
||||||
|
}
|
||||||
|
|
||||||
type CreatedResolved {
|
type CreatedResolved {
|
||||||
created: Int!
|
created: Int!
|
||||||
resolved: Int!
|
resolved: Int!
|
||||||
@@ -465,6 +472,7 @@ type Mutation {
|
|||||||
|
|
||||||
"""Plan a sprint (owners and admins)"""
|
"""Plan a sprint (owners and admins)"""
|
||||||
createSprint(input: CreateSprintInput!): CommandResult!
|
createSprint(input: CreateSprintInput!): CommandResult!
|
||||||
|
createWebhook(input: CreateWebhookInput!): WebhookCreated!
|
||||||
|
|
||||||
"""Author or project admin"""
|
"""Author or project admin"""
|
||||||
deleteComment(commentId: ID!): CommandResult!
|
deleteComment(commentId: ID!): CommandResult!
|
||||||
@@ -479,6 +487,7 @@ type Mutation {
|
|||||||
|
|
||||||
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
||||||
deleteSprint(sprintId: ID!): CommandResult!
|
deleteSprint(sprintId: ID!): CommandResult!
|
||||||
|
deleteWebhook(webhookId: ID!): Boolean!
|
||||||
disableGitIntegration(projectId: ID!): Boolean!
|
disableGitIntegration(projectId: ID!): Boolean!
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
@@ -522,6 +531,9 @@ type Mutation {
|
|||||||
|
|
||||||
"""Start a planned sprint (only one under way per project)"""
|
"""Start a planned sprint (only one under way per project)"""
|
||||||
startSprint(sprintId: ID!): CommandResult!
|
startSprint(sprintId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Send a ping now and tell how it went"""
|
||||||
|
testWebhook(webhookId: ID!): WebhookDelivery!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
"""Stop following a ticket"""
|
"""Stop following a ticket"""
|
||||||
@@ -539,6 +551,7 @@ type Mutation {
|
|||||||
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||||
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
||||||
updateSprint(input: UpdateSprintInput!): CommandResult!
|
updateSprint(input: UpdateSprintInput!): CommandResult!
|
||||||
|
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
||||||
@@ -776,6 +789,10 @@ type Query {
|
|||||||
|
|
||||||
"""Search users by name or email (to assign / add members)"""
|
"""Search users by name or email (to assign / add members)"""
|
||||||
users(limit: Int! = 20, search: String! = ""): [User!]!
|
users(limit: Int! = 20, search: String! = ""): [User!]!
|
||||||
|
|
||||||
|
"""Event names a webhook can subscribe to (also family.* and *)"""
|
||||||
|
webhookEvents: [String!]!
|
||||||
|
webhooks(projectId: ID!): [Webhook!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input RankIssueInput {
|
input RankIssueInput {
|
||||||
@@ -1053,6 +1070,13 @@ input UpdateSprintInput {
|
|||||||
startDate: String
|
startDate: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateWebhookInput {
|
||||||
|
active: Boolean
|
||||||
|
events: [String!]
|
||||||
|
url: String
|
||||||
|
webhookId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
"""User managed by the Pireon identity server"""
|
"""User managed by the Pireon identity server"""
|
||||||
type User {
|
type User {
|
||||||
email: String!
|
email: String!
|
||||||
@@ -1066,6 +1090,39 @@ type UserRef {
|
|||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
Where the project tells another system what happened (signed POST, HMAC-SHA256)
|
||||||
|
"""
|
||||||
|
type Webhook {
|
||||||
|
active: Boolean!
|
||||||
|
createdAt: String!
|
||||||
|
createdBy: UserRef!
|
||||||
|
|
||||||
|
"""Event names, family.* or *"""
|
||||||
|
events: [String!]!
|
||||||
|
id: ID!
|
||||||
|
lastDelivery: WebhookDelivery
|
||||||
|
projectId: ID!
|
||||||
|
url: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookCreated {
|
||||||
|
id: ID!
|
||||||
|
|
||||||
|
"""Signs every delivery. Shown only now: keep it."""
|
||||||
|
secret: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookDelivery {
|
||||||
|
at: String!
|
||||||
|
error: String
|
||||||
|
event: String!
|
||||||
|
ok: Boolean!
|
||||||
|
|
||||||
|
"""HTTP status"""
|
||||||
|
status: Int
|
||||||
|
}
|
||||||
|
|
||||||
type WorkflowStatus {
|
type WorkflowStatus {
|
||||||
"""No new tickets or transitions into it"""
|
"""No new tickets or transitions into it"""
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
|
|||||||
@@ -1136,6 +1136,79 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/webhook-events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Event names a webhook can subscribe to */
|
||||||
|
get: operations["WebhooksController_events"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/projects/{project}/webhooks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** The project's webhooks (owners and admins) */
|
||||||
|
get: operations["WebhooksController_list"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Add a webhook
|
||||||
|
* @description The answer carries the secret, the only time it is shown.
|
||||||
|
*/
|
||||||
|
post: operations["WebhooksController_create"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/outgoing-webhooks/{webhook}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete a webhook */
|
||||||
|
delete: operations["WebhooksController_delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Change the URL or the events, pause or resume */
|
||||||
|
patch: operations["WebhooksController_update"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/outgoing-webhooks/{webhook}/test": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Send a ping now and tell how it went */
|
||||||
|
post: operations["WebhooksController_test"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -2098,6 +2171,61 @@ export interface components {
|
|||||||
/** @description Tickets that got something new */
|
/** @description Tickets that got something new */
|
||||||
recorded: number;
|
recorded: number;
|
||||||
};
|
};
|
||||||
|
WebhookDeliveryDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
at: string;
|
||||||
|
/** @example issue.created */
|
||||||
|
event: string;
|
||||||
|
/** @description HTTP status */
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
WebhookDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
projectId: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdBy: components["schemas"]["UserRefDto"];
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
lastDelivery: components["schemas"]["WebhookDeliveryDto"] | null;
|
||||||
|
};
|
||||||
|
CreateWebhookDto: {
|
||||||
|
/** @example https://chat.example.org/hooks/taskstream */
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* @description Event names (GET /api/webhook-events), family.* or *
|
||||||
|
* @example [
|
||||||
|
* "issue.status_changed",
|
||||||
|
* "release.*"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
events: string[];
|
||||||
|
};
|
||||||
|
WebhookCreatedDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @description Signs every delivery. Shown only now. */
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
UpdateWebhookDto: {
|
||||||
|
/** @example https://chat.example.org/hooks/taskstream */
|
||||||
|
url?: string;
|
||||||
|
/**
|
||||||
|
* @description Event names (GET /api/webhook-events), family.* or *
|
||||||
|
* @example [
|
||||||
|
* "issue.status_changed",
|
||||||
|
* "release.*"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
events?: string[];
|
||||||
|
/** @description Paused webhooks get nothing */
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -7539,4 +7667,388 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
WebhooksController_events: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WebhooksController_list: {
|
||||||
|
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"]["WebhookDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WebhooksController_create: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateWebhookDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["WebhookCreatedDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WebhooksController_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
webhook: 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WebhooksController_update: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
webhook: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateWebhookDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["WebhookDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WebhooksController_test: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
webhook: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["WebhookDeliveryDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ services:
|
|||||||
SMTP_URL: ${SMTP_URL:-}
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
MAIL_FROM: ${MAIL_FROM:-}
|
MAIL_FROM: ${MAIL_FROM:-}
|
||||||
WEB_URL: ${WEB_URL:-}
|
WEB_URL: ${WEB_URL:-}
|
||||||
|
# Outgoing webhooks to private addresses (off: they could reach internal services).
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
|
||||||
volumes:
|
volumes:
|
||||||
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
||||||
- attachments-data:/data/attachments
|
- attachments-data:/data/attachments
|
||||||
|
|||||||
@@ -27,6 +27,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 |
|
| 📎 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) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||||
|
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ stateDiagram-v2
|
|||||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
||||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
||||||
event store.
|
event store.
|
||||||
|
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
|
||||||
|
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
|
||||||
|
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
|
||||||
|
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
|
||||||
|
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
|
||||||
|
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
||||||
|
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
||||||
|
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
||||||
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||||
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||||
@@ -259,6 +267,7 @@ que funcionan también sin token.
|
|||||||
| `publicProjects` | Proyectos públicos (sin token) |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
||||||
|
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
|
||||||
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
||||||
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
||||||
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
||||||
@@ -276,6 +285,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
|
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||||
@@ -489,6 +499,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ donde los lee Compose.
|
|||||||
## 7.2 Variables de entorno de la API
|
## 7.2 Variables de entorno de la API
|
||||||
|
|
||||||
| Variable | Por defecto | Descripción |
|
| Variable | Por defecto | Descripción |
|
||||||
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | `3000` | Puerto HTTP |
|
| `PORT` | `3000` | Puerto HTTP |
|
||||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||||
@@ -98,6 +98,7 @@ donde los lee Compose.
|
|||||||
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
||||||
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||||
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||||
|
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||||
|
|
||||||
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export * from './lib/notifications';
|
|||||||
export * from './lib/saved-filters.service';
|
export * from './lib/saved-filters.service';
|
||||||
export * from './lib/sprints.service';
|
export * from './lib/sprints.service';
|
||||||
export * from './lib/git.service';
|
export * from './lib/git.service';
|
||||||
|
export * from './lib/webhooks.service';
|
||||||
|
export * from './lib/webhooks/webhooks';
|
||||||
|
export * from './lib/webhooks/webhook-events';
|
||||||
export * from './lib/git/git';
|
export * from './lib/git/git';
|
||||||
export * from './lib/git/git-payloads';
|
export * from './lib/git/git-payloads';
|
||||||
export * from './lib/saved-filters/saved-filters';
|
export * from './lib/saved-filters/saved-filters';
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ export * from './saved-filters.repository';
|
|||||||
export * from './find-sprints.repository';
|
export * from './find-sprints.repository';
|
||||||
export * from './upsert-materialized-sprint.repository';
|
export * from './upsert-materialized-sprint.repository';
|
||||||
export * from './git-integrations.repository';
|
export * from './git-integrations.repository';
|
||||||
|
export * from './outgoing-webhooks.repository';
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
export interface WebhookDelivery {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
/** HTTP status, null when it never answered. */
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a project tells other systems what happened in it. */
|
||||||
|
export interface OutgoingWebhook {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
url: string;
|
||||||
|
/** Signs every delivery (HMAC-SHA256); shown once, when created. */
|
||||||
|
secret: string;
|
||||||
|
/** Event names, `issue.*` or `*`. */
|
||||||
|
events: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdBy: UserRefReadModel;
|
||||||
|
createdAt: string;
|
||||||
|
lastDelivery: WebhookDelivery | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuration, not history: kept outside the event store. */
|
||||||
|
export abstract class OutgoingWebhooksRepository {
|
||||||
|
abstract findByProject(projectId: string): Promise<OutgoingWebhook[]>;
|
||||||
|
abstract findActive(projectId: string): Promise<OutgoingWebhook[]>;
|
||||||
|
abstract findById(id: string): Promise<OutgoingWebhook | null>;
|
||||||
|
abstract insert(webhook: OutgoingWebhook): Promise<void>;
|
||||||
|
abstract update(
|
||||||
|
id: string,
|
||||||
|
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||||
|
): Promise<void>;
|
||||||
|
abstract delete(id: string): Promise<void>;
|
||||||
|
abstract recordDelivery(id: string, delivery: WebhookDelivery): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends one delivery. Throws when the receiver cannot be reached. */
|
||||||
|
export abstract class WebhookSender {
|
||||||
|
abstract send(request: {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}): Promise<{ status: number }>;
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import { SavedFiltersService } from './saved-filters.service';
|
|||||||
import { SprintsService } from './sprints.service';
|
import { SprintsService } from './sprints.service';
|
||||||
import { GitService } from './git.service';
|
import { GitService } from './git.service';
|
||||||
import { GIT_HANDLERS } from './git/git';
|
import { GIT_HANDLERS } from './git/git';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
||||||
|
|
||||||
/** Notifications: derived from events, kept and dismissed per person. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
const NOTIFICATIONS = [
|
const NOTIFICATIONS = [
|
||||||
@@ -56,6 +58,7 @@ const SERVICES = [
|
|||||||
SavedFiltersService,
|
SavedFiltersService,
|
||||||
SprintsService,
|
SprintsService,
|
||||||
GitService,
|
GitService,
|
||||||
|
WebhooksService,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +82,7 @@ const SERVICES = [
|
|||||||
...NOTIFICATIONS,
|
...NOTIFICATIONS,
|
||||||
...SAVED_FILTERS,
|
...SAVED_FILTERS,
|
||||||
...GIT_HANDLERS,
|
...GIT_HANDLERS,
|
||||||
|
...WEBHOOK_HANDLERS,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
})
|
})
|
||||||
|
|||||||
62
libs/api/tracker/application/src/lib/webhooks.service.ts
Normal file
62
libs/api/tracker/application/src/lib/webhooks.service.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { WebhookDelivery } from './ports/outgoing-webhooks.repository';
|
||||||
|
import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events';
|
||||||
|
import {
|
||||||
|
CreateWebhookCommand,
|
||||||
|
DeleteWebhookCommand,
|
||||||
|
GetWebhooksQuery,
|
||||||
|
TestWebhookCommand,
|
||||||
|
UpdateWebhookCommand,
|
||||||
|
WebhookView,
|
||||||
|
} from './webhooks/webhooks';
|
||||||
|
|
||||||
|
/** The project's outgoing webhooks (owners and admins). */
|
||||||
|
@Injectable()
|
||||||
|
export class WebhooksService {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Names other systems can subscribe to (plus `family.*` and `*`). */
|
||||||
|
events(): string[] {
|
||||||
|
return WEBHOOK_EVENT_NAMES;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(viewerId: string, projectId: string): Promise<WebhookView[]> {
|
||||||
|
return this.queryBus.execute(new GetWebhooksQuery(viewerId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The secret comes back only here. */
|
||||||
|
create(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
url: string,
|
||||||
|
events: string[],
|
||||||
|
): Promise<{ id: string; secret: string }> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new CreateWebhookCommand(actorId, projectId, url, events),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
actorId: string,
|
||||||
|
webhookId: string,
|
||||||
|
changes: { url?: string; events?: string[]; active?: boolean },
|
||||||
|
): Promise<WebhookView> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new UpdateWebhookCommand(actorId, webhookId, changes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(actorId: string, webhookId: string): Promise<void> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new DeleteWebhookCommand(actorId, webhookId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test(actorId: string, webhookId: string): Promise<WebhookDelivery> {
|
||||||
|
return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
CommentAddedEvent,
|
||||||
|
CommentDeletedEvent,
|
||||||
|
CommentEditedEvent,
|
||||||
|
IssueAssignedEvent,
|
||||||
|
IssueCreatedEvent,
|
||||||
|
IssueDeletedEvent,
|
||||||
|
IssueDevLinkRecordedEvent,
|
||||||
|
IssueReleaseChangedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
IssueStatusChangedEvent,
|
||||||
|
IssueUpdatedEvent,
|
||||||
|
ReleaseArchivedEvent,
|
||||||
|
ReleaseCreatedEvent,
|
||||||
|
ReleaseShippedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
TrackerEvent,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { Type } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** What other systems can subscribe to, by the event that causes it. */
|
||||||
|
export const WEBHOOK_EVENTS: [Type<TrackerEvent>, string][] = [
|
||||||
|
[IssueCreatedEvent, 'issue.created'],
|
||||||
|
[IssueUpdatedEvent, 'issue.updated'],
|
||||||
|
[IssueStatusChangedEvent, 'issue.status_changed'],
|
||||||
|
[IssueAssignedEvent, 'issue.assigned'],
|
||||||
|
[IssueSprintChangedEvent, 'issue.sprint_changed'],
|
||||||
|
[IssueReleaseChangedEvent, 'issue.release_changed'],
|
||||||
|
[IssueDevLinkRecordedEvent, 'issue.dev_link'],
|
||||||
|
[IssueDeletedEvent, 'issue.deleted'],
|
||||||
|
[CommentAddedEvent, 'comment.added'],
|
||||||
|
[CommentEditedEvent, 'comment.edited'],
|
||||||
|
[CommentDeletedEvent, 'comment.deleted'],
|
||||||
|
[ReleaseCreatedEvent, 'release.created'],
|
||||||
|
[ReleaseShippedEvent, 'release.shipped'],
|
||||||
|
[ReleaseArchivedEvent, 'release.archived'],
|
||||||
|
[SprintStartedEvent, 'sprint.started'],
|
||||||
|
[SprintClosedEvent, 'sprint.closed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEBHOOK_EVENT_NAMES = WEBHOOK_EVENTS.map(([, name]) => name);
|
||||||
|
|
||||||
|
export function webhookEventOf(event: TrackerEvent): string | null {
|
||||||
|
return WEBHOOK_EVENTS.find(([type]) => event instanceof type)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `*`, an exact name, or a family (`issue.*`). */
|
||||||
|
export function subscribes(events: string[], name: string): boolean {
|
||||||
|
return events.some(
|
||||||
|
(e) =>
|
||||||
|
e === '*' ||
|
||||||
|
e === name ||
|
||||||
|
(e.endsWith('.*') && name.startsWith(e.slice(0, -1))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps known names and families, once each. */
|
||||||
|
export function normalizeWebhookEvents(events: string[]): string[] {
|
||||||
|
const families = new Set(
|
||||||
|
WEBHOOK_EVENT_NAMES.map((n) => `${n.split('.')[0]}.*`),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
events
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e === '*' || families.has(e) || WEBHOOK_EVENT_NAMES.includes(e),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
OutgoingWebhook,
|
||||||
|
OutgoingWebhooksRepository,
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookSender,
|
||||||
|
} from '../ports/outgoing-webhooks.repository';
|
||||||
|
import { normalizeWebhookEvents, subscribes } from './webhook-events';
|
||||||
|
import { WebhookDispatcher } from './webhooks';
|
||||||
|
|
||||||
|
const webhook: OutgoingWebhook = {
|
||||||
|
id: 'w1',
|
||||||
|
projectId: 'p1',
|
||||||
|
url: 'https://example.org/hook',
|
||||||
|
secret: 's3cret',
|
||||||
|
events: ['issue.*'],
|
||||||
|
active: true,
|
||||||
|
createdBy: { id: 'u', name: 'U' },
|
||||||
|
createdAt: '',
|
||||||
|
lastDelivery: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function doubles(statuses: (number | Error)[]) {
|
||||||
|
const recorded: WebhookDelivery[] = [];
|
||||||
|
const sent: { body: string; headers: Record<string, string> }[] = [];
|
||||||
|
const repo = {
|
||||||
|
recordDelivery: async (_id: string, d: WebhookDelivery) => {
|
||||||
|
recorded.push(d);
|
||||||
|
},
|
||||||
|
} as unknown as OutgoingWebhooksRepository;
|
||||||
|
const sender: WebhookSender = {
|
||||||
|
send: async (request) => {
|
||||||
|
sent.push(request);
|
||||||
|
const next = statuses.shift() ?? 200;
|
||||||
|
if (next instanceof Error) throw next;
|
||||||
|
return { status: next };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { repo, sender, recorded, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('outgoing webhooks', () => {
|
||||||
|
it('subscribes to names, families and everything', () => {
|
||||||
|
expect(subscribes(['issue.*'], 'issue.created')).toBe(true);
|
||||||
|
expect(subscribes(['issue.*'], 'comment.added')).toBe(false);
|
||||||
|
expect(subscribes(['*'], 'sprint.closed')).toBe(true);
|
||||||
|
expect(
|
||||||
|
normalizeWebhookEvents(['issue.*', 'nope', ' sprint.closed ', 'issue.*']),
|
||||||
|
).toEqual(['issue.*', 'sprint.closed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs the body and retries until it gets through', async () => {
|
||||||
|
const { repo, sender, recorded, sent } = doubles([
|
||||||
|
new Error('ECONNRESET'),
|
||||||
|
503,
|
||||||
|
204,
|
||||||
|
]);
|
||||||
|
const delivery = await new WebhookDispatcher(
|
||||||
|
repo,
|
||||||
|
sender,
|
||||||
|
[0, 0, 0],
|
||||||
|
).deliver(webhook, 'issue.created', { data: { title: 'x' } });
|
||||||
|
expect(sent).toHaveLength(3);
|
||||||
|
const { body, headers } = sent[0];
|
||||||
|
expect(headers['x-taskstream-signature']).toBe(
|
||||||
|
`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(body)).toMatchObject({
|
||||||
|
event: 'issue.created',
|
||||||
|
data: { title: 'x' },
|
||||||
|
});
|
||||||
|
expect(delivery).toMatchObject({ ok: true, status: 204 });
|
||||||
|
expect(recorded).toEqual([delivery]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up at once on a 4xx', async () => {
|
||||||
|
const { repo, sender, sent } = doubles([410]);
|
||||||
|
const delivery = await new WebhookDispatcher(
|
||||||
|
repo,
|
||||||
|
sender,
|
||||||
|
[0, 0, 0],
|
||||||
|
).deliver(webhook, 'issue.created', {});
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(delivery).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
status: 410,
|
||||||
|
error: 'HTTP 410',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
411
libs/api/tracker/application/src/lib/webhooks/webhooks.ts
Normal file
411
libs/api/tracker/application/src/lib/webhooks/webhooks.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandHandler,
|
||||||
|
EventsHandler,
|
||||||
|
ICommandHandler,
|
||||||
|
IEventHandler,
|
||||||
|
IQueryHandler,
|
||||||
|
Query,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
NotFoundError,
|
||||||
|
assertThat,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from '@task-stream/api/tracker/domain';
|
||||||
|
import { WriteAccess } from '../commands/write-access';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import {
|
||||||
|
OutgoingWebhook,
|
||||||
|
OutgoingWebhooksRepository,
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookSender,
|
||||||
|
} from '../ports/outgoing-webhooks.repository';
|
||||||
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
import {
|
||||||
|
WEBHOOK_EVENTS,
|
||||||
|
normalizeWebhookEvents,
|
||||||
|
subscribes,
|
||||||
|
webhookEventOf,
|
||||||
|
} from './webhook-events';
|
||||||
|
|
||||||
|
/** A project keeps at most this many webhooks. */
|
||||||
|
export const MAX_WEBHOOKS = 20;
|
||||||
|
/** Waits between attempts: right away, after 2 s, after 10 s. */
|
||||||
|
export const RETRY_DELAYS_MS = [0, 2_000, 10_000];
|
||||||
|
|
||||||
|
/** The webhook as listed: the secret is only ever shown when created. */
|
||||||
|
export type WebhookView = Omit<OutgoingWebhook, 'secret'>;
|
||||||
|
const view = ({ secret: _secret, ...rest }: OutgoingWebhook): WebhookView =>
|
||||||
|
rest;
|
||||||
|
|
||||||
|
function assertUrl(url: string): string {
|
||||||
|
const next = url.trim();
|
||||||
|
let parsed: URL | null = null;
|
||||||
|
try {
|
||||||
|
parsed = new URL(next);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
assertThat(
|
||||||
|
!!parsed && ['http:', 'https:'].includes(parsed.protocol),
|
||||||
|
'A webhook needs an http(s) URL',
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateWebhookCommand extends Command<{
|
||||||
|
id: string;
|
||||||
|
secret: string;
|
||||||
|
}> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly url: string,
|
||||||
|
public readonly events: string[],
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateWebhookCommand extends Command<WebhookView> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly webhookId: string,
|
||||||
|
public readonly changes: {
|
||||||
|
url?: string;
|
||||||
|
events?: string[];
|
||||||
|
active?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteWebhookCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly webhookId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a `ping` now and tells how it went. */
|
||||||
|
export class TestWebhookCommand extends Command<WebhookDelivery> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly webhookId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetWebhooksQuery extends Query<WebhookView[]> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs and sends deliveries, retrying a couple of times, and records the outcome. */
|
||||||
|
export class WebhookDispatcher {
|
||||||
|
private readonly logger = new Logger(WebhookDispatcher.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly sender: WebhookSender,
|
||||||
|
private readonly delays: number[] = RETRY_DELAYS_MS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async deliver(
|
||||||
|
webhook: OutgoingWebhook,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<WebhookDelivery> {
|
||||||
|
const deliveryId = randomUUID();
|
||||||
|
const body = JSON.stringify({ event, deliveryId, ...payload });
|
||||||
|
const signature = createHmac('sha256', webhook.secret)
|
||||||
|
.update(body)
|
||||||
|
.digest('hex');
|
||||||
|
let delivery: WebhookDelivery = {
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
event,
|
||||||
|
status: null,
|
||||||
|
ok: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
for (const delay of this.delays) {
|
||||||
|
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
try {
|
||||||
|
const { status } = await this.sender.send({
|
||||||
|
url: webhook.url,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': 'TaskStream-Webhooks',
|
||||||
|
'x-taskstream-event': event,
|
||||||
|
'x-taskstream-delivery': deliveryId,
|
||||||
|
'x-taskstream-signature': `sha256=${signature}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
delivery = {
|
||||||
|
...delivery,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
status,
|
||||||
|
ok: status < 300,
|
||||||
|
error: status < 300 ? null : `HTTP ${status}`,
|
||||||
|
};
|
||||||
|
// A 4xx will not get better by retrying.
|
||||||
|
if (delivery.ok || (status >= 400 && status < 500)) break;
|
||||||
|
} catch (error) {
|
||||||
|
delivery = {
|
||||||
|
...delivery,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
status: null,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!delivery.ok)
|
||||||
|
this.logger.warn(
|
||||||
|
`Webhook ${webhook.id} (${event}) failed: ${delivery.error}`,
|
||||||
|
);
|
||||||
|
await this.webhooks.recordDelivery(webhook.id, delivery);
|
||||||
|
return delivery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whoever manages the project of a webhook. */
|
||||||
|
async function managedWebhook(
|
||||||
|
webhooks: OutgoingWebhooksRepository,
|
||||||
|
access: WriteAccess,
|
||||||
|
webhookId: string,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<OutgoingWebhook> {
|
||||||
|
const webhook = await webhooks.findById(webhookId);
|
||||||
|
if (!webhook) throw new NotFoundError('Webhook', webhookId);
|
||||||
|
(await access.project(webhook.projectId)).assertCanManage(actorId);
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(CreateWebhookCommand)
|
||||||
|
export class CreateWebhookCommandHandler
|
||||||
|
implements ICommandHandler<CreateWebhookCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly users: UserDirectory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CreateWebhookCommand) {
|
||||||
|
(await this.access.project(command.projectId)).assertCanManage(
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
const events = normalizeWebhookEvents(command.events);
|
||||||
|
assertThat(events.length > 0, 'Pick at least one event');
|
||||||
|
if (
|
||||||
|
(await this.webhooks.findByProject(command.projectId)).length >=
|
||||||
|
MAX_WEBHOOKS
|
||||||
|
)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`A project keeps at most ${MAX_WEBHOOKS} webhooks`,
|
||||||
|
);
|
||||||
|
const users = await this.users.refs([command.actorId]);
|
||||||
|
const webhook: OutgoingWebhook = {
|
||||||
|
id: randomUUID(),
|
||||||
|
projectId: command.projectId,
|
||||||
|
url: assertUrl(command.url),
|
||||||
|
secret: randomBytes(24).toString('hex'),
|
||||||
|
events,
|
||||||
|
active: true,
|
||||||
|
createdBy: users.of(command.actorId),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastDelivery: null,
|
||||||
|
};
|
||||||
|
await this.webhooks.insert(webhook);
|
||||||
|
return { id: webhook.id, secret: webhook.secret };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(UpdateWebhookCommand)
|
||||||
|
export class UpdateWebhookCommandHandler
|
||||||
|
implements ICommandHandler<UpdateWebhookCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: UpdateWebhookCommand): Promise<WebhookView> {
|
||||||
|
const webhook = await managedWebhook(
|
||||||
|
this.webhooks,
|
||||||
|
this.access,
|
||||||
|
command.webhookId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
const changes: { url?: string; events?: string[]; active?: boolean } = {};
|
||||||
|
if (command.changes.url !== undefined)
|
||||||
|
changes.url = assertUrl(command.changes.url);
|
||||||
|
if (command.changes.events !== undefined) {
|
||||||
|
changes.events = normalizeWebhookEvents(command.changes.events);
|
||||||
|
assertThat(changes.events.length > 0, 'Pick at least one event');
|
||||||
|
}
|
||||||
|
if (command.changes.active !== undefined)
|
||||||
|
changes.active = command.changes.active;
|
||||||
|
await this.webhooks.update(webhook.id, changes);
|
||||||
|
return view({ ...webhook, ...changes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(DeleteWebhookCommand)
|
||||||
|
export class DeleteWebhookCommandHandler
|
||||||
|
implements ICommandHandler<DeleteWebhookCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: DeleteWebhookCommand): Promise<void> {
|
||||||
|
const webhook = await managedWebhook(
|
||||||
|
this.webhooks,
|
||||||
|
this.access,
|
||||||
|
command.webhookId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
await this.webhooks.delete(webhook.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(TestWebhookCommand)
|
||||||
|
export class TestWebhookCommandHandler
|
||||||
|
implements ICommandHandler<TestWebhookCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly sender: WebhookSender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: TestWebhookCommand): Promise<WebhookDelivery> {
|
||||||
|
const webhook = await managedWebhook(
|
||||||
|
this.webhooks,
|
||||||
|
this.access,
|
||||||
|
command.webhookId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
// One attempt: someone is waiting for the answer.
|
||||||
|
return new WebhookDispatcher(this.webhooks, this.sender, [0]).deliver(
|
||||||
|
webhook,
|
||||||
|
'ping',
|
||||||
|
{
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
projectId: webhook.projectId,
|
||||||
|
actorId: command.actorId,
|
||||||
|
data: { message: 'Hola desde TaskStream' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetWebhooksQuery)
|
||||||
|
export class GetWebhooksQueryHandler
|
||||||
|
implements IQueryHandler<GetWebhooksQuery, WebhookView[]>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetWebhooksQuery): Promise<WebhookView[]> {
|
||||||
|
(await this.access.project(query.projectId)).assertCanManage(
|
||||||
|
query.viewerId,
|
||||||
|
);
|
||||||
|
return (await this.webhooks.findByProject(query.projectId)).map(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event's own fields: what happened, without the envelope. */
|
||||||
|
function dataOf(event: TrackerEvent): Record<string, unknown> {
|
||||||
|
const {
|
||||||
|
actorId: _actor,
|
||||||
|
aggregateId: _aggregate,
|
||||||
|
projectId: _project,
|
||||||
|
occurredAt: _at,
|
||||||
|
aggregateVersion: _version,
|
||||||
|
...data
|
||||||
|
} = event as unknown as Record<string, unknown>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the project's webhooks about what happened. Deliveries go out in the
|
||||||
|
* background: a slow receiver never holds the events bridge.
|
||||||
|
*/
|
||||||
|
@EventsHandler(...WEBHOOK_EVENTS.map(([type]) => type))
|
||||||
|
export class OutgoingWebhooksEventHandler
|
||||||
|
implements IEventHandler<TrackerEvent>
|
||||||
|
{
|
||||||
|
private readonly dispatcher: WebhookDispatcher;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: OutgoingWebhooksRepository,
|
||||||
|
sender: WebhookSender,
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
) {
|
||||||
|
this.dispatcher = new WebhookDispatcher(webhooks, sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(event: TrackerEvent) {
|
||||||
|
const name = webhookEventOf(event);
|
||||||
|
if (!name) return;
|
||||||
|
const targets = (await this.webhooks.findActive(event.projectId)).filter(
|
||||||
|
(w) => subscribes(w.events, name),
|
||||||
|
);
|
||||||
|
if (!targets.length) return;
|
||||||
|
const project = await this.findProjects.findById(event.projectId);
|
||||||
|
const issueId = event.constructor.name.startsWith('Issue')
|
||||||
|
? event.aggregateId
|
||||||
|
: ((event as { issueId?: string; comment?: { issueId: string } })
|
||||||
|
.issueId ??
|
||||||
|
(event as { comment?: { issueId: string } }).comment?.issueId ??
|
||||||
|
null);
|
||||||
|
const issue = issueId ? await this.findIssues.findById(issueId) : null;
|
||||||
|
const payload = {
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
actorId: event.actorId,
|
||||||
|
project: project
|
||||||
|
? { id: project.id, key: project.key }
|
||||||
|
: { id: event.projectId },
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
version: event.aggregateVersion ?? null,
|
||||||
|
...(issue
|
||||||
|
? { issue: { id: issue.id, key: issue.key, title: issue.title } }
|
||||||
|
: {}),
|
||||||
|
data: dataOf(event),
|
||||||
|
};
|
||||||
|
for (const webhook of targets)
|
||||||
|
void this.dispatcher
|
||||||
|
.deliver(webhook, name, payload)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEBHOOK_HANDLERS = [
|
||||||
|
CreateWebhookCommandHandler,
|
||||||
|
UpdateWebhookCommandHandler,
|
||||||
|
DeleteWebhookCommandHandler,
|
||||||
|
TestWebhookCommandHandler,
|
||||||
|
GetWebhooksQueryHandler,
|
||||||
|
OutgoingWebhooksEventHandler,
|
||||||
|
];
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
OutgoingWebhook,
|
||||||
|
OutgoingWebhooksRepository,
|
||||||
|
WebhookDelivery,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { OutgoingWebhookDoc, ReadModels, fromDoc } from './read-model.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.OutgoingWebhook)
|
||||||
|
private readonly webhooks: Model<OutgoingWebhookDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByProject(projectId: string): Promise<OutgoingWebhook[]> {
|
||||||
|
const docs = await this.webhooks
|
||||||
|
.find({ projectId })
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.lean<OutgoingWebhookDoc[]>();
|
||||||
|
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActive(projectId: string): Promise<OutgoingWebhook[]> {
|
||||||
|
const docs = await this.webhooks
|
||||||
|
.find({ projectId, active: true })
|
||||||
|
.lean<OutgoingWebhookDoc[]>();
|
||||||
|
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<OutgoingWebhook | null> {
|
||||||
|
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
|
||||||
|
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
|
||||||
|
await this.webhooks.create({ _id: id, ...rest });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (Object.keys(changes).length)
|
||||||
|
await this.webhooks.updateOne({ _id: id }, { $set: changes });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.webhooks.deleteOne({ _id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
|
||||||
|
await this.webhooks.updateOne(
|
||||||
|
{ _id: id },
|
||||||
|
{ $set: { lastDelivery: delivery } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OutgoingWebhook } from '@task-stream/api/tracker/application';
|
||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
@@ -30,6 +31,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
|||||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||||
|
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||||
export interface GitIntegrationDoc {
|
export interface GitIntegrationDoc {
|
||||||
_id: string;
|
_id: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -52,6 +54,7 @@ export const ReadModels = {
|
|||||||
SavedFilter: 'SavedFilter',
|
SavedFilter: 'SavedFilter',
|
||||||
Sprint: 'SprintReadModel',
|
Sprint: 'SprintReadModel',
|
||||||
GitIntegration: 'GitIntegration',
|
GitIntegration: 'GitIntegration',
|
||||||
|
OutgoingWebhook: 'OutgoingWebhook',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const userRef = { _id: false, id: String, name: String };
|
const userRef = { _id: false, id: String, name: String };
|
||||||
@@ -203,6 +206,18 @@ export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
|||||||
{ ...loose, collection: 'git_integrations' },
|
{ ...loose, collection: 'git_integrations' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||||
|
{
|
||||||
|
_id: String,
|
||||||
|
projectId: { type: String, index: true },
|
||||||
|
active: Boolean,
|
||||||
|
events: [String],
|
||||||
|
createdBy: userRef,
|
||||||
|
createdAt: String,
|
||||||
|
},
|
||||||
|
{ ...loose, collection: 'outgoing_webhooks' },
|
||||||
|
);
|
||||||
|
|
||||||
export const READ_MODEL_SCHEMAS = [
|
export const READ_MODEL_SCHEMAS = [
|
||||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||||
@@ -210,6 +225,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||||
{ name: ReadModels.Sprint, schema: SprintSchema },
|
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||||
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
|
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
|
||||||
|
{ name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema },
|
||||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
ReleaseDoc,
|
ReleaseDoc,
|
||||||
SavedFilterDoc,
|
SavedFilterDoc,
|
||||||
GitIntegrationDoc,
|
GitIntegrationDoc,
|
||||||
|
OutgoingWebhookDoc,
|
||||||
TimelineDoc,
|
TimelineDoc,
|
||||||
} from './read-model.schemas';
|
} from './read-model.schemas';
|
||||||
import { upsertIfNewer } from './upsert-if-newer';
|
import { upsertIfNewer } from './upsert-if-newer';
|
||||||
@@ -52,6 +53,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
|||||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||||
@InjectModel(ReadModels.GitIntegration)
|
@InjectModel(ReadModels.GitIntegration)
|
||||||
private readonly gitIntegrations: Model<GitIntegrationDoc>,
|
private readonly gitIntegrations: Model<GitIntegrationDoc>,
|
||||||
|
@InjectModel(ReadModels.OutgoingWebhook)
|
||||||
|
private readonly outgoingWebhooks: Model<OutgoingWebhookDoc>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
|||||||
// Views of the board of a project that is gone.
|
// Views of the board of a project that is gone.
|
||||||
this.savedFilters.deleteMany({ projectId }),
|
this.savedFilters.deleteMany({ projectId }),
|
||||||
this.gitIntegrations.deleteOne({ _id: projectId }),
|
this.gitIntegrations.deleteOne({ _id: projectId }),
|
||||||
|
this.outgoingWebhooks.deleteMany({ projectId }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
NotificationsRepository,
|
NotificationsRepository,
|
||||||
SavedFiltersRepository,
|
SavedFiltersRepository,
|
||||||
GitIntegrationsRepository,
|
GitIntegrationsRepository,
|
||||||
|
OutgoingWebhooksRepository,
|
||||||
|
WebhookSender,
|
||||||
FindActivityRepository,
|
FindActivityRepository,
|
||||||
FindCommentsRepository,
|
FindCommentsRepository,
|
||||||
FindIssuesRepository,
|
FindIssuesRepository,
|
||||||
@@ -62,6 +64,8 @@ import { RedisChangeFeed } from './realtime/redis-change-feed';
|
|||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||||
|
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||||
|
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||||
import {
|
import {
|
||||||
MongoFindSprintsRepository,
|
MongoFindSprintsRepository,
|
||||||
MongoUpsertMaterializedSprintRepository,
|
MongoUpsertMaterializedSprintRepository,
|
||||||
@@ -127,6 +131,11 @@ const PORTS = [
|
|||||||
provide: GitIntegrationsRepository,
|
provide: GitIntegrationsRepository,
|
||||||
useClass: MongoGitIntegrationsRepository,
|
useClass: MongoGitIntegrationsRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: OutgoingWebhooksRepository,
|
||||||
|
useClass: MongoOutgoingWebhooksRepository,
|
||||||
|
},
|
||||||
|
{ provide: WebhookSender, useClass: HttpWebhookSender },
|
||||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||||
// Realtime → Redis pub/sub
|
// Realtime → Redis pub/sub
|
||||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPrivateAddress } from './http-webhook.sender';
|
||||||
|
|
||||||
|
describe('webhook sender', () => {
|
||||||
|
it('knows private and loopback addresses', () => {
|
||||||
|
for (const address of [
|
||||||
|
'127.0.0.1',
|
||||||
|
'10.1.2.3',
|
||||||
|
'172.20.0.5',
|
||||||
|
'192.168.1.9',
|
||||||
|
'169.254.169.254',
|
||||||
|
'::1',
|
||||||
|
'fd00::1',
|
||||||
|
'::ffff:127.0.0.1',
|
||||||
|
])
|
||||||
|
expect(isPrivateAddress(address)).toBe(true);
|
||||||
|
for (const address of ['8.8.8.8', '172.32.0.1', '2a00:1450::1'])
|
||||||
|
expect(isPrivateAddress(address)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { WebhookSender } from '@task-stream/api/tracker/application';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
/** Loopback, private, link-local and unique-local addresses. */
|
||||||
|
export function isPrivateAddress(address: string): boolean {
|
||||||
|
if (isIP(address) === 4) {
|
||||||
|
const [a, b] = address.split('.').map(Number);
|
||||||
|
return (
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
a === 0 ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const v6 = address.toLowerCase();
|
||||||
|
return (
|
||||||
|
v6 === '::1' ||
|
||||||
|
v6 === '::' ||
|
||||||
|
v6.startsWith('fe80:') ||
|
||||||
|
v6.startsWith('fc') ||
|
||||||
|
v6.startsWith('fd') ||
|
||||||
|
(v6.startsWith('::ffff:') && isPrivateAddress(v6.slice(7)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts deliveries with fetch, 5 s at most. Unless WEBHOOKS_ALLOW_PRIVATE is
|
||||||
|
* true, refuses hosts that resolve to this machine or its private network:
|
||||||
|
* a webhook must not become a way to reach internal services.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HttpWebhookSender extends WebhookSender {
|
||||||
|
private readonly allowPrivate: boolean;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super();
|
||||||
|
this.allowPrivate =
|
||||||
|
String(config.get('WEBHOOKS_ALLOW_PRIVATE') ?? '').toLowerCase() ===
|
||||||
|
'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(request: {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}): Promise<{ status: number }> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (!this.allowPrivate) {
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const addresses = isIP(host)
|
||||||
|
? [host]
|
||||||
|
: (await lookup(host, { all: true })).map((a) => a.address);
|
||||||
|
if (addresses.some(isPrivateAddress))
|
||||||
|
throw new Error(`${url.hostname} is a private address`);
|
||||||
|
}
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
return { status: response.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,6 +275,33 @@ export class SaveFilterInput {
|
|||||||
criteria!: SavedFilterCriteriaInput;
|
criteria!: SavedFilterCriteriaInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class CreateWebhookInput {
|
||||||
|
@Field(() => ID) @IsUUID() projectId!: string;
|
||||||
|
@Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string;
|
||||||
|
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
events!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class UpdateWebhookInput {
|
||||||
|
@Field(() => ID) @IsUUID() webhookId!: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_tld: false, require_protocol: true })
|
||||||
|
url?: string;
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
events?: string[];
|
||||||
|
@Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class RankIssueInput {
|
export class RankIssueInput {
|
||||||
@Field(() => ID) @IsUUID() issueId!: string;
|
@Field(() => ID) @IsUUID() issueId!: string;
|
||||||
|
|||||||
@@ -170,6 +170,42 @@ export class GitIntegrationGql {
|
|||||||
webhookPath!: string;
|
webhookPath!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('WebhookDelivery')
|
||||||
|
export class WebhookDeliveryGql {
|
||||||
|
@Field() at!: string;
|
||||||
|
@Field() event!: string;
|
||||||
|
@Field(() => Int, { nullable: true, description: 'HTTP status' })
|
||||||
|
status!: number | null;
|
||||||
|
@Field() ok!: boolean;
|
||||||
|
@Field(() => String, { nullable: true }) error!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Webhook', {
|
||||||
|
description:
|
||||||
|
'Where the project tells another system what happened (signed POST, HMAC-SHA256)',
|
||||||
|
})
|
||||||
|
export class WebhookGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field(() => ID) projectId!: string;
|
||||||
|
@Field() url!: string;
|
||||||
|
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||||
|
events!: string[];
|
||||||
|
@Field() active!: boolean;
|
||||||
|
@Field(() => UserRefGql) createdBy!: UserRefGql;
|
||||||
|
@Field() createdAt!: string;
|
||||||
|
@Field(() => WebhookDeliveryGql, { nullable: true })
|
||||||
|
lastDelivery!: WebhookDeliveryGql | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('WebhookCreated')
|
||||||
|
export class WebhookCreatedGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field({
|
||||||
|
description: 'Signs every delivery. Shown only now: keep it.',
|
||||||
|
})
|
||||||
|
secret!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||||
export class IssueParentGql {
|
export class IssueParentGql {
|
||||||
@Field(() => ID) id!: string;
|
@Field(() => ID) id!: string;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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 { WebhooksService } from '@task-stream/api/tracker/application';
|
||||||
|
import { CreateWebhookInput, UpdateWebhookInput } from './inputs';
|
||||||
|
import { WebhookCreatedGql, WebhookDeliveryGql, WebhookGql } from './types';
|
||||||
|
|
||||||
|
/** Outgoing webhooks of a project (owners and admins). */
|
||||||
|
@Resolver(() => WebhookGql)
|
||||||
|
export class WebhookResolver {
|
||||||
|
constructor(private readonly webhooksService: WebhooksService) {}
|
||||||
|
|
||||||
|
@Query(() => [WebhookGql])
|
||||||
|
webhooks(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.list(me.id, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [String], {
|
||||||
|
description: 'Event names a webhook can subscribe to (also family.* and *)',
|
||||||
|
})
|
||||||
|
webhookEvents() {
|
||||||
|
return this.webhooksService.events();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => WebhookCreatedGql)
|
||||||
|
createWebhook(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: CreateWebhookInput,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.create(
|
||||||
|
me.id,
|
||||||
|
input.projectId,
|
||||||
|
input.url,
|
||||||
|
input.events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => WebhookGql)
|
||||||
|
updateWebhook(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: UpdateWebhookInput,
|
||||||
|
) {
|
||||||
|
const { webhookId, url, events, active } = input;
|
||||||
|
return this.webhooksService.update(me.id, webhookId, {
|
||||||
|
...(url != null && { url }),
|
||||||
|
...(events != null && { events }),
|
||||||
|
...(active != null && { active }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean)
|
||||||
|
async deleteWebhook(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||||
|
) {
|
||||||
|
await this.webhooksService.delete(me.id, webhookId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => WebhookDeliveryGql, {
|
||||||
|
description: 'Send a ping now and tell how it went',
|
||||||
|
})
|
||||||
|
testWebhook(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||||
|
) {
|
||||||
|
return this.webhooksService.test(me.id, webhookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -731,6 +731,29 @@ export class SaveFilterDto {
|
|||||||
criteria!: SavedFilterCriteriaDto;
|
criteria!: SavedFilterCriteriaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateWebhookDto {
|
||||||
|
@ApiProperty({ example: 'https://chat.example.org/hooks/taskstream' })
|
||||||
|
@IsUrl({ require_tld: false, require_protocol: true })
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
example: ['issue.status_changed', 'release.*'],
|
||||||
|
description: 'Event names (GET /api/webhook-events), family.* or *',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
events!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
||||||
|
@ApiPropertyOptional({ description: 'Paused webhooks get nothing' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class RankIssueDto {
|
export class RankIssueDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -167,6 +167,33 @@ export class GitIntegrationDto {
|
|||||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WebhookDeliveryDto {
|
||||||
|
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||||
|
@ApiProperty({ example: 'issue.created' }) event!: string;
|
||||||
|
@ApiProperty({ type: Number, nullable: true, description: 'HTTP status' })
|
||||||
|
status!: number | null;
|
||||||
|
@ApiProperty() ok!: boolean;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) error!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebhookDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
|
@ApiProperty() url!: string;
|
||||||
|
@ApiProperty({ type: [String] }) events!: string[];
|
||||||
|
@ApiProperty() active!: boolean;
|
||||||
|
@ApiProperty({ type: UserRefDto }) createdBy!: UserRefDto;
|
||||||
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
|
@ApiProperty({ type: WebhookDeliveryDto, nullable: true })
|
||||||
|
lastDelivery!: WebhookDeliveryDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebhookCreatedDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ description: 'Signs every delivery. Shown only now.' })
|
||||||
|
secret!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class GitDeliveryResultDto {
|
export class GitDeliveryResultDto {
|
||||||
@ApiProperty({ description: 'Tickets that got something new' })
|
@ApiProperty({ description: 'Tickets that got something new' })
|
||||||
recorded!: number;
|
recorded!: number;
|
||||||
|
|||||||
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
ApiPireonAuth,
|
||||||
|
CurrentUser,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import {
|
||||||
|
ApiCommandErrors,
|
||||||
|
ApiReadErrors,
|
||||||
|
} from '@task-stream/api/shared/presenters';
|
||||||
|
import { WebhooksService } from '@task-stream/api/tracker/application';
|
||||||
|
import { CreateWebhookDto, UpdateWebhookDto } from './dto/requests.dto';
|
||||||
|
import {
|
||||||
|
WebhookCreatedDto,
|
||||||
|
WebhookDeliveryDto,
|
||||||
|
WebhookDto,
|
||||||
|
} from './dto/responses.dto';
|
||||||
|
import { Refs } from './refs';
|
||||||
|
|
||||||
|
const WEBHOOK = ApiParam({ name: 'webhook', format: 'uuid' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing webhooks: a signed POST to another system when something happens
|
||||||
|
* in the project. Header `X-TaskStream-Signature: sha256=<HMAC of the body>`.
|
||||||
|
*/
|
||||||
|
@ApiTags('Webhooks')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@Controller()
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly refs: Refs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('webhook-events')
|
||||||
|
@ApiOperation({ summary: 'Event names a webhook can subscribe to' })
|
||||||
|
@ApiOkResponse({ type: [String] })
|
||||||
|
events() {
|
||||||
|
return this.webhooks.events();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('projects/:project/webhooks')
|
||||||
|
@ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "The project's webhooks (owners and admins)" })
|
||||||
|
@ApiOkResponse({ type: [WebhookDto] })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||||
|
return this.webhooks.list(me.id, await this.refs.projectId(me.id, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('projects/:project/webhooks')
|
||||||
|
@ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Add a webhook',
|
||||||
|
description: 'The answer carries the secret, the only time it is shown.',
|
||||||
|
})
|
||||||
|
@ApiCreatedResponse({ type: WebhookCreatedDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Body() body: CreateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooks.create(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
body.url,
|
||||||
|
body.events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('outgoing-webhooks/:webhook')
|
||||||
|
@WEBHOOK
|
||||||
|
@ApiOperation({ summary: 'Change the URL or the events, pause or resume' })
|
||||||
|
@ApiOkResponse({ type: WebhookDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
update(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||||
|
@Body() body: UpdateWebhookDto,
|
||||||
|
) {
|
||||||
|
return this.webhooks.update(me.id, webhook, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('outgoing-webhooks/:webhook')
|
||||||
|
@WEBHOOK
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: 'Delete a webhook' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async delete(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||||
|
) {
|
||||||
|
await this.webhooks.delete(me.id, webhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('outgoing-webhooks/:webhook/test')
|
||||||
|
@WEBHOOK
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: 'Send a ping now and tell how it went' })
|
||||||
|
@ApiOkResponse({ type: WebhookDeliveryDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
test(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||||
|
) {
|
||||||
|
return this.webhooks.test(me.id, webhook);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import { SprintResolver } from './graphql/sprint.resolver';
|
|||||||
import { SprintsController } from './http/sprints.controller';
|
import { SprintsController } from './http/sprints.controller';
|
||||||
import { GitController } from './http/git.controller';
|
import { GitController } from './http/git.controller';
|
||||||
import { GitResolver } from './graphql/git.resolver';
|
import { GitResolver } from './graphql/git.resolver';
|
||||||
|
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||||
|
import { WebhooksController } from './http/webhooks.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||||
@@ -54,6 +56,7 @@ import { GitResolver } from './graphql/git.resolver';
|
|||||||
SavedFiltersController,
|
SavedFiltersController,
|
||||||
SprintsController,
|
SprintsController,
|
||||||
GitController,
|
GitController,
|
||||||
|
WebhooksController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
@@ -66,6 +69,7 @@ import { GitResolver } from './graphql/git.resolver';
|
|||||||
SavedFilterResolver,
|
SavedFilterResolver,
|
||||||
SprintResolver,
|
SprintResolver,
|
||||||
GitResolver,
|
GitResolver,
|
||||||
|
WebhookResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrackerPresentersModule {
|
export class TrackerPresentersModule {
|
||||||
|
|||||||
@@ -170,6 +170,27 @@ export interface DevLink {
|
|||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebhookDelivery {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
/** HTTP status, null when it never answered. */
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the project tells another system what happened. */
|
||||||
|
export interface Webhook {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
/** Event names, `family.*` or `*`. */
|
||||||
|
events: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdBy: UserRef;
|
||||||
|
createdAt: string;
|
||||||
|
lastDelivery: WebhookDelivery | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The project's webhook for GitHub, Gitea or GitLab. */
|
/** The project's webhook for GitHub, Gitea or GitLab. */
|
||||||
export interface GitIntegration {
|
export interface GitIntegration {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
|
Webhook,
|
||||||
|
WebhookDelivery,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
Sprint,
|
Sprint,
|
||||||
SprintBurndown,
|
SprintBurndown,
|
||||||
@@ -131,6 +133,24 @@ export abstract class IssuesGateway {
|
|||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The project's outgoing webhooks (owners and admins). */
|
||||||
|
export abstract class WebhooksGateway {
|
||||||
|
abstract list(projectId: string): Observable<Webhook[]>;
|
||||||
|
abstract events(): Observable<string[]>;
|
||||||
|
/** The secret comes back only here. */
|
||||||
|
abstract create(
|
||||||
|
projectId: string,
|
||||||
|
url: string,
|
||||||
|
events: string[],
|
||||||
|
): Observable<{ id: string; secret: string }>;
|
||||||
|
abstract update(
|
||||||
|
webhookId: string,
|
||||||
|
changes: { url?: string; events?: string[]; active?: boolean },
|
||||||
|
): Observable<Webhook>;
|
||||||
|
abstract remove(webhookId: string): Observable<void>;
|
||||||
|
abstract test(webhookId: string): Observable<WebhookDelivery>;
|
||||||
|
}
|
||||||
|
|
||||||
/** The project's Git webhook (owners and admins configure it). */
|
/** The project's Git webhook (owners and admins configure it). */
|
||||||
export abstract class GitGateway {
|
export abstract class GitGateway {
|
||||||
abstract integration(projectId: string): Observable<GitIntegration>;
|
abstract integration(projectId: string): Observable<GitIntegration>;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Webhook,
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhooksGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
CreateWebhookDocument,
|
||||||
|
DeleteWebhookDocument,
|
||||||
|
TestWebhookDocument,
|
||||||
|
UpdateWebhookDocument,
|
||||||
|
WebhookEventsDocument,
|
||||||
|
WebhooksDocument,
|
||||||
|
} from '../graphql/generated';
|
||||||
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GraphqlWebhooksGateway extends WebhooksGateway {
|
||||||
|
private readonly gql = inject(GraphqlOperations);
|
||||||
|
|
||||||
|
list(projectId: string): Observable<Webhook[]> {
|
||||||
|
return this.gql
|
||||||
|
.query(WebhooksDocument, { projectId })
|
||||||
|
.pipe(map((d) => d.webhooks as Webhook[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
events(): Observable<string[]> {
|
||||||
|
return this.gql
|
||||||
|
.query(WebhookEventsDocument, {})
|
||||||
|
.pipe(map((d) => d.webhookEvents));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(
|
||||||
|
projectId: string,
|
||||||
|
url: string,
|
||||||
|
events: string[],
|
||||||
|
): Observable<{ id: string; secret: string }> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(CreateWebhookDocument, { input: { projectId, url, events } })
|
||||||
|
.pipe(map((d) => d.createWebhook));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
webhookId: string,
|
||||||
|
changes: { url?: string; events?: string[]; active?: boolean },
|
||||||
|
): Observable<Webhook> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(UpdateWebhookDocument, { input: { webhookId, ...changes } })
|
||||||
|
.pipe(map((d) => d.updateWebhook as Webhook));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(webhookId: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(DeleteWebhookDocument, { webhookId })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
test(webhookId: string): Observable<WebhookDelivery> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(TestWebhookDocument, { webhookId })
|
||||||
|
.pipe(map((d) => d.testWebhook));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,13 @@ export type CreateSprintInput = {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateWebhookInput = {
|
||||||
|
/** Event names, family.* or * */
|
||||||
|
events: Array<string>;
|
||||||
|
projectId: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
||||||
|
|
||||||
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
||||||
@@ -240,6 +247,13 @@ export type UpdateSprintInput = {
|
|||||||
startDate?: string | null | undefined;
|
startDate?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateWebhookInput = {
|
||||||
|
active?: boolean | null | undefined;
|
||||||
|
events?: Array<string> | null | undefined;
|
||||||
|
url?: string | null | undefined;
|
||||||
|
webhookId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowStatusInput = {
|
export type WorkflowStatusInput = {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
category: StatusCategory;
|
category: StatusCategory;
|
||||||
@@ -1656,6 +1670,106 @@ export type MyProjectsChangedSubscription = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WebhookFieldsFragment = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: Array<string>;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: { id: string; name: string };
|
||||||
|
lastDelivery: {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookDeliveryFieldsFragment = {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhooksQueryVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type WebhooksQuery = {
|
||||||
|
webhooks: Array<{
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: Array<string>;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: { id: string; name: string };
|
||||||
|
lastDelivery: {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookEventsQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
|
export type WebhookEventsQuery = { webhookEvents: Array<string> };
|
||||||
|
|
||||||
|
export type CreateWebhookMutationVariables = Exact<{
|
||||||
|
input: CreateWebhookInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CreateWebhookMutation = {
|
||||||
|
createWebhook: { id: string; secret: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateWebhookMutationVariables = Exact<{
|
||||||
|
input: UpdateWebhookInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UpdateWebhookMutation = {
|
||||||
|
updateWebhook: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: Array<string>;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: { id: string; name: string };
|
||||||
|
lastDelivery: {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteWebhookMutationVariables = Exact<{
|
||||||
|
webhookId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type DeleteWebhookMutation = { deleteWebhook: boolean };
|
||||||
|
|
||||||
|
export type TestWebhookMutationVariables = Exact<{
|
||||||
|
webhookId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type TestWebhookMutation = {
|
||||||
|
testWebhook: {
|
||||||
|
at: string;
|
||||||
|
event: string;
|
||||||
|
status: number | null;
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const UserRefFieldsFragmentDoc = {
|
export const UserRefFieldsFragmentDoc = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -2484,6 +2598,111 @@ export const SprintFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<SprintFieldsFragment, unknown>;
|
} as unknown as DocumentNode<SprintFieldsFragment, unknown>;
|
||||||
|
export const WebhookDeliveryFieldsFragmentDoc = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDelivery' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<WebhookDeliveryFieldsFragment, unknown>;
|
||||||
|
export const WebhookFieldsFragmentDoc = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Webhook' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'createdBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'lastDelivery' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDelivery' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
|
||||||
export const GitIntegrationDocument = {
|
export const GitIntegrationDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -9995,3 +10214,450 @@ export const MyProjectsChangedDocument = {
|
|||||||
MyProjectsChangedSubscription,
|
MyProjectsChangedSubscription,
|
||||||
MyProjectsChangedSubscriptionVariables
|
MyProjectsChangedSubscriptionVariables
|
||||||
>;
|
>;
|
||||||
|
export const WebhooksDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'Webhooks' },
|
||||||
|
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: 'webhooks' },
|
||||||
|
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: 'WebhookFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDelivery' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Webhook' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'createdBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'lastDelivery' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<WebhooksQuery, WebhooksQueryVariables>;
|
||||||
|
export const WebhookEventsDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'WebhookEvents' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'webhookEvents' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<WebhookEventsQuery, WebhookEventsQueryVariables>;
|
||||||
|
export const CreateWebhookDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'CreateWebhook' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CreateWebhookInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'createWebhook' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
CreateWebhookMutation,
|
||||||
|
CreateWebhookMutationVariables
|
||||||
|
>;
|
||||||
|
export const UpdateWebhookDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'UpdateWebhook' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UpdateWebhookInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'updateWebhook' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
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: 'WebhookFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDelivery' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Webhook' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'createdBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'lastDelivery' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
UpdateWebhookMutation,
|
||||||
|
UpdateWebhookMutationVariables
|
||||||
|
>;
|
||||||
|
export const DeleteWebhookDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'DeleteWebhook' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'deleteWebhook' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
DeleteWebhookMutation,
|
||||||
|
DeleteWebhookMutationVariables
|
||||||
|
>;
|
||||||
|
export const TestWebhookDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'TestWebhook' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'testWebhook' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'webhookId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'WebhookDelivery' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<TestWebhookMutation, TestWebhookMutationVariables>;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
fragment WebhookFields on Webhook {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
events
|
||||||
|
active
|
||||||
|
createdAt
|
||||||
|
createdBy {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
lastDelivery {
|
||||||
|
...WebhookDeliveryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment WebhookDeliveryFields on WebhookDelivery {
|
||||||
|
at
|
||||||
|
event
|
||||||
|
status
|
||||||
|
ok
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
query Webhooks($projectId: ID!) {
|
||||||
|
webhooks(projectId: $projectId) {
|
||||||
|
...WebhookFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query WebhookEvents {
|
||||||
|
webhookEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CreateWebhook($input: CreateWebhookInput!) {
|
||||||
|
createWebhook(input: $input) {
|
||||||
|
id
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateWebhook($input: UpdateWebhookInput!) {
|
||||||
|
updateWebhook(input: $input) {
|
||||||
|
...WebhookFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation DeleteWebhook($webhookId: ID!) {
|
||||||
|
deleteWebhook(webhookId: $webhookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation TestWebhook($webhookId: ID!) {
|
||||||
|
testWebhook(webhookId: $webhookId) {
|
||||||
|
...WebhookDeliveryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SavedFiltersGateway,
|
SavedFiltersGateway,
|
||||||
SprintsGateway,
|
SprintsGateway,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
|
WebhooksGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -30,6 +31,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
|||||||
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||||
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||||
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||||
|
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||||
|
|
||||||
/** Ports → GraphQL adapters (and REST for files). */
|
/** Ports → GraphQL adapters (and REST for files). */
|
||||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||||
@@ -46,5 +48,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||||
|
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||||
|
import { WebhooksComponent } from '../ui/webhooks';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -53,6 +54,7 @@ function idFrom(name: string): string {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
GitIntegrationComponent,
|
GitIntegrationComponent,
|
||||||
|
WebhooksComponent,
|
||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDrag,
|
CdkDrag,
|
||||||
CdkDragHandle,
|
CdkDragHandle,
|
||||||
@@ -468,6 +470,7 @@ function idFrom(name: string): string {
|
|||||||
[projectId]="project.id"
|
[projectId]="project.id"
|
||||||
[projectKey]="project.key"
|
[projectKey]="project.key"
|
||||||
/>
|
/>
|
||||||
|
<ts-webhooks [projectId]="project.id" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (facade.isOwner() && facade.project(); as project) {
|
@if (facade.isOwner() && facade.project(); as project) {
|
||||||
|
|||||||
338
libs/web/tracker/presenters/src/lib/ui/webhooks.ts
Normal file
338
libs/web/tracker/presenters/src/lib/ui/webhooks.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { PiActionButtonComponent, PiTextFieldComponent } from '@pi/ui';
|
||||||
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
|
import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
import { timeAgo } from './copy';
|
||||||
|
|
||||||
|
/** What each event means, in the words of the settings page. */
|
||||||
|
const EVENT_LABEL: Record<string, string> = {
|
||||||
|
'issue.created': 'Ticket creado',
|
||||||
|
'issue.updated': 'Ticket editado',
|
||||||
|
'issue.status_changed': 'Cambio de estado',
|
||||||
|
'issue.assigned': 'Asignación',
|
||||||
|
'issue.sprint_changed': 'Entra o sale de un sprint',
|
||||||
|
'issue.release_changed': 'Entra o sale de una release',
|
||||||
|
'issue.dev_link': 'Commit, rama o PR',
|
||||||
|
'issue.deleted': 'Ticket borrado',
|
||||||
|
'comment.added': 'Comentario',
|
||||||
|
'comment.edited': 'Comentario editado',
|
||||||
|
'comment.deleted': 'Comentario borrado',
|
||||||
|
'release.created': 'Release creada',
|
||||||
|
'release.shipped': 'Release publicada',
|
||||||
|
'release.archived': 'Release archivada',
|
||||||
|
'sprint.started': 'Sprint empezado',
|
||||||
|
'sprint.closed': 'Sprint cerrado',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Settings card: signed POSTs to other systems when things happen. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-webhooks',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiActionButtonComponent, PiTextFieldComponent],
|
||||||
|
template: `
|
||||||
|
<section class="ts-panel ts-block ts-hooks" aria-labelledby="hooks-title">
|
||||||
|
<div>
|
||||||
|
<h3 id="hooks-title">Webhooks</h3>
|
||||||
|
<p class="ts-muted">
|
||||||
|
Avisa a otro sistema (un chat, un CI, un script) de lo que pasa en el
|
||||||
|
proyecto: un <code>POST</code> en JSON firmado con
|
||||||
|
<code>X-TaskStream-Signature: sha256=…</code> (HMAC del cuerpo con el
|
||||||
|
secreto).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (secret(); as shown) {
|
||||||
|
<p class="ts-hooks__secret" role="status">
|
||||||
|
Secreto del webhook nuevo (no se volverá a mostrar):
|
||||||
|
<code>{{ shown }}</code>
|
||||||
|
<button type="button" class="ts-plain-link" (click)="copy(shown)">
|
||||||
|
Copiar
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (hook of hooks(); track hook.id) {
|
||||||
|
<article class="ts-hooks__item" [class.is-paused]="!hook.active">
|
||||||
|
<div class="ts-hooks__head">
|
||||||
|
<code class="ts-hooks__url">{{ hook.url }}</code>
|
||||||
|
@if (!hook.active) {
|
||||||
|
<span class="ts-pill">En pausa</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="ts-muted ts-hooks__events">
|
||||||
|
{{ eventsLabel(hook.events) }}
|
||||||
|
</p>
|
||||||
|
@if (hook.lastDelivery; as last) {
|
||||||
|
<p class="ts-hooks__last" [class.is-bad]="!last.ok">
|
||||||
|
Último envío ({{ last.event }}) {{ ago(last.at) }}:
|
||||||
|
{{ last.ok ? 'OK ' + last.status : (last.error ?? 'falló') }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="ts-hooks__actions">
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
text="Probar"
|
||||||
|
(execute)="test(hook)"
|
||||||
|
/>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
[text]="hook.active ? 'Pausar' : 'Reanudar'"
|
||||||
|
(execute)="toggle(hook)"
|
||||||
|
/>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
text="Borrar"
|
||||||
|
(execute)="remove(hook)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="ts-hooks__new"
|
||||||
|
(submit)="$event.preventDefault(); create()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-text-field placeholder="https://…" [(value)]="url" />
|
||||||
|
<fieldset class="ts-hooks__picks">
|
||||||
|
<legend>Eventos</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="picked().includes('*')"
|
||||||
|
(change)="pick('*', $any($event.target).checked)"
|
||||||
|
/>
|
||||||
|
Todos
|
||||||
|
</label>
|
||||||
|
@for (event of events(); track event) {
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[disabled]="picked().includes('*')"
|
||||||
|
[checked]="picked().includes(event)"
|
||||||
|
(change)="pick(event, $any($event.target).checked)"
|
||||||
|
/>
|
||||||
|
{{ label(event) }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
<div>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
text="Añadir webhook"
|
||||||
|
[disabled]="!url().trim() || !picked().length"
|
||||||
|
(execute)="create()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-hooks {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-hooks__secret {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 10%, transparent);
|
||||||
|
code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-hooks__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.625rem 0;
|
||||||
|
border-top: 1px solid var(--ts-line);
|
||||||
|
&.is-paused {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-hooks__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ts-hooks__url {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ts-hooks__events,
|
||||||
|
.ts-hooks__last {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ts-hooks__last.is-bad {
|
||||||
|
color: var(--ts-c-red, var(--ts-ink));
|
||||||
|
}
|
||||||
|
.ts-hooks__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.ts-hooks__new {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--ts-line);
|
||||||
|
}
|
||||||
|
.ts-hooks__picks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
legend {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WebhooksComponent {
|
||||||
|
readonly projectId = input.required<string>();
|
||||||
|
|
||||||
|
private readonly gateway = inject(WebhooksGateway);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
protected readonly hooks = signal<Webhook[]>([]);
|
||||||
|
protected readonly events = signal<string[]>([]);
|
||||||
|
protected readonly url = signal('');
|
||||||
|
protected readonly picked = signal<string[]>(['issue.status_changed']);
|
||||||
|
protected readonly secret = signal<string | null>(null);
|
||||||
|
protected readonly ago = timeAgo;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.gateway
|
||||||
|
.events()
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((events) => this.events.set(events));
|
||||||
|
effect(() => {
|
||||||
|
const projectId = this.projectId();
|
||||||
|
untracked(() => this.reload(projectId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected label = (event: string) => EVENT_LABEL[event] ?? event;
|
||||||
|
protected eventsLabel(events: string[]): string {
|
||||||
|
return events.includes('*')
|
||||||
|
? 'Todos los eventos'
|
||||||
|
: events.map((e) => this.label(e)).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected pick(event: string, on: boolean): void {
|
||||||
|
this.picked.update((list) =>
|
||||||
|
on ? [...new Set([...list, event])] : list.filter((e) => e !== event),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected create(): void {
|
||||||
|
const url = this.url().trim();
|
||||||
|
if (!url || !this.picked().length) return;
|
||||||
|
const events = this.picked().includes('*') ? ['*'] : this.picked();
|
||||||
|
this.gateway
|
||||||
|
.create(this.projectId(), url, events)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: ({ secret }) => {
|
||||||
|
this.secret.set(secret);
|
||||||
|
this.url.set('');
|
||||||
|
this.reload(this.projectId());
|
||||||
|
},
|
||||||
|
error: (error) => this.fail(error, 'No se pudo añadir'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggle(hook: Webhook): void {
|
||||||
|
this.gateway
|
||||||
|
.update(hook.id, { active: !hook.active })
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.reload(this.projectId()),
|
||||||
|
error: (error) => this.fail(error, 'No se pudo cambiar'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected remove(hook: Webhook): void {
|
||||||
|
if (!confirm(`¿Borrar el webhook a ${hook.url}?`)) return;
|
||||||
|
this.gateway
|
||||||
|
.remove(hook.id)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.reload(this.projectId()),
|
||||||
|
error: (error) => this.fail(error, 'No se pudo borrar'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected test(hook: Webhook): void {
|
||||||
|
this.gateway
|
||||||
|
.test(hook.id)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (delivery) => {
|
||||||
|
if (delivery.ok)
|
||||||
|
this.toast.success(`Ping entregado (${delivery.status})`);
|
||||||
|
else
|
||||||
|
this.toast.error(
|
||||||
|
`El ping falló: ${delivery.error ?? 'sin respuesta'}`,
|
||||||
|
);
|
||||||
|
this.reload(this.projectId());
|
||||||
|
},
|
||||||
|
error: (error) => this.fail(error, 'No se pudo probar'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected copy(text: string): void {
|
||||||
|
navigator.clipboard
|
||||||
|
?.writeText(text)
|
||||||
|
.then(() => this.toast.success('Copiado'))
|
||||||
|
.catch(() => this.toast.error('No se pudo copiar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(projectId: string): void {
|
||||||
|
this.gateway
|
||||||
|
.list(projectId)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (hooks) => this.hooks.set(hooks),
|
||||||
|
error: (error) => this.fail(error, 'No se pudieron leer los webhooks'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(error: unknown, fallback: string): void {
|
||||||
|
const message =
|
||||||
|
(error as { message?: string })?.message?.split('\n')[0] ?? fallback;
|
||||||
|
this.toast.error(message || fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user