feat: several Git repositories per project

A project keeps a list of repositories (api, web, admin…), each with its
own secret, instead of a single webhook secret. They all call the same
URL; the secret that verifies the signature tells which one it was, and
every commit, branch or pull request is linked with its repository's
name (shown on the ticket and in the MCP). Rotating or removing one
leaves the others alone, and what it linked stays.

A project that had turned the single webhook on keeps its secret, as a
repository named "principal".

API: the enable/disable endpoints and mutations give way to adding a
repository, rotating its secret and removing it (REST and GraphQL). The
web settings card lists the repositories. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:39:59 +02:00
parent e6ac02d328
commit c2928d2385
31 changed files with 1451 additions and 301 deletions

View File

@@ -8087,13 +8087,16 @@
"pireon": [] "pireon": []
} }
], ],
"summary": "Whether the Git webhook is on (and its secret, for admins)", "summary": "The project's repositories and the webhook URL they share (secrets, for admins)",
"tags": [ "tags": [
"Git" "Git"
] ]
}, }
},
"/api/projects/{project}/git-integration/repositories": {
"post": { "post": {
"operationId": "GitController_enable", "description": "Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.",
"operationId": "GitController_addRepository",
"parameters": [ "parameters": [
{ {
"name": "project", "name": "project",
@@ -8106,13 +8109,23 @@
} }
} }
], ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddGitRepositoryDto"
}
}
}
},
"responses": { "responses": {
"201": { "201": {
"description": "", "description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/GitIntegrationDto" "$ref": "#/components/schemas/GitRepositoryDto"
} }
} }
} }
@@ -8186,13 +8199,15 @@
"pireon": [] "pireon": []
} }
], ],
"summary": "Turn the Git webhook on with a new secret (or rotate it)", "summary": "Add a repository, with its own secret (owners and admins)",
"tags": [ "tags": [
"Git" "Git"
] ]
}, }
"delete": { },
"operationId": "GitController_disable", "/api/projects/{project}/git-integration/repositories/{repository}/secret": {
"post": {
"operationId": "GitController_rotateSecret",
"parameters": [ "parameters": [
{ {
"name": "project", "name": "project",
@@ -8203,6 +8218,125 @@
"example": "WEB", "example": "WEB",
"type": "string" "type": "string"
} }
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
}
},
"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": "Give a repository a new secret (the old one stops working)",
"tags": [
"Git"
]
}
},
"/api/projects/{project}/git-integration/repositories/{repository}": {
"delete": {
"operationId": "GitController_removeRepository",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -8278,7 +8412,7 @@
"pireon": [] "pireon": []
} }
], ],
"summary": "Turn the Git webhook off", "summary": "Remove a repository: its webhooks are refused, what it linked stays",
"tags": [ "tags": [
"Git" "Git"
] ]
@@ -8286,7 +8420,7 @@
}, },
"/api/webhooks/git/{project}": { "/api/webhooks/git/{project}": {
"post": { "post": {
"description": "Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.", "description": "Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.",
"operationId": "GitController_receive", "operationId": "GitController_receive",
"parameters": [ "parameters": [
{ {
@@ -10179,6 +10313,12 @@
"type": "string", "type": "string",
"example": "gitea" "example": "gitea"
}, },
"repository": {
"type": "string",
"nullable": true,
"example": "api",
"description": "The project's name for the repository it came from"
},
"author": { "author": {
"type": "string", "type": "string",
"nullable": true "nullable": true
@@ -10195,6 +10335,7 @@
"ref", "ref",
"state", "state",
"provider", "provider",
"repository",
"author", "author",
"at" "at"
] ]
@@ -12324,26 +12465,71 @@
"sprintId" "sprintId"
] ]
}, },
"GitIntegrationDto": { "GitRepositoryDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": { "id": {
"type": "boolean" "type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"example": "api"
}, },
"secret": { "secret": {
"type": "string", "type": "string",
"nullable": true, "nullable": true,
"description": "Webhook secret; only owners and admins see it" "description": "Webhook secret of this repository; only owners and admins see it"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"secret",
"createdAt"
]
},
"GitIntegrationDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "On while there is at least one repository"
}, },
"webhookPath": { "webhookPath": {
"type": "string", "type": "string",
"example": "/api/webhooks/git/3f2c…" "example": "/api/webhooks/git/3f2c…",
"description": "The same for every repository of the project"
},
"repositories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
} }
}, },
"required": [ "required": [
"enabled", "enabled",
"secret", "webhookPath",
"webhookPath" "repositories"
]
},
"AddGitRepositoryDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "api",
"maxLength": 40,
"description": "The project's name for it (api, web, admin…); unique"
}
},
"required": [
"name"
] ]
}, },
"GitDeliveryResultDto": { "GitDeliveryResultDto": {

View File

@@ -335,6 +335,9 @@ type DevLink {
"""Short SHA, branch or PR number""" """Short SHA, branch or PR number"""
ref: String! ref: String!
"""The project's name for the repository it came from"""
repository: String
state: DevLinkState state: DevLinkState
title: String! title: String!
url: String! url: String!
@@ -372,18 +375,29 @@ type FlowSeries {
} }
""" """
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
""" """
type GitIntegration { type GitIntegration {
"""On while there is at least one repository"""
enabled: Boolean! enabled: Boolean!
repositories: [GitRepository!]!
"""Webhook secret; only owners and admins see it""" """Path of the webhook on this API, the same for every repository"""
secret: String
"""Path of the webhook on this API"""
webhookPath: String! webhookPath: String!
} }
"""A repository whose webhooks the project accepts"""
type GitRepository {
createdAt: String!
id: ID!
"""The project's name for it: api, web, admin…"""
name: String!
"""Webhook secret of this repository; only owners and admins see it"""
secret: String
}
type Issue { type Issue {
"""Statuses this ticket can move to next (project's workflow)""" """Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]! allowedTransitions: [String!]!
@@ -590,6 +604,11 @@ type Mutation {
addChecklistItem(input: AddChecklistItemInput!): CommandResult! addChecklistItem(input: AddChecklistItemInput!): CommandResult!
addComment(body: String!, issueId: ID!): CommandResult! addComment(body: String!, issueId: ID!): CommandResult!
"""
Add a repository with its own secret (the first turns the integration on)
"""
addGitRepository(name: String!, projectId: ID!): GitRepository!
"""Add a Pireon user to the project""" """Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult! archiveRelease(releaseId: ID!): CommandResult!
@@ -638,12 +657,8 @@ 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! deleteWebhook(webhookId: ID!): Boolean!
disableGitIntegration(projectId: ID!): Boolean!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
enableGitIntegration(projectId: ID!): GitIntegration!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult! linkIssues(input: LinkIssuesInput!): CommandResult!
@@ -663,11 +678,17 @@ type Mutation {
""" """
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult! removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
"""Remove a repository; what it linked stays on the tickets"""
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Back from the trash""" """Back from the trash"""
restoreIssue(issueId: ID!): CommandResult! restoreIssue(issueId: ID!): CommandResult!
"""A new secret for a repository; the old one stops working"""
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
"""Save the current board filters under a name (members of the project)""" """Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID! saveFilter(input: SaveFilterInput!): ID!

View File

@@ -1182,13 +1182,65 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Whether the Git webhook is on (and its secret, for admins) */ /** The project's repositories and the webhook URL they share (secrets, for admins) */
get: operations["GitController_integration"]; get: operations["GitController_integration"];
put?: never; put?: never;
/** Turn the Git webhook on with a new secret (or rotate it) */ post?: never;
post: operations["GitController_enable"]; delete?: never;
/** Turn the Git webhook off */ options?: never;
delete: operations["GitController_disable"]; head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Add a repository, with its own secret (owners and admins)
* @description Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.
*/
post: operations["GitController_addRepository"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Give a repository a new secret (the old one stops working) */
post: operations["GitController_rotateSecret"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove a repository: its webhooks are refused, what it linked stays */
delete: operations["GitController_removeRepository"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
@@ -1205,7 +1257,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Webhook for GitHub, Gitea or GitLab (called by the Git host) * Webhook for GitHub, Gitea or GitLab (called by the Git host)
* @description Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored. * @description Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.
*/ */
post: operations["GitController_receive"]; post: operations["GitController_receive"];
delete?: never; delete?: never;
@@ -1672,6 +1724,11 @@ export interface components {
state: components["schemas"]["DevLinkState"] | null; state: components["schemas"]["DevLinkState"] | null;
/** @example gitea */ /** @example gitea */
provider: string; provider: string;
/**
* @description The project's name for the repository it came from
* @example api
*/
repository: string | null;
author: string | null; author: string | null;
/** Format: date-time */ /** Format: date-time */
at: string; at: string;
@@ -2380,12 +2437,32 @@ export interface components {
*/ */
sprintId: string | null; sprintId: string | null;
}; };
GitIntegrationDto: { GitRepositoryDto: {
enabled: boolean; /** Format: uuid */
/** @description Webhook secret; only owners and admins see it */ id: string;
/** @example api */
name: string;
/** @description Webhook secret of this repository; only owners and admins see it */
secret: string | null; secret: string | null;
/** @example /api/webhooks/git/3f2c… */ /** Format: date-time */
createdAt: string;
};
GitIntegrationDto: {
/** @description On while there is at least one repository */
enabled: boolean;
/**
* @description The same for every repository of the project
* @example /api/webhooks/git/3f2c…
*/
webhookPath: string; webhookPath: string;
repositories: components["schemas"]["GitRepositoryDto"][];
};
AddGitRepositoryDto: {
/**
* @description The project's name for it (api, web, admin…); unique
* @example api
*/
name: string;
}; };
GitDeliveryResultDto: { GitDeliveryResultDto: {
/** @description Tickets that got something new */ /** @description Tickets that got something new */
@@ -8054,7 +8131,7 @@ export interface operations {
}; };
}; };
}; };
GitController_enable: { GitController_addRepository: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -8064,14 +8141,18 @@ export interface operations {
}; };
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody: {
content: {
"application/json": components["schemas"]["AddGitRepositoryDto"];
};
};
responses: { responses: {
201: { 201: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["GitIntegrationDto"]; "application/json": components["schemas"]["GitRepositoryDto"];
}; };
}; };
/** @description Invalid input */ /** @description Invalid input */
@@ -8130,13 +8211,93 @@ export interface operations {
}; };
}; };
}; };
GitController_disable: { GitController_rotateSecret: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
/** @description Project id or key */ /** @description Project id or key */
project: string; project: string;
/** @description Repository id */
repository: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GitRepositoryDto"];
};
};
/** @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"];
};
};
};
};
GitController_removeRepository: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description Repository id */
repository: string;
}; };
cookie?: never; cookie?: never;
}; };

