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(

View File

@@ -206,6 +206,8 @@ export interface Release {
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
/** What shipped, told for people (Markdown). */
notes: string | null;
progress: { total: number; finished: number; percent: number };
}

View File

@@ -175,6 +175,13 @@ export abstract class ReleasesGateway {
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
/** A Markdown draft from the release's finished tickets (not saved). */
abstract notesDraft(releaseId: string): Observable<string>;
/** null removes them. */
abstract writeNotes(
releaseId: string,
notes: string | null,
): Observable<CommandResult>;
}
export abstract class SprintsGateway {

View File

@@ -23,6 +23,8 @@ import {
IssueCommentsDocument,
PublishReleaseDocument,
ReleaseBurnupDocument,
ReleaseNotesDraftDocument,
WriteReleaseNotesDocument,
ReleasesDocument,
UpcomingReleasesDocument,
SearchUsersDocument,
@@ -98,6 +100,21 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
.query(ReleaseBurnupDocument, { id: releaseId })
.pipe(map((d) => d.release.burnup));
}
notesDraft(releaseId: string): Observable<string> {
return this.gql
.query(ReleaseNotesDraftDocument, { id: releaseId })
.pipe(map((d) => d.release.notesDraft));
}
writeNotes(
releaseId: string,
notes: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(WriteReleaseNotesDocument, { releaseId, notes })
.pipe(map((d) => toCommandResult(d.writeReleaseNotes)));
}
}
@Injectable()

View File

@@ -140,6 +140,7 @@ fragment ReleaseFields on Release {
targetDate
status
releasedAt
notes
}
fragment ActivityFields on Activity {

View File

@@ -368,6 +368,7 @@ export type ReleaseFieldsFragment = {
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
notes: string | null;
};
export type ActivityFieldsFragment = {
@@ -1148,6 +1149,7 @@ export type ReleasesQuery = {
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
notes: string | null;
progress: { total: number; finished: number; percent: number };
}>;
};
@@ -1208,6 +1210,7 @@ export type UpcomingReleasesQuery = {
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
notes: string | null;
progress: { total: number; finished: number; percent: number };
}>;
};
@@ -1229,6 +1232,23 @@ export type ReleaseBurnupQuery = {
};
};
export type ReleaseNotesDraftQueryVariables = Exact<{
id: string;
}>;
export type ReleaseNotesDraftQuery = {
release: { id: string; notesDraft: string };
};
export type WriteReleaseNotesMutationVariables = Exact<{
releaseId: string;
notes?: string | null | undefined;
}>;
export type WriteReleaseNotesMutation = {
writeReleaseNotes: { id: string; key: string | null; version: number };
};
export type SavedFilterFieldsFragment = {
id: string;
projectId: string;
@@ -1489,6 +1509,7 @@ export type ReleaseChangedSubscription = {
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
notes: string | null;
};
};
};
@@ -2119,6 +2140,7 @@ export const ReleaseFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
],
},
},
@@ -7298,6 +7320,7 @@ export const ReleasesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
],
},
},
@@ -7741,6 +7764,7 @@ export const UpcomingReleasesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
],
},
},
@@ -7814,6 +7838,141 @@ export const ReleaseBurnupDocument = {
},
],
} as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
export const ReleaseNotesDraftDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'ReleaseNotesDraft' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notesDraft' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
ReleaseNotesDraftQuery,
ReleaseNotesDraftQueryVariables
>;
export const WriteReleaseNotesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'WriteReleaseNotes' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'notes' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'writeReleaseNotes' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'releaseId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'notes' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'notes' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
WriteReleaseNotesMutation,
WriteReleaseNotesMutationVariables
>;
export const SavedFiltersDocument = {
kind: 'Document',
definitions: [
@@ -9123,6 +9282,7 @@ export const ReleaseChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
],
},
},

View File

