Compare commits
2 Commits
58fe5e8301
...
c2928d2385
| Author | SHA1 | Date | |
|---|---|---|---|
| c2928d2385 | |||
| e6ac02d328 |
@@ -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": {
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal file
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal 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 } }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal 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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
@@ -42,6 +42,7 @@ describe('automation rules', () => {
|
||||
ref: '#1',
|
||||
state: DevLinkState.MERGED,
|
||||
provider: 'github',
|
||||
repository: null,
|
||||
author: null,
|
||||
at: '',
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
81
libs/api/tracker/application/src/lib/git.service.spec.ts
Normal file
81
libs/api/tracker/application/src/lib/git.service.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ fragment IssueFields on Issue {
|
||||
ref
|
||||
state
|
||||
provider
|
||||
repository
|
||||
author
|
||||
at
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user