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[];
|
||||
|
||||
Reference in New Issue
Block a user