View File

@@ -250,6 +250,7 @@ export class IssuesResolver {
links: issue.links, links: issue.links,
devLinks: issue.devLinks.map((d) => ({ devLinks: issue.devLinks.map((d) => ({
kind: d.kind, kind: d.kind,
repository: d.repository,
ref: d.ref, ref: d.ref,
title: d.title, title: d.title,
state: d.state, state: d.state,

View File

@@ -160,13 +160,16 @@ stateDiagram-v2
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden. columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host - **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el `admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`, como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR
event store. fusionada). Proyecto desconocido, sin repositorios o firma mala responden igual: 403. Cambiar el secreto de un
repositorio o quitarlo no afecta a los demás, y lo ya enlazado se queda. Los repositorios viven en MongoDB
(`git_integrations`, un documento por proyecto), no en el event store; un proyecto que activó el webhook único de
antes conserva su secreto como repositorio «principal».
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de - **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
@@ -322,7 +325,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| 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` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| 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)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
@@ -537,7 +540,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | 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 /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` | | Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
| 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` |

Binary file not shown.

Binary file not shown.

View File

@@ -42,6 +42,7 @@ describe('automation rules', () => {
ref: '#1', ref: '#1',
state: DevLinkState.MERGED, state: DevLinkState.MERGED,
provider: 'github', provider: 'github',
repository: null,
author: null, author: null,
at: '', at: '',
}); });

View File

@@ -9,6 +9,7 @@ import {
ConcurrencyError, ConcurrencyError,
ForbiddenError, ForbiddenError,
NotFoundError, NotFoundError,
ValidationError,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
import { import {
Comment, Comment,
@@ -40,7 +41,14 @@ import {
StartSprintCommand, StartSprintCommand,
} from './sprint.commands'; } from './sprint.commands';
import { IssueTree } from './issue-tree'; import { IssueTree } from './issue-tree';
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git'; import {
AddGitRepositoryCommand,
GIT_HANDLERS,
GitRepository,
RecordGitMentionsCommand,
RemoveGitRepositoryCommand,
RotateGitRepositorySecretCommand,
} from '../git/git';
import { RunAutomationCommand } from '../automations/automations'; import { RunAutomationCommand } from '../automations/automations';
import { RunAutomationCommandHandler } from '../automations/automations'; import { RunAutomationCommandHandler } from '../automations/automations';
import { import {
@@ -48,7 +56,10 @@ import {
AutomationTriggerKind, AutomationTriggerKind,
} from '../automations/automation-rules'; } from '../automations/automation-rules';
import { Automation } from '../ports/automations.repository'; import { Automation } from '../ports/automations.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository'; import {
GitIntegrationsRepository,
GitRepositoryConfig,
} from '../ports/git-integrations.repository';
import { DevLinkKind } from '@task-stream/api/tracker/domain'; import { DevLinkKind } from '@task-stream/api/tracker/domain';
import { SetIssueParentCommand } from './set-issue-parent.command'; import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command'; import { RankIssueCommand } from './rank-issue.command';
@@ -86,6 +97,14 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() { async function setup() {
const store = new InMemoryEventStore(); const store = new InMemoryEventStore();
// The project's Git repositories, as Mongo would keep them.
const gitRepositories = new Map<string, GitRepositoryConfig[]>();
const git: GitIntegrationsRepository = {
repositoriesOf: async (projectId) => gitRepositories.get(projectId) ?? [],
saveAll: async (projectId, list) => {
gitRepositories.set(projectId, list);
},
};
const keys = new Map<string, { id: string; projectId: string }>(); const keys = new Map<string, { id: string; projectId: string }>();
const sprints = { const sprints = {
active: null as { id: string; name: string } | null, active: null as { id: string; name: string } | null,
@@ -112,7 +131,7 @@ async function setup() {
provide: FindProjectsRepository, provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null }, useValue: { findRole: async () => null, findById: async () => null },
}, },
{ provide: GitIntegrationsRepository, useValue: {} }, { provide: GitIntegrationsRepository, useValue: git },
AggregateRehydrator, AggregateRehydrator,
{ provide: EventStore, useValue: store }, { provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files }, { provide: AttachmentStorage, useValue: files },
@@ -157,6 +176,7 @@ async function setup() {
files, files,
sprints, sprints,
keys, keys,
git,
run, run,
rehydrator: moduleRef.get(AggregateRehydrator), rehydrator: moduleRef.get(AggregateRehydrator),
}; };
@@ -520,16 +540,52 @@ describe('tracker commands (event sourced)', () => {
ref: 'abc', ref: 'abc',
state: null, state: null,
provider: 'gitea', provider: 'gitea',
repository: null,
author: 'Ana', author: 'Ana',
at: '2026-10-08T10:00:00Z', at: '2026-10-08T10:00:00Z',
}, },
}; };
expect( expect(
await run(new RecordGitMentionsCommand(projectId, [commit, commit])), await run(
new RecordGitMentionsCommand(projectId, 'web', [commit, commit]),
),
).toBe(1); ).toBe(1);
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue); const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
expect(devLinks.map((l) => l.ref)).toEqual(['abc']); expect(devLinks.map((l) => [l.ref, l.repository])).toEqual([
['abc', 'web'],
]);
});
it('keeps several Git repositories, each with its own secret', async () => {
const { run, git } = await setup();
const projectId = await projectWithTeam(run);
const add = (actor: string, name: string) =>
run(
new AddGitRepositoryCommand(actor, projectId, name),
) as unknown as Promise<GitRepository>;
const api = await add('owner', 'api');
const web = await add('owner', ' web ');
expect(web.name).toBe('web');
expect(api.secret).toMatch(/^[0-9a-f]{48}$/);
expect(web.secret).not.toBe(api.secret);
await expect(add('owner', 'API')).rejects.toThrow(/already/);
await expect(add('owner', '')).rejects.toThrow(ValidationError);
await expect(add('dev', 'admin')).rejects.toThrow(ForbiddenError);
const rotated = (await run(
new RotateGitRepositorySecretCommand('owner', projectId, api.id),
)) as unknown as GitRepository;
expect(rotated.secret).not.toBe(api.secret);
await run(new RemoveGitRepositoryCommand('owner', projectId, web.id));
await expect(
run(new RemoveGitRepositoryCommand('owner', projectId, web.id)),
).rejects.toThrow(NotFoundError);
expect(await git.repositoriesOf(projectId)).toEqual([
expect.objectContaining({ name: 'api', secret: rotated.secret }),
]);
}); });
it('runs automation actions as TaskStream, within the workflow', async () => { it('runs automation actions as TaskStream, within the workflow', async () => {

View File

@@ -0,0 +1,81 @@
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { createHmac } from 'node:crypto';
import { RecordGitMentionsCommand } from './git/git';
import { GitService } from './git.service';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
const push = {
ref: 'refs/heads/main',
repository: { html_url: 'https://git.example.org/acme/api' },
commits: [
{
id: 'abc1234',
message: 'WEB-7: pay by card',
url: 'https://git.example.org/acme/api/commit/abc1234',
timestamp: '2026-10-09T10:00:00Z',
author: { name: 'Ana', username: 'ana' },
},
],
};
describe('GitService', () => {
const executed: object[] = [];
const git = new GitService(
{
execute: async (command: object) => {
executed.push(command);
return 1;
},
} as unknown as CommandBus,
{} as QueryBus,
{
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),
findByKey: async (key: string) => (key === 'WEB' ? { id: 'p1' } : null),
} as unknown as FindProjectsRepository,
{
repositoriesOf: async () => [
{ id: 'r1', name: 'web', secret: 'web-secret', createdAt: '' },
{ id: 'r2', name: 'api', secret: 'api-secret', createdAt: '' },
],
saveAll: async () => undefined,
} as GitIntegrationsRepository,
);
/** A Gitea delivery signed with `secret`. */
const delivery = (secret: string, project = 'WEB') => {
const rawBody = Buffer.from(JSON.stringify(push));
return {
project,
rawBody,
body: push,
headers: {
'x-gitea-event': 'push',
'x-gitea-signature': createHmac('sha256', secret)
.update(rawBody)
.digest('hex'),
},
};
};
beforeEach(() => (executed.length = 0));
it('tells the repository by the secret its webhook was signed with', async () => {
expect(await git.receive(delivery('api-secret'))).toEqual({ recorded: 1 });
expect(executed).toEqual([
expect.objectContaining({ projectId: 'p1', repository: 'api' }),
]);
expect(executed[0]).toBeInstanceOf(RecordGitMentionsCommand);
});
it('refuses a secret no repository of the project has, or another project', async () => {
await expect(git.receive(delivery('stolen'))).rejects.toThrow(
ForbiddenError,
);
await expect(git.receive(delivery('api-secret', 'NOPE'))).rejects.toThrow(
ForbiddenError,
);
expect(executed).toEqual([]);
});
});

View File

@@ -2,11 +2,13 @@ import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain'; import { ForbiddenError } from '@task-stream/api/shared/domain';
import { import {
DisableGitIntegrationCommand, AddGitRepositoryCommand,
EnableGitIntegrationCommand,
GetGitIntegrationQuery, GetGitIntegrationQuery,
GitIntegration, GitIntegration,
GitRepository,
RecordGitMentionsCommand, RecordGitMentionsCommand,
RemoveGitRepositoryCommand,
RotateGitRepositorySecretCommand,
} from './git/git'; } from './git/git';
import { import {
gitMentionsOf, gitMentionsOf,
@@ -40,41 +42,59 @@ export class GitService {
); );
} }
enable(actorId: string, projectId: string): Promise<GitIntegration> { addRepository(
actorId: string,
projectId: string,
name: string,
): Promise<GitRepository> {
return this.commandBus.execute( return this.commandBus.execute(
new EnableGitIntegrationCommand(actorId, projectId), new AddGitRepositoryCommand(actorId, projectId, name),
); );
} }
disable(actorId: string, projectId: string): Promise<void> { rotateSecret(
actorId: string,
projectId: string,
repositoryId: string,
): Promise<GitRepository> {
return this.commandBus.execute( return this.commandBus.execute(
new DisableGitIntegrationCommand(actorId, projectId), new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
);
}
removeRepository(
actorId: string,
projectId: string,
repositoryId: string,
): Promise<void> {
return this.commandBus.execute(
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
); );
} }
/** /**
* A webhook delivery. Unknown project, integration off or a bad signature * A webhook delivery. Every repository of the project shares the URL; the
* all look the same from outside (403): nothing to learn by probing. * secret that verifies the signature says which one it came from. Unknown
* project, integration off or a bad signature all look the same from
* outside (403): nothing to learn by probing.
*/ */
async receive(delivery: GitDelivery): Promise<{ recorded: number }> { async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
const project = const project =
(await this.findProjects.findById(delivery.project)) ?? (await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase())); (await this.findProjects.findByKey(delivery.project.toUpperCase()));
const secret = project
? await this.integrations.secretOf(project.id)
: null;
const source = gitProviderOf(delivery.headers); const source = gitProviderOf(delivery.headers);
if ( const repository =
!project || project && source
!secret || ? (await this.integrations.repositoriesOf(project.id)).find((r) =>
!source || verifyGitSignature(
!verifyGitSignature( source.provider,
source.provider, delivery.headers,
delivery.headers, delivery.rawBody,
delivery.rawBody, r.secret,
secret, ),
) )
) : undefined;
if (!project || !source || !repository)
throw new ForbiddenError( throw new ForbiddenError(
'Unknown project, integration off or bad signature', 'Unknown project, integration off or bad signature',
); );
@@ -85,7 +105,7 @@ export class GitService {
); );
if (!mentions.length) return { recorded: 0 }; if (!mentions.length) return { recorded: 0 };
const recorded: number = await this.commandBus.execute( const recorded: number = await this.commandBus.execute(
new RecordGitMentionsCommand(project.id, mentions), new RecordGitMentionsCommand(project.id, repository.name, mentions),
); );
return { recorded }; return { recorded };
} }

View File

@@ -82,6 +82,7 @@ function fromPush(
ref: String(commit['id']).slice(0, 8), ref: String(commit['id']).slice(0, 8),
state: null, state: null,
provider, provider,
repository: null,
author: commit['author']?.['name'] ?? null, author: commit['author']?.['name'] ?? null,
at: String(commit['timestamp'] ?? new Date().toISOString()), at: String(commit['timestamp'] ?? new Date().toISOString()),
}, },
@@ -103,6 +104,7 @@ function fromPush(
ref: branch, ref: branch,
state: null, state: null,
provider, provider,
repository: null,
author: author:
body['pusher']?.['name'] ?? body['pusher']?.['name'] ??
body['pusher']?.['login'] ?? body['pusher']?.['login'] ??
@@ -148,6 +150,7 @@ export function gitMentionsOf(
ref: `#${pr['number'] ?? ''}`, ref: `#${pr['number'] ?? ''}`,
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')), state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
provider, provider,
repository: null,
author: pr['user']?.['login'] ?? null, author: pr['user']?.['login'] ?? null,
at: String(pr['updated_at'] ?? new Date().toISOString()), at: String(pr['updated_at'] ?? new Date().toISOString()),
}, },
@@ -168,6 +171,7 @@ export function gitMentionsOf(
ref: `!${mr['iid'] ?? ''}`, ref: `!${mr['iid'] ?? ''}`,
state: prState(false, String(mr['state'] ?? 'opened')), state: prState(false, String(mr['state'] ?? 'opened')),
provider, provider,
repository: null,
author: body['user']?.['username'] ?? null, author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()), at: String(mr['updated_at'] ?? new Date().toISOString()),
}, },

View File

@@ -1,4 +1,4 @@
import { randomBytes } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { import {
Command, Command,
CommandHandler, CommandHandler,
@@ -7,6 +7,7 @@ import {
Query, Query,
QueryHandler, QueryHandler,
} from '@nestjs/cqrs'; } from '@nestjs/cqrs';
import { NotFoundError, assertThat } from '@task-stream/api/shared/domain';
import { import {
Issue, Issue,
ProjectRole, ProjectRole,
@@ -15,30 +16,61 @@ import {
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access'; import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository'; import {
GitIntegrationsRepository,
GitRepositoryConfig,
} from '../ports/git-integrations.repository';
import { ReadAccess } from '../queries/read-access'; import { ReadAccess } from '../queries/read-access';
import { GitMention } from './git-payloads'; import { GitMention } from './git-payloads';
export interface GitIntegration { export interface GitRepository {
enabled: boolean; id: string;
/** The project's name for it: api, web, admin… */
name: string;
/** Only for whoever manages the project. */ /** Only for whoever manages the project. */
secret: string | null; secret: string | null;
createdAt: string;
} }
/** Turns the integration on with a new secret (or rotates it). */ export interface GitIntegration {
export class EnableGitIntegrationCommand extends Command<GitIntegration> { /** On while there is at least one repository. */
enabled: boolean;
repositories: GitRepository[];
}
/** A project takes this many repositories at most. */
export const MAX_GIT_REPOSITORIES = 20;
const newSecret = () => randomBytes(24).toString('hex');
/** Adds a repository with its own secret (turning the integration on). */
export class AddGitRepositoryCommand extends Command<GitRepository> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly projectId: string, public readonly projectId: string,
public readonly name: string,
) { ) {
super(); super();
} }
} }
export class DisableGitIntegrationCommand extends Command<void> { /** A new secret for one repository; the old one stops working at once. */
export class RotateGitRepositorySecretCommand extends Command<GitRepository> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly projectId: string, public readonly projectId: string,
public readonly repositoryId: string,
) {
super();
}
}
/** Its webhooks are refused from then on; what it linked stays. */
export class RemoveGitRepositoryCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly repositoryId: string,
) { ) {
super(); super();
} }
@@ -48,6 +80,8 @@ export class DisableGitIntegrationCommand extends Command<void> {
export class RecordGitMentionsCommand extends Command<number> { export class RecordGitMentionsCommand extends Command<number> {
constructor( constructor(
public readonly projectId: string, public readonly projectId: string,
/** Name of the repository whose secret signed the delivery. */
public readonly repository: string,
public readonly mentions: GitMention[], public readonly mentions: GitMention[],
) { ) {
super(); super();
@@ -63,39 +97,100 @@ export class GetGitIntegrationQuery extends Query<GitIntegration> {
} }
} }
@CommandHandler(EnableGitIntegrationCommand) @CommandHandler(AddGitRepositoryCommand)
export class EnableGitIntegrationCommandHandler export class AddGitRepositoryCommandHandler
implements ICommandHandler<EnableGitIntegrationCommand> implements ICommandHandler<AddGitRepositoryCommand>
{ {
constructor( constructor(
private readonly access: WriteAccess, private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository, private readonly integrations: GitIntegrationsRepository,
) {} ) {}
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> { async execute(command: AddGitRepositoryCommand): Promise<GitRepository> {
(await this.access.project(command.projectId)).assertCanManage( (await this.access.project(command.projectId)).assertCanManage(
command.actorId, command.actorId,
); );
const secret = randomBytes(24).toString('hex'); const name = command.name.trim();
await this.integrations.save(command.projectId, secret); assertThat(
return { enabled: true, secret }; name.length > 0 && name.length <= 40,
'A repository needs a name of 1 to 40 characters',
);
const repositories = await this.integrations.repositoriesOf(
command.projectId,
);
assertThat(
repositories.length < MAX_GIT_REPOSITORIES,
`A project takes ${MAX_GIT_REPOSITORIES} repositories at most`,
);
assertThat(
!repositories.some((r) => r.name.toLowerCase() === name.toLowerCase()),
`There is already a repository named "${name}"`,
);
const added: GitRepositoryConfig = {
id: randomUUID(),
name,
secret: newSecret(),
createdAt: new Date().toISOString(),
};
await this.integrations.saveAll(command.projectId, [
...repositories,
added,
]);
return added;
} }
} }
@CommandHandler(DisableGitIntegrationCommand) @CommandHandler(RotateGitRepositorySecretCommand)
export class DisableGitIntegrationCommandHandler export class RotateGitRepositorySecretCommandHandler
implements ICommandHandler<DisableGitIntegrationCommand> implements ICommandHandler<RotateGitRepositorySecretCommand>
{ {
constructor( constructor(
private readonly access: WriteAccess, private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository, private readonly integrations: GitIntegrationsRepository,
) {} ) {}
async execute(command: DisableGitIntegrationCommand): Promise<void> { async execute(
command: RotateGitRepositorySecretCommand,
): Promise<GitRepository> {
(await this.access.project(command.projectId)).assertCanManage( (await this.access.project(command.projectId)).assertCanManage(
command.actorId, command.actorId,
); );
await this.integrations.remove(command.projectId); const repositories = await this.integrations.repositoriesOf(
command.projectId,
);
const found = repositories.find((r) => r.id === command.repositoryId);
if (!found) throw new NotFoundError('Repository', command.repositoryId);
const rotated = { ...found, secret: newSecret() };
await this.integrations.saveAll(
command.projectId,
repositories.map((r) => (r.id === found.id ? rotated : r)),
);
return rotated;
}
}
@CommandHandler(RemoveGitRepositoryCommand)
export class RemoveGitRepositoryCommandHandler
implements ICommandHandler<RemoveGitRepositoryCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: RemoveGitRepositoryCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const repositories = await this.integrations.repositoriesOf(
command.projectId,
);
if (!repositories.some((r) => r.id === command.repositoryId))
throw new NotFoundError('Repository', command.repositoryId);
await this.integrations.saveAll(
command.projectId,
repositories.filter((r) => r.id !== command.repositoryId),
);
} }
} }
@@ -119,7 +214,10 @@ export class RecordGitMentionsCommandHandler
const issue = await this.access.live(found.id, Issue); const issue = await this.access.live(found.id, Issue);
if (!issue) continue; if (!issue) continue;
const before = issue.getUncommittedEvents().length; const before = issue.getUncommittedEvents().length;
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link); issue.recordDevLink(SYSTEM_ACTOR_ID, {
...mention.link,
repository: command.repository,
});
if (issue.getUncommittedEvents().length === before) continue; if (issue.getUncommittedEvents().length === before) continue;
await issue.commitAndWait(); await issue.commitAndWait();
recorded++; recorded++;
@@ -143,15 +241,24 @@ export class GetGitIntegrationQueryHandler
query.projectId, query.projectId,
query.viewerId, query.viewerId,
); );
const secret = await this.integrations.secretOf(query.projectId); const repositories = await this.integrations.repositoriesOf(
query.projectId,
);
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN; const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { enabled: !!secret, secret: manages ? secret : null }; return {
enabled: repositories.length > 0,
repositories: repositories.map((r) => ({
...r,
secret: manages ? r.secret : null,
})),
};
} }
} }
export const GIT_HANDLERS = [ export const GIT_HANDLERS = [
EnableGitIntegrationCommandHandler, AddGitRepositoryCommandHandler,
DisableGitIntegrationCommandHandler, RotateGitRepositorySecretCommandHandler,
RemoveGitRepositoryCommandHandler,
RecordGitMentionsCommandHandler, RecordGitMentionsCommandHandler,
GetGitIntegrationQueryHandler, GetGitIntegrationQueryHandler,
]; ];

