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()) {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user