Compare commits

...

2 Commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:39:59 +02:00
e6ac02d328 feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP:
create a project and read its public showcase; sprints (list, progress
and burndown, plan, change, start, close with carry-over, delete, move a
ticket in or out); epics and the backlog (list epics with progress, set
a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying
which refused; the trash; checklists; following a ticket, removing a
relation, editing and deleting comments; editing and archiving releases,
drafting and saving their notes; notifications.

Searching filters by epic, sprint and dates and sorts by backlog order;
get_issue shows parent, checklist, commits and followers; create and
update take parent and start date.

API: `unreadOnly` and `dryRun` were documented as objects in the
OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:10 +02:00
41 changed files with 2580 additions and 355 deletions

View File

@@ -6314,11 +6314,7 @@
"description": "Only unread ones",
"schema": {
"default": false,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "boolean"
}
},
{
@@ -8091,13 +8087,16 @@
"pireon": []
}
],
"summary": "Whether the Git webhook is on (and its secret, for admins)",
"summary": "The project's repositories and the webhook URL they share (secrets, for admins)",
"tags": [
"Git"
]
}
},
"/api/projects/{project}/git-integration/repositories": {
"post": {
"operationId": "GitController_enable",
"description": "Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.",
"operationId": "GitController_addRepository",
"parameters": [
{
"name": "project",
@@ -8110,13 +8109,23 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddGitRepositoryDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitIntegrationDto"
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
}
@@ -8190,13 +8199,15 @@
"pireon": []
}
],
"summary": "Turn the Git webhook on with a new secret (or rotate it)",
"summary": "Add a repository, with its own secret (owners and admins)",
"tags": [
"Git"
]
}
},
"delete": {
"operationId": "GitController_disable",
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
"post": {
"operationId": "GitController_rotateSecret",
"parameters": [
{
"name": "project",
@@ -8207,6 +8218,125 @@
"example": "WEB",
"type": "string"
}
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Give a repository a new secret (the old one stops working)",
"tags": [
"Git"
]
}
},
"/api/projects/{project}/git-integration/repositories/{repository}": {
"delete": {
"operationId": "GitController_removeRepository",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -8282,7 +8412,7 @@
"pireon": []
}
],
"summary": "Turn the Git webhook off",
"summary": "Remove a repository: its webhooks are refused, what it linked stays",
"tags": [
"Git"
]
@@ -8290,7 +8420,7 @@
},
"/api/webhooks/git/{project}": {
"post": {
"description": "Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.",
"description": "Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.",
"operationId": "GitController_receive",
"parameters": [
{
@@ -8952,11 +9082,7 @@
"description": "Only tell what would be created",
"schema": {
"default": false,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "boolean"
}
}
],
@@ -10187,6 +10313,12 @@
"type": "string",
"example": "gitea"
},
"repository": {
"type": "string",
"nullable": true,
"example": "api",
"description": "The project's name for the repository it came from"
},
"author": {
"type": "string",
"nullable": true
@@ -10203,6 +10335,7 @@
"ref",
"state",
"provider",
"repository",
"author",
"at"
]
@@ -11788,10 +11921,6 @@
"goodFirst"
]
},
"Object": {
"type": "object",
"properties": {}
},
"NotificationKind": {
"type": "string",
"enum": [
@@ -12336,26 +12465,71 @@
"sprintId"
]
},
"GitIntegrationDto": {
"GitRepositoryDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"example": "api"
},
"secret": {
"type": "string",
"nullable": true,
"description": "Webhook secret; only owners and admins see it"
"description": "Webhook secret of this repository; only owners and admins see it"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"secret",
"createdAt"
]
},
"GitIntegrationDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "On while there is at least one repository"
},
"webhookPath": {
"type": "string",
"example": "/api/webhooks/git/3f2c…"
"example": "/api/webhooks/git/3f2c…",
"description": "The same for every repository of the project"
},
"repositories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
},
"required": [
"enabled",
"secret",
"webhookPath"
"webhookPath",
"repositories"
]
},
"AddGitRepositoryDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "api",
"maxLength": 40,
"description": "The project's name for it (api, web, admin…); unique"
}
},
"required": [
"name"
]
},
"GitDeliveryResultDto": {

View File

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

View File

@@ -1182,13 +1182,65 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Whether the Git webhook is on (and its secret, for admins) */
/** The project's repositories and the webhook URL they share (secrets, for admins) */
get: operations["GitController_integration"];
put?: never;
/** Turn the Git webhook on with a new secret (or rotate it) */
post: operations["GitController_enable"];
/** Turn the Git webhook off */
delete: operations["GitController_disable"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Add a repository, with its own secret (owners and admins)
* @description Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.
*/
post: operations["GitController_addRepository"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Give a repository a new secret (the old one stops working) */
post: operations["GitController_rotateSecret"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove a repository: its webhooks are refused, what it linked stays */
delete: operations["GitController_removeRepository"];
options?: never;
head?: never;
patch?: never;
@@ -1205,7 +1257,7 @@ export interface paths {
put?: never;
/**
* Webhook for GitHub, Gitea or GitLab (called by the Git host)
* @description Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.
* @description Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.
*/
post: operations["GitController_receive"];
delete?: never;
@@ -1672,6 +1724,11 @@ export interface components {
state: components["schemas"]["DevLinkState"] | null;
/** @example gitea */
provider: string;
/**
* @description The project's name for the repository it came from
* @example api
*/
repository: string | null;
author: string | null;
/** Format: date-time */
at: string;
@@ -2192,7 +2249,6 @@ export interface components {
/** @description Open tickets labelled good-first-issue */
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
};
Object: Record<string, never>;
/** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationDto: {
@@ -2381,12 +2437,32 @@ export interface components {
*/
sprintId: string | null;
};
GitIntegrationDto: {
enabled: boolean;
/** @description Webhook secret; only owners and admins see it */
GitRepositoryDto: {
/** Format: uuid */
id: string;
/** @example api */
name: string;
/** @description Webhook secret of this repository; only owners and admins see it */
secret: string | null;
/** @example /api/webhooks/git/3f2c… */
/** Format: date-time */
createdAt: string;
};
GitIntegrationDto: {
/** @description On while there is at least one repository */
enabled: boolean;
/**
* @description The same for every repository of the project
* @example /api/webhooks/git/3f2c…
*/
webhookPath: string;
repositories: components["schemas"]["GitRepositoryDto"][];
};
AddGitRepositoryDto: {
/**
* @description The project's name for it (api, web, admin…); unique
* @example api
*/
name: string;
};
GitDeliveryResultDto: {
/** @description Tickets that got something new */
@@ -6766,7 +6842,7 @@ export interface operations {
parameters: {
query?: {
/** @description Only unread ones */
unreadOnly?: components["schemas"]["Object"];
unreadOnly?: boolean;
limit?: number;
};
header?: never;
@@ -8055,7 +8131,7 @@ export interface operations {
};
};
};
GitController_enable: {
GitController_addRepository: {
parameters: {
query?: never;
header?: never;
@@ -8065,14 +8141,18 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AddGitRepositoryDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GitIntegrationDto"];
"application/json": components["schemas"]["GitRepositoryDto"];
};
};
/** @description Invalid input */
@@ -8131,13 +8211,93 @@ export interface operations {
};
};
};
GitController_disable: {
GitController_rotateSecret: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description Repository id */
repository: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GitRepositoryDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
GitController_removeRepository: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description Repository id */
repository: string;
};
cookie?: never;
};
@@ -8679,7 +8839,7 @@ export interface operations {
parameters: {
query?: {
/** @description Only tell what would be created */
dryRun?: components["schemas"]["Object"];
dryRun?: boolean;
};
header?: never;
path: {

View File

@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
import { HealthController } from './health.controller';
import { McpHostsMiddleware } from './mcp-hosts.middleware';
import { BacklogResolver } from './tools/backlog.resolver';
import { IssuesResolver } from './tools/issues.resolver';
import { NotificationsResolver } from './tools/notifications.resolver';
import { ProjectsResolver } from './tools/projects.resolver';
import { ReleasesResolver } from './tools/releases.resolver';
import { SprintsResolver } from './tools/sprints.resolver';
import { UsersResolver } from './tools/users.resolver';
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases).
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, epics, sprints, QA, releases).
Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per
project: read their ids with get_project before creating, updating or moving tickets, and
use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon
token the client sends; without one, only public projects can be read.`;
use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
(list_sprints, list_releases); the backlog is search_issues with noSprint and sort RANK.
Tools act as the user whose Pireon token the client sends; without one, only public
projects can be read.`;
@Module({
imports: [
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
TaskStreamApi,
ProjectsResolver,
IssuesResolver,
BacklogResolver,
SprintsResolver,
ReleasesResolver,
NotificationsResolver,
UsersResolver,
],
})

View File

@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
import { TASKSTREAM_API_URL } from './api/taskstream-api';
import { AppModule } from './app.module';
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
/** What the fake API received. */
interface Received {
method: string;
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
code: 'BUSINESS_RULE',
message: 'Cannot move',
});
if (req.method === 'POST' && req.url === '/api/projects')
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
if (req.url === '/api/issues/bulk')
return reply(200, {
updated: 1,
results: [
{ issueId: ISSUE_1, ok: true, error: null },
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
],
});
if (req.url === '/api/sprints/s1/close')
return reply(201, { id: 's1', version: 3 });
if (req.url?.startsWith('/api/me/notifications?'))
return reply(200, {
unread: 1,
items: [
{
id: 'n1',
kind: 'MENTIONED',
projectId: 'p1',
projectKey: 'WEB',
issueId: ISSUE_1,
issueKey: 'WEB-1',
actor: { id: '101', name: 'Ana' },
summary: 'Ana te ha mencionado en WEB-1',
readAt: null,
createdAt: '2026-10-09T10:00:00Z',
},
],
});
return reply(404, { statusCode: 404, message: 'Not found' });
});
}
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
'change_issue_status',
'get_release',
'whoami',
'create_project',
'list_epics',
'bulk_update_issues',
'delete_issue',
'list_sprints',
'get_sprint',
'close_sprint',
'draft_release_notes',
'add_checklist_item',
'my_notifications',
]),
);
const byName = new Map(tools.map((t) => [t.name, t]));
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
expect(byName.get('delete_issue')?.annotations).toMatchObject({
destructiveHint: true,
});
});
it("calls the API with the caller's own token and compacts the answer", async () => {
@@ -217,6 +265,68 @@ describe('TaskStream MCP', () => {
expect(received).toHaveLength(0);
});
it('creates projects with the key in capitals', async () => {
const result = await call(
'create_project',
{ key: 'docs', name: 'Documentación', isPublic: true },
'pireon-token',
);
expect(result.isError).toBeUndefined();
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/projects',
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
});
});
it('changes many tickets by key and says which refused', async () => {
const result = await call(
'bulk_update_issues',
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
'pireon-token',
);
expect(received.at(-1)).toMatchObject({
method: 'POST',
url: '/api/issues/bulk',
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
});
expect(JSON.parse(result.content[0].text)).toEqual({
updated: 1,
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
});
});
it('closes a sprint carrying the unfinished tickets over', async () => {
await call(
'close_sprint',
{ sprintId: 's1', carryOverTo: 's2' },
'pireon-token',
);
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/sprints/s1/close',
body: { carryOverTo: 's2' },
});
});
it('reads the unread notifications', async () => {
const result = await call('my_notifications', {}, 'pireon-token');
expect(received[0].url).toBe(
'/api/me/notifications?unreadOnly=true&limit=30',
);
expect(JSON.parse(result.content[0].text)).toEqual({
unread: 1,
items: [
expect.objectContaining({
kind: 'MENTIONED',
issue: 'WEB-1',
who: 'Ana',
read: false,
}),
],
});
});
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
function rawPost(headers: Record<string, string>): Promise<number> {
const { port } = new URL(url);

View File

@@ -0,0 +1,243 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi, type TaskStreamClient } from '../api/taskstream-api';
import { issueRef, projectRef } from './params';
import { issueLine } from './summaries';
import { toolResult } from './tool-result';
const READ = { readOnlyHint: true, openWorldHint: false } as const;
const WRITE = {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
} as const;
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* How the work is organised: epics and the tickets hanging from them, the
* backlog order, changes to many tickets at once and the trash.
*/
@Resolver()
export class BacklogResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_epics',
description:
'Epics of a project (top-level tickets that group others) with the progress of the tickets hanging from each one.',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
epics({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/epics', {
params: { path: { project } },
}),
)
).map((epic) => ({
...issueLine(epic.issue),
startDate: epic.issue.startDate,
progress: epic.progress,
})),
);
}
@Tool({
name: 'set_issue_parent',
description:
'Place a ticket under an epic or another ticket of the same project (three levels at most: epic → ticket → subtask), or back to the top (null).',
paramsSchema: z.object({
issue: issueRef,
parent: z.string().nullable().describe('Epic or ticket key, or null'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
setParent(
{ issue, parent }: { issue: string; parent: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/parent', {
params: { path: { issue } },
body: { parent },
}),
),
);
}
@Tool({
name: 'rank_issue',
description:
'Move a ticket in the backlog (board columns follow the same order): say which ticket ends up right above it or right below it; above null puts it at the top, below null at the bottom.',
paramsSchema: z.object({
issue: issueRef,
above: z
.string()
.nullable()
.optional()
.describe('Ticket that ends up right above (null: to the top)'),
below: z
.string()
.nullable()
.optional()
.describe('Ticket that ends up right below (null: to the bottom)'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
rank(
params: { issue: string; above?: string | null; below?: string | null },
ctx: McpContext,
) {
const { issue, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/rank', {
params: { path: { issue } },
body,
}),
),
);
}
@Tool({
name: 'bulk_update_issues',
description:
'The same change on up to 100 tickets: status, assignee, sprint, release, priority, extra labels. Each ticket follows the normal rules and may refuse on its own; the answer says which.',
paramsSchema: z.object({
issues: z.array(issueRef).min(1).max(100),
status: z.string().optional().describe('Status id'),
assigneeId: z.string().optional().describe('User id of a member'),
unassign: z.boolean().optional(),
sprintId: z.string().optional(),
noSprint: z.boolean().optional().describe('Back to the backlog'),
releaseId: z.string().optional(),
noRelease: z.boolean().optional().describe('Out of their release'),
priority: PRIORITY.optional(),
addLabels: z.array(z.string().max(40)).max(20).optional(),
}),
annotations: WRITE,
})
bulk(
params: {
issues: string[];
status?: string;
assigneeId?: string;
unassign?: boolean;
sprintId?: string;
noSprint?: boolean;
releaseId?: string;
noRelease?: boolean;
priority?: z.infer<typeof PRIORITY>;
addLabels?: string[];
},
ctx: McpContext,
) {
const { issues, ...change } = params;
return toolResult(async () => {
const client = this.api.as(ctx);
const refs = await this.ids(client, issues);
const answer = await this.api.data(
client.POST('/api/issues/bulk', {
body: { issueIds: refs.map((r) => r.id), ...change },
}),
);
const keyOf = new Map(refs.map((r) => [r.id, r.ref]));
return {
updated: answer.updated,
refused: answer.results
.filter((r) => !r.ok)
.map((r) => ({ issue: keyOf.get(r.issueId), error: r.error })),
};
});
}
@Tool({
name: 'delete_issue',
description:
'Move a ticket to the trash (whoever opened it, or an owner/admin). It disappears everywhere; restore_issue brings it back within 30 days, then it is deleted for good.',
paramsSchema: z.object({ issue: issueRef }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
trash({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'restore_issue',
description: 'Bring a ticket back from the trash.',
paramsSchema: z.object({ issue: issueRef }),
annotations: { ...WRITE, idempotentHint: true },
})
restore({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/restore', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'list_trash',
description: "A project's tickets in the trash, newest first.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
listTrash({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/trash', {
params: { path: { project } },
}),
)
).map((issue) => ({ ...issueLine(issue), trashedAt: issue.trashedAt })),
);
}
/**
* The bulk endpoint takes ids; people and models talk in keys. Keys are
* looked up a few at a time, ids pass as they are.
*/
private async ids(
client: TaskStreamClient,
refs: string[],
): Promise<{ ref: string; id: string }[]> {
const found: { ref: string; id: string }[] = [];
for (let i = 0; i < refs.length; i += 10) {
const batch = refs.slice(i, i + 10);
found.push(
...(await Promise.all(
batch.map(async (ref) =>
UUID.test(ref)
? { ref, id: ref }
: {
ref,
id: (
await this.api.data(
client.GET('/api/issues/{issue}', {
params: { path: { issue: ref } },
}),
)
).id,
},
),
)),
);
}
return found;
}
}

View File

@@ -11,6 +11,11 @@ const WRITE = {
destructiveHint: false,
openWorldHint: false,
} as const;
const REMOVE = {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
} as const;
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
@@ -35,6 +40,10 @@ const issueFields = {
labels: z.array(z.string().max(40)).max(20).optional(),
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
storyPoints: z.number().int().min(0).max(100).optional(),
startDate: z
.string()
.optional()
.describe('ISO date when work should start (roadmap)'),
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
};
@@ -45,6 +54,7 @@ type IssueFields = {
labels?: string[];
figmaUrl?: string;
storyPoints?: number;
startDate?: string;
dueDate?: string;
};
@@ -95,7 +105,7 @@ export class IssuesResolver {
@Tool({
name: 'search_issues',
description:
'Search the tickets of a project by text (key, title, description) and filters.',
'Search the tickets of a project by text (key, title, description) and filters. The backlog: noSprint with sort RANK.',
paramsSchema: z.object({
project: projectRef,
search: z.string().max(200).optional(),
@@ -108,6 +118,21 @@ export class IssuesResolver {
unassigned: z.boolean().optional(),
releaseId: z.string().optional(),
labels: z.array(z.string()).optional(),
parent: z
.string()
.optional()
.describe('Only the tickets hanging from this epic or ticket (key)'),
sprintId: z.string().optional().describe('Only this sprint'),
noSprint: z
.boolean()
.optional()
.describe('Only tickets in no sprint (the backlog)'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
sort: z
.enum(['RECENT', 'RANK'])
.default('RECENT')
.describe('RECENT: last updated first. RANK: backlog order'),
limit: limit(200, 30),
offset: z.number().int().min(0).default(0),
}),
@@ -126,6 +151,12 @@ export class IssuesResolver {
unassigned?: boolean;
releaseId?: string;
labels?: string[];
parent?: string;
sprintId?: string;
noSprint?: boolean;
dueFrom?: string;
dueTo?: string;
sort: 'RECENT' | 'RANK';
limit: number;
offset: number;
},
@@ -147,6 +178,12 @@ export class IssuesResolver {
unassigned: params.unassigned,
releaseId: params.releaseId,
label: params.labels,
parent: params.parent,
sprintId: params.sprintId,
noSprint: params.noSprint,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
sort: params.sort,
limit: params.limit,
offset: params.offset,
},
@@ -160,7 +197,7 @@ export class IssuesResolver {
@Tool({
name: 'get_issue',
description:
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.',
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.',
paramsSchema: z.object({
issue: issueRef,
withComments: z.boolean().default(true),
@@ -206,10 +243,22 @@ export class IssuesResolver {
description: issue.description,
reporter: issue.reporter.name,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
startDate: issue.startDate,
allowedTransitions: issue.allowedTransitions,
parent: issue.parent,
checklist: issue.checklist,
links: issue.links,
devLinks: issue.devLinks.map((d) => ({
kind: d.kind,
repository: d.repository,
ref: d.ref,
title: d.title,
state: d.state,
url: d.url,
})),
watchers: issue.watchers.map((w) => w.name),
attachments: issue.attachments.length,
trashedAt: issue.trashedAt,
createdAt: issue.createdAt,
comments,
history,
@@ -229,6 +278,10 @@ export class IssuesResolver {
title: z.string().min(1).max(255),
assigneeId: z.string().optional().describe('User id (a project member)'),
releaseId: z.string().optional(),
parent: z
.string()
.optional()
.describe('Epic or ticket of the same project it breaks down (key)'),
...issueFields,
}),
annotations: WRITE,
@@ -240,6 +293,7 @@ export class IssuesResolver {
title: string;
assigneeId?: string;
releaseId?: string;
parent?: string;
},
ctx: McpContext,
) {
@@ -399,4 +453,167 @@ export class IssuesResolver {
),
);
}
@Tool({
name: 'unlink_issues',
description:
'Remove a relation between two tickets (as link_issues made it).',
paramsSchema: z.object({
issue: issueRef,
type: LINK_TYPE,
target: issueRef,
}),
annotations: REMOVE,
})
unlink(
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}/links/{type}/{target}', {
params: { path: params },
}),
),
);
}
@Tool({
name: 'watch_issue',
description:
'Follow a ticket (notifications of its changes and comments) or stop following it.',
paramsSchema: z.object({
issue: issueRef,
follow: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
watch(
{ issue, follow }: { issue: string; follow: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const options = { params: { path: { issue } } };
return toolResult(() =>
this.api.data(
follow
? client.PUT('/api/issues/{issue}/watch', options)
: client.DELETE('/api/issues/{issue}/watch', options),
),
);
}
@Tool({
name: 'edit_comment',
description:
'Reword a comment (only its author). Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
body: z.string().min(1).max(20000),
}),
annotations: { ...WRITE, idempotentHint: true },
})
editComment(
{ commentId, body }: { commentId: string; body: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/comments/{commentId}', {
params: { path: { commentId } },
body: { body },
}),
),
);
}
@Tool({
name: 'delete_comment',
description: 'Delete a comment (its author or a project admin).',
paramsSchema: z.object({ commentId: z.string().min(1) }),
annotations: REMOVE,
})
deleteComment({ commentId }: { commentId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/comments/{commentId}', {
params: { path: { commentId } },
}),
),
);
}
/* ---- Checklist ---- */
@Tool({
name: 'add_checklist_item',
description: "Add a step to a ticket's checklist.",
paramsSchema: z.object({
issue: issueRef,
text: z.string().min(1).max(300),
}),
annotations: WRITE,
})
addChecklistItem(
{ issue, text }: { issue: string; text: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/checklist', {
params: { path: { issue } },
body: { text },
}),
),
);
}
@Tool({
name: 'update_checklist_item',
description:
'Tick, untick or reword a checklist step. Item ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
item: z.string().min(1).describe('Checklist item id'),
done: z.boolean().optional(),
text: z.string().min(1).max(300).optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
updateChecklistItem(
params: { issue: string; item: string; done?: boolean; text?: string },
ctx: McpContext,
) {
const { issue, item, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/issues/{issue}/checklist/{item}', {
params: { path: { issue, item } },
body,
}),
),
);
}
@Tool({
name: 'remove_checklist_item',
description: "Remove a step from a ticket's checklist.",
paramsSchema: z.object({
issue: issueRef,
item: z.string().min(1).describe('Checklist item id'),
}),
annotations: REMOVE,
})
removeChecklistItem(
{ issue, item }: { issue: string; item: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}/checklist/{item}', {
params: { path: { issue, item } },
}),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { limit } from './params';
import { toolResult } from './tool-result';
@Resolver()
export class NotificationsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'my_notifications',
description:
"The user's notifications, newest first: assignments, status changes, failed QA, comments, mentions, followed tickets, reminders.",
paramsSchema: z.object({
unreadOnly: z.boolean().default(true),
limit: limit(100, 30),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
})
mine(
{ unreadOnly, limit }: { unreadOnly: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const page = await this.api.data(
this.api.as(ctx).GET('/api/me/notifications', {
params: { query: { unreadOnly, limit } },
}),
);
return {
unread: page.unread,
items: page.items.map((n) => ({
id: n.id,
kind: n.kind,
project: n.projectKey,
issue: n.issueKey,
who: n.actor?.name ?? null,
summary: n.summary,
read: n.readAt !== null,
at: n.createdAt,
})),
};
});
}
@Tool({
name: 'mark_notifications_read',
description: 'Mark notifications as read: these ids, or all of them.',
paramsSchema: z.object({
ids: z
.array(z.string())
.optional()
.describe('Notification ids; absent: all'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
})
markRead({ ids }: { ids?: string[] }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/me/notifications/read', { body: { ids } }),
),
);
}
}