View File

@@ -1,9 +1,22 @@
/** A repository of a project: its name there (api, web…) and its secret. */
export interface GitRepositoryConfig {
id: string;
name: string;
/** What the Git host signs its webhooks with. */
secret: string;
createdAt: string;
}
/** /**
* Per-project secret that Git hosts sign their webhooks with. Configuration, * The repositories whose webhooks a project accepts. Configuration, not
* not history: kept outside the event store. * history: kept outside the event store.
*/ */
export abstract class GitIntegrationsRepository { export abstract class GitIntegrationsRepository {
abstract secretOf(projectId: string): Promise<string | null>; /** Oldest first; empty when the integration is off. */
abstract save(projectId: string, secret: string): Promise<void>; abstract repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]>;
abstract remove(projectId: string): Promise<void>; /** Replaces the whole list; an empty one turns the integration off. */
abstract saveAll(
projectId: string,
repositories: GitRepositoryConfig[],
): Promise<void>;
} }

View File

@@ -11,6 +11,7 @@ const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({
ref: '#12', ref: '#12',
state, state,
provider: 'gitea', provider: 'gitea',
repository: 'web',
author: 'ana', author: 'ana',
at: '2026-10-08T10:00:00Z', at: '2026-10-08T10:00:00Z',
}); });

