feat: comments marked as decisions, on top of the ticket and searchable

Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.

API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 11:32:07 +02:00
parent 8f83fdefb6
commit 70e4fbd399
45 changed files with 2515 additions and 84 deletions

View File

@@ -1,4 +1,5 @@
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -46,6 +47,7 @@ import {
export const COMMAND_HANDLERS = [
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
MarkDecisionCommandHandler,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,

View File

@@ -77,6 +77,7 @@ import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command';
import { UpdateProjectCommand } from './update-project.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
@@ -916,6 +917,32 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(ForbiddenError);
});
it('lets the team mark a comment as a decision, and only the team', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Caché', {}),
);
const comment = await run(
new AddCommentCommand('qa', issue.id, 'Usamos Redis'),
);
await run(new MarkDecisionCommand('dev', comment.id, true));
// Twice is still once.
const again = await run(new MarkDecisionCommand('dev', comment.id, true));
expect(again.version).toBe(2);
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
true,
);
// A viewer reads the decisions but does not make them.
await expect(
run(new MarkDecisionCommand('pm', comment.id, false)),
).rejects.toThrow(ForbiddenError);
await run(new MarkDecisionCommand('owner', comment.id, false));
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
false,
);
});
it('takes votes from members, and from anyone once it is public', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,20 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { MarkDecisionCommand } from './mark-decision.command';
import { WriteAccess } from './write-access';
/** The team decides: whoever can change the ticket marks its decisions. */
@CommandHandler(MarkDecisionCommand)
export class MarkDecisionCommandHandler
implements ICommandHandler<MarkDecisionCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: MarkDecisionCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
await this.access.issueForContributor(comment.issueId, command.actorId);
comment.markDecision(command.actorId, command.on);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Marks a comment as a decision of its ticket (`on`) or unmarks it. */
export class MarkDecisionCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly on: boolean,
) {
super();
}
}

View File

@@ -1,14 +1,22 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import {
CommentReadModel,
DecisionReadModel,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result';
import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command';
import { ReactToCommentCommand } from './commands/react-to-comment.command';
import { MarkDecisionCommand } from './commands/mark-decision.command';
import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query';
import {
GetIssueDecisionsQuery,
SearchDecisionsQuery,
} from './queries/decisions.query';
@Injectable()
export class CommentsService {
@@ -50,6 +58,35 @@ export class CommentsService {
);
}
/** Marks it as a decision of its ticket (`on`) or unmarks it: the team does. */
markDecision(
actorId: string,
commentId: string,
on: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new MarkDecisionCommand(actorId, commentId, on),
);
}
/** A ticket's decisions, oldest first. */
decisions(
viewerId: string | null,
issueId: string,
): Promise<CommentReadModel[]> {
return this.queryBus.execute(new GetIssueDecisionsQuery(viewerId, issueId));
}
/** Decisions by their text: of a project, or of all mine without one. */
searchDecisions(
viewerId: string | null,
filter: { projectId?: string; search?: string; limit?: number },
): Promise<DecisionReadModel[]> {
return this.queryBus.execute(
new SearchDecisionsQuery(viewerId, { limit: 20, ...filter }),
);
}
delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
}

View File

@@ -30,6 +30,9 @@ export class CommentAddedEventHandler
replyTo: comment.replyTo ?? null,
deleted: false,
reactions: [],
decision: false,
decidedBy: null,
decidedAt: null,
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),

View File

@@ -0,0 +1,30 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentDecisionMarkedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentDecisionMarkedEvent)
export class CommentDecisionMarkedEventHandler
implements IEventHandler<CommentDecisionMarkedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentDecisionMarkedEvent) {
const refs = await this.users.refs([event.actorId]);
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
decision: event.decision,
decidedBy: event.decision ? refs.of(event.actorId) : null,
decidedAt: event.decision ? event.occurredAt : null,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
// The ticket shows its decisions on top: its page reloads them.
await this.notifier.issue(event.issueId, event);
}
}

View File

