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:
@@ -31,6 +31,13 @@ import {
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import {
|
||||
CreateSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const sprints = {
|
||||
active: null as { id: string; name: string } | null,
|
||||
findActive: async () => sprints.active,
|
||||
};
|
||||
const files = new InMemoryAttachmentStorage();
|
||||
const users = new FakeUserDirectory()
|
||||
.add('owner')
|
||||
@@ -92,6 +103,20 @@ async function setup() {
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
// Read-model lookups: the sprint under way is set by each test.
|
||||
{ provide: FindSprintsRepository, useValue: sprints },
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
sprintProgress: async () => ({
|
||||
total: 0,
|
||||
finished: 0,
|
||||
percent: 0,
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
@@ -103,6 +128,7 @@ async function setup() {
|
||||
return {
|
||||
store,
|
||||
files,
|
||||
sprints,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
};
|
||||
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('plans and starts sprints, one under way at a time, and fills them', async () => {
|
||||
const { run, rehydrator, sprints } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const plan = (name: string) =>
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'owner',
|
||||
projectId,
|
||||
name,
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
);
|
||||
const first = await plan('Sprint 1');
|
||||
const second = await plan('Sprint 2');
|
||||
await expect(
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
'X',
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
await run(new StartSprintCommand('owner', first.id));
|
||||
sprints.active = { id: first.id, name: 'Sprint 1' };
|
||||
await expect(
|
||||
run(new StartSprintCommand('owner', second.id)),
|
||||
).rejects.toThrow(/Sprint 1 is under way/);
|
||||
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||
);
|
||||
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
|
||||
first.id,
|
||||
);
|
||||
await expect(
|
||||
run(new SetIssueSprintCommand('pm', issue.id, null)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
Reference in New Issue
Block a user