diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 3bd5e6f..0e0f131 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7561,6 +7561,316 @@ "Sprints" ] } + }, + "/api/projects/{project}/git-integration": { + "get": { + "operationId": "GitController_integration", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GitIntegrationDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Whether the Git webhook is on (and its secret, for admins)", + "tags": [ + "Git" + ] + }, + "post": { + "operationId": "GitController_enable", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GitIntegrationDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Turn the Git webhook on with a new secret (or rotate it)", + "tags": [ + "Git" + ] + }, + "delete": { + "operationId": "GitController_disable", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Turn the Git webhook off", + "tags": [ + "Git" + ] + } + }, + "/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.", + "operationId": "GitController_receive", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GitDeliveryResultDto" + } + } + } + }, + "403": { + "description": "Unknown project, integration off or bad signature" + } + }, + "summary": "Webhook for GitHub, Gitea or GitLab (called by the Git host)", + "tags": [ + "Git" + ], + "security": [] + } } }, "info": { @@ -8582,6 +8892,74 @@ "done" ] }, + "DevLinkKind": { + "type": "string", + "enum": [ + "COMMIT", + "BRANCH", + "PULL_REQUEST" + ] + }, + "DevLinkState": { + "type": "string", + "enum": [ + "OPEN", + "MERGED", + "CLOSED" + ] + }, + "DevLinkDto": { + "type": "object", + "properties": { + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/DevLinkKind" + } + ] + }, + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "ref": { + "type": "string", + "description": "Short SHA, branch or PR number" + }, + "state": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/DevLinkState" + } + ] + }, + "provider": { + "type": "string", + "example": "gitea" + }, + "author": { + "type": "string", + "nullable": true + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kind", + "url", + "title", + "ref", + "state", + "provider", + "author", + "at" + ] + }, "IssueDto": { "type": "object", "properties": { @@ -8744,6 +9122,13 @@ "$ref": "#/components/schemas/ChecklistItemDto" } }, + "devLinks": { + "description": "Commits, branches and pull requests that mention it", + "type": "array", + "items": { + "$ref": "#/components/schemas/DevLinkDto" + } + }, "commentCount": { "type": "number" }, @@ -8788,6 +9173,7 @@ "parent", "rank", "checklist", + "devLinks", "commentCount", "createdAt", "updatedAt", @@ -10395,6 +10781,40 @@ "required": [ "sprintId" ] + }, + "GitIntegrationDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "secret": { + "type": "string", + "nullable": true, + "description": "Webhook secret; only owners and admins see it" + }, + "webhookPath": { + "type": "string", + "example": "/api/webhooks/git/3f2c…" + } + }, + "required": [ + "enabled", + "secret", + "webhookPath" + ] + }, + "GitDeliveryResultDto": { + "type": "object", + "properties": { + "recorded": { + "type": "number", + "description": "Tickets that got something new" + } + }, + "required": [ + "recorded" + ] } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 2a05b32..1fc430e 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -203,6 +203,34 @@ type CumulativeFlow { series: [FlowSeries!]! } +"""A commit, branch or pull request that mentions the ticket""" +type DevLink { + at: String! + author: String + kind: DevLinkKind! + + """github, gitea or gitlab""" + provider: String! + + """Short SHA, branch or PR number""" + ref: String! + state: DevLinkState + title: String! + url: String! +} + +enum DevLinkKind { + BRANCH + COMMIT + PULL_REQUEST +} + +enum DevLinkState { + CLOSED + MERGED + OPEN +} + type DurationStats { averageDays: Float count: Int! @@ -222,6 +250,19 @@ type FlowSeries { status: String! } +""" +Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it +""" +type GitIntegration { + enabled: Boolean! + + """Webhook secret; only owners and admins see it""" + secret: String + + """Path of the webhook on this API""" + webhookPath: String! +} + type Issue { """Statuses this ticket can move to next (project's workflow)""" allowedTransitions: [String!]! @@ -235,6 +276,9 @@ type Issue { comments(limit: Int! = 50, offset: Int! = 0): CommentPage! createdAt: String! description: String + + """Commits, branches and pull requests that mention it""" + devLinks: [DevLink!]! dueDate: String figmaUrl: String @@ -435,8 +479,12 @@ type Mutation { """Delete a sprint that never started; its tickets go back to the backlog""" deleteSprint(sprintId: ID!): CommandResult! + 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! @@ -681,6 +729,7 @@ type Query { A project's epics: top-level tickets that group others, with the progress of those """ epics(projectId: ID!): [Epic!]! + gitIntegration(projectId: ID!): GitIntegration! issue( """Issue id or key (e.g. "TS-42")""" idOrKey: String! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index d8b8c46..fe6fba1 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1097,6 +1097,45 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project}/git-integration": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Whether the Git webhook is on (and its secret, 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"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/webhooks/git/{project}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + 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. + */ + post: operations["GitController_receive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1417,6 +1456,23 @@ export interface components { text: string; done: boolean; }; + /** @enum {string} */ + DevLinkKind: "COMMIT" | "BRANCH" | "PULL_REQUEST"; + /** @enum {string} */ + DevLinkState: "OPEN" | "MERGED" | "CLOSED"; + DevLinkDto: { + kind: components["schemas"]["DevLinkKind"]; + url: string; + title: string; + /** @description Short SHA, branch or PR number */ + ref: string; + state: components["schemas"]["DevLinkState"] | null; + /** @example gitea */ + provider: string; + author: string | null; + /** Format: date-time */ + at: string; + }; IssueDto: { /** Format: uuid */ id: string; @@ -1473,6 +1529,8 @@ export interface components { /** @description Place in the backlog and its board column (ascending, as text) */ rank: string; checklist: components["schemas"]["ChecklistItemDto"][]; + /** @description Commits, branches and pull requests that mention it */ + devLinks: components["schemas"]["DevLinkDto"][]; commentCount: number; /** Format: date-time */ createdAt: string; @@ -2029,6 +2087,17 @@ export interface components { */ sprintId: string | null; }; + GitIntegrationDto: { + enabled: boolean; + /** @description Webhook secret; only owners and admins see it */ + secret: string | null; + /** @example /api/webhooks/git/3f2c… */ + webhookPath: string; + }; + GitDeliveryResultDto: { + /** @description Tickets that got something new */ + recorded: number; + }; }; responses: never; parameters: never; @@ -7233,4 +7302,241 @@ export interface operations { }; }; }; + GitController_integration: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitIntegrationDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + GitController_enable: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitIntegrationDto"]; + }; + }; + /** @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_disable: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + GitController_receive: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitDeliveryResultDto"]; + }; + }; + /** @description Unknown project, integration off or bad signature */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 5e6f258..000bb4e 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -26,6 +26,7 @@ | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | diff --git a/docs/03-api.md b/docs/03-api.md index 3efcd45..e11ea81 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -160,6 +160,13 @@ 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. - **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un bloqueo: la API acepta pasarse y el tablero marca la columna. - **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro. @@ -202,13 +209,13 @@ por defecto); un correo que falla se registra y no bloquea el stream. Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). -| Agregado | Eventos | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | -| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | -| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | -| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | +| Agregado | Eventos | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | +| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. @@ -252,6 +259,7 @@ que funcionan también sin token. | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | +| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto | | `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | | `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | | `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | @@ -268,6 +276,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | | Comentarios | `addComment`, `editComment`, `deleteComment` | +| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | @@ -480,6 +489,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 74caf05..2eead38 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -11,5 +11,8 @@ export * from './lib/notifications.service'; export * from './lib/notifications'; export * from './lib/saved-filters.service'; export * from './lib/sprints.service'; +export * from './lib/git.service'; +export * from './lib/git/git'; +export * from './lib/git/git-payloads'; export * from './lib/saved-filters/saved-filters'; export * from './lib/tracker-application.module'; 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 e2858b4..b7e76e2 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -39,6 +39,9 @@ import { StartSprintCommand, } from './sprint.commands'; import { IssueTree } from './issue-tree'; +import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git'; +import { GitIntegrationsRepository } 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'; import { @@ -67,9 +70,12 @@ import { DeleteCommentCommand } from './delete-comment.command'; import { PublishReleaseCommand } from './publish-release.command'; import { SetIssueReleaseCommand } from './set-issue-release.command'; import { WriteAccess } from './write-access'; +import { ReadAccess } from '../queries/read-access'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; async function setup() { const store = new InMemoryEventStore(); + const keys = new Map(); const sprints = { active: null as { id: string; name: string } | null, findActive: async () => sprints.active, @@ -85,9 +91,16 @@ async function setup() { imports: [CqrsModule.forRoot()], providers: [ ...COMMAND_HANDLERS, + ...GIT_HANDLERS, ...TRACKER_FACTORIES, WriteAccess, IssueTree, + ReadAccess, + { + provide: FindProjectsRepository, + useValue: { findRole: async () => null, findById: async () => null }, + }, + { provide: GitIntegrationsRepository, useValue: {} }, AggregateRehydrator, { provide: EventStore, useValue: store }, { provide: AttachmentStorage, useValue: files }, @@ -108,6 +121,8 @@ async function setup() { { provide: FindIssuesRepository, useValue: { + // Tickets by key, as the read model would answer (filled by tests). + findByKey: async (key: string) => keys.get(key) ?? null, sprintProgress: async () => ({ total: 0, finished: 0, @@ -129,6 +144,7 @@ async function setup() { store, files, sprints, + keys, run, rehydrator: moduleRef.get(AggregateRehydrator), }; @@ -476,6 +492,34 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(ForbiddenError); }); + it('links commits and pull requests to the tickets they name', async () => { + const { run, rehydrator, keys } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}), + ); + keys.set(issue.key ?? '', { id: issue.id, projectId }); + const commit = { + text: `${issue.key}: card form (refs TS-999, OTHER-1)`, + link: { + kind: DevLinkKind.COMMIT, + url: 'https://git.example.org/acme/web/commit/abc', + title: 'card form', + ref: 'abc', + state: null, + provider: 'gitea', + author: 'Ana', + at: '2026-10-08T10:00:00Z', + }, + }; + + expect( + await run(new RecordGitMentionsCommand(projectId, [commit, commit])), + ).toBe(1); + const { devLinks } = await rehydrator.rehydrate(issue.id, Issue); + expect(devLinks.map((l) => l.ref)).toEqual(['abc']); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index e704b9a..d933e6d 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -9,6 +9,7 @@ import { IssueChecklistItemRemovedEvent, IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, + IssueDevLinkRecordedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueLinkType, @@ -162,6 +163,19 @@ export function describeEvent( ? `${actor} metió ${issueKey} en ${sprint}` : `${actor} sacó ${issueKey} de ${sprint}`; } + if (event instanceof IssueDevLinkRecordedEvent) { + const { kind, ref, state, author } = event.link; + const who = author ? ` (${author})` : ''; + if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`; + if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`; + const verb = + state === 'MERGED' + ? 'fusionada' + : state === 'CLOSED' + ? 'cerrada' + : 'abierta'; + return `Pull request ${ref} ${verb} para ${issueKey}${who}`; + } if (event instanceof IssueParentChangedEvent) { return event.parentKey ? `${actor} colocó ${issueKey} bajo ${event.parentKey}` diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index 8d3d2b2..6d45ccf 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -11,6 +11,7 @@ import { IssueChecklistItemRemovedEvent, IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, + IssueDevLinkRecordedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueReleaseChangedEvent, @@ -68,6 +69,7 @@ import { versionOf } from './read-model-notifier'; IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, IssueParentChangedEvent, + IssueDevLinkRecordedEvent, IssueChecklistItemAddedEvent, IssueChecklistItemUpdatedEvent, IssueChecklistItemRemovedEvent, diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index a101d8f..3e96a52 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -33,6 +33,7 @@ import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; import { IssueRankedEventHandler } from './issue-ranked.event-handler'; import { SprintEventHandler } from './sprint.event-handler'; import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler'; +import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler'; import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ @@ -71,6 +72,7 @@ export const EVENT_HANDLERS = [ IssueRankedEventHandler, SprintEventHandler, ReleaseNotesWrittenEventHandler, + IssueDevLinkRecordedEventHandler, IssueSprintChangedEventHandler, ]; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-dev-link-recorded.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-dev-link-recorded.event-handler.ts new file mode 100644 index 0000000..7b89ea1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-dev-link-recorded.event-handler.ts @@ -0,0 +1,27 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueDevLinkRecordedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueDevLinkRecordedEvent) +export class IssueDevLinkRecordedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueDevLinkRecordedEvent) { + // Code moving is not an edit of the ticket: `updatedAt` stays. + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + version: versionOf(event), + }); + await this.upsertMaterializedIssueRepository.putDevLink( + event.aggregateId, + event.link, + ); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/git.service.ts b/libs/api/tracker/application/src/lib/git.service.ts new file mode 100644 index 0000000..56ef598 --- /dev/null +++ b/libs/api/tracker/application/src/lib/git.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { ForbiddenError } from '@task-stream/api/shared/domain'; +import { + DisableGitIntegrationCommand, + EnableGitIntegrationCommand, + GetGitIntegrationQuery, + GitIntegration, + RecordGitMentionsCommand, +} from './git/git'; +import { + gitMentionsOf, + gitProviderOf, + verifyGitSignature, +} from './git/git-payloads'; +import { FindProjectsRepository } from './ports/find-projects.repository'; +import { GitIntegrationsRepository } from './ports/git-integrations.repository'; + +export interface GitDelivery { + /** Project id or key, from the webhook URL. */ + project: string; + headers: Record; + rawBody: Buffer | undefined; + body: Record; +} + +/** Links commits, branches and pull requests to the tickets they name. */ +@Injectable() +export class GitService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly findProjects: FindProjectsRepository, + private readonly integrations: GitIntegrationsRepository, + ) {} + + integration(viewerId: string, projectId: string): Promise { + return this.queryBus.execute( + new GetGitIntegrationQuery(viewerId, projectId), + ); + } + + enable(actorId: string, projectId: string): Promise { + return this.commandBus.execute( + new EnableGitIntegrationCommand(actorId, projectId), + ); + } + + disable(actorId: string, projectId: string): Promise { + return this.commandBus.execute( + new DisableGitIntegrationCommand(actorId, projectId), + ); + } + + /** + * A webhook delivery. 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, + ) + ) + throw new ForbiddenError( + 'Unknown project, integration off or bad signature', + ); + const mentions = gitMentionsOf( + source.provider, + source.event, + delivery.body, + ); + if (!mentions.length) return { recorded: 0 }; + const recorded: number = await this.commandBus.execute( + new RecordGitMentionsCommand(project.id, mentions), + ); + return { recorded }; + } +} diff --git a/libs/api/tracker/application/src/lib/git/git-payloads.spec.ts b/libs/api/tracker/application/src/lib/git/git-payloads.spec.ts new file mode 100644 index 0000000..3c64449 --- /dev/null +++ b/libs/api/tracker/application/src/lib/git/git-payloads.spec.ts @@ -0,0 +1,88 @@ +import { createHmac } from 'node:crypto'; +import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain'; +import { + gitMentionsOf, + gitProviderOf, + verifyGitSignature, +} from './git-payloads'; + +describe('git payloads', () => { + const body = Buffer.from('{"a":1}'); + const hmac = createHmac('sha256', 's3cret').update(body).digest('hex'); + + it('tells the host apart and checks its signature', () => { + expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({ + provider: 'gitea', + event: 'push', + }); + expect(gitProviderOf({})).toBeNull(); + expect( + verifyGitSignature( + 'github', + { 'x-hub-signature-256': `sha256=${hmac}` }, + body, + 's3cret', + ), + ).toBe(true); + expect( + verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'), + ).toBe(false); + expect( + verifyGitSignature( + 'gitlab', + { 'x-gitlab-token': 's3cret' }, + body, + 's3cret', + ), + ).toBe(true); + }); + + it('reads the commits and the branch of a push', () => { + const mentions = gitMentionsOf('gitea', 'push', { + ref: 'refs/heads/web-7-checkout', + repository: { html_url: 'https://git.example.org/acme/web/' }, + pusher: { login: 'ana' }, + commits: [ + { + id: 'abcdef1234567', + message: 'WEB-7: card form\n\nlong text', + url: 'https://git.example.org/acme/web/commit/abcdef1234567', + author: { name: 'Ana' }, + timestamp: '2026-10-08T10:00:00Z', + }, + ], + }); + expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([ + [DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'], + [DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'], + ]); + expect(mentions[1].link.url).toBe( + 'https://git.example.org/acme/web/src/branch/web-7-checkout', + ); + }); + + it('reads pull and merge requests with their state', () => { + const [pr] = gitMentionsOf('github', 'pull_request', { + pull_request: { + html_url: 'https://github.com/acme/web/pull/12', + number: 12, + title: 'Pay by card', + head: { ref: 'web-7' }, + state: 'closed', + merged: true, + }, + }); + expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED }); + expect(pr.text).toContain('web-7'); + const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', { + object_attributes: { + url: 'https://gitlab.com/a/b/-/merge_requests/3', + iid: 3, + title: 'WEB-9', + state: 'opened', + }, + }); + expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN }); + expect(gitMentionsOf('github', 'ping', {})).toEqual([]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/git/git-payloads.ts b/libs/api/tracker/application/src/lib/git/git-payloads.ts new file mode 100644 index 0000000..7fd87b0 --- /dev/null +++ b/libs/api/tracker/application/src/lib/git/git-payloads.ts @@ -0,0 +1,178 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { + DevLink, + DevLinkKind, + DevLinkState, +} from '@task-stream/api/tracker/domain'; + +export type GitProvider = 'github' | 'gitea' | 'gitlab'; + +/** Code the host told us about, and the text where ticket keys may appear. */ +export interface GitMention { + text: string; + link: DevLink; +} + +type Json = Record; // eslint-disable-line @typescript-eslint/no-explicit-any + +/** Which host sent it, from its event header. */ +export function gitProviderOf( + headers: Record, +): { provider: GitProvider; event: string } | null { + if (headers['x-gitea-event']) + return { provider: 'gitea', event: headers['x-gitea-event'] }; + if (headers['x-github-event']) + return { provider: 'github', event: headers['x-github-event'] }; + if (headers['x-gitlab-event']) + return { provider: 'gitlab', event: headers['x-gitlab-event'] }; + return null; +} + +const same = (a: string, b: string) => + a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b)); + +/** + * GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the + * secret itself as a token. + */ +export function verifyGitSignature( + provider: GitProvider, + headers: Record, + rawBody: Buffer | undefined, + secret: string, +): boolean { + if (provider === 'gitlab') + return ( + !!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret) + ); + const received = ( + provider === 'github' + ? headers['x-hub-signature-256'] + : headers['x-gitea-signature'] + )?.replace(/^sha256=/, ''); + if (!received || !rawBody) return false; + return same( + received, + createHmac('sha256', secret).update(rawBody).digest('hex'), + ); +} + +const branchOf = (ref: unknown) => + typeof ref === 'string' && ref.startsWith('refs/heads/') + ? ref.slice('refs/heads/'.length) + : null; + +/** Commits of a push, and the branch they went to (all three hosts). */ +function fromPush( + provider: GitProvider, + body: Json, + repoUrl: string, +): GitMention[] { + const mentions: GitMention[] = []; + const branch = branchOf(body['ref']); + for (const commit of (body['commits'] ?? []) as Json[]) { + if (!commit?.['url'] || !commit['id']) continue; + const message = String(commit['message'] ?? ''); + mentions.push({ + text: message, + link: { + kind: DevLinkKind.COMMIT, + url: String(commit['url']), + title: message, + ref: String(commit['id']).slice(0, 8), + state: null, + provider, + author: commit['author']?.['name'] ?? null, + at: String(commit['timestamp'] ?? new Date().toISOString()), + }, + }); + } + if (branch && repoUrl) { + const path = + provider === 'gitlab' + ? '/-/tree/' + : provider === 'gitea' + ? '/src/branch/' + : '/tree/'; + mentions.push({ + text: branch, + link: { + kind: DevLinkKind.BRANCH, + url: `${repoUrl}${path}${branch}`, + title: branch, + ref: branch, + state: null, + provider, + author: + body['pusher']?.['name'] ?? + body['pusher']?.['login'] ?? + body['user_name'] ?? + null, + at: new Date().toISOString(), + }, + }); + } + return mentions; +} + +function prState(merged: boolean, state: string): DevLinkState { + if (merged || state === 'merged') return DevLinkState.MERGED; + return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN; +} + +/** + * What a webhook delivery is about: commits and branches of a push, or a + * pull/merge request. Anything else (pings, tags, issues…) is nothing. + */ +export function gitMentionsOf( + provider: GitProvider, + event: string, + body: Json, +): GitMention[] { + const repoUrl = String( + body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '', + ).replace(/\/$/, ''); + if (event === 'push' || event === 'Push Hook') + return fromPush(provider, body, repoUrl); + if (provider !== 'gitlab' && event === 'pull_request') { + const pr = body['pull_request'] as Json | undefined; + if (!pr?.['html_url']) return []; + const title = String(pr['title'] ?? ''); + return [ + { + text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`, + link: { + kind: DevLinkKind.PULL_REQUEST, + url: String(pr['html_url']), + title, + ref: `#${pr['number'] ?? ''}`, + state: prState(!!pr['merged'], String(pr['state'] ?? 'open')), + provider, + author: pr['user']?.['login'] ?? null, + at: String(pr['updated_at'] ?? new Date().toISOString()), + }, + }, + ]; + } + if (provider === 'gitlab' && event === 'Merge Request Hook') { + const mr = body['object_attributes'] as Json | undefined; + if (!mr?.['url']) return []; + const title = String(mr['title'] ?? ''); + return [ + { + text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`, + link: { + kind: DevLinkKind.PULL_REQUEST, + url: String(mr['url']), + title, + ref: `!${mr['iid'] ?? ''}`, + state: prState(false, String(mr['state'] ?? 'opened')), + provider, + author: body['user']?.['username'] ?? null, + at: String(mr['updated_at'] ?? new Date().toISOString()), + }, + }, + ]; + } + return []; +} diff --git a/libs/api/tracker/application/src/lib/git/git.ts b/libs/api/tracker/application/src/lib/git/git.ts new file mode 100644 index 0000000..84ad69d --- /dev/null +++ b/libs/api/tracker/application/src/lib/git/git.ts @@ -0,0 +1,157 @@ +import { randomBytes } from 'node:crypto'; +import { + Command, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryHandler, +} from '@nestjs/cqrs'; +import { + Issue, + ProjectRole, + SYSTEM_ACTOR_ID, + issueKeysIn, +} 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 { ReadAccess } from '../queries/read-access'; +import { GitMention } from './git-payloads'; + +export interface GitIntegration { + enabled: boolean; + /** Only for whoever manages the project. */ + secret: string | null; +} + +/** Turns the integration on with a new secret (or rotates it). */ +export class EnableGitIntegrationCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + ) { + super(); + } +} + +export class DisableGitIntegrationCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + ) { + super(); + } +} + +/** Webhook only: code from a verified delivery, linked to the tickets it names. */ +export class RecordGitMentionsCommand extends Command { + constructor( + public readonly projectId: string, + public readonly mentions: GitMention[], + ) { + super(); + } +} + +export class GetGitIntegrationQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly projectId: string, + ) { + super(); + } +} + +@CommandHandler(EnableGitIntegrationCommand) +export class EnableGitIntegrationCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly integrations: GitIntegrationsRepository, + ) {} + + async execute(command: EnableGitIntegrationCommand): 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 }; + } +} + +@CommandHandler(DisableGitIntegrationCommand) +export class DisableGitIntegrationCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly integrations: GitIntegrationsRepository, + ) {} + + async execute(command: DisableGitIntegrationCommand): Promise { + (await this.access.project(command.projectId)).assertCanManage( + command.actorId, + ); + await this.integrations.remove(command.projectId); + } +} + +@CommandHandler(RecordGitMentionsCommand) +export class RecordGitMentionsCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly findIssues: FindIssuesRepository, + ) {} + + /** How many tickets got something new. Unknown keys are ignored. */ + async execute(command: RecordGitMentionsCommand): Promise { + const project = await this.access.project(command.projectId); + let recorded = 0; + for (const mention of command.mentions) { + for (const key of issueKeysIn(mention.text, project.key)) { + const found = await this.findIssues.findByKey(key); + if (!found || found.projectId !== project.id) continue; + const issue = await this.access.live(found.id, Issue); + if (!issue) continue; + const before = issue.getUncommittedEvents().length; + issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link); + if (issue.getUncommittedEvents().length === before) continue; + await issue.commitAndWait(); + recorded++; + } + } + return recorded; + } +} + +@QueryHandler(GetGitIntegrationQuery) +export class GetGitIntegrationQueryHandler + implements IQueryHandler +{ + constructor( + private readonly access: ReadAccess, + private readonly integrations: GitIntegrationsRepository, + ) {} + + async execute(query: GetGitIntegrationQuery): Promise { + const role = await this.access.assertCanView( + query.projectId, + query.viewerId, + ); + const secret = await this.integrations.secretOf(query.projectId); + const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN; + return { enabled: !!secret, secret: manages ? secret : null }; + } +} + +export const GIT_HANDLERS = [ + EnableGitIntegrationCommandHandler, + DisableGitIntegrationCommandHandler, + 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 new file mode 100644 index 0000000..172cc3b --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/git-integrations.repository.ts @@ -0,0 +1,9 @@ +/** + * Per-project secret that Git hosts sign their webhooks with. 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; +} diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 57ccfe6..d9ddcee 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -23,3 +23,4 @@ export * from './notification-mailer'; export * from './saved-filters.repository'; export * from './find-sprints.repository'; export * from './upsert-materialized-sprint.repository'; +export * from './git-integrations.repository'; diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts index bdee5d7..96a7d9c 100644 --- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts @@ -1,5 +1,6 @@ import { AttachmentReadModel, + DevLink, ChecklistItemReadModel, IssueLinkReadModel, IssueReadModel, @@ -14,7 +15,12 @@ export abstract class UpsertMaterializedIssueRepository { Partial< Omit< IssueReadModel, - 'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist' + | 'links' + | 'commentCount' + | 'attachments' + | 'watchers' + | 'checklist' + | 'devLinks' > >, ): Promise; @@ -47,6 +53,8 @@ export abstract class UpsertMaterializedIssueRepository { changes: Partial>, ): Promise; abstract removeChecklistItem(issueId: string, itemId: string): Promise; + /** Adds a commit, branch or pull request, or replaces it (same URL). */ + abstract putDevLink(issueId: string, link: DevLink): Promise; /** Keeps the denormalised parent title of a ticket's children in sync. */ abstract renameParent(parentId: string, title: string): Promise; abstract setCommentCount( diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 20bc3c9..5c24296 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -32,6 +32,8 @@ import { import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { SavedFiltersService } from './saved-filters.service'; import { SprintsService } from './sprints.service'; +import { GitService } from './git.service'; +import { GIT_HANDLERS } from './git/git'; /** Notifications: derived from events, kept and dismissed per person. */ const NOTIFICATIONS = [ @@ -53,6 +55,7 @@ const SERVICES = [ NotificationsService, SavedFiltersService, SprintsService, + GitService, ]; /** @@ -75,6 +78,7 @@ const SERVICES = [ ...SAGAS, ...NOTIFICATIONS, ...SAVED_FILTERS, + ...GIT_HANDLERS, ], exports: SERVICES, }) diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 440b51c..8e3c881 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/issue/mentions'; export * from './lib/issue/issue-hierarchy'; export * from './lib/issue/issue-checklist'; export * from './lib/issue/issue-rank'; +export * from './lib/issue/dev-link'; export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 5758522..2d28c22 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -38,3 +38,4 @@ export * from './sprint-closed.event'; export * from './sprint-deleted.event'; export * from './issue-sprint-changed.event'; export * from './release-notes-written.event'; +export * from './issue-dev-link-recorded.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-dev-link-recorded.event.ts b/libs/api/tracker/domain/src/lib/events/issue-dev-link-recorded.event.ts new file mode 100644 index 0000000..249837b --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-dev-link-recorded.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { DevLink } from '../issue/dev-link'; +import { TrackerEvent } from './tracker.event'; + +/** Code mentioning the ticket was pushed, or a pull request changed state. */ +@AutowiredEvent +export class IssueDevLinkRecordedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly link: DevLink, + ) { + super(actorId, issueId, projectId); + } +} 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 new file mode 100644 index 0000000..b639ebc --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts @@ -0,0 +1,48 @@ +import { IssueFactory } from '../factories/issue.factory'; +import { replay } from '../testing'; +import { DevLinkKind, DevLinkState, issueKeysIn } from './dev-link'; +import { Issue } from './issue'; +import { IssueType } from './issue.enums'; + +const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({ + kind: DevLinkKind.PULL_REQUEST, + url: 'https://git.example.org/acme/web/pulls/12', + title, + ref: '#12', + state, + provider: 'gitea', + author: 'ana', + at: '2026-10-08T10:00:00Z', +}); + +describe('dev links', () => { + it('finds the keys of a project in commit messages and branch names', () => { + expect( + issueKeysIn( + 'fix(web-7): pay; refs WEB-7, WEB-12 and API-3 (WEB-120x)', + 'WEB', + ), + ).toEqual(['WEB-7', 'WEB-12']); + expect(issueKeysIn('feature/web-31-checkout', 'WEB')).toEqual(['WEB-31']); + expect(issueKeysIn('NEWWEB-4', 'WEB')).toEqual([]); + }); + + it('records a pull request once per change of state', () => { + const issue = new IssueFactory().create({ + projectId: 'p', + projectKey: 'WEB', + number: 7, + title: 'Pay', + type: IssueType.FEATURE, + reporterId: 'u', + }); + issue.recordDevLink('system', pr(DevLinkState.OPEN)); + issue.recordDevLink('system', pr(DevLinkState.OPEN)); + issue.recordDevLink('system', pr(DevLinkState.MERGED)); + + expect(issue.getUncommittedEvents()).toHaveLength(3); // created + 2 + expect(replay(issue, Issue).rebuilt.devLinks).toEqual([ + expect.objectContaining({ state: DevLinkState.MERGED }), + ]); + }); +}); diff --git a/libs/api/tracker/domain/src/lib/issue/dev-link.ts b/libs/api/tracker/domain/src/lib/issue/dev-link.ts new file mode 100644 index 0000000..6a215e6 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/dev-link.ts @@ -0,0 +1,60 @@ +import { assertThat } from '@task-stream/api/shared/domain'; + +export enum DevLinkKind { + COMMIT = 'COMMIT', + BRANCH = 'BRANCH', + PULL_REQUEST = 'PULL_REQUEST', +} + +export enum DevLinkState { + OPEN = 'OPEN', + MERGED = 'MERGED', + CLOSED = 'CLOSED', +} + +/** + * Code that mentions a ticket: a commit, a branch or a pull/merge request, + * as the Git host reported it. The URL identifies it. + */ +export interface DevLink { + kind: DevLinkKind; + url: string; + /** Commit message's first line, branch name or PR title. */ + title: string; + /** Short SHA, branch or PR number. */ + ref: string; + /** Pull requests only. */ + state: DevLinkState | null; + /** github, gitea, gitlab. */ + provider: string; + author: string | null; + at: string; +} + +/** A ticket keeps the last this-many pieces of code. */ +export const MAX_DEV_LINKS = 100; + +/** Ticket keys of a project mentioned in a text (WEB-12, web-12), once each. */ +export function issueKeysIn(text: string, projectKey: string): string[] { + const pattern = new RegExp( + `(? `${projectKey.toUpperCase()}-${m[1]}`, + ), + ), + ]; +} + +export function normalizeDevLink(link: DevLink): DevLink { + assertThat(/^https?:\/\//.test(link.url), 'A dev link needs an http(s) URL'); + return { + ...link, + title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref, + ref: link.ref.slice(0, 120), + author: link.author?.slice(0, 120) ?? null, + }; +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 2c75855..e0f3ab5 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -14,6 +14,7 @@ import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; +import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event'; import { IssueRankedEvent } from '../events/issue-ranked.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event'; @@ -34,6 +35,7 @@ import { normalizeChecklistText, } from './issue-checklist'; import { ParentCandidate, assertCanNest } from './issue-hierarchy'; +import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link'; import { implicitRank, rankBetween } from './issue-rank'; import { IssueLinkType, IssuePriority } from './issue.enums'; @@ -118,6 +120,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { /** Place in the backlog once someone moved it (see `backlogRank`). */ public rank: string | null = null; public sprintId: string | null = null; + /** Commits, branches and pull requests that mention it. */ + public devLinks: DevLink[] = []; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -264,6 +268,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** + * Records code that mentions the ticket. The same URL again only counts + * when something changed (a pull request merged, a title edited). + */ + recordDevLink(actorId: string, link: DevLink): void { + const next = normalizeDevLink(link); + const known = this.devLinks.find((l) => l.url === next.url); + if (known && known.state === next.state && known.title === next.title) + return; + this.apply( + new IssueDevLinkRecordedEvent(actorId, this.id, this.projectId, next), + ); + } + /** Moves the ticket in or out of a sprint (null: back to the backlog). */ setSprint(actorId: string, sprintId: string | null): void { if (this.sprintId === sprintId) return; @@ -551,6 +569,13 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.deleted = true; } + onIssueDevLinkRecordedEvent(event: IssueDevLinkRecordedEvent): void { + this.devLinks = [ + ...this.devLinks.filter((l) => l.url !== event.link.url), + { ...event.link }, + ].slice(-MAX_DEV_LINKS); + } + onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void { this.sprintId = event.sprintId; this.updatedAt = event.occurredAt; diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 3cef753..0c564a4 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -1,3 +1,4 @@ +import { DevLink } from '../issue/dev-link'; import { IssueLinkType, IssuePriority } from '../issue/issue.enums'; import { StatusCategory } from '../project/project-settings'; import { UserRefReadModel } from './user-ref.read-model'; @@ -84,6 +85,8 @@ export class IssueReadModel { /** Place in the backlog (and in each board column): ascending. */ rank!: string; checklist!: ChecklistItemReadModel[]; + /** Commits, branches and pull requests that mention it. */ + devLinks!: DevLink[]; commentCount!: number; createdAt!: string; updatedAt!: string; 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 new file mode 100644 index 0000000..c73ca77 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/git-integrations.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { GitIntegrationsRepository } from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { GitIntegrationDoc, ReadModels } from './read-model.schemas'; + +@Injectable() +export class MongoGitIntegrationsRepository extends GitIntegrationsRepository { + constructor( + @InjectModel(ReadModels.GitIntegration) + private readonly integrations: Model, + ) { + super(); + } + + async secretOf(projectId: string): Promise { + const doc = await this.integrations + .findById(projectId) + .lean(); + return doc?.secret ?? null; + } + + async save(projectId: string, secret: string): Promise { + await this.integrations.updateOne( + { _id: projectId }, + { $set: { secret, createdAt: new Date().toISOString() } }, + { 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 28b1bfa..1a3a386 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 @@ -30,6 +30,11 @@ export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; +export interface GitIntegrationDoc { + _id: string; + secret: string; + createdAt: string; +} export interface NotificationSettingsDoc { _id: string; email: boolean; @@ -46,6 +51,7 @@ export const ReadModels = { NotificationSettings: 'NotificationSettings', SavedFilter: 'SavedFilter', Sprint: 'SprintReadModel', + GitIntegration: 'GitIntegration', } as const; const userRef = { _id: false, id: String, name: String }; @@ -192,12 +198,18 @@ export const SavedFilterSchema = new Schema( SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 }); +export const GitIntegrationSchema = new Schema( + { _id: String, secret: String, createdAt: String }, + { ...loose, collection: 'git_integrations' }, +); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, { name: ReadModels.Comment, schema: CommentSchema }, { name: ReadModels.Release, schema: ReleaseSchema }, { name: ReadModels.Sprint, schema: SprintSchema }, + { name: ReadModels.GitIntegration, schema: GitIntegrationSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, @@ -225,6 +237,7 @@ 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 ?? [], checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 3482c9f..3894d2f 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -12,6 +12,8 @@ import { ActivityReadModel, AttachmentReadModel, ChecklistItemReadModel, + DevLink, + MAX_DEV_LINKS, CommentReadModel, IssueLinkReadModel, IssueReadModel, @@ -30,6 +32,7 @@ import { ReadModels, ReleaseDoc, SavedFilterDoc, + GitIntegrationDoc, TimelineDoc, } from './read-model.schemas'; import { upsertIfNewer } from './upsert-if-newer'; @@ -47,6 +50,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized private readonly timeline: Model, @InjectModel(ReadModels.SavedFilter) private readonly savedFilters: Model, + @InjectModel(ReadModels.GitIntegration) + private readonly gitIntegrations: Model, ) { super(); } @@ -58,6 +63,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized this.timeline.deleteMany({ projectId }), // Views of the board of a project that is gone. this.savedFilters.deleteMany({ projectId }), + this.gitIntegrations.deleteOne({ _id: projectId }), ]); } @@ -168,6 +174,18 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs ); } + async putDevLink(issueId: string, link: DevLink) { + // Same URL: the newer report replaces the older one. + await this.issues.updateOne( + { _id: issueId }, + { $pull: { devLinks: { url: link.url } } }, + ); + await this.issues.updateOne( + { _id: issueId }, + { $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } }, + ); + } + async renameParent(parentId: string, title: string) { await this.issues.updateMany( { 'parent.id': parentId }, diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 797d4ee..4e07ca3 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -8,6 +8,7 @@ import { NotificationMailer, NotificationsRepository, SavedFiltersRepository, + GitIntegrationsRepository, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, @@ -60,6 +61,7 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep import { RedisChangeFeed } from './realtime/redis-change-feed'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; +import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoFindSprintsRepository, MongoUpsertMaterializedSprintRepository, @@ -121,6 +123,10 @@ const PORTS = [ // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) { provide: NotificationsRepository, useClass: MongoNotificationsRepository }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, + { + provide: GitIntegrationsRepository, + useClass: MongoGitIntegrationsRepository, + }, { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index 81cbdc6..25c9068 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -8,6 +8,8 @@ import { ReleaseStatus, SprintStatus, StatusCategory, + DevLinkKind, + DevLinkState, } from '@task-stream/api/tracker/domain'; export enum LinkDirection { @@ -22,6 +24,8 @@ registerEnumType(StatusCategory, { 'What a status means, whatever each project calls it. DONE and CANCELLED are finished.', }); registerEnumType(IssueLinkType, { name: 'IssueLinkType' }); +registerEnumType(DevLinkKind, { name: 'DevLinkKind' }); +registerEnumType(DevLinkState, { name: 'DevLinkState' }); registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); registerEnumType(SprintStatus, { diff --git a/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts new file mode 100644 index 0000000..16cb93d --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts @@ -0,0 +1,43 @@ +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'; + +const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`; + +/** The project's Git integration (owners and admins configure it). */ +@Resolver(() => GitIntegrationGql) +export class GitResolver { + constructor(private readonly git: GitService) {} + + @Query(() => GitIntegrationGql) + async gitIntegration( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + const integration = await this.git.integration(me.id, projectId); + return { ...integration, webhookPath: webhookPath(projectId) }; + } + + @Mutation(() => GitIntegrationGql, { + description: + 'Turn the Git webhook on, with a new secret (also to rotate it)', + }) + async enableGitIntegration( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + const integration = await this.git.enable(me.id, projectId); + return { ...integration, webhookPath: webhookPath(projectId) }; + } + + @Mutation(() => Boolean) + async disableGitIntegration( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + await this.git.disable(me.id, projectId); + 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 409947b..6505e43 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -7,6 +7,8 @@ import { ReleaseStatus, SprintStatus, StatusCategory, + DevLinkKind, + DevLinkState, } from '@task-stream/api/tracker/domain'; import { LinkDirection } from './enums'; @@ -139,6 +141,35 @@ export class ChecklistItemGql { @Field() done!: boolean; } +@ObjectType('DevLink', { + description: 'A commit, branch or pull request that mentions the ticket', +}) +export class DevLinkGql { + @Field(() => DevLinkKind) kind!: DevLinkKind; + @Field() url!: string; + @Field() title!: string; + @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 }) 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', +}) +export class GitIntegrationGql { + @Field() enabled!: boolean; + @Field(() => String, { + nullable: true, + description: 'Webhook secret; only owners and admins see it', + }) + secret!: string | null; + @Field({ description: 'Path of the webhook on this API' }) + webhookPath!: string; +} + @ObjectType('IssueParent', { description: 'The ticket another one hangs from' }) export class IssueParentGql { @Field(() => ID) id!: string; @@ -192,6 +223,10 @@ export class IssueGql { }) rank!: string; @Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[]; + @Field(() => [DevLinkGql], { + description: 'Commits, branches and pull requests that mention it', + }) + devLinks!: DevLinkGql[]; @Field(() => Int) commentCount!: number; @Field() createdAt!: string; @Field() updatedAt!: 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 ab4ba2b..724971d 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 @@ -136,6 +136,42 @@ export class ChecklistItemDto { @ApiProperty() done!: boolean; } +export class DevLinkDto { + @ApiProperty({ + enum: ['COMMIT', 'BRANCH', 'PULL_REQUEST'], + enumName: 'DevLinkKind', + }) + kind!: string; + @ApiProperty() url!: string; + @ApiProperty() title!: string; + @ApiProperty({ description: 'Short SHA, branch or PR number' }) ref!: string; + @ApiProperty({ + enum: ['OPEN', 'MERGED', 'CLOSED'], + enumName: 'DevLinkState', + nullable: true, + }) + state!: string | null; + @ApiProperty({ example: 'gitea' }) provider!: string; + @ApiProperty({ type: String, nullable: true }) author!: string | null; + @ApiProperty({ format: 'date-time' }) at!: string; +} + +export class GitIntegrationDto { + @ApiProperty() enabled!: boolean; + @ApiProperty({ + type: String, + nullable: true, + description: 'Webhook secret; only owners and admins see it', + }) + secret!: string | null; + @ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string; +} + +export class GitDeliveryResultDto { + @ApiProperty({ description: 'Tickets that got something new' }) + recorded!: number; +} + export class IssueParentDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ example: 'WEB-3' }) key!: string; @@ -206,6 +242,11 @@ export class IssueDto { }) rank!: string; @ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[]; + @ApiProperty({ + type: [DevLinkDto], + description: 'Commits, branches and pull requests that mention it', + }) + devLinks!: DevLinkDto[]; @ApiProperty() commentCount!: number; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) updatedAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/git.controller.ts b/libs/api/tracker/presenters/src/lib/http/git.controller.ts new file mode 100644 index 0000000..e45823c --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/git.controller.ts @@ -0,0 +1,126 @@ +import { + Controller, + Delete, + Get, + Headers, + HttpCode, + Param, + Post, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, + Public, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { GitService } from '@task-stream/api/tracker/application'; +import { Request } from 'express'; +import { GitDeliveryResultDto, GitIntegrationDto } from './dto/responses.dto'; +import { Refs } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); + +/** + * 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) + * show on that ticket. + */ +@ApiTags('Git') +@Controller() +export class GitController { + constructor( + private readonly git: GitService, + private readonly refs: Refs, + ) {} + + @Get('projects/:project/git-integration') + @ApiPireonAuth() + @PROJECT + @ApiOperation({ + summary: 'Whether the Git webhook is on (and its secret, for admins)', + }) + @ApiOkResponse({ type: GitIntegrationDto }) + @ApiReadErrors() + async integration( + @CurrentUser() me: User, + @Param('project') project: string, + ) { + const projectId = await this.refs.projectId(me.id, project); + return { + ...(await this.git.integration(me.id, projectId)), + webhookPath: `/api/webhooks/git/${projectId}`, + }; + } + + @Post('projects/:project/git-integration') + @ApiPireonAuth() + @PROJECT + @ApiOperation({ + summary: 'Turn the Git webhook on with a new secret (or rotate it)', + }) + @ApiCreatedResponse({ type: GitIntegrationDto }) + @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}`, + }; + } + + @Delete('projects/:project/git-integration') + @ApiPireonAuth() + @PROJECT + @HttpCode(204) + @ApiOperation({ summary: 'Turn the Git webhook off' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async disable(@CurrentUser() me: User, @Param('project') project: string) { + await this.git.disable(me.id, await this.refs.projectId(me.id, project)); + } + + @Public() + @Post('webhooks/git/:project') + @HttpCode(200) + @PROJECT + @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.', + }) + @ApiOkResponse({ type: GitDeliveryResultDto }) + @ApiForbiddenResponse({ + description: 'Unknown project, integration off or bad signature', + }) + receive( + @Param('project') project: string, + @Req() req: RawBodyRequest, + @Headers() headers: Record, + ) { + return this.git.receive({ + project, + headers, + rawBody: req.rawBody, + body: (req.body ?? {}) as Record, + }); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index b01b956..10e471d 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -20,6 +20,8 @@ import { SavedFiltersController } from './http/saved-filters.controller'; import { SavedFilterResolver } from './graphql/saved-filter.resolver'; import { SprintResolver } from './graphql/sprint.resolver'; import { SprintsController } from './http/sprints.controller'; +import { GitController } from './http/git.controller'; +import { GitResolver } from './graphql/git.resolver'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -51,6 +53,7 @@ import { SprintsController } from './http/sprints.controller'; NotificationsController, SavedFiltersController, SprintsController, + GitController, ], providers: [ Refs, @@ -62,6 +65,7 @@ import { SprintsController } from './http/sprints.controller'; NotificationResolver, SavedFilterResolver, SprintResolver, + GitResolver, ], }) export class TrackerPresentersModule { diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 3b77e23..a9ef1dd 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -151,10 +151,33 @@ export interface Issue extends IssueSummary { /** People following the ticket. */ watchers: UserRef[]; checklist: ChecklistItem[]; + /** Commits, branches and pull requests that mention it. */ + devLinks: DevLink[]; allowedTransitions: string[]; createdAt: string; } +/** A commit, branch or pull request that mentions a ticket. */ +export interface DevLink { + kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST'; + url: string; + title: string; + /** Short SHA, branch or PR number. */ + ref: string; + state: 'OPEN' | 'MERGED' | 'CLOSED' | null; + provider: string; + author: string | null; + at: string; +} + +/** The project's webhook for GitHub, Gitea or GitLab. */ +export interface GitIntegration { + enabled: boolean; + /** Only for owners and admins. */ + secret: string | null; + webhookPath: string; +} + export interface Comment { id: string; issueId: string; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 09baff9..80fab70 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -5,6 +5,7 @@ import { BoardColumn, Comment, Epic, + GitIntegration, SavedFilter, Sprint, SprintBurndown, @@ -130,6 +131,14 @@ export abstract class IssuesGateway { ): Observable; } +/** 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; +} + /** Saved views of a project's board (mine and the shared ones). */ export abstract class SavedFiltersGateway { abstract list(projectId: string): Observable; 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 new file mode 100644 index 0000000..e991f00 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-git.gateway.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from '@angular/core'; +import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + DisableGitIntegrationDocument, + EnableGitIntegrationDocument, + GitIntegrationDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlGitGateway extends GitGateway { + private readonly gql = inject(GraphqlOperations); + + integration(projectId: string): Observable { + return this.gql + .query(GitIntegrationDocument, { projectId }) + .pipe(map((d) => d.gitIntegration)); + } + + enable(projectId: string): Observable { + return this.gql + .mutate(EnableGitIntegrationDocument, { projectId }) + .pipe(map((d) => d.enableGitIntegration)); + } + + disable(projectId: string): Observable { + return this.gql + .mutate(DisableGitIntegrationDocument, { projectId }) + .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 06d35ea..3d67339 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -108,6 +108,16 @@ fragment IssueFields on Issue { text done } + devLinks { + kind + url + title + ref + state + provider + author + at + } attachments { id fileName diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index f9bb9da..189c876 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -75,6 +75,10 @@ export type CreateSprintInput = { startDate: string; }; +export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST'; + +export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN'; + export type IssueFilterInput = { areas?: Array | null | undefined; assigneeId?: string | null | undefined; @@ -336,6 +340,16 @@ export type IssueFieldsFragment = { }>; watchers: Array<{ id: string; name: string }>; checklist: Array<{ id: string; text: string; done: boolean }>; + devLinks: Array<{ + kind: DevLinkKind; + url: string; + title: string; + ref: string; + state: DevLinkState | null; + provider: string; + author: string | null; + at: string; + }>; attachments: Array<{ id: string; fileName: string; @@ -387,6 +401,42 @@ export type CommandResultFieldsFragment = { version: number; }; +export type GitIntegrationFieldsFragment = { + enabled: boolean; + secret: string | null; + webhookPath: string; +}; + +export type GitIntegrationQueryVariables = Exact<{ + projectId: string; +}>; + +export type GitIntegrationQuery = { + gitIntegration: { + enabled: boolean; + secret: string | null; + webhookPath: string; + }; +}; + +export type EnableGitIntegrationMutationVariables = Exact<{ + projectId: string; +}>; + +export type EnableGitIntegrationMutation = { + enableGitIntegration: { + enabled: boolean; + secret: string | null; + webhookPath: string; + }; +}; + +export type DisableGitIntegrationMutationVariables = Exact<{ + projectId: string; +}>; + +export type DisableGitIntegrationMutation = { disableGitIntegration: boolean }; + export type BoardQueryVariables = Exact<{ idOrKey: string; releaseId?: string | null | undefined; @@ -469,6 +519,16 @@ export type IssueQuery = { }>; watchers: Array<{ id: string; name: string }>; checklist: Array<{ id: string; text: string; done: boolean }>; + devLinks: Array<{ + kind: DevLinkKind; + url: string; + title: string; + ref: string; + state: DevLinkState | null; + provider: string; + author: string | null; + at: string; + }>; attachments: Array<{ id: string; fileName: string; @@ -1944,6 +2004,23 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'devLinks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'url' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { 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: 'author' } }, + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, @@ -2219,6 +2296,27 @@ export const CommandResultFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const GitIntegrationFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'GitIntegrationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'GitIntegration' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, + { kind: 'Field', name: { kind: 'Name', value: 'secret' } }, + { kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const NotificationFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -2386,6 +2484,188 @@ export const SprintFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const GitIntegrationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'GitIntegration' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'gitIntegration' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'GitIntegrationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'GitIntegrationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'GitIntegration' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, + { kind: 'Field', name: { kind: 'Name', value: 'secret' } }, + { kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const EnableGitIntegrationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'EnableGitIntegration' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'enableGitIntegration' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'GitIntegrationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'GitIntegrationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'GitIntegration' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'enabled' } }, + { kind: 'Field', name: { kind: 'Name', value: 'secret' } }, + { kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + EnableGitIntegrationMutation, + EnableGitIntegrationMutationVariables +>; +export const DisableGitIntegrationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'DisableGitIntegration' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'disableGitIntegration' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + DisableGitIntegrationMutation, + DisableGitIntegrationMutationVariables +>; export const BoardDocument = { kind: 'Document', definitions: [ @@ -2950,6 +3230,23 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'devLinks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'url' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { 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: 'author' } }, + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql new file mode 100644 index 0000000..50013e3 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/git.graphql @@ -0,0 +1,21 @@ +fragment GitIntegrationFields on GitIntegration { + enabled + secret + webhookPath +} + +query GitIntegration($projectId: ID!) { + gitIntegration(projectId: $projectId) { + ...GitIntegrationFields + } +} + +mutation EnableGitIntegration($projectId: ID!) { + enableGitIntegration(projectId: $projectId) { + ...GitIntegrationFields + } +} + +mutation DisableGitIntegration($projectId: ID!) { + disableGitIntegration(projectId: $projectId) +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 540bbb1..33e3c37 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -12,6 +12,7 @@ import { ReleasesGateway, SavedFiltersGateway, SprintsGateway, + GitGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -28,6 +29,7 @@ import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway'; import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway'; +import { GraphqlGitGateway } from './adapters/graphql-git.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -43,5 +45,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, + { provide: GitGateway, useClass: GraphqlGitGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index c61ba09..82792f8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -467,3 +467,56 @@ a.ts-key { .ts-mentions__option:focus-visible { border-color: currentColor; } + +// Code that mentions the ticket (from the Git integration). +.ts-dev { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.ts-dev__item { + display: grid; + grid-template-columns: 4rem minmax(0, 1fr) auto auto auto; + gap: 0.625rem; + align-items: center; + padding: 0.375rem 0; + border-top: 1px solid var(--ts-line); + + @media (max-width: 640px) { + grid-template-columns: 4rem minmax(0, 1fr); + + .ts-dev__ref, + .ts-dev__when { + display: none; + } + } + + .ts-pill[data-state='MERGED'] { + --tone: var(--ts-c-violet, var(--ts-brand)); + } + + .ts-pill[data-state='OPEN'] { + --tone: var(--ts-c-green, var(--ts-brand)); + } +} + +.ts-dev__kind { + color: var(--ts-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ts-dev__title { + overflow: hidden; + color: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ts-dev__when { + font-size: 0.8125rem; +} 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 b2801d7..77741dd 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -38,6 +38,7 @@ import { import { Attachment, Comment, + DevLink, insertMention, IssueLink, IssueLinkType, @@ -457,6 +458,39 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } + @if (issue.devLinks.length) { +
+

+ Desarrollo + {{ issue.devLinks.length }} +

+
    + @for (link of devLinks(); track link.url) { +
  • + {{ devKind(link) }} + {{ link.title }} + {{ link.ref }} + @if (link.state) { + {{ + devState(link) + }} + } + {{ link.author ? link.author + ' · ' : '' + }}{{ ago(link.at) }} +
  • + } +
+
+ } +

