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