feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will take them; drag them, or use the arrows, to reorder. Moving a ticket only rewrites that ticket's rank, read from the event store, and board columns follow the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -69,6 +70,7 @@ export const COMMAND_HANDLERS = [
|
||||
DetachFileCommandHandler,
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
RankIssueCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
@@ -372,6 +373,38 @@ describe('tracker commands (event sourced)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('orders the backlog from the ranks in the event store', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids = [];
|
||||
for (const title of ['A', 'B', 'C'])
|
||||
ids.push(
|
||||
(
|
||||
await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
|
||||
)
|
||||
).id,
|
||||
);
|
||||
const [a, b, c] = ids;
|
||||
|
||||
// C to the top, then B between C and A: C, B, A.
|
||||
await run(new RankIssueCommand('dev', c, null, a));
|
||||
await run(new RankIssueCommand('dev', b, c, a));
|
||||
const ranks = await Promise.all(
|
||||
[c, b, a].map(
|
||||
async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank,
|
||||
),
|
||||
);
|
||||
expect([...ranks].sort()).toEqual(ranks);
|
||||
// Neighbours given the wrong way round: the order changed meanwhile.
|
||||
await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from './watch-issue.command';
|
||||
export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(RankIssueCommand)
|
||||
export class RankIssueCommandHandler
|
||||
implements ICommandHandler<RankIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RankIssueCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// The neighbours' ranks come from their streams, not from the screen.
|
||||
const [above, below] = await Promise.all(
|
||||
[command.aboveIssueId, command.belowIssueId].map((id) =>
|
||||
id ? this.access.issue(id) : null,
|
||||
),
|
||||
);
|
||||
issue.moveBetween(command.actorId, above, below);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/**
|
||||
* Moves a ticket in the backlog, between the one that ends up above it and the
|
||||
* one that ends up below (null: the top or the bottom).
|
||||
*/
|
||||
export class RankIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly aboveIssueId: string | null,
|
||||
public readonly belowIssueId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handle
|
||||
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
@@ -64,6 +65,7 @@ export const EVENT_HANDLERS = [
|
||||
IssueWatchersEventHandler,
|
||||
IssueParentChangedEventHandler,
|
||||
IssueChecklistEventHandler,
|
||||
IssueRankedEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueCreatedEvent,
|
||||
implicitRank,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -49,6 +50,7 @@ export class IssueCreatedEventHandler
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
parent: null,
|
||||
rank: implicitRank(issue.number),
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueRankedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueRankedEvent)
|
||||
export class IssueRankedEventHandler
|
||||
implements IEventHandler<IssueRankedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueRankedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
rank: event.rank,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
@@ -218,6 +219,18 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
|
||||
rank(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new RankIssueCommand(actorId, issueId, above, below),
|
||||
);
|
||||
}
|
||||
|
||||
addChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum IssueSort {
|
||||
RECENT = 'RECENT',
|
||||
RANK = 'RANK',
|
||||
}
|
||||
|
||||
export interface IssueFilter {
|
||||
projectId: string;
|
||||
/** Status, type and area ids of the project's settings. */
|
||||
@@ -21,6 +26,8 @@ export interface IssueFilter {
|
||||
labels?: string[];
|
||||
/** Only the tickets hanging from this one. */
|
||||
parentId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
sort?: IssueSort;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import {
|
||||
FindIssuesRepository,
|
||||
IssueSort,
|
||||
} from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { BoardColumn, GetBoardQuery } from './get-board.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
@@ -33,6 +36,8 @@ export class GetBoardQueryHandler
|
||||
const page = await this.findIssues.search({
|
||||
...query.filter,
|
||||
statuses: [status.id],
|
||||
// Each column follows the backlog order.
|
||||
sort: IssueSort.RANK,
|
||||
limit: Math.min(query.perColumn, 200),
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './lib/issue/issue-attachment';
|
||||
export * from './lib/issue/mentions';
|
||||
export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/issue/issue-rank';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -30,3 +30,4 @@ export * from './issue-parent-changed.event';
|
||||
export * from './issue-checklist-item-added.event';
|
||||
export * from './issue-checklist-item-updated.event';
|
||||
export * from './issue-checklist-item-removed.event';
|
||||
export * from './issue-ranked.event';
|
||||
|
||||
15
libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts
Normal file
15
libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket was moved to another place in the backlog. */
|
||||
@AutowiredEvent
|
||||
export class IssueRankedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly rank: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
42
libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts
Normal file
42
libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { implicitRank, rankBetween } from './issue-rank';
|
||||
|
||||
describe('backlog ranks', () => {
|
||||
it('orders tickets never moved by creation', () => {
|
||||
const ranks = [1, 2, 10, 36, 999].map(implicitRank);
|
||||
expect([...ranks].sort()).toEqual(ranks);
|
||||
});
|
||||
|
||||
it('always finds a rank between two, at the ends too', () => {
|
||||
const pairs: [string | null, string | null][] = [
|
||||
[null, null],
|
||||
[null, implicitRank(1)],
|
||||
[implicitRank(1), implicitRank(2)],
|
||||
[implicitRank(9), null],
|
||||
['a', 'b'],
|
||||
['az', 'b'],
|
||||
['a', 'a1'],
|
||||
['zz', null],
|
||||
];
|
||||
for (const [above, below] of pairs) {
|
||||
const rank = rankBetween(above, below);
|
||||
if (above !== null) expect(rank > above).toBe(true);
|
||||
if (below !== null) expect(rank < below).toBe(true);
|
||||
expect(rank.endsWith('0')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps finding room when the same gap is split again and again', () => {
|
||||
let above = implicitRank(1);
|
||||
const below = implicitRank(2);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const rank = rankBetween(above, below);
|
||||
expect(rank > above && rank < below).toBe(true);
|
||||
above = rank;
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses neighbours that are out of order', () => {
|
||||
expect(() => rankBetween('b', 'a')).toThrow(BusinessRuleError);
|
||||
});
|
||||
});
|
||||
45
libs/api/tracker/domain/src/lib/issue/issue-rank.ts
Normal file
45
libs/api/tracker/domain/src/lib/issue/issue-rank.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
|
||||
/*
|
||||
* Backlog order as fractional ranks: strings compared character by character,
|
||||
* so moving a ticket rewrites only that ticket. A rank is a base-36 fraction
|
||||
* (`0.xyz…`) that never ends in `0`, which keeps a free key between any two.
|
||||
*/
|
||||
|
||||
const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const BASE = DIGITS.length;
|
||||
|
||||
const digit = (rank: string, at: number) =>
|
||||
at < rank.length ? DIGITS.indexOf(rank[at]) : 0;
|
||||
|
||||
/**
|
||||
* Where a ticket that was never moved sits: after anything placed above it,
|
||||
* in creation order. Starts at `n`, the middle of the key space.
|
||||
*/
|
||||
export function implicitRank(issueNumber: number): string {
|
||||
return `n${issueNumber.toString(BASE).padStart(7, '0')}i`;
|
||||
}
|
||||
|
||||
/** A rank strictly between `above` and `below` (null: open end). */
|
||||
export function rankBetween(
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): string {
|
||||
const low = above ?? '';
|
||||
if (below !== null && low >= below)
|
||||
throw new BusinessRuleError('The order changed meanwhile; reload it');
|
||||
let upper = below;
|
||||
let rank = '';
|
||||
for (let at = 0; ; at++) {
|
||||
const from = digit(low, at);
|
||||
const to = upper === null ? BASE : digit(upper, at);
|
||||
if (from === to) {
|
||||
rank += DIGITS[from];
|
||||
continue;
|
||||
}
|
||||
if (to - from > 1) return rank + DIGITS[(from + to) >> 1];
|
||||
// Neighbours here: keep the lower digit, and anything after it fits below.
|
||||
rank += DIGITS[from];
|
||||
upper = null;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,32 @@ describe('Issue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlog order', () => {
|
||||
it('moves between two tickets, to the top, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
const other = (number: number) =>
|
||||
factory.create({
|
||||
projectId: 'p-1',
|
||||
projectKey: 'TS',
|
||||
number,
|
||||
title: `#${number}`,
|
||||
type: IssueType.TASK,
|
||||
reporterId: 'u',
|
||||
});
|
||||
const [one, two] = [other(1), other(2)];
|
||||
|
||||
issue.moveBetween('u-pm', one, two);
|
||||
expect(issue.backlogRank > one.backlogRank).toBe(true);
|
||||
expect(issue.backlogRank < two.backlogRank).toBe(true);
|
||||
issue.moveBetween('u-pm', null, one);
|
||||
expect(issue.backlogRank < one.backlogRank).toBe(true);
|
||||
expect(replay(issue, Issue).rebuilt.rank).toBe(issue.rank);
|
||||
expect(() => issue.moveBetween('u-pm', issue, null)).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklist', () => {
|
||||
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueRankedEvent } from '../events/issue-ranked.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
normalizeChecklistText,
|
||||
} from './issue-checklist';
|
||||
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||
import { implicitRank, rankBetween } from './issue-rank';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
export interface IssueLink {
|
||||
@@ -108,6 +110,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
/** The epic or ticket this one breaks down. */
|
||||
public parentId: string | null = null;
|
||||
public checklist: ChecklistItem[] = [];
|
||||
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
||||
public rank: string | null = null;
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -316,6 +320,28 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Where it sits in the backlog: where it was put, or its creation order. */
|
||||
get backlogRank(): string {
|
||||
return this.rank ?? implicitRank(this.number);
|
||||
}
|
||||
|
||||
/** Puts the ticket between two others of the backlog (null: an end). */
|
||||
moveBetween(actorId: string, above: Issue | null, below: Issue | null): void {
|
||||
for (const neighbour of [above, below]) {
|
||||
if (neighbour?.id === this.id)
|
||||
throw new BusinessRuleError(`${this.key} cannot move next to itself`);
|
||||
if (neighbour && neighbour.projectId !== this.projectId)
|
||||
throw new BusinessRuleError(
|
||||
`${neighbour.key} belongs to another project`,
|
||||
);
|
||||
}
|
||||
const rank = rankBetween(
|
||||
above?.backlogRank ?? null,
|
||||
below?.backlogRank ?? null,
|
||||
);
|
||||
this.apply(new IssueRankedEvent(actorId, this.id, this.projectId, rank));
|
||||
}
|
||||
|
||||
/** Adds a step to the checklist; `id` is generated by the caller. */
|
||||
addChecklistItem(actorId: string, id: string, text: string): void {
|
||||
if (this.checklist.some((i) => i.id === id)) return;
|
||||
@@ -463,6 +489,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueRankedEvent(event: IssueRankedEvent): void {
|
||||
// Reordering is not a change to the ticket: `updatedAt` stays.
|
||||
this.rank = event.rank;
|
||||
}
|
||||
|
||||
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
|
||||
this.checklist = [...this.checklist, { ...event.item }];
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -79,6 +79,8 @@ export class IssueReadModel {
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
parent!: IssueParentReadModel | null;
|
||||
/** Place in the backlog (and in each board column): ascending. */
|
||||
rank!: string;
|
||||
checklist!: ChecklistItemReadModel[];
|
||||
commentCount!: number;
|
||||
createdAt!: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FindReleasesRepository,
|
||||
FindTimelineRepository,
|
||||
IssueFilter,
|
||||
IssueSort,
|
||||
Page,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
@@ -135,7 +136,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
const [docs, total] = await Promise.all([
|
||||
this.issues
|
||||
.find(query)
|
||||
.sort({ updatedAt: -1 })
|
||||
.sort(
|
||||
filter.sort === IssueSort.RANK
|
||||
? { rank: 1, number: 1 }
|
||||
: { updatedAt: -1 },
|
||||
)
|
||||
.skip(filter.offset)
|
||||
.limit(filter.limit)
|
||||
.lean<IssueDoc[]>(),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { implicitRank } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { IssueDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
/**
|
||||
* Tickets projected before the backlog could be ordered lack `rank`; give
|
||||
* them their creation order once at startup, the rank they have anyway.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RankBackfill implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(RankBackfill.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const missing = await this.issues
|
||||
.find({ rank: { $exists: false } }, { number: 1 })
|
||||
.lean<Pick<IssueDoc, '_id' | 'number'>[]>();
|
||||
if (missing.length === 0) return;
|
||||
const result = await this.issues.bulkWrite(
|
||||
missing.map((doc) => ({
|
||||
updateOne: {
|
||||
filter: { _id: doc._id, rank: { $exists: false } },
|
||||
update: { $set: { rank: implicitRank(doc.number) } },
|
||||
},
|
||||
})),
|
||||
);
|
||||
this.logger.log(
|
||||
`Backlog rank filled in on ${result.modifiedCount} tickets`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
|
||||
@@ -102,6 +103,7 @@ IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||
IssueSchema.index({ 'assignee.id': 1 });
|
||||
IssueSchema.index({ 'watchers.id': 1 });
|
||||
IssueSchema.index({ 'parent.id': 1 });
|
||||
IssueSchema.index({ projectId: 1, rank: 1 });
|
||||
IssueSchema.index({ 'release.id': 1 });
|
||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
|
||||
@@ -186,6 +188,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
attachments: rest.attachments ?? [],
|
||||
watchers: rest.watchers ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
MongoUpsertMaterializedTimelineRepository,
|
||||
} from './mongo/upsert-materialized.repositories';
|
||||
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
|
||||
import { RankBackfill } from './mongo/rank.backfill';
|
||||
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
|
||||
import {
|
||||
PostgresIssueNumbersRepository,
|
||||
@@ -120,7 +121,7 @@ const PORTS = [
|
||||
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
|
||||
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
|
||||
],
|
||||
providers: [...PORTS, StatusCategoryBackfill],
|
||||
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
|
||||
exports: PORTS.map((port) => port.provide),
|
||||
})
|
||||
export class TrackerInfrastructureModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
import { IssueSort } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
@@ -23,6 +24,10 @@ registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
||||
registerEnumType(IssueSort, {
|
||||
name: 'IssueSort',
|
||||
description: 'RECENT: last updated first. RANK: backlog order.',
|
||||
});
|
||||
registerEnumType(NotificationKind, {
|
||||
name: 'NotificationKind',
|
||||
description: 'Why someone is told',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { IssueSort } from '@task-stream/api/tracker/application';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
@@ -180,6 +181,25 @@ export class ChangeIssueStatusInput {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class RankIssueInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'The ticket that ends up right above (null: the top)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
aboveIssueId?: string | null;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'The ticket that ends up right below (null: the bottom)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
belowIssueId?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AddChecklistItemInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@@ -249,6 +269,10 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueSort)
|
||||
sort?: IssueSort;
|
||||
@Field({ nullable: true, description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CreateIssueInput,
|
||||
IssueFilterInput,
|
||||
LinkIssuesInput,
|
||||
RankIssueInput,
|
||||
UpdateChecklistItemInput,
|
||||
UpdateIssueInput,
|
||||
} from './inputs';
|
||||
@@ -266,6 +267,19 @@ export class IssueResolver {
|
||||
return this.issuesService.setParent(me.id, issueId, parentId ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Move a ticket in the backlog, between two others of the same project',
|
||||
})
|
||||
rankIssue(@CurrentUser() me: User, @Args('input') input: RankIssueInput) {
|
||||
return this.issuesService.rank(
|
||||
me.id,
|
||||
input.issueId,
|
||||
input.aboveIssueId ?? null,
|
||||
input.belowIssueId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: "Add a step to a ticket's checklist",
|
||||
})
|
||||
|
||||
@@ -167,6 +167,11 @@ export class IssueGql {
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
})
|
||||
parent!: IssueParentGql | null;
|
||||
@Field({
|
||||
description:
|
||||
'Place in the backlog and in its board column (ascending, compare as text)',
|
||||
})
|
||||
rank!: string;
|
||||
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
|
||||
@Field(() => Int) commentCount!: number;
|
||||
@Field() createdAt!: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { IssueSort } from '@task-stream/api/tracker/application';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
@@ -416,6 +417,15 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@IsString()
|
||||
parent?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueSort,
|
||||
enumName: 'IssueSort',
|
||||
description: 'RECENT (default): last updated first. RANK: backlog order.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssueSort)
|
||||
sort?: IssueSort;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -579,6 +589,30 @@ export class SetIssueReleaseDto {
|
||||
releaseId!: string | null;
|
||||
}
|
||||
|
||||
export class RankIssueDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'WEB-4',
|
||||
description:
|
||||
'The ticket that ends up right above (id or key; null: the top)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
above?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'WEB-9',
|
||||
description:
|
||||
'The ticket that ends up right below (id or key; null: the bottom)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
below?: string | null;
|
||||
}
|
||||
|
||||
export class SetIssueParentDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
|
||||
@@ -179,6 +179,11 @@ export class IssueDto {
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
})
|
||||
parent!: IssueParentDto | null;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Place in the backlog and its board column (ascending, as text)',
|
||||
})
|
||||
rank!: string;
|
||||
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
|
||||
@ApiProperty() commentCount!: number;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
LinkTypeParamDto,
|
||||
MyIssuesQueryDto,
|
||||
PageQueryDto,
|
||||
RankIssueDto,
|
||||
SetIssueParentDto,
|
||||
SetIssueReleaseDto,
|
||||
UpdateChecklistItemDto,
|
||||
@@ -156,6 +157,7 @@ export class IssuesController {
|
||||
unassigned: query.unassigned,
|
||||
releaseId: query.releaseId,
|
||||
labels: query.label,
|
||||
sort: query.sort,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
@@ -323,6 +325,30 @@ export class IssuesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/rank')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Move the ticket in the backlog',
|
||||
description:
|
||||
'Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async rank(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: RankIssueDto,
|
||||
) {
|
||||
const ref = (value?: string | null) =>
|
||||
value ? this.refs.issueId(me.id, value) : null;
|
||||
return this.issues.rank(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
await ref(body.above),
|
||||
await ref(body.below),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/checklist')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: "Add a step to the ticket's checklist" })
|
||||
|
||||
Reference in New Issue
Block a user