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()) {