feat: follow an epic with everything under it

«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).

API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:59:04 +02:00
parent 179d6a5e94
commit d79ef418d3
31 changed files with 1072 additions and 62 deletions

View File

@@ -51,6 +51,8 @@ import {
VoteIssueDocument,
WatchIssueDocument,
MyPinsDocument,
FollowTreeDocument,
UnfollowTreeDocument,
SearchDecisionsDocument,
PinIssueDocument,
UpdateChecklistItemDocument,
@@ -218,6 +220,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
),
);
}
followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId })
: this.gql.mutate(UnfollowTreeDocument, { issueId });
return done$.pipe(map(() => undefined));
}
pin(issueId: string, pinned: boolean): Observable<void> {
const done$: Observable<unknown> = pinned
? this.gql.mutate(PinIssueDocument, { issueId })
@@ -259,6 +268,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
issue: toIssueSummary(epic.issue),
progress: { ...epic.progress },
links: epic.issue.links.map((l) => toIssueLink(l)),
followingTree: epic.issue.followingTree,
})),
),
);

View File

@@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
issue: toIssueSummary(e.issue),
progress: { ...e.progress },
links: [],
// The portfolio does not offer following.
followingTree: false,
})),
})),
),

View File

@@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue {
...IssueSummaryFields
pinned
followingTree
decisions {
...CommentFields
}

View File

@@ -688,6 +688,7 @@ export type IssueSummaryFieldsFragment = {
export type IssueFieldsFragment = {
pinned: boolean;
followingTree: boolean;
description: string | null;
figmaUrl: string | null;
createdAt: string;
@@ -933,6 +934,7 @@ export type IssueQueryVariables = Exact<{
export type IssueQuery = {
issue: {
pinned: boolean;
followingTree: boolean;
description: string | null;
figmaUrl: string | null;
createdAt: string;
@@ -1151,6 +1153,18 @@ export type MyPinsQuery = {
}>;
};
export type FollowTreeMutationVariables = Exact<{
issueId: string;
}>;
export type FollowTreeMutation = { followTree: boolean };
export type UnfollowTreeMutationVariables = Exact<{
issueId: string;
}>;
export type UnfollowTreeMutation = { unfollowTree: boolean };
export type PinIssueMutationVariables = Exact<{
issueId: string;
}>;
@@ -1321,6 +1335,7 @@ export type EpicsQueryVariables = Exact<{
export type EpicsQuery = {
epics: Array<{
issue: {
followingTree: boolean;
id: string;
projectId: string;
key: string;
@@ -3820,6 +3835,7 @@ export const IssueFieldsFragmentDoc = {
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -6420,6 +6436,7 @@ export const IssueDocument = {
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -7452,6 +7469,93 @@ export const MyPinsDocument = {
},
],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
export const FollowTreeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'FollowTree' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'followTree' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<FollowTreeMutation, FollowTreeMutationVariables>;
export const UnfollowTreeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnfollowTree' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unfollowTree' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnfollowTreeMutation,
UnfollowTreeMutationVariables
>;
export const PinIssueDocument = {
kind: 'Document',
definitions: [
@@ -8633,6 +8737,10 @@ export const EpicsDocument = {
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'followingTree' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'links' },

View File

@@ -101,6 +101,14 @@ query MyPins {
}
}
mutation FollowTree($issueId: ID!) {
followTree(issueId: $issueId)
}
mutation UnfollowTree($issueId: ID!) {
unfollowTree(issueId: $issueId)
}
mutation PinIssue($issueId: ID!) {
pinIssue(issueId: $issueId)
}
@@ -185,6 +193,7 @@ query Epics($projectId: ID!) {
epics(projectId: $projectId) {
issue {
...IssueSummaryFields
followingTree
links {
type
direction