feat: comments marked as decisions, on top of the ticket and searchable
Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.
API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5908,6 +5908,290 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/decisions": {
|
||||
"get": {
|
||||
"operationId": "IssuesController_issueDecisions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issue",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Ticket id or key",
|
||||
"schema": {
|
||||
"example": "WEB-12",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CommentDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Comments of the ticket marked as decisions (oldest first)",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/decisions": {
|
||||
"get": {
|
||||
"operationId": "IssuesController_projectDecisions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Words in the decision",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DecisionDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Search a project's decisions (most recent first)",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/me/decisions": {
|
||||
"get": {
|
||||
"operationId": "IssuesController_myDecisions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "search",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Words in the decision",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DecisionDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Search the decisions of all my projects (most recent first)",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/history": {
|
||||
"get": {
|
||||
"operationId": "IssuesController_history",
|
||||
@@ -6632,6 +6916,203 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/decision": {
|
||||
"put": {
|
||||
"description": "Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).",
|
||||
"operationId": "CommentsController_markDecision",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "commentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Mark a comment as a decision of its ticket",
|
||||
"tags": [
|
||||
"Comments"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "CommentsController_unmarkDecision",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "commentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "It is no longer a decision",
|
||||
"tags": [
|
||||
"Comments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/me/releases": {
|
||||
"get": {
|
||||
"operationId": "ReleasesController_upcoming",
|
||||
@@ -15237,6 +15718,25 @@
|
||||
"$ref": "#/components/schemas/CommentReactionDto"
|
||||
}
|
||||
},
|
||||
"decision": {
|
||||
"type": "boolean",
|
||||
"description": "Marked as a decision of the ticket (shown above it)"
|
||||
},
|
||||
"decidedBy": {
|
||||
"nullable": true,
|
||||
"description": "Who marked it as a decision",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"decidedAt": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -15258,6 +15758,9 @@
|
||||
"replyTo",
|
||||
"deleted",
|
||||
"reactions",
|
||||
"decision",
|
||||
"decidedBy",
|
||||
"decidedAt",
|
||||
"createdAt",
|
||||
"editedAt",
|
||||
"version"
|
||||
@@ -15281,6 +15784,21 @@
|
||||
"total"
|
||||
]
|
||||
},
|
||||
"DecisionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"$ref": "#/components/schemas/CommentDto"
|
||||
},
|
||||
"issue": {
|
||||
"$ref": "#/components/schemas/IssueParentDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"comment",
|
||||
"issue"
|
||||
]
|
||||
},
|
||||
"AddCommentDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -270,6 +270,13 @@ type Comment {
|
||||
"""Empty on a deleted comment kept for its replies"""
|
||||
body: String!
|
||||
createdAt: String!
|
||||
decidedAt: String
|
||||
|
||||
"""Who marked it as a decision"""
|
||||
decidedBy: UserRef
|
||||
|
||||
"""Marked as a decision of the ticket (shown above it)"""
|
||||
decision: Boolean!
|
||||
|
||||
"""Deleted, but kept (without its text) while it has replies"""
|
||||
deleted: Boolean!
|
||||
@@ -475,6 +482,12 @@ type DashboardShare {
|
||||
token: String!
|
||||
}
|
||||
|
||||
"""A comment marked as a decision, with its ticket"""
|
||||
type Decision {
|
||||
comment: Comment!
|
||||
issue: IssueParent!
|
||||
}
|
||||
|
||||
"""A commit, branch or pull request that mentions the ticket"""
|
||||
type DevLink {
|
||||
at: String!
|
||||
@@ -640,6 +653,9 @@ type Issue {
|
||||
|
||||
"""Values of the project's custom fields (only those set)"""
|
||||
customFields: [CustomFieldValue!]!
|
||||
|
||||
"""Its comments marked as decisions, oldest first"""
|
||||
decisions: [Comment!]!
|
||||
description: String
|
||||
|
||||
"""Commits, branches and pull requests that mention it"""
|
||||
@@ -973,6 +989,11 @@ type Mutation {
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket
|
||||
"""
|
||||
markDecision(commentId: ID!, on: Boolean! = true): CommandResult!
|
||||
|
||||
"""Mark these as read, or all of them when `ids` is omitted"""
|
||||
markNotificationsRead(ids: [ID!]): Boolean!
|
||||
|
||||
@@ -1491,6 +1512,11 @@ type Query {
|
||||
"""The project's link for clients, if any (owners and admins)"""
|
||||
dashboardShare(projectId: ID!): DashboardShare
|
||||
|
||||
"""
|
||||
Decisions by their text, most recent first: of a project, or of all my projects without one
|
||||
"""
|
||||
decisions(limit: Int! = 20, projectId: ID, search: String): [Decision!]!
|
||||
|
||||
"""
|
||||
A project's epics: top-level tickets that group others, with the progress of those
|
||||
"""
|
||||
|
||||
@@ -885,6 +885,57 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/issues/{issue}/decisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Comments of the ticket marked as decisions (oldest first) */
|
||||
get: operations["IssuesController_issueDecisions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/projects/{project}/decisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Search a project's decisions (most recent first) */
|
||||
get: operations["IssuesController_projectDecisions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/me/decisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Search the decisions of all my projects (most recent first) */
|
||||
get: operations["IssuesController_myDecisions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/issues/{issue}/history": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -978,6 +1029,27 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/decision": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Mark a comment as a decision of its ticket
|
||||
* @description Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).
|
||||
*/
|
||||
put: operations["CommentsController_markDecision"];
|
||||
post?: never;
|
||||
/** It is no longer a decision */
|
||||
delete: operations["CommentsController_unmarkDecision"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/me/releases": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2929,6 +3001,12 @@ export interface components {
|
||||
deleted: boolean;
|
||||
/** @description In the order they are offered; only the ones somebody chose */
|
||||
reactions: components["schemas"]["CommentReactionDto"][];
|
||||
/** @description Marked as a decision of the ticket (shown above it) */
|
||||
decision: boolean;
|
||||
/** @description Who marked it as a decision */
|
||||
decidedBy: components["schemas"]["UserRefDto"] | null;
|
||||
/** Format: date-time */
|
||||
decidedAt: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -2939,6 +3017,10 @@ export interface components {
|
||||
items: components["schemas"]["CommentDto"][];
|
||||
total: number;
|
||||
};
|
||||
DecisionDto: {
|
||||
comment: components["schemas"]["CommentDto"];
|
||||
issue: components["schemas"]["IssueParentDto"];
|
||||
};
|
||||
AddCommentDto: {
|
||||
/** @example Reproducido en Safari 17. */
|
||||
body: string;
|
||||
@@ -7578,6 +7660,185 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_issueDecisions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Ticket id or key */
|
||||
issue: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommentDto"][];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_projectDecisions: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Words in the decision */
|
||||
search?: string;
|
||||
limit?: number;
|
||||
};
|
||||
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"]["DecisionDto"][];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_myDecisions: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Words in the decision */
|
||||
search?: string;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DecisionDto"][];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_history: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -8087,6 +8348,156 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_markDecision: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommandResultDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_unmarkDecision: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommandResultDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ReleasesController_upcoming: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -254,7 +254,7 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'get_issue',
|
||||
description:
|
||||
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.',
|
||||
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, the comments marked as decisions, and optionally its comments and history.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
withComments: z.boolean().default(true),
|
||||
@@ -289,8 +289,20 @@ export class IssuesResolver {
|
||||
emoji: r.emoji,
|
||||
by: r.users.map((u) => u.name),
|
||||
})),
|
||||
decision: c.decision || undefined,
|
||||
}))
|
||||
: undefined;
|
||||
const decisions = (
|
||||
await this.api.data(
|
||||
client.GET('/api/issues/{issue}/decisions', { params: { path } }),
|
||||
)
|
||||
).map((c) => ({
|
||||
commentId: c.id,
|
||||
by: c.author.name,
|
||||
at: c.createdAt,
|
||||
markedBy: c.decidedBy?.name ?? null,
|
||||
body: c.body,
|
||||
}));
|
||||
const history = params.withHistory
|
||||
? (
|
||||
await this.api.data(
|
||||
@@ -322,6 +334,7 @@ export class IssuesResolver {
|
||||
attachments: issue.attachments.length,
|
||||
trashedAt: issue.trashedAt,
|
||||
createdAt: issue.createdAt,
|
||||
decisions,
|
||||
comments,
|
||||
history,
|
||||
};
|
||||
@@ -734,6 +747,67 @@ export class IssuesResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_decision',
|
||||
description:
|
||||
'Mark a comment as a decision of its ticket (what was agreed), or unmark it (on: false). Whoever can change the ticket. Comment ids come with get_issue.',
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
on: z.boolean().default(true),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
markDecision(
|
||||
{ commentId, on }: { commentId: string; on: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const options = { params: { path: { commentId } } };
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
on
|
||||
? client.PUT('/api/comments/{commentId}/decision', options)
|
||||
: client.DELETE('/api/comments/{commentId}/decision', options),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_decisions',
|
||||
description:
|
||||
'What was decided, by words in the decision: comments marked as decisions, most recent first, with their ticket. Of one project, or of all my projects without one.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef.optional(),
|
||||
search: z.string().max(200).optional(),
|
||||
limit: limit(100, 20),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
searchDecisions(
|
||||
params: { project?: string; search?: string; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const query = { search: params.search, limit: params.limit };
|
||||
const found = await this.api.data(
|
||||
params.project
|
||||
? client.GET('/api/projects/{project}/decisions', {
|
||||
params: { path: { project: params.project }, query },
|
||||
})
|
||||
: client.GET('/api/me/decisions', { params: { query } }),
|
||||
);
|
||||
return found.map((d) => ({
|
||||
issue: d.issue.key,
|
||||
title: d.issue.title,
|
||||
commentId: d.comment.id,
|
||||
by: d.comment.author.name,
|
||||
at: d.comment.createdAt,
|
||||
body: d.comment.body,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'vote_issue',
|
||||
description:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
@@ -14,7 +14,7 @@
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
@@ -24,7 +24,7 @@
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
|
||||
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
|
||||
@@ -238,6 +238,12 @@ stateDiagram-v2
|
||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
||||
eligió cada una. No generan actividad ni avisos.
|
||||
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
||||
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
||||
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
||||
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
|
||||
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
|
||||
las de la papelera y, para un invitado, solo las de sus tickets).
|
||||
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
|
||||
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
|
||||
otro ticket), con una sola consulta por tablero o página.
|
||||
@@ -515,7 +521,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`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` |
|
||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; consulta `decisions(projectId, search)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
@@ -723,7 +729,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
|
||||
@@ -305,7 +305,7 @@ visualización de la plataforma:
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
|
||||
@@ -54,7 +54,7 @@ flowchart LR
|
||||
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios |
|
||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||
@@ -66,6 +66,8 @@ flowchart LR
|
||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
export const COMMAND_HANDLERS = [
|
||||
VoteIssueCommandHandler,
|
||||
ReactToCommentCommandHandler,
|
||||
MarkDecisionCommandHandler,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -77,6 +77,7 @@ import { UserDirectory } from '../ports/user-directory';
|
||||
import { AttachmentStorage } from '../ports/attachment-storage';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { VoteIssueCommand } from './vote-issue.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
@@ -916,6 +917,32 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('lets the team mark a comment as a decision, and only the team', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Caché', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('qa', issue.id, 'Usamos Redis'),
|
||||
);
|
||||
await run(new MarkDecisionCommand('dev', comment.id, true));
|
||||
// Twice is still once.
|
||||
const again = await run(new MarkDecisionCommand('dev', comment.id, true));
|
||||
expect(again.version).toBe(2);
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
|
||||
true,
|
||||
);
|
||||
// A viewer reads the decisions but does not make them.
|
||||
await expect(
|
||||
run(new MarkDecisionCommand('pm', comment.id, false)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new MarkDecisionCommand('owner', comment.id, false));
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes votes from members, and from anyone once it is public', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** The team decides: whoever can change the ticket marks its decisions. */
|
||||
@CommandHandler(MarkDecisionCommand)
|
||||
export class MarkDecisionCommandHandler
|
||||
implements ICommandHandler<MarkDecisionCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: MarkDecisionCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
await this.access.issueForContributor(comment.issueId, command.actorId);
|
||||
comment.markDecision(command.actorId, command.on);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Marks a comment as a decision of its ticket (`on`) or unmarks it. */
|
||||
export class MarkDecisionCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly on: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentReadModel,
|
||||
DecisionReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './commands/add-comment.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { DeleteCommentCommand } from './commands/delete-comment.command';
|
||||
import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
import {
|
||||
GetIssueDecisionsQuery,
|
||||
SearchDecisionsQuery,
|
||||
} from './queries/decisions.query';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
@@ -50,6 +58,35 @@ export class CommentsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it: the team does. */
|
||||
markDecision(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
on: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new MarkDecisionCommand(actorId, commentId, on),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's decisions, oldest first. */
|
||||
decisions(
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
): Promise<CommentReadModel[]> {
|
||||
return this.queryBus.execute(new GetIssueDecisionsQuery(viewerId, issueId));
|
||||
}
|
||||
|
||||
/** Decisions by their text: of a project, or of all mine without one. */
|
||||
searchDecisions(
|
||||
viewerId: string | null,
|
||||
filter: { projectId?: string; search?: string; limit?: number },
|
||||
): Promise<DecisionReadModel[]> {
|
||||
return this.queryBus.execute(
|
||||
new SearchDecisionsQuery(viewerId, { limit: 20, ...filter }),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, commentId: string): Promise<CommandResult> {
|
||||
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export class CommentAddedEventHandler
|
||||
replyTo: comment.replyTo ?? null,
|
||||
deleted: false,
|
||||
reactions: [],
|
||||
decision: false,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
createdAt: comment.createdAt,
|
||||
editedAt: null,
|
||||
version: versionOf(event),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentDecisionMarkedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentDecisionMarkedEvent)
|
||||
export class CommentDecisionMarkedEventHandler
|
||||
implements IEventHandler<CommentDecisionMarkedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentDecisionMarkedEvent) {
|
||||
const refs = await this.users.refs([event.actorId]);
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
decision: event.decision,
|
||||
decidedBy: event.decision ? refs.of(event.actorId) : null,
|
||||
decidedAt: event.decision ? event.occurredAt : null,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
// The ticket shows its decisions on top: its page reloads them.
|
||||
await this.notifier.issue(event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { CommentAddedEventHandler } from './comment-added.event-handler';
|
||||
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
|
||||
import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -50,6 +51,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentDeletedEventHandler,
|
||||
CommentEditedEventHandler,
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
|
||||
@@ -11,4 +11,14 @@ export abstract class FindCommentsRepository {
|
||||
/** Comments still standing (deleted ones kept for their replies do not count). */
|
||||
abstract countByIssue(issueId: string): Promise<number>;
|
||||
abstract countReplies(commentId: string): Promise<number>;
|
||||
/**
|
||||
* Comments marked as decisions (not deleted): of one ticket, or of some
|
||||
* projects, oldest first for a ticket and most recent first otherwise.
|
||||
*/
|
||||
abstract findDecisions(filter: {
|
||||
issueId?: string;
|
||||
projectIds?: string[];
|
||||
search?: string;
|
||||
limit: number;
|
||||
}): Promise<CommentReadModel[]>;
|
||||
}
|
||||
|
||||
112
libs/api/tracker/application/src/lib/queries/decisions.query.ts
Normal file
112
libs/api/tracker/application/src/lib/queries/decisions.query.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentReadModel,
|
||||
DecisionReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
/** The decisions of a ticket, oldest first (shown above its conversation). */
|
||||
export class GetIssueDecisionsQuery extends Query<CommentReadModel[]> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisions by their text, most recently decided first: of one project, or
|
||||
* (without one) of every project the viewer belongs to.
|
||||
*/
|
||||
export class SearchDecisionsQuery extends Query<DecisionReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly filter: {
|
||||
projectId?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetIssueDecisionsQuery)
|
||||
export class GetIssueDecisionsQueryHandler
|
||||
implements IQueryHandler<GetIssueDecisionsQuery, CommentReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIssueDecisionsQuery): Promise<CommentReadModel[]> {
|
||||
const issue = this.access.assertFound(
|
||||
await this.findIssues.findById(query.issueId),
|
||||
'Issue',
|
||||
query.issueId,
|
||||
);
|
||||
await this.access.assertCanViewIssue(issue, query.viewerId);
|
||||
return this.findComments.findDecisions({ issueId: issue.id, limit: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(SearchDecisionsQuery)
|
||||
export class SearchDecisionsQueryHandler
|
||||
implements IQueryHandler<SearchDecisionsQuery, DecisionReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: SearchDecisionsQuery): Promise<DecisionReadModel[]> {
|
||||
const { filter, viewerId } = query;
|
||||
let projectIds: string[];
|
||||
if (filter.projectId) {
|
||||
await this.access.assertCanView(filter.projectId, viewerId);
|
||||
projectIds = [filter.projectId];
|
||||
} else {
|
||||
if (!viewerId) return [];
|
||||
projectIds = (await this.findProjects.findByMember(viewerId)).map(
|
||||
(p) => p.id,
|
||||
);
|
||||
}
|
||||
if (projectIds.length === 0) return [];
|
||||
const limit = Math.min(filter.limit, 100);
|
||||
// Read a few more: some may sit in the trash or, for a guest, in
|
||||
// tickets they do not take part in.
|
||||
const comments = await this.findComments.findDecisions({
|
||||
projectIds,
|
||||
search: filter.search?.trim().slice(0, 200) || undefined,
|
||||
limit: limit * 2,
|
||||
});
|
||||
const issues = new Map(
|
||||
(
|
||||
await this.findIssues.findByIds([
|
||||
...new Set(comments.map((c) => c.issueId)),
|
||||
])
|
||||
).map((issue) => [issue.id, issue]),
|
||||
);
|
||||
const found: DecisionReadModel[] = [];
|
||||
for (const comment of comments) {
|
||||
const issue = issues.get(comment.issueId);
|
||||
if (!issue || issue.trashedAt) continue;
|
||||
if (!(await this.access.canViewIssue(issue, viewerId))) continue;
|
||||
found.push({
|
||||
comment,
|
||||
issue: { id: issue.id, key: issue.key, title: issue.title },
|
||||
});
|
||||
if (found.length === limit) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.quer
|
||||
import { GetTrashQueryHandler } from './get-trash.query';
|
||||
import { GetShowcaseQueryHandler } from './get-showcase.query';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
import {
|
||||
GetIssueDecisionsQueryHandler,
|
||||
SearchDecisionsQueryHandler,
|
||||
} from './decisions.query';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
GetPublicProjectsQueryHandler,
|
||||
@@ -33,6 +37,8 @@ export const QUERY_HANDLERS = [
|
||||
GetActivityQueryHandler,
|
||||
GetBoardQueryHandler,
|
||||
GetCommentsQueryHandler,
|
||||
GetIssueDecisionsQueryHandler,
|
||||
SearchDecisionsQueryHandler,
|
||||
GetIssueQueryHandler,
|
||||
GetIssuesByIdsQueryHandler,
|
||||
GetMyProjectsQueryHandler,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||
@@ -41,6 +42,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public replyTo: string | null = null;
|
||||
/** Who chose each emoji (only the ones somebody chose). */
|
||||
public reactions: Record<string, string[]> = {};
|
||||
/** What was decided on the ticket: shown above it and searchable. */
|
||||
public decision = false;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -93,6 +96,24 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks it as a decision of the ticket (`on`) or unmarks it; repeating it
|
||||
* changes nothing. Who may do it (the team) is the caller's check.
|
||||
*/
|
||||
markDecision(actorId: string, on: boolean): void {
|
||||
this.assertAlive();
|
||||
if (on === this.decision) return;
|
||||
this.apply(
|
||||
new CommentDecisionMarkedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
on,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||
delete(actorId: string, canModerate: boolean): void {
|
||||
this.assertAlive();
|
||||
@@ -121,6 +142,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
this.reactions = event.reactions;
|
||||
}
|
||||
|
||||
onCommentDecisionMarkedEvent(event: CommentDecisionMarkedEvent): void {
|
||||
this.decision = event.decision;
|
||||
}
|
||||
|
||||
onCommentDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A comment was marked as a decision of the ticket, or unmarked. */
|
||||
@AutowiredEvent
|
||||
export class CommentDecisionMarkedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** False: it is no longer a decision. */
|
||||
readonly decision: boolean,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from './issue-release-changed.event';
|
||||
export * from './comment-added.event';
|
||||
export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -21,7 +21,18 @@ export class CommentReadModel {
|
||||
deleted!: boolean;
|
||||
/** In the order they are offered; only the ones somebody chose. */
|
||||
reactions!: CommentReactionReadModel[];
|
||||
/** Marked as a decision of the ticket. */
|
||||
decision!: boolean;
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | null;
|
||||
decidedAt!: string | null;
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** A decision with the ticket it belongs to, for searching across tickets. */
|
||||
export class DecisionReadModel {
|
||||
comment!: CommentReadModel;
|
||||
issue!: { id: string; key: string; title: string };
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
replyTo: doc.replyTo ?? null,
|
||||
deleted: doc.deleted ?? false,
|
||||
reactions: doc.reactions ?? [],
|
||||
decision: doc.decision ?? false,
|
||||
decidedBy: doc.decidedBy ?? null,
|
||||
decidedAt: doc.decidedAt ?? null,
|
||||
});
|
||||
|
||||
const escapeRegex = (text: string) =>
|
||||
@@ -545,6 +548,28 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
||||
countReplies(commentId: string): Promise<number> {
|
||||
return this.comments.countDocuments({ replyTo: commentId });
|
||||
}
|
||||
|
||||
async findDecisions(filter: {
|
||||
issueId?: string;
|
||||
projectIds?: string[];
|
||||
search?: string;
|
||||
limit: number;
|
||||
}): Promise<CommentReadModel[]> {
|
||||
const query: Record<string, unknown> = {
|
||||
decision: true,
|
||||
deleted: { $ne: true },
|
||||
};
|
||||
if (filter.issueId) query['issueId'] = filter.issueId;
|
||||
if (filter.projectIds) query['projectId'] = { $in: filter.projectIds };
|
||||
if (filter.search)
|
||||
query['body'] = { $regex: escapeRegex(filter.search), $options: 'i' };
|
||||
const docs = await this.comments
|
||||
.find(query)
|
||||
.sort(filter.issueId ? { createdAt: 1 } : { decidedAt: -1 })
|
||||
.limit(filter.limit)
|
||||
.lean<CommentDoc[]>();
|
||||
return docs.map(commentFromDoc);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -239,6 +239,10 @@ class CommentDocument {
|
||||
}
|
||||
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
|
||||
CommentSchema.index({ issueId: 1, createdAt: 1 });
|
||||
CommentSchema.index(
|
||||
{ projectId: 1, decidedAt: -1 },
|
||||
{ partialFilterExpression: { decision: true } },
|
||||
);
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'releases_view' })
|
||||
class ReleaseDocument {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Args, ID, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
CurrentUser,
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { CommandResultGql, CommentGql } from './types';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { CommandResultGql, CommentGql, DecisionGql } from './types';
|
||||
|
||||
@Resolver(() => CommentGql)
|
||||
export class CommentResolver {
|
||||
@@ -43,6 +47,37 @@ export class CommentResolver {
|
||||
return this.comments.react(me.id, commentId, emoji, on);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket',
|
||||
})
|
||||
markDecision(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('on', { defaultValue: true }) on: boolean,
|
||||
) {
|
||||
return this.comments.markDecision(me.id, commentId, on);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [DecisionGql], {
|
||||
description:
|
||||
'Decisions by their text, most recent first: of a project, or of all my projects without one',
|
||||
})
|
||||
decisions(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('projectId', { type: () => ID, nullable: true })
|
||||
projectId: string | null,
|
||||
@Args('search', { nullable: true }) search: string | null,
|
||||
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
|
||||
) {
|
||||
return this.comments.searchDecisions(viewerOf(me), {
|
||||
projectId: projectId ?? undefined,
|
||||
search: search ?? undefined,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, { description: 'Author or project admin' })
|
||||
deleteComment(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
ActivityGql,
|
||||
BulkResultGql,
|
||||
CommandResultGql,
|
||||
CommentGql,
|
||||
CommentPageGql,
|
||||
EpicGql,
|
||||
CustomFieldValueGql,
|
||||
@@ -247,6 +248,16 @@ export class IssueResolver {
|
||||
return !!me && issue.voterIds.includes(me.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [CommentGql], {
|
||||
description: 'Its comments marked as decisions, oldest first',
|
||||
})
|
||||
decisions(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() issue: IssueReadModel,
|
||||
) {
|
||||
return this.commentsService.decisions(viewerOf(me), issue.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean, {
|
||||
description: 'Whether I pinned it (false without a token)',
|
||||
})
|
||||
|
||||
@@ -548,6 +548,16 @@ export class CommentGql {
|
||||
description: 'In the order they are offered; only the ones somebody chose',
|
||||
})
|
||||
reactions!: CommentReactionGql[];
|
||||
@Field({
|
||||
description: 'Marked as a decision of the ticket (shown above it)',
|
||||
})
|
||||
decision!: boolean;
|
||||
@Field(() => UserRefGql, {
|
||||
nullable: true,
|
||||
description: 'Who marked it as a decision',
|
||||
})
|
||||
decidedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
@@ -559,6 +569,14 @@ export class CommentPageGql {
|
||||
@Field(() => Int) total!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Decision', {
|
||||
description: 'A comment marked as a decision, with its ticket',
|
||||
})
|
||||
export class DecisionGql {
|
||||
@Field(() => CommentGql) comment!: CommentGql;
|
||||
@Field(() => IssueParentGql) issue!: IssueParentGql;
|
||||
}
|
||||
|
||||
@ObjectType('Release')
|
||||
export class ReleaseGql {
|
||||
@Field(() => ID) id!: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
@@ -54,6 +55,29 @@ export class CommentsController {
|
||||
return this.comments.react(me.id, commentId, body.emoji, body.on);
|
||||
}
|
||||
|
||||
@Put(':commentId/decision')
|
||||
@ApiOperation({
|
||||
summary: 'Mark a comment as a decision of its ticket',
|
||||
description:
|
||||
'Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
markDecision(@CurrentUser() me: User, @Param('commentId') commentId: string) {
|
||||
return this.comments.markDecision(me.id, commentId, true);
|
||||
}
|
||||
|
||||
@Delete(':commentId/decision')
|
||||
@ApiOperation({ summary: 'It is no longer a decision' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
unmarkDecision(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
) {
|
||||
return this.comments.markDecision(me.id, commentId, false);
|
||||
}
|
||||
|
||||
@Delete(':commentId')
|
||||
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
|
||||
@@ -501,6 +501,22 @@ export class PageQueryDto {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
export class DecisionsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Words in the decision' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 20;
|
||||
}
|
||||
|
||||
// ---- Issues -----------------------------------------------------------------
|
||||
|
||||
export class MyIssuesQueryDto extends PageQueryDto {
|
||||
|
||||
@@ -625,12 +625,30 @@ export class CommentDto {
|
||||
description: 'In the order they are offered; only the ones somebody chose',
|
||||
})
|
||||
reactions!: CommentReactionDto[];
|
||||
@ApiProperty({
|
||||
description: 'Marked as a decision of the ticket (shown above it)',
|
||||
})
|
||||
decision!: boolean;
|
||||
@ApiProperty({
|
||||
type: UserRefDto,
|
||||
nullable: true,
|
||||
description: 'Who marked it as a decision',
|
||||
})
|
||||
decidedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
decidedAt!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
/** A comment marked as a decision, with its ticket. */
|
||||
export class DecisionDto {
|
||||
@ApiProperty({ type: CommentDto }) comment!: CommentDto;
|
||||
@ApiProperty({ type: IssueParentDto }) issue!: IssueParentDto;
|
||||
}
|
||||
|
||||
export class CommentPageDto {
|
||||
@ApiProperty({ type: [CommentDto] }) items!: CommentDto[];
|
||||
@ApiProperty() total!: number;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ChecklistItemBodyDto,
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
DecisionsQueryDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
@@ -61,7 +62,9 @@ import {
|
||||
ActivityDto,
|
||||
BulkResultDto,
|
||||
CommandResultDto,
|
||||
CommentDto,
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
@@ -707,6 +710,57 @@ export class IssuesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('issues/:issue/decisions')
|
||||
@ApiOptionalPireonAuth()
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Comments of the ticket marked as decisions (oldest first)',
|
||||
})
|
||||
@ApiOkResponse({ type: [CommentDto] })
|
||||
@ApiReadErrors()
|
||||
async issueDecisions(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('issue') issue: string,
|
||||
) {
|
||||
return this.comments.decisions(
|
||||
viewerOf(me),
|
||||
await this.refs.issueId(viewerOf(me), issue),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('projects/:project/decisions')
|
||||
@ApiOptionalPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: "Search a project's decisions (most recent first)",
|
||||
})
|
||||
@ApiOkResponse({ type: [DecisionDto] })
|
||||
@ApiReadErrors()
|
||||
async projectDecisions(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('project') project: string,
|
||||
@Query() query: DecisionsQueryDto,
|
||||
) {
|
||||
return this.comments.searchDecisions(viewerOf(me), {
|
||||
projectId: await this.refs.projectId(viewerOf(me), project),
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me/decisions')
|
||||
@ApiOperation({
|
||||
summary: 'Search the decisions of all my projects (most recent first)',
|
||||
})
|
||||
@ApiOkResponse({ type: [DecisionDto] })
|
||||
@ApiReadErrors()
|
||||
myDecisions(@CurrentUser() me: User, @Query() query: DecisionsQueryDto) {
|
||||
return this.comments.searchDecisions(me.id, {
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('issues/:issue/comments')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
@@ -71,6 +71,7 @@ export const issuePageEvents = eventGroup({
|
||||
}>(),
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
||||
decisionRequested: type<{ commentId: string; on: boolean }>(),
|
||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||
imagePasted: type<{ issueId: string }>(),
|
||||
|
||||
@@ -231,6 +231,11 @@ export class IssueFacade {
|
||||
this.dispatch.reactRequested({ commentId, emoji, on });
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
}
|
||||
|
||||
/** A copy of what the open ticket says; answers its key. */
|
||||
duplicate(): Observable<string | null> {
|
||||
const issueId = this.store.issue()?.id ?? '';
|
||||
|
||||
@@ -423,6 +423,20 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.decisionRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.markDecision(payload.commentId, payload.on),
|
||||
payload.on
|
||||
? 'Marcado como decisión: sale arriba del ticket'
|
||||
: 'Ya no es una decisión',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface Issue extends IssueSummary {
|
||||
watchers: UserRef[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** Its comments marked as decisions, oldest first. */
|
||||
decisions: Comment[];
|
||||
checklist: ChecklistItem[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks: DevLink[];
|
||||
@@ -326,10 +328,20 @@ export interface Comment {
|
||||
deleted: boolean;
|
||||
/** 👍… and who chose each (only the ones somebody chose). */
|
||||
reactions: { emoji: string; users: UserRef[] }[];
|
||||
/** Marked as a decision of the ticket: shown above it and searchable. */
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
/** A decision found by its text, with its ticket. */
|
||||
export interface Decision {
|
||||
comment: Comment;
|
||||
issue: { id: string; key: string; title: string };
|
||||
}
|
||||
|
||||
export interface SprintProgress {
|
||||
total: number;
|
||||
/** Done or cancelled. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PushDeviceSubscription,
|
||||
CommandResult,
|
||||
Comment,
|
||||
Decision,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
GitRepository,
|
||||
@@ -144,6 +145,14 @@ export abstract class IssuesGateway {
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
*/
|
||||
abstract searchDecisions(
|
||||
search: string,
|
||||
limit: number,
|
||||
): Observable<Decision[]>;
|
||||
/** Tickets hanging from `issueId`. */
|
||||
abstract children(
|
||||
projectId: string,
|
||||
@@ -289,6 +298,11 @@ export abstract class CommentsGateway {
|
||||
on: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||
abstract markDecision(
|
||||
commentId: string,
|
||||
on: boolean,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** A ticket's files. Uploads and downloads go through the REST API. */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
EditCommentDocument,
|
||||
IssueCommentsDocument,
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -75,6 +76,12 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.mutate(DeleteCommentDocument, { commentId })
|
||||
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
||||
}
|
||||
|
||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(MarkDecisionDocument, { commentId, on })
|
||||
.pipe(map((d) => toCommandResult(d.markDecision)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChangeStatusInput,
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
Decision,
|
||||
Epic,
|
||||
Issue,
|
||||
IssuePage,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
MyPinsDocument,
|
||||
SearchDecisionsDocument,
|
||||
PinIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
UpdateIssueDocument,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
toCommandResult,
|
||||
toIssue,
|
||||
toIssueLink,
|
||||
toComment,
|
||||
toIssueSummary,
|
||||
} from './mappers';
|
||||
|
||||
@@ -201,6 +204,18 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map((d) => d.myPins.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
searchDecisions(search: string, limit: number): Observable<Decision[]> {
|
||||
return this.gql
|
||||
.query(SearchDecisionsDocument, { search: search.trim() || null, limit })
|
||||
.pipe(
|
||||
map((d) =>
|
||||
d.decisions.map((found) => ({
|
||||
comment: toComment(found.comment),
|
||||
issue: found.issue,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = pinned
|
||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||
|
||||
@@ -121,6 +121,9 @@ fragment IssueSummaryFields on Issue {
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
description
|
||||
figmaUrl
|
||||
createdAt
|
||||
@@ -178,6 +181,10 @@ fragment CommentFields on Comment {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
decision
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -650,6 +650,22 @@ export type IssueFieldsFragment = {
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -693,12 +709,14 @@ export type CommentFieldsFragment = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
|
||||
@@ -873,6 +891,22 @@ export type IssueQuery = {
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -1083,12 +1117,14 @@ export type IssueCommentsQuery = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
@@ -1490,6 +1526,42 @@ export type ReactToCommentMutation = {
|
||||
reactToComment: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type MarkDecisionMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
on: boolean;
|
||||
}>;
|
||||
|
||||
export type MarkDecisionMutation = {
|
||||
markDecision: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type SearchDecisionsQueryVariables = Exact<{
|
||||
search?: string | null | undefined;
|
||||
limit: number;
|
||||
}>;
|
||||
|
||||
export type SearchDecisionsQuery = {
|
||||
decisions: Array<{
|
||||
comment: {
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
issue: { id: string; key: string; title: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DuplicateIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
@@ -2780,12 +2852,14 @@ export type CommentChangedSubscription = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
};
|
||||
@@ -3498,6 +3572,96 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<IssueSummaryFieldsFragment, unknown>;
|
||||
export const CommentFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
|
||||
export const IssueFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -3516,6 +3680,19 @@ export const IssueFieldsFragmentDoc = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -3742,11 +3919,6 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
|
||||
export const CommentFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
@@ -3785,6 +3957,20 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -3803,23 +3989,8 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
|
||||
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
|
||||
export const ReleaseFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -5716,6 +5887,76 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueFields' },
|
||||
@@ -5731,6 +5972,19 @@ export const IssueDocument = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -7051,6 +7305,20 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -9539,6 +9807,271 @@ export const ReactToCommentDocument = {
|
||||
ReactToCommentMutation,
|
||||
ReactToCommentMutationVariables
|
||||
>;
|
||||
export const MarkDecisionDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'MarkDecision' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'on' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'markDecision' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'on' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'on' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
MarkDecisionMutation,
|
||||
MarkDecisionMutationVariables
|
||||
>;
|
||||
export const SearchDecisionsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'SearchDecisions' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'comment' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issue' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
SearchDecisionsQuery,
|
||||
SearchDecisionsQueryVariables
|
||||
>;
|
||||
export const DuplicateIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -16294,6 +16827,20 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -293,6 +293,25 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||
markDecision(commentId: $commentId, on: $on) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query SearchDecisions($search: String, $limit: Int!) {
|
||||
decisions(search: $search, limit: $limit) {
|
||||
comment {
|
||||
...CommentFields
|
||||
}
|
||||
issue {
|
||||
id
|
||||
key
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation DuplicateIssue($issueId: ID!) {
|
||||
duplicateIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -331,6 +331,109 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
/* A decision: marked on its comment, and listed above the ticket. */
|
||||
.ts-comment.is-decision {
|
||||
margin-inline: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
border-left: 3px solid var(--ts-c-green);
|
||||
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
|
||||
}
|
||||
|
||||
.ts-comment.is-pointed {
|
||||
animation: ts-pointed 2.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ts-pointed {
|
||||
0%,
|
||||
40% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__decision {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-green) 16%, transparent);
|
||||
color: var(--ts-c-green);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* A plain icon button, not @pi/ui's sized one. */
|
||||
.ts-comment__flag {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2rem !important;
|
||||
min-width: 0 !important;
|
||||
height: 2rem !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--ts-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
|
||||
&.is-on {
|
||||
color: var(--ts-c-green);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-decisions {
|
||||
ol {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid var(--ts-c-green);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
|
||||
}
|
||||
|
||||
.ts-section pi-icon {
|
||||
color: var(--ts-c-green);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-decisions__meta {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Looks like a link: not @pi/ui's sized button. */
|
||||
.ts-decisions__go {
|
||||
display: inline !important;
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -259,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (issue.decisions.length) {
|
||||
<article
|
||||
class="ts-block ts-decisions"
|
||||
aria-labelledby="decisions-title"
|
||||
>
|
||||
<h2 class="ts-section" id="decisions-title">
|
||||
<pi-icon icon="flag" size="small" />
|
||||
Decisiones
|
||||
<span class="ts-mono">{{ issue.decisions.length }}</span>
|
||||
</h2>
|
||||
<ol>
|
||||
@for (decision of issue.decisions; track decision.id) {
|
||||
<li>
|
||||
<ts-markdown
|
||||
[text]="decision.body"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
<p class="ts-decisions__meta">
|
||||
{{ decision.author.name }} ·
|
||||
{{ ago(decision.createdAt) }}
|
||||
@if (decision.decidedBy; as by) {
|
||||
@if (by.id !== decision.author.id) {
|
||||
· marcada por {{ by.name }}
|
||||
}
|
||||
}
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="ts-decisions__go"
|
||||
(click)="goToComment(decision.id)"
|
||||
>
|
||||
Ver en la conversación
|
||||
</button>
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</article>
|
||||
}
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Descripción</h2>
|
||||
@if (issue.description) {
|
||||
@@ -615,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-mono">{{ commentCount() }}</span>
|
||||
</h2>
|
||||
<ng-template #commentView let-comment>
|
||||
<div class="ts-comment">
|
||||
<div
|
||||
class="ts-comment"
|
||||
[id]="'comment-' + comment.id"
|
||||
[class.is-decision]="comment.decision && !comment.deleted"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
}}</span>
|
||||
@@ -628,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>{{ ago(comment.createdAt)
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
@if (comment.decision && !comment.deleted) {
|
||||
<span class="ts-comment__decision">Decisión</span>
|
||||
}
|
||||
<span class="ts-spacer"></span>
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native: pi-icon-button leaves its button unnamed. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ts-comment__flag"
|
||||
[class.is-on]="comment.decision"
|
||||
[attr.aria-pressed]="comment.decision"
|
||||
[attr.aria-label]="
|
||||
comment.decision
|
||||
? 'Quitar de las decisiones'
|
||||
: 'Marcar como decisión'
|
||||
"
|
||||
[attr.title]="
|
||||
comment.decision
|
||||
? 'Quitar de las decisiones'
|
||||
: 'Marcar como decisión: sale arriba del ticket'
|
||||
"
|
||||
(click)="
|
||||
facade.markDecision(comment.id, !comment.decision)
|
||||
"
|
||||
>
|
||||
<pi-icon icon="flag" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted && comment.author.id === facade.me()
|
||||
) {
|
||||
@@ -1028,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
export class IssuePage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly issueKey = input.required<string>();
|
||||
/** `?comment=` (query param): the comment to bring into view. */
|
||||
readonly comment = input<string>();
|
||||
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
@@ -1334,12 +1408,29 @@ export class IssuePage {
|
||||
});
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const id = this.comment();
|
||||
// Once its comment has arrived (they load after the ticket).
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
effect(() => {
|
||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||
untracked(() => this.presence.setEditing(editing));
|
||||
});
|
||||
}
|
||||
|
||||
/** Scrolls to a comment of the conversation and points at it. */
|
||||
protected goToComment(id: string): void {
|
||||
const element = document.getElementById(`comment-${id}`);
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
element.classList.remove('is-pointed');
|
||||
// Restart the animation when it is pointed at again.
|
||||
void element.offsetWidth;
|
||||
element.classList.add('is-pointed');
|
||||
}
|
||||
|
||||
/** While a form is open in the drawer, I am editing. */
|
||||
private trackEditing(ref: {
|
||||
afterClosed(): { subscribe(f: () => void): unknown };
|
||||
|
||||
@@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Decision,
|
||||
GO_TO,
|
||||
isTyping,
|
||||
IssuesGateway,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
@@ -39,9 +41,28 @@ const issueResult = (i: IssueSummary): Result => ({
|
||||
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
|
||||
});
|
||||
|
||||
/** A decision as a result: opens its ticket at the comment. */
|
||||
const decisionResult = ({ comment, issue }: Decision): Result => ({
|
||||
id: `d:${comment.id}`,
|
||||
kind: 'decision',
|
||||
label: `${issue.key} · ${excerpt(comment.body)}`,
|
||||
detail: comment.author.name,
|
||||
url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`,
|
||||
});
|
||||
|
||||
/** The first words of a comment, without Markdown marks. */
|
||||
function excerpt(body: string): string {
|
||||
const text = body
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_`~#>]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
id: string;
|
||||
kind: 'project' | 'issue';
|
||||
kind: 'project' | 'issue' | 'decision';
|
||||
label: string;
|
||||
detail: string;
|
||||
url: string;
|
||||
@@ -83,7 +104,7 @@ interface Result {
|
||||
#search
|
||||
class="ts-palette__input"
|
||||
type="search"
|
||||
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
|
||||
placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto"
|
||||
role="combobox"
|
||||
aria-controls="ts-palette-results"
|
||||
[attr.aria-expanded]="results().length > 0"
|
||||
@@ -113,6 +134,8 @@ interface Result {
|
||||
<span class="ts-palette__kind">{{
|
||||
result.kind === 'project'
|
||||
? 'Proyecto'
|
||||
: result.kind === 'decision'
|
||||
? 'Decisión'
|
||||
: text().trim().length < 2
|
||||
? 'Fijado'
|
||||
: 'Ticket'
|
||||
@@ -268,6 +291,7 @@ export class CommandPalette {
|
||||
private goTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly foundIssues = signal<IssueSummary[]>([]);
|
||||
private readonly foundDecisions = signal<Decision[]>([]);
|
||||
/** Mine, shown before typing; read again each time it opens. */
|
||||
protected readonly pins = signal<IssueSummary[]>([]);
|
||||
protected readonly results = computed<Result[]>(() => {
|
||||
@@ -288,7 +312,11 @@ export class CommandPalette {
|
||||
detail: p.key,
|
||||
url: `/p/${p.key}/board`,
|
||||
}));
|
||||
return [...projects, ...this.foundIssues().map(issueResult)];
|
||||
return [
|
||||
...projects,
|
||||
...this.foundIssues().map(issueResult),
|
||||
...this.foundDecisions().map(decisionResult),
|
||||
];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -299,15 +327,25 @@ export class CommandPalette {
|
||||
distinctUntilChanged(),
|
||||
switchMap((search) =>
|
||||
search.length < 2
|
||||
? of([])
|
||||
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
|
||||
? of<[IssueSummary[], Decision[]]>([[], []])
|
||||
: combineLatest([
|
||||
this.issues
|
||||
.mine({ assignedToMe: false, search, limit: 8 })
|
||||
.pipe(
|
||||
map((page) => page.items),
|
||||
catchError(() => of([])),
|
||||
),
|
||||
this.issues
|
||||
.searchDecisions(search, 4)
|
||||
.pipe(catchError(() => of([]))),
|
||||
]),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((found) => this.foundIssues.set(found));
|
||||
.subscribe((found) => {
|
||||
this.foundIssues.set(found[0]);
|
||||
this.foundDecisions.set(found[1]);
|
||||
});
|
||||
effect(() => {
|
||||
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user