Comentarios @@ -887,6 +921,26 @@ export class IssuePage { ) .slice(0, 6); }); + /** Pull requests first, then the newest. */ + protected readonly devLinks = computed(() => + [...(this.facade.issue()?.devLinks ?? [])].sort( + (a, b) => + Number(b.kind === 'PULL_REQUEST') - Number(a.kind === 'PULL_REQUEST') || + b.at.localeCompare(a.at), + ), + ); + protected devKind = (link: DevLink) => + link.kind === 'PULL_REQUEST' + ? 'PR' + : link.kind === 'BRANCH' + ? 'Rama' + : 'Commit'; + protected devState = (link: DevLink) => + link.state === 'MERGED' + ? 'Fusionada' + : link.state === 'CLOSED' + ? 'Cerrada' + : 'Abierta'; protected readonly checklistText = signal(''); protected readonly subtaskTitle = signal(''); protected readonly subtaskType = signal(null); diff --git a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts index 6698a37..ab6b649 100644 --- a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts @@ -162,10 +162,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; [disabled]="drafting()" (execute)="draftNotes()" /> - + Notas + @if (facade.canManage() && facade.project(); as project) { + + } + @if (facade.isOwner() && facade.project(); as project) {
+
+

Integración con Git

+

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

+
+ @if (integration(); as git) { + @if (git.enabled) { +
+
URL del webhook
+
+ {{ url(git) }} + +
+ @if (git.secret) { +
Secreto
+
+ {{ shown() ? git.secret : '••••••••••••••••' }} + + +
+ } +
+

+ En el repositorio: Webhooks → Añadir, pega la URL, + tipo application/json, el secreto (en GitLab, como + «Secret token») y los eventos push y + pull/merge request. +

+
+ + +
+ } @else { +
+ +
+ } + } +
+ `, + styles: ` + .ts-git { + display: grid; + gap: 0.75rem; + h3 { + margin: 0 0 0.25rem; + } + } + .ts-git__facts { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.375rem 1rem; + margin: 0; + dt { + color: var(--ts-muted); + font-size: 0.875rem; + } + dd { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + margin: 0; + min-width: 0; + } + code { + overflow-wrap: anywhere; + } + } + .ts-git__how { + margin: 0; + font-size: 0.875rem; + } + .ts-git__actions { + display: flex; + gap: 0.5rem; + } + `, +}) +export class GitIntegrationComponent { + readonly projectId = input.required(); + readonly projectKey = input.required(); + + private readonly git = inject(GitGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly integration = signal(null); + protected readonly shown = signal(false); + + constructor() { + effect(() => { + const projectId = this.projectId(); + untracked(() => + this.git + .integration(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (git) => this.integration.set(git), + error: () => this.toast.error('No se pudo leer la integración'), + }), + ); + }); + } + + /** The API is served from the same origin as the web app. */ + protected url(git: GitIntegration): string { + 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?', + ) + ) + return; + this.git + .enable(this.projectId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (git) => { + this.integration.set(git); + this.shown.set(true); + this.toast.success( + rotating ? 'Secreto cambiado' : 'Integración activada', + ); + }, + error: () => this.toast.error('No se pudo activar'), + }); + } + + protected disable(): void { + if ( + !confirm( + 'Los repositorios dejarán de enlazar código a los tickets. ¿Desactivar?', + ) + ) + return; + this.git + .disable(this.projectId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.integration.update((git) => + git ? { ...git, enabled: false, secret: null } : git, + ); + this.toast.success('Integración desactivada'); + }, + error: () => this.toast.error('No se pudo desactivar'), + }); + } + + protected copy(text: string): void { + navigator.clipboard + ?.writeText(text) + .then(() => this.toast.success('Copiado')) + .catch(() => this.toast.error('No se pudo copiar')); + } +}