feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and put tickets in them from the ticket page. The Sprints tab follows the one under way with its progress and a burndown in tickets or points against the ideal line. Closing a sprint sends what was left to the next one or back to the backlog; the board can be filtered by sprint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
ProjectRole,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
SprintProgressReadModel,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
@@ -281,6 +282,37 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async sprintProgress(sprintId: string): Promise<SprintProgressReadModel> {
|
||||
const finished = { $in: ['$statusCategory', FINISHED] };
|
||||
const points = { $ifNull: ['$storyPoints', 0] };
|
||||
const [row] = await this.issues.aggregate<{
|
||||
total: number;
|
||||
finished: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
}>([
|
||||
{ $match: { 'sprint.id': sprintId } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
total: { $sum: 1 },
|
||||
finished: { $sum: { $cond: [finished, 1, 0] } },
|
||||
storyPoints: { $sum: points },
|
||||
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const total = row?.total ?? 0;
|
||||
return {
|
||||
total,
|
||||
finished: row?.finished ?? 0,
|
||||
percent:
|
||||
total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100),
|
||||
storyPoints: row?.storyPoints ?? 0,
|
||||
finishedStoryPoints: row?.finishedStoryPoints ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
||||
const rows = await this.issues.aggregate<{
|
||||
_id: { status: string; category: StatusCategory };
|
||||
@@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
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.noSprint) query['sprint'] = null;
|
||||
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.search?.trim()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SavedFilterReadModel,
|
||||
SprintReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
@@ -24,6 +25,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
|
||||
};
|
||||
export type CommentDoc = Doc<CommentReadModel>;
|
||||
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||
export type SprintDoc = Doc<SprintReadModel>;
|
||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
@@ -43,6 +45,7 @@ export const ReadModels = {
|
||||
Notification: 'NotificationReadModel',
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
Sprint: 'SprintReadModel',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -79,6 +82,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
||||
reporter: userRef,
|
||||
assignee: userRef,
|
||||
release: { _id: false, id: String, name: String },
|
||||
sprint: { _id: false, id: String, name: String },
|
||||
outwardLinks: [link],
|
||||
inwardLinks: [link],
|
||||
watchers: [userRef],
|
||||
@@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 });
|
||||
IssueSchema.index({ 'parent.id': 1 });
|
||||
IssueSchema.index({ projectId: 1, rank: 1 });
|
||||
IssueSchema.index({ 'release.id': 1 });
|
||||
IssueSchema.index({ 'sprint.id': 1 });
|
||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
|
||||
export const CommentSchema = new Schema<CommentDoc>(
|
||||
@@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema<ReleaseDoc>(
|
||||
{ ...loose, collection: 'releases_view' },
|
||||
);
|
||||
|
||||
export const SprintSchema = new Schema<SprintDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
status: String,
|
||||
version: Number,
|
||||
},
|
||||
{ ...loose, collection: 'sprints_view' },
|
||||
);
|
||||
|
||||
export const ActivitySchema = new Schema<ActivityDoc>(
|
||||
{
|
||||
_id: String,
|
||||
@@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
{ name: ReadModels.Comment, schema: CommentSchema },
|
||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
@@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
watchers: rest.watchers ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
FindSprintsRepository,
|
||||
UpsertMaterializedSprintRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
|
||||
const ORDER: Record<SprintStatus, number> = {
|
||||
[SprintStatus.ACTIVE]: 0,
|
||||
[SprintStatus.PLANNED]: 1,
|
||||
[SprintStatus.CLOSED]: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MongoFindSprintsRepository extends FindSprintsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SprintReadModel | null> {
|
||||
const doc = await this.sprints.findById(id).lean<SprintDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<SprintReadModel[]> {
|
||||
const docs = await this.sprints.find({ projectId }).lean<SprintDoc[]>();
|
||||
return docs
|
||||
.map(fromDoc)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
ORDER[a.status] - ORDER[b.status] ||
|
||||
(a.status === SprintStatus.CLOSED
|
||||
? b.startDate.localeCompare(a.startDate)
|
||||
: a.startDate.localeCompare(b.startDate)) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
async findActive(projectId: string): Promise<SprintReadModel | null> {
|
||||
const doc = await this.sprints
|
||||
.findOne({ projectId, status: SprintStatus.ACTIVE })
|
||||
.lean<SprintDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async upsert({
|
||||
id,
|
||||
version,
|
||||
...fields
|
||||
}: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>) {
|
||||
await upsertIfNewer(this.sprints, id, version, fields);
|
||||
}
|
||||
|
||||
async remove(sprintId: string) {
|
||||
await this.sprints.deleteOne({ _id: sprintId });
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
if (ops.length) await this.issues.bulkWrite(ops);
|
||||
}
|
||||
|
||||
async renameSprint(sprintId: string, name: string) {
|
||||
await this.issues.updateMany(
|
||||
{ 'sprint.id': sprintId },
|
||||
{ $set: { 'sprint.name': name } },
|
||||
);
|
||||
}
|
||||
|
||||
async renameRelease(releaseId: string, name: string) {
|
||||
await this.issues.updateMany(
|
||||
{ 'release.id': releaseId },
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
SprintCreatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@@ -32,6 +34,22 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
sprintIdsOfProject(projectId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
SprintCreatedEvent.name,
|
||||
`data->>'projectId'`,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
issueIdsEverInSprint(sprintId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
IssueSprintChangedEvent.name,
|
||||
`data->>'sprintId'`,
|
||||
sprintId,
|
||||
);
|
||||
}
|
||||
|
||||
commentIdsOfIssue(issueId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
CommentAddedEvent.name,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IssueChange,
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
@@ -19,6 +20,7 @@ const Channels = {
|
||||
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
||||
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
||||
project: (projectId: string) => `tracker.project.${projectId}`,
|
||||
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
||||
notifications: (userId: string) => `tracker.notifications.${userId}`,
|
||||
@@ -49,6 +51,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
||||
);
|
||||
}
|
||||
|
||||
publishSprint(change: SprintChange): Promise<void> {
|
||||
return this.pubSub.publish(Channels.sprint(change.projectId), change);
|
||||
}
|
||||
|
||||
async publishProject(
|
||||
change: ProjectChange,
|
||||
notifyUserIds: string[] = [],
|
||||
@@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
||||
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
|
||||
}
|
||||
|
||||
sprints(projectId: string): AsyncIterableIterator<SprintChange> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.sprint(projectId));
|
||||
}
|
||||
|
||||
project(projectId: string): AsyncIterableIterator<ProjectChange> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
FindIssuesRepository,
|
||||
FindProjectsRepository,
|
||||
FindReleasesRepository,
|
||||
FindSprintsRepository,
|
||||
UpsertMaterializedSprintRepository,
|
||||
FindTimelineRepository,
|
||||
IssueNumbersRepository,
|
||||
ProjectKeysRepository,
|
||||
@@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import {
|
||||
MongoFindSprintsRepository,
|
||||
MongoUpsertMaterializedSprintRepository,
|
||||
} from './mongo/sprints.repositories';
|
||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||
|
||||
@@ -79,6 +85,11 @@ const PORTS = [
|
||||
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
|
||||
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
|
||||
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
|
||||
{ provide: FindSprintsRepository, useClass: MongoFindSprintsRepository },
|
||||
{
|
||||
provide: UpsertMaterializedSprintRepository,
|
||||
useClass: MongoUpsertMaterializedSprintRepository,
|
||||
},
|
||||
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
|
||||
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user