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:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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';

View File

@@ -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';

View 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);
}
}

View 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);
});
});

View 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;
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[]>(),

View File

@@ -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`,
);
}
}

View File

@@ -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 ?? [],
};
}

View File

@@ -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 {}

View File

@@ -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',

View File

@@ -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()

View File

@@ -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",
})

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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" })