@@ -3,6 +3,7 @@ import { CommentAddedEventHandler } from './comment-added.event-handler';
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -50,6 +51,7 @@ export const EVENT_HANDLERS = [
CommentDeletedEventHandler,
CommentEditedEventHandler,
CommentReactedEventHandler,
CommentDecisionMarkedEventHandler,
IssueVotedEventHandler,
IssueAssignedEventHandler,
IssueCreatedEventHandler,

View File

@@ -11,4 +11,14 @@ export abstract class FindCommentsRepository {
/** Comments still standing (deleted ones kept for their replies do not count). */
abstract countByIssue(issueId: string): Promise<number>;
abstract countReplies(commentId: string): Promise<number>;
/**
* Comments marked as decisions (not deleted): of one ticket, or of some
* projects, oldest first for a ticket and most recent first otherwise.
*/
abstract findDecisions(filter: {
issueId?: string;
projectIds?: string[];
search?: string;
limit: number;
}): Promise<CommentReadModel[]>;
}

View File

@@ -0,0 +1,112 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
CommentReadModel,
DecisionReadModel,
} from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from './read-access';
/** The decisions of a ticket, oldest first (shown above its conversation). */
export class GetIssueDecisionsQuery extends Query<CommentReadModel[]> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly issueId: string,
) {
super();
}
}
/**
* Decisions by their text, most recently decided first: of one project, or
* (without one) of every project the viewer belongs to.
*/
export class SearchDecisionsQuery extends Query<DecisionReadModel[]> {
constructor(
public readonly viewerId: string | null,
public readonly filter: {
projectId?: string;
search?: string;
limit: number;
},
) {
super();
}
}
@QueryHandler(GetIssueDecisionsQuery)
export class GetIssueDecisionsQueryHandler
implements IQueryHandler<GetIssueDecisionsQuery, CommentReadModel[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetIssueDecisionsQuery): Promise<CommentReadModel[]> {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
await this.access.assertCanViewIssue(issue, query.viewerId);
return this.findComments.findDecisions({ issueId: issue.id, limit: 50 });
}
}
@QueryHandler(SearchDecisionsQuery)
export class SearchDecisionsQueryHandler
implements IQueryHandler<SearchDecisionsQuery, DecisionReadModel[]>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: SearchDecisionsQuery): Promise<DecisionReadModel[]> {
const { filter, viewerId } = query;
let projectIds: string[];
if (filter.projectId) {
await this.access.assertCanView(filter.projectId, viewerId);
projectIds = [filter.projectId];
} else {
if (!viewerId) return [];
projectIds = (await this.findProjects.findByMember(viewerId)).map(
(p) => p.id,
);
}
if (projectIds.length === 0) return [];
const limit = Math.min(filter.limit, 100);
// Read a few more: some may sit in the trash or, for a guest, in
// tickets they do not take part in.
const comments = await this.findComments.findDecisions({
projectIds,
search: filter.search?.trim().slice(0, 200) || undefined,
limit: limit * 2,
});
const issues = new Map(
(
await this.findIssues.findByIds([
...new Set(comments.map((c) => c.issueId)),
])
).map((issue) => [issue.id, issue]),
);
const found: DecisionReadModel[] = [];
for (const comment of comments) {
const issue = issues.get(comment.issueId);
if (!issue || issue.trashedAt) continue;
if (!(await this.access.canViewIssue(issue, viewerId))) continue;
found.push({
comment,
issue: { id: issue.id, key: issue.key, title: issue.title },
});
if (found.length === limit) break;
}
return found;
}
}

View File

