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:
@@ -49,6 +49,7 @@ export * from './lib/issue-reminders.service';
|
||||
export * from './lib/my-day/my-day';
|
||||
export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -50,6 +51,7 @@ export const COMMAND_HANDLERS = [
|
||||
ReactToCommentCommandHandler,
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Project,
|
||||
IssuePriority,
|
||||
IssueType,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
@@ -79,6 +80,11 @@ import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './reviews';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { VoteIssueCommand } from './vote-issue.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 () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
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,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -191,6 +195,14 @@ export function describeEvent(
|
||||
: 'abierta';
|
||||
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) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
(event as { reviewerId?: string }).reviewerId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
const actorOf = (id: string) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.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 { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -54,6 +55,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
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,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
} from './commands/attach-file.command';
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './commands/reviews';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-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. */
|
||||
download(
|
||||
viewerId: string | null,
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssuePriority,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
@@ -69,6 +72,8 @@ const excerpt = (text: string, max = 120) => {
|
||||
IssueUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
CommentAddedEvent,
|
||||
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
|
||||
// only way to hear of it.
|
||||
if (event instanceof IssueParentChangedEvent && event.parentId) {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface IssueFilter {
|
||||
watching?: boolean;
|
||||
/** Only the ones this person follows. */
|
||||
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). */
|
||||
participantId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
@@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter {
|
||||
assigneeId?: string;
|
||||
/** Only the ones this person follows. */
|
||||
watcherId?: string;
|
||||
/** Only the ones this person was asked to review and has not answered. */
|
||||
reviewerId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
|
||||
@@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
reviewerId: query.filter.reviewing ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets the viewer follows. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets the viewer was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
statuses?: string[];
|
||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||
categories?: StatusCategory[];
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/issue/issue-rank';
|
||||
export * from './lib/issue/dev-link';
|
||||
export * from './lib/issue/review';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.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 {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} 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 { implicitRank, rankBetween } from './issue-rank';
|
||||
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 {
|
||||
type: IssueLinkType;
|
||||
@@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
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). */
|
||||
public voterIds: string[] = [];
|
||||
/** 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. */
|
||||
watch(actorId: string, userId: string): void {
|
||||
if (this.watchers.includes(userId)) return;
|
||||
@@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
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 {
|
||||
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 { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
@@ -85,6 +86,8 @@ export class IssueReadModel {
|
||||
attachments!: AttachmentReadModel[];
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
/** Asked reviews, open and answered, oldest first. */
|
||||
reviews!: IssueReviewReadModel[];
|
||||
/** How many want it done. */
|
||||
votes!: number;
|
||||
/** 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. */
|
||||
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',
|
||||
/** A ticket of yours is due soon or has been still for too long. */
|
||||
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.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.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
@@ -497,6 +501,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||
if (filter.reviewerId)
|
||||
query['reviews'] = {
|
||||
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
|
||||
};
|
||||
if (filter.participantId)
|
||||
query.$and = [
|
||||
...(query.$and ?? []),
|
||||
|
||||
@@ -524,6 +524,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
// Tickets projected before attachments existed have none.
|
||||
attachments: rest.attachments ?? [],
|
||||
watchers: rest.watchers ?? [],
|
||||
reviews: rest.reviews ?? [],
|
||||
votes: rest.votes ?? 0,
|
||||
voterIds: rest.voterIds ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
@@ -23,6 +24,10 @@ export enum LinkDirection {
|
||||
}
|
||||
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(ReviewVerdict, {
|
||||
name: 'ReviewVerdict',
|
||||
description: 'APPROVED: all good. CHANGES: something to change',
|
||||
});
|
||||
registerEnumType(CustomFieldType, {
|
||||
name: 'CustomFieldType',
|
||||
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
@@ -168,6 +169,11 @@ export class IssueResolver {
|
||||
description: 'Only the tickets I follow',
|
||||
})
|
||||
watching: boolean,
|
||||
@Args('reviewing', {
|
||||
defaultValue: false,
|
||||
description: 'Only the tickets I was asked to review (unanswered)',
|
||||
})
|
||||
reviewing: boolean,
|
||||
@Args('statuses', { type: () => [String], nullable: true })
|
||||
statuses: string[] | undefined,
|
||||
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
||||
@@ -204,6 +210,7 @@ export class IssueResolver {
|
||||
return this.issuesService.mine(me.id, {
|
||||
assignedToMe,
|
||||
watching,
|
||||
reviewing,
|
||||
statuses: statuses ?? undefined,
|
||||
categories: categories ?? undefined,
|
||||
search: search ?? undefined,
|
||||
@@ -508,6 +515,54 @@ export class IssueResolver {
|
||||
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' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
@@ -371,6 +372,24 @@ export class IssueParentGql {
|
||||
@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')
|
||||
export class IssueGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -408,6 +427,10 @@ export class IssueGql {
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||
watchers!: UserRefGql[];
|
||||
@Field(() => [IssueReviewGql], {
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewGql[];
|
||||
@Field(() => IssueParentGql, {
|
||||
nullable: true,
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
REACTIONS,
|
||||
ReviewVerdict,
|
||||
SETTING_COLOR_PATTERN,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
@@ -555,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
||||
@IsBoolean()
|
||||
watching?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only the tickets I was asked to review and have not answered',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
reviewing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||
@@ -1552,6 +1561,30 @@ export class PushEndpointDto {
|
||||
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 {
|
||||
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
@@ -481,6 +482,11 @@ export class IssueDto {
|
||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||
watchers!: UserRefDto[];
|
||||
@ApiProperty({
|
||||
type: () => [IssueReviewDto],
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewDto[];
|
||||
@ApiProperty({
|
||||
type: IssueParentDto,
|
||||
nullable: true,
|
||||
@@ -1439,3 +1445,21 @@ export class MyDayDto {
|
||||
@ApiProperty({ type: MyDayLeftoverDto, nullable: true })
|
||||
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,
|
||||
DecisionsQueryDto,
|
||||
SetReminderDto,
|
||||
RequestReviewDto,
|
||||
AnswerReviewDto,
|
||||
MyDayQueryDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
@@ -316,6 +318,70 @@ export class IssuesController {
|
||||
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')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@@ -390,6 +456,7 @@ export class IssuesController {
|
||||
const page = await this.issues.mine(me.id, {
|
||||
assignedToMe: query.assignedToMe,
|
||||
watching: query.watching,
|
||||
reviewing: query.reviewing,
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
search: query.search,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({
|
||||
mine: IssuePage;
|
||||
awaitingQa: IssuePage;
|
||||
following: IssuePage;
|
||||
reviewing: IssuePage;
|
||||
releases: Release[];
|
||||
}>(),
|
||||
loadFailed: type<string>(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const issuePageEvents = eventGroup({
|
||||
@@ -50,6 +51,18 @@ export const issuePageEvents = eventGroup({
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: 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<{
|
||||
issueId: string;
|
||||
/** null: drop it. */
|
||||
|
||||
@@ -21,6 +21,8 @@ export class HomeFacade {
|
||||
/** Open tickets I follow, whoever works on them. */
|
||||
readonly following = this.store.following;
|
||||
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. */
|
||||
readonly releases = this.store.releases;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IssueLinkType,
|
||||
IssueSummary,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
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. */
|
||||
setReminder(at: string | null, note: string | null = null): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface HomeState {
|
||||
awaitingQaTotal: number;
|
||||
following: IssueSummary[];
|
||||
followingTotal: number;
|
||||
/** Tickets I was asked to review and have not answered. */
|
||||
reviewing: IssueSummary[];
|
||||
releases: Release[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
@@ -54,6 +56,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: 0,
|
||||
following: [],
|
||||
followingTotal: 0,
|
||||
reviewing: [],
|
||||
releases: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
@@ -88,6 +91,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: payload.awaitingQa.total,
|
||||
following: payload.following.items,
|
||||
followingTotal: payload.following.total,
|
||||
reviewing: payload.reviewing.items,
|
||||
releases: payload.releases,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
@@ -123,6 +127,12 @@ export const HomeStore = signalStore(
|
||||
categories: [...OPEN_CATEGORIES],
|
||||
limit: 20,
|
||||
}),
|
||||
reviewing: issues.mine({
|
||||
assignedToMe: false,
|
||||
reviewing: true,
|
||||
categories: [...OPEN_CATEGORIES, StatusCategory.DONE],
|
||||
limit: 20,
|
||||
}),
|
||||
releases: releases.upcoming(5),
|
||||
}).pipe(
|
||||
mapResponse({
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
SprintsGateway,
|
||||
ReviewVerdict,
|
||||
TrackerRealtimeGateway,
|
||||
whenLabel,
|
||||
} 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
|
||||
.on(issuePageEvents.reminderRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -70,7 +70,15 @@ export enum NotificationKind {
|
||||
WATCHED = 'WATCHED',
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
REMINDER = 'REMINDER',
|
||||
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
|
||||
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
|
||||
}
|
||||
|
||||
/** The reactions a comment takes, in the order they are offered (GitHub's). */
|
||||
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];
|
||||
|
||||
/** What a reviewer said. */
|
||||
export enum ReviewVerdict {
|
||||
APPROVED = 'APPROVED',
|
||||
CHANGES = 'CHANGES',
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets I follow. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets I was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
NotificationKind,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
@@ -192,6 +193,8 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
/** Reviews asked, open and answered, oldest first. */
|
||||
reviews: IssueReview[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
export interface IssueReminder {
|
||||
/** 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 { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
@@ -143,6 +143,23 @@ export abstract class IssuesGateway {
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): 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. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** My pinned tickets, most recently pinned first. */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LinkIssuesInput,
|
||||
MyDay,
|
||||
MyIssuesFilter,
|
||||
ReviewVerdict,
|
||||
mergeBulkResults,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -51,6 +52,9 @@ import {
|
||||
UnwatchIssueDocument,
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
RequestReviewDocument,
|
||||
AnswerReviewDocument,
|
||||
CancelReviewDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
SetReminderDocument,
|
||||
@@ -115,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.query(MyIssuesDocument, {
|
||||
assignedToMe: filter.assignedToMe,
|
||||
watching: filter.watching ?? false,
|
||||
reviewing: filter.reviewing ?? false,
|
||||
categories: filter.categories as never,
|
||||
limit: filter.limit,
|
||||
search: filter.search?.trim() || null,
|
||||
@@ -281,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
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> {
|
||||
return watching
|
||||
? this.gql
|
||||
|
||||
@@ -126,6 +126,20 @@ fragment IssueFields on Issue {
|
||||
at
|
||||
note
|
||||
}
|
||||
reviews {
|
||||
id
|
||||
requester {
|
||||
...UserRefFields
|
||||
}
|
||||
reviewer {
|
||||
...UserRefFields
|
||||
}
|
||||
note
|
||||
requestedAt
|
||||
verdict
|
||||
answer
|
||||
answeredAt
|
||||
}
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@ export type NotificationKind =
|
||||
| 'QA_FAILED'
|
||||
| 'REMINDER'
|
||||
| 'REPLIED'
|
||||
| 'REVIEW_ANSWERED'
|
||||
| 'REVIEW_REQUESTED'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'WATCHED';
|
||||
|
||||
@@ -312,6 +314,9 @@ export type RankIssueInput = {
|
||||
|
||||
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
|
||||
|
||||
/** APPROVED: all good. CHANGES: something to change */
|
||||
export type ReviewVerdict = 'APPROVED' | 'CHANGES';
|
||||
|
||||
export type SaveFilterInput = {
|
||||
criteria: SavedFilterCriteriaInput;
|
||||
name: string;
|
||||
@@ -715,6 +720,16 @@ export type IssueFieldsFragment = {
|
||||
dueDate: string | null;
|
||||
trashedAt: string | 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<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
@@ -962,6 +977,16 @@ export type IssueQuery = {
|
||||
dueDate: string | null;
|
||||
trashedAt: string | 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<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
@@ -1105,6 +1130,36 @@ export type UnlinkIssuesMutation = {
|
||||
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<{
|
||||
issueId: string;
|
||||
}>;
|
||||
@@ -1347,6 +1402,7 @@ export type DeleteCommentMutation = {
|
||||
export type MyIssuesQueryVariables = Exact<{
|
||||
assignedToMe: boolean;
|
||||
watching?: boolean | null | undefined;
|
||||
reviewing?: boolean | null | undefined;
|
||||
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
||||
limit: number;
|
||||
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',
|
||||
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',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
@@ -7308,6 +7446,324 @@ export const UnlinkIssuesDocument = {
|
||||
UnlinkIssuesMutation,
|
||||
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 = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -8781,6 +9237,14 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
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',
|
||||
variable: {
|
||||
@@ -8888,6 +9352,14 @@ export const MyIssuesDocument = {
|
||||
name: { kind: 'Name', value: 'watching' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'reviewing' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewing' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
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!) {
|
||||
watchIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
@@ -188,6 +216,7 @@ mutation DeleteComment($commentId: ID!) {
|
||||
query MyIssues(
|
||||
$assignedToMe: Boolean!
|
||||
$watching: Boolean
|
||||
$reviewing: Boolean
|
||||
$categories: [StatusCategory!]
|
||||
$limit: Int!
|
||||
$search: String
|
||||
@@ -199,6 +228,7 @@ query MyIssues(
|
||||
myIssues(
|
||||
assignedToMe: $assignedToMe
|
||||
watching: $watching
|
||||
reviewing: $reviewing
|
||||
categories: $categories
|
||||
limit: $limit
|
||||
search: $search
|
||||
|
||||
@@ -837,3 +837,92 @@ a.ts-key {
|
||||
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,
|
||||
isTyping,
|
||||
POINT_SCALE,
|
||||
ProjectRole,
|
||||
IssuesGateway,
|
||||
localDay,
|
||||
ReleaseStatus,
|
||||
ReminderChoice,
|
||||
reminderChoices,
|
||||
ReviewVerdict,
|
||||
StatusCategory,
|
||||
whenLabel,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -211,6 +213,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(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) {
|
||||
<pi-icon-button
|
||||
icon="sun"
|
||||
@@ -337,6 +349,91 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</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) {
|
||||
<article
|
||||
class="ts-block ts-decisions"
|
||||
@@ -1218,6 +1315,85 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<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>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -1373,6 +1549,30 @@ export class IssuePage {
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly 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. */
|
||||
protected readonly reminderAt = 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 {
|
||||
const current = this.facade.issue()?.myReminder;
|
||||
this.reminderChoices.set(reminderChoices());
|
||||
|
||||
@@ -145,6 +145,31 @@ const VISIBLE = 12;
|
||||
}
|
||||
</section>
|
||||
} @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">
|
||||
<header class="ts-work__head">
|
||||
<h2 id="qa-title">Esperando QA</h2>
|
||||
|
||||
@@ -32,6 +32,8 @@ const ICON: Record<NotificationKind, string> = {
|
||||
[NotificationKind.WATCHED]: 'focus',
|
||||
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
||||
[NotificationKind.REMINDER]: 'calendar',
|
||||
[NotificationKind.REVIEW_REQUESTED]: 'eye',
|
||||
[NotificationKind.REVIEW_ANSWERED]: 'check',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user