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:
@@ -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 }>(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user