feat: release notes
Each release can carry notes in Markdown, written before or after it ships. A draft is generated from its finished tickets grouped by type, to edit and copy from the releases page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ 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 { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
@@ -72,6 +73,7 @@ export const COMMAND_HANDLERS = [
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
RankIssueCommandHandler,
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
|
||||
@@ -34,3 +34,4 @@ export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteReleaseNotesCommand } from './write-release-notes.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(WriteReleaseNotesCommand)
|
||||
export class WriteReleaseNotesCommandHandler
|
||||
implements ICommandHandler<WriteReleaseNotesCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: WriteReleaseNotesCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
release.writeNotes(command.actorId, command.notes);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Writes (or with null, removes) a release's notes. */
|
||||
export class WriteReleaseNotesCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
public readonly notes: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ProjectUpdatedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
@@ -191,6 +192,10 @@ export function describeEvent(
|
||||
return `${actor} actualizó la release ${ctx.releaseName}`;
|
||||
if (event instanceof ReleaseShippedEvent)
|
||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
||||
if (event instanceof ReleaseNotesWrittenEvent)
|
||||
return event.notes
|
||||
? `${actor} escribió las notas de la release ${ctx.releaseName}`
|
||||
: `${actor} quitó las notas de la release ${ctx.releaseName}`;
|
||||
if (event instanceof ReleaseArchivedEvent)
|
||||
return `${actor} archivó la release ${ctx.releaseName}`;
|
||||
const sprint = ctx.sprintName || 'un sprint';
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
@@ -77,6 +78,7 @@ import { versionOf } from './read-model-notifier';
|
||||
ReleaseUpdatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { IssueParentChangedEventHandler } from './issue-parent-changed.event-han
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
import { SprintEventHandler } from './sprint.event-handler';
|
||||
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
|
||||
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
@@ -69,6 +70,7 @@ export const EVENT_HANDLERS = [
|
||||
IssueChecklistEventHandler,
|
||||
IssueRankedEventHandler,
|
||||
SprintEventHandler,
|
||||
ReleaseNotesWrittenEventHandler,
|
||||
IssueSprintChangedEventHandler,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseNotesWrittenEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseNotesWrittenEvent)
|
||||
export class ReleaseNotesWrittenEventHandler
|
||||
implements IEventHandler<ReleaseNotesWrittenEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseNotesWrittenEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
notes: event.notes,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.release(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { draftReleaseNotes } from '../releases/release-notes';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
/** A Markdown draft of a release's notes, from its finished tickets. */
|
||||
export class GetReleaseNotesDraftQuery extends Query<string> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetReleaseNotesDraftQuery)
|
||||
export class GetReleaseNotesDraftQueryHandler
|
||||
implements IQueryHandler<GetReleaseNotesDraftQuery, string>
|
||||
{
|
||||
constructor(
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReleaseNotesDraftQuery): Promise<string> {
|
||||
const release = this.access.assertFound(
|
||||
await this.findReleases.findById(query.releaseId),
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||
entity: 'Release',
|
||||
ref: query.releaseId,
|
||||
});
|
||||
const [page, project] = await Promise.all([
|
||||
this.findIssues.search({
|
||||
projectId: release.projectId,
|
||||
releaseId: release.id,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
}),
|
||||
this.findProjects.findById(release.projectId),
|
||||
]);
|
||||
return draftReleaseNotes({
|
||||
release,
|
||||
issues: page.items,
|
||||
workflow: new ProjectWorkflow(
|
||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export * from './get-public-projects.query';
|
||||
export * from './get-attachment.query';
|
||||
export * from './get-epics.query';
|
||||
export * from './sprint.queries';
|
||||
export * from './get-release-notes-draft.query';
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-h
|
||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
|
||||
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
@@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [
|
||||
SearchIssuesQueryHandler,
|
||||
GetEpicsQueryHandler,
|
||||
...SPRINT_QUERY_HANDLERS,
|
||||
GetReleaseNotesDraftQueryHandler,
|
||||
SearchMyIssuesQueryHandler,
|
||||
GetAttachmentQueryHandler,
|
||||
];
|
||||
|
||||
@@ -10,6 +10,8 @@ import { CommandResult } from './commands/command-result';
|
||||
import { CreateReleaseCommand } from './commands/create-release.command';
|
||||
import { PublishReleaseCommand } from './commands/publish-release.command';
|
||||
import { UpdateReleaseCommand } from './commands/update-release.command';
|
||||
import { WriteReleaseNotesCommand } from './commands/write-release-notes.command';
|
||||
import { GetReleaseNotesDraftQuery } from './queries/get-release-notes-draft.query';
|
||||
import { GetReleaseProgressQuery } from './queries/get-release-progress.query';
|
||||
import { GetReleaseQuery } from './queries/get-release.query';
|
||||
import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query';
|
||||
@@ -82,6 +84,24 @@ export class ReleasesService {
|
||||
return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit));
|
||||
}
|
||||
|
||||
/** Writes the notes (null removes them); also after the release ships. */
|
||||
writeNotes(
|
||||
actorId: string,
|
||||
releaseId: string,
|
||||
notes: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new WriteReleaseNotesCommand(actorId, releaseId, notes),
|
||||
);
|
||||
}
|
||||
|
||||
/** A Markdown draft from the release's finished tickets (not saved). */
|
||||
notesDraft(viewerId: string | null, releaseId: string): Promise<string> {
|
||||
return this.queryBus.execute(
|
||||
new GetReleaseNotesDraftQuery(viewerId, releaseId),
|
||||
);
|
||||
}
|
||||
|
||||
progress(
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
IssueReadModel,
|
||||
ProjectWorkflow,
|
||||
ReleaseReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { draftReleaseNotes } from './release-notes';
|
||||
|
||||
const issue = (
|
||||
number: number,
|
||||
type: string,
|
||||
title: string,
|
||||
statusCategory = StatusCategory.DONE,
|
||||
) =>
|
||||
({
|
||||
number,
|
||||
key: `WEB-${number}`,
|
||||
type,
|
||||
title,
|
||||
statusCategory,
|
||||
}) as IssueReadModel;
|
||||
|
||||
describe('release notes draft', () => {
|
||||
it('lists finished tickets by type, news first, QA left out', () => {
|
||||
const notes = draftReleaseNotes({
|
||||
release: {
|
||||
name: 'v1.4.0',
|
||||
description: 'Pagos.',
|
||||
targetDate: '2026-11-14',
|
||||
releasedAt: null,
|
||||
} as ReleaseReadModel,
|
||||
issues: [
|
||||
issue(3, 'BUG', 'El botón de pagar no responde en Safari'),
|
||||
issue(1, 'FEATURE', 'Pago con tarjeta'),
|
||||
issue(2, 'QA', 'Plan de pruebas de pagos'),
|
||||
issue(4, 'FEATURE', 'Recibo por correo', StatusCategory.IN_PROGRESS),
|
||||
],
|
||||
workflow: new ProjectWorkflow(defaultProjectSettings()),
|
||||
});
|
||||
expect(notes).toBe(
|
||||
[
|
||||
'## v1.4.0 — 14 de noviembre de 2026',
|
||||
'',
|
||||
'Pagos.',
|
||||
'',
|
||||
'### Correcciones',
|
||||
'',
|
||||
'- El botón de pagar no responde en Safari (WEB-3)',
|
||||
'',
|
||||
'### Novedades',
|
||||
'',
|
||||
'- Pago con tarjeta (WEB-1)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
IssueReadModel,
|
||||
ProjectWorkflow,
|
||||
ReleaseReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** How the default types are told in release notes; others use their name. */
|
||||
const HEADING: Record<string, string> = {
|
||||
FEATURE: 'Novedades',
|
||||
UI_CHANGE: 'Cambios en la interfaz',
|
||||
BUG: 'Correcciones',
|
||||
TASK: 'Mejoras internas',
|
||||
};
|
||||
/** Verification work is not news for whoever reads the notes. */
|
||||
const LEFT_OUT = new Set(['QA']);
|
||||
|
||||
/**
|
||||
* A first draft of a release's notes, in Markdown: its finished tickets
|
||||
* grouped by type (in the project's order of types), newest version header
|
||||
* first. Someone edits it before publishing it; it is never saved by itself.
|
||||
*/
|
||||
export function draftReleaseNotes(input: {
|
||||
release: ReleaseReadModel;
|
||||
issues: IssueReadModel[];
|
||||
workflow: ProjectWorkflow;
|
||||
}): string {
|
||||
const { release, issues, workflow } = input;
|
||||
const done = issues.filter(
|
||||
(i) => i.statusCategory === StatusCategory.DONE && !LEFT_OUT.has(i.type),
|
||||
);
|
||||
const day = release.releasedAt ?? release.targetDate;
|
||||
const date = day
|
||||
? ` — ${new Date(day).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}`
|
||||
: '';
|
||||
const lines = [`## ${release.name}${date}`];
|
||||
if (release.description) lines.push('', release.description);
|
||||
const order = workflow.settings.issueTypes.map((t) => t.id);
|
||||
const types = [...new Set(done.map((i) => i.type))].sort(
|
||||
(a, b) =>
|
||||
(order.indexOf(a) + 1 || order.length + 1) -
|
||||
(order.indexOf(b) + 1 || order.length + 1),
|
||||
);
|
||||
for (const type of types) {
|
||||
lines.push('', `### ${HEADING[type] ?? workflow.typeName(type)}`, '');
|
||||
for (const issue of done
|
||||
.filter((i) => i.type === type)
|
||||
.sort((a, b) => a.number - b.number))
|
||||
lines.push(`- ${issue.title} (${issue.key})`);
|
||||
}
|
||||
if (!done.length)
|
||||
lines.push('', '_Aún no hay tickets terminados en esta release._');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -37,3 +37,4 @@ export * from './sprint-started.event';
|
||||
export * from './sprint-closed.event';
|
||||
export * from './sprint-deleted.event';
|
||||
export * from './issue-sprint-changed.event';
|
||||
export * from './release-notes-written.event';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** The release notes were written or rewritten (null: removed). */
|
||||
@AutowiredEvent
|
||||
export class ReleaseNotesWrittenEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
releaseId: string,
|
||||
projectId: string,
|
||||
readonly notes: string | null,
|
||||
) {
|
||||
super(actorId, releaseId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export class ReleaseReadModel {
|
||||
targetDate!: string | null;
|
||||
status!: ReleaseStatus;
|
||||
releasedAt!: string | null;
|
||||
/** Markdown, for people. */
|
||||
notes!: string | null;
|
||||
createdAt!: string;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ describe('Release', () => {
|
||||
expect(release.status).toBe(ReleaseStatus.RELEASED);
|
||||
});
|
||||
|
||||
it('takes notes before and after shipping, not once archived', () => {
|
||||
const release = newRelease();
|
||||
release.writeNotes('u', ' ## Novedades ');
|
||||
release.ship('u', 0);
|
||||
release.writeNotes('u', '## Novedades\n- Pago con tarjeta');
|
||||
release.writeNotes('u', '## Novedades\n- Pago con tarjeta');
|
||||
expect(replay(release, Release).rebuilt.notes).toBe(
|
||||
'## Novedades\n- Pago con tarjeta',
|
||||
);
|
||||
release.archive('u');
|
||||
expect(() => release.writeNotes('u', null)).toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('is locked once shipped, also after being rebuilt', () => {
|
||||
const release = newRelease();
|
||||
release.ship('u', 0);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { ReleaseArchivedEvent } from '../events/release-archived.event';
|
||||
import { ReleaseCreatedEvent } from '../events/release-created.event';
|
||||
import { ReleaseDeletedEvent } from '../events/release-deleted.event';
|
||||
import { ReleaseNotesWrittenEvent } from '../events/release-notes-written.event';
|
||||
import { ReleaseShippedEvent } from '../events/release-shipped.event';
|
||||
import { ReleaseUpdatedEvent } from '../events/release-updated.event';
|
||||
import { ReleaseStatus } from './release-status';
|
||||
@@ -22,6 +23,8 @@ export interface ReleaseProps {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const MAX_RELEASE_NOTES = 20_000;
|
||||
|
||||
/** A version of the product: controls which tickets ship together. */
|
||||
export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
public projectId!: string;
|
||||
@@ -30,6 +33,8 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
public targetDate: string | null = null;
|
||||
public status: ReleaseStatus = ReleaseStatus.PLANNED;
|
||||
public releasedAt: string | null = null;
|
||||
/** What shipped, told for people (Markdown). */
|
||||
public notes: string | null = null;
|
||||
public createdAt!: string;
|
||||
public deleted = false;
|
||||
|
||||
@@ -109,6 +114,24 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the notes. Unlike the rest of the release they can be written
|
||||
* after it ships: that is usually when they are needed. Not once archived.
|
||||
*/
|
||||
writeNotes(actorId: string, notes: string | null): void {
|
||||
if (this.status === ReleaseStatus.ARCHIVED)
|
||||
throw new BusinessRuleError(`Release ${this.name} is archived`);
|
||||
const next = notes?.trim() || null;
|
||||
assertThat(
|
||||
(next?.length ?? 0) <= MAX_RELEASE_NOTES,
|
||||
`Release notes are at most ${MAX_RELEASE_NOTES} characters`,
|
||||
);
|
||||
if (next === this.notes) return;
|
||||
this.apply(
|
||||
new ReleaseNotesWrittenEvent(actorId, this.id, this.projectId, next),
|
||||
);
|
||||
}
|
||||
|
||||
archive(actorId: string): void {
|
||||
if (this.status === ReleaseStatus.ARCHIVED) return;
|
||||
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
|
||||
@@ -135,6 +158,10 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
this.releasedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void {
|
||||
this.notes = event.notes;
|
||||
}
|
||||
|
||||
onReleaseArchivedEvent(): void {
|
||||
this.status = ReleaseStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,29 @@ export class ReleaseResolver {
|
||||
return this.releases.progress(viewerOf(me), release.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => String, {
|
||||
description:
|
||||
'A Markdown draft of the notes from its finished tickets, by type (not saved)',
|
||||
})
|
||||
notesDraft(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() release: ReleaseReadModel,
|
||||
) {
|
||||
return this.releases.notesDraft(viewerOf(me), release.id);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Write the release notes (Markdown; null removes them). Also after shipping.',
|
||||
})
|
||||
writeReleaseNotes(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
@Args('notes', { type: () => String, nullable: true }) notes: string | null,
|
||||
) {
|
||||
return this.releases.writeNotes(me.id, releaseId, notes ?? null);
|
||||
}
|
||||
|
||||
@ResolveField(() => ReleaseBurnupGql, {
|
||||
description: 'Scope vs finished tickets, day by day',
|
||||
})
|
||||
|
||||
@@ -285,6 +285,11 @@ export class ReleaseGql {
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => ReleaseStatus) status!: ReleaseStatus;
|
||||
@Field(() => String, { nullable: true }) releasedAt!: string | null;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'What shipped, told for people (Markdown)',
|
||||
})
|
||||
notes!: string | null;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@@ -857,6 +857,19 @@ export class CreateReleaseDto {
|
||||
|
||||
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
||||
|
||||
export class ReleaseNotesDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
maxLength: 20000,
|
||||
description: 'Markdown; null removes the notes',
|
||||
})
|
||||
@ValidateIf((dto: ReleaseNotesDto) => dto.notes !== null)
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
notes!: string | null;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class CreateSprintDto {
|
||||
|
||||
@@ -331,6 +331,12 @@ export class ReleaseDto {
|
||||
status!: ReleaseStatus;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
releasedAt!: string | null;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'What shipped, told for people (Markdown)',
|
||||
})
|
||||
notes!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
@@ -370,6 +376,13 @@ export class EpicDto {
|
||||
@ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto;
|
||||
}
|
||||
|
||||
export class ReleaseNotesDraftDto {
|
||||
@ApiProperty({
|
||||
example: '## v1.4.0\n\n### Novedades\n\n- Pago con tarjeta (WEB-1)\n',
|
||||
})
|
||||
markdown!: string;
|
||||
}
|
||||
|
||||
export class ReleaseProgressDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() finished!: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
CreateReleaseDto,
|
||||
PageQueryDto,
|
||||
PublishReleaseDto,
|
||||
ReleaseNotesDto,
|
||||
UpcomingReleasesQueryDto,
|
||||
UpdateReleaseDto,
|
||||
} from './dto/requests.dto';
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
CommandResultDto,
|
||||
IssuePageDto,
|
||||
ReleaseDto,
|
||||
ReleaseNotesDraftDto,
|
||||
ReleaseBurnupDto,
|
||||
ReleaseProgressDto,
|
||||
} from './dto/responses.dto';
|
||||
@@ -194,6 +197,38 @@ export class ReleasesController {
|
||||
return this.releases.publish(me.id, releaseId, body.force);
|
||||
}
|
||||
|
||||
@Put('releases/:releaseId/notes')
|
||||
@ApiOperation({
|
||||
summary: 'Write the release notes (Markdown)',
|
||||
description: 'Also after the release ships; not once archived.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
writeNotes(
|
||||
@CurrentUser() me: User,
|
||||
@Param('releaseId') releaseId: string,
|
||||
@Body() body: ReleaseNotesDto,
|
||||
) {
|
||||
return this.releases.writeNotes(me.id, releaseId, body.notes);
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId/notes/draft')
|
||||
@ApiOptionalPireonAuth()
|
||||
@ApiOperation({
|
||||
summary: 'A Markdown draft of the notes from the finished tickets',
|
||||
description: 'Grouped by type; nothing is saved.',
|
||||
})
|
||||
@ApiOkResponse({ type: ReleaseNotesDraftDto })
|
||||
@ApiReadErrors()
|
||||
async notesDraft(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('releaseId') releaseId: string,
|
||||
) {
|
||||
return {
|
||||
markdown: await this.releases.notesDraft(viewerOf(me), releaseId),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('releases/:releaseId/archive')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' })
|
||||
|
||||
Reference in New Issue
Block a user