@@ -26,6 +26,10 @@ import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.quer
import { GetTrashQueryHandler } from './get-trash.query';
import { GetShowcaseQueryHandler } from './get-showcase.query';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
import {
GetIssueDecisionsQueryHandler,
SearchDecisionsQueryHandler,
} from './decisions.query';
export const QUERY_HANDLERS = [
GetPublicProjectsQueryHandler,
@@ -33,6 +37,8 @@ export const QUERY_HANDLERS = [
GetActivityQueryHandler,
GetBoardQueryHandler,
GetCommentsQueryHandler,
GetIssueDecisionsQueryHandler,
SearchDecisionsQueryHandler,
GetIssueQueryHandler,
GetIssuesByIdsQueryHandler,
GetMyProjectsQueryHandler,

View File

@@ -5,6 +5,7 @@ import {
assertThat,
} from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event';
import { CommentReactedEvent } from '../events/comment-reacted.event';
@@ -41,6 +42,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public replyTo: string | null = null;
/** Who chose each emoji (only the ones somebody chose). */
public reactions: Record<string, string[]> = {};
/** What was decided on the ticket: shown above it and searchable. */
public decision = false;
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
@@ -93,6 +96,24 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
);
}
/**
* Marks it as a decision of the ticket (`on`) or unmarks it; repeating it
* changes nothing. Who may do it (the team) is the caller's check.
*/
markDecision(actorId: string, on: boolean): void {
this.assertAlive();
if (on === this.decision) return;
this.apply(
new CommentDecisionMarkedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
on,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void {
this.assertAlive();
@@ -121,6 +142,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.reactions = event.reactions;
}
onCommentDecisionMarkedEvent(event: CommentDecisionMarkedEvent): void {
this.decision = event.decision;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A comment was marked as a decision of the ticket, or unmarked. */
@AutowiredEvent
export class CommentDecisionMarkedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** False: it is no longer a decision. */
readonly decision: boolean,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -15,6 +15,7 @@ export * from './issue-release-changed.event';
export * from './comment-added.event';
export * from './comment-edited.event';
export * from './comment-reacted.event';
export * from './comment-decision-marked.event';
export * from './comment-deleted.event';
export * from './release-created.event';
export * from './release-updated.event';

View File

@@ -21,7 +21,18 @@ export class CommentReadModel {
deleted!: boolean;
/** In the order they are offered; only the ones somebody chose. */
reactions!: CommentReactionReadModel[];
/** Marked as a decision of the ticket. */
decision!: boolean;
/** Who marked it and when (null when it is not a decision). */
decidedBy!: UserRefReadModel | null;
decidedAt!: string | null;
createdAt!: string;
editedAt!: string | null;
version!: number;
}
/** A decision with the ticket it belongs to, for searching across tickets. */
export class DecisionReadModel {
comment!: CommentReadModel;
issue!: { id: string; key: string; title: string };
}

View File

@@ -51,6 +51,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
replyTo: doc.replyTo ?? null,
deleted: doc.deleted ?? false,
reactions: doc.reactions ?? [],
decision: doc.decision ?? false,
decidedBy: doc.decidedBy ?? null,
decidedAt: doc.decidedAt ?? null,
});
const escapeRegex = (text: string) =>
@@ -545,6 +548,28 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
countReplies(commentId: string): Promise<number> {
return this.comments.countDocuments({ replyTo: commentId });
}
async findDecisions(filter: {
issueId?: string;
projectIds?: string[];
search?: string;
limit: number;
}): Promise<CommentReadModel[]> {
const query: Record<string, unknown> = {
decision: true,
deleted: { $ne: true },
};
if (filter.issueId) query['issueId'] = filter.issueId;
if (filter.projectIds) query['projectId'] = { $in: filter.projectIds };
if (filter.search)
query['body'] = { $regex: escapeRegex(filter.search), $options: 'i' };
const docs = await this.comments
.find(query)
.sort(filter.issueId ? { createdAt: 1 } : { decidedAt: -1 })
.limit(filter.limit)
.lean<CommentDoc[]>();
return docs.map(commentFromDoc);
}
}
@Injectable()

View File