View File

@@ -27,6 +27,11 @@ export interface DevLink {
state: DevLinkState | null; state: DevLinkState | null;
/** github, gitea, gitlab. */ /** github, gitea, gitlab. */
provider: string; provider: string;
/**
* The project's name for the repository that reported it (api, web…);
* null on links recorded before projects had several.
*/
repository: string | null;
author: string | null; author: string | null;
at: string; at: string;
} }
@@ -55,6 +60,7 @@ export function normalizeDevLink(link: DevLink): DevLink {
...link, ...link,
title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref, title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref,
ref: link.ref.slice(0, 120), ref: link.ref.slice(0, 120),
repository: link.repository?.slice(0, 40) ?? null,
author: link.author?.slice(0, 120) ?? null, author: link.author?.slice(0, 120) ?? null,
}; };
} }

View File

@@ -1,9 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { GitIntegrationsRepository } from '@task-stream/api/tracker/application'; import {
GitIntegrationsRepository,
GitRepositoryConfig,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose'; import { Model } from 'mongoose';
import { GitIntegrationDoc, ReadModels } from './read-model.schemas'; import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
/** What a project's single webhook of before is called once it is one of many. */
export const LEGACY_REPOSITORY_NAME = 'principal';
@Injectable() @Injectable()
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
constructor( constructor(
@@ -13,22 +19,38 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
super(); super();
} }
async secretOf(projectId: string): Promise<string | null> { async repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]> {
const doc = await this.integrations const doc = await this.integrations
.findById(projectId) .findById(projectId)
.lean<GitIntegrationDoc>(); .lean<GitIntegrationDoc>();
return doc?.secret ?? null; if (!doc) return [];
if (doc.repositories) return doc.repositories;
// Saved before projects had several repositories: its secret keeps
// working, now as one of them.
return doc.secret
? [
{
id: projectId,
name: LEGACY_REPOSITORY_NAME,
secret: doc.secret,
createdAt: doc.createdAt ?? new Date(0).toISOString(),
},
]
: [];
} }
async save(projectId: string, secret: string): Promise<void> { async saveAll(
projectId: string,
repositories: GitRepositoryConfig[],
): Promise<void> {
if (!repositories.length) {
await this.integrations.deleteOne({ _id: projectId });
return;
}
await this.integrations.updateOne( await this.integrations.updateOne(
{ _id: projectId }, { _id: projectId },
{ $set: { secret, createdAt: new Date().toISOString() } }, { $set: { repositories }, $unset: { secret: '', createdAt: '' } },
{ upsert: true }, { upsert: true },
); );
} }
async remove(projectId: string): Promise<void> {
await this.integrations.deleteOne({ _id: projectId });
}
} }

