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:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user