@@ -239,6 +239,10 @@ class CommentDocument {
}
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
CommentSchema.index({ issueId: 1, createdAt: 1 });
CommentSchema.index(
{ projectId: 1, decidedAt: -1 },
{ partialFilterExpression: { decision: true } },
);
@Schema({ ...LOOSE, collection: 'releases_view' })
class ReleaseDocument {

View File

@@ -1,8 +1,12 @@
import { Args, ID, Mutation, Resolver } from '@nestjs/graphql';
import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
CurrentUser,
OptionalAuth,
} from '@task-stream/api/identity/presenters';
import { CommentsService } from '@task-stream/api/tracker/application';
import { CommandResultGql, CommentGql } from './types';
import { viewerOf } from '../common/viewer';
import { CommandResultGql, CommentGql, DecisionGql } from './types';
@Resolver(() => CommentGql)
export class CommentResolver {
@@ -43,6 +47,37 @@ export class CommentResolver {
return this.comments.react(me.id, commentId, emoji, on);
}
@Mutation(() => CommandResultGql, {
description:
'Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket',
})
markDecision(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('on', { defaultValue: true }) on: boolean,
) {
return this.comments.markDecision(me.id, commentId, on);
}
@OptionalAuth()
@Query(() => [DecisionGql], {
description:
'Decisions by their text, most recent first: of a project, or of all my projects without one',
})
decisions(
@CurrentUser() me: User | undefined,
@Args('projectId', { type: () => ID, nullable: true })
projectId: string | null,
@Args('search', { nullable: true }) search: string | null,
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
) {
return this.comments.searchDecisions(viewerOf(me), {
projectId: projectId ?? undefined,
search: search ?? undefined,
limit,
});
}
@Mutation(() => CommandResultGql, { description: 'Author or project admin' })
deleteComment(
@CurrentUser() me: User,

View File

@@ -44,6 +44,7 @@ import {
ActivityGql,
BulkResultGql,
CommandResultGql,
CommentGql,
CommentPageGql,
EpicGql,
CustomFieldValueGql,
@@ -247,6 +248,16 @@ export class IssueResolver {
return !!me && issue.voterIds.includes(me.id);
}
@ResolveField(() => [CommentGql], {
description: 'Its comments marked as decisions, oldest first',
})
decisions(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
) {
return this.commentsService.decisions(viewerOf(me), issue.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})

View File

@@ -548,6 +548,16 @@ export class CommentGql {
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionGql[];
@Field({
description: 'Marked as a decision of the ticket (shown above it)',
})
decision!: boolean;
@Field(() => UserRefGql, {
nullable: true,
description: 'Who marked it as a decision',
})
decidedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) decidedAt!: string | null;
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;
@@ -559,6 +569,14 @@ export class CommentPageGql {
@Field(() => Int) total!: number;
}
@ObjectType('Decision', {
description: 'A comment marked as a decision, with its ticket',
})
export class DecisionGql {
@Field(() => CommentGql) comment!: CommentGql;
@Field(() => IssueParentGql) issue!: IssueParentGql;
}
@ObjectType('Release')
export class ReleaseGql {
@Field(() => ID) id!: string;

View File

@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
@@ -54,6 +55,29 @@ export class CommentsController {
return this.comments.react(me.id, commentId, body.emoji, body.on);
}
@Put(':commentId/decision')
@ApiOperation({
summary: 'Mark a comment as a decision of its ticket',
description:
'Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
markDecision(@CurrentUser() me: User, @Param('commentId') commentId: string) {
return this.comments.markDecision(me.id, commentId, true);
}
@Delete(':commentId/decision')
@ApiOperation({ summary: 'It is no longer a decision' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
unmarkDecision(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
) {
return this.comments.markDecision(me.id, commentId, false);
}
@Delete(':commentId')
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
@ApiOkResponse({ type: CommandResultDto })

View File

@@ -501,6 +501,22 @@ export class PageQueryDto {
offset = 0;
}
export class DecisionsQueryDto {
@ApiPropertyOptional({ description: 'Words in the decision' })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}
// ---- Issues -----------------------------------------------------------------
export class MyIssuesQueryDto extends PageQueryDto {

View File

@@ -625,12 +625,30 @@ export class CommentDto {
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionDto[];
@ApiProperty({
description: 'Marked as a decision of the ticket (shown above it)',
})
decision!: boolean;
@ApiProperty({
type: UserRefDto,
nullable: true,
description: 'Who marked it as a decision',
})
decidedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
decidedAt!: string | null;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null;
@ApiProperty() version!: number;
}
/** A comment marked as a decision, with its ticket. */
export class DecisionDto {
@ApiProperty({ type: CommentDto }) comment!: CommentDto;
@ApiProperty({ type: IssueParentDto }) issue!: IssueParentDto;
}
export class CommentPageDto {
@ApiProperty({ type: [CommentDto] }) items!: CommentDto[];
@ApiProperty() total!: number;

View File

@@ -45,6 +45,7 @@ import {
ChecklistItemBodyDto,
AddCommentDto,
CreateIssueDto,
DecisionsQueryDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
@@ -61,7 +62,9 @@ import {
ActivityDto,
BulkResultDto,
CommandResultDto,
CommentDto,
CommentPageDto,
DecisionDto,
EpicDto,
IssueDto,
IssuePageDto,
@@ -707,6 +710,57 @@ export class IssuesController {
);
}
@Get('issues/:issue/decisions')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({
summary: 'Comments of the ticket marked as decisions (oldest first)',
})
@ApiOkResponse({ type: [CommentDto] })
@ApiReadErrors()
async issueDecisions(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
) {
return this.comments.decisions(
viewerOf(me),
await this.refs.issueId(viewerOf(me), issue),
);
}
@Get('projects/:project/decisions')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: "Search a project's decisions (most recent first)",
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
async projectDecisions(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: DecisionsQueryDto,
) {
return this.comments.searchDecisions(viewerOf(me), {
projectId: await this.refs.projectId(viewerOf(me), project),
search: query.search,
limit: query.limit,
});
}
@Get('me/decisions')
@ApiOperation({
summary: 'Search the decisions of all my projects (most recent first)',
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
myDecisions(@CurrentUser() me: User, @Query() query: DecisionsQueryDto) {
return this.comments.searchDecisions(me.id, {
search: query.search,
limit: query.limit,
});
}
@Post('issues/:issue/comments')
@ISSUE
@ApiOperation({

View File

@@ -71,6 +71,7 @@ export const issuePageEvents = eventGroup({
}>(),
commentEditRequested: type<{ commentId: string; body: string }>(),
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
decisionRequested: type<{ commentId: string; on: boolean }>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
/** An image pasted into a text was uploaded (it joins the ticket's files). */
imagePasted: type<{ issueId: string }>(),

View File

@@ -231,6 +231,11 @@ export class IssueFacade {
this.dispatch.reactRequested({ commentId, emoji, on });
}
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
markDecision(commentId: string, on: boolean): void {
this.dispatch.decisionRequested({ commentId, on });
}
/** A copy of what the open ticket says; answers its key. */
duplicate(): Observable<string | null> {
const issueId = this.store.issue()?.id ?? '';

View File

@@ -423,6 +423,20 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.decisionRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.markDecision(payload.commentId, payload.on),
payload.on
? 'Marcado como decisión: sale arriba del ticket'
: 'Ya no es una decisión',
done,
failed,
),
),
),
events.on(issuePageEvents.reactRequested).pipe(
mergeMap(({ payload }) =>
comments.react(payload.commentId, payload.emoji, payload.on).pipe(

View File

@@ -192,6 +192,8 @@ export interface Issue extends IssueSummary {
watchers: UserRef[];
/** Whether I keep it pinned (home page and search). */
pinned: boolean;
/** Its comments marked as decisions, oldest first. */
decisions: Comment[];
checklist: ChecklistItem[];
/** Commits, branches and pull requests that mention it. */
devLinks: DevLink[];
@@ -326,10 +328,20 @@ export interface Comment {
deleted: boolean;
/** 👍… and who chose each (only the ones somebody chose). */
reactions: { emoji: string; users: UserRef[] }[];
/** Marked as a decision of the ticket: shown above it and searchable. */
decision: boolean;
/** Who marked it as a decision (null when it is not one). */
decidedBy: UserRef | null;
createdAt: string;
editedAt: string | null;
}
/** A decision found by its text, with its ticket. */
export interface Decision {
comment: Comment;
issue: { id: string; key: string; title: string };
}
export interface SprintProgress {
total: number;
/** Done or cancelled. */

View File

@@ -16,6 +16,7 @@ import {
PushDeviceSubscription,
CommandResult,
Comment,
Decision,
Epic,
GitIntegration,
GitRepository,
@@ -144,6 +145,14 @@ export abstract class IssuesGateway {
abstract pins(): Observable<IssueSummary[]>;
/** Pins the ticket for me (or unpins it): private, at most 20. */
abstract pin(issueId: string, pinned: boolean): Observable<void>;
/**
* Decisions (comments marked as such) of all my projects by their text,
* most recent first: here and not in CommentsGateway, for the palette.
*/
abstract searchDecisions(
search: string,
limit: number,
): Observable<Decision[]>;
/** Tickets hanging from `issueId`. */
abstract children(
projectId: string,
@@ -289,6 +298,11 @@ export abstract class CommentsGateway {
on: boolean,
): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
abstract markDecision(
commentId: string,
on: boolean,
): Observable<CommandResult>;
}
/** A ticket's files. Uploads and downloads go through the REST API. */

View File

@@ -23,6 +23,7 @@ import {
EditCommentDocument,
IssueCommentsDocument,
ReactToCommentDocument,
MarkDecisionDocument,
PublishReleaseDocument,
ReleaseBurnupDocument,
ReleaseNotesDraftDocument,
@@ -75,6 +76,12 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.mutate(DeleteCommentDocument, { commentId })
.pipe(map((d) => toCommandResult(d.deleteComment)));
}
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(MarkDecisionDocument, { commentId, on })
.pipe(map((d) => toCommandResult(d.markDecision)));
}
}
@Injectable()

View File

@@ -7,6 +7,7 @@ import {
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Decision,
Epic,
Issue,
IssuePage,
@@ -48,6 +49,7 @@ import {
VoteIssueDocument,
WatchIssueDocument,
MyPinsDocument,
SearchDecisionsDocument,
PinIssueDocument,
UpdateChecklistItemDocument,
UpdateIssueDocument,
@@ -57,6 +59,7 @@ import {
toCommandResult,
toIssue,
toIssueLink,
toComment,
toIssueSummary,
} from './mappers';
@@ -201,6 +204,18 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => d.myPins.map(toIssueSummary)));
}
searchDecisions(search: string, limit: number): Observable<Decision[]> {
return this.gql
.query(SearchDecisionsDocument, { search: search.trim() || null, limit })
.pipe(
map((d) =>
d.decisions.map((found) => ({
comment: toComment(found.comment),
issue: found.issue,
})),
),
);
}
pin(issueId: string, pinned: boolean): Observable<void> {
const done$: Observable<unknown> = pinned
? this.gql.mutate(PinIssueDocument, { issueId })

View File

@@ -121,6 +121,9 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue {
...IssueSummaryFields
pinned
decisions {
...CommentFields
}
description
figmaUrl
createdAt
@@ -178,6 +181,10 @@ fragment CommentFields on Comment {
...UserRefFields
}
}
decision
decidedBy {
...UserRefFields
}
createdAt
editedAt
author {

View File

@@ -650,6 +650,22 @@ export type IssueFieldsFragment = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
decisions: Array<{
id: string;
issueId: string;
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -693,12 +709,14 @@ export type CommentFieldsFragment = {
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
};
@@ -873,6 +891,22 @@ export type IssueQuery = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
decisions: Array<{
id: string;
issueId: string;
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -1083,12 +1117,14 @@ export type IssueCommentsQuery = {
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
}>;
};
@@ -1490,6 +1526,42 @@ export type ReactToCommentMutation = {
reactToComment: { id: string; key: string | null; version: number };
};
export type MarkDecisionMutationVariables = Exact<{
commentId: string;
on: boolean;
}>;
export type MarkDecisionMutation = {
markDecision: { id: string; key: string | null; version: number };
};
export type SearchDecisionsQueryVariables = Exact<{
search?: string | null | undefined;
limit: number;
}>;
export type SearchDecisionsQuery = {
decisions: Array<{
comment: {
id: string;
issueId: string;
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
};
issue: { id: string; key: string; title: string };
}>;
};
export type DuplicateIssueMutationVariables = Exact<{
issueId: string;
}>;
@@ -2780,12 +2852,14 @@ export type CommentChangedSubscription = {
body: string;
replyTo: string | null;
deleted: boolean;
decision: boolean;
createdAt: string;
editedAt: string | null;
reactions: Array<{
emoji: string;
users: Array<{ id: string; name: string }>;
}>;
decidedBy: { id: string; name: string } | null;
author: { id: string; name: string };
} | null;
};
@@ -3498,6 +3572,96 @@ export const IssueSummaryFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<IssueSummaryFieldsFragment, unknown>;
export const CommentFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommentFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Comment' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'reactions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'users' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'author' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
export const IssueFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -3516,6 +3680,19 @@ export const IssueFieldsFragmentDoc = {
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommentFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
@@ -3742,11 +3919,6 @@ export const IssueFieldsFragmentDoc = {
],
},
},
],
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
export const CommentFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommentFields' },
@@ -3785,6 +3957,20 @@ export const CommentFieldsFragmentDoc = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -3803,23 +3989,8 @@ export const CommentFieldsFragmentDoc = {
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
export const ReleaseFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -5716,6 +5887,76 @@ export const IssueDocument = {
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommentFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Comment' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'reactions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'users' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'author' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueFields' },
@@ -5731,6 +5972,19 @@ export const IssueDocument = {
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommentFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
@@ -7051,6 +7305,20 @@ export const IssueCommentsDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
@@ -9539,6 +9807,271 @@ export const ReactToCommentDocument = {
ReactToCommentMutation,
ReactToCommentMutationVariables
>;
export const MarkDecisionDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'MarkDecision' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'on' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'markDecision' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'commentId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'commentId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'on' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'on' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
MarkDecisionMutation,
MarkDecisionMutationVariables
>;
export const SearchDecisionsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'SearchDecisions' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'limit' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'search' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'limit' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'limit' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'comment' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommentFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issue' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommentFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Comment' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'reactions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'users' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'author' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
SearchDecisionsQuery,
SearchDecisionsQueryVariables
>;
export const DuplicateIssueDocument = {
kind: 'Document',
definitions: [
@@ -16294,6 +16827,20 @@ export const CommentChangedDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decidedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
{

View File

@@ -293,6 +293,25 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
}
}
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
markDecision(commentId: $commentId, on: $on) {
...CommandResultFields
}
}
query SearchDecisions($search: String, $limit: Int!) {
decisions(search: $search, limit: $limit) {
comment {
...CommentFields
}
issue {
id
key
title
}
}
}
mutation DuplicateIssue($issueId: ID!) {
duplicateIssue(issueId: $issueId) {
...CommandResultFields

View File

@@ -331,6 +331,109 @@ a.ts-key {
}
}
/* A decision: marked on its comment, and listed above the ticket. */
.ts-comment.is-decision {
margin-inline: -0.75rem;
padding-inline: 0.75rem;
border-left: 3px solid var(--ts-c-green);
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
}
.ts-comment.is-pointed {
animation: ts-pointed 2.4s ease-out;
}
@keyframes ts-pointed {
0%,
40% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent);
}
100% {
box-shadow: 0 0 0 3px transparent;
}
}
.ts-comment__decision {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-green) 16%, transparent);
color: var(--ts-c-green);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* A plain icon button, not @pi/ui's sized one. */
.ts-comment__flag {
display: inline-grid;
place-items: center;
width: 2rem !important;
min-width: 0 !important;
height: 2rem !important;
padding: 0 !important;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: var(--ts-muted);
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
&.is-on {
color: var(--ts-c-green);
}
}
.ts-decisions {
ol {
display: grid;
gap: 0.875rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.75rem 1rem;
border-left: 3px solid var(--ts-c-green);
border-radius: 0 0.5rem 0.5rem 0;
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
}
.ts-section pi-icon {
color: var(--ts-c-green);
}
}
.ts-decisions__meta {
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
/* Looks like a link: not @pi/ui's sized button. */
.ts-decisions__go {
display: inline !important;
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-comment__head {
display: flex;
align-items: center;

View File

@@ -259,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
}
@if (issue.decisions.length) {
<article
class="ts-block ts-decisions"
aria-labelledby="decisions-title"
>
<h2 class="ts-section" id="decisions-title">
<pi-icon icon="flag" size="small" />
Decisiones
<span class="ts-mono">{{ issue.decisions.length }}</span>
</h2>
<ol>
@for (decision of issue.decisions; track decision.id) {
<li>
<ts-markdown
[text]="decision.body"
[me]="facade.me()"
[loader]="loadImage"
/>
<p class="ts-decisions__meta">
{{ decision.author.name }} ·
{{ ago(decision.createdAt) }}
@if (decision.decidedBy; as by) {
@if (by.id !== decision.author.id) {
· marcada por {{ by.name }}
}
}
·
<button
type="button"
class="ts-decisions__go"
(click)="goToComment(decision.id)"
>
Ver en la conversación
</button>
</p>
</li>
}
</ol>
</article>
}
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
@@ -615,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<span class="ts-mono">{{ commentCount() }}</span>
</h2>
<ng-template #commentView let-comment>
<div class="ts-comment">
<div
class="ts-comment"
[id]="'comment-' + comment.id"
[class.is-decision]="comment.decision && !comment.deleted"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
@@ -628,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
@if (comment.decision && !comment.deleted) {
<span class="ts-comment__decision">Decisión</span>
}
<span class="ts-spacer"></span>
@if (!comment.deleted && project.canContribute()) {
<!-- Native: pi-icon-button leaves its button unnamed. -->
<button
type="button"
class="ts-comment__flag"
[class.is-on]="comment.decision"
[attr.aria-pressed]="comment.decision"
[attr.aria-label]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión'
"
[attr.title]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión: sale arriba del ticket'
"
(click)="
facade.markDecision(comment.id, !comment.decision)
"
>
<pi-icon icon="flag" size="small" />
</button>
}
@if (
!comment.deleted && comment.author.id === facade.me()
) {
@@ -1028,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required<string>();
/** `?comment=` (query param): the comment to bring into view. */
readonly comment = input<string>();
protected readonly facade = inject(IssueFacade);
protected readonly project = inject(ProjectFacade);
@@ -1334,12 +1408,29 @@ export class IssuePage {
});
});
});
effect(() => {
const id = this.comment();
// Once its comment has arrived (they load after the ticket).
if (id && this.facade.comments().some((c) => c.id === id))
untracked(() => setTimeout(() => this.goToComment(id)));
});
effect(() => {
const editing = this.editingInDrawer() || !!this.commentBody().trim();
untracked(() => this.presence.setEditing(editing));
});
}
/** Scrolls to a comment of the conversation and points at it. */
protected goToComment(id: string): void {
const element = document.getElementById(`comment-${id}`);
if (!element) return;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.remove('is-pointed');
// Restart the animation when it is pointed at again.
void element.offsetWidth;
element.classList.add('is-pointed');
}
/** While a form is open in the drawer, I am editing. */
private trackEditing(ref: {
afterClosed(): { subscribe(f: () => void): unknown };

View File

@@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import {
Decision,
GO_TO,
isTyping,
IssuesGateway,
@@ -23,6 +24,7 @@ import {
} from '@task-stream/web/tracker/domain';
import {
catchError,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
@@ -39,9 +41,28 @@ const issueResult = (i: IssueSummary): Result => ({
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
});
/** A decision as a result: opens its ticket at the comment. */
const decisionResult = ({ comment, issue }: Decision): Result => ({
id: `d:${comment.id}`,
kind: 'decision',
label: `${issue.key} · ${excerpt(comment.body)}`,
detail: comment.author.name,
url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`,
});
/** The first words of a comment, without Markdown marks. */
function excerpt(body: string): string {
const text = body
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_`~#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
}
interface Result {
id: string;
kind: 'project' | 'issue';
kind: 'project' | 'issue' | 'decision';
label: string;
detail: string;
url: string;
@@ -83,7 +104,7 @@ interface Result {
#search
class="ts-palette__input"
type="search"
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto"
role="combobox"
aria-controls="ts-palette-results"
[attr.aria-expanded]="results().length > 0"
@@ -113,9 +134,11 @@ interface Result {
<span class="ts-palette__kind">{{
result.kind === 'project'
? 'Proyecto'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
: result.kind === 'decision'
? 'Decisión'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
}}</span>
<span class="ts-palette__label">{{ result.label }}</span>
<span class="ts-palette__detail">{{ result.detail }}</span>
@@ -268,6 +291,7 @@ export class CommandPalette {
private goTimer: ReturnType<typeof setTimeout> | null = null;
private readonly foundIssues = signal<IssueSummary[]>([]);
private readonly foundDecisions = signal<Decision[]>([]);
/** Mine, shown before typing; read again each time it opens. */
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly results = computed<Result[]>(() => {
@@ -288,7 +312,11 @@ export class CommandPalette {
detail: p.key,
url: `/p/${p.key}/board`,
}));
return [...projects, ...this.foundIssues().map(issueResult)];
return [
...projects,
...this.foundIssues().map(issueResult),
...this.foundDecisions().map(decisionResult),
];
});
constructor() {
@@ -299,15 +327,25 @@ export class CommandPalette {
distinctUntilChanged(),
switchMap((search) =>
search.length < 2
? of([])
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
map((page) => page.items),
catchError(() => of([])),
),
? of<[IssueSummary[], Decision[]]>([[], []])
: combineLatest([
this.issues
.mine({ assignedToMe: false, search, limit: 8 })
.pipe(
map((page) => page.items),
catchError(() => of([])),
),
this.issues
.searchDecisions(search, 4)
.pipe(catchError(() => of([]))),
]),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((found) => this.foundIssues.set(found));
.subscribe((found) => {
this.foundIssues.set(found[0]);
this.foundDecisions.set(found[1]);
});
effect(() => {
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
});