View File

@@ -41,11 +41,22 @@ export interface AutomationRunDoc {
_id: string; _id: string;
at: Date; at: Date;
} }
export interface GitIntegrationDoc { export interface GitRepositoryDoc {
_id: string; id: string;
name: string;
secret: string; secret: string;
createdAt: string; createdAt: string;
} }
/**
* One per project. `secret` alone is how the single webhook of before was
* kept: it reads as a repository named "principal" until the list is saved.
*/
export interface GitIntegrationDoc {
_id: string;
repositories?: GitRepositoryDoc[];
secret?: string;
createdAt?: string;
}
export interface NotificationSettingsDoc { export interface NotificationSettingsDoc {
_id: string; _id: string;
email: boolean; email: boolean;
@@ -214,7 +225,7 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>( export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ _id: String, secret: String, createdAt: String }, { _id: String, repositories: [Object], secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' }, { ...loose, collection: 'git_integrations' },
); );
@@ -285,7 +296,11 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
rank: rest.rank ?? implicitRank(rest.number), rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null, startDate: rest.startDate ?? null,
sprint: rest.sprint?.id ? rest.sprint : null, sprint: rest.sprint?.id ? rest.sprint : null,
devLinks: rest.devLinks ?? [], // Links recorded before projects had several repositories name none.
devLinks: (rest.devLinks ?? []).map((link) => ({
...link,
repository: link.repository ?? null,
})),
trashedAt: rest.trashedAt ?? null, trashedAt: rest.trashedAt ?? null,
checklist: rest.checklist ?? [], checklist: rest.checklist ?? [],
}; };

View File

