feat: ask a teammate to review a ticket

«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:38:30 +02:00
parent 172c53d21d
commit 93d028e870
53 changed files with 2836 additions and 54 deletions

View File

@@ -3341,6 +3341,348 @@
] ]
} }
}, },
"/api/issues/{issue}/reviews": {
"post": {
"description": "Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.",
"operationId": "IssuesController_requestReview",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestReviewDto"
}
}
}
},
"responses": {
"201": {
"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": "Ask a member to review the ticket",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/reviews/{review}/answer": {
"post": {
"operationId": "IssuesController_answerReview",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
},
{
"name": "review",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AnswerReviewDto"
}
}
}
},
"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": "Answer a review I was asked for (all good, or changes)",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/reviews/{review}": {
"delete": {
"operationId": "IssuesController_cancelReview",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
},
{
"name": "review",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"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": "Withdraw a review request I made",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/follow-tree": { "/api/issues/{issue}/follow-tree": {
"put": { "put": {
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.", "description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
@@ -3774,6 +4116,15 @@
"type": "boolean" "type": "boolean"
} }
}, },
{
"name": "reviewing",
"required": false,
"in": "query",
"description": "Only the tickets I was asked to review and have not answered",
"schema": {
"type": "boolean"
}
},
{ {
"name": "status", "name": "status",
"required": false, "required": false,
@@ -14605,6 +14956,65 @@
"commentId" "commentId"
] ]
}, },
"ReviewVerdict": {
"type": "string",
"enum": [
"APPROVED",
"CHANGES"
],
"description": "Null while waiting for an answer"
},
"IssueReviewDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"requester": {
"$ref": "#/components/schemas/UserRefDto"
},
"reviewer": {
"$ref": "#/components/schemas/UserRefDto"
},
"note": {
"type": "string",
"nullable": true
},
"requestedAt": {
"type": "string",
"format": "date-time"
},
"verdict": {
"nullable": true,
"description": "Null while waiting for an answer",
"allOf": [
{
"$ref": "#/components/schemas/ReviewVerdict"
}
]
},
"answer": {
"type": "string",
"nullable": true
},
"answeredAt": {
"type": "string",
"nullable": true,
"format": "date-time"
}
},
"required": [
"id",
"requester",
"reviewer",
"note",
"requestedAt",
"verdict",
"answer",
"answeredAt"
]
},
"IssueParentDto": { "IssueParentDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -14880,6 +15290,13 @@
"$ref": "#/components/schemas/UserRefDto" "$ref": "#/components/schemas/UserRefDto"
} }
}, },
"reviews": {
"description": "Reviews asked, open and answered, oldest first",
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueReviewDto"
}
},
"parent": { "parent": {
"nullable": true, "nullable": true,
"description": "The epic or ticket this one breaks down", "description": "The epic or ticket this one breaks down",
@@ -14971,6 +15388,7 @@
"links", "links",
"attachments", "attachments",
"watchers", "watchers",
"reviews",
"parent", "parent",
"blockedBy", "blockedBy",
"votes", "votes",
@@ -16724,6 +17142,42 @@
"at" "at"
] ]
}, },
"RequestReviewDto": {
"type": "object",
"properties": {
"reviewer": {
"type": "string",
"description": "Reviewer's user id (a member of the project)"
},
"note": {
"type": "string",
"maxLength": 1000,
"description": "What to look at"
}
},
"required": [
"reviewer"
]
},
"AnswerReviewDto": {
"type": "object",
"properties": {
"verdict": {
"allOf": [
{
"$ref": "#/components/schemas/ReviewVerdict"
}
]
},
"answer": {
"type": "string",
"maxLength": 1000
}
},
"required": [
"verdict"
]
},
"IssuePageDto": { "IssuePageDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -17986,7 +18440,9 @@
"PROPOSED", "PROPOSED",
"WATCHED", "WATCHED",
"ADDED_TO_PROJECT", "ADDED_TO_PROJECT",
"REMINDER" "REMINDER",
"REVIEW_REQUESTED",
"REVIEW_ANSWERED"
] ]
}, },
"NotificationDto": { "NotificationDto": {

View File

@@ -725,6 +725,9 @@ type Issue {
rank: String! rank: String!
release: ReleaseRef release: ReleaseRef
reporter: UserRef! reporter: UserRef!
"""Reviews asked, open and answered, oldest first"""
reviews: [IssueReview!]!
sprint: SprintRef sprint: SprintRef
"""When work should start (roadmap)""" """When work should start (roadmap)"""
@@ -855,6 +858,20 @@ type IssueReminder {
note: String note: String
} }
"""A review someone asked on the ticket"""
type IssueReview {
answer: String
answeredAt: String
id: ID!
note: String
requestedAt: String!
requester: UserRef!
reviewer: UserRef!
"""Null while waiting for an answer"""
verdict: ReviewVerdict
}
"""RECENT: last updated first. RANK: backlog order.""" """RECENT: last updated first. RANK: backlog order."""
enum IssueSort { enum IssueSort {
RANK RANK
@@ -960,6 +977,9 @@ type Mutation {
"""Add tickets to «Mi día» (10 at most)""" """Add tickets to «Mi día» (10 at most)"""
addToMyDay(day: String!, issueIds: [ID!]!): Boolean! addToMyDay(day: String!, issueIds: [ID!]!): Boolean!
"""Answer a review I was asked for: all good, or changes"""
answerReview(answer: String, issueId: ID!, reviewId: ID!, verdict: ReviewVerdict!): CommandResult!
""" """
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
""" """
@@ -980,6 +1000,9 @@ type Mutation {
""" """
bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult! bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult!
"""Withdraw a review request I made (or, as admin, any)"""
cancelReview(issueId: ID!, reviewId: ID!): CommandResult!
"""Move a ticket through the workflow (incl. QA)""" """Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
@@ -1083,6 +1106,11 @@ type Mutation {
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean! removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""
Ask a member to review a ticket (with an optional note): they are told and see it in their work
"""
requestReview(issueId: ID!, note: String, reviewerId: ID!): CommandResult!
""" """
Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket
""" """
@@ -1311,6 +1339,8 @@ enum NotificationKind {
QA_FAILED QA_FAILED
REMINDER REMINDER
REPLIED REPLIED
REVIEW_ANSWERED
REVIEW_REQUESTED
STATUS_CHANGED STATUS_CHANGED
WATCHED WATCHED
} }
@@ -1679,6 +1709,9 @@ type Query {
"""Only these of my projects""" """Only these of my projects"""
projectIds: [ID!] projectIds: [ID!]
"""Only the tickets I was asked to review (unanswered)"""
reviewing: Boolean! = false
"""Matches key, title or description (global search)""" """Matches key, title or description (global search)"""
search: String search: String
statuses: [String!] statuses: [String!]
@@ -1841,6 +1874,12 @@ type ReminderOnIssue {
reminder: IssueReminder! reminder: IssueReminder!
} }
"""APPROVED: all good. CHANGES: something to change"""
enum ReviewVerdict {
APPROVED
CHANGES
}
"""Tickets to look at now (worst first)""" """Tickets to look at now (worst first)"""
type Risks { type Risks {
blocked: [InsightIssue!]! blocked: [InsightIssue!]!

View File

@@ -578,6 +578,60 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/issues/{issue}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Ask a member to review the ticket
* @description Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.
*/
post: operations["IssuesController_requestReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reviews/{review}/answer": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Answer a review I was asked for (all good, or changes) */
post: operations["IssuesController_answerReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reviews/{review}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Withdraw a review request I made */
delete: operations["IssuesController_cancelReview"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/follow-tree": { "/api/issues/{issue}/follow-tree": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2421,6 +2475,25 @@ export interface components {
*/ */
commentId: string | null; commentId: string | null;
}; };
/**
* @description Null while waiting for an answer
* @enum {string}
*/
ReviewVerdict: "APPROVED" | "CHANGES";
IssueReviewDto: {
/** Format: uuid */
id: string;
requester: components["schemas"]["UserRefDto"];
reviewer: components["schemas"]["UserRefDto"];
note: string | null;
/** Format: date-time */
requestedAt: string;
/** @description Null while waiting for an answer */
verdict: components["schemas"]["ReviewVerdict"] | null;
answer: string | null;
/** Format: date-time */
answeredAt: string | null;
};
IssueParentDto: { IssueParentDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -2520,6 +2593,8 @@ export interface components {
attachments: components["schemas"]["AttachmentDto"][]; attachments: components["schemas"]["AttachmentDto"][];
/** @description People following it */ /** @description People following it */
watchers: components["schemas"]["UserRefDto"][]; watchers: components["schemas"]["UserRefDto"][];
/** @description Reviews asked, open and answered, oldest first */
reviews: components["schemas"]["IssueReviewDto"][];
/** @description The epic or ticket this one breaks down */ /** @description The epic or ticket this one breaks down */
parent: components["schemas"]["IssueParentDto"] | null; parent: components["schemas"]["IssueParentDto"] | null;
/** @description Open tickets it waits for: the ones that block it and the ones it depends on */ /** @description Open tickets it waits for: the ones that block it and the ones it depends on */
@@ -3017,6 +3092,16 @@ export interface components {
/** @description What to remember */ /** @description What to remember */
note?: string; note?: string;
}; };
RequestReviewDto: {
/** @description Reviewer's user id (a member of the project) */
reviewer: string;
/** @description What to look at */
note?: string;
};
AnswerReviewDto: {
verdict: components["schemas"]["ReviewVerdict"];
answer?: string;
};
IssuePageDto: { IssuePageDto: {
items: components["schemas"]["IssueDto"][]; items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */ /** @description Total matches (for pagination) */
@@ -3493,7 +3578,7 @@ export interface components {
path: string | null; path: string | null;
}; };
/** @enum {string} */ /** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER" | "REVIEW_REQUESTED" | "REVIEW_ANSWERED";
NotificationDto: { NotificationDto: {
id: string; id: string;
kind: components["schemas"]["NotificationKind"]; kind: components["schemas"]["NotificationKind"];
@@ -6278,6 +6363,244 @@ export interface operations {
}; };
}; };
}; };
IssuesController_requestReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RequestReviewDto"];
};
};
responses: {
201: {
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"];
};
};
};
};
IssuesController_answerReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
review: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnswerReviewDto"];
};
};
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"];
};
};
};
};
IssuesController_cancelReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
review: 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"];
};
};
};
};
IssuesController_followTree: { IssuesController_followTree: {
parameters: { parameters: {
query?: never; query?: never;
@@ -6587,6 +6910,8 @@ export interface operations {
assignedToMe?: boolean; assignedToMe?: boolean;
/** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */ /** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */
watching?: boolean; watching?: boolean;
/** @description Only the tickets I was asked to review and have not answered */
reviewing?: boolean;
/** @description Status ids. Repeat the parameter or separate with commas */ /** @description Status ids. Repeat the parameter or separate with commas */
status?: string[]; status?: string[];
/** @description Whatever each project calls its statuses */ /** @description Whatever each project calls its statuses */

View File

@@ -89,6 +89,12 @@ export class IssuesResolver {
.describe( .describe(
'Only the tickets the user follows (use with assignedToMe false)', 'Only the tickets the user follows (use with assignedToMe false)',
), ),
reviewing: z
.boolean()
.default(false)
.describe(
'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)',
),
categories: z categories: z
.array(CATEGORY) .array(CATEGORY)
.optional() .optional()
@@ -110,6 +116,7 @@ export class IssuesResolver {
params: { params: {
assignedToMe: boolean; assignedToMe: boolean;
watching: boolean; watching: boolean;
reviewing: boolean;
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[]; priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean; bugsOnly?: boolean;
@@ -125,6 +132,7 @@ export class IssuesResolver {
query: { query: {
assignedToMe: params.assignedToMe, assignedToMe: params.assignedToMe,
watching: params.watching, watching: params.watching,
reviewing: params.reviewing,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities, priority: params.priorities,
bugsOnly: params.bugsOnly, bugsOnly: params.bugsOnly,
@@ -332,6 +340,14 @@ export class IssuesResolver {
url: d.url, url: d.url,
})), })),
watchers: issue.watchers.map((w) => w.name), watchers: issue.watchers.map((w) => w.name),
reviews: issue.reviews.map((r) => ({
id: r.id,
requester: r.requester.name,
reviewer: r.reviewer.name,
note: r.note,
verdict: r.verdict,
answer: r.answer,
})),
attachments: issue.attachments.length, attachments: issue.attachments.length,
trashedAt: issue.trashedAt, trashedAt: issue.trashedAt,
createdAt: issue.createdAt, createdAt: issue.createdAt,
@@ -783,6 +799,62 @@ export class IssuesResolver {
); );
} }
@Tool({
name: 'request_review',
description:
'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).',
paramsSchema: z.object({
issue: issueRef,
reviewer: z.string().min(1),
note: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
requestReview(
params: { issue: string; reviewer: string; note?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews', {
params: { path: { issue: params.issue } },
body: { reviewer: params.reviewer, note: params.note },
}),
),
);
}
@Tool({
name: 'answer_review',
description:
'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
reviewId: z.string().min(1),
verdict: z.enum(['APPROVED', 'CHANGES']),
answer: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
answerReview(
params: {
issue: string;
reviewId: string;
verdict: 'APPROVED' | 'CHANGES';
answer?: string;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', {
params: { path: { issue: params.issue, review: params.reviewId } },
body: { verdict: params.verdict, answer: params.answer },
}),
),
);
}
@Tool({ @Tool({
name: 'my_day', name: 'my_day',
description: description:

View File

@@ -425,7 +425,13 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`, `?watching=true`; sin sesión, ninguno). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional
(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`).
Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST
…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE
…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos
`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` /
`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es `removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
@@ -554,9 +560,9 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | | Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
@@ -762,10 +768,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | 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) | | 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`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `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` | | 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`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `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`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `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` | | 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}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |

View File

@@ -301,14 +301,14 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/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 | | `/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) | | `/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). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre | | `/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). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
| `/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. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy | | `/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. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones |
| `/p/:key/releases` | Releases | | `/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/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 | | `/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 |

View File

@@ -52,7 +52,7 @@ flowchart LR
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
| | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) | | | `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 | | 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; `reviewing`: los que me piden revisar |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `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, decisiones y 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) | | | `create_issue` | Crear un ticket (también colgado de una épica) |
@@ -70,6 +70,8 @@ flowchart LR
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla | | | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) | | | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno | | | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
| | `answer_review` | Responder una revisión: todo bien o cambios |
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos | | | `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 | | | `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 | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |

View File

@@ -49,6 +49,7 @@ export * from './lib/issue-reminders.service';
export * from './lib/my-day/my-day'; export * from './lib/my-day/my-day';
export * from './lib/ports/my-days.repository'; export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service'; export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; export * from './lib/presence.service';

View File

@@ -1,6 +1,7 @@
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler'; import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { MarkDecisionCommandHandler } from './mark-decision.command-handler'; import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler'; import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
import { REVIEW_HANDLERS } from './reviews';
import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -50,6 +51,7 @@ export const COMMAND_HANDLERS = [
ReactToCommentCommandHandler, ReactToCommentCommandHandler,
MarkDecisionCommandHandler, MarkDecisionCommandHandler,
ResolveThreadCommandHandler, ResolveThreadCommandHandler,
...REVIEW_HANDLERS,
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler, UpdateProjectSettingsCommandHandler,

View File

@@ -19,6 +19,7 @@ import {
Project, Project,
IssuePriority, IssuePriority,
IssueType, IssueType,
ReviewVerdict,
ProjectRole, ProjectRole,
TRACKER_FACTORIES, TRACKER_FACTORIES,
defaultProjectSettings, defaultProjectSettings,
@@ -79,6 +80,11 @@ import { AddCommentCommand } from './add-comment.command';
import { ReactToCommentCommand } from './react-to-comment.command'; import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command'; import { MarkDecisionCommand } from './mark-decision.command';
import { ResolveThreadCommand } from './resolve-thread.command'; import { ResolveThreadCommand } from './resolve-thread.command';
import {
AnswerReviewCommand,
CancelReviewCommand,
RequestReviewCommand,
} from './reviews';
import { UpdateProjectCommand } from './update-project.command'; import { UpdateProjectCommand } from './update-project.command';
import { VoteIssueCommand } from './vote-issue.command'; import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command'; import { AddProjectMemberCommand } from './add-project-member.command';
@@ -944,6 +950,74 @@ describe('tracker commands (event sourced)', () => {
); );
}); });
it('asks a teammate to review a ticket, who answers it', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(
new RequestReviewCommand('dev', issue.id, 'qa', ' Mira el móvil '),
);
// Not oneself, not twice the same open one, not outsiders.
await expect(
run(new RequestReviewCommand('dev', issue.id, 'dev')),
).rejects.toThrow(ValidationError);
await expect(
run(new RequestReviewCommand('dev', issue.id, 'qa')),
).rejects.toThrow(BusinessRuleError);
await expect(
run(new RequestReviewCommand('dev', issue.id, 'stranger')),
).rejects.toThrow(BusinessRuleError);
// A viewer can be asked and answer, but not ask.
await run(new RequestReviewCommand('dev', issue.id, 'pm'));
await expect(
run(new RequestReviewCommand('pm', issue.id, 'qa')),
).rejects.toThrow(ForbiddenError);
let reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
expect(reviews.map((r) => [r.reviewerId, r.note, r.verdict])).toEqual([
['qa', 'Mira el móvil', null],
['pm', null, null],
]);
const [forQa, forPm] = reviews;
await expect(
run(
new AnswerReviewCommand(
'dev',
issue.id,
forQa.id,
ReviewVerdict.APPROVED,
),
),
).rejects.toThrow(ForbiddenError);
await run(
new AnswerReviewCommand(
'qa',
issue.id,
forQa.id,
ReviewVerdict.CHANGES,
'Falla en iOS',
),
);
await expect(
run(
new AnswerReviewCommand(
'qa',
issue.id,
forQa.id,
ReviewVerdict.APPROVED,
),
),
).rejects.toThrow(BusinessRuleError);
await run(new CancelReviewCommand('dev', issue.id, forPm.id));
reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
expect(reviews.map((r) => [r.reviewerId, r.verdict, r.answer])).toEqual([
['qa', ReviewVerdict.CHANGES, 'Falla en iOS'],
]);
});
it('settles a conversation, keeping its conclusion as a decision', async () => { it('settles a conversation, keeping its conclusion as a decision', async () => {
const { run, rehydrator, store } = await setup(); const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run); const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,132 @@
import { randomUUID } from 'node:crypto';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
} from '@task-stream/api/shared/domain';
import { ProjectRole, ReviewVerdict } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
/*
* «Ana, can you review this?»: a request on a ticket that the reviewer
* answers (all good, or changes wanted). Events of the ticket, so they show
* in its history and in the activity feed.
*/
export class RequestReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewerId: string,
public readonly note: string | null = null,
) {
super();
}
}
export class AnswerReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewId: string,
public readonly verdict: ReviewVerdict,
public readonly answer: string | null = null,
) {
super();
}
}
export class CancelReviewCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly reviewId: string,
) {
super();
}
}
/** The team asks for reviews: whoever can change the ticket. */
@CommandHandler(RequestReviewCommand)
export class RequestReviewCommandHandler
implements ICommandHandler<RequestReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: RequestReviewCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// Someone who reads the whole project: a guest would not see the ticket.
const role = project.roleOf(command.reviewerId);
if (!role || (role === ProjectRole.GUEST && !project.isPublic))
throw new BusinessRuleError(
`Only members of ${project.key} can be asked to review`,
);
issue.requestReview(
command.actorId,
randomUUID(),
command.reviewerId,
command.note,
);
await issue.commitAndWait();
return ack(issue);
}
}
/** The person asked answers; reading the ticket is enough (viewers too). */
@CommandHandler(AnswerReviewCommand)
export class AnswerReviewCommandHandler
implements ICommandHandler<AnswerReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: AnswerReviewCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
issue.answerReview(
command.actorId,
command.reviewId,
command.verdict,
command.answer,
);
await issue.commitAndWait();
return ack(issue);
}
}
@CommandHandler(CancelReviewCommand)
export class CancelReviewCommandHandler
implements ICommandHandler<CancelReviewCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: CancelReviewCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (!project.isMember(command.actorId))
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
const role = project.roleOf(command.actorId);
issue.cancelReview(
command.actorId,
command.reviewId,
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
);
await issue.commitAndWait();
return ack(issue);
}
}
export const REVIEW_HANDLERS = [
RequestReviewCommandHandler,
AnswerReviewCommandHandler,
CancelReviewCommandHandler,
];

View File

@@ -9,6 +9,10 @@ import {
IssueChecklistItemRemovedEvent, IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent, IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
ReviewVerdict,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -191,6 +195,14 @@ export function describeEvent(
: 'abierta'; : 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`; return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
} }
if (event instanceof IssueReviewRequestedEvent)
return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`;
if (event instanceof IssueReviewAnsweredEvent)
return event.verdict === ReviewVerdict.APPROVED
? `${actor} dio por bueno ${issueKey} al revisarlo`
: `${actor} pidió cambios en ${issueKey} al revisarlo`;
if (event instanceof IssueReviewCancelledEvent)
return `${actor} retiró una petición de revisión de ${issueKey}`;
if (event instanceof IssueParentChangedEvent) { if (event instanceof IssueParentChangedEvent) {
return event.parentKey return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}` ? `${actor} colocó ${issueKey} bajo ${event.parentKey}`

View File

@@ -11,6 +11,9 @@ import {
IssueChecklistItemRemovedEvent, IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent, IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier';
IssueAttachmentAddedEvent, IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent, IssueAttachmentRemovedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueDevLinkRecordedEvent, IssueDevLinkRecordedEvent,
IssueTrashedEvent, IssueTrashedEvent,
IssueRestoredEvent, IssueRestoredEvent,
@@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
event.actorId, event.actorId,
(event as { userId?: string }).userId, (event as { userId?: string }).userId,
(event as { assigneeId?: string }).assigneeId, (event as { assigneeId?: string }).assigneeId,
(event as { reviewerId?: string }).reviewerId,
]; ];
const users = await this.users.refs(mentioned); const users = await this.users.refs(mentioned);
const actorOf = (id: string) => const actorOf = (id: string) =>

View File

@@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler'; import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler'; import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler'; import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler'; import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -54,6 +55,7 @@ export const EVENT_HANDLERS = [
CommentReactedEventHandler, CommentReactedEventHandler,
CommentDecisionMarkedEventHandler, CommentDecisionMarkedEventHandler,
CommentThreadResolvedEventHandler, CommentThreadResolvedEventHandler,
IssueReviewsEventHandler,
IssueVotedEventHandler, IssueVotedEventHandler,
IssueAssignedEventHandler, IssueAssignedEventHandler,
IssueCreatedEventHandler, IssueCreatedEventHandler,

View File

@@ -0,0 +1,74 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type ReviewEvent =
| IssueReviewRequestedEvent
| IssueReviewAnsweredEvent
| IssueReviewCancelledEvent;
/** The ticket's review requests, as its page and «my work» show them. */
@EventsHandler(
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
)
export class IssueReviewsEventHandler implements IEventHandler<ReviewEvent> {
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findIssues: FindIssuesRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReviewEvent) {
const issue = await this.findIssues.findById(event.aggregateId);
if (!issue) return;
// Events of a stream come in order: the list read is the one before.
let reviews = issue.reviews ?? [];
if (event instanceof IssueReviewRequestedEvent) {
if (reviews.some((r) => r.id === event.reviewId)) return;
const refs = await this.users.refs([event.actorId, event.reviewerId]);
reviews = [
...reviews,
{
id: event.reviewId,
requester: refs.of(event.actorId),
reviewer: refs.of(event.reviewerId),
note: event.note,
requestedAt: event.occurredAt,
verdict: null,
answer: null,
answeredAt: null,
},
];
} else if (event instanceof IssueReviewAnsweredEvent) {
reviews = reviews.map((r) =>
r.id === event.reviewId
? {
...r,
verdict: event.verdict,
answer: event.answer,
answeredAt: event.occurredAt,
}
: r,
);
} else {
reviews = reviews.filter((r) => r.id !== event.reviewId);
}
// A review is not a change to the ticket itself: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
reviews,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -9,6 +9,7 @@ import {
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
IssueReadModel, IssueReadModel,
ReviewVerdict,
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change'; import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
@@ -21,6 +22,11 @@ import {
} from './commands/attach-file.command'; } from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command'; import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command'; import { WatchIssueCommand } from './commands/watch-issue.command';
import {
AnswerReviewCommand,
CancelReviewCommand,
RequestReviewCommand,
} from './commands/reviews';
import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command'; import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command'; import { DeleteIssueCommand } from './commands/delete-issue.command';
@@ -212,6 +218,41 @@ export class IssuesService {
); );
} }
/** Asks `reviewerId` (a member) to review the ticket. */
requestReview(
actorId: string,
issueId: string,
reviewerId: string,
note: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new RequestReviewCommand(actorId, issueId, reviewerId, note),
);
}
/** The person asked answers: all good, or changes wanted. */
answerReview(
actorId: string,
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new AnswerReviewCommand(actorId, issueId, reviewId, verdict, answer),
);
}
cancelReview(
actorId: string,
issueId: string,
reviewId: string,
): Promise<CommandResult> {
return this.commands.execute(
new CancelReviewCommand(actorId, issueId, reviewId),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */ /** A ticket's file, with its bytes, for whoever can read the ticket. */
download( download(
viewerId: string | null, viewerId: string | null,

View File

@@ -5,6 +5,9 @@ import {
IssueCreatedEvent, IssueCreatedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssuePriority, IssuePriority,
IssueReviewAnsweredEvent,
IssueReviewRequestedEvent,
ReviewVerdict,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
@@ -69,6 +72,8 @@ const excerpt = (text: string, max = 120) => {
IssueUpdatedEvent, IssueUpdatedEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueParentChangedEvent, IssueParentChangedEvent,
IssueReviewRequestedEvent,
IssueReviewAnsweredEvent,
CommentAddedEvent, CommentAddedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
) )
@@ -161,6 +166,29 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
}); });
} }
} }
if (event instanceof IssueReviewRequestedEvent) {
const note = event.note ? `: «${excerpt(event.note)}»` : '';
drafts.push({
userId: event.reviewerId,
kind: NotificationKind.REVIEW_REQUESTED,
summary: `${actor} te pide que revises ${ticket}${note}`,
});
}
if (event instanceof IssueReviewAnsweredEvent) {
const review = ('reviews' in issue ? (issue.reviews ?? []) : []).find(
(r) => r.id === event.reviewId,
);
const answer = event.answer ? `: «${excerpt(event.answer)}»` : '';
if (review)
drafts.push({
userId: review.requester.id,
kind: NotificationKind.REVIEW_ANSWERED,
summary:
event.verdict === ReviewVerdict.APPROVED
? `${actor} revisó ${ticket} y lo da por bueno${answer}`
: `${actor} revisó ${ticket} y pide cambios${answer}`,
});
}
// A ticket new under a followed one (created there or moved in): the // A ticket new under a followed one (created there or moved in): the
// only way to hear of it. // only way to hear of it.
if (event instanceof IssueParentChangedEvent && event.parentId) { if (event instanceof IssueParentChangedEvent && event.parentId) {

View File

@@ -47,6 +47,8 @@ export interface IssueFilter {
watching?: boolean; watching?: boolean;
/** Only the ones this person follows. */ /** Only the ones this person follows. */
watcherId?: string; watcherId?: string;
/** Only the ones this person was asked to review and has not answered. */
reviewerId?: string;
/** Only the ones this person opened, has or follows (a guest's view). */ /** Only the ones this person opened, has or follows (a guest's view). */
participantId?: string; participantId?: string;
/** RECENT (default): last updated first. RANK: backlog order. */ /** RECENT (default): last updated first. RANK: backlog order. */
@@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter {
assigneeId?: string; assigneeId?: string;
/** Only the ones this person follows. */ /** Only the ones this person follows. */
watcherId?: string; watcherId?: string;
/** Only the ones this person was asked to review and has not answered. */
reviewerId?: string;
statuses?: string[]; statuses?: string[];
categories?: StatusCategory[]; categories?: StatusCategory[];
priorities?: IssuePriority[]; priorities?: IssuePriority[];

View File

@@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler
: undefined, : undefined,
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined,
reviewerId: query.filter.reviewing ? query.viewerId : undefined,
statuses: query.filter.statuses, statuses: query.filter.statuses,
categories: filter.overdue categories: filter.overdue
? (filter.categories?.length ? filter.categories : open).filter((c) => ? (filter.categories?.length ? filter.categories : open).filter((c) =>

View File

@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
assignedToMe: boolean; assignedToMe: boolean;
/** Only the tickets the viewer follows. */ /** Only the tickets the viewer follows. */
watching?: boolean; watching?: boolean;
/** Only the tickets the viewer was asked to review (unanswered). */
reviewing?: boolean;
statuses?: string[]; statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[]; categories?: StatusCategory[];

View File

@@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy';
export * from './lib/issue/issue-checklist'; export * from './lib/issue/issue-checklist';
export * from './lib/issue/issue-rank'; export * from './lib/issue/issue-rank';
export * from './lib/issue/dev-link'; export * from './lib/issue/dev-link';
export * from './lib/issue/review';
export * from './lib/project/project-settings'; export * from './lib/project/project-settings';
export * from './lib/comment/comment'; export * from './lib/comment/comment';
export * from './lib/release/release'; export * from './lib/release/release';

View File

@@ -17,6 +17,7 @@ export * from './comment-edited.event';
export * from './comment-reacted.event'; export * from './comment-reacted.event';
export * from './comment-decision-marked.event'; export * from './comment-decision-marked.event';
export * from './comment-thread-resolved.event'; export * from './comment-thread-resolved.event';
export * from './issue-review.events';
export * from './comment-deleted.event'; export * from './comment-deleted.event';
export * from './release-created.event'; export * from './release-created.event';
export * from './release-updated.event'; export * from './release-updated.event';

View File

@@ -0,0 +1,46 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ReviewVerdict } from '../issue/review';
import { TrackerEvent } from './tracker.event';
/** Someone asked someone else to review a ticket. */
@AutowiredEvent
export class IssueReviewRequestedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
readonly reviewerId: string,
readonly note: string | null,
) {
super(actorId, issueId, projectId);
}
}
/** The reviewer answered: all good, or changes wanted. */
@AutowiredEvent
export class IssueReviewAnsweredEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
readonly verdict: ReviewVerdict,
readonly answer: string | null,
) {
super(actorId, issueId, projectId);
}
}
/** A review request was withdrawn before it was answered. */
@AutowiredEvent
export class IssueReviewCancelledEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly reviewId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -1,6 +1,7 @@
import { import {
BusinessRuleError, BusinessRuleError,
ForbiddenError, ForbiddenError,
NotFoundError,
VersionedAggregateRoot, VersionedAggregateRoot,
assertThat, assertThat,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
@@ -41,6 +42,17 @@ import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link'; import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link';
import { implicitRank, rankBetween } from './issue-rank'; import { implicitRank, rankBetween } from './issue-rank';
import { IssueLinkType, IssuePriority } from './issue.enums'; import { IssueLinkType, IssuePriority } from './issue.enums';
import {
IssueReview,
MAX_OPEN_REVIEWS,
MAX_REVIEW_NOTE,
ReviewVerdict,
} from './review';
import {
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
} from '../events/issue-review.events';
export interface IssueLink { export interface IssueLink {
type: IssueLinkType; type: IssueLinkType;
@@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public attachments: IssueAttachment[] = []; public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */ /** People who asked to be told about this ticket. */
public watchers: string[] = []; public watchers: string[] = [];
/** Asked reviews, open and answered, oldest first. */
public reviews: IssueReview[] = [];
/** People who want it done (a public roadmap's way to prioritise). */ /** People who want it done (a public roadmap's way to prioritise). */
public voterIds: string[] = []; public voterIds: string[] = [];
/** The epic or ticket this one breaks down. */ /** The epic or ticket this one breaks down. */
@@ -404,6 +418,96 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
); );
} }
/**
* Asks `reviewerId` to review it. Not oneself, and not twice to the same
* person while the first is unanswered. Who may be asked is the caller's
* check (a member of the team).
*/
requestReview(
actorId: string,
reviewId: string,
reviewerId: string,
note: string | null,
): void {
if (this.trashedAt)
throw new BusinessRuleError(
`${this.key} is in the trash: restore it to change it`,
);
assertThat(reviewerId !== actorId, 'Ask someone else to review it');
const text = note?.trim() || null;
assertThat(
!text || text.length <= MAX_REVIEW_NOTE,
`A review request takes ${MAX_REVIEW_NOTE} characters at most`,
);
const open = this.reviews.filter((r) => !r.verdict);
if (open.some((r) => r.reviewerId === reviewerId))
throw new BusinessRuleError(
'That person already has an unanswered review request on this ticket',
);
assertThat(
open.length < MAX_OPEN_REVIEWS,
`A ticket takes ${MAX_OPEN_REVIEWS} open review requests at most`,
);
this.apply(
new IssueReviewRequestedEvent(
actorId,
this.id,
this.projectId,
reviewId,
reviewerId,
text,
),
);
}
/** The reviewer answers an open request of theirs. */
answerReview(
actorId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): void {
const review = this.openReview(reviewId);
if (review.reviewerId !== actorId)
throw new ForbiddenError('Only the person asked can answer the review');
const text = answer?.trim() || null;
assertThat(
!text || text.length <= MAX_REVIEW_NOTE,
`An answer takes ${MAX_REVIEW_NOTE} characters at most`,
);
this.apply(
new IssueReviewAnsweredEvent(
actorId,
this.id,
this.projectId,
reviewId,
verdict,
text,
),
);
}
/**
* Withdraws an open request: whoever asked, or (`canModerate`) whoever
* manages the project.
*/
cancelReview(actorId: string, reviewId: string, canModerate: boolean): void {
const review = this.openReview(reviewId);
if (review.requesterId !== actorId && !canModerate)
throw new ForbiddenError('Only whoever asked can withdraw the request');
this.apply(
new IssueReviewCancelledEvent(actorId, this.id, this.projectId, reviewId),
);
}
private openReview(reviewId: string): IssueReview {
const review = this.reviews.find((r) => r.id === reviewId);
if (!review) throw new NotFoundError('Review', reviewId);
if (review.verdict)
throw new BusinessRuleError('That review was already answered');
return review;
}
/** `userId` starts following the ticket. Idempotent. */ /** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void { watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return; if (this.watchers.includes(userId)) return;
@@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.voterIds = event.voterIds; this.voterIds = event.voterIds;
} }
onIssueReviewRequestedEvent(event: IssueReviewRequestedEvent): void {
this.reviews = [
...(this.reviews ?? []),
{
id: event.reviewId,
requesterId: event.actorId,
reviewerId: event.reviewerId,
note: event.note,
requestedAt: event.occurredAt,
verdict: null,
answer: null,
answeredAt: null,
},
];
}
onIssueReviewAnsweredEvent(event: IssueReviewAnsweredEvent): void {
this.reviews = this.reviews.map((r) =>
r.id === event.reviewId
? {
...r,
verdict: event.verdict,
answer: event.answer,
answeredAt: event.occurredAt,
}
: r,
);
}
onIssueReviewCancelledEvent(event: IssueReviewCancelledEvent): void {
this.reviews = this.reviews.filter((r) => r.id !== event.reviewId);
}
onIssueWatchedEvent(event: IssueWatchedEvent): void { onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId]; this.watchers = [...this.watchers, event.userId];
} }

View File

@@ -0,0 +1,23 @@
/** What a reviewer said. */
export enum ReviewVerdict {
APPROVED = 'APPROVED',
CHANGES = 'CHANGES',
}
/** «Ana, can you review this?» on a ticket. */
export interface IssueReview {
id: string;
requesterId: string;
reviewerId: string;
/** What to look at, in a line or two. */
note: string | null;
requestedAt: string;
/** Null while waiting for an answer. */
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
}
export const MAX_REVIEW_NOTE = 1000;
/** Open requests a ticket takes at once. */
export const MAX_OPEN_REVIEWS = 10;

View File

@@ -1,3 +1,4 @@
import { ReviewVerdict } from '../issue/review';
import { DevLink } from '../issue/dev-link'; import { DevLink } from '../issue/dev-link';
import { IssueLinkType, IssuePriority } from '../issue/issue.enums'; import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
import { StatusCategory } from '../project/project-settings'; import { StatusCategory } from '../project/project-settings';
@@ -85,6 +86,8 @@ export class IssueReadModel {
attachments!: AttachmentReadModel[]; attachments!: AttachmentReadModel[];
/** People following the ticket. */ /** People following the ticket. */
watchers!: UserRefReadModel[]; watchers!: UserRefReadModel[];
/** Asked reviews, open and answered, oldest first. */
reviews!: IssueReviewReadModel[];
/** How many want it done. */ /** How many want it done. */
votes!: number; votes!: number;
/** Who voted: to tell someone whether they did; never shown as a list. */ /** Who voted: to tell someone whether they did; never shown as a list. */
@@ -103,3 +106,16 @@ export class IssueReadModel {
/** Position of the last projected event. */ /** Position of the last projected event. */
version!: number; version!: number;
} }
/** A review asked on a ticket, as the read side shows it. */
export class IssueReviewReadModel {
id!: string;
requester!: UserRefReadModel;
reviewer!: UserRefReadModel;
note!: string | null;
requestedAt!: string;
/** Null while waiting for an answer. */
verdict!: ReviewVerdict | null;
answer!: string | null;
answeredAt!: string | null;
}

View File

@@ -22,6 +22,10 @@ export enum NotificationKind {
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
/** A ticket of yours is due soon or has been still for too long. */ /** A ticket of yours is due soon or has been still for too long. */
REMINDER = 'REMINDER', REMINDER = 'REMINDER',
/** Someone asked you to review a ticket. */
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
/** The review you asked for was answered. */
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
} }
/** /**

View File

@@ -176,6 +176,10 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}; };
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.reviewerId)
query['reviews'] = {
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
};
if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length) if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories }; query['statusCategory'] = { $in: filter.categories };
@@ -497,6 +501,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
if (filter.unassigned) query['assignee'] = null; if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.reviewerId)
query['reviews'] = {
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
};
if (filter.participantId) if (filter.participantId)
query.$and = [ query.$and = [
...(query.$and ?? []), ...(query.$and ?? []),

View File

@@ -524,6 +524,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
// Tickets projected before attachments existed have none. // Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [], attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [], watchers: rest.watchers ?? [],
reviews: rest.reviews ?? [],
votes: rest.votes ?? 0, votes: rest.votes ?? 0,
voterIds: rest.voterIds ?? [], voterIds: rest.voterIds ?? [],
parent: rest.parent?.id ? rest.parent : null, parent: rest.parent?.id ? rest.parent : null,

View File

@@ -15,6 +15,7 @@ import {
StatusCategory, StatusCategory,
DevLinkKind, DevLinkKind,
DevLinkState, DevLinkState,
ReviewVerdict,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
export enum LinkDirection { export enum LinkDirection {
@@ -23,6 +24,10 @@ export enum LinkDirection {
} }
registerEnumType(IssuePriority, { name: 'IssuePriority' }); registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(ReviewVerdict, {
name: 'ReviewVerdict',
description: 'APPROVED: all good. CHANGES: something to change',
});
registerEnumType(CustomFieldType, { registerEnumType(CustomFieldType, {
name: 'CustomFieldType', name: 'CustomFieldType',
description: 'What a custom field holds (DATE: YYYY-MM-DD)', description: 'What a custom field holds (DATE: YYYY-MM-DD)',

View File

@@ -27,6 +27,7 @@ import {
import { import {
IssuePriority, IssuePriority,
IssueReadModel, IssueReadModel,
ReviewVerdict,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
@@ -168,6 +169,11 @@ export class IssueResolver {
description: 'Only the tickets I follow', description: 'Only the tickets I follow',
}) })
watching: boolean, watching: boolean,
@Args('reviewing', {
defaultValue: false,
description: 'Only the tickets I was asked to review (unanswered)',
})
reviewing: boolean,
@Args('statuses', { type: () => [String], nullable: true }) @Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined, statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true }) @Args('categories', { type: () => [StatusCategory], nullable: true })
@@ -204,6 +210,7 @@ export class IssueResolver {
return this.issuesService.mine(me.id, { return this.issuesService.mine(me.id, {
assignedToMe, assignedToMe,
watching, watching,
reviewing,
statuses: statuses ?? undefined, statuses: statuses ?? undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
search: search ?? undefined, search: search ?? undefined,
@@ -508,6 +515,54 @@ export class IssueResolver {
return true; return true;
} }
@Mutation(() => CommandResultGql, {
description:
'Ask a member to review a ticket (with an optional note): they are told and see it in their work',
})
requestReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewerId', { type: () => ID }) reviewerId: string,
@Args('note', { nullable: true }) note: string | null,
) {
return this.issuesService.requestReview(
me.id,
issueId,
reviewerId,
note ?? null,
);
}
@Mutation(() => CommandResultGql, {
description: 'Answer a review I was asked for: all good, or changes',
})
answerReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewId', { type: () => ID }) reviewId: string,
@Args('verdict', { type: () => ReviewVerdict }) verdict: ReviewVerdict,
@Args('answer', { nullable: true }) answer: string | null,
) {
return this.issuesService.answerReview(
me.id,
issueId,
reviewId,
verdict,
answer ?? null,
);
}
@Mutation(() => CommandResultGql, {
description: 'Withdraw a review request I made (or, as admin, any)',
})
cancelReview(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('reviewId', { type: () => ID }) reviewId: string,
) {
return this.issuesService.cancelReview(me.id, issueId, reviewId);
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue( unwatchIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -10,6 +10,7 @@ import {
StatusCategory, StatusCategory,
DevLinkKind, DevLinkKind,
DevLinkState, DevLinkState,
ReviewVerdict,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
AutomationActionKind, AutomationActionKind,
@@ -371,6 +372,24 @@ export class IssueParentGql {
@Field() title!: string; @Field() title!: string;
} }
@ObjectType('IssueReview', {
description: 'A review someone asked on the ticket',
})
export class IssueReviewGql {
@Field(() => ID) id!: string;
@Field(() => UserRefGql) requester!: UserRefGql;
@Field(() => UserRefGql) reviewer!: UserRefGql;
@Field(() => String, { nullable: true }) note!: string | null;
@Field() requestedAt!: string;
@Field(() => ReviewVerdict, {
nullable: true,
description: 'Null while waiting for an answer',
})
verdict!: ReviewVerdict | null;
@Field(() => String, { nullable: true }) answer!: string | null;
@Field(() => String, { nullable: true }) answeredAt!: string | null;
}
@ObjectType('Issue') @ObjectType('Issue')
export class IssueGql { export class IssueGql {
@Field(() => ID) id!: string; @Field(() => ID) id!: string;
@@ -408,6 +427,10 @@ export class IssueGql {
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
@Field(() => [UserRefGql], { description: 'People following the ticket' }) @Field(() => [UserRefGql], { description: 'People following the ticket' })
watchers!: UserRefGql[]; watchers!: UserRefGql[];
@Field(() => [IssueReviewGql], {
description: 'Reviews asked, open and answered, oldest first',
})
reviews!: IssueReviewGql[];
@Field(() => IssueParentGql, { @Field(() => IssueParentGql, {
nullable: true, nullable: true,
description: 'The epic or ticket this one breaks down', description: 'The epic or ticket this one breaks down',

View File

@@ -10,6 +10,7 @@ import {
IssuePriority, IssuePriority,
ProjectRole, ProjectRole,
REACTIONS, REACTIONS,
ReviewVerdict,
SETTING_COLOR_PATTERN, SETTING_COLOR_PATTERN,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
@@ -555,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsBoolean() @IsBoolean()
watching?: boolean; watching?: boolean;
@ApiPropertyOptional({
description: 'Only the tickets I was asked to review and have not answered',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
reviewing?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: [String], type: [String],
description: 'Status ids. Repeat the parameter or separate with commas', description: 'Status ids. Repeat the parameter or separate with commas',
@@ -1552,6 +1561,30 @@ export class PushEndpointDto {
endpoint!: string; endpoint!: string;
} }
export class RequestReviewDto {
@ApiProperty({ description: "Reviewer's user id (a member of the project)" })
@IsString()
reviewer!: string;
@ApiPropertyOptional({ maxLength: 1000, description: 'What to look at' })
@IsOptional()
@IsString()
@MaxLength(1000)
note?: string;
}
export class AnswerReviewDto {
@ApiProperty({ enum: ReviewVerdict, enumName: 'ReviewVerdict' })
@IsEnum(ReviewVerdict)
verdict!: ReviewVerdict;
@ApiPropertyOptional({ maxLength: 1000 })
@IsOptional()
@IsString()
@MaxLength(1000)
answer?: string;
}
export class MyDayQueryDto { export class MyDayQueryDto {
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' }) @ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)

View File

@@ -3,6 +3,7 @@ import {
CustomFieldType, CustomFieldType,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
ReviewVerdict,
NotificationKind, NotificationKind,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
@@ -481,6 +482,11 @@ export class IssueDto {
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' }) @ApiProperty({ type: [UserRefDto], description: 'People following it' })
watchers!: UserRefDto[]; watchers!: UserRefDto[];
@ApiProperty({
type: () => [IssueReviewDto],
description: 'Reviews asked, open and answered, oldest first',
})
reviews!: IssueReviewDto[];
@ApiProperty({ @ApiProperty({
type: IssueParentDto, type: IssueParentDto,
nullable: true, nullable: true,
@@ -1439,3 +1445,21 @@ export class MyDayDto {
@ApiProperty({ type: MyDayLeftoverDto, nullable: true }) @ApiProperty({ type: MyDayLeftoverDto, nullable: true })
leftover!: MyDayLeftoverDto | null; leftover!: MyDayLeftoverDto | null;
} }
export class IssueReviewDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ type: UserRefDto }) requester!: UserRefDto;
@ApiProperty({ type: UserRefDto }) reviewer!: UserRefDto;
@ApiProperty({ type: String, nullable: true }) note!: string | null;
@ApiProperty({ format: 'date-time' }) requestedAt!: string;
@ApiProperty({
enum: ReviewVerdict,
enumName: 'ReviewVerdict',
nullable: true,
description: 'Null while waiting for an answer',
})
verdict!: ReviewVerdict | null;
@ApiProperty({ type: String, nullable: true }) answer!: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
answeredAt!: string | null;
}

View File

@@ -50,6 +50,8 @@ import {
CreateIssueDto, CreateIssueDto,
DecisionsQueryDto, DecisionsQueryDto,
SetReminderDto, SetReminderDto,
RequestReviewDto,
AnswerReviewDto,
MyDayQueryDto, MyDayQueryDto,
IssueSearchQueryDto, IssueSearchQueryDto,
PresenceBodyDto, PresenceBodyDto,
@@ -316,6 +318,70 @@ export class IssuesController {
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue)); await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
} }
@Post('issues/:issue/reviews')
@ISSUE
@ApiOperation({
summary: 'Ask a member to review the ticket',
description:
'Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async requestReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: RequestReviewDto,
) {
return this.issues.requestReview(
me.id,
await this.refs.issueId(me.id, issue),
body.reviewer,
body.note ?? null,
);
}
@Post('issues/:issue/reviews/:review/answer')
@HttpCode(200)
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({
summary: 'Answer a review I was asked for (all good, or changes)',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async answerReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
@Body() body: AnswerReviewDto,
) {
return this.issues.answerReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
body.verdict,
body.answer ?? null,
);
}
@Delete('issues/:issue/reviews/:review')
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({ summary: 'Withdraw a review request I made' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async cancelReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
) {
return this.issues.cancelReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
);
}
@Put('issues/:issue/follow-tree') @Put('issues/:issue/follow-tree')
@HttpCode(204) @HttpCode(204)
@ISSUE @ISSUE
@@ -390,6 +456,7 @@ export class IssuesController {
const page = await this.issues.mine(me.id, { const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe, assignedToMe: query.assignedToMe,
watching: query.watching, watching: query.watching,
reviewing: query.reviewing,
statuses: query.status, statuses: query.status,
categories: query.category, categories: query.category,
search: query.search, search: query.search,

View File

@@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({
mine: IssuePage; mine: IssuePage;
awaitingQa: IssuePage; awaitingQa: IssuePage;
following: IssuePage; following: IssuePage;
reviewing: IssuePage;
releases: Release[]; releases: Release[];
}>(), }>(),
loadFailed: type<string>(), loadFailed: type<string>(),

View File

@@ -9,6 +9,7 @@ import {
IssueSummary, IssueSummary,
LinkIssuesInput, LinkIssuesInput,
UpdateIssueInput, UpdateIssueInput,
ReviewVerdict,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
export const issuePageEvents = eventGroup({ export const issuePageEvents = eventGroup({
@@ -50,6 +51,18 @@ export const issuePageEvents = eventGroup({
watchRequested: type<{ issueId: string; watching: boolean }>(), watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(),
followTreeRequested: type<{ issueId: string; on: boolean }>(), followTreeRequested: type<{ issueId: string; on: boolean }>(),
reviewRequested: type<{
issueId: string;
reviewerId: string;
note: string | null;
}>(),
reviewAnswerRequested: type<{
issueId: string;
reviewId: string;
verdict: ReviewVerdict;
answer: string | null;
}>(),
reviewCancelRequested: type<{ issueId: string; reviewId: string }>(),
reminderRequested: type<{ reminderRequested: type<{
issueId: string; issueId: string;
/** null: drop it. */ /** null: drop it. */

