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

@@ -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 {