@@ -2,11 +2,11 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain'; import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters'; import { CurrentUser } from '@task-stream/api/identity/presenters';
import { GitService } from '@task-stream/api/tracker/application'; import { GitService } from '@task-stream/api/tracker/application';
import { GitIntegrationGql } from './types'; import { GitIntegrationGql, GitRepositoryGql } from './types';
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`; const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
/** The project's Git integration (owners and admins configure it). */ /** The project's Git repositories (owners and admins configure them). */
@Resolver(() => GitIntegrationGql) @Resolver(() => GitIntegrationGql)
export class GitResolver { export class GitResolver {
constructor(private readonly git: GitService) {} constructor(private readonly git: GitService) {}
@@ -20,24 +20,38 @@ export class GitResolver {
return { ...integration, webhookPath: webhookPath(projectId) }; return { ...integration, webhookPath: webhookPath(projectId) };
} }
@Mutation(() => GitIntegrationGql, { @Mutation(() => GitRepositoryGql, {
description: description:
'Turn the Git webhook on, with a new secret (also to rotate it)', 'Add a repository with its own secret (the first turns the integration on)',
}) })
async enableGitIntegration( addGitRepository(
@CurrentUser() me: User, @CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string, @Args('projectId', { type: () => ID }) projectId: string,
@Args('name') name: string,
) { ) {
const integration = await this.git.enable(me.id, projectId); return this.git.addRepository(me.id, projectId, name);
return { ...integration, webhookPath: webhookPath(projectId) };
} }
@Mutation(() => Boolean) @Mutation(() => GitRepositoryGql, {
async disableGitIntegration( description: 'A new secret for a repository; the old one stops working',
})
rotateGitRepositorySecret(
@CurrentUser() me: User, @CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string, @Args('projectId', { type: () => ID }) projectId: string,
@Args('repositoryId', { type: () => ID }) repositoryId: string,
) { ) {
await this.git.disable(me.id, projectId); return this.git.rotateSecret(me.id, projectId, repositoryId);
}
@Mutation(() => Boolean, {
description: 'Remove a repository; what it linked stays on the tickets',
})
async removeGitRepository(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('repositoryId', { type: () => ID }) repositoryId: string,
) {
await this.git.removeRepository(me.id, projectId, repositoryId);
return true; return true;
} }
} }

View File

@@ -170,23 +170,45 @@ export class DevLinkGql {
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string; @Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null; @Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
@Field({ description: 'github, gitea or gitlab' }) provider!: string; @Field({ description: 'github, gitea or gitlab' }) provider!: string;
@Field(() => String, {
nullable: true,
description: "The project's name for the repository it came from",
})
repository!: string | null;
@Field(() => String, { nullable: true }) author!: string | null; @Field(() => String, { nullable: true }) author!: string | null;
@Field() at!: string; @Field() at!: string;
} }
@ObjectType('GitIntegration', { @ObjectType('GitRepository', {
description: description: 'A repository whose webhooks the project accepts',
'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
}) })
export class GitIntegrationGql { export class GitRepositoryGql {
@Field() enabled!: boolean; @Field(() => ID) id!: string;
@Field({ description: "The project's name for it: api, web, admin…" })
name!: string;
@Field(() => String, { @Field(() => String, {
nullable: true, nullable: true,
description: 'Webhook secret; only owners and admins see it', description:
'Webhook secret of this repository; only owners and admins see it',
}) })
secret!: string | null; secret!: string | null;
@Field({ description: 'Path of the webhook on this API' }) @Field() createdAt!: string;
}
@ObjectType('GitIntegration', {
description:
'Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
})
export class GitIntegrationGql {
@Field({ description: 'On while there is at least one repository' })
enabled!: boolean;
@Field({
description:
'Path of the webhook on this API, the same for every repository',
})
webhookPath!: string; webhookPath!: string;
@Field(() => [GitRepositoryGql])
repositories!: GitRepositoryGql[];
} }
@ObjectType('AutomationTriggerSpec') @ObjectType('AutomationTriggerSpec')

View File

@@ -1106,3 +1106,17 @@ export class NotificationSettingsDto {
@IsBoolean() @IsBoolean()
email!: boolean; email!: boolean;
} }
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {
@ApiProperty({
example: 'api',
maxLength: 40,
description: "The project's name for it (api, web, admin…); unique",
})
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
}

View File

@@ -162,19 +162,40 @@ export class DevLinkDto {
}) })
state!: string | null; state!: string | null;
@ApiProperty({ example: 'gitea' }) provider!: string; @ApiProperty({ example: 'gitea' }) provider!: string;
@ApiProperty({
type: String,
nullable: true,
example: 'api',
description: "The project's name for the repository it came from",
})
repository!: string | null;
@ApiProperty({ type: String, nullable: true }) author!: string | null; @ApiProperty({ type: String, nullable: true }) author!: string | null;
@ApiProperty({ format: 'date-time' }) at!: string; @ApiProperty({ format: 'date-time' }) at!: string;
} }
export class GitIntegrationDto { export class GitRepositoryDto {
@ApiProperty() enabled!: boolean; @ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'api' }) name!: string;
@ApiProperty({ @ApiProperty({
type: String, type: String,
nullable: true, nullable: true,
description: 'Webhook secret; only owners and admins see it', description:
'Webhook secret of this repository; only owners and admins see it',
}) })
secret!: string | null; secret!: string | null;
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string; @ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class GitIntegrationDto {
@ApiProperty({ description: 'On while there is at least one repository' })
enabled!: boolean;
@ApiProperty({
example: '/api/webhooks/git/3f2c…',
description: 'The same for every repository of the project',
})
webhookPath!: string;
@ApiProperty({ type: [GitRepositoryDto] })
repositories!: GitRepositoryDto[];
} }
export class WebhookDeliveryDto { export class WebhookDeliveryDto {

View File

@@ -1,4 +1,5 @@
import { import {
Body,
Controller, Controller,
Delete, Delete,
Get, Get,
@@ -30,7 +31,12 @@ import {
} from '@task-stream/api/shared/presenters'; } from '@task-stream/api/shared/presenters';
import { GitService } from '@task-stream/api/tracker/application'; import { GitService } from '@task-stream/api/tracker/application';
import { Request } from 'express'; import { Request } from 'express';
import { GitDeliveryResultDto, GitIntegrationDto } from './dto/responses.dto'; import { AddGitRepositoryDto } from './dto/requests.dto';
import {
GitDeliveryResultDto,
GitIntegrationDto,
GitRepositoryDto,
} from './dto/responses.dto';
import { Refs } from './refs'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
@@ -38,10 +44,16 @@ const PROJECT = ApiParam({
description: 'Project id or key', description: 'Project id or key',
example: 'WEB', example: 'WEB',
}); });
const REPOSITORY = ApiParam({
name: 'repository',
description: 'Repository id',
});
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
/** /**
* Git hosts: the webhook they call, and its configuration for owners and * Git hosts: the webhook they call, and the project's repositories (each
* admins. Commits, branches and pull requests that name a ticket key (WEB-12) * with its own secret) for owners and admins. Commits, branches and pull requests that name a ticket key (WEB-12)
* show on that ticket. * show on that ticket.
*/ */
@ApiTags('Git') @ApiTags('Git')
@@ -56,7 +68,8 @@ export class GitController {
@ApiPireonAuth() @ApiPireonAuth()
@PROJECT @PROJECT
@ApiOperation({ @ApiOperation({
summary: 'Whether the Git webhook is on (and its secret, for admins)', summary:
"The project's repositories and the webhook URL they share (secrets, for admins)",
}) })
@ApiOkResponse({ type: GitIntegrationDto }) @ApiOkResponse({ type: GitIntegrationDto })
@ApiReadErrors() @ApiReadErrors()
@@ -67,35 +80,74 @@ export class GitController {
const projectId = await this.refs.projectId(me.id, project); const projectId = await this.refs.projectId(me.id, project);
return { return {
...(await this.git.integration(me.id, projectId)), ...(await this.git.integration(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`, webhookPath: webhookPath(projectId),
}; };
} }
@Post('projects/:project/git-integration') @Post('projects/:project/git-integration/repositories')
@ApiPireonAuth() @ApiPireonAuth()
@PROJECT @PROJECT
@ApiOperation({ @ApiOperation({
summary: 'Turn the Git webhook on with a new secret (or rotate it)', summary: 'Add a repository, with its own secret (owners and admins)',
description:
'Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.',
}) })
@ApiCreatedResponse({ type: GitIntegrationDto }) @ApiCreatedResponse({ type: GitRepositoryDto })
@ApiCommandErrors() @ApiCommandErrors()
async enable(@CurrentUser() me: User, @Param('project') project: string) { async addRepository(
const projectId = await this.refs.projectId(me.id, project); @CurrentUser() me: User,
return { @Param('project') project: string,
...(await this.git.enable(me.id, projectId)), @Body() body: AddGitRepositoryDto,
webhookPath: `/api/webhooks/git/${projectId}`, ) {
}; return this.git.addRepository(
me.id,
await this.refs.projectId(me.id, project),
body.name,
);
} }
@Delete('projects/:project/git-integration') @Post('projects/:project/git-integration/repositories/:repository/secret')
@ApiPireonAuth() @ApiPireonAuth()
@PROJECT @PROJECT
@REPOSITORY
@ApiOperation({
summary: 'Give a repository a new secret (the old one stops working)',
})
@ApiCreatedResponse({ type: GitRepositoryDto })
@ApiCommandErrors()
async rotateSecret(
@CurrentUser() me: User,
@Param('project') project: string,
@Param('repository') repository: string,
) {
return this.git.rotateSecret(
me.id,
await this.refs.projectId(me.id, project),
repository,
);
}
@Delete('projects/:project/git-integration/repositories/:repository')
@ApiPireonAuth()
@PROJECT
@REPOSITORY
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Turn the Git webhook off' }) @ApiOperation({
summary:
'Remove a repository: its webhooks are refused, what it linked stays',
})
@ApiNoContentResponse() @ApiNoContentResponse()
@ApiCommandErrors() @ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) { async removeRepository(
await this.git.disable(me.id, await this.refs.projectId(me.id, project)); @CurrentUser() me: User,
@Param('project') project: string,
@Param('repository') repository: string,
) {
await this.git.removeRepository(
me.id,
await this.refs.projectId(me.id, project),
repository,
);
} }
@Public() @Public()
@@ -105,7 +157,7 @@ export class GitController {
@ApiOperation({ @ApiOperation({
summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)', summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)',
description: description:
'Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.', 'Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.',
}) })
@ApiOkResponse({ type: GitDeliveryResultDto }) @ApiOkResponse({ type: GitDeliveryResultDto })
@ApiForbiddenResponse({ @ApiForbiddenResponse({

View File

@@ -168,6 +168,8 @@ export interface DevLink {
ref: string; ref: string;
state: 'OPEN' | 'MERGED' | 'CLOSED' | null; state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
provider: string; provider: string;
/** The project's name for the repository it came from. */
repository: string | null;
author: string | null; author: string | null;
at: string; at: string;
} }
@@ -245,11 +247,22 @@ export interface ImportReport {
} }
/** The project's webhook for GitHub, Gitea or GitLab. */ /** The project's webhook for GitHub, Gitea or GitLab. */
export interface GitIntegration { /** A repository whose webhooks the project accepts. */
enabled: boolean; export interface GitRepository {
id: string;
/** The project's name for it: api, web, admin… */
name: string;
/** Only for owners and admins. */ /** Only for owners and admins. */
secret: string | null; secret: string | null;
createdAt: string;
}
export interface GitIntegration {
/** On while there is at least one repository. */
enabled: boolean;
/** The same for every repository: the secret tells them apart. */
webhookPath: string; webhookPath: string;
repositories: GitRepository[];
} }
export interface Comment { export interface Comment {

View File

@@ -6,6 +6,7 @@ import {
Comment, Comment,
Epic, Epic,
GitIntegration, GitIntegration,
GitRepository,
Automation, Automation,
AutomationInput, AutomationInput,
ImportReport, ImportReport,
@@ -200,9 +201,19 @@ export abstract class WebhooksGateway {
/** 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>;
/** On with a new secret (also to rotate it). */ /** A repository with its own secret (the first turns the integration on). */
abstract enable(projectId: string): Observable<GitIntegration>; abstract addRepository(
abstract disable(projectId: string): Observable<void>; projectId: string,
name: string,
): Observable<GitRepository>;
abstract rotateSecret(
projectId: string,
repositoryId: string,
): Observable<GitRepository>;
abstract removeRepository(
projectId: string,
repositoryId: string,
): Observable<void>;
} }
/** Saved views of a project's board (mine and the shared ones). */ /** Saved views of a project's board (mine and the shared ones). */

View File

@@ -1,10 +1,15 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain'; import {
GitGateway,
GitIntegration,
GitRepository,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { import {
DisableGitIntegrationDocument, AddGitRepositoryDocument,
EnableGitIntegrationDocument,
GitIntegrationDocument, GitIntegrationDocument,
RemoveGitRepositoryDocument,
RotateGitRepositorySecretDocument,
} from '../graphql/generated'; } from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations'; import { GraphqlOperations } from './graphql-operations';
@@ -18,15 +23,24 @@ export class GraphqlGitGateway extends GitGateway {
.pipe(map((d) => d.gitIntegration)); .pipe(map((d) => d.gitIntegration));
} }
enable(projectId: string): Observable<GitIntegration> { addRepository(projectId: string, name: string): Observable<GitRepository> {
return this.gql return this.gql
.mutate(EnableGitIntegrationDocument, { projectId }) .mutate(AddGitRepositoryDocument, { projectId, name })
.pipe(map((d) => d.enableGitIntegration)); .pipe(map((d) => d.addGitRepository));
} }
disable(projectId: string): Observable<void> { rotateSecret(
projectId: string,
repositoryId: string,
): Observable<GitRepository> {
return this.gql return this.gql
.mutate(DisableGitIntegrationDocument, { projectId }) .mutate(RotateGitRepositorySecretDocument, { projectId, repositoryId })
.pipe(map((d) => d.rotateGitRepositorySecret));
}
removeRepository(projectId: string, repositoryId: string): Observable<void> {
return this.gql
.mutate(RemoveGitRepositoryDocument, { projectId, repositoryId })
.pipe(map(() => undefined)); .pipe(map(() => undefined));
} }
} }

View File

@@ -120,6 +120,7 @@ fragment IssueFields on Issue {
ref ref
state state
provider provider
repository
author author
at at
} }

View File

