diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 33274b2..eb1453f 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -8087,13 +8087,16 @@ "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": [ "Git" ] - }, + } + }, + "/api/projects/{project}/git-integration/repositories": { "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": [ { "name": "project", @@ -8106,13 +8109,23 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddGitRepositoryDto" + } + } + } + }, "responses": { "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitIntegrationDto" + "$ref": "#/components/schemas/GitRepositoryDto" } } } @@ -8186,13 +8199,15 @@ "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": [ "Git" ] - }, - "delete": { - "operationId": "GitController_disable", + } + }, + "/api/projects/{project}/git-integration/repositories/{repository}/secret": { + "post": { + "operationId": "GitController_rotateSecret", "parameters": [ { "name": "project", @@ -8203,6 +8218,125 @@ "example": "WEB", "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": { @@ -8278,7 +8412,7 @@ "pireon": [] } ], - "summary": "Turn the Git webhook off", + "summary": "Remove a repository: its webhooks are refused, what it linked stays", "tags": [ "Git" ] @@ -8286,7 +8420,7 @@ }, "/api/webhooks/git/{project}": { "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", "parameters": [ { @@ -10179,6 +10313,12 @@ "type": "string", "example": "gitea" }, + "repository": { + "type": "string", + "nullable": true, + "example": "api", + "description": "The project's name for the repository it came from" + }, "author": { "type": "string", "nullable": true @@ -10195,6 +10335,7 @@ "ref", "state", "provider", + "repository", "author", "at" ] @@ -12324,26 +12465,71 @@ "sprintId" ] }, - "GitIntegrationDto": { + "GitRepositoryDto": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "api" }, "secret": { "type": "string", "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": { "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": [ "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": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index c3a6496..22584ec 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -335,6 +335,9 @@ type DevLink { """Short SHA, branch or PR number""" ref: String! + + """The project's name for the repository it came from""" + repository: String state: DevLinkState title: 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 { + """On while there is at least one repository""" enabled: Boolean! + repositories: [GitRepository!]! - """Webhook secret; only owners and admins see it""" - secret: String - - """Path of the webhook on this API""" + """Path of the webhook on this API, the same for every repository""" 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 { """Statuses this ticket can move to next (project's workflow)""" allowedTransitions: [String!]! @@ -590,6 +604,11 @@ type Mutation { addChecklistItem(input: AddChecklistItemInput!): 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""" addProjectMember(input: ProjectMemberInput!): CommandResult! archiveRelease(releaseId: ID!): CommandResult! @@ -638,12 +657,8 @@ type Mutation { """Delete a sprint that never started; its tickets go back to the backlog""" deleteSprint(sprintId: ID!): CommandResult! deleteWebhook(webhookId: ID!): Boolean! - disableGitIntegration(projectId: ID!): Boolean! 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""" linkIssues(input: LinkIssuesInput!): CommandResult! @@ -663,11 +678,17 @@ type Mutation { """ removeAttachment(attachmentId: ID!, issueId: 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! """Back from the trash""" 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)""" saveFilter(input: SaveFilterInput!): ID! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 0cd304b..a360b02 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1182,13 +1182,65 @@ export interface paths { path?: 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"]; put?: never; - /** Turn the Git webhook on with a new secret (or rotate it) */ - post: operations["GitController_enable"]; - /** Turn the Git webhook off */ - delete: operations["GitController_disable"]; + post?: never; + delete?: never; + options?: never; + 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; head?: never; patch?: never; @@ -1205,7 +1257,7 @@ export interface paths { put?: never; /** * 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"]; delete?: never; @@ -1672,6 +1724,11 @@ export interface components { state: components["schemas"]["DevLinkState"] | null; /** @example gitea */ provider: string; + /** + * @description The project's name for the repository it came from + * @example api + */ + repository: string | null; author: string | null; /** Format: date-time */ at: string; @@ -2380,12 +2437,32 @@ export interface components { */ sprintId: string | null; }; - GitIntegrationDto: { - enabled: boolean; - /** @description Webhook secret; only owners and admins see it */ + GitRepositoryDto: { + /** Format: uuid */ + id: string; + /** @example api */ + name: string; + /** @description Webhook secret of this repository; only owners and admins see it */ 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; + repositories: components["schemas"]["GitRepositoryDto"][]; + }; + AddGitRepositoryDto: { + /** + * @description The project's name for it (api, web, admin…); unique + * @example api + */ + name: string; }; GitDeliveryResultDto: { /** @description Tickets that got something new */ @@ -8054,7 +8131,7 @@ export interface operations { }; }; }; - GitController_enable: { + GitController_addRepository: { parameters: { query?: never; header?: never; @@ -8064,14 +8141,18 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddGitRepositoryDto"]; + }; + }; responses: { 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GitIntegrationDto"]; + "application/json": components["schemas"]["GitRepositoryDto"]; }; }; /** @description Invalid input */ @@ -8130,13 +8211,93 @@ export interface operations { }; }; }; - GitController_disable: { + GitController_rotateSecret: { parameters: { query?: never; header?: never; path: { /** @description Project id or key */ 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; }; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 4e93e91..78235a6 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -250,6 +250,7 @@ export class IssuesResolver { links: issue.links, devLinks: issue.devLinks.map((d) => ({ kind: d.kind, + repository: d.repository, ref: d.ref, title: d.title, state: d.state, diff --git a/docs/03-api.md b/docs/03-api.md index c6f300f..5fa77bb 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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 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. -- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host - llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el - cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una - clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`, - actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, - integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el - event store. +- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`, + `admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los + eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía + como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que + nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su + repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR + 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 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 @@ -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) | | Comentarios | `addComment`, `editComment`, `deleteComment` | | 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` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | 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) | | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET/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` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index aa75130..0b5b9c7 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 436d276..37cf2fe 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts b/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts index 41ce561..886c8a1 100644 --- a/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts +++ b/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts @@ -42,6 +42,7 @@ describe('automation rules', () => { ref: '#1', state: DevLinkState.MERGED, provider: 'github', + repository: null, author: null, at: '', }); diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 24a107e..0827550 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -9,6 +9,7 @@ import { ConcurrencyError, ForbiddenError, NotFoundError, + ValidationError, } from '@task-stream/api/shared/domain'; import { Comment, @@ -40,7 +41,14 @@ import { StartSprintCommand, } from './sprint.commands'; 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 { RunAutomationCommandHandler } from '../automations/automations'; import { @@ -48,7 +56,10 @@ import { AutomationTriggerKind, } from '../automations/automation-rules'; 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 { SetIssueParentCommand } from './set-issue-parent.command'; import { RankIssueCommand } from './rank-issue.command'; @@ -86,6 +97,14 @@ import { FindProjectsRepository } from '../ports/find-projects.repository'; async function setup() { const store = new InMemoryEventStore(); + // The project's Git repositories, as Mongo would keep them. + const gitRepositories = new Map(); + const git: GitIntegrationsRepository = { + repositoriesOf: async (projectId) => gitRepositories.get(projectId) ?? [], + saveAll: async (projectId, list) => { + gitRepositories.set(projectId, list); + }, + }; const keys = new Map(); const sprints = { active: null as { id: string; name: string } | null, @@ -112,7 +131,7 @@ async function setup() { provide: FindProjectsRepository, useValue: { findRole: async () => null, findById: async () => null }, }, - { provide: GitIntegrationsRepository, useValue: {} }, + { provide: GitIntegrationsRepository, useValue: git }, AggregateRehydrator, { provide: EventStore, useValue: store }, { provide: AttachmentStorage, useValue: files }, @@ -157,6 +176,7 @@ async function setup() { files, sprints, keys, + git, run, rehydrator: moduleRef.get(AggregateRehydrator), }; @@ -520,16 +540,52 @@ describe('tracker commands (event sourced)', () => { ref: 'abc', state: null, provider: 'gitea', + repository: null, author: 'Ana', at: '2026-10-08T10:00:00Z', }, }; expect( - await run(new RecordGitMentionsCommand(projectId, [commit, commit])), + await run( + new RecordGitMentionsCommand(projectId, 'web', [commit, commit]), + ), ).toBe(1); 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; + + 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 () => { diff --git a/libs/api/tracker/application/src/lib/git.service.spec.ts b/libs/api/tracker/application/src/lib/git.service.spec.ts new file mode 100644 index 0000000..2d25680 --- /dev/null +++ b/libs/api/tracker/application/src/lib/git.service.spec.ts @@ -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([]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/git.service.ts b/libs/api/tracker/application/src/lib/git.service.ts index 56ef598..e0ec46c 100644 --- a/libs/api/tracker/application/src/lib/git.service.ts +++ b/libs/api/tracker/application/src/lib/git.service.ts @@ -2,11 +2,13 @@ import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ForbiddenError } from '@task-stream/api/shared/domain'; import { - DisableGitIntegrationCommand, - EnableGitIntegrationCommand, + AddGitRepositoryCommand, GetGitIntegrationQuery, GitIntegration, + GitRepository, RecordGitMentionsCommand, + RemoveGitRepositoryCommand, + RotateGitRepositorySecretCommand, } from './git/git'; import { gitMentionsOf, @@ -40,41 +42,59 @@ export class GitService { ); } - enable(actorId: string, projectId: string): Promise { + addRepository( + actorId: string, + projectId: string, + name: string, + ): Promise { return this.commandBus.execute( - new EnableGitIntegrationCommand(actorId, projectId), + new AddGitRepositoryCommand(actorId, projectId, name), ); } - disable(actorId: string, projectId: string): Promise { + rotateSecret( + actorId: string, + projectId: string, + repositoryId: string, + ): Promise { return this.commandBus.execute( - new DisableGitIntegrationCommand(actorId, projectId), + new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId), + ); + } + + removeRepository( + actorId: string, + projectId: string, + repositoryId: string, + ): Promise { + return this.commandBus.execute( + new RemoveGitRepositoryCommand(actorId, projectId, repositoryId), ); } /** - * A webhook delivery. Unknown project, integration off or a bad signature - * all look the same from outside (403): nothing to learn by probing. + * A webhook delivery. Every repository of the project shares the URL; the + * 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 }> { const project = (await this.findProjects.findById(delivery.project)) ?? (await this.findProjects.findByKey(delivery.project.toUpperCase())); - const secret = project - ? await this.integrations.secretOf(project.id) - : null; const source = gitProviderOf(delivery.headers); - if ( - !project || - !secret || - !source || - !verifyGitSignature( - source.provider, - delivery.headers, - delivery.rawBody, - secret, - ) - ) + const repository = + project && source + ? (await this.integrations.repositoriesOf(project.id)).find((r) => + verifyGitSignature( + source.provider, + delivery.headers, + delivery.rawBody, + r.secret, + ), + ) + : undefined; + if (!project || !source || !repository) throw new ForbiddenError( 'Unknown project, integration off or bad signature', ); @@ -85,7 +105,7 @@ export class GitService { ); if (!mentions.length) return { recorded: 0 }; const recorded: number = await this.commandBus.execute( - new RecordGitMentionsCommand(project.id, mentions), + new RecordGitMentionsCommand(project.id, repository.name, mentions), ); return { recorded }; } diff --git a/libs/api/tracker/application/src/lib/git/git-payloads.ts b/libs/api/tracker/application/src/lib/git/git-payloads.ts index 7fd87b0..de41b00 100644 --- a/libs/api/tracker/application/src/lib/git/git-payloads.ts +++ b/libs/api/tracker/application/src/lib/git/git-payloads.ts @@ -82,6 +82,7 @@ function fromPush( ref: String(commit['id']).slice(0, 8), state: null, provider, + repository: null, author: commit['author']?.['name'] ?? null, at: String(commit['timestamp'] ?? new Date().toISOString()), }, @@ -103,6 +104,7 @@ function fromPush( ref: branch, state: null, provider, + repository: null, author: body['pusher']?.['name'] ?? body['pusher']?.['login'] ?? @@ -148,6 +150,7 @@ export function gitMentionsOf( ref: `#${pr['number'] ?? ''}`, state: prState(!!pr['merged'], String(pr['state'] ?? 'open')), provider, + repository: null, author: pr['user']?.['login'] ?? null, at: String(pr['updated_at'] ?? new Date().toISOString()), }, @@ -168,6 +171,7 @@ export function gitMentionsOf( ref: `!${mr['iid'] ?? ''}`, state: prState(false, String(mr['state'] ?? 'opened')), provider, + repository: null, author: body['user']?.['username'] ?? null, at: String(mr['updated_at'] ?? new Date().toISOString()), }, diff --git a/libs/api/tracker/application/src/lib/git/git.ts b/libs/api/tracker/application/src/lib/git/git.ts index 84ad69d..0c625c6 100644 --- a/libs/api/tracker/application/src/lib/git/git.ts +++ b/libs/api/tracker/application/src/lib/git/git.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { Command, CommandHandler, @@ -7,6 +7,7 @@ import { Query, QueryHandler, } from '@nestjs/cqrs'; +import { NotFoundError, assertThat } from '@task-stream/api/shared/domain'; import { Issue, ProjectRole, @@ -15,30 +16,61 @@ import { } from '@task-stream/api/tracker/domain'; import { WriteAccess } from '../commands/write-access'; 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 { GitMention } from './git-payloads'; -export interface GitIntegration { - enabled: boolean; +export interface GitRepository { + id: string; + /** The project's name for it: api, web, admin… */ + name: string; /** Only for whoever manages the project. */ secret: string | null; + createdAt: string; } -/** Turns the integration on with a new secret (or rotates it). */ -export class EnableGitIntegrationCommand extends Command { +export interface 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 { constructor( public readonly actorId: string, public readonly projectId: string, + public readonly name: string, ) { super(); } } -export class DisableGitIntegrationCommand extends Command { +/** A new secret for one repository; the old one stops working at once. */ +export class RotateGitRepositorySecretCommand extends Command { constructor( public readonly actorId: 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 { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly repositoryId: string, ) { super(); } @@ -48,6 +80,8 @@ export class DisableGitIntegrationCommand extends Command { export class RecordGitMentionsCommand extends Command { constructor( public readonly projectId: string, + /** Name of the repository whose secret signed the delivery. */ + public readonly repository: string, public readonly mentions: GitMention[], ) { super(); @@ -63,39 +97,100 @@ export class GetGitIntegrationQuery extends Query { } } -@CommandHandler(EnableGitIntegrationCommand) -export class EnableGitIntegrationCommandHandler - implements ICommandHandler +@CommandHandler(AddGitRepositoryCommand) +export class AddGitRepositoryCommandHandler + implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly integrations: GitIntegrationsRepository, ) {} - async execute(command: EnableGitIntegrationCommand): Promise { + async execute(command: AddGitRepositoryCommand): Promise { (await this.access.project(command.projectId)).assertCanManage( command.actorId, ); - const secret = randomBytes(24).toString('hex'); - await this.integrations.save(command.projectId, secret); - return { enabled: true, secret }; + const name = command.name.trim(); + assertThat( + 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) -export class DisableGitIntegrationCommandHandler - implements ICommandHandler +@CommandHandler(RotateGitRepositorySecretCommand) +export class RotateGitRepositorySecretCommandHandler + implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly integrations: GitIntegrationsRepository, ) {} - async execute(command: DisableGitIntegrationCommand): Promise { + async execute( + command: RotateGitRepositorySecretCommand, + ): Promise { (await this.access.project(command.projectId)).assertCanManage( 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 +{ + constructor( + private readonly access: WriteAccess, + private readonly integrations: GitIntegrationsRepository, + ) {} + + async execute(command: RemoveGitRepositoryCommand): Promise { + (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); if (!issue) continue; 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; await issue.commitAndWait(); recorded++; @@ -143,15 +241,24 @@ export class GetGitIntegrationQueryHandler query.projectId, 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; - 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 = [ - EnableGitIntegrationCommandHandler, - DisableGitIntegrationCommandHandler, + AddGitRepositoryCommandHandler, + RotateGitRepositorySecretCommandHandler, + RemoveGitRepositoryCommandHandler, RecordGitMentionsCommandHandler, GetGitIntegrationQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/ports/git-integrations.repository.ts b/libs/api/tracker/application/src/lib/ports/git-integrations.repository.ts index 172cc3b..4a0f78d 100644 --- a/libs/api/tracker/application/src/lib/ports/git-integrations.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/git-integrations.repository.ts @@ -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, - * not history: kept outside the event store. + * The repositories whose webhooks a project accepts. Configuration, not + * history: kept outside the event store. */ export abstract class GitIntegrationsRepository { - abstract secretOf(projectId: string): Promise; - abstract save(projectId: string, secret: string): Promise; - abstract remove(projectId: string): Promise; + /** Oldest first; empty when the integration is off. */ + abstract repositoriesOf(projectId: string): Promise; + /** Replaces the whole list; an empty one turns the integration off. */ + abstract saveAll( + projectId: string, + repositories: GitRepositoryConfig[], + ): Promise; } diff --git a/libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts b/libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts index b639ebc..015c709 100644 --- a/libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts @@ -11,6 +11,7 @@ const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({ ref: '#12', state, provider: 'gitea', + repository: 'web', author: 'ana', at: '2026-10-08T10:00:00Z', }); diff --git a/libs/api/tracker/domain/src/lib/issue/dev-link.ts b/libs/api/tracker/domain/src/lib/issue/dev-link.ts index 6a215e6..99c5330 100644 --- a/libs/api/tracker/domain/src/lib/issue/dev-link.ts +++ b/libs/api/tracker/domain/src/lib/issue/dev-link.ts @@ -27,6 +27,11 @@ export interface DevLink { state: DevLinkState | null; /** github, gitea, gitlab. */ 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; at: string; } @@ -55,6 +60,7 @@ export function normalizeDevLink(link: DevLink): DevLink { ...link, title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref, ref: link.ref.slice(0, 120), + repository: link.repository?.slice(0, 40) ?? null, author: link.author?.slice(0, 120) ?? null, }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts index c73ca77..d53d230 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts @@ -1,9 +1,15 @@ import { Injectable } from '@nestjs/common'; 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 { 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() export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { constructor( @@ -13,22 +19,38 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { super(); } - async secretOf(projectId: string): Promise { + async repositoriesOf(projectId: string): Promise { const doc = await this.integrations .findById(projectId) .lean(); - 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 { + async saveAll( + projectId: string, + repositories: GitRepositoryConfig[], + ): Promise { + if (!repositories.length) { + await this.integrations.deleteOne({ _id: projectId }); + return; + } await this.integrations.updateOne( { _id: projectId }, - { $set: { secret, createdAt: new Date().toISOString() } }, + { $set: { repositories }, $unset: { secret: '', createdAt: '' } }, { upsert: true }, ); } - - async remove(projectId: string): Promise { - await this.integrations.deleteOne({ _id: projectId }); - } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index ab51ac1..5849ec0 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -41,11 +41,22 @@ export interface AutomationRunDoc { _id: string; at: Date; } -export interface GitIntegrationDoc { - _id: string; +export interface GitRepositoryDoc { + id: string; + name: string; secret: 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 { _id: string; email: boolean; @@ -214,7 +225,7 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 }); export const GitIntegrationSchema = new Schema( - { _id: String, secret: String, createdAt: String }, + { _id: String, repositories: [Object], secret: String, createdAt: String }, { ...loose, collection: 'git_integrations' }, ); @@ -285,7 +296,11 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { rank: rest.rank ?? implicitRank(rest.number), startDate: rest.startDate ?? 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, checklist: rest.checklist ?? [], }; diff --git a/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts index 16cb93d..c9a97ad 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts @@ -2,11 +2,11 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; import { User } from '@task-stream/api/identity/domain'; import { CurrentUser } from '@task-stream/api/identity/presenters'; import { GitService } from '@task-stream/api/tracker/application'; -import { GitIntegrationGql } from './types'; +import { GitIntegrationGql, GitRepositoryGql } from './types'; 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) export class GitResolver { constructor(private readonly git: GitService) {} @@ -20,24 +20,38 @@ export class GitResolver { return { ...integration, webhookPath: webhookPath(projectId) }; } - @Mutation(() => GitIntegrationGql, { + @Mutation(() => GitRepositoryGql, { 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, @Args('projectId', { type: () => ID }) projectId: string, + @Args('name') name: string, ) { - const integration = await this.git.enable(me.id, projectId); - return { ...integration, webhookPath: webhookPath(projectId) }; + return this.git.addRepository(me.id, projectId, name); } - @Mutation(() => Boolean) - async disableGitIntegration( + @Mutation(() => GitRepositoryGql, { + description: 'A new secret for a repository; the old one stops working', + }) + rotateGitRepositorySecret( @CurrentUser() me: User, @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; } } diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index ddf753e..c8ee272 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -170,23 +170,45 @@ export class DevLinkGql { @Field({ description: 'Short SHA, branch or PR number' }) ref!: string; @Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null; @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() at!: string; } -@ObjectType('GitIntegration', { - description: - 'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it', +@ObjectType('GitRepository', { + description: 'A repository whose webhooks the project accepts', }) -export class GitIntegrationGql { - @Field() enabled!: boolean; +export class GitRepositoryGql { + @Field(() => ID) id!: string; + @Field({ description: "The project's name for it: api, web, admin…" }) + name!: string; @Field(() => String, { 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; - @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; + @Field(() => [GitRepositoryGql]) + repositories!: GitRepositoryGql[]; } @ObjectType('AutomationTriggerSpec') diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index b4497b8..94ddee4 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -1106,3 +1106,17 @@ export class NotificationSettingsDto { @IsBoolean() 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; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index b723862..10f0140 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -162,19 +162,40 @@ export class DevLinkDto { }) state!: string | null; @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({ format: 'date-time' }) at!: string; } -export class GitIntegrationDto { - @ApiProperty() enabled!: boolean; +export class GitRepositoryDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'api' }) name!: string; @ApiProperty({ type: String, 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; - @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 { diff --git a/libs/api/tracker/presenters/src/lib/http/git.controller.ts b/libs/api/tracker/presenters/src/lib/http/git.controller.ts index e45823c..f5a38b0 100644 --- a/libs/api/tracker/presenters/src/lib/http/git.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/git.controller.ts @@ -1,4 +1,5 @@ import { + Body, Controller, Delete, Get, @@ -30,7 +31,12 @@ import { } from '@task-stream/api/shared/presenters'; import { GitService } from '@task-stream/api/tracker/application'; 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'; const PROJECT = ApiParam({ @@ -38,10 +44,16 @@ const PROJECT = ApiParam({ description: 'Project id or key', 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 - * admins. Commits, branches and pull requests that name a ticket key (WEB-12) + * Git hosts: the webhook they call, and the project's repositories (each + * with its own secret) for owners and admins. Commits, branches and pull requests that name a ticket key (WEB-12) * show on that ticket. */ @ApiTags('Git') @@ -56,7 +68,8 @@ export class GitController { @ApiPireonAuth() @PROJECT @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 }) @ApiReadErrors() @@ -67,35 +80,74 @@ export class GitController { const projectId = await this.refs.projectId(me.id, project); return { ...(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() @PROJECT @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() - async enable(@CurrentUser() me: User, @Param('project') project: string) { - const projectId = await this.refs.projectId(me.id, project); - return { - ...(await this.git.enable(me.id, projectId)), - webhookPath: `/api/webhooks/git/${projectId}`, - }; + async addRepository( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: AddGitRepositoryDto, + ) { + 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() @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) - @ApiOperation({ summary: 'Turn the Git webhook off' }) + @ApiOperation({ + summary: + 'Remove a repository: its webhooks are refused, what it linked stays', + }) @ApiNoContentResponse() @ApiCommandErrors() - async disable(@CurrentUser() me: User, @Param('project') project: string) { - await this.git.disable(me.id, await this.refs.projectId(me.id, project)); + async removeRepository( + @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() @@ -105,7 +157,7 @@ export class GitController { @ApiOperation({ summary: '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.', + '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 }) @ApiForbiddenResponse({ diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 52a3966..b62bf0c 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -168,6 +168,8 @@ export interface DevLink { ref: string; state: 'OPEN' | 'MERGED' | 'CLOSED' | null; provider: string; + /** The project's name for the repository it came from. */ + repository: string | null; author: string | null; at: string; } @@ -245,11 +247,22 @@ export interface ImportReport { } /** The project's webhook for GitHub, Gitea or GitLab. */ -export interface GitIntegration { - enabled: boolean; +/** A repository whose webhooks the project accepts. */ +export interface GitRepository { + id: string; + /** The project's name for it: api, web, admin… */ + name: string; /** Only for owners and admins. */ 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; + repositories: GitRepository[]; } export interface Comment { diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 29d88b1..596a307 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,7 @@ import { Comment, Epic, GitIntegration, + GitRepository, Automation, AutomationInput, ImportReport, @@ -200,9 +201,19 @@ export abstract class WebhooksGateway { /** The project's Git webhook (owners and admins configure it). */ export abstract class GitGateway { abstract integration(projectId: string): Observable; - /** On with a new secret (also to rotate it). */ - abstract enable(projectId: string): Observable; - abstract disable(projectId: string): Observable; + /** A repository with its own secret (the first turns the integration on). */ + abstract addRepository( + projectId: string, + name: string, + ): Observable; + abstract rotateSecret( + projectId: string, + repositoryId: string, + ): Observable; + abstract removeRepository( + projectId: string, + repositoryId: string, + ): Observable; } /** Saved views of a project's board (mine and the shared ones). */ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-git.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-git.gateway.ts index e991f00..b73b0ec 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-git.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-git.gateway.ts @@ -1,10 +1,15 @@ 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 { - DisableGitIntegrationDocument, - EnableGitIntegrationDocument, + AddGitRepositoryDocument, GitIntegrationDocument, + RemoveGitRepositoryDocument, + RotateGitRepositorySecretDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; @@ -18,15 +23,24 @@ export class GraphqlGitGateway extends GitGateway { .pipe(map((d) => d.gitIntegration)); } - enable(projectId: string): Observable { + addRepository(projectId: string, name: string): Observable { return this.gql - .mutate(EnableGitIntegrationDocument, { projectId }) - .pipe(map((d) => d.enableGitIntegration)); + .mutate(AddGitRepositoryDocument, { projectId, name }) + .pipe(map((d) => d.addGitRepository)); } - disable(projectId: string): Observable { + rotateSecret( + projectId: string, + repositoryId: string, + ): Observable { return this.gql - .mutate(DisableGitIntegrationDocument, { projectId }) + .mutate(RotateGitRepositorySecretDocument, { projectId, repositoryId }) + .pipe(map((d) => d.rotateGitRepositorySecret)); + } + + removeRepository(projectId: string, repositoryId: string): Observable { + return this.gql + .mutate(RemoveGitRepositoryDocument, { projectId, repositoryId }) .pipe(map(() => undefined)); } } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 2f09486..900aff7 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -120,6 +120,7 @@ fragment IssueFields on Issue { ref state provider + repository author at } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index a7e153a..65436b0 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -531,6 +531,7 @@ export type IssueFieldsFragment = { ref: string; state: DevLinkState | null; provider: string; + repository: string | null; author: string | null; at: string; }>; @@ -585,10 +586,11 @@ export type CommandResultFieldsFragment = { version: number; }; -export type GitIntegrationFieldsFragment = { - enabled: boolean; +export type GitRepositoryFieldsFragment = { + id: string; + name: string; secret: string | null; - webhookPath: string; + createdAt: string; }; export type GitIntegrationQueryVariables = Exact<{ @@ -598,28 +600,50 @@ export type GitIntegrationQueryVariables = Exact<{ export type GitIntegrationQuery = { gitIntegration: { enabled: boolean; - secret: string | null; webhookPath: string; + repositories: Array<{ + id: string; + name: string; + secret: string | null; + createdAt: string; + }>; }; }; -export type EnableGitIntegrationMutationVariables = Exact<{ +export type AddGitRepositoryMutationVariables = Exact<{ projectId: string; + name: string; }>; -export type EnableGitIntegrationMutation = { - enableGitIntegration: { - enabled: boolean; +export type AddGitRepositoryMutation = { + addGitRepository: { + id: string; + name: string; secret: string | null; - webhookPath: string; + createdAt: string; }; }; -export type DisableGitIntegrationMutationVariables = Exact<{ +export type RotateGitRepositorySecretMutationVariables = Exact<{ 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<{ idOrKey: string; @@ -712,6 +736,7 @@ export type IssueQuery = { ref: string; state: DevLinkState | null; provider: string; + repository: string | null; author: string | null; at: string; }>; @@ -2525,6 +2550,7 @@ export const IssueFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'ref' } }, { kind: 'Field', name: { kind: 'Name', value: 'state' } }, { 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: 'at' } }, ], @@ -2806,27 +2832,28 @@ export const CommandResultFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; -export const GitIntegrationFieldsFragmentDoc = { +export const GitRepositoryFieldsFragmentDoc = { kind: 'Document', definitions: [ { kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'GitIntegrationFields' }, + name: { kind: 'Name', value: 'GitRepositoryFields' }, typeCondition: { kind: 'NamedType', - name: { kind: 'Name', value: 'GitIntegration' }, + name: { kind: 'Name', value: 'GitRepository' }, }, selectionSet: { kind: 'SelectionSet', 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: 'webhookPath' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, ], }, }, ], -} as unknown as DocumentNode; +} as unknown as DocumentNode; export const NotificationFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -3620,9 +3647,20 @@ export const GitIntegrationDocument = { selectionSet: { kind: 'SelectionSet', selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, + { kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'GitIntegrationFields' }, + kind: 'Field', + 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', - name: { kind: 'Name', value: 'GitIntegrationFields' }, + name: { kind: 'Name', value: 'GitRepositoryFields' }, typeCondition: { kind: 'NamedType', - name: { kind: 'Name', value: 'GitIntegration' }, + name: { kind: 'Name', value: 'GitRepository' }, }, selectionSet: { kind: 'SelectionSet', 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: 'webhookPath' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, ], }, }, ], } as unknown as DocumentNode; -export const EnableGitIntegrationDocument = { +export const AddGitRepositoryDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'mutation', - name: { kind: 'Name', value: 'EnableGitIntegration' }, + name: { kind: 'Name', value: 'AddGitRepository' }, variableDefinitions: [ { kind: 'VariableDefinition', @@ -3667,13 +3706,24 @@ export const EnableGitIntegrationDocument = { 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: { kind: 'SelectionSet', selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'enableGitIntegration' }, + name: { kind: 'Name', value: 'addGitRepository' }, arguments: [ { kind: 'Argument', @@ -3683,13 +3733,21 @@ export const EnableGitIntegrationDocument = { name: { kind: 'Name', value: 'projectId' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, ], selectionSet: { kind: 'SelectionSet', selections: [ { kind: 'FragmentSpread', - name: { kind: 'Name', value: 'GitIntegrationFields' }, + name: { kind: 'Name', value: 'GitRepositoryFields' }, }, ], }, @@ -3699,32 +3757,33 @@ export const EnableGitIntegrationDocument = { }, { kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'GitIntegrationFields' }, + name: { kind: 'Name', value: 'GitRepositoryFields' }, typeCondition: { kind: 'NamedType', - name: { kind: 'Name', value: 'GitIntegration' }, + name: { kind: 'Name', value: 'GitRepository' }, }, selectionSet: { kind: 'SelectionSet', 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: 'webhookPath' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, ], }, }, ], } as unknown as DocumentNode< - EnableGitIntegrationMutation, - EnableGitIntegrationMutationVariables + AddGitRepositoryMutation, + AddGitRepositoryMutationVariables >; -export const DisableGitIntegrationDocument = { +export const RotateGitRepositorySecretDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'mutation', - name: { kind: 'Name', value: 'DisableGitIntegration' }, + name: { kind: 'Name', value: 'RotateGitRepositorySecret' }, variableDefinitions: [ { kind: 'VariableDefinition', @@ -3737,13 +3796,24 @@ export const DisableGitIntegrationDocument = { 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: 'disableGitIntegration' }, + name: { kind: 'Name', value: 'rotateGitRepositorySecret' }, arguments: [ { kind: 'Argument', @@ -3753,6 +3823,104 @@ export const DisableGitIntegrationDocument = { 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< - DisableGitIntegrationMutation, - DisableGitIntegrationMutationVariables + RemoveGitRepositoryMutation, + RemoveGitRepositoryMutationVariables >; export const BoardDocument = { kind: 'Document', @@ -4341,6 +4509,7 @@ export const IssueDocument = { { kind: 'Field', name: { kind: 'Name', value: 'ref' } }, { kind: 'Field', name: { kind: 'Name', value: 'state' } }, { 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: 'at' } }, ], diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql index 50013e3..784a502 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql @@ -1,21 +1,35 @@ -fragment GitIntegrationFields on GitIntegration { - enabled +fragment GitRepositoryFields on GitRepository { + id + name secret - webhookPath + createdAt } query GitIntegration($projectId: ID!) { gitIntegration(projectId: $projectId) { - ...GitIntegrationFields + enabled + webhookPath + repositories { + ...GitRepositoryFields + } } } -mutation EnableGitIntegration($projectId: ID!) { - enableGitIntegration(projectId: $projectId) { - ...GitIntegrationFields +mutation AddGitRepository($projectId: ID!, $name: String!) { + addGitRepository(projectId: $projectId, name: $name) { + ...GitRepositoryFields } } -mutation DisableGitIntegration($projectId: ID!) { - disableGitIntegration(projectId: $projectId) +mutation RotateGitRepositorySecret($projectId: ID!, $repositoryId: ID!) { + rotateGitRepositorySecret( + projectId: $projectId + repositoryId: $repositoryId + ) { + ...GitRepositoryFields + } +} + +mutation RemoveGitRepository($projectId: ID!, $repositoryId: ID!) { + removeGitRepository(projectId: $projectId, repositoryId: $repositoryId) } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index cd608de..9ed80bf 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -490,7 +490,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; rel="noopener" >{{ link.title }} - {{ link.ref }} + {{ link.repository ? link.repository + ' · ' : '' + }}{{ link.ref }} @if (link.state) { {{ devState(link) diff --git a/libs/web/tracker/presenters/src/lib/ui/git-integration.ts b/libs/web/tracker/presenters/src/lib/ui/git-integration.ts index 63ab362..15e46ae 100644 --- a/libs/web/tracker/presenters/src/lib/ui/git-integration.ts +++ b/libs/web/tracker/presenters/src/lib/ui/git-integration.ts @@ -9,17 +9,22 @@ import { untracked, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { PiTextFieldComponent } from '@pi/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 - * secret into GitHub, Gitea or GitLab, rotate or turn it off. + * Settings card for the Git webhooks: the project's repositories (api, web, + * admin…), each with its own secret, and the URL they all share. */ @Component({ selector: 'ts-git-integration', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], + imports: [PiTextFieldComponent], styleUrl: './settings-card.scss', template: `
@@ -28,8 +33,8 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';

Los commits, ramas y pull requests que nombren un ticket ({{ projectKey() - }}-12) aparecen en su ficha, en «Desarrollo». Funciona con GitHub, - Gitea y GitLab. + }}-12) aparecen en su ficha, en «Desarrollo», con el repositorio del + que vienen. Funciona con GitHub, Gitea y GitLab.