@@ -59,3 +59,16 @@ query ReleaseBurnup($id: ID!) {
}
}
}
query ReleaseNotesDraft($id: ID!) {
release(id: $id) {
id
notesDraft
}
}
mutation WriteReleaseNotes($releaseId: ID!, $notes: String) {
writeReleaseNotes(releaseId: $releaseId, notes: $notes) {
...CommandResultFields
}
}

View File

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
TemplateRef,
@@ -15,7 +16,8 @@ import {
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
@@ -83,6 +85,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
{{ release.progress.percent }}%</span
>
</div>
@if (release.notes) {
<details class="ts-release__notes">
<summary>Notas de la versión</summary>
<pre class="ts-release__notes-text">{{ release.notes }}</pre>
</details>
}
<div class="ts-release__actions">
<a
class="ts-release__link"
@@ -91,6 +99,24 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
>Ver en el tablero →</a
>
<span class="ts-spacer"></span>
@if (release.notes) {
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Copiar notas"
(execute)="copyNotes(release)"
/>
}
@if (facade.canManage() && release.status !== 'ARCHIVED') {
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="release.notes ? 'Editar notas' : 'Escribir notas'"
(execute)="openNotes(release)"
/>
}
@if (facade.canManage() && release.status === 'PLANNED') {
<pi-action-button
size="small"
@@ -118,6 +144,39 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
}
</ol>
<ng-template #notesForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveNotes()"
novalidate
>
<p class="ts-muted">
Lo que trae la versión, contado para quien la usa (Markdown). El
borrador sale de los tickets terminados, agrupados por tipo.
</p>
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="drafting() ? 'Generando…' : 'Generar borrador'"
[disabled]="drafting()"
(execute)="draftNotes()"
/>
<pi-textarea
placeholder="## Novedades&#10;- …"
[(value)]="notes"
>
<pi-label>Notas</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Guardar notas"
(execute)="saveNotes()"
/>
</form>
</ng-template>
<ng-template #createForm>
<form
class="ts-form"
@@ -144,6 +203,20 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
</ng-template>
`,
styles: `
.ts-release__notes {
margin-top: 0.25rem;
summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--ts-muted);
}
}
.ts-release__notes-text {
margin: 0.5rem 0 0;
white-space: pre-wrap;
font: inherit;
font-size: 0.875rem;
}
/* A timeline: one node per version on a vertical rail. */
.ts-releases {
position: relative;
@@ -276,6 +349,15 @@ export class ReleasesPage {
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('notesForm');
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
/* Release notes */
private writing: Release | null = null;
protected readonly notes = signal('');
protected readonly drafting = signal(false);
private readonly model = signal({
name: '',
@@ -327,6 +409,52 @@ export class ReleasesPage {
this.facade.publishRelease(release.id);
}
protected openNotes(release: Release): void {
this.writing = release;
this.notes.set(release.notes ?? '');
this.drawer.open(`Notas de ${release.name}`, this.notesForm(), '40rem');
if (!release.notes) this.draftNotes();
}
protected draftNotes(): void {
if (!this.writing) return;
// Replacing what someone wrote needs a yes.
if (
this.notes().trim() &&
this.notes() !== this.writing.notes &&
!confirm('¿Sustituir lo escrito por el borrador?')
)
return;
this.drafting.set(true);
this.facade
.releaseNotesDraft(this.writing.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (draft) => {
this.notes.set(draft);
this.drafting.set(false);
},
error: () => {
this.drafting.set(false);
this.toast.error('No se pudo generar el borrador');
},
});
}
protected saveNotes(): void {
if (!this.writing) return;
this.facade.writeReleaseNotes(this.writing.id, this.notes());
this.writing = null;
this.drawer.closeAll();
}
protected copyNotes(release: Release): void {
navigator.clipboard
?.writeText(release.notes ?? '')
.then(() => this.toast.success('Notas copiadas'))
.catch(() => this.toast.error('No se pudieron copiar'));
}
protected archive(release: Release): void {
this.facade.archiveRelease(release.id);
}