View File

@@ -21,6 +21,8 @@ export class HomeFacade {
/** Open tickets I follow, whoever works on them. */ /** Open tickets I follow, whoever works on them. */
readonly following = this.store.following; readonly following = this.store.following;
readonly followingTotal = this.store.followingTotal; readonly followingTotal = this.store.followingTotal;
/** Tickets I was asked to review and have not answered. */
readonly reviewing = this.store.reviewing;
/** Planned releases of my projects, soonest first. */ /** Planned releases of my projects, soonest first. */
readonly releases = this.store.releases; readonly releases = this.store.releases;
/** False until the first load: show skeletons, not empty states. */ /** False until the first load: show skeletons, not empty states. */

View File

@@ -8,6 +8,7 @@ import {
IssueLinkType, IssueLinkType,
IssueSummary, IssueSummary,
UpdateIssueInput, UpdateIssueInput,
ReviewVerdict,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { issuePageEvents } from '../events/issue.events'; import { issuePageEvents } from '../events/issue.events';
@@ -120,6 +121,39 @@ export class IssueFacade {
); );
} }
/** Asks a member to review the open ticket. */
requestReview(reviewerId: string, note: string | null = null): void {
this.withIssue((issueId) =>
this.dispatch.reviewRequested({
issueId,
reviewerId,
note: note?.trim() || null,
}),
);
}
/** Answers a review I was asked for. */
answerReview(
reviewId: string,
verdict: ReviewVerdict,
answer: string | null = null,
): void {
this.withIssue((issueId) =>
this.dispatch.reviewAnswerRequested({
issueId,
reviewId,
verdict,
answer: answer?.trim() || null,
}),
);
}
cancelReview(reviewId: string): void {
this.withIssue((issueId) =>
this.dispatch.reviewCancelRequested({ issueId, reviewId }),
);
}
/** Remind me of the ticket then (null: drop it); one per ticket. */ /** Remind me of the ticket then (null: drop it); one per ticket. */
setReminder(at: string | null, note: string | null = null): void { setReminder(at: string | null, note: string | null = null): void {
this.withIssue((issueId) => this.withIssue((issueId) =>

View File

@@ -34,6 +34,8 @@ interface HomeState {
awaitingQaTotal: number; awaitingQaTotal: number;
following: IssueSummary[]; following: IssueSummary[];
followingTotal: number; followingTotal: number;
/** Tickets I was asked to review and have not answered. */
reviewing: IssueSummary[];
releases: Release[]; releases: Release[];
loaded: boolean; loaded: boolean;
loading: boolean; loading: boolean;
@@ -54,6 +56,7 @@ export const HomeStore = signalStore(
awaitingQaTotal: 0, awaitingQaTotal: 0,
following: [], following: [],
followingTotal: 0, followingTotal: 0,
reviewing: [],
releases: [], releases: [],
loaded: false, loaded: false,
loading: false, loading: false,
@@ -88,6 +91,7 @@ export const HomeStore = signalStore(
awaitingQaTotal: payload.awaitingQa.total, awaitingQaTotal: payload.awaitingQa.total,
following: payload.following.items, following: payload.following.items,
followingTotal: payload.following.total, followingTotal: payload.following.total,
reviewing: payload.reviewing.items,
releases: payload.releases, releases: payload.releases,
loaded: true, loaded: true,
loading: false, loading: false,
@@ -123,6 +127,12 @@ export const HomeStore = signalStore(
categories: [...OPEN_CATEGORIES], categories: [...OPEN_CATEGORIES],
limit: 20, limit: 20,
}), }),
reviewing: issues.mine({
assignedToMe: false,
reviewing: true,
categories: [...OPEN_CATEGORIES, StatusCategory.DONE],
limit: 20,
}),
releases: releases.upcoming(5), releases: releases.upcoming(5),
}).pipe( }).pipe(
mapResponse({ mapResponse({

View File

@@ -21,6 +21,7 @@ import {
IssuesGateway, IssuesGateway,
IssueSummary, IssueSummary,
SprintsGateway, SprintsGateway,
ReviewVerdict,
TrackerRealtimeGateway, TrackerRealtimeGateway,
whenLabel, whenLabel,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -380,6 +381,53 @@ export const IssueStore = signalStore(
), ),
), ),
), ),
events
.on(issuePageEvents.reviewRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.requestReview(
payload.issueId,
payload.reviewerId,
payload.note,
),
'Revisión pedida',
done,
failed,
),
),
),
events
.on(issuePageEvents.reviewAnswerRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.answerReview(
payload.issueId,
payload.reviewId,
payload.verdict,
payload.answer,
),
payload.verdict === ReviewVerdict.APPROVED
? 'Revisado: todo bien'
: 'Revisado: pides cambios',
done,
failed,
),
),
),
events
.on(issuePageEvents.reviewCancelRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.cancelReview(payload.issueId, payload.reviewId),
'Petición de revisión retirada',
done,
failed,
),
),
),
events events
.on(issuePageEvents.reminderRequested) .on(issuePageEvents.reminderRequested)
.pipe( .pipe(

View File

@@ -70,7 +70,15 @@ export enum NotificationKind {
WATCHED = 'WATCHED', WATCHED = 'WATCHED',
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
REMINDER = 'REMINDER', REMINDER = 'REMINDER',
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
} }
/** The reactions a comment takes, in the order they are offered (GitHub's). */ /** The reactions a comment takes, in the order they are offered (GitHub's). */
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀']; export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];
/** What a reviewer said. */
export enum ReviewVerdict {
APPROVED = 'APPROVED',
CHANGES = 'CHANGES',
}

View File

@@ -121,6 +121,8 @@ export interface MyIssuesFilter {
assignedToMe: boolean; assignedToMe: boolean;
/** Only the tickets I follow. */ /** Only the tickets I follow. */
watching?: boolean; watching?: boolean;
/** Only the tickets I was asked to review (unanswered). */
reviewing?: boolean;
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */ /** Matches key, title or description. */
search?: string; search?: string;

View File

@@ -2,6 +2,7 @@ import {
NotificationKind, NotificationKind,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
ReviewVerdict,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
SprintStatus, SprintStatus,
@@ -192,6 +193,8 @@ export interface Issue extends IssueSummary {
attachments: Attachment[]; attachments: Attachment[];
/** People following the ticket. */ /** People following the ticket. */
watchers: UserRef[]; watchers: UserRef[];
/** Reviews asked, open and answered, oldest first. */
reviews: IssueReview[];
/** Whether I keep it pinned (home page and search). */ /** Whether I keep it pinned (home page and search). */
pinned: boolean; pinned: boolean;
/** Whether I follow it with everything under it (an epic). */ /** Whether I follow it with everything under it (an epic). */
@@ -208,6 +211,19 @@ export interface Issue extends IssueSummary {
} }
/** Someone else on a ticket's page right now. */ /** Someone else on a ticket's page right now. */
/** A review someone asked on a ticket. */
export interface IssueReview {
id: string;
requester: UserRef;
reviewer: UserRef;
note: string | null;
requestedAt: string;
/** Null while waiting for an answer. */
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
}
/** My own reminder on a ticket. */ /** My own reminder on a ticket. */
export interface IssueReminder { export interface IssueReminder {
/** ISO date-time. */ /** ISO date-time. */

View File

@@ -1,4 +1,4 @@
import { IssuePriority } from '../models/enums'; import { IssuePriority, ReviewVerdict } from '../models/enums';
import { MyDay } from '../models/my-day'; import { MyDay } from '../models/my-day';
import { Home } from '../models/home'; import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights'; import { PortfolioProject, ProjectInsights } from '../models/insights';
@@ -143,6 +143,23 @@ export abstract class IssuesGateway {
issueId: string, issueId: string,
releaseId: string | null, releaseId: string | null,
): Observable<CommandResult>; ): Observable<CommandResult>;
/** Asks a member to review the ticket. */
abstract requestReview(
issueId: string,
reviewerId: string,
note: string | null,
): Observable<CommandResult>;
/** Answers a review I was asked for. */
abstract answerReview(
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Observable<CommandResult>;
abstract cancelReview(
issueId: string,
reviewId: string,
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */ /** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>; abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
/** My pinned tickets, most recently pinned first. */ /** My pinned tickets, most recently pinned first. */

View File

@@ -18,6 +18,7 @@ import {
LinkIssuesInput, LinkIssuesInput,
MyDay, MyDay,
MyIssuesFilter, MyIssuesFilter,
ReviewVerdict,
mergeBulkResults, mergeBulkResults,
UpdateIssueInput, UpdateIssueInput,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -51,6 +52,9 @@ import {
UnwatchIssueDocument, UnwatchIssueDocument,
VoteIssueDocument, VoteIssueDocument,
WatchIssueDocument, WatchIssueDocument,
RequestReviewDocument,
AnswerReviewDocument,
CancelReviewDocument,
MyPinsDocument, MyPinsDocument,
FollowTreeDocument, FollowTreeDocument,
SetReminderDocument, SetReminderDocument,
@@ -115,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.query(MyIssuesDocument, { .query(MyIssuesDocument, {
assignedToMe: filter.assignedToMe, assignedToMe: filter.assignedToMe,
watching: filter.watching ?? false, watching: filter.watching ?? false,
reviewing: filter.reviewing ?? false,
categories: filter.categories as never, categories: filter.categories as never,
limit: filter.limit, limit: filter.limit,
search: filter.search?.trim() || null, search: filter.search?.trim() || null,
@@ -281,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway {
return done$.pipe(map(() => undefined)); return done$.pipe(map(() => undefined));
} }
requestReview(
issueId: string,
reviewerId: string,
note: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(RequestReviewDocument, { issueId, reviewerId, note })
.pipe(map((d) => toCommandResult(d.requestReview)));
}
answerReview(
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(AnswerReviewDocument, {
issueId,
reviewId,
verdict: verdict as never,
answer,
})
.pipe(map((d) => toCommandResult(d.answerReview)));
}
cancelReview(issueId: string, reviewId: string): Observable<CommandResult> {
return this.gql
.mutate(CancelReviewDocument, { issueId, reviewId })
.pipe(map((d) => toCommandResult(d.cancelReview)));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> { watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching return watching
? this.gql ? this.gql

View File

@@ -126,6 +126,20 @@ fragment IssueFields on Issue {
at at
note note
} }
reviews {
id
requester {
...UserRefFields
}
reviewer {
...UserRefFields
}
note
requestedAt
verdict
answer
answeredAt
}
decisions { decisions {
...CommentFields ...CommentFields
} }

View File

@@ -269,6 +269,8 @@ export type NotificationKind =
| 'QA_FAILED' | 'QA_FAILED'
| 'REMINDER' | 'REMINDER'
| 'REPLIED' | 'REPLIED'
| 'REVIEW_ANSWERED'
| 'REVIEW_REQUESTED'
| 'STATUS_CHANGED' | 'STATUS_CHANGED'
| 'WATCHED'; | 'WATCHED';
@@ -312,6 +314,9 @@ export type RankIssueInput = {
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
/** APPROVED: all good. CHANGES: something to change */
export type ReviewVerdict = 'APPROVED' | 'CHANGES';
export type SaveFilterInput = { export type SaveFilterInput = {
criteria: SavedFilterCriteriaInput; criteria: SavedFilterCriteriaInput;
name: string; name: string;
@@ -715,6 +720,16 @@ export type IssueFieldsFragment = {
dueDate: string | null; dueDate: string | null;
trashedAt: string | null; trashedAt: string | null;
myReminder: { at: string; note: string | null } | null; myReminder: { at: string; note: string | null } | null;
reviews: Array<{
id: string;
note: string | null;
requestedAt: string;
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
requester: { id: string; name: string };
reviewer: { id: string; name: string };
}>;
decisions: Array<{ decisions: Array<{
id: string; id: string;
issueId: string; issueId: string;
@@ -962,6 +977,16 @@ export type IssueQuery = {
dueDate: string | null; dueDate: string | null;
trashedAt: string | null; trashedAt: string | null;
myReminder: { at: string; note: string | null } | null; myReminder: { at: string; note: string | null } | null;
reviews: Array<{
id: string;
note: string | null;
requestedAt: string;
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
requester: { id: string; name: string };
reviewer: { id: string; name: string };
}>;
decisions: Array<{ decisions: Array<{
id: string; id: string;
issueId: string; issueId: string;
@@ -1105,6 +1130,36 @@ export type UnlinkIssuesMutation = {
unlinkIssues: { id: string; key: string | null; version: number }; unlinkIssues: { id: string; key: string | null; version: number };
}; };
export type RequestReviewMutationVariables = Exact<{
issueId: string;
reviewerId: string;
note?: string | null | undefined;
}>;
export type RequestReviewMutation = {
requestReview: { id: string; key: string | null; version: number };
};
export type AnswerReviewMutationVariables = Exact<{
issueId: string;
reviewId: string;
verdict: ReviewVerdict;
answer?: string | null | undefined;
}>;
export type AnswerReviewMutation = {
answerReview: { id: string; key: string | null; version: number };
};
export type CancelReviewMutationVariables = Exact<{
issueId: string;
reviewId: string;
}>;
export type CancelReviewMutation = {
cancelReview: { id: string; key: string | null; version: number };
};
export type WatchIssueMutationVariables = Exact<{ export type WatchIssueMutationVariables = Exact<{
issueId: string; issueId: string;
}>; }>;
@@ -1347,6 +1402,7 @@ export type DeleteCommentMutation = {
export type MyIssuesQueryVariables = Exact<{ export type MyIssuesQueryVariables = Exact<{
assignedToMe: boolean; assignedToMe: boolean;
watching?: boolean | null | undefined; watching?: boolean | null | undefined;
reviewing?: boolean | null | undefined;
categories?: Array<StatusCategory> | StatusCategory | null | undefined; categories?: Array<StatusCategory> | StatusCategory | null | undefined;
limit: number; limit: number;
search?: string | null | undefined; search?: string | null | undefined;
@@ -3948,6 +4004,47 @@ export const IssueFieldsFragmentDoc = {
], ],
}, },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'reviews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'requester' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviewer' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
],
},
},
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, name: { kind: 'Name', value: 'decisions' },
@@ -6560,6 +6657,47 @@ export const IssueDocument = {
], ],
}, },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'reviews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'requester' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviewer' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
],
},
},
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, name: { kind: 'Name', value: 'decisions' },
@@ -7308,6 +7446,324 @@ export const UnlinkIssuesDocument = {
UnlinkIssuesMutation, UnlinkIssuesMutation,
UnlinkIssuesMutationVariables UnlinkIssuesMutationVariables
>; >;
export const RequestReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RequestReview' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewerId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'requestReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewerId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewerId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'note' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'note' },
},
},
],
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<
RequestReviewMutation,
RequestReviewMutationVariables
>;
export const AnswerReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'AnswerReview' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'verdict' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ReviewVerdict' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'answer' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'answerReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'verdict' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'verdict' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'answer' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'answer' },
},
},
],
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<
AnswerReviewMutation,
AnswerReviewMutationVariables
>;
export const CancelReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CancelReview' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'cancelReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
},
],
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<
CancelReviewMutation,
CancelReviewMutationVariables
>;
export const WatchIssueDocument = { export const WatchIssueDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -8781,6 +9237,14 @@ export const MyIssuesDocument = {
}, },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
}, },
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewing' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
{ {
kind: 'VariableDefinition', kind: 'VariableDefinition',
variable: { variable: {
@@ -8888,6 +9352,14 @@ export const MyIssuesDocument = {
name: { kind: 'Name', value: 'watching' }, name: { kind: 'Name', value: 'watching' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewing' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewing' },
},
},
{ {
kind: 'Argument', kind: 'Argument',
name: { kind: 'Name', value: 'categories' }, name: { kind: 'Name', value: 'categories' },

View File

@@ -83,6 +83,34 @@ mutation UnlinkIssues($input: LinkIssuesInput!) {
} }
} }
mutation RequestReview($issueId: ID!, $reviewerId: ID!, $note: String) {
requestReview(issueId: $issueId, reviewerId: $reviewerId, note: $note) {
...CommandResultFields
}
}
mutation AnswerReview(
$issueId: ID!
$reviewId: ID!
$verdict: ReviewVerdict!
$answer: String
) {
answerReview(
issueId: $issueId
reviewId: $reviewId
verdict: $verdict
answer: $answer
) {
...CommandResultFields
}
}
mutation CancelReview($issueId: ID!, $reviewId: ID!) {
cancelReview(issueId: $issueId, reviewId: $reviewId) {
...CommandResultFields
}
}
mutation WatchIssue($issueId: ID!) { mutation WatchIssue($issueId: ID!) {
watchIssue(issueId: $issueId) { watchIssue(issueId: $issueId) {
...CommandResultFields ...CommandResultFields
@@ -188,6 +216,7 @@ mutation DeleteComment($commentId: ID!) {
query MyIssues( query MyIssues(
$assignedToMe: Boolean! $assignedToMe: Boolean!
$watching: Boolean $watching: Boolean
$reviewing: Boolean
$categories: [StatusCategory!] $categories: [StatusCategory!]
$limit: Int! $limit: Int!
$search: String $search: String
@@ -199,6 +228,7 @@ query MyIssues(
myIssues( myIssues(
assignedToMe: $assignedToMe assignedToMe: $assignedToMe
watching: $watching watching: $watching
reviewing: $reviewing
categories: $categories categories: $categories
limit: $limit limit: $limit
search: $search search: $search

View File

@@ -837,3 +837,92 @@ a.ts-key {
color: var(--ts-ink); color: var(--ts-ink);
} }
} }
/* Reviews asked on the ticket. */
.ts-reviews {
ul {
display: grid;
gap: 0.625rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.625rem 0.875rem;
border-left: 3px solid var(--ts-c-amber);
border-radius: 0 0.5rem 0.5rem 0;
background: color-mix(in srgb, var(--ts-c-amber) 8%, var(--ts-surface));
&.is-approved {
border-left-color: var(--ts-c-green);
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
}
&.is-changes {
border-left-color: var(--ts-c-red);
background: color-mix(in srgb, var(--ts-c-red) 6%, var(--ts-surface));
}
}
p {
margin: 0;
font-size: 0.875rem;
}
}
.ts-reviews__note {
margin-top: 0.25rem !important;
color: var(--ts-ink-soft);
}
.ts-reviews__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.5rem;
&:empty {
display: none;
}
}
/* Small buttons, not @pi/ui's sized ones. */
.ts-reviews__button {
height: auto !important;
min-width: 0 !important;
padding: 0.25rem 0.75rem !important;
border: 1px solid var(--ts-line-strong) !important;
border-radius: 999px !important;
background: var(--ts-surface) !important;
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&.is-ok {
border-color: transparent !important;
background: var(--ts-c-green) !important;
color: #fff;
}
&:hover,
&:focus-visible {
filter: brightness(1.05);
}
}
.ts-reviews__link {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}

View File

@@ -52,11 +52,13 @@ import {
IssuePriority, IssuePriority,
isTyping, isTyping,
POINT_SCALE, POINT_SCALE,
ProjectRole,
IssuesGateway, IssuesGateway,
localDay, localDay,
ReleaseStatus, ReleaseStatus,
ReminderChoice, ReminderChoice,
reminderChoices, reminderChoices,
ReviewVerdict,
StatusCategory, StatusCategory,
whenLabel, whenLabel,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -211,6 +213,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())" (execute)="facade.setWatching(!facade.watching())"
/> />
} }
@if (project.canContribute()) {
<pi-icon-button
icon="eye"
color="grey"
appearance="outline"
text="Pedir revisión"
title="Pedir a alguien del equipo que lo revise"
(execute)="openReviewRequest()"
/>
}
@if (project.myRole() && inMyDay() !== null) { @if (project.myRole() && inMyDay() !== null) {
<pi-icon-button <pi-icon-button
icon="sun" icon="sun"
@@ -337,6 +349,91 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div> </div>
} }
@if (issue.reviews.length) {
<article
class="ts-block ts-reviews"
aria-labelledby="reviews-title"
>
<h2 class="ts-section" id="reviews-title">Revisiones</h2>
<ul>
@for (review of issue.reviews; track review.id) {
<li
[class.is-approved]="review.verdict === approved"
[class.is-changes]="review.verdict === changes"
>
@if (!review.verdict) {
<p class="ts-reviews__what">
<strong>{{ review.requester.name }}</strong> pidió a
<strong>{{
review.reviewer.id === facade.me()
? 'ti'
: review.reviewer.name
}}</strong>
que lo revise
<span class="ts-muted"
>· {{ ago(review.requestedAt) }}</span
>
</p>
@if (review.note) {
<p class="ts-reviews__note">«{{ review.note }}»</p>
}
<div class="ts-reviews__actions">
@if (review.reviewer.id === facade.me()) {
<button
type="button"
class="ts-reviews__button is-ok"
(click)="openReviewAnswer(review.id, approved)"
>
✓ Todo bien
</button>
<button
type="button"
class="ts-reviews__button"
(click)="openReviewAnswer(review.id, changes)"
>
Pedir cambios
</button>
}
@if (
review.requester.id === facade.me() ||
project.canManage()
) {
<button
type="button"
class="ts-reviews__link"
(click)="facade.cancelReview(review.id)"
>
Retirar
</button>
}
</div>
} @else {
<p class="ts-reviews__what">
<span aria-hidden="true">{{
review.verdict === approved ? '✓' : '✗'
}}</span>
<strong>{{ review.reviewer.name }}</strong>
{{
review.verdict === approved
? 'lo dio por bueno'
: 'pide cambios'
}}
<span class="ts-muted"
>·
{{ ago(review.answeredAt ?? review.requestedAt) }} ·
lo pidió {{ review.requester.name }}</span
>
</p>
@if (review.answer) {
<p class="ts-reviews__note">«{{ review.answer }}»</p>
}
}
</li>
}
</ul>
</article>
}
@if (issue.decisions.length) { @if (issue.decisions.length) {
<article <article
class="ts-block ts-decisions" class="ts-block ts-decisions"
@@ -1218,6 +1315,85 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" /> <pi-skeleton shape="text" [lines]="8" />
} }
<ng-template #reviewRequestForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); sendReviewRequest()"
novalidate
>
<p class="ts-muted">
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
</p>
<label class="ts-reminder__field">
Quién
<select
class="ts-input"
[value]="reviewer()"
(change)="reviewer.set($any($event.target).value)"
>
<option value="">Elige a alguien…</option>
@for (member of reviewers(); track member.id) {
<option [value]="member.id">{{ member.name }}</option>
}
</select>
</label>
<label class="ts-reminder__field">
Qué mirar (opcional)
<textarea
class="ts-input ts-resolve__text"
rows="3"
maxlength="1000"
placeholder="Revisa el flujo en móvil, sobre todo el pago"
[value]="reviewNote()"
(input)="reviewNote.set($any($event.target).value)"
></textarea>
</label>
<pi-action-button
size="w100"
color="primary"
text="Pedir revisión"
[disabled]="!reviewer()"
(execute)="sendReviewRequest()"
/>
</form>
</ng-template>
<ng-template #reviewAnswerForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); sendReviewAnswer()"
novalidate
>
<label class="ts-reminder__field">
{{
answering()?.verdict === approved
? 'Un comentario (opcional)'
: 'Qué hay que cambiar'
}}
<textarea
class="ts-input ts-resolve__text"
rows="4"
maxlength="1000"
[value]="reviewAnswer()"
(input)="reviewAnswer.set($any($event.target).value)"
></textarea>
</label>
<pi-action-button
size="w100"
color="primary"
[text]="
answering()?.verdict === approved
? 'Dar por bueno'
: 'Pedir cambios'
"
[disabled]="
answering()?.verdict === changes && !reviewAnswer().trim()
"
(execute)="sendReviewAnswer()"
/>
</form>
</ng-template>
<ng-template #reminderForm> <ng-template #reminderForm>
<form <form
class="ts-form" class="ts-form"
@@ -1373,6 +1549,30 @@ export class IssuePage {
viewChild.required<TemplateRef<unknown>>('reasonForm'); viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm = private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm'); viewChild.required<TemplateRef<unknown>>('reminderForm');
private readonly reviewRequestForm =
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
private readonly reviewAnswerForm =
viewChild.required<TemplateRef<unknown>>('reviewAnswerForm');
protected readonly approved = ReviewVerdict.APPROVED;
protected readonly changes = ReviewVerdict.CHANGES;
/** The review being asked: to whom, and what to look at. */
protected readonly reviewer = signal('');
protected readonly reviewNote = signal('');
/** The review being answered, and the answer. */
protected readonly answering = signal<{
reviewId: string;
verdict: ReviewVerdict;
} | null>(null);
protected readonly reviewAnswer = signal('');
/** Who can be asked: the members, not me. */
protected readonly reviewers = computed(() =>
this.project
.members()
.filter(
(m) => m.user.id !== this.facade.me() && m.role !== ProjectRole.GUEST,
)
.map((m) => ({ id: m.user.id, name: m.user.name })),
);
/** The reminder being written: `datetime-local` value and note. */ /** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal(''); protected readonly reminderAt = signal('');
protected readonly reminderNote = signal(''); protected readonly reminderNote = signal('');
@@ -1792,6 +1992,44 @@ export class IssuePage {
}); });
} }
protected openReviewRequest(): void {
this.reviewer.set('');
this.reviewNote.set('');
this.drawer.open('Pedir revisión', this.reviewRequestForm());
}
protected sendReviewRequest(): void {
if (!this.reviewer()) return;
this.facade.requestReview(this.reviewer(), this.reviewNote());
this.drawer.closeAll();
}
protected openReviewAnswer(reviewId: string, verdict: ReviewVerdict): void {
this.answering.set({ reviewId, verdict });
this.reviewAnswer.set('');
this.drawer.open(
verdict === ReviewVerdict.APPROVED ? 'Todo bien' : 'Pedir cambios',
this.reviewAnswerForm(),
);
}
protected sendReviewAnswer(): void {
const answering = this.answering();
if (!answering) return;
if (
answering.verdict === ReviewVerdict.CHANGES &&
!this.reviewAnswer().trim()
)
return;
this.facade.answerReview(
answering.reviewId,
answering.verdict,
this.reviewAnswer(),
);
this.answering.set(null);
this.drawer.closeAll();
}
protected openReminder(): void { protected openReminder(): void {
const current = this.facade.issue()?.myReminder; const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices()); this.reminderChoices.set(reminderChoices());

View File

@@ -145,6 +145,31 @@ const VISIBLE = 12;
} }
</section> </section>
} @else { } @else {
@if (home.reviewing().length) {
<section
class="ts-panel ts-work__panel"
aria-labelledby="reviewing-title"
>
<header class="ts-work__head">
<h2 id="reviewing-title">Te piden revisión</h2>
<span class="ts-muted ts-mono">{{ home.reviewing().length }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.reviewing().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
</a>
</li>
}
</ul>
</section>
}
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title"> <section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
<header class="ts-work__head"> <header class="ts-work__head">
<h2 id="qa-title">Esperando QA</h2> <h2 id="qa-title">Esperando QA</h2>

View File

@@ -32,6 +32,8 @@ const ICON: Record<NotificationKind, string> = {
[NotificationKind.WATCHED]: 'focus', [NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase', [NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar', [NotificationKind.REMINDER]: 'calendar',
[NotificationKind.REVIEW_REQUESTED]: 'eye',
[NotificationKind.REVIEW_ANSWERED]: 'check',
}; };
/** /**