@@ -531,6 +531,7 @@ export type IssueFieldsFragment = {
ref: string; ref: string;
state: DevLinkState | null; state: DevLinkState | null;
provider: string; provider: string;
repository: string | null;
author: string | null; author: string | null;
at: string; at: string;
}>; }>;
@@ -585,10 +586,11 @@ export type CommandResultFieldsFragment = {
version: number; version: number;
}; };
export type GitIntegrationFieldsFragment = { export type GitRepositoryFieldsFragment = {
enabled: boolean; id: string;
name: string;
secret: string | null; secret: string | null;
webhookPath: string; createdAt: string;
}; };
export type GitIntegrationQueryVariables = Exact<{ export type GitIntegrationQueryVariables = Exact<{
@@ -598,28 +600,50 @@ export type GitIntegrationQueryVariables = Exact<{
export type GitIntegrationQuery = { export type GitIntegrationQuery = {
gitIntegration: { gitIntegration: {
enabled: boolean; enabled: boolean;
secret: string | null;
webhookPath: string; webhookPath: string;
repositories: Array<{
id: string;
name: string;
secret: string | null;
createdAt: string;
}>;
}; };
}; };
export type EnableGitIntegrationMutationVariables = Exact<{ export type AddGitRepositoryMutationVariables = Exact<{
projectId: string; projectId: string;
name: string;
}>; }>;
export type EnableGitIntegrationMutation = { export type AddGitRepositoryMutation = {
enableGitIntegration: { addGitRepository: {
enabled: boolean; id: string;
name: string;
secret: string | null; secret: string | null;
webhookPath: string; createdAt: string;
}; };
}; };
export type DisableGitIntegrationMutationVariables = Exact<{ export type RotateGitRepositorySecretMutationVariables = Exact<{
projectId: string; projectId: string;
repositoryId: string;
}>; }>;
export type DisableGitIntegrationMutation = { disableGitIntegration: boolean }; export type RotateGitRepositorySecretMutation = {
rotateGitRepositorySecret: {
id: string;
name: string;
secret: string | null;
createdAt: string;
};
};
export type RemoveGitRepositoryMutationVariables = Exact<{
projectId: string;
repositoryId: string;
}>;
export type RemoveGitRepositoryMutation = { removeGitRepository: boolean };
export type BoardQueryVariables = Exact<{ export type BoardQueryVariables = Exact<{
idOrKey: string; idOrKey: string;
@@ -712,6 +736,7 @@ export type IssueQuery = {
ref: string; ref: string;
state: DevLinkState | null; state: DevLinkState | null;
provider: string; provider: string;
repository: string | null;
author: string | null; author: string | null;
at: string; at: string;
}>; }>;
@@ -2525,6 +2550,7 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } }, { kind: 'Field', name: { kind: 'Name', value: 'ref' } },
{ kind: 'Field', name: { kind: 'Name', value: 'state' } }, { kind: 'Field', name: { kind: 'Name', value: 'state' } },
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } }, { kind: 'Field', name: { kind: 'Name', value: 'provider' } },
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
{ kind: 'Field', name: { kind: 'Name', value: 'author' } }, { kind: 'Field', name: { kind: 'Name', value: 'author' } },
{ kind: 'Field', name: { kind: 'Name', value: 'at' } }, { kind: 'Field', name: { kind: 'Name', value: 'at' } },
], ],
@@ -2806,27 +2832,28 @@ export const CommandResultFieldsFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>; } as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
export const GitIntegrationFieldsFragmentDoc = { export const GitRepositoryFieldsFragmentDoc = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
{ {
kind: 'FragmentDefinition', kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' }, name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: { typeCondition: {
kind: 'NamedType', kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' }, name: { kind: 'Name', value: 'GitRepository' },
}, },
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } }, { kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
], ],
}, },
}, },
], ],
} as unknown as DocumentNode<GitIntegrationFieldsFragment, unknown>; } as unknown as DocumentNode<GitRepositoryFieldsFragment, unknown>;
export const NotificationFieldsFragmentDoc = { export const NotificationFieldsFragmentDoc = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -3620,9 +3647,20 @@ export const GitIntegrationDocument = {
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
{ {
kind: 'FragmentSpread', kind: 'Field',
name: { kind: 'Name', value: 'GitIntegrationFields' }, name: { kind: 'Name', value: 'repositories' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitRepositoryFields' },
},
],
},
}, },
], ],
}, },
@@ -3632,29 +3670,30 @@ export const GitIntegrationDocument = {
}, },
{ {
kind: 'FragmentDefinition', kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' }, name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: { typeCondition: {
kind: 'NamedType', kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' }, name: { kind: 'Name', value: 'GitRepository' },
}, },
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } }, { kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
], ],
}, },
}, },
], ],
} as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>; } as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>;
export const EnableGitIntegrationDocument = { export const AddGitRepositoryDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
{ {
kind: 'OperationDefinition', kind: 'OperationDefinition',
operation: 'mutation', operation: 'mutation',
name: { kind: 'Name', value: 'EnableGitIntegration' }, name: { kind: 'Name', value: 'AddGitRepository' },
variableDefinitions: [ variableDefinitions: [
{ {
kind: 'VariableDefinition', kind: 'VariableDefinition',
@@ -3667,13 +3706,24 @@ export const EnableGitIntegrationDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
}, },
}, },
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'enableGitIntegration' }, name: { kind: 'Name', value: 'addGitRepository' },
arguments: [ arguments: [
{ {
kind: 'Argument', kind: 'Argument',
@@ -3683,13 +3733,21 @@ export const EnableGitIntegrationDocument = {
name: { kind: 'Name', value: 'projectId' }, name: { kind: 'Name', value: 'projectId' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'name' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'name' },
},
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ {
kind: 'FragmentSpread', kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitIntegrationFields' }, name: { kind: 'Name', value: 'GitRepositoryFields' },
}, },
], ],
}, },
@@ -3699,32 +3757,33 @@ export const EnableGitIntegrationDocument = {
}, },
{ {
kind: 'FragmentDefinition', kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' }, name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: { typeCondition: {
kind: 'NamedType', kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' }, name: { kind: 'Name', value: 'GitRepository' },
}, },
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } }, { kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
], ],
}, },
}, },
], ],
} as unknown as DocumentNode< } as unknown as DocumentNode<
EnableGitIntegrationMutation, AddGitRepositoryMutation,
EnableGitIntegrationMutationVariables AddGitRepositoryMutationVariables
>; >;
export const DisableGitIntegrationDocument = { export const RotateGitRepositorySecretDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
{ {
kind: 'OperationDefinition', kind: 'OperationDefinition',
operation: 'mutation', operation: 'mutation',
name: { kind: 'Name', value: 'DisableGitIntegration' }, name: { kind: 'Name', value: 'RotateGitRepositorySecret' },
variableDefinitions: [ variableDefinitions: [
{ {
kind: 'VariableDefinition', kind: 'VariableDefinition',
@@ -3737,13 +3796,24 @@ export const DisableGitIntegrationDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
}, },
}, },
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
selections: [ selections: [
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'disableGitIntegration' }, name: { kind: 'Name', value: 'rotateGitRepositorySecret' },
arguments: [ arguments: [
{ {
kind: 'Argument', kind: 'Argument',
@@ -3753,6 +3823,104 @@ export const DisableGitIntegrationDocument = {
name: { kind: 'Name', value: 'projectId' }, name: { kind: 'Name', value: 'projectId' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'repositoryId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitRepositoryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'GitRepository' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
RotateGitRepositorySecretMutation,
RotateGitRepositorySecretMutationVariables
>;
export const RemoveGitRepositoryDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RemoveGitRepository' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'removeGitRepository' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'repositoryId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
},
], ],
}, },
], ],
@@ -3760,8 +3928,8 @@ export const DisableGitIntegrationDocument = {
}, },
], ],
} as unknown as DocumentNode< } as unknown as DocumentNode<
DisableGitIntegrationMutation, RemoveGitRepositoryMutation,
DisableGitIntegrationMutationVariables RemoveGitRepositoryMutationVariables
>; >;
export const BoardDocument = { export const BoardDocument = {
kind: 'Document', kind: 'Document',
@@ -4341,6 +4509,7 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } }, { kind: 'Field', name: { kind: 'Name', value: 'ref' } },
{ kind: 'Field', name: { kind: 'Name', value: 'state' } }, { kind: 'Field', name: { kind: 'Name', value: 'state' } },
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } }, { kind: 'Field', name: { kind: 'Name', value: 'provider' } },
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
{ kind: 'Field', name: { kind: 'Name', value: 'author' } }, { kind: 'Field', name: { kind: 'Name', value: 'author' } },
{ kind: 'Field', name: { kind: 'Name', value: 'at' } }, { kind: 'Field', name: { kind: 'Name', value: 'at' } },
], ],

View File

@@ -1,21 +1,35 @@
fragment GitIntegrationFields on GitIntegration { fragment GitRepositoryFields on GitRepository {
enabled id
name
secret secret
webhookPath createdAt
} }
query GitIntegration($projectId: ID!) { query GitIntegration($projectId: ID!) {
gitIntegration(projectId: $projectId) { gitIntegration(projectId: $projectId) {
...GitIntegrationFields enabled
webhookPath
repositories {
...GitRepositoryFields
}
} }
} }
mutation EnableGitIntegration($projectId: ID!) { mutation AddGitRepository($projectId: ID!, $name: String!) {
enableGitIntegration(projectId: $projectId) { addGitRepository(projectId: $projectId, name: $name) {
...GitIntegrationFields ...GitRepositoryFields
} }
} }
mutation DisableGitIntegration($projectId: ID!) { mutation RotateGitRepositorySecret($projectId: ID!, $repositoryId: ID!) {
disableGitIntegration(projectId: $projectId) rotateGitRepositorySecret(
projectId: $projectId
repositoryId: $repositoryId
) {
...GitRepositoryFields
}
}
mutation RemoveGitRepository($projectId: ID!, $repositoryId: ID!) {
removeGitRepository(projectId: $projectId, repositoryId: $repositoryId)
} }

View File