View File

@@ -13,6 +13,11 @@ export const issueRef = z
export const releaseId = z.string().min(1).describe('Release id');
export const sprintId = z
.string()
.min(1)
.describe('Sprint id (see list_sprints)');
export const limit = (max: number, fallback: number) =>
z
.number()

View File

@@ -37,6 +37,59 @@ export class ProjectsResolver {
);
}
@Tool({
name: 'create_project',
description:
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
paramsSchema: z.object({
key: z
.string()
.regex(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
.describe('2-10 letters or digits, starting with a letter; unique'),
name: z.string().min(1).max(120),
description: z.string().max(5000).optional(),
isPublic: z
.boolean()
.default(false)
.describe('Anyone can read it, also without signing in'),
}),
annotations: { readOnlyHint: false, destructiveHint: false },
})
create(
params: {
key: string;
name: string;
description?: string;
isPublic: boolean;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects', {
body: { ...params, key: params.key.toUpperCase() },
}),
),
);
}
@Tool({
name: 'get_project_showcase',
description:
'The public summary of a public project: epics on the roadmap with dates and progress, latest releases (notes of shipped ones), the sprint under way and open good-first-issue tickets. Paths point into the web app.',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
showcase({ project }: { project: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/explore/projects/{project}/showcase', {
params: { path: { project } },
}),
),
);
}
@Tool({
name: 'get_project',
description:
@@ -72,6 +125,7 @@ export class ProjectsResolver {
assigneeId: z.string().optional().describe('Only this person (user id)'),
releaseId: z.string().optional().describe('Only this release'),
area: z.string().optional().describe('Only this area id'),
sprintId: z.string().optional().describe('Only this sprint'),
perColumn: limit(200, 25),
}),
annotations: READ,
@@ -82,6 +136,7 @@ export class ProjectsResolver {
assigneeId?: string;
releaseId?: string;
area?: string;
sprintId?: string;
perColumn: number;
},
ctx: McpContext,

View File

@@ -54,7 +54,7 @@ export class ReleasesResolver {
@Tool({
name: 'get_release',
description:
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.',
'A release with its progress (finished out of total), its notes, its tickets and, optionally, the day-by-day burnup.',
paramsSchema: z.object({
releaseId,
withBurnup: z.boolean().default(false),
@@ -90,6 +90,7 @@ export class ReleasesResolver {
return {
...releaseLine(release),
description: release.description,
notes: release.notes,
progress,
issues: issues.items.map(issueLine),
burnup,
@@ -148,4 +149,104 @@ export class ReleasesResolver {
),
);
}
@Tool({
name: 'update_release',
description: 'Rename a release, describe it or move its target date.',
paramsSchema: z.object({
releaseId,
name: z.string().min(1).max(60).optional(),
description: z.string().max(5000).nullable().optional(),
targetDate: z.string().nullable().optional().describe('ISO date'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
update(
params: {
releaseId: string;
name?: string;
description?: string | null;
targetDate?: string | null;
},
ctx: McpContext,
) {
const { releaseId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/releases/{releaseId}', {
params: { path: { releaseId } },
body,
}),
),
);
}
@Tool({
name: 'archive_release',
description:
'Archive a planned release that will not ship; its tickets leave it.',
paramsSchema: z.object({ releaseId }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
params: { path: { releaseId } },
}),
),
);
}
@Tool({
name: 'draft_release_notes',
description:
"A Markdown draft of a release's notes from its finished tickets, grouped by type. Nothing is saved: polish it and save it with set_release_notes.",
paramsSchema: z.object({ releaseId }),
annotations: READ,
})
draftNotes({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(
async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/releases/{releaseId}/notes/draft', {
params: { path: { releaseId } },
}),
)
).markdown,
);
}
@Tool({
name: 'set_release_notes',
description:
'Write the notes of a release (Markdown, for people: what shipped). null clears them.',
paramsSchema: z.object({
releaseId,
notes: z.string().max(20000).nullable(),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
setNotes(
{ releaseId, notes }: { releaseId: string; notes: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/releases/{releaseId}/notes', {
params: { path: { releaseId } },
body: { notes },
}),
),
);
}
}

View File

@@ -0,0 +1,240 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { issueRef, limit, projectRef, sprintId } from './params';
import { issueLine, sprintLine } from './summaries';
import { toolResult } from './tool-result';
const READ = { readOnlyHint: true, openWorldHint: false } as const;
const WRITE = {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
} as const;
const date = (what: string) => z.string().describe(`${what} (ISO date)`);
@Resolver()
export class SprintsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_sprints',
description:
"A project's sprints (planned, active and closed) with their dates and goal.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
list({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/sprints', {
params: { path: { project } },
}),
)
).map(sprintLine),
);
}
@Tool({
name: 'get_sprint',
description:
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
paramsSchema: z.object({
sprintId,
withBurndown: z.boolean().default(false),
limit: limit(200, 100),
}),
annotations: READ,
})
get(
params: { sprintId: string; withBurndown: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const client = this.api.as(ctx);
const path = { sprint: params.sprintId };
const [sprint, progress, issues] = await Promise.all([
this.api.data(
client.GET('/api/sprints/{sprint}', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/issues', {
params: { path, query: { limit: params.limit } },
}),
),
]);
const burndown = params.withBurndown
? await this.api.data(
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
)
: undefined;
return {
...sprintLine(sprint),
progress,
total: issues.total,
issues: issues.items.map(issueLine),
burndown,
};
});
}
@Tool({
name: 'create_sprint',
description: 'Plan a sprint in a project (owners and admins).',
paramsSchema: z.object({
project: projectRef,
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
goal: z.string().max(500).optional(),
startDate: date('First day'),
endDate: date('Last day'),
}),
annotations: WRITE,
})
create(
params: {
project: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
},
ctx: McpContext,
) {
const { project, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
params: { path: { project } },
body,
}),
),
);
}
@Tool({
name: 'update_sprint',
description:
'Rename a sprint, change its goal or its dates (not once closed).',
paramsSchema: z.object({
sprintId,
name: z.string().min(1).max(80).optional(),
goal: z.string().max(500).nullable().optional(),
startDate: date('First day').optional(),
endDate: date('Last day').optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
update(
params: {
sprintId: string;
name?: string;
goal?: string | null;
startDate?: string;
endDate?: string;
},
ctx: McpContext,
) {
const { sprintId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
body,
}),
),
);
}
@Tool({
name: 'start_sprint',
description:
'Start a planned sprint (only one can be under way per project).',
paramsSchema: z.object({ sprintId }),
annotations: WRITE,
})
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'close_sprint',
description:
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
paramsSchema: z.object({
sprintId,
carryOverTo: z
.string()
.nullable()
.optional()
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
}),
annotations: WRITE,
})
close(
{
sprintId,
carryOverTo,
}: { sprintId: string; carryOverTo?: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
params: { path: { sprint: sprintId } },
body: { carryOverTo },
}),
),
);
}
@Tool({
name: 'delete_sprint',
description: 'Delete a sprint that never started.',
paramsSchema: z.object({ sprintId }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'set_issue_sprint',
description:
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
paramsSchema: z.object({
issue: issueRef,
sprintId: z.string().nullable().describe('Sprint id, or null'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
setSprint(
{ issue, sprintId }: { issue: string; sprintId: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
params: { path: { issue } },
body: { sprintId },
}),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import type { Schemas } from '../api/taskstream-api';
type Issue = Schemas['IssueDto'];
type Project = Schemas['ProjectDto'];
type Release = Schemas['ReleaseDto'];
type Sprint = Schemas['SprintDto'];
export const issueLine = (issue: Issue) => ({
key: issue.key,
@@ -19,6 +20,10 @@ export const issueLine = (issue: Issue) => ({
area: issue.area,
assignee: issue.assignee?.name ?? null,
release: issue.release?.name ?? null,
sprint: issue.sprint?.name ?? null,
parent: issue.parent?.key ?? null,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
labels: issue.labels,
updatedAt: issue.updatedAt,
});
@@ -61,3 +66,12 @@ export const releaseLine = (release: Release) => ({
targetDate: release.targetDate,
releasedAt: release.releasedAt,
});
export const sprintLine = (sprint: Sprint) => ({
id: sprint.id,
name: sprint.name,
goal: sprint.goal,
status: sprint.status,
startDate: sprint.startDate,
endDate: sprint.endDate,
});

View File

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

View File

@@ -32,35 +32,56 @@ flowchart LR
## 9.2 Herramientas
54 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace |
| --------- | ----------------------- | ------------------------------------------------------------------------------------------- |
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
| | `mark_notifications_read` | Marcar como leídas unas o todas |
| Proyectos | `list_my_projects` | Mis proyectos |
| | `list_public_projects` | Proyectos públicos |
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
| | `get_board` | Tablero por columnas (filtros por persona, release, área) |
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
| | `search_issues` | Buscar por texto y filtros |
| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial |
| | `create_issue` | Crear un ticket |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `assign_issue` | Asignar o desasignar |
| | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` | Comentar |
| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
| | `rank_issue` | Mover un ticket en el backlog |
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
| Sprints | `list_sprints` | Sprints de un proyecto |
| | `get_sprint` | Progreso, tickets y burndown |
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
| Releases | `list_releases` | Releases de un proyecto |
| | `upcoming_releases` | Próximas releases de mis proyectos |
| | `get_release` | Progreso, tickets y burnup |
| | `create_release` | Planificar una release |
| | `get_release` | Progreso, notas, tickets y burnup |
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web.
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
borrar proyectos, webhooks, integración con git, importar y exportar CSV) no se expone: se hace desde la web.
## 9.3 Conectar un cliente

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -802,6 +802,7 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
export class ImportQueryDto {
@ApiPropertyOptional({
type: Boolean,
default: false,
description: 'Only tell what would be created',
})
@@ -1069,7 +1070,11 @@ export class PublishReleaseDto {
// ---- Notifications ----------------------------------------------------------
export class MyNotificationsQueryDto {
@ApiPropertyOptional({ default: false, description: 'Only unread ones' })
@ApiPropertyOptional({
type: Boolean,
default: false,
description: 'Only unread ones',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
@@ -1101,3 +1106,17 @@ export class NotificationSettingsDto {
@IsBoolean()
email!: boolean;
}
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {
@ApiProperty({
example: 'api',
maxLength: 40,
description: "The project's name for it (api, web, admin…); unique",
})
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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