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:
2026-10-08 15:19:23 +02:00
parent 45a3dc1e16
commit 24b37b6628
40 changed files with 1251 additions and 31 deletions

View File

@@ -27,6 +27,10 @@ export const projectPageEvents = eventGroup({
releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
releaseNotesWriteRequested: type<{
releaseId: string;
notes: string | null;
}>(),
sprintCreateRequested: type<CreateSprintInput>(),
sprintUpdateRequested: type<UpdateSprintInput>(),
sprintStartRequested: type<{ sprintId: string }>(),

View File

@@ -64,6 +64,7 @@ function gateways() {
},
list: {
list: vi.fn(() => of([])),
writeNotes: vi.fn(() => of(ok)),
add: vi.fn(() => of({ ...ok, id: 'c1' })),
},
attachments: {
@@ -252,6 +253,17 @@ describe('ProjectFacade', () => {
expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null);
});
it('writes the notes of a release', () => {
const { project, fakes } = setup();
project.open('WEB');
project.writeReleaseNotes('r1', ' ## Novedades ');
project.writeReleaseNotes('r1', ' ');
expect(fakes.list.writeNotes.mock.calls).toEqual([
['r1', '## Novedades'],
['r1', null],
]);
});
it('makes the project public and deletes it, then goes home', () => {
const { project, fakes, navigate, notifications } = setup();
project.open('WEB');

View File

@@ -5,9 +5,11 @@ import {
CreateSprintInput,
ProjectRole,
ProjectSettings,
ReleasesGateway,
SprintStatus,
UpdateSprintInput,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -19,6 +21,7 @@ import { ProjectStore } from '../stores/project.store';
export class ProjectFacade {
private readonly store = inject(ProjectStore);
private readonly dispatch = injectDispatch(projectPageEvents);
private readonly releasesGateway = inject(ReleasesGateway);
readonly project = this.store.project;
readonly members = this.store.members;
@@ -128,6 +131,19 @@ export class ProjectFacade {
this.dispatch.releasePublishRequested({ releaseId, force });
}
/** Writes the notes (null removes them); also after it ships. */
writeReleaseNotes(releaseId: string, notes: string | null): void {
this.dispatch.releaseNotesWriteRequested({
releaseId,
notes: notes?.trim() || null,
});
}
/** A Markdown draft from the release's finished tickets, to start from. */
releaseNotesDraft(releaseId: string): Observable<string> {
return this.releasesGateway.notesDraft(releaseId);
}
archiveRelease(releaseId: string): void {
this.dispatch.releaseArchiveRequested({ releaseId });
}

View File

@@ -505,6 +505,18 @@ export const ProjectStore = signalStore(
),
),
),
events
.on(projectPageEvents.releaseNotesWriteRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
releases.writeNotes(payload.releaseId, payload.notes),
payload.notes ? 'Notas guardadas' : 'Notas quitadas',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releaseArchiveRequested)
.pipe(