@@ -490,7 +490,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
rel="noopener" rel="noopener"
>{{ link.title }}</a >{{ link.title }}</a
> >
<code class="ts-dev__ref">{{ link.ref }}</code> <code class="ts-dev__ref"
>{{ link.repository ? link.repository + ' · ' : ''
}}{{ link.ref }}</code
>
@if (link.state) { @if (link.state) {
<span class="ts-pill" [attr.data-state]="link.state">{{ <span class="ts-pill" [attr.data-state]="link.state">{{
devState(link) devState(link)

View File

@@ -9,17 +9,22 @@ import {
untracked, untracked,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PiTextFieldComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui'; import { ToastService } from '@task-stream/web/shared/ui';
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain'; import {
GitGateway,
GitIntegration,
GitRepository,
} from '@task-stream/web/tracker/domain';
/** /**
* Settings card for the Git webhook: turn it on, copy the URL and the * Settings card for the Git webhooks: the project's repositories (api, web,
* secret into GitHub, Gitea or GitLab, rotate or turn it off. * admin…), each with its own secret, and the URL they all share.
*/ */
@Component({ @Component({
selector: 'ts-git-integration', selector: 'ts-git-integration',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [], imports: [PiTextFieldComponent],
styleUrl: './settings-card.scss', styleUrl: './settings-card.scss',
template: ` template: `
<section class="ts-panel ts-block ts-git" aria-labelledby="git-title"> <section class="ts-panel ts-block ts-git" aria-labelledby="git-title">
@@ -28,8 +33,8 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
<p class="ts-muted"> <p class="ts-muted">
Los commits, ramas y pull requests que nombren un ticket ({{ Los commits, ramas y pull requests que nombren un ticket ({{
projectKey() projectKey()
}}-12) aparecen en su ficha, en «Desarrollo». Funciona con GitHub, }}-12) aparecen en su ficha, en «Desarrollo», con el repositorio del
Gitea y GitLab. que vienen. Funciona con GitHub, Gitea y GitLab.
</p> </p>
</div> </div>
@if (integration(); as git) { @if (integration(); as git) {
@@ -46,60 +51,78 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
Copiar Copiar
</button> </button>
</dd> </dd>
@if (git.secret) {
<dt>Secreto</dt>
<dd>
<code>{{ shown() ? git.secret : '••••••••••••••••' }}</code>
<button
type="button"
class="ts-plain-link"
(click)="shown.set(!shown())"
>
{{ shown() ? 'Ocultar' : 'Ver' }}
</button>
<button
type="button"
class="ts-plain-link"
(click)="copy(git.secret)"
>
Copiar
</button>
</dd>
}
</dl> </dl>
<p class="ts-muted ts-git__how"> <p class="ts-muted ts-git__how">
En el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL, La misma para todos los repositorios; cada uno lleva su secreto. En
tipo <code>application/json</code>, el secreto (en GitLab, como el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL,
tipo <code>application/json</code>, su secreto (en GitLab, como
«Secret token») y los eventos <em>push</em> y «Secret token») y los eventos <em>push</em> y
<em>pull/merge request</em>. <em>pull/merge request</em>.
</p> </p>
<div class="ts-git__actions"> <ul class="ts-git__repos">
<button @for (repo of git.repositories; track repo.id) {
type="button" <li class="ts-git__repo">
class="ts-btn ts-btn--secondary" <strong class="ts-git__name">{{ repo.name }}</strong>
(click)="enable()" @if (repo.secret) {
> <span class="ts-git__secret">
Cambiar el secreto <code>{{
</button> shown() === repo.id ? repo.secret : '••••••••••••••••'
<button }}</code>
type="button" <button
class="ts-btn ts-btn--ghost" type="button"
(click)="disable()" class="ts-plain-link"
> (click)="shown.set(shown() === repo.id ? null : repo.id)"
Desactivar >
</button> {{ shown() === repo.id ? 'Ocultar' : 'Ver' }}
</div> </button>
} @else { <button
<div> type="button"
<button class="ts-plain-link"
type="button" (click)="copy(repo.secret)"
class="ts-btn ts-btn--primary" >
(click)="enable()" Copiar
> </button>
Activar </span>
</button> <span class="ts-git__actions">
</div> <button
type="button"
class="ts-btn ts-btn--secondary"
(click)="rotate(repo)"
>
Cambiar el secreto
</button>
<button
type="button"
class="ts-btn ts-btn--ghost"
(click)="remove(repo)"
>
Quitar
</button>
</span>
}
</li>
}
</ul>
} }
<form
class="ts-git__new"
(submit)="$event.preventDefault(); add()"
novalidate
>
<pi-text-field
placeholder="Nombre: api, web, admin…"
[(value)]="name"
/>
<button
type="submit"
class="ts-btn ts-btn--primary"
[disabled]="!name().trim()"
>
{{
git.enabled ? 'Añadir repositorio' : 'Activar con un repositorio'
}}
</button>
</form>
} }
</section> </section>
`, `,
@@ -128,17 +151,51 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
margin: 0; margin: 0;
min-width: 0; min-width: 0;
} }
code { }
overflow-wrap: anywhere; code {
} overflow-wrap: anywhere;
} }
.ts-git__how { .ts-git__how {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
.ts-git__repos {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.ts-git__repo {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
padding: 0.625rem 0;
border-top: 1px solid var(--ts-line);
}
.ts-git__name {
min-width: 5rem;
}
.ts-git__secret {
display: flex;
flex: 1 1 14rem;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
min-width: 0;
}
.ts-git__actions { .ts-git__actions {
display: flex; display: flex;
gap: 0.25rem;
}
.ts-git__new {
display: flex;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
pi-text-field {
flex: 1 1 14rem;
}
} }
`, `,
}) })
@@ -150,7 +207,9 @@ export class GitIntegrationComponent {
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
protected readonly integration = signal<GitIntegration | null>(null); protected readonly integration = signal<GitIntegration | null>(null);
protected readonly shown = signal(false); /** The repository whose secret is on show. */
protected readonly shown = signal<string | null>(null);
protected readonly name = signal('');
constructor() { constructor() {
effect(() => { effect(() => {
@@ -172,48 +231,80 @@ export class GitIntegrationComponent {
return `${location.origin}${git.webhookPath}`; return `${location.origin}${git.webhookPath}`;
} }
protected enable(): void { protected add(): void {
const rotating = this.integration()?.enabled; const name = this.name().trim();
if ( if (!name) return;
rotating && const taken = this.integration()?.repositories.some(
!confirm( (r) => r.name.toLowerCase() === name.toLowerCase(),
'El secreto actual dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?', );
) if (taken) {
) this.toast.error(`Ya hay un repositorio «${name}»`);
return; return;
}
this.git this.git
.enable(this.projectId()) .addRepository(this.projectId(), name)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (git) => { next: (repo) => {
this.integration.set(git); this.integration.update((git) =>
this.shown.set(true); git
this.toast.success( ? {
rotating ? 'Secreto cambiado' : 'Integración activada', ...git,
enabled: true,
repositories: [...git.repositories, repo],
}
: git,
); );
this.name.set('');
this.shown.set(repo.id);
this.toast.success(`Repositorio «${repo.name}» añadido`);
}, },
error: () => this.toast.error('No se pudo activar'), error: () => this.toast.error('No se pudo añadir el repositorio'),
}); });
} }
protected disable(): void { protected rotate(repo: GitRepository): void {
if ( if (
!confirm( !confirm(
'Los repositorios dejarán de enlazar código a los tickets. ¿Desactivar?', `El secreto actual de «${repo.name}» dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?`,
) )
) )
return; return;
this.git this.git
.disable(this.projectId()) .rotateSecret(this.projectId(), repo.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (rotated) => {
this.replace(rotated);
this.shown.set(rotated.id);
this.toast.success('Secreto cambiado');
},
error: () => this.toast.error('No se pudo cambiar el secreto'),
});
}
protected remove(repo: GitRepository): void {
if (
!confirm(
`«${repo.name}» dejará de enlazar código a los tickets (lo ya enlazado se queda). ¿Quitarlo?`,
)
)
return;
this.git
.removeRepository(this.projectId(), repo.id)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.integration.update((git) => this.integration.update((git) => {
git ? { ...git, enabled: false, secret: null } : git, if (!git) return git;
); const repositories = git.repositories.filter(
this.toast.success('Integración desactivada'); (r) => r.id !== repo.id,
);
return { ...git, enabled: repositories.length > 0, repositories };
});
this.toast.success(`Repositorio «${repo.name}» quitado`);
}, },
error: () => this.toast.error('No se pudo desactivar'), error: () => this.toast.error('No se pudo quitar'),
}); });
} }
@@ -223,4 +314,17 @@ export class GitIntegrationComponent {
.then(() => this.toast.success('Copiado')) .then(() => this.toast.success('Copiado'))
.catch(() => this.toast.error('No se pudo copiar')); .catch(() => this.toast.error('No se pudo copiar'));
} }
private replace(repo: GitRepository): void {
this.integration.update((git) =>
git
? {
...git,
repositories: git.repositories.map((r) =>
r.id === repo.id ? repo : r,
),
}
: git,
);
}
} }