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

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