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"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/git-integration": {
|
||||
"get": {
|
||||
"operationId": "GitController_integration",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GitIntegrationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Whether the Git webhook is on (and its secret, for admins)",
|
||||
"tags": [
|
||||
"Git"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "GitController_enable",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GitIntegrationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Turn the Git webhook on with a new secret (or rotate it)",
|
||||
"tags": [
|
||||
"Git"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "GitController_disable",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Turn the Git webhook off",
|
||||
"tags": [
|
||||
"Git"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/webhooks/git/{project}": {
|
||||
"post": {
|
||||
"description": "Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.",
|
||||
"operationId": "GitController_receive",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GitDeliveryResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Unknown project, integration off or bad signature"
|
||||
}
|
||||
},
|
||||
"summary": "Webhook for GitHub, Gitea or GitLab (called by the Git host)",
|
||||
"tags": [
|
||||
"Git"
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
@@ -8582,6 +8892,74 @@
|
||||
"done"
|
||||
]
|
||||
},
|
||||
"DevLinkKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"COMMIT",
|
||||
"BRANCH",
|
||||
"PULL_REQUEST"
|
||||
]
|
||||
},
|
||||
"DevLinkState": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"OPEN",
|
||||
"MERGED",
|
||||
"CLOSED"
|
||||
]
|
||||
},
|
||||
"DevLinkDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/DevLinkKind"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "Short SHA, branch or PR number"
|
||||
},
|
||||
"state": {
|
||||
"nullable": true,
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/DevLinkState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"example": "gitea"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"url",
|
||||
"title",
|
||||
"ref",
|
||||
"state",
|
||||
"provider",
|
||||
"author",
|
||||
"at"
|
||||
]
|
||||
},
|
||||
"IssueDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8744,6 +9122,13 @@
|
||||
"$ref": "#/components/schemas/ChecklistItemDto"
|
||||
}
|
||||
},
|
||||
"devLinks": {
|
||||
"description": "Commits, branches and pull requests that mention it",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DevLinkDto"
|
||||
}
|
||||
},
|
||||
"commentCount": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -8788,6 +9173,7 @@
|
||||
"parent",
|
||||
"rank",
|
||||
"checklist",
|
||||
"devLinks",
|
||||
"commentCount",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -10395,6 +10781,40 @@
|
||||
"required": [
|
||||
"sprintId"
|
||||
]
|
||||
},
|
||||
"GitIntegrationDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Webhook secret; only owners and admins see it"
|
||||
},
|
||||
"webhookPath": {
|
||||
"type": "string",
|
||||
"example": "/api/webhooks/git/3f2c…"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"secret",
|
||||
"webhookPath"
|
||||
]
|
||||
},
|
||||
"GitDeliveryResultDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recorded": {
|
||||
"type": "number",
|
||||
"description": "Tickets that got something new"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"recorded"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,34 @@ type CumulativeFlow {
|
||||
series: [FlowSeries!]!
|
||||
}
|
||||
|
||||
"""A commit, branch or pull request that mentions the ticket"""
|
||||
type DevLink {
|
||||
at: String!
|
||||
author: String
|
||||
kind: DevLinkKind!
|
||||
|
||||
"""github, gitea or gitlab"""
|
||||
provider: String!
|
||||
|
||||
"""Short SHA, branch or PR number"""
|
||||
ref: String!
|
||||
state: DevLinkState
|
||||
title: String!
|
||||
url: String!
|
||||
}
|
||||
|
||||
enum DevLinkKind {
|
||||
BRANCH
|
||||
COMMIT
|
||||
PULL_REQUEST
|
||||
}
|
||||
|
||||
enum DevLinkState {
|
||||
CLOSED
|
||||
MERGED
|
||||
OPEN
|
||||
}
|
||||
|
||||
type DurationStats {
|
||||
averageDays: Float
|
||||
count: Int!
|
||||
@@ -222,6 +250,19 @@ type FlowSeries {
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
|
||||
"""
|
||||
type GitIntegration {
|
||||
enabled: Boolean!
|
||||
|
||||
"""Webhook secret; only owners and admins see it"""
|
||||
secret: String
|
||||
|
||||
"""Path of the webhook on this API"""
|
||||
webhookPath: String!
|
||||
}
|
||||
|
||||
type Issue {
|
||||
"""Statuses this ticket can move to next (project's workflow)"""
|
||||
allowedTransitions: [String!]!
|
||||
@@ -235,6 +276,9 @@ type Issue {
|
||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||
createdAt: String!
|
||||
description: String
|
||||
|
||||
"""Commits, branches and pull requests that mention it"""
|
||||
devLinks: [DevLink!]!
|
||||
dueDate: String
|
||||
figmaUrl: String
|
||||
|
||||
@@ -435,8 +479,12 @@ type Mutation {
|
||||
|
||||
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
||||
deleteSprint(sprintId: ID!): CommandResult!
|
||||
disableGitIntegration(projectId: ID!): Boolean!
|
||||
editComment(body: String!, commentId: ID!): CommandResult!
|
||||
|
||||
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
|
||||
enableGitIntegration(projectId: ID!): GitIntegration!
|
||||
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
@@ -681,6 +729,7 @@ type Query {
|
||||
A project's epics: top-level tickets that group others, with the progress of those
|
||||
"""
|
||||
epics(projectId: ID!): [Epic!]!
|
||||
gitIntegration(projectId: ID!): GitIntegration!
|
||||
issue(
|
||||
"""Issue id or key (e.g. "TS-42")"""
|
||||
idOrKey: String!
|
||||
|
||||
@@ -1097,6 +1097,45 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/projects/{project}/git-integration": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Whether the Git webhook is on (and its secret, for admins) */
|
||||
get: operations["GitController_integration"];
|
||||
put?: never;
|
||||
/** Turn the Git webhook on with a new secret (or rotate it) */
|
||||
post: operations["GitController_enable"];
|
||||
/** Turn the Git webhook off */
|
||||
delete: operations["GitController_disable"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/webhooks/git/{project}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Webhook for GitHub, Gitea or GitLab (called by the Git host)
|
||||
* @description Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.
|
||||
*/
|
||||
post: operations["GitController_receive"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1417,6 +1456,23 @@ export interface components {
|
||||
text: string;
|
||||
done: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
DevLinkKind: "COMMIT" | "BRANCH" | "PULL_REQUEST";
|
||||
/** @enum {string} */
|
||||
DevLinkState: "OPEN" | "MERGED" | "CLOSED";
|
||||
DevLinkDto: {
|
||||
kind: components["schemas"]["DevLinkKind"];
|
||||
url: string;
|
||||
title: string;
|
||||
/** @description Short SHA, branch or PR number */
|
||||
ref: string;
|
||||
state: components["schemas"]["DevLinkState"] | null;
|
||||
/** @example gitea */
|
||||
provider: string;
|
||||
author: string | null;
|
||||
/** Format: date-time */
|
||||
at: string;
|
||||
};
|
||||
IssueDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1473,6 +1529,8 @@ export interface components {
|
||||
/** @description Place in the backlog and its board column (ascending, as text) */
|
||||
rank: string;
|
||||
checklist: components["schemas"]["ChecklistItemDto"][];
|
||||
/** @description Commits, branches and pull requests that mention it */
|
||||
devLinks: components["schemas"]["DevLinkDto"][];
|
||||
commentCount: number;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
@@ -2029,6 +2087,17 @@ export interface components {
|
||||
*/
|
||||
sprintId: string | null;
|
||||
};
|
||||
GitIntegrationDto: {
|
||||
enabled: boolean;
|
||||
/** @description Webhook secret; only owners and admins see it */
|
||||
secret: string | null;
|
||||
/** @example /api/webhooks/git/3f2c… */
|
||||
webhookPath: string;
|
||||
};
|
||||
GitDeliveryResultDto: {
|
||||
/** @description Tickets that got something new */
|
||||
recorded: number;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -7233,4 +7302,241 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GitController_integration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
project: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GitIntegrationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Missing or rejected Pireon token */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not a member of the project (or not allowed) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GitController_enable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
project: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GitIntegrationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Missing or rejected Pireon token */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not a member of the project (or not allowed) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Someone changed it at the same time: retry */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GitController_disable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
project: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Missing or rejected Pireon token */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not a member of the project (or not allowed) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Someone changed it at the same time: retry */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GitController_receive: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
project: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GitDeliveryResultDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unknown project, integration off or bad signature */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
|
||||
@@ -160,6 +160,13 @@ stateDiagram-v2
|
||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||
- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host
|
||||
llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el
|
||||
cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una
|
||||
clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`,
|
||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
||||
event store.
|
||||
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||
@@ -203,9 +210,9 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
|
||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||
|
||||
| Agregado | Eventos |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||
@@ -252,6 +259,7 @@ que funcionan también sin token.
|
||||
| `publicProjects` | Proyectos públicos (sin token) |
|
||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
||||
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
||||
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
||||
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
||||
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||
@@ -268,6 +276,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
@@ -480,6 +489,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||
|
||||
@@ -11,5 +11,8 @@ export * from './lib/notifications.service';
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/saved-filters.service';
|
||||
export * from './lib/sprints.service';
|
||||
export * from './lib/git.service';
|
||||
export * from './lib/git/git';
|
||||
export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
StartSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import {
|
||||
@@ -67,9 +70,12 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const keys = new Map<string, { id: string; projectId: string }>();
|
||||
const sprints = {
|
||||
active: null as { id: string; name: string } | null,
|
||||
findActive: async () => sprints.active,
|
||||
@@ -85,9 +91,16 @@ async function setup() {
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...GIT_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
ReadAccess,
|
||||
{
|
||||
provide: FindProjectsRepository,
|
||||
useValue: { findRole: async () => null, findById: async () => null },
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: {} },
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
@@ -108,6 +121,8 @@ async function setup() {
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
// Tickets by key, as the read model would answer (filled by tests).
|
||||
findByKey: async (key: string) => keys.get(key) ?? null,
|
||||
sprintProgress: async () => ({
|
||||
total: 0,
|
||||
finished: 0,
|
||||
@@ -129,6 +144,7 @@ async function setup() {
|
||||
store,
|
||||
files,
|
||||
sprints,
|
||||
keys,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
};
|
||||
@@ -476,6 +492,34 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('links commits and pull requests to the tickets they name', async () => {
|
||||
const { run, rehydrator, keys } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
|
||||
);
|
||||
keys.set(issue.key ?? '', { id: issue.id, projectId });
|
||||
const commit = {
|
||||
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
|
||||
link: {
|
||||
kind: DevLinkKind.COMMIT,
|
||||
url: 'https://git.example.org/acme/web/commit/abc',
|
||||
title: 'card form',
|
||||
ref: 'abc',
|
||||
state: null,
|
||||
provider: 'gitea',
|
||||
author: 'Ana',
|
||||
at: '2026-10-08T10:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
|
||||
).toBe(1);
|
||||
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
@@ -162,6 +163,19 @@ export function describeEvent(
|
||||
? `${actor} metió ${issueKey} en ${sprint}`
|
||||
: `${actor} sacó ${issueKey} de ${sprint}`;
|
||||
}
|
||||
if (event instanceof IssueDevLinkRecordedEvent) {
|
||||
const { kind, ref, state, author } = event.link;
|
||||
const who = author ? ` (${author})` : '';
|
||||
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
|
||||
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
|
||||
const verb =
|
||||
state === 'MERGED'
|
||||
? 'fusionada'
|
||||
: state === 'CLOSED'
|
||||
? 'cerrada'
|
||||
: 'abierta';
|
||||
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
|
||||
}
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
@@ -68,6 +69,7 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
import { SprintEventHandler } from './sprint.event-handler';
|
||||
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
|
||||
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
|
||||
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
@@ -71,6 +72,7 @@ export const EVENT_HANDLERS = [
|
||||
IssueRankedEventHandler,
|
||||
SprintEventHandler,
|
||||
ReleaseNotesWrittenEventHandler,
|
||||
IssueDevLinkRecordedEventHandler,
|
||||
IssueSprintChangedEventHandler,
|
||||
];
|
||||
|
||||
|
||||
@@ -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 './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AttachmentReadModel,
|
||||
DevLink,
|
||||
ChecklistItemReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
@@ -14,7 +15,12 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
Partial<
|
||||
Omit<
|
||||
IssueReadModel,
|
||||
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
|
||||
| 'links'
|
||||
| 'commentCount'
|
||||
| 'attachments'
|
||||
| 'watchers'
|
||||
| 'checklist'
|
||||
| 'devLinks'
|
||||
>
|
||||
>,
|
||||
): Promise<void>;
|
||||
@@ -47,6 +53,8 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
|
||||
): 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. */
|
||||
abstract renameParent(parentId: string, title: string): Promise<void>;
|
||||
abstract setCommentCount(
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
import { GitService } from './git.service';
|
||||
import { GIT_HANDLERS } from './git/git';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -53,6 +55,7 @@ const SERVICES = [
|
||||
NotificationsService,
|
||||
SavedFiltersService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -75,6 +78,7 @@ const SERVICES = [
|
||||
...SAGAS,
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...GIT_HANDLERS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './lib/issue/mentions';
|
||||
export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/issue/issue-rank';
|
||||
export * from './lib/issue/dev-link';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -38,3 +38,4 @@ export * from './sprint-closed.event';
|
||||
export * from './sprint-deleted.event';
|
||||
export * from './issue-sprint-changed.event';
|
||||
export * from './release-notes-written.event';
|
||||
export * from './issue-dev-link-recorded.event';
|
||||
|
||||
@@ -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 { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event';
|
||||
import { IssueRankedEvent } from '../events/issue-ranked.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
normalizeChecklistText,
|
||||
} from './issue-checklist';
|
||||
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||
import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link';
|
||||
import { implicitRank, rankBetween } from './issue-rank';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
@@ -118,6 +120,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
||||
public rank: string | null = null;
|
||||
public sprintId: string | null = null;
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
public devLinks: DevLink[] = [];
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -264,6 +268,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records code that mentions the ticket. The same URL again only counts
|
||||
* when something changed (a pull request merged, a title edited).
|
||||
*/
|
||||
recordDevLink(actorId: string, link: DevLink): void {
|
||||
const next = normalizeDevLink(link);
|
||||
const known = this.devLinks.find((l) => l.url === next.url);
|
||||
if (known && known.state === next.state && known.title === next.title)
|
||||
return;
|
||||
this.apply(
|
||||
new IssueDevLinkRecordedEvent(actorId, this.id, this.projectId, next),
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
|
||||
setSprint(actorId: string, sprintId: string | null): void {
|
||||
if (this.sprintId === sprintId) return;
|
||||
@@ -551,6 +569,13 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onIssueDevLinkRecordedEvent(event: IssueDevLinkRecordedEvent): void {
|
||||
this.devLinks = [
|
||||
...this.devLinks.filter((l) => l.url !== event.link.url),
|
||||
{ ...event.link },
|
||||
].slice(-MAX_DEV_LINKS);
|
||||
}
|
||||
|
||||
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
|
||||
this.sprintId = event.sprintId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DevLink } from '../issue/dev-link';
|
||||
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
@@ -84,6 +85,8 @@ export class IssueReadModel {
|
||||
/** Place in the backlog (and in each board column): ascending. */
|
||||
rank!: string;
|
||||
checklist!: ChecklistItemReadModel[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks!: DevLink[];
|
||||
commentCount!: number;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
|
||||
@@ -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 NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
secret: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
email: boolean;
|
||||
@@ -46,6 +51,7 @@ export const ReadModels = {
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -192,12 +198,18 @@ export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ _id: String, secret: String, createdAt: String },
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
);
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
{ name: ReadModels.Comment, schema: CommentSchema },
|
||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
|
||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
@@ -225,6 +237,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
devLinks: rest.devLinks ?? [],
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ActivityReadModel,
|
||||
AttachmentReadModel,
|
||||
ChecklistItemReadModel,
|
||||
DevLink,
|
||||
MAX_DEV_LINKS,
|
||||
CommentReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
ReadModels,
|
||||
ReleaseDoc,
|
||||
SavedFilterDoc,
|
||||
GitIntegrationDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
@@ -47,6 +50,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
@InjectModel(ReadModels.SavedFilter)
|
||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||
@InjectModel(ReadModels.GitIntegration)
|
||||
private readonly gitIntegrations: Model<GitIntegrationDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -58,6 +63,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
this.timeline.deleteMany({ projectId }),
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
this.gitIntegrations.deleteOne({ _id: projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -168,6 +174,18 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
);
|
||||
}
|
||||
|
||||
async putDevLink(issueId: string, link: DevLink) {
|
||||
// Same URL: the newer report replaces the older one.
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ $pull: { devLinks: { url: link.url } } },
|
||||
);
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
|
||||
);
|
||||
}
|
||||
|
||||
async renameParent(parentId: string, title: string) {
|
||||
await this.issues.updateMany(
|
||||
{ 'parent.id': parentId },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotificationMailer,
|
||||
NotificationsRepository,
|
||||
SavedFiltersRepository,
|
||||
GitIntegrationsRepository,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
@@ -60,6 +61,7 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import {
|
||||
MongoFindSprintsRepository,
|
||||
MongoUpsertMaterializedSprintRepository,
|
||||
@@ -121,6 +123,10 @@ const PORTS = [
|
||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
},
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
@@ -22,6 +24,8 @@ registerEnumType(StatusCategory, {
|
||||
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
|
||||
});
|
||||
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||
registerEnumType(DevLinkKind, { name: 'DevLinkKind' });
|
||||
registerEnumType(DevLinkState, { name: 'DevLinkState' });
|
||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||
registerEnumType(SprintStatus, {
|
||||
|
||||
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,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { LinkDirection } from './enums';
|
||||
|
||||
@@ -139,6 +141,35 @@ export class ChecklistItemGql {
|
||||
@Field() done!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('DevLink', {
|
||||
description: 'A commit, branch or pull request that mentions the ticket',
|
||||
})
|
||||
export class DevLinkGql {
|
||||
@Field(() => DevLinkKind) kind!: DevLinkKind;
|
||||
@Field() url!: string;
|
||||
@Field() title!: string;
|
||||
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
||||
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
|
||||
@Field({ description: 'github, gitea or gitlab' }) provider!: string;
|
||||
@Field(() => String, { nullable: true }) author!: string | null;
|
||||
@Field() at!: string;
|
||||
}
|
||||
|
||||
@ObjectType('GitIntegration', {
|
||||
description:
|
||||
'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
|
||||
})
|
||||
export class GitIntegrationGql {
|
||||
@Field() enabled!: boolean;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Webhook secret; only owners and admins see it',
|
||||
})
|
||||
secret!: string | null;
|
||||
@Field({ description: 'Path of the webhook on this API' })
|
||||
webhookPath!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||
export class IssueParentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -192,6 +223,10 @@ export class IssueGql {
|
||||
})
|
||||
rank!: string;
|
||||
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
|
||||
@Field(() => [DevLinkGql], {
|
||||
description: 'Commits, branches and pull requests that mention it',
|
||||
})
|
||||
devLinks!: DevLinkGql[];
|
||||
@Field(() => Int) commentCount!: number;
|
||||
@Field() createdAt!: string;
|
||||
@Field() updatedAt!: string;
|
||||
|
||||
@@ -136,6 +136,42 @@ export class ChecklistItemDto {
|
||||
@ApiProperty() done!: boolean;
|
||||
}
|
||||
|
||||
export class DevLinkDto {
|
||||
@ApiProperty({
|
||||
enum: ['COMMIT', 'BRANCH', 'PULL_REQUEST'],
|
||||
enumName: 'DevLinkKind',
|
||||
})
|
||||
kind!: string;
|
||||
@ApiProperty() url!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
||||
@ApiProperty({
|
||||
enum: ['OPEN', 'MERGED', 'CLOSED'],
|
||||
enumName: 'DevLinkState',
|
||||
nullable: true,
|
||||
})
|
||||
state!: string | null;
|
||||
@ApiProperty({ example: 'gitea' }) provider!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) author!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
}
|
||||
|
||||
export class GitIntegrationDto {
|
||||
@ApiProperty() enabled!: boolean;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Webhook secret; only owners and admins see it',
|
||||
})
|
||||
secret!: string | null;
|
||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
||||
}
|
||||
|
||||
export class GitDeliveryResultDto {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
}
|
||||
|
||||
export class IssueParentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB-3' }) key!: string;
|
||||
@@ -206,6 +242,11 @@ export class IssueDto {
|
||||
})
|
||||
rank!: string;
|
||||
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
|
||||
@ApiProperty({
|
||||
type: [DevLinkDto],
|
||||
description: 'Commits, branches and pull requests that mention it',
|
||||
})
|
||||
devLinks!: DevLinkDto[];
|
||||
@ApiProperty() commentCount!: number;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
|
||||
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 { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -51,6 +53,7 @@ import { SprintsController } from './http/sprints.controller';
|
||||
NotificationsController,
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
GitController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -62,6 +65,7 @@ import { SprintsController } from './http/sprints.controller';
|
||||
NotificationResolver,
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
@@ -151,10 +151,33 @@ export interface Issue extends IssueSummary {
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
checklist: ChecklistItem[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks: DevLink[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A commit, branch or pull request that mentions a ticket. */
|
||||
export interface DevLink {
|
||||
kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST';
|
||||
url: string;
|
||||
title: string;
|
||||
/** Short SHA, branch or PR number. */
|
||||
ref: string;
|
||||
state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The project's webhook for GitHub, Gitea or GitLab. */
|
||||
export interface GitIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for owners and admins. */
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
issueId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BoardColumn,
|
||||
Comment,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
SavedFilter,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
@@ -130,6 +131,14 @@ export abstract class IssuesGateway {
|
||||
): 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). */
|
||||
export abstract class SavedFiltersGateway {
|
||||
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
|
||||
done
|
||||
}
|
||||
devLinks {
|
||||
kind
|
||||
url
|
||||
title
|
||||
ref
|
||||
state
|
||||
provider
|
||||
author
|
||||
at
|
||||
}
|
||||
attachments {
|
||||
id
|
||||
fileName
|
||||
|
||||
@@ -75,6 +75,10 @@ export type CreateSprintInput = {
|
||||
startDate: string;
|
||||
};
|
||||
|
||||
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
||||
|
||||
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
||||
|
||||
export type IssueFilterInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
@@ -336,6 +340,16 @@ export type IssueFieldsFragment = {
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
devLinks: Array<{
|
||||
kind: DevLinkKind;
|
||||
url: string;
|
||||
title: string;
|
||||
ref: string;
|
||||
state: DevLinkState | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -387,6 +401,42 @@ export type CommandResultFieldsFragment = {
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type GitIntegrationFieldsFragment = {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
|
||||
export type GitIntegrationQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type GitIntegrationQuery = {
|
||||
gitIntegration: {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EnableGitIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type EnableGitIntegrationMutation = {
|
||||
enableGitIntegration: {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DisableGitIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type DisableGitIntegrationMutation = { disableGitIntegration: boolean };
|
||||
|
||||
export type BoardQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
releaseId?: string | null | undefined;
|
||||
@@ -469,6 +519,16 @@ export type IssueQuery = {
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
devLinks: Array<{
|
||||
kind: DevLinkKind;
|
||||
url: string;
|
||||
title: string;
|
||||
ref: string;
|
||||
state: DevLinkState | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -1944,6 +2004,23 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'devLinks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
@@ -2219,6 +2296,27 @@ export const CommandResultFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<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 = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2386,6 +2484,188 @@ export const SprintFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} 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 = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2950,6 +3230,23 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'devLinks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
|
||||
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,
|
||||
SavedFiltersGateway,
|
||||
SprintsGateway,
|
||||
GitGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -28,6 +29,7 @@ import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||
|
||||
/** Ports → GraphQL adapters (and REST for files). */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
@@ -43,5 +45,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -467,3 +467,56 @@ a.ts-key {
|
||||
.ts-mentions__option:focus-visible {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
// Code that mentions the ticket (from the Git integration).
|
||||
.ts-dev {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ts-dev__item {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 1fr) auto auto auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 4rem minmax(0, 1fr);
|
||||
|
||||
.ts-dev__ref,
|
||||
.ts-dev__when {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-pill[data-state='MERGED'] {
|
||||
--tone: var(--ts-c-violet, var(--ts-brand));
|
||||
}
|
||||
|
||||
.ts-pill[data-state='OPEN'] {
|
||||
--tone: var(--ts-c-green, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
|
||||
.ts-dev__kind {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ts-dev__title {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-dev__when {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
DevLink,
|
||||
insertMention,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
@@ -457,6 +458,39 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</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">
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
@@ -887,6 +921,26 @@ export class IssuePage {
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
/** Pull requests first, then the newest. */
|
||||
protected readonly devLinks = computed(() =>
|
||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||
(a, b) =>
|
||||
Number(b.kind === 'PULL_REQUEST') - Number(a.kind === 'PULL_REQUEST') ||
|
||||
b.at.localeCompare(a.at),
|
||||
),
|
||||
);
|
||||
protected devKind = (link: DevLink) =>
|
||||
link.kind === 'PULL_REQUEST'
|
||||
? 'PR'
|
||||
: link.kind === 'BRANCH'
|
||||
? 'Rama'
|
||||
: 'Commit';
|
||||
protected devState = (link: DevLink) =>
|
||||
link.state === 'MERGED'
|
||||
? 'Fusionada'
|
||||
: link.state === 'CLOSED'
|
||||
? 'Cerrada'
|
||||
: 'Abierta';
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
|
||||
@@ -162,10 +162,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
[disabled]="drafting()"
|
||||
(execute)="draftNotes()"
|
||||
/>
|
||||
<pi-textarea
|
||||
placeholder="## Novedades - …"
|
||||
[(value)]="notes"
|
||||
>
|
||||
<pi-textarea placeholder="## Novedades - …" [(value)]="notes">
|
||||
<pi-label>Notas</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectSettings,
|
||||
@@ -51,6 +52,7 @@ function idFrom(name: string): string {
|
||||
selector: 'ts-settings-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
GitIntegrationComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -461,6 +463,13 @@ function idFrom(name: string): string {
|
||||
}
|
||||
</form>
|
||||
|
||||
@if (facade.canManage() && facade.project(); as project) {
|
||||
<ts-git-integration
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
<section
|
||||
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