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

@@ -18,6 +18,7 @@ import {
CommentsService,
IssuesService,
PinsService,
TreeFollowsService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -63,6 +64,7 @@ export class IssueResolver {
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -280,6 +282,17 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id);
}
@ResolveField(() => Boolean, {
description:
'Whether I follow it with everything under it (false without a token)',
})
followingTree(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.treeFollows.isFollowing(me.id, issue.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
@@ -391,6 +404,29 @@ export class IssueResolver {
return true;
}
@Mutation(() => Boolean, {
description:
'Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones',
})
async followTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, {
description: 'Stop following a ticket with everything under it',
})
async unfollowTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -33,6 +33,7 @@ import {
CommentsService,
IssuesService,
PinsService,
TreeFollowsService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -99,6 +100,7 @@ export class IssuesController {
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {}
@Get('me/home')
@@ -203,6 +205,38 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Follow a ticket with everything under it (an epic)',
description:
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({