@if (integration(); as git) { @@ -46,60 +51,78 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain'; Copiar - @if (git.secret) { -
Secreto
-
- {{ shown() ? git.secret : '••••••••••••••••' }} - - -
- }

- En el repositorio: Webhooks → Añadir, pega la URL, - tipo application/json, el secreto (en GitLab, como + La misma para todos los repositorios; cada uno lleva su secreto. En + el repositorio: Webhooks → Añadir, pega la URL, + tipo application/json, su secreto (en GitLab, como «Secret token») y los eventos push y pull/merge request.

-
- - -
- } @else { -
- -
+
    + @for (repo of git.repositories; track repo.id) { +
  • + {{ repo.name }} + @if (repo.secret) { + + {{ + shown() === repo.id ? repo.secret : '••••••••••••••••' + }} + + + + + + + + } +
  • + } +
} +
+ + + }
`, @@ -128,17 +151,51 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain'; margin: 0; min-width: 0; } - code { - overflow-wrap: anywhere; - } + } + code { + overflow-wrap: anywhere; } .ts-git__how { margin: 0; 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 { display: flex; + gap: 0.25rem; + } + .ts-git__new { + display: flex; + flex-wrap: wrap; 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 destroyRef = inject(DestroyRef); protected readonly integration = signal(null); - protected readonly shown = signal(false); + /** The repository whose secret is on show. */ + protected readonly shown = signal(null); + protected readonly name = signal(''); constructor() { effect(() => { @@ -172,48 +231,80 @@ export class GitIntegrationComponent { return `${location.origin}${git.webhookPath}`; } - protected enable(): void { - const rotating = this.integration()?.enabled; - if ( - rotating && - !confirm( - 'El secreto actual dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?', - ) - ) + protected add(): void { + const name = this.name().trim(); + if (!name) return; + const taken = this.integration()?.repositories.some( + (r) => r.name.toLowerCase() === name.toLowerCase(), + ); + if (taken) { + this.toast.error(`Ya hay un repositorio «${name}»`); return; + } this.git - .enable(this.projectId()) + .addRepository(this.projectId(), name) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: (git) => { - this.integration.set(git); - this.shown.set(true); - this.toast.success( - rotating ? 'Secreto cambiado' : 'Integración activada', + next: (repo) => { + this.integration.update((git) => + git + ? { + ...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 ( !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; 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)) .subscribe({ next: () => { - this.integration.update((git) => - git ? { ...git, enabled: false, secret: null } : git, - ); - this.toast.success('Integración desactivada'); + this.integration.update((git) => { + if (!git) return git; + const repositories = git.repositories.filter( + (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')) .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, + ); + } }