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:
@@ -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": {
|
||||||
|
|||||||
@@ -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!]!
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}` |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal 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,
|
||||||
|
];
|
||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal file
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}$/)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user