feat: epics, subtasks and checklists

Tickets can hang from another one of the same project, three levels deep
at most (epic → ticket → subtask). The ticket page lists its subtasks with
their progress, creates new ones and moves the ticket under an epic; a new
Épicas tab shows every epic and how far it has got. Each ticket also gets a
checklist for the small steps that don't deserve a ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View File

@@ -14,6 +14,7 @@ import {
import {
ActivityReadModel,
CommentReadModel,
EpicReadModel,
CountBy,
defaultProjectSettings,
IssueReadModel,
@@ -228,6 +229,53 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
return docs.map(issueFromDoc);
}
async epics(projectId: string): Promise<EpicReadModel[]> {
const finished = { $in: ['$statusCategory', FINISHED] };
const points = { $ifNull: ['$storyPoints', 0] };
const rows = await this.issues.aggregate<{
_id: string;
total: number;
finished: number;
storyPoints: number;
finishedStoryPoints: number;
}>([
{ $match: { projectId, 'parent.id': { $type: 'string' } } },
{
$group: {
_id: '$parent.id',
total: { $sum: 1 },
finished: { $sum: { $cond: [finished, 1, 0] } },
storyPoints: { $sum: points },
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
},
},
]);
if (rows.length === 0) return [];
const progress = new Map(rows.map((row) => [row._id, row]));
// Only the top of each tree: a ticket with subtasks under an epic is not one.
const docs = await this.issues
.find({ _id: { $in: [...progress.keys()] }, 'parent.id': null })
.sort({ number: 1 })
.lean<IssueDoc[]>();
return docs.map((doc) => {
const { _id: _ignored, ...row } = progress.get(doc._id) ?? {
_id: doc._id,
total: 0,
finished: 0,
storyPoints: 0,
finishedStoryPoints: 0,
};
return {
issue: issueFromDoc(doc),
progress: {
...row,
percent:
row.total === 0 ? 0 : Math.round((row.finished / row.total) * 100),
},
};
});
}
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
const rows = await this.issues.aggregate<{
_id: { status: string; category: StatusCategory };
@@ -268,6 +316,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
if (filter.labels?.length)
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
if (filter.releaseId) query['release.id'] = filter.releaseId;
if (filter.parentId) query['parent.id'] = filter.parentId;
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.search?.trim()) {

View File

@@ -78,6 +78,8 @@ export const IssueSchema = new Schema<IssueDoc>(
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
parent: { _id: false, id: String, key: String, title: String },
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
attachments: [
{
_id: false,
@@ -99,6 +101,7 @@ IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
@@ -182,5 +185,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
// Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null,
checklist: rest.checklist ?? [],
};
}

View File

@@ -11,6 +11,7 @@ import {
import {
ActivityReadModel,
AttachmentReadModel,
ChecklistItemReadModel,
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
@@ -130,6 +131,45 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
);
}
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': { $ne: item.id } },
{ $push: { checklist: item } },
);
}
async updateChecklistItem(
issueId: string,
itemId: string,
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
) {
const set = Object.fromEntries(
Object.entries(changes).map(([field, value]) => [
`checklist.$.${field}`,
value,
]),
);
if (Object.keys(set).length === 0) return;
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': itemId },
{ $set: set },
);
}
async removeChecklistItem(issueId: string, itemId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { checklist: { id: itemId } } },
);
}
async renameParent(parentId: string, title: string) {
await this.issues.updateMany(
{ 'parent.id': parentId },
{ $set: { 'parent.title': title } },
);
}
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },

View File

@@ -4,6 +4,7 @@ import {
CommentAddedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueParentChangedEvent,
ReleaseCreatedEvent,
} from '@task-stream/api/tracker/domain';
import { DataSource } from 'typeorm';
@@ -47,6 +48,14 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
);
}
issueIdsEverUnder(issueId: string): Promise<string[]> {
return this.streams(
IssueParentChangedEvent.name,
`data->>'parentId'`,
issueId,
);
}
/** `field` is one of the fixed JSON paths above, never user input. */
private async streams(
type: string,