feat: definition of done per project

A project can ask that tickets reach a DONE status only with their
checklist ticked (settings.checklistBeforeDone, off by default; absent
when saving means unchanged). Members get a 422 telling how many steps are
left; owners and admins may still close it. Settings has the switch and
the ticket's checklist says when the project asks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:39:24 +02:00
parent 5b0396bc10
commit e598f76e1c
25 changed files with 285 additions and 4 deletions

View File

@@ -14,11 +14,14 @@ export class ChangeIssueStatusCommandHandler
command.issueId,
command.actorId,
);
const role = project.roleOf(command.actorId);
issue.changeStatus(
command.actorId,
command.status,
command.reason,
project.workflow,
// The definition of done holds back members, not who runs the project.
role === 'OWNER' || role === 'ADMIN',
);
await issue.commitAndWait();
return ack(issue);

View File

@@ -26,7 +26,7 @@ import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
import { IssueWatchedEvent } from '../events/issue-watched.event';
import { IssueVotedEvent } from '../events/issue-voted.event';
import { ProjectWorkflow } from '../project/project-settings';
import { ProjectWorkflow, StatusCategory } from '../project/project-settings';
import {
IssueAttachment,
MAX_ATTACHMENTS_PER_ISSUE,
@@ -200,6 +200,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
to: string,
reason?: string | null,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
/** Owners and admins may close a ticket its definition of done holds back. */
canOverride = false,
): void {
const from = this.status;
if (from === to) return;
@@ -214,6 +216,16 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
}`,
);
}
const pending = this.checklist.filter((item) => !item.done).length;
if (
workflow.checklistBeforeDone &&
workflow.categoryOf(to) === StatusCategory.DONE &&
pending > 0 &&
!canOverride
)
throw new BusinessRuleError(
`A ${this.key} le ${pending === 1 ? 'queda 1 paso' : `quedan ${pending} pasos`} de la lista por marcar, y el proyecto pide terminarla antes de darlo por hecho`,
);
const trimmedReason = reason?.trim() || null;
if (workflow.requiresReason(to) && !trimmedReason) {
throw new BusinessRuleError(

View File

@@ -353,4 +353,54 @@ describe('Project settings', () => {
});
});
});
it('holds a ticket with an unticked checklist out of DONE, if asked', () => {
const project = newProject();
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Pago',
type: 'TASK',
reporterId: 'dev',
},
project.workflow,
);
issue.addChecklistItem('dev', 'a', 'Probar en Safari');
issue.addChecklistItem('dev', 'b', 'Probar en Chrome');
for (const to of [
'TODO',
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
])
issue.changeStatus('dev', to, null, project.workflow);
// Off by default: nothing changes for projects that do not ask.
const done = () =>
issue.changeStatus('dev', 'DONE', null, project.workflow);
expect(project.workflow.checklistBeforeDone).toBe(false);
project.updateSettings('owner', {
...defaultProjectSettings(),
checklistBeforeDone: true,
});
expect(done).toThrow(/quedan 2 pasos/);
// A client that does not know about it keeps it on.
project.updateSettings('owner', defaultProjectSettings());
expect(project.workflow.checklistBeforeDone).toBe(true);
issue.updateChecklistItem('dev', 'a', { done: true });
expect(done).toThrow(/queda 1 paso/);
// An owner or admin may close it anyway.
issue.changeStatus('owner', 'DONE', null, project.workflow, true);
expect(issue.status).toBe('DONE');
project.updateSettings('owner', {
...defaultProjectSettings(),
checklistBeforeDone: false,
});
expect(project.workflow.checklistBeforeDone).toBe(false);
});
});

View File

@@ -134,6 +134,12 @@ export interface ProjectSettings {
* unchanged (clients that predate them keep them).
*/
customFields?: CustomFieldDefinition[];
/**
* Definition of done: a ticket only reaches a DONE status with its
* checklist ticked (owners and admins may still close it). Absent when
* off; when saving, absent means unchanged.
*/
checklistBeforeDone?: boolean;
}
const status = (
@@ -323,6 +329,11 @@ export class ProjectWorkflow {
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
}
/** Tickets reach DONE only with their checklist ticked (definition of done). */
get checklistBeforeDone(): boolean {
return !!this.settings.checklistBeforeDone;
}
/** The project's custom fields, archived ones included. */
get customFields(): CustomFieldDefinition[] {
return this.settings.customFields ?? [];
@@ -503,6 +514,9 @@ export function validateProjectSettings(
areas,
// Only when there are some: settings saved unchanged must compare equal.
...(customFields.length ? { customFields } : {}),
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
? { checklistBeforeDone: true }
: {}),
};
}

View File

@@ -27,6 +27,8 @@ export interface SettingsPayload {
template?: { description?: string | null; checklist?: string[] } | null;
}[];
areas: { id: string; name: string; archived: boolean }[];
/** Definition of done. Absent when saving: unchanged. */
checklistBeforeDone?: boolean;
/** Absent when saving: unchanged. */
customFields?: {
id: string;
@@ -41,6 +43,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
return {
...settings,
customFields: settings.customFields ?? [],
checklistBeforeDone: !!settings.checklistBeforeDone,
transitions: settings.statuses.map((s) => ({
from: s.id,
to: settings.transitions[s.id] ?? [],
@@ -72,6 +75,9 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
: {}),
})),
areas: payload.areas,
...(payload.checklistBeforeDone != null
? { checklistBeforeDone: payload.checklistBeforeDone }
: {}),
...(payload.customFields
? {
customFields: payload.customFields.map((f) => ({

View File

@@ -772,6 +772,15 @@ export class ProjectSettingsInput {
@ValidateNested({ each: true })
@Type(() => CustomFieldDefinitionInput)
customFields?: CustomFieldDefinitionInput[];
@Field({
nullable: true,
description:
'Definition of done: DONE only with the checklist ticked. Absent: unchanged',
})
@IsOptional()
@IsBoolean()
checklistBeforeDone?: boolean;
}
@InputType({

View File

@@ -115,6 +115,11 @@ export class ProjectSettingsGql {
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
@Field(() => [CustomFieldDefinitionGql])
customFields!: CustomFieldDefinitionGql[];
@Field({
description:
'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)',
})
checklistBeforeDone!: boolean;
}
@ObjectType('Project')

View File

@@ -318,6 +318,14 @@ export class ProjectSettingsBodyDto {
@ValidateNested({ each: true })
@Type(() => CustomFieldDefinitionBodyDto)
customFields?: CustomFieldDefinitionBodyDto[];
@ApiPropertyOptional({
description:
'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged',
})
@IsOptional()
@IsBoolean()
checklistBeforeDone?: boolean;
}
export class AnalyticsQueryDto {

View File

@@ -103,6 +103,11 @@ export class ProjectSettingsDto {
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
@ApiProperty({ type: [CustomFieldDefinitionDto] })
customFields!: CustomFieldDefinitionDto[];
@ApiProperty({
description:
'Definition of done: tickets reach DONE only with their checklist ticked',
})
checklistBeforeDone!: boolean;
}
export class ProjectDto {