Compare commits
2 Commits
58fe5e8301
...
c2928d2385
| Author | SHA1 | Date | |
|---|---|---|---|
| c2928d2385 | |||
| e6ac02d328 |
@@ -6314,11 +6314,7 @@
|
|||||||
"description": "Only unread ones",
|
"description": "Only unread ones",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"allOf": [
|
"type": "boolean"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -8091,13 +8087,16 @@
|
|||||||
"pireon": []
|
"pireon": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Whether the Git webhook is on (and its secret, for admins)",
|
"summary": "The project's repositories and the webhook URL they share (secrets, for admins)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Git"
|
"Git"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/git-integration/repositories": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "GitController_enable",
|
"description": "Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.",
|
||||||
|
"operationId": "GitController_addRepository",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "project",
|
"name": "project",
|
||||||
@@ -8110,13 +8109,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AddGitRepositoryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
"description": "",
|
"description": "",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/GitIntegrationDto"
|
"$ref": "#/components/schemas/GitRepositoryDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8190,13 +8199,15 @@
|
|||||||
"pireon": []
|
"pireon": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Turn the Git webhook on with a new secret (or rotate it)",
|
"summary": "Add a repository, with its own secret (owners and admins)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Git"
|
"Git"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"delete": {
|
},
|
||||||
"operationId": "GitController_disable",
|
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "GitController_rotateSecret",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "project",
|
"name": "project",
|
||||||
@@ -8207,6 +8218,125 @@
|
|||||||
"example": "WEB",
|
"example": "WEB",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "repository",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Repository id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GitRepositoryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Give a repository a new secret (the old one stops working)",
|
||||||
|
"tags": [
|
||||||
|
"Git"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/git-integration/repositories/{repository}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "GitController_removeRepository",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "repository",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Repository id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -8282,7 +8412,7 @@
|
|||||||
"pireon": []
|
"pireon": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Turn the Git webhook off",
|
"summary": "Remove a repository: its webhooks are refused, what it linked stays",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Git"
|
"Git"
|
||||||
]
|
]
|
||||||
@@ -8290,7 +8420,7 @@
|
|||||||
},
|
},
|
||||||
"/api/webhooks/git/{project}": {
|
"/api/webhooks/git/{project}": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.",
|
"description": "Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.",
|
||||||
"operationId": "GitController_receive",
|
"operationId": "GitController_receive",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -8952,11 +9082,7 @@
|
|||||||
"description": "Only tell what would be created",
|
"description": "Only tell what would be created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"allOf": [
|
"type": "boolean"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -10187,6 +10313,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "gitea"
|
"example": "gitea"
|
||||||
},
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "api",
|
||||||
|
"description": "The project's name for the repository it came from"
|
||||||
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@@ -10203,6 +10335,7 @@
|
|||||||
"ref",
|
"ref",
|
||||||
"state",
|
"state",
|
||||||
"provider",
|
"provider",
|
||||||
|
"repository",
|
||||||
"author",
|
"author",
|
||||||
"at"
|
"at"
|
||||||
]
|
]
|
||||||
@@ -11788,10 +11921,6 @@
|
|||||||
"goodFirst"
|
"goodFirst"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Object": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
},
|
|
||||||
"NotificationKind": {
|
"NotificationKind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -12336,26 +12465,71 @@
|
|||||||
"sprintId"
|
"sprintId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GitIntegrationDto": {
|
"GitRepositoryDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"id": {
|
||||||
"type": "boolean"
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "api"
|
||||||
},
|
},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"description": "Webhook secret; only owners and admins see it"
|
"description": "Webhook secret of this repository; only owners and admins see it"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"secret",
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GitIntegrationDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "On while there is at least one repository"
|
||||||
},
|
},
|
||||||
"webhookPath": {
|
"webhookPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "/api/webhooks/git/3f2c…"
|
"example": "/api/webhooks/git/3f2c…",
|
||||||
|
"description": "The same for every repository of the project"
|
||||||
|
},
|
||||||
|
"repositories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/GitRepositoryDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"secret",
|
"webhookPath",
|
||||||
"webhookPath"
|
"repositories"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AddGitRepositoryDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "api",
|
||||||
|
"maxLength": 40,
|
||||||
|
"description": "The project's name for it (api, web, admin…); unique"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GitDeliveryResultDto": {
|
"GitDeliveryResultDto": {
|
||||||
|
|||||||
@@ -335,6 +335,9 @@ type DevLink {
|
|||||||
|
|
||||||
"""Short SHA, branch or PR number"""
|
"""Short SHA, branch or PR number"""
|
||||||
ref: String!
|
ref: String!
|
||||||
|
|
||||||
|
"""The project's name for the repository it came from"""
|
||||||
|
repository: String
|
||||||
state: DevLinkState
|
state: DevLinkState
|
||||||
title: String!
|
title: String!
|
||||||
url: String!
|
url: String!
|
||||||
@@ -372,18 +375,29 @@ type FlowSeries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
|
Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
|
||||||
"""
|
"""
|
||||||
type GitIntegration {
|
type GitIntegration {
|
||||||
|
"""On while there is at least one repository"""
|
||||||
enabled: Boolean!
|
enabled: Boolean!
|
||||||
|
repositories: [GitRepository!]!
|
||||||
|
|
||||||
"""Webhook secret; only owners and admins see it"""
|
"""Path of the webhook on this API, the same for every repository"""
|
||||||
secret: String
|
|
||||||
|
|
||||||
"""Path of the webhook on this API"""
|
|
||||||
webhookPath: String!
|
webhookPath: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""A repository whose webhooks the project accepts"""
|
||||||
|
type GitRepository {
|
||||||
|
createdAt: String!
|
||||||
|
id: ID!
|
||||||
|
|
||||||
|
"""The project's name for it: api, web, admin…"""
|
||||||
|
name: String!
|
||||||
|
|
||||||
|
"""Webhook secret of this repository; only owners and admins see it"""
|
||||||
|
secret: String
|
||||||
|
}
|
||||||
|
|
||||||
type Issue {
|
type Issue {
|
||||||
"""Statuses this ticket can move to next (project's workflow)"""
|
"""Statuses this ticket can move to next (project's workflow)"""
|
||||||
allowedTransitions: [String!]!
|
allowedTransitions: [String!]!
|
||||||
@@ -590,6 +604,11 @@ type Mutation {
|
|||||||
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
||||||
addComment(body: String!, issueId: ID!): CommandResult!
|
addComment(body: String!, issueId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Add a repository with its own secret (the first turns the integration on)
|
||||||
|
"""
|
||||||
|
addGitRepository(name: String!, projectId: ID!): GitRepository!
|
||||||
|
|
||||||
"""Add a Pireon user to the project"""
|
"""Add a Pireon user to the project"""
|
||||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||||
archiveRelease(releaseId: ID!): CommandResult!
|
archiveRelease(releaseId: ID!): CommandResult!
|
||||||
@@ -638,12 +657,8 @@ type Mutation {
|
|||||||
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
||||||
deleteSprint(sprintId: ID!): CommandResult!
|
deleteSprint(sprintId: ID!): CommandResult!
|
||||||
deleteWebhook(webhookId: ID!): Boolean!
|
deleteWebhook(webhookId: ID!): Boolean!
|
||||||
disableGitIntegration(projectId: ID!): Boolean!
|
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
|
|
||||||
enableGitIntegration(projectId: ID!): GitIntegration!
|
|
||||||
|
|
||||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
@@ -663,11 +678,17 @@ type Mutation {
|
|||||||
"""
|
"""
|
||||||
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
||||||
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Remove a repository; what it linked stays on the tickets"""
|
||||||
|
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Back from the trash"""
|
"""Back from the trash"""
|
||||||
restoreIssue(issueId: ID!): CommandResult!
|
restoreIssue(issueId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""A new secret for a repository; the old one stops working"""
|
||||||
|
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
|
||||||
|
|
||||||
"""Save the current board filters under a name (members of the project)"""
|
"""Save the current board filters under a name (members of the project)"""
|
||||||
saveFilter(input: SaveFilterInput!): ID!
|
saveFilter(input: SaveFilterInput!): ID!
|
||||||
|
|
||||||
|
|||||||
@@ -1182,13 +1182,65 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** Whether the Git webhook is on (and its secret, for admins) */
|
/** The project's repositories and the webhook URL they share (secrets, for admins) */
|
||||||
get: operations["GitController_integration"];
|
get: operations["GitController_integration"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Turn the Git webhook on with a new secret (or rotate it) */
|
post?: never;
|
||||||
post: operations["GitController_enable"];
|
delete?: never;
|
||||||
/** Turn the Git webhook off */
|
options?: never;
|
||||||
delete: operations["GitController_disable"];
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/projects/{project}/git-integration/repositories": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Add a repository, with its own secret (owners and admins)
|
||||||
|
* @description Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.
|
||||||
|
*/
|
||||||
|
post: operations["GitController_addRepository"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Give a repository a new secret (the old one stops working) */
|
||||||
|
post: operations["GitController_rotateSecret"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/projects/{project}/git-integration/repositories/{repository}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Remove a repository: its webhooks are refused, what it linked stays */
|
||||||
|
delete: operations["GitController_removeRepository"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@@ -1205,7 +1257,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Webhook for GitHub, Gitea or GitLab (called by the Git host)
|
* Webhook for GitHub, Gitea or GitLab (called by the Git host)
|
||||||
* @description Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.
|
* @description Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.
|
||||||
*/
|
*/
|
||||||
post: operations["GitController_receive"];
|
post: operations["GitController_receive"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1672,6 +1724,11 @@ export interface components {
|
|||||||
state: components["schemas"]["DevLinkState"] | null;
|
state: components["schemas"]["DevLinkState"] | null;
|
||||||
/** @example gitea */
|
/** @example gitea */
|
||||||
provider: string;
|
provider: string;
|
||||||
|
/**
|
||||||
|
* @description The project's name for the repository it came from
|
||||||
|
* @example api
|
||||||
|
*/
|
||||||
|
repository: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
at: string;
|
at: string;
|
||||||
@@ -2192,7 +2249,6 @@ export interface components {
|
|||||||
/** @description Open tickets labelled good-first-issue */
|
/** @description Open tickets labelled good-first-issue */
|
||||||
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
||||||
};
|
};
|
||||||
Object: Record<string, never>;
|
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
||||||
NotificationDto: {
|
NotificationDto: {
|
||||||
@@ -2381,12 +2437,32 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
sprintId: string | null;
|
sprintId: string | null;
|
||||||
};
|
};
|
||||||
GitIntegrationDto: {
|
GitRepositoryDto: {
|
||||||
enabled: boolean;
|
/** Format: uuid */
|
||||||
/** @description Webhook secret; only owners and admins see it */
|
id: string;
|
||||||
|
/** @example api */
|
||||||
|
name: string;
|
||||||
|
/** @description Webhook secret of this repository; only owners and admins see it */
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
/** @example /api/webhooks/git/3f2c… */
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
GitIntegrationDto: {
|
||||||
|
/** @description On while there is at least one repository */
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* @description The same for every repository of the project
|
||||||
|
* @example /api/webhooks/git/3f2c…
|
||||||
|
*/
|
||||||
webhookPath: string;
|
webhookPath: string;
|
||||||
|
repositories: components["schemas"]["GitRepositoryDto"][];
|
||||||
|
};
|
||||||
|
AddGitRepositoryDto: {
|
||||||
|
/**
|
||||||
|
* @description The project's name for it (api, web, admin…); unique
|
||||||
|
* @example api
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
};
|
};
|
||||||
GitDeliveryResultDto: {
|
GitDeliveryResultDto: {
|
||||||
/** @description Tickets that got something new */
|
/** @description Tickets that got something new */
|
||||||
@@ -6766,7 +6842,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Only unread ones */
|
/** @description Only unread ones */
|
||||||
unreadOnly?: components["schemas"]["Object"];
|
unreadOnly?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -8055,7 +8131,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GitController_enable: {
|
GitController_addRepository: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -8065,14 +8141,18 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AddGitRepositoryDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
201: {
|
201: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["GitIntegrationDto"];
|
"application/json": components["schemas"]["GitRepositoryDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Invalid input */
|
/** @description Invalid input */
|
||||||
@@ -8131,13 +8211,93 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GitController_disable: {
|
GitController_rotateSecret: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
/** @description Project id or key */
|
/** @description Project id or key */
|
||||||
project: string;
|
project: string;
|
||||||
|
/** @description Repository id */
|
||||||
|
repository: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GitRepositoryDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
GitController_removeRepository: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
/** @description Repository id */
|
||||||
|
repository: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
@@ -8679,7 +8839,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Only tell what would be created */
|
/** @description Only tell what would be created */
|
||||||
dryRun?: components["schemas"]["Object"];
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
|
|||||||
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
||||||
|
import { BacklogResolver } from './tools/backlog.resolver';
|
||||||
import { IssuesResolver } from './tools/issues.resolver';
|
import { IssuesResolver } from './tools/issues.resolver';
|
||||||
|
import { NotificationsResolver } from './tools/notifications.resolver';
|
||||||
import { ProjectsResolver } from './tools/projects.resolver';
|
import { ProjectsResolver } from './tools/projects.resolver';
|
||||||
import { ReleasesResolver } from './tools/releases.resolver';
|
import { ReleasesResolver } from './tools/releases.resolver';
|
||||||
|
import { SprintsResolver } from './tools/sprints.resolver';
|
||||||
import { UsersResolver } from './tools/users.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
|
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
|
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
|
use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
|
||||||
token the client sends; without one, only public projects can be read.`;
|
(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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
|
|||||||
TaskStreamApi,
|
TaskStreamApi,
|
||||||
ProjectsResolver,
|
ProjectsResolver,
|
||||||
IssuesResolver,
|
IssuesResolver,
|
||||||
|
BacklogResolver,
|
||||||
|
SprintsResolver,
|
||||||
ReleasesResolver,
|
ReleasesResolver,
|
||||||
|
NotificationsResolver,
|
||||||
UsersResolver,
|
UsersResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
|
|||||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||||
import { AppModule } from './app.module';
|
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. */
|
/** What the fake API received. */
|
||||||
interface Received {
|
interface Received {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
|
|||||||
code: 'BUSINESS_RULE',
|
code: 'BUSINESS_RULE',
|
||||||
message: 'Cannot move',
|
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' });
|
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
|
|||||||
'change_issue_status',
|
'change_issue_status',
|
||||||
'get_release',
|
'get_release',
|
||||||
'whoami',
|
'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]));
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||||
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
||||||
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
|
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 () => {
|
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);
|
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). */
|
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||||
function rawPost(headers: Record<string, string>): Promise<number> {
|
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||||
const { port } = new URL(url);
|
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,
|
destructiveHint: false,
|
||||||
openWorldHint: false,
|
openWorldHint: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
const REMOVE = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
||||||
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
|
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(),
|
labels: z.array(z.string().max(40)).max(20).optional(),
|
||||||
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
||||||
storyPoints: z.number().int().min(0).max(100).optional(),
|
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'),
|
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +54,7 @@ type IssueFields = {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
figmaUrl?: string;
|
figmaUrl?: string;
|
||||||
storyPoints?: number;
|
storyPoints?: number;
|
||||||
|
startDate?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +105,7 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'search_issues',
|
name: 'search_issues',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
project: projectRef,
|
project: projectRef,
|
||||||
search: z.string().max(200).optional(),
|
search: z.string().max(200).optional(),
|
||||||
@@ -108,6 +118,21 @@ export class IssuesResolver {
|
|||||||
unassigned: z.boolean().optional(),
|
unassigned: z.boolean().optional(),
|
||||||
releaseId: z.string().optional(),
|
releaseId: z.string().optional(),
|
||||||
labels: z.array(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),
|
limit: limit(200, 30),
|
||||||
offset: z.number().int().min(0).default(0),
|
offset: z.number().int().min(0).default(0),
|
||||||
}),
|
}),
|
||||||
@@ -126,6 +151,12 @@ export class IssuesResolver {
|
|||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
parent?: string;
|
||||||
|
sprintId?: string;
|
||||||
|
noSprint?: boolean;
|
||||||
|
dueFrom?: string;
|
||||||
|
dueTo?: string;
|
||||||
|
sort: 'RECENT' | 'RANK';
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
},
|
},
|
||||||
@@ -147,6 +178,12 @@ export class IssuesResolver {
|
|||||||
unassigned: params.unassigned,
|
unassigned: params.unassigned,
|
||||||
releaseId: params.releaseId,
|
releaseId: params.releaseId,
|
||||||
label: params.labels,
|
label: params.labels,
|
||||||
|
parent: params.parent,
|
||||||
|
sprintId: params.sprintId,
|
||||||
|
noSprint: params.noSprint,
|
||||||
|
dueFrom: params.dueFrom,
|
||||||
|
dueTo: params.dueTo,
|
||||||
|
sort: params.sort,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
},
|
},
|
||||||
@@ -160,7 +197,7 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_issue',
|
name: 'get_issue',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
issue: issueRef,
|
issue: issueRef,
|
||||||
withComments: z.boolean().default(true),
|
withComments: z.boolean().default(true),
|
||||||
@@ -206,10 +243,22 @@ export class IssuesResolver {
|
|||||||
description: issue.description,
|
description: issue.description,
|
||||||
reporter: issue.reporter.name,
|
reporter: issue.reporter.name,
|
||||||
figmaUrl: issue.figmaUrl,
|
figmaUrl: issue.figmaUrl,
|
||||||
storyPoints: issue.storyPoints,
|
startDate: issue.startDate,
|
||||||
dueDate: issue.dueDate,
|
|
||||||
allowedTransitions: issue.allowedTransitions,
|
allowedTransitions: issue.allowedTransitions,
|
||||||
|
parent: issue.parent,
|
||||||
|
checklist: issue.checklist,
|
||||||
links: issue.links,
|
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,
|
createdAt: issue.createdAt,
|
||||||
comments,
|
comments,
|
||||||
history,
|
history,
|
||||||
@@ -229,6 +278,10 @@ export class IssuesResolver {
|
|||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
assigneeId: z.string().optional().describe('User id (a project member)'),
|
assigneeId: z.string().optional().describe('User id (a project member)'),
|
||||||
releaseId: z.string().optional(),
|
releaseId: z.string().optional(),
|
||||||
|
parent: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Epic or ticket of the same project it breaks down (key)'),
|
||||||
...issueFields,
|
...issueFields,
|
||||||
}),
|
}),
|
||||||
annotations: WRITE,
|
annotations: WRITE,
|
||||||
@@ -240,6 +293,7 @@ export class IssuesResolver {
|
|||||||
title: string;
|
title: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
|
parent?: string;
|
||||||
},
|
},
|
||||||
ctx: McpContext,
|
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 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) =>
|
export const limit = (max: number, fallback: number) =>
|
||||||
z
|
z
|
||||||
.number()
|
.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({
|
@Tool({
|
||||||
name: 'get_project',
|
name: 'get_project',
|
||||||
description:
|
description:
|
||||||
@@ -72,6 +125,7 @@ export class ProjectsResolver {
|
|||||||
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
||||||
releaseId: z.string().optional().describe('Only this release'),
|
releaseId: z.string().optional().describe('Only this release'),
|
||||||
area: z.string().optional().describe('Only this area id'),
|
area: z.string().optional().describe('Only this area id'),
|
||||||
|
sprintId: z.string().optional().describe('Only this sprint'),
|
||||||
perColumn: limit(200, 25),
|
perColumn: limit(200, 25),
|
||||||
}),
|
}),
|
||||||
annotations: READ,
|
annotations: READ,
|
||||||
@@ -82,6 +136,7 @@ export class ProjectsResolver {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
|
sprintId?: string;
|
||||||
perColumn: number;
|
perColumn: number;
|
||||||
},
|
},
|
||||||
ctx: McpContext,
|
ctx: McpContext,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class ReleasesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_release',
|
name: 'get_release',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
releaseId,
|
releaseId,
|
||||||
withBurnup: z.boolean().default(false),
|
withBurnup: z.boolean().default(false),
|
||||||
@@ -90,6 +90,7 @@ export class ReleasesResolver {
|
|||||||
return {
|
return {
|
||||||
...releaseLine(release),
|
...releaseLine(release),
|
||||||
description: release.description,
|
description: release.description,
|
||||||
|
notes: release.notes,
|
||||||
progress,
|
progress,
|
||||||
issues: issues.items.map(issueLine),
|
issues: issues.items.map(issueLine),
|
||||||
burnup,
|
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 Issue = Schemas['IssueDto'];
|
||||||
type Project = Schemas['ProjectDto'];
|
type Project = Schemas['ProjectDto'];
|
||||||
type Release = Schemas['ReleaseDto'];
|
type Release = Schemas['ReleaseDto'];
|
||||||
|
type Sprint = Schemas['SprintDto'];
|
||||||
|
|
||||||
export const issueLine = (issue: Issue) => ({
|
export const issueLine = (issue: Issue) => ({
|
||||||
key: issue.key,
|
key: issue.key,
|
||||||
@@ -19,6 +20,10 @@ export const issueLine = (issue: Issue) => ({
|
|||||||
area: issue.area,
|
area: issue.area,
|
||||||
assignee: issue.assignee?.name ?? null,
|
assignee: issue.assignee?.name ?? null,
|
||||||
release: issue.release?.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,
|
labels: issue.labels,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -61,3 +66,12 @@ export const releaseLine = (release: Release) => ({
|
|||||||
targetDate: release.targetDate,
|
targetDate: release.targetDate,
|
||||||
releasedAt: release.releasedAt,
|
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
|
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||||
- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host
|
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
|
||||||
llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el
|
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
|
||||||
cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una
|
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
|
||||||
clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`,
|
como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que
|
||||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su
|
||||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR
|
||||||
event store.
|
fusionada). Proyecto desconocido, sin repositorios o firma mala responden igual: 403. Cambiar el secreto de un
|
||||||
|
repositorio o quitarlo no afecta a los demás, y lo ya enlazado se queda. Los repositorios viven en MongoDB
|
||||||
|
(`git_integrations`, un documento por proyecto), no en el event store; un proyecto que activó el webhook único de
|
||||||
|
antes conserva su secreto como repositorio «principal».
|
||||||
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
||||||
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
||||||
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
||||||
@@ -322,7 +325,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
@@ -537,7 +540,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
|
|||||||
@@ -32,35 +32,56 @@ flowchart LR
|
|||||||
|
|
||||||
## 9.2 Herramientas
|
## 9.2 Herramientas
|
||||||
|
|
||||||
| Ámbito | Herramienta | Qué hace |
|
54 herramientas, agrupadas por ámbito:
|
||||||
| --------- | ----------------------- | ------------------------------------------------------------------------------------------- |
|
|
||||||
| Usuario | `whoami` | El usuario del token |
|
|
||||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
|
||||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
|
||||||
| | `list_public_projects` | Proyectos públicos |
|
|
||||||
| | `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_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… |
|
|
||||||
| | `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`…) |
|
|
||||||
| 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 |
|
|
||||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
|
||||||
|
|
||||||
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de
|
| Ámbito | Herramienta | Qué hace |
|
||||||
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web.
|
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| 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_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 (é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` / `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, 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, 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
|
## 9.3 Conectar un cliente
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -42,6 +42,7 @@ describe('automation rules', () => {
|
|||||||
ref: '#1',
|
ref: '#1',
|
||||||
state: DevLinkState.MERGED,
|
state: DevLinkState.MERGED,
|
||||||
provider: 'github',
|
provider: 'github',
|
||||||
|
repository: null,
|
||||||
author: null,
|
author: null,
|
||||||
at: '',
|
at: '',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ConcurrencyError,
|
ConcurrencyError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
} from '@task-stream/api/shared/domain';
|
} from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
@@ -40,7 +41,14 @@ import {
|
|||||||
StartSprintCommand,
|
StartSprintCommand,
|
||||||
} from './sprint.commands';
|
} from './sprint.commands';
|
||||||
import { IssueTree } from './issue-tree';
|
import { IssueTree } from './issue-tree';
|
||||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
import {
|
||||||
|
AddGitRepositoryCommand,
|
||||||
|
GIT_HANDLERS,
|
||||||
|
GitRepository,
|
||||||
|
RecordGitMentionsCommand,
|
||||||
|
RemoveGitRepositoryCommand,
|
||||||
|
RotateGitRepositorySecretCommand,
|
||||||
|
} from '../git/git';
|
||||||
import { RunAutomationCommand } from '../automations/automations';
|
import { RunAutomationCommand } from '../automations/automations';
|
||||||
import { RunAutomationCommandHandler } from '../automations/automations';
|
import { RunAutomationCommandHandler } from '../automations/automations';
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +56,10 @@ import {
|
|||||||
AutomationTriggerKind,
|
AutomationTriggerKind,
|
||||||
} from '../automations/automation-rules';
|
} from '../automations/automation-rules';
|
||||||
import { Automation } from '../ports/automations.repository';
|
import { Automation } from '../ports/automations.repository';
|
||||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
import {
|
||||||
|
GitIntegrationsRepository,
|
||||||
|
GitRepositoryConfig,
|
||||||
|
} from '../ports/git-integrations.repository';
|
||||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||||
import { RankIssueCommand } from './rank-issue.command';
|
import { RankIssueCommand } from './rank-issue.command';
|
||||||
@@ -86,6 +97,14 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
|||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const store = new InMemoryEventStore();
|
const store = new InMemoryEventStore();
|
||||||
|
// The project's Git repositories, as Mongo would keep them.
|
||||||
|
const gitRepositories = new Map<string, GitRepositoryConfig[]>();
|
||||||
|
const git: GitIntegrationsRepository = {
|
||||||
|
repositoriesOf: async (projectId) => gitRepositories.get(projectId) ?? [],
|
||||||
|
saveAll: async (projectId, list) => {
|
||||||
|
gitRepositories.set(projectId, list);
|
||||||
|
},
|
||||||
|
};
|
||||||
const keys = new Map<string, { id: string; projectId: string }>();
|
const keys = new Map<string, { id: string; projectId: string }>();
|
||||||
const sprints = {
|
const sprints = {
|
||||||
active: null as { id: string; name: string } | null,
|
active: null as { id: string; name: string } | null,
|
||||||
@@ -112,7 +131,7 @@ async function setup() {
|
|||||||
provide: FindProjectsRepository,
|
provide: FindProjectsRepository,
|
||||||
useValue: { findRole: async () => null, findById: async () => null },
|
useValue: { findRole: async () => null, findById: async () => null },
|
||||||
},
|
},
|
||||||
{ provide: GitIntegrationsRepository, useValue: {} },
|
{ provide: GitIntegrationsRepository, useValue: git },
|
||||||
AggregateRehydrator,
|
AggregateRehydrator,
|
||||||
{ provide: EventStore, useValue: store },
|
{ provide: EventStore, useValue: store },
|
||||||
{ provide: AttachmentStorage, useValue: files },
|
{ provide: AttachmentStorage, useValue: files },
|
||||||
@@ -157,6 +176,7 @@ async function setup() {
|
|||||||
files,
|
files,
|
||||||
sprints,
|
sprints,
|
||||||
keys,
|
keys,
|
||||||
|
git,
|
||||||
run,
|
run,
|
||||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||||
};
|
};
|
||||||
@@ -520,16 +540,52 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
ref: 'abc',
|
ref: 'abc',
|
||||||
state: null,
|
state: null,
|
||||||
provider: 'gitea',
|
provider: 'gitea',
|
||||||
|
repository: null,
|
||||||
author: 'Ana',
|
author: 'Ana',
|
||||||
at: '2026-10-08T10:00:00Z',
|
at: '2026-10-08T10:00:00Z',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
|
await run(
|
||||||
|
new RecordGitMentionsCommand(projectId, 'web', [commit, commit]),
|
||||||
|
),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
|
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
|
||||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
expect(devLinks.map((l) => [l.ref, l.repository])).toEqual([
|
||||||
|
['abc', 'web'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps several Git repositories, each with its own secret', async () => {
|
||||||
|
const { run, git } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const add = (actor: string, name: string) =>
|
||||||
|
run(
|
||||||
|
new AddGitRepositoryCommand(actor, projectId, name),
|
||||||
|
) as unknown as Promise<GitRepository>;
|
||||||
|
|
||||||
|
const api = await add('owner', 'api');
|
||||||
|
const web = await add('owner', ' web ');
|
||||||
|
expect(web.name).toBe('web');
|
||||||
|
expect(api.secret).toMatch(/^[0-9a-f]{48}$/);
|
||||||
|
expect(web.secret).not.toBe(api.secret);
|
||||||
|
await expect(add('owner', 'API')).rejects.toThrow(/already/);
|
||||||
|
await expect(add('owner', '')).rejects.toThrow(ValidationError);
|
||||||
|
await expect(add('dev', 'admin')).rejects.toThrow(ForbiddenError);
|
||||||
|
|
||||||
|
const rotated = (await run(
|
||||||
|
new RotateGitRepositorySecretCommand('owner', projectId, api.id),
|
||||||
|
)) as unknown as GitRepository;
|
||||||
|
expect(rotated.secret).not.toBe(api.secret);
|
||||||
|
await run(new RemoveGitRepositoryCommand('owner', projectId, web.id));
|
||||||
|
await expect(
|
||||||
|
run(new RemoveGitRepositoryCommand('owner', projectId, web.id)),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(await git.repositoriesOf(projectId)).toEqual([
|
||||||
|
expect.objectContaining({ name: 'api', secret: rotated.secret }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs automation actions as TaskStream, within the workflow', async () => {
|
it('runs automation actions as TaskStream, within the workflow', async () => {
|
||||||
|
|||||||
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 { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
DisableGitIntegrationCommand,
|
AddGitRepositoryCommand,
|
||||||
EnableGitIntegrationCommand,
|
|
||||||
GetGitIntegrationQuery,
|
GetGitIntegrationQuery,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
|
GitRepository,
|
||||||
RecordGitMentionsCommand,
|
RecordGitMentionsCommand,
|
||||||
|
RemoveGitRepositoryCommand,
|
||||||
|
RotateGitRepositorySecretCommand,
|
||||||
} from './git/git';
|
} from './git/git';
|
||||||
import {
|
import {
|
||||||
gitMentionsOf,
|
gitMentionsOf,
|
||||||
@@ -40,41 +42,59 @@ export class GitService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(actorId: string, projectId: string): Promise<GitIntegration> {
|
addRepository(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<GitRepository> {
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new EnableGitIntegrationCommand(actorId, projectId),
|
new AddGitRepositoryCommand(actorId, projectId, name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(actorId: string, projectId: string): Promise<void> {
|
rotateSecret(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
repositoryId: string,
|
||||||
|
): Promise<GitRepository> {
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new DisableGitIntegrationCommand(actorId, projectId),
|
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRepository(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
repositoryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A webhook delivery. Unknown project, integration off or a bad signature
|
* A webhook delivery. Every repository of the project shares the URL; the
|
||||||
* all look the same from outside (403): nothing to learn by probing.
|
* secret that verifies the signature says which one it came from. Unknown
|
||||||
|
* project, integration off or a bad signature all look the same from
|
||||||
|
* outside (403): nothing to learn by probing.
|
||||||
*/
|
*/
|
||||||
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
|
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
|
||||||
const project =
|
const project =
|
||||||
(await this.findProjects.findById(delivery.project)) ??
|
(await this.findProjects.findById(delivery.project)) ??
|
||||||
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
||||||
const secret = project
|
|
||||||
? await this.integrations.secretOf(project.id)
|
|
||||||
: null;
|
|
||||||
const source = gitProviderOf(delivery.headers);
|
const source = gitProviderOf(delivery.headers);
|
||||||
if (
|
const repository =
|
||||||
!project ||
|
project && source
|
||||||
!secret ||
|
? (await this.integrations.repositoriesOf(project.id)).find((r) =>
|
||||||
!source ||
|
verifyGitSignature(
|
||||||
!verifyGitSignature(
|
source.provider,
|
||||||
source.provider,
|
delivery.headers,
|
||||||
delivery.headers,
|
delivery.rawBody,
|
||||||
delivery.rawBody,
|
r.secret,
|
||||||
secret,
|
),
|
||||||
)
|
)
|
||||||
)
|
: undefined;
|
||||||
|
if (!project || !source || !repository)
|
||||||
throw new ForbiddenError(
|
throw new ForbiddenError(
|
||||||
'Unknown project, integration off or bad signature',
|
'Unknown project, integration off or bad signature',
|
||||||
);
|
);
|
||||||
@@ -85,7 +105,7 @@ export class GitService {
|
|||||||
);
|
);
|
||||||
if (!mentions.length) return { recorded: 0 };
|
if (!mentions.length) return { recorded: 0 };
|
||||||
const recorded: number = await this.commandBus.execute(
|
const recorded: number = await this.commandBus.execute(
|
||||||
new RecordGitMentionsCommand(project.id, mentions),
|
new RecordGitMentionsCommand(project.id, repository.name, mentions),
|
||||||
);
|
);
|
||||||
return { recorded };
|
return { recorded };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function fromPush(
|
|||||||
ref: String(commit['id']).slice(0, 8),
|
ref: String(commit['id']).slice(0, 8),
|
||||||
state: null,
|
state: null,
|
||||||
provider,
|
provider,
|
||||||
|
repository: null,
|
||||||
author: commit['author']?.['name'] ?? null,
|
author: commit['author']?.['name'] ?? null,
|
||||||
at: String(commit['timestamp'] ?? new Date().toISOString()),
|
at: String(commit['timestamp'] ?? new Date().toISOString()),
|
||||||
},
|
},
|
||||||
@@ -103,6 +104,7 @@ function fromPush(
|
|||||||
ref: branch,
|
ref: branch,
|
||||||
state: null,
|
state: null,
|
||||||
provider,
|
provider,
|
||||||
|
repository: null,
|
||||||
author:
|
author:
|
||||||
body['pusher']?.['name'] ??
|
body['pusher']?.['name'] ??
|
||||||
body['pusher']?.['login'] ??
|
body['pusher']?.['login'] ??
|
||||||
@@ -148,6 +150,7 @@ export function gitMentionsOf(
|
|||||||
ref: `#${pr['number'] ?? ''}`,
|
ref: `#${pr['number'] ?? ''}`,
|
||||||
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
|
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
|
||||||
provider,
|
provider,
|
||||||
|
repository: null,
|
||||||
author: pr['user']?.['login'] ?? null,
|
author: pr['user']?.['login'] ?? null,
|
||||||
at: String(pr['updated_at'] ?? new Date().toISOString()),
|
at: String(pr['updated_at'] ?? new Date().toISOString()),
|
||||||
},
|
},
|
||||||
@@ -168,6 +171,7 @@ export function gitMentionsOf(
|
|||||||
ref: `!${mr['iid'] ?? ''}`,
|
ref: `!${mr['iid'] ?? ''}`,
|
||||||
state: prState(false, String(mr['state'] ?? 'opened')),
|
state: prState(false, String(mr['state'] ?? 'opened')),
|
||||||
provider,
|
provider,
|
||||||
|
repository: null,
|
||||||
author: body['user']?.['username'] ?? null,
|
author: body['user']?.['username'] ?? null,
|
||||||
at: String(mr['updated_at'] ?? new Date().toISOString()),
|
at: String(mr['updated_at'] ?? new Date().toISOString()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
QueryHandler,
|
QueryHandler,
|
||||||
} from '@nestjs/cqrs';
|
} from '@nestjs/cqrs';
|
||||||
|
import { NotFoundError, assertThat } from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
Issue,
|
Issue,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
@@ -15,30 +16,61 @@ import {
|
|||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { WriteAccess } from '../commands/write-access';
|
import { WriteAccess } from '../commands/write-access';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
import {
|
||||||
|
GitIntegrationsRepository,
|
||||||
|
GitRepositoryConfig,
|
||||||
|
} from '../ports/git-integrations.repository';
|
||||||
import { ReadAccess } from '../queries/read-access';
|
import { ReadAccess } from '../queries/read-access';
|
||||||
import { GitMention } from './git-payloads';
|
import { GitMention } from './git-payloads';
|
||||||
|
|
||||||
export interface GitIntegration {
|
export interface GitRepository {
|
||||||
enabled: boolean;
|
id: string;
|
||||||
|
/** The project's name for it: api, web, admin… */
|
||||||
|
name: string;
|
||||||
/** Only for whoever manages the project. */
|
/** Only for whoever manages the project. */
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Turns the integration on with a new secret (or rotates it). */
|
export interface GitIntegration {
|
||||||
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
|
/** On while there is at least one repository. */
|
||||||
|
enabled: boolean;
|
||||||
|
repositories: GitRepository[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A project takes this many repositories at most. */
|
||||||
|
export const MAX_GIT_REPOSITORIES = 20;
|
||||||
|
|
||||||
|
const newSecret = () => randomBytes(24).toString('hex');
|
||||||
|
|
||||||
|
/** Adds a repository with its own secret (turning the integration on). */
|
||||||
|
export class AddGitRepositoryCommand extends Command<GitRepository> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly projectId: string,
|
public readonly projectId: string,
|
||||||
|
public readonly name: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DisableGitIntegrationCommand extends Command<void> {
|
/** A new secret for one repository; the old one stops working at once. */
|
||||||
|
export class RotateGitRepositorySecretCommand extends Command<GitRepository> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly projectId: string,
|
public readonly projectId: string,
|
||||||
|
public readonly repositoryId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its webhooks are refused from then on; what it linked stays. */
|
||||||
|
export class RemoveGitRepositoryCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly repositoryId: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -48,6 +80,8 @@ export class DisableGitIntegrationCommand extends Command<void> {
|
|||||||
export class RecordGitMentionsCommand extends Command<number> {
|
export class RecordGitMentionsCommand extends Command<number> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly projectId: string,
|
public readonly projectId: string,
|
||||||
|
/** Name of the repository whose secret signed the delivery. */
|
||||||
|
public readonly repository: string,
|
||||||
public readonly mentions: GitMention[],
|
public readonly mentions: GitMention[],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -63,39 +97,100 @@ export class GetGitIntegrationQuery extends Query<GitIntegration> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CommandHandler(EnableGitIntegrationCommand)
|
@CommandHandler(AddGitRepositoryCommand)
|
||||||
export class EnableGitIntegrationCommandHandler
|
export class AddGitRepositoryCommandHandler
|
||||||
implements ICommandHandler<EnableGitIntegrationCommand>
|
implements ICommandHandler<AddGitRepositoryCommand>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: WriteAccess,
|
private readonly access: WriteAccess,
|
||||||
private readonly integrations: GitIntegrationsRepository,
|
private readonly integrations: GitIntegrationsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
|
async execute(command: AddGitRepositoryCommand): Promise<GitRepository> {
|
||||||
(await this.access.project(command.projectId)).assertCanManage(
|
(await this.access.project(command.projectId)).assertCanManage(
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
const secret = randomBytes(24).toString('hex');
|
const name = command.name.trim();
|
||||||
await this.integrations.save(command.projectId, secret);
|
assertThat(
|
||||||
return { enabled: true, secret };
|
name.length > 0 && name.length <= 40,
|
||||||
|
'A repository needs a name of 1 to 40 characters',
|
||||||
|
);
|
||||||
|
const repositories = await this.integrations.repositoriesOf(
|
||||||
|
command.projectId,
|
||||||
|
);
|
||||||
|
assertThat(
|
||||||
|
repositories.length < MAX_GIT_REPOSITORIES,
|
||||||
|
`A project takes ${MAX_GIT_REPOSITORIES} repositories at most`,
|
||||||
|
);
|
||||||
|
assertThat(
|
||||||
|
!repositories.some((r) => r.name.toLowerCase() === name.toLowerCase()),
|
||||||
|
`There is already a repository named "${name}"`,
|
||||||
|
);
|
||||||
|
const added: GitRepositoryConfig = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name,
|
||||||
|
secret: newSecret(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.integrations.saveAll(command.projectId, [
|
||||||
|
...repositories,
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
return added;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CommandHandler(DisableGitIntegrationCommand)
|
@CommandHandler(RotateGitRepositorySecretCommand)
|
||||||
export class DisableGitIntegrationCommandHandler
|
export class RotateGitRepositorySecretCommandHandler
|
||||||
implements ICommandHandler<DisableGitIntegrationCommand>
|
implements ICommandHandler<RotateGitRepositorySecretCommand>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: WriteAccess,
|
private readonly access: WriteAccess,
|
||||||
private readonly integrations: GitIntegrationsRepository,
|
private readonly integrations: GitIntegrationsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: DisableGitIntegrationCommand): Promise<void> {
|
async execute(
|
||||||
|
command: RotateGitRepositorySecretCommand,
|
||||||
|
): Promise<GitRepository> {
|
||||||
(await this.access.project(command.projectId)).assertCanManage(
|
(await this.access.project(command.projectId)).assertCanManage(
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
await this.integrations.remove(command.projectId);
|
const repositories = await this.integrations.repositoriesOf(
|
||||||
|
command.projectId,
|
||||||
|
);
|
||||||
|
const found = repositories.find((r) => r.id === command.repositoryId);
|
||||||
|
if (!found) throw new NotFoundError('Repository', command.repositoryId);
|
||||||
|
const rotated = { ...found, secret: newSecret() };
|
||||||
|
await this.integrations.saveAll(
|
||||||
|
command.projectId,
|
||||||
|
repositories.map((r) => (r.id === found.id ? rotated : r)),
|
||||||
|
);
|
||||||
|
return rotated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(RemoveGitRepositoryCommand)
|
||||||
|
export class RemoveGitRepositoryCommandHandler
|
||||||
|
implements ICommandHandler<RemoveGitRepositoryCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly integrations: GitIntegrationsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: RemoveGitRepositoryCommand): Promise<void> {
|
||||||
|
(await this.access.project(command.projectId)).assertCanManage(
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
const repositories = await this.integrations.repositoriesOf(
|
||||||
|
command.projectId,
|
||||||
|
);
|
||||||
|
if (!repositories.some((r) => r.id === command.repositoryId))
|
||||||
|
throw new NotFoundError('Repository', command.repositoryId);
|
||||||
|
await this.integrations.saveAll(
|
||||||
|
command.projectId,
|
||||||
|
repositories.filter((r) => r.id !== command.repositoryId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +214,10 @@ export class RecordGitMentionsCommandHandler
|
|||||||
const issue = await this.access.live(found.id, Issue);
|
const issue = await this.access.live(found.id, Issue);
|
||||||
if (!issue) continue;
|
if (!issue) continue;
|
||||||
const before = issue.getUncommittedEvents().length;
|
const before = issue.getUncommittedEvents().length;
|
||||||
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
|
issue.recordDevLink(SYSTEM_ACTOR_ID, {
|
||||||
|
...mention.link,
|
||||||
|
repository: command.repository,
|
||||||
|
});
|
||||||
if (issue.getUncommittedEvents().length === before) continue;
|
if (issue.getUncommittedEvents().length === before) continue;
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
recorded++;
|
recorded++;
|
||||||
@@ -143,15 +241,24 @@ export class GetGitIntegrationQueryHandler
|
|||||||
query.projectId,
|
query.projectId,
|
||||||
query.viewerId,
|
query.viewerId,
|
||||||
);
|
);
|
||||||
const secret = await this.integrations.secretOf(query.projectId);
|
const repositories = await this.integrations.repositoriesOf(
|
||||||
|
query.projectId,
|
||||||
|
);
|
||||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||||
return { enabled: !!secret, secret: manages ? secret : null };
|
return {
|
||||||
|
enabled: repositories.length > 0,
|
||||||
|
repositories: repositories.map((r) => ({
|
||||||
|
...r,
|
||||||
|
secret: manages ? r.secret : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GIT_HANDLERS = [
|
export const GIT_HANDLERS = [
|
||||||
EnableGitIntegrationCommandHandler,
|
AddGitRepositoryCommandHandler,
|
||||||
DisableGitIntegrationCommandHandler,
|
RotateGitRepositorySecretCommandHandler,
|
||||||
|
RemoveGitRepositoryCommandHandler,
|
||||||
RecordGitMentionsCommandHandler,
|
RecordGitMentionsCommandHandler,
|
||||||
GetGitIntegrationQueryHandler,
|
GetGitIntegrationQueryHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
|
/** A repository of a project: its name there (api, web…) and its secret. */
|
||||||
|
export interface GitRepositoryConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** What the Git host signs its webhooks with. */
|
||||||
|
secret: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-project secret that Git hosts sign their webhooks with. Configuration,
|
* The repositories whose webhooks a project accepts. Configuration, not
|
||||||
* not history: kept outside the event store.
|
* history: kept outside the event store.
|
||||||
*/
|
*/
|
||||||
export abstract class GitIntegrationsRepository {
|
export abstract class GitIntegrationsRepository {
|
||||||
abstract secretOf(projectId: string): Promise<string | null>;
|
/** Oldest first; empty when the integration is off. */
|
||||||
abstract save(projectId: string, secret: string): Promise<void>;
|
abstract repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]>;
|
||||||
abstract remove(projectId: string): Promise<void>;
|
/** Replaces the whole list; an empty one turns the integration off. */
|
||||||
|
abstract saveAll(
|
||||||
|
projectId: string,
|
||||||
|
repositories: GitRepositoryConfig[],
|
||||||
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({
|
|||||||
ref: '#12',
|
ref: '#12',
|
||||||
state,
|
state,
|
||||||
provider: 'gitea',
|
provider: 'gitea',
|
||||||
|
repository: 'web',
|
||||||
author: 'ana',
|
author: 'ana',
|
||||||
at: '2026-10-08T10:00:00Z',
|
at: '2026-10-08T10:00:00Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface DevLink {
|
|||||||
state: DevLinkState | null;
|
state: DevLinkState | null;
|
||||||
/** github, gitea, gitlab. */
|
/** github, gitea, gitlab. */
|
||||||
provider: string;
|
provider: string;
|
||||||
|
/**
|
||||||
|
* The project's name for the repository that reported it (api, web…);
|
||||||
|
* null on links recorded before projects had several.
|
||||||
|
*/
|
||||||
|
repository: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
@@ -55,6 +60,7 @@ export function normalizeDevLink(link: DevLink): DevLink {
|
|||||||
...link,
|
...link,
|
||||||
title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref,
|
title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref,
|
||||||
ref: link.ref.slice(0, 120),
|
ref: link.ref.slice(0, 120),
|
||||||
|
repository: link.repository?.slice(0, 40) ?? null,
|
||||||
author: link.author?.slice(0, 120) ?? null,
|
author: link.author?.slice(0, 120) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { GitIntegrationsRepository } from '@task-stream/api/tracker/application';
|
import {
|
||||||
|
GitIntegrationsRepository,
|
||||||
|
GitRepositoryConfig,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
import { Model } from 'mongoose';
|
import { Model } from 'mongoose';
|
||||||
import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
|
import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
|
||||||
|
|
||||||
|
/** What a project's single webhook of before is called once it is one of many. */
|
||||||
|
export const LEGACY_REPOSITORY_NAME = 'principal';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -13,22 +19,38 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async secretOf(projectId: string): Promise<string | null> {
|
async repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]> {
|
||||||
const doc = await this.integrations
|
const doc = await this.integrations
|
||||||
.findById(projectId)
|
.findById(projectId)
|
||||||
.lean<GitIntegrationDoc>();
|
.lean<GitIntegrationDoc>();
|
||||||
return doc?.secret ?? null;
|
if (!doc) return [];
|
||||||
|
if (doc.repositories) return doc.repositories;
|
||||||
|
// Saved before projects had several repositories: its secret keeps
|
||||||
|
// working, now as one of them.
|
||||||
|
return doc.secret
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: projectId,
|
||||||
|
name: LEGACY_REPOSITORY_NAME,
|
||||||
|
secret: doc.secret,
|
||||||
|
createdAt: doc.createdAt ?? new Date(0).toISOString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(projectId: string, secret: string): Promise<void> {
|
async saveAll(
|
||||||
|
projectId: string,
|
||||||
|
repositories: GitRepositoryConfig[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!repositories.length) {
|
||||||
|
await this.integrations.deleteOne({ _id: projectId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.integrations.updateOne(
|
await this.integrations.updateOne(
|
||||||
{ _id: projectId },
|
{ _id: projectId },
|
||||||
{ $set: { secret, createdAt: new Date().toISOString() } },
|
{ $set: { repositories }, $unset: { secret: '', createdAt: '' } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(projectId: string): Promise<void> {
|
|
||||||
await this.integrations.deleteOne({ _id: projectId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,22 @@ export interface AutomationRunDoc {
|
|||||||
_id: string;
|
_id: string;
|
||||||
at: Date;
|
at: Date;
|
||||||
}
|
}
|
||||||
export interface GitIntegrationDoc {
|
export interface GitRepositoryDoc {
|
||||||
_id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* One per project. `secret` alone is how the single webhook of before was
|
||||||
|
* kept: it reads as a repository named "principal" until the list is saved.
|
||||||
|
*/
|
||||||
|
export interface GitIntegrationDoc {
|
||||||
|
_id: string;
|
||||||
|
repositories?: GitRepositoryDoc[];
|
||||||
|
secret?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
export interface NotificationSettingsDoc {
|
export interface NotificationSettingsDoc {
|
||||||
_id: string;
|
_id: string;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
@@ -214,7 +225,7 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
|||||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||||
|
|
||||||
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||||
{ _id: String, secret: String, createdAt: String },
|
{ _id: String, repositories: [Object], secret: String, createdAt: String },
|
||||||
{ ...loose, collection: 'git_integrations' },
|
{ ...loose, collection: 'git_integrations' },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -285,7 +296,11 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|||||||
rank: rest.rank ?? implicitRank(rest.number),
|
rank: rest.rank ?? implicitRank(rest.number),
|
||||||
startDate: rest.startDate ?? null,
|
startDate: rest.startDate ?? null,
|
||||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||||
devLinks: rest.devLinks ?? [],
|
// Links recorded before projects had several repositories name none.
|
||||||
|
devLinks: (rest.devLinks ?? []).map((link) => ({
|
||||||
|
...link,
|
||||||
|
repository: link.repository ?? null,
|
||||||
|
})),
|
||||||
trashedAt: rest.trashedAt ?? null,
|
trashedAt: rest.trashedAt ?? null,
|
||||||
checklist: rest.checklist ?? [],
|
checklist: rest.checklist ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
|||||||
import { User } from '@task-stream/api/identity/domain';
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||||
import { GitService } from '@task-stream/api/tracker/application';
|
import { GitService } from '@task-stream/api/tracker/application';
|
||||||
import { GitIntegrationGql } from './types';
|
import { GitIntegrationGql, GitRepositoryGql } from './types';
|
||||||
|
|
||||||
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
|
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
|
||||||
|
|
||||||
/** The project's Git integration (owners and admins configure it). */
|
/** The project's Git repositories (owners and admins configure them). */
|
||||||
@Resolver(() => GitIntegrationGql)
|
@Resolver(() => GitIntegrationGql)
|
||||||
export class GitResolver {
|
export class GitResolver {
|
||||||
constructor(private readonly git: GitService) {}
|
constructor(private readonly git: GitService) {}
|
||||||
@@ -20,24 +20,38 @@ export class GitResolver {
|
|||||||
return { ...integration, webhookPath: webhookPath(projectId) };
|
return { ...integration, webhookPath: webhookPath(projectId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => GitIntegrationGql, {
|
@Mutation(() => GitRepositoryGql, {
|
||||||
description:
|
description:
|
||||||
'Turn the Git webhook on, with a new secret (also to rotate it)',
|
'Add a repository with its own secret (the first turns the integration on)',
|
||||||
})
|
})
|
||||||
async enableGitIntegration(
|
addGitRepository(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('projectId', { type: () => ID }) projectId: string,
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
@Args('name') name: string,
|
||||||
) {
|
) {
|
||||||
const integration = await this.git.enable(me.id, projectId);
|
return this.git.addRepository(me.id, projectId, name);
|
||||||
return { ...integration, webhookPath: webhookPath(projectId) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => GitRepositoryGql, {
|
||||||
async disableGitIntegration(
|
description: 'A new secret for a repository; the old one stops working',
|
||||||
|
})
|
||||||
|
rotateGitRepositorySecret(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('projectId', { type: () => ID }) projectId: string,
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
@Args('repositoryId', { type: () => ID }) repositoryId: string,
|
||||||
) {
|
) {
|
||||||
await this.git.disable(me.id, projectId);
|
return this.git.rotateSecret(me.id, projectId, repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Remove a repository; what it linked stays on the tickets',
|
||||||
|
})
|
||||||
|
async removeGitRepository(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
@Args('repositoryId', { type: () => ID }) repositoryId: string,
|
||||||
|
) {
|
||||||
|
await this.git.removeRepository(me.id, projectId, repositoryId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,23 +170,45 @@ export class DevLinkGql {
|
|||||||
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
||||||
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
|
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
|
||||||
@Field({ description: 'github, gitea or gitlab' }) provider!: string;
|
@Field({ description: 'github, gitea or gitlab' }) provider!: string;
|
||||||
|
@Field(() => String, {
|
||||||
|
nullable: true,
|
||||||
|
description: "The project's name for the repository it came from",
|
||||||
|
})
|
||||||
|
repository!: string | null;
|
||||||
@Field(() => String, { nullable: true }) author!: string | null;
|
@Field(() => String, { nullable: true }) author!: string | null;
|
||||||
@Field() at!: string;
|
@Field() at!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('GitIntegration', {
|
@ObjectType('GitRepository', {
|
||||||
description:
|
description: 'A repository whose webhooks the project accepts',
|
||||||
'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
|
|
||||||
})
|
})
|
||||||
export class GitIntegrationGql {
|
export class GitRepositoryGql {
|
||||||
@Field() enabled!: boolean;
|
@Field(() => ID) id!: string;
|
||||||
|
@Field({ description: "The project's name for it: api, web, admin…" })
|
||||||
|
name!: string;
|
||||||
@Field(() => String, {
|
@Field(() => String, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Webhook secret; only owners and admins see it',
|
description:
|
||||||
|
'Webhook secret of this repository; only owners and admins see it',
|
||||||
})
|
})
|
||||||
secret!: string | null;
|
secret!: string | null;
|
||||||
@Field({ description: 'Path of the webhook on this API' })
|
@Field() createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('GitIntegration', {
|
||||||
|
description:
|
||||||
|
'Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
|
||||||
|
})
|
||||||
|
export class GitIntegrationGql {
|
||||||
|
@Field({ description: 'On while there is at least one repository' })
|
||||||
|
enabled!: boolean;
|
||||||
|
@Field({
|
||||||
|
description:
|
||||||
|
'Path of the webhook on this API, the same for every repository',
|
||||||
|
})
|
||||||
webhookPath!: string;
|
webhookPath!: string;
|
||||||
|
@Field(() => [GitRepositoryGql])
|
||||||
|
repositories!: GitRepositoryGql[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('AutomationTriggerSpec')
|
@ObjectType('AutomationTriggerSpec')
|
||||||
|
|||||||
@@ -802,6 +802,7 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
|||||||
|
|
||||||
export class ImportQueryDto {
|
export class ImportQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Only tell what would be created',
|
description: 'Only tell what would be created',
|
||||||
})
|
})
|
||||||
@@ -1069,7 +1070,11 @@ export class PublishReleaseDto {
|
|||||||
// ---- Notifications ----------------------------------------------------------
|
// ---- Notifications ----------------------------------------------------------
|
||||||
|
|
||||||
export class MyNotificationsQueryDto {
|
export class MyNotificationsQueryDto {
|
||||||
@ApiPropertyOptional({ default: false, description: 'Only unread ones' })
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
description: 'Only unread ones',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -1101,3 +1106,17 @@ export class NotificationSettingsDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
email!: boolean;
|
email!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Git --------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class AddGitRepositoryDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'api',
|
||||||
|
maxLength: 40,
|
||||||
|
description: "The project's name for it (api, web, admin…); unique",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(40)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,19 +162,40 @@ export class DevLinkDto {
|
|||||||
})
|
})
|
||||||
state!: string | null;
|
state!: string | null;
|
||||||
@ApiProperty({ example: 'gitea' }) provider!: string;
|
@ApiProperty({ example: 'gitea' }) provider!: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
example: 'api',
|
||||||
|
description: "The project's name for the repository it came from",
|
||||||
|
})
|
||||||
|
repository!: string | null;
|
||||||
@ApiProperty({ type: String, nullable: true }) author!: string | null;
|
@ApiProperty({ type: String, nullable: true }) author!: string | null;
|
||||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GitIntegrationDto {
|
export class GitRepositoryDto {
|
||||||
@ApiProperty() enabled!: boolean;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ example: 'api' }) name!: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: String,
|
type: String,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Webhook secret; only owners and admins see it',
|
description:
|
||||||
|
'Webhook secret of this repository; only owners and admins see it',
|
||||||
})
|
})
|
||||||
secret!: string | null;
|
secret!: string | null;
|
||||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitIntegrationDto {
|
||||||
|
@ApiProperty({ description: 'On while there is at least one repository' })
|
||||||
|
enabled!: boolean;
|
||||||
|
@ApiProperty({
|
||||||
|
example: '/api/webhooks/git/3f2c…',
|
||||||
|
description: 'The same for every repository of the project',
|
||||||
|
})
|
||||||
|
webhookPath!: string;
|
||||||
|
@ApiProperty({ type: [GitRepositoryDto] })
|
||||||
|
repositories!: GitRepositoryDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebhookDeliveryDto {
|
export class WebhookDeliveryDto {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
@@ -30,7 +31,12 @@ import {
|
|||||||
} from '@task-stream/api/shared/presenters';
|
} from '@task-stream/api/shared/presenters';
|
||||||
import { GitService } from '@task-stream/api/tracker/application';
|
import { GitService } from '@task-stream/api/tracker/application';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { GitDeliveryResultDto, GitIntegrationDto } from './dto/responses.dto';
|
import { AddGitRepositoryDto } from './dto/requests.dto';
|
||||||
|
import {
|
||||||
|
GitDeliveryResultDto,
|
||||||
|
GitIntegrationDto,
|
||||||
|
GitRepositoryDto,
|
||||||
|
} from './dto/responses.dto';
|
||||||
import { Refs } from './refs';
|
import { Refs } from './refs';
|
||||||
|
|
||||||
const PROJECT = ApiParam({
|
const PROJECT = ApiParam({
|
||||||
@@ -38,10 +44,16 @@ const PROJECT = ApiParam({
|
|||||||
description: 'Project id or key',
|
description: 'Project id or key',
|
||||||
example: 'WEB',
|
example: 'WEB',
|
||||||
});
|
});
|
||||||
|
const REPOSITORY = ApiParam({
|
||||||
|
name: 'repository',
|
||||||
|
description: 'Repository id',
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Git hosts: the webhook they call, and its configuration for owners and
|
* Git hosts: the webhook they call, and the project's repositories (each
|
||||||
* admins. Commits, branches and pull requests that name a ticket key (WEB-12)
|
* with its own secret) for owners and admins. Commits, branches and pull requests that name a ticket key (WEB-12)
|
||||||
* show on that ticket.
|
* show on that ticket.
|
||||||
*/
|
*/
|
||||||
@ApiTags('Git')
|
@ApiTags('Git')
|
||||||
@@ -56,7 +68,8 @@ export class GitController {
|
|||||||
@ApiPireonAuth()
|
@ApiPireonAuth()
|
||||||
@PROJECT
|
@PROJECT
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Whether the Git webhook is on (and its secret, for admins)',
|
summary:
|
||||||
|
"The project's repositories and the webhook URL they share (secrets, for admins)",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ type: GitIntegrationDto })
|
@ApiOkResponse({ type: GitIntegrationDto })
|
||||||
@ApiReadErrors()
|
@ApiReadErrors()
|
||||||
@@ -67,35 +80,74 @@ export class GitController {
|
|||||||
const projectId = await this.refs.projectId(me.id, project);
|
const projectId = await this.refs.projectId(me.id, project);
|
||||||
return {
|
return {
|
||||||
...(await this.git.integration(me.id, projectId)),
|
...(await this.git.integration(me.id, projectId)),
|
||||||
webhookPath: `/api/webhooks/git/${projectId}`,
|
webhookPath: webhookPath(projectId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('projects/:project/git-integration')
|
@Post('projects/:project/git-integration/repositories')
|
||||||
@ApiPireonAuth()
|
@ApiPireonAuth()
|
||||||
@PROJECT
|
@PROJECT
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Turn the Git webhook on with a new secret (or rotate it)',
|
summary: 'Add a repository, with its own secret (owners and admins)',
|
||||||
|
description:
|
||||||
|
'Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.',
|
||||||
})
|
})
|
||||||
@ApiCreatedResponse({ type: GitIntegrationDto })
|
@ApiCreatedResponse({ type: GitRepositoryDto })
|
||||||
@ApiCommandErrors()
|
@ApiCommandErrors()
|
||||||
async enable(@CurrentUser() me: User, @Param('project') project: string) {
|
async addRepository(
|
||||||
const projectId = await this.refs.projectId(me.id, project);
|
@CurrentUser() me: User,
|
||||||
return {
|
@Param('project') project: string,
|
||||||
...(await this.git.enable(me.id, projectId)),
|
@Body() body: AddGitRepositoryDto,
|
||||||
webhookPath: `/api/webhooks/git/${projectId}`,
|
) {
|
||||||
};
|
return this.git.addRepository(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
body.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('projects/:project/git-integration')
|
@Post('projects/:project/git-integration/repositories/:repository/secret')
|
||||||
@ApiPireonAuth()
|
@ApiPireonAuth()
|
||||||
@PROJECT
|
@PROJECT
|
||||||
|
@REPOSITORY
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Give a repository a new secret (the old one stops working)',
|
||||||
|
})
|
||||||
|
@ApiCreatedResponse({ type: GitRepositoryDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async rotateSecret(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Param('repository') repository: string,
|
||||||
|
) {
|
||||||
|
return this.git.rotateSecret(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
repository,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('projects/:project/git-integration/repositories/:repository')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@REPOSITORY
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ApiOperation({ summary: 'Turn the Git webhook off' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Remove a repository: its webhooks are refused, what it linked stays',
|
||||||
|
})
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiCommandErrors()
|
@ApiCommandErrors()
|
||||||
async disable(@CurrentUser() me: User, @Param('project') project: string) {
|
async removeRepository(
|
||||||
await this.git.disable(me.id, await this.refs.projectId(me.id, project));
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Param('repository') repository: string,
|
||||||
|
) {
|
||||||
|
await this.git.removeRepository(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
repository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -105,7 +157,7 @@ export class GitController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)',
|
summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)',
|
||||||
description:
|
description:
|
||||||
'Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.',
|
'Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.',
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ type: GitDeliveryResultDto })
|
@ApiOkResponse({ type: GitDeliveryResultDto })
|
||||||
@ApiForbiddenResponse({
|
@ApiForbiddenResponse({
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface DevLink {
|
|||||||
ref: string;
|
ref: string;
|
||||||
state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
|
state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
/** The project's name for the repository it came from. */
|
||||||
|
repository: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
@@ -245,11 +247,22 @@ export interface ImportReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The project's webhook for GitHub, Gitea or GitLab. */
|
/** The project's webhook for GitHub, Gitea or GitLab. */
|
||||||
export interface GitIntegration {
|
/** A repository whose webhooks the project accepts. */
|
||||||
enabled: boolean;
|
export interface GitRepository {
|
||||||
|
id: string;
|
||||||
|
/** The project's name for it: api, web, admin… */
|
||||||
|
name: string;
|
||||||
/** Only for owners and admins. */
|
/** Only for owners and admins. */
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitIntegration {
|
||||||
|
/** On while there is at least one repository. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** The same for every repository: the secret tells them apart. */
|
||||||
webhookPath: string;
|
webhookPath: string;
|
||||||
|
repositories: GitRepository[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
|
GitRepository,
|
||||||
Automation,
|
Automation,
|
||||||
AutomationInput,
|
AutomationInput,
|
||||||
ImportReport,
|
ImportReport,
|
||||||
@@ -200,9 +201,19 @@ export abstract class WebhooksGateway {
|
|||||||
/** The project's Git webhook (owners and admins configure it). */
|
/** The project's Git webhook (owners and admins configure it). */
|
||||||
export abstract class GitGateway {
|
export abstract class GitGateway {
|
||||||
abstract integration(projectId: string): Observable<GitIntegration>;
|
abstract integration(projectId: string): Observable<GitIntegration>;
|
||||||
/** On with a new secret (also to rotate it). */
|
/** A repository with its own secret (the first turns the integration on). */
|
||||||
abstract enable(projectId: string): Observable<GitIntegration>;
|
abstract addRepository(
|
||||||
abstract disable(projectId: string): Observable<void>;
|
projectId: string,
|
||||||
|
name: string,
|
||||||
|
): Observable<GitRepository>;
|
||||||
|
abstract rotateSecret(
|
||||||
|
projectId: string,
|
||||||
|
repositoryId: string,
|
||||||
|
): Observable<GitRepository>;
|
||||||
|
abstract removeRepository(
|
||||||
|
projectId: string,
|
||||||
|
repositoryId: string,
|
||||||
|
): Observable<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saved views of a project's board (mine and the shared ones). */
|
/** Saved views of a project's board (mine and the shared ones). */
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
GitGateway,
|
||||||
|
GitIntegration,
|
||||||
|
GitRepository,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { map, Observable } from 'rxjs';
|
import { map, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
DisableGitIntegrationDocument,
|
AddGitRepositoryDocument,
|
||||||
EnableGitIntegrationDocument,
|
|
||||||
GitIntegrationDocument,
|
GitIntegrationDocument,
|
||||||
|
RemoveGitRepositoryDocument,
|
||||||
|
RotateGitRepositorySecretDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
@@ -18,15 +23,24 @@ export class GraphqlGitGateway extends GitGateway {
|
|||||||
.pipe(map((d) => d.gitIntegration));
|
.pipe(map((d) => d.gitIntegration));
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(projectId: string): Observable<GitIntegration> {
|
addRepository(projectId: string, name: string): Observable<GitRepository> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(EnableGitIntegrationDocument, { projectId })
|
.mutate(AddGitRepositoryDocument, { projectId, name })
|
||||||
.pipe(map((d) => d.enableGitIntegration));
|
.pipe(map((d) => d.addGitRepository));
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(projectId: string): Observable<void> {
|
rotateSecret(
|
||||||
|
projectId: string,
|
||||||
|
repositoryId: string,
|
||||||
|
): Observable<GitRepository> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(DisableGitIntegrationDocument, { projectId })
|
.mutate(RotateGitRepositorySecretDocument, { projectId, repositoryId })
|
||||||
|
.pipe(map((d) => d.rotateGitRepositorySecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRepository(projectId: string, repositoryId: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(RemoveGitRepositoryDocument, { projectId, repositoryId })
|
||||||
.pipe(map(() => undefined));
|
.pipe(map(() => undefined));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ fragment IssueFields on Issue {
|
|||||||
ref
|
ref
|
||||||
state
|
state
|
||||||
provider
|
provider
|
||||||
|
repository
|
||||||
author
|
author
|
||||||
at
|
at
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ export type IssueFieldsFragment = {
|
|||||||
ref: string;
|
ref: string;
|
||||||
state: DevLinkState | null;
|
state: DevLinkState | null;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
repository: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -585,10 +586,11 @@ export type CommandResultFieldsFragment = {
|
|||||||
version: number;
|
version: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitIntegrationFieldsFragment = {
|
export type GitRepositoryFieldsFragment = {
|
||||||
enabled: boolean;
|
id: string;
|
||||||
|
name: string;
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
webhookPath: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitIntegrationQueryVariables = Exact<{
|
export type GitIntegrationQueryVariables = Exact<{
|
||||||
@@ -598,28 +600,50 @@ export type GitIntegrationQueryVariables = Exact<{
|
|||||||
export type GitIntegrationQuery = {
|
export type GitIntegrationQuery = {
|
||||||
gitIntegration: {
|
gitIntegration: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
secret: string | null;
|
|
||||||
webhookPath: string;
|
webhookPath: string;
|
||||||
|
repositories: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
secret: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EnableGitIntegrationMutationVariables = Exact<{
|
export type AddGitRepositoryMutationVariables = Exact<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type EnableGitIntegrationMutation = {
|
export type AddGitRepositoryMutation = {
|
||||||
enableGitIntegration: {
|
addGitRepository: {
|
||||||
enabled: boolean;
|
id: string;
|
||||||
|
name: string;
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
webhookPath: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DisableGitIntegrationMutationVariables = Exact<{
|
export type RotateGitRepositorySecretMutationVariables = Exact<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
repositoryId: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type DisableGitIntegrationMutation = { disableGitIntegration: boolean };
|
export type RotateGitRepositorySecretMutation = {
|
||||||
|
rotateGitRepositorySecret: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
secret: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveGitRepositoryMutationVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RemoveGitRepositoryMutation = { removeGitRepository: boolean };
|
||||||
|
|
||||||
export type BoardQueryVariables = Exact<{
|
export type BoardQueryVariables = Exact<{
|
||||||
idOrKey: string;
|
idOrKey: string;
|
||||||
@@ -712,6 +736,7 @@ export type IssueQuery = {
|
|||||||
ref: string;
|
ref: string;
|
||||||
state: DevLinkState | null;
|
state: DevLinkState | null;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
repository: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -2525,6 +2550,7 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
],
|
],
|
||||||
@@ -2806,27 +2832,28 @@ export const CommandResultFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
||||||
export const GitIntegrationFieldsFragmentDoc = {
|
export const GitRepositoryFieldsFragmentDoc = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
{
|
{
|
||||||
kind: 'FragmentDefinition',
|
kind: 'FragmentDefinition',
|
||||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
typeCondition: {
|
typeCondition: {
|
||||||
kind: 'NamedType',
|
kind: 'NamedType',
|
||||||
name: { kind: 'Name', value: 'GitIntegration' },
|
name: { kind: 'Name', value: 'GitRepository' },
|
||||||
},
|
},
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<GitIntegrationFieldsFragment, unknown>;
|
} as unknown as DocumentNode<GitRepositoryFieldsFragment, unknown>;
|
||||||
export const NotificationFieldsFragmentDoc = {
|
export const NotificationFieldsFragmentDoc = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -3620,9 +3647,20 @@ export const GitIntegrationDocument = {
|
|||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||||
{
|
{
|
||||||
kind: 'FragmentSpread',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
name: { kind: 'Name', value: 'repositories' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -3632,29 +3670,30 @@ export const GitIntegrationDocument = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'FragmentDefinition',
|
kind: 'FragmentDefinition',
|
||||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
typeCondition: {
|
typeCondition: {
|
||||||
kind: 'NamedType',
|
kind: 'NamedType',
|
||||||
name: { kind: 'Name', value: 'GitIntegration' },
|
name: { kind: 'Name', value: 'GitRepository' },
|
||||||
},
|
},
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>;
|
} as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>;
|
||||||
export const EnableGitIntegrationDocument = {
|
export const AddGitRepositoryDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
{
|
{
|
||||||
kind: 'OperationDefinition',
|
kind: 'OperationDefinition',
|
||||||
operation: 'mutation',
|
operation: 'mutation',
|
||||||
name: { kind: 'Name', value: 'EnableGitIntegration' },
|
name: { kind: 'Name', value: 'AddGitRepository' },
|
||||||
variableDefinitions: [
|
variableDefinitions: [
|
||||||
{
|
{
|
||||||
kind: 'VariableDefinition',
|
kind: 'VariableDefinition',
|
||||||
@@ -3667,13 +3706,24 @@ export const EnableGitIntegrationDocument = {
|
|||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } },
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'enableGitIntegration' },
|
name: { kind: 'Name', value: 'addGitRepository' },
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
kind: 'Argument',
|
kind: 'Argument',
|
||||||
@@ -3683,13 +3733,21 @@ export const EnableGitIntegrationDocument = {
|
|||||||
name: { kind: 'Name', value: 'projectId' },
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'name' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'name' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{
|
{
|
||||||
kind: 'FragmentSpread',
|
kind: 'FragmentSpread',
|
||||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -3699,32 +3757,33 @@ export const EnableGitIntegrationDocument = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'FragmentDefinition',
|
kind: 'FragmentDefinition',
|
||||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
typeCondition: {
|
typeCondition: {
|
||||||
kind: 'NamedType',
|
kind: 'NamedType',
|
||||||
name: { kind: 'Name', value: 'GitIntegration' },
|
name: { kind: 'Name', value: 'GitRepository' },
|
||||||
},
|
},
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<
|
} as unknown as DocumentNode<
|
||||||
EnableGitIntegrationMutation,
|
AddGitRepositoryMutation,
|
||||||
EnableGitIntegrationMutationVariables
|
AddGitRepositoryMutationVariables
|
||||||
>;
|
>;
|
||||||
export const DisableGitIntegrationDocument = {
|
export const RotateGitRepositorySecretDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
{
|
{
|
||||||
kind: 'OperationDefinition',
|
kind: 'OperationDefinition',
|
||||||
operation: 'mutation',
|
operation: 'mutation',
|
||||||
name: { kind: 'Name', value: 'DisableGitIntegration' },
|
name: { kind: 'Name', value: 'RotateGitRepositorySecret' },
|
||||||
variableDefinitions: [
|
variableDefinitions: [
|
||||||
{
|
{
|
||||||
kind: 'VariableDefinition',
|
kind: 'VariableDefinition',
|
||||||
@@ -3737,13 +3796,24 @@ export const DisableGitIntegrationDocument = {
|
|||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
selections: [
|
selections: [
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'disableGitIntegration' },
|
name: { kind: 'Name', value: 'rotateGitRepositorySecret' },
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
kind: 'Argument',
|
kind: 'Argument',
|
||||||
@@ -3753,6 +3823,104 @@ export const DisableGitIntegrationDocument = {
|
|||||||
name: { kind: 'Name', value: 'projectId' },
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'GitRepositoryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'GitRepository' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
RotateGitRepositorySecretMutation,
|
||||||
|
RotateGitRepositorySecretMutationVariables
|
||||||
|
>;
|
||||||
|
export const RemoveGitRepositoryDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'RemoveGitRepository' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'removeGitRepository' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'repositoryId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3760,8 +3928,8 @@ export const DisableGitIntegrationDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<
|
} as unknown as DocumentNode<
|
||||||
DisableGitIntegrationMutation,
|
RemoveGitRepositoryMutation,
|
||||||
DisableGitIntegrationMutationVariables
|
RemoveGitRepositoryMutationVariables
|
||||||
>;
|
>;
|
||||||
export const BoardDocument = {
|
export const BoardDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
@@ -4341,6 +4509,7 @@ export const IssueDocument = {
|
|||||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
fragment GitIntegrationFields on GitIntegration {
|
fragment GitRepositoryFields on GitRepository {
|
||||||
enabled
|
id
|
||||||
|
name
|
||||||
secret
|
secret
|
||||||
webhookPath
|
createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
query GitIntegration($projectId: ID!) {
|
query GitIntegration($projectId: ID!) {
|
||||||
gitIntegration(projectId: $projectId) {
|
gitIntegration(projectId: $projectId) {
|
||||||
...GitIntegrationFields
|
enabled
|
||||||
|
webhookPath
|
||||||
|
repositories {
|
||||||
|
...GitRepositoryFields
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation EnableGitIntegration($projectId: ID!) {
|
mutation AddGitRepository($projectId: ID!, $name: String!) {
|
||||||
enableGitIntegration(projectId: $projectId) {
|
addGitRepository(projectId: $projectId, name: $name) {
|
||||||
...GitIntegrationFields
|
...GitRepositoryFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation DisableGitIntegration($projectId: ID!) {
|
mutation RotateGitRepositorySecret($projectId: ID!, $repositoryId: ID!) {
|
||||||
disableGitIntegration(projectId: $projectId)
|
rotateGitRepositorySecret(
|
||||||
|
projectId: $projectId
|
||||||
|
repositoryId: $repositoryId
|
||||||
|
) {
|
||||||
|
...GitRepositoryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation RemoveGitRepository($projectId: ID!, $repositoryId: ID!) {
|
||||||
|
removeGitRepository(projectId: $projectId, repositoryId: $repositoryId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,7 +490,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
rel="noopener"
|
rel="noopener"
|
||||||
>{{ link.title }}</a
|
>{{ link.title }}</a
|
||||||
>
|
>
|
||||||
<code class="ts-dev__ref">{{ link.ref }}</code>
|
<code class="ts-dev__ref"
|
||||||
|
>{{ link.repository ? link.repository + ' · ' : ''
|
||||||
|
}}{{ link.ref }}</code
|
||||||
|
>
|
||||||
@if (link.state) {
|
@if (link.state) {
|
||||||
<span class="ts-pill" [attr.data-state]="link.state">{{
|
<span class="ts-pill" [attr.data-state]="link.state">{{
|
||||||
devState(link)
|
devState(link)
|
||||||
|
|||||||
@@ -9,17 +9,22 @@ import {
|
|||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { PiTextFieldComponent } from '@pi/ui';
|
||||||
import { ToastService } from '@task-stream/web/shared/ui';
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
GitGateway,
|
||||||
|
GitIntegration,
|
||||||
|
GitRepository,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings card for the Git webhook: turn it on, copy the URL and the
|
* Settings card for the Git webhooks: the project's repositories (api, web,
|
||||||
* secret into GitHub, Gitea or GitLab, rotate or turn it off.
|
* admin…), each with its own secret, and the URL they all share.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-git-integration',
|
selector: 'ts-git-integration',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [],
|
imports: [PiTextFieldComponent],
|
||||||
styleUrl: './settings-card.scss',
|
styleUrl: './settings-card.scss',
|
||||||
template: `
|
template: `
|
||||||
<section class="ts-panel ts-block ts-git" aria-labelledby="git-title">
|
<section class="ts-panel ts-block ts-git" aria-labelledby="git-title">
|
||||||
@@ -28,8 +33,8 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
|||||||
<p class="ts-muted">
|
<p class="ts-muted">
|
||||||
Los commits, ramas y pull requests que nombren un ticket ({{
|
Los commits, ramas y pull requests que nombren un ticket ({{
|
||||||
projectKey()
|
projectKey()
|
||||||
}}-12) aparecen en su ficha, en «Desarrollo». Funciona con GitHub,
|
}}-12) aparecen en su ficha, en «Desarrollo», con el repositorio del
|
||||||
Gitea y GitLab.
|
que vienen. Funciona con GitHub, Gitea y GitLab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@if (integration(); as git) {
|
@if (integration(); as git) {
|
||||||
@@ -46,60 +51,78 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
|||||||
Copiar
|
Copiar
|
||||||
</button>
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
@if (git.secret) {
|
|
||||||
<dt>Secreto</dt>
|
|
||||||
<dd>
|
|
||||||
<code>{{ shown() ? git.secret : '••••••••••••••••' }}</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ts-plain-link"
|
|
||||||
(click)="shown.set(!shown())"
|
|
||||||
>
|
|
||||||
{{ shown() ? 'Ocultar' : 'Ver' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ts-plain-link"
|
|
||||||
(click)="copy(git.secret)"
|
|
||||||
>
|
|
||||||
Copiar
|
|
||||||
</button>
|
|
||||||
</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
</dl>
|
||||||
<p class="ts-muted ts-git__how">
|
<p class="ts-muted ts-git__how">
|
||||||
En el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL,
|
La misma para todos los repositorios; cada uno lleva su secreto. En
|
||||||
tipo <code>application/json</code>, el secreto (en GitLab, como
|
el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL,
|
||||||
|
tipo <code>application/json</code>, su secreto (en GitLab, como
|
||||||
«Secret token») y los eventos <em>push</em> y
|
«Secret token») y los eventos <em>push</em> y
|
||||||
<em>pull/merge request</em>.
|
<em>pull/merge request</em>.
|
||||||
</p>
|
</p>
|
||||||
<div class="ts-git__actions">
|
<ul class="ts-git__repos">
|
||||||
<button
|
@for (repo of git.repositories; track repo.id) {
|
||||||
type="button"
|
<li class="ts-git__repo">
|
||||||
class="ts-btn ts-btn--secondary"
|
<strong class="ts-git__name">{{ repo.name }}</strong>
|
||||||
(click)="enable()"
|
@if (repo.secret) {
|
||||||
>
|
<span class="ts-git__secret">
|
||||||
Cambiar el secreto
|
<code>{{
|
||||||
</button>
|
shown() === repo.id ? repo.secret : '••••••••••••••••'
|
||||||
<button
|
}}</code>
|
||||||
type="button"
|
<button
|
||||||
class="ts-btn ts-btn--ghost"
|
type="button"
|
||||||
(click)="disable()"
|
class="ts-plain-link"
|
||||||
>
|
(click)="shown.set(shown() === repo.id ? null : repo.id)"
|
||||||
Desactivar
|
>
|
||||||
</button>
|
{{ shown() === repo.id ? 'Ocultar' : 'Ver' }}
|
||||||
</div>
|
</button>
|
||||||
} @else {
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<button
|
class="ts-plain-link"
|
||||||
type="button"
|
(click)="copy(repo.secret)"
|
||||||
class="ts-btn ts-btn--primary"
|
>
|
||||||
(click)="enable()"
|
Copiar
|
||||||
>
|
</button>
|
||||||
Activar
|
</span>
|
||||||
</button>
|
<span class="ts-git__actions">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-btn ts-btn--secondary"
|
||||||
|
(click)="rotate(repo)"
|
||||||
|
>
|
||||||
|
Cambiar el secreto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-btn ts-btn--ghost"
|
||||||
|
(click)="remove(repo)"
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
}
|
}
|
||||||
|
<form
|
||||||
|
class="ts-git__new"
|
||||||
|
(submit)="$event.preventDefault(); add()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-text-field
|
||||||
|
placeholder="Nombre: api, web, admin…"
|
||||||
|
[(value)]="name"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ts-btn ts-btn--primary"
|
||||||
|
[disabled]="!name().trim()"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
git.enabled ? 'Añadir repositorio' : 'Activar con un repositorio'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
@@ -128,17 +151,51 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
code {
|
}
|
||||||
overflow-wrap: anywhere;
|
code {
|
||||||
}
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.ts-git__how {
|
.ts-git__how {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
.ts-git__repos {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.ts-git__repo {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.625rem 0;
|
||||||
|
border-top: 1px solid var(--ts-line);
|
||||||
|
}
|
||||||
|
.ts-git__name {
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
.ts-git__secret {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.ts-git__actions {
|
.ts-git__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.ts-git__new {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
pi-text-field {
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -150,7 +207,9 @@ export class GitIntegrationComponent {
|
|||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
protected readonly integration = signal<GitIntegration | null>(null);
|
protected readonly integration = signal<GitIntegration | null>(null);
|
||||||
protected readonly shown = signal(false);
|
/** The repository whose secret is on show. */
|
||||||
|
protected readonly shown = signal<string | null>(null);
|
||||||
|
protected readonly name = signal('');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -172,48 +231,80 @@ export class GitIntegrationComponent {
|
|||||||
return `${location.origin}${git.webhookPath}`;
|
return `${location.origin}${git.webhookPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected enable(): void {
|
protected add(): void {
|
||||||
const rotating = this.integration()?.enabled;
|
const name = this.name().trim();
|
||||||
if (
|
if (!name) return;
|
||||||
rotating &&
|
const taken = this.integration()?.repositories.some(
|
||||||
!confirm(
|
(r) => r.name.toLowerCase() === name.toLowerCase(),
|
||||||
'El secreto actual dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?',
|
);
|
||||||
)
|
if (taken) {
|
||||||
)
|
this.toast.error(`Ya hay un repositorio «${name}»`);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
this.git
|
this.git
|
||||||
.enable(this.projectId())
|
.addRepository(this.projectId(), name)
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (git) => {
|
next: (repo) => {
|
||||||
this.integration.set(git);
|
this.integration.update((git) =>
|
||||||
this.shown.set(true);
|
git
|
||||||
this.toast.success(
|
? {
|
||||||
rotating ? 'Secreto cambiado' : 'Integración activada',
|
...git,
|
||||||
|
enabled: true,
|
||||||
|
repositories: [...git.repositories, repo],
|
||||||
|
}
|
||||||
|
: git,
|
||||||
);
|
);
|
||||||
|
this.name.set('');
|
||||||
|
this.shown.set(repo.id);
|
||||||
|
this.toast.success(`Repositorio «${repo.name}» añadido`);
|
||||||
},
|
},
|
||||||
error: () => this.toast.error('No se pudo activar'),
|
error: () => this.toast.error('No se pudo añadir el repositorio'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected disable(): void {
|
protected rotate(repo: GitRepository): void {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
'Los repositorios dejarán de enlazar código a los tickets. ¿Desactivar?',
|
`El secreto actual de «${repo.name}» dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?`,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.git
|
this.git
|
||||||
.disable(this.projectId())
|
.rotateSecret(this.projectId(), repo.id)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (rotated) => {
|
||||||
|
this.replace(rotated);
|
||||||
|
this.shown.set(rotated.id);
|
||||||
|
this.toast.success('Secreto cambiado');
|
||||||
|
},
|
||||||
|
error: () => this.toast.error('No se pudo cambiar el secreto'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected remove(repo: GitRepository): void {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`«${repo.name}» dejará de enlazar código a los tickets (lo ya enlazado se queda). ¿Quitarlo?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.git
|
||||||
|
.removeRepository(this.projectId(), repo.id)
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.integration.update((git) =>
|
this.integration.update((git) => {
|
||||||
git ? { ...git, enabled: false, secret: null } : git,
|
if (!git) return git;
|
||||||
);
|
const repositories = git.repositories.filter(
|
||||||
this.toast.success('Integración desactivada');
|
(r) => r.id !== repo.id,
|
||||||
|
);
|
||||||
|
return { ...git, enabled: repositories.length > 0, repositories };
|
||||||
|
});
|
||||||
|
this.toast.success(`Repositorio «${repo.name}» quitado`);
|
||||||
},
|
},
|
||||||
error: () => this.toast.error('No se pudo desactivar'),
|
error: () => this.toast.error('No se pudo quitar'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,4 +314,17 @@ export class GitIntegrationComponent {
|
|||||||
.then(() => this.toast.success('Copiado'))
|
.then(() => this.toast.success('Copiado'))
|
||||||
.catch(() => this.toast.error('No se pudo copiar'));
|
.catch(() => this.toast.error('No se pudo copiar'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private replace(repo: GitRepository): void {
|
||||||
|
this.integration.update((git) =>
|
||||||
|
git
|
||||||
|
? {
|
||||||
|
...git,
|
||||||
|
repositories: git.repositories.map((r) =>
|
||||||
|
r.id === repo.id ? repo : r,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: git,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user