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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -140,6 +140,7 @@ fragment ReleaseFields on Release {
|
||||
targetDate
|
||||
status
|
||||
releasedAt
|
||||
notes
|
||||
}
|
||||
|
||||
fragment ActivityFields on Activity {
|
||||
|
||||
@@ -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' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 - …"
|
||||
[(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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user