From ebbae4b3d26e909d292fd6752dcd3c9f61bcfae6 Mon Sep 17 00:00:00 2001 From: avwchapman Date: Mon, 27 Jul 2026 20:25:39 -0400 Subject: [PATCH 1/8] feat(release-tracks): add release preview and version tagging --- src/app/app.module.ts | 2 + .../release-preview-dialog.component.html | 236 ++++++++++ .../release-preview-dialog.component.scss | 408 ++++++++++++++++++ .../release-preview-dialog.component.spec.ts | 154 +++++++ .../release-preview-dialog.component.ts | 250 +++++++++++ .../release-track-page.component.html | 2 +- .../release-track-page.component.spec.ts | 94 ++-- .../release-track-page.component.ts | 153 ++++--- 8 files changed, 1193 insertions(+), 106 deletions(-) create mode 100644 src/app/components/release-preview-dialog/release-preview-dialog.component.html create mode 100644 src/app/components/release-preview-dialog/release-preview-dialog.component.scss create mode 100644 src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts create mode 100644 src/app/components/release-preview-dialog/release-preview-dialog.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 580b43ea..3e706d83 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -75,6 +75,7 @@ import { MarkdownViewDialogComponent } from './components/markdown-view-dialog/m import { MultipleChoiceDialogComponent } from './components/multiple-choice-dialog/multiple-choice-dialog.component'; import { NavigationComponent } from './components/navigation/navigation.component'; import { ReferenceEditDialogComponent } from './components/reference-edit-dialog/reference-edit-dialog.component'; +import { ReleasePreviewDialogComponent } from './components/release-preview-dialog/release-preview-dialog.component'; import { HistoryTimelineComponent } from './components/stix/stix-page-tabs/history-timeline/history-timeline.component'; import { ReferenceSidebarComponent } from './components/resources-drawer/reference-sidebar/reference-sidebar.component'; import { ResourcesDrawerComponent } from './components/resources-drawer/resources-drawer.component'; @@ -266,6 +267,7 @@ export function initConfig(appConfigService: AppConfigService) { HistoryTimelineComponent, ReferenceSidebarComponent, ReferenceEditDialogComponent, + ReleasePreviewDialogComponent, MultipleChoiceDialogComponent, ValidationResultsComponent, AddRelationshipButtonComponent, diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.html b/src/app/components/release-preview-dialog/release-preview-dialog.component.html new file mode 100644 index 00000000..4d95e918 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html @@ -0,0 +1,236 @@ +
+
+
+

+ Release Preview: {{ trackName }} +

+

+ Current: {{ currentVersion }} + Minor Bump: {{ minorVersion }} + Major Bump: {{ majorVersion }} +

+
+ + +
+ + + + + + Summary + + +
+
+ {{ includedObjects.length }} + Total objects included +
+
+ {{ newObjectCount }} + New objects +
+
+ {{ updatedMemberCount }} + Updated members +
+
+ {{ unchangedObjectCount }} + Unchanged objects +
+
+ {{ excludedCandidates.length }} + Candidates not included +
+
+
+ + + + + Included ({{ includedObjects.length }}) + + +
+

+ No members or staged objects will be included. +

+
+ + + + + + + + + + + + + + + + + +
Name / IDTypeVersionSource
+ {{ getObjectName(item.object) }} + + {{ item.object.attack_id || item.object.object_ref }} + + {{ getObjectType(item.object) }}{{ getObjectVersion(item.object) }} + + {{ getIncludedSource(item) }} + +
+
+
+
+ + + + + Excluded ({{ excludedCandidates.length }}) + + +
+

+ No candidates are excluded from this snapshot. +

+
+ + + + + + + + + + + + + +
Name / IDWorkflow State
+ {{ getObjectName(item) }} + {{ item.attack_id || item.object_ref }} + + + {{ getWorkflowState(item) }} + +
+
+
+
+ + + + + Replacements ({{ replacements.length }}) + + +
+

+ No existing members will be replaced. +

+
+ + + + + + + + + + + + + + + +
Name / IDCurrent Member VersionIncoming Staged Version
+ {{ getObjectName(item.object) }} + + {{ item.object.attack_id || item.object.object_ref }} + + {{ getObjectVersion(item.currentMember) }} + + {{ getObjectVersion(item.incomingStaged) }} + + + warning_amber + +
+
+
+
+
+ +
+ + +
+ + + +
+
+
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss new file mode 100644 index 00000000..fd4ae570 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss @@ -0,0 +1,408 @@ +@use '../../../style/colors'; +@use '../../../style/typography'; + +.release-preview-dialog-backdrop { + background: rgba(colors.color(mitre-black), 0.76); +} + +.release-preview-dialog-panel .mat-mdc-dialog-surface { + border: 1px solid; + border-radius: 10px; + overflow: hidden; + @include colors.theme-border-color; +} + +.release-preview-dialog { + display: flex; + width: min(94vw, 1160px); + max-height: min(88vh, 820px); + flex-direction: column; + box-sizing: border-box; + + .release-preview-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 20px 24px 16px; + border-bottom: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.045), + rgba(colors.color(mitre-black), 0.025) + ); + + h2 { + margin: 0; + font-size: 22px; + font-weight: 800; + line-height: 28px; + } + + p { + margin: 4px 0 0; + font-size: 13px; + line-height: 20px; + @include colors.theme-text-deemphasis; + } + } + + .release-version-summary { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + font-family: typography.$mono-font; + + strong { + @include colors.theme-property( + color, + colors.color(mitre-light-blue), + colors.color(primary-dark) + ); + } + } + + .release-preview-tabs { + min-height: 0; + flex: 1 1 auto; + + .mat-mdc-tab-header { + border-bottom: 1px solid; + @include colors.theme-border-color; + } + + .mat-mdc-tab-labels { + padding: 0 24px; + } + + .mdc-tab { + min-width: 150px; + } + + .mdc-tab__text-label { + display: inline-flex; + align-items: center; + gap: 12px; + } + + .mat-icon { + width: 19px; + height: 19px; + font-size: 19px; + line-height: 19px; + } + + .mat-mdc-tab-body-wrapper { + min-height: 0; + height: min(55vh, 520px); + } + + .mat-mdc-tab-body-content { + box-sizing: border-box; + overflow: auto; + } + } + + .release-summary { + display: grid; + width: min(760px, calc(100% - 48px)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px 28px; + margin: 28px auto; + } + + .release-stat { + display: flex; + min-height: 132px; + align-items: center; + justify-content: center; + flex-direction: column; + box-sizing: border-box; + gap: 8px; + padding: 20px; + border: 1px solid; + border-radius: 8px; + text-align: center; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.035), + rgba(colors.color(mitre-black), 0.025) + ); + + strong { + font-size: 52px; + font-weight: 800; + line-height: 56px; + } + + span { + font-size: 15px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 20px; + text-transform: uppercase; + @include colors.theme-text-emphasis; + } + } + + .release-stat--included strong { + color: colors.color(success); + } + + .release-stat--excluded strong { + color: colors.color(error); + } + + .release-stat--new strong { + @include colors.theme-property( + color, + colors.color(mitre-light-blue), + colors.color(primary-dark) + ); + } + + .release-stat--updated strong { + color: colors.color(warn); + } + + .release-stat--unchanged strong { + color: #c34ddd; + } + + .release-object-list { + display: grid; + gap: 10px; + width: calc(100% - 48px); + margin: 24px auto; + } + + .release-object-empty { + box-sizing: border-box; + border: 1px solid; + border-radius: 6px; + @include colors.theme-border-color; + } + + .release-table-wrapper { + overflow: auto; + border: 1px solid; + border-radius: 8px; + @include colors.theme-border-color; + } + + .release-preview-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + box-sizing: border-box; + padding: 18px 26px; + border-bottom: 1px solid; + text-align: left; + vertical-align: middle; + @include colors.theme-border-color; + } + + th { + font-size: 13px; + font-weight: 800; + line-height: 18px; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.07), + rgba(colors.color(mitre-black), 0.055) + ); + } + + td { + height: 76px; + font-size: 13px; + line-height: 18px; + @include colors.theme-text-deemphasis; + + strong { + display: inline-block; + @include colors.theme-text-emphasis; + } + + small { + display: block; + margin-top: 2px; + font-family: typography.$mono-font; + @include colors.theme-text-deemphasis; + } + } + + tbody tr:last-child td { + border-bottom: 0; + } + + th:first-child, + td:first-child { + width: 43%; + } + } + + .release-preview-table--excluded { + th:first-child, + td:first-child { + width: 60%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 40%; + } + } + + .release-preview-table--replacements { + th, + td { + width: 34%; + } + } + + .source-chip, + .workflow-chip { + display: inline-flex; + padding: 3px 8px; + border: 1px solid; + border-radius: 5px; + font-family: typography.$mono-font; + font-size: 11px; + font-weight: 700; + line-height: 16px; + } + + .source-chip { + border-color: rgba(#c34ddd, 0.45); + background: rgba(#c34ddd, 0.13); + color: #c34ddd; + } + + .source-chip--staged { + border-color: rgba(colors.color(success), 0.45); + background: rgba(colors.color(success), 0.13); + color: colors.color(success); + } + + .workflow-chip { + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.06), + rgba(colors.color(mitre-black), 0.045) + ); + @include colors.theme-text-emphasis; + } + + .release-table-row--invalid { + background: rgba(colors.color(error), 0.12); + } + + .incoming-version { + color: colors.color(success) !important; + font-family: typography.$mono-font; + } + + .incoming-version--invalid, + .invalid-version-icon { + color: colors.color(error) !important; + } + + .invalid-version-icon { + width: 16px; + height: 16px; + margin-left: 4px; + font-size: 16px; + line-height: 16px; + vertical-align: middle; + } + + .release-object-empty { + margin: 0; + padding: 28px; + text-align: center; + @include colors.theme-text-deemphasis; + } + + .release-preview-footer { + display: flex; + min-height: 72px; + align-items: center; + justify-content: space-between; + gap: 20px; + box-sizing: border-box; + padding: 14px 20px; + border-top: 1px solid; + @include colors.theme-border-color; + } + + .release-blocked-message { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + margin: 0; + color: colors.color(error); + font-size: 13px; + line-height: 20px; + + .mat-icon { + flex: 0 0 auto; + } + } + + .release-preview-actions { + display: flex; + flex: 0 0 auto; + gap: 12px; + margin-left: auto; + } +} + +@media (max-width: 720px) { + .release-preview-dialog { + width: 96vw; + max-height: 92vh; + + .release-preview-header { + padding: 16px; + } + + .release-preview-tabs .mat-mdc-tab-labels { + padding: 0; + } + + .release-summary { + width: calc(100% - 32px); + grid-template-columns: 1fr; + gap: 12px; + margin: 16px auto; + } + + .release-stat { + min-height: 108px; + } + + .release-preview-footer { + align-items: stretch; + flex-direction: column; + } + + .release-preview-actions { + width: 100%; + flex-wrap: wrap; + margin-left: 0; + + button { + flex: 1 1 auto; + } + } + } +} diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts new file mode 100644 index 00000000..d8e05c3f --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts @@ -0,0 +1,154 @@ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { vi } from 'vitest'; + +import { + ReleasePreviewDialogComponent, + ReleasePreviewDialogData, +} from './release-preview-dialog.component'; + +describe('ReleasePreviewDialogComponent', () => { + let component: ReleasePreviewDialogComponent; + let fixture: ComponentFixture; + let dialogRef: { close: ReturnType }; + let data: ReleasePreviewDialogData; + + beforeEach(async () => { + dialogRef = { close: vi.fn() }; + data = { + track: { + name: 'Core Objects', + members: [ + { + object_ref: 'attack-pattern--member', + attack_id: 'T0001', + name: 'Existing Member', + x_mitre_version: '1.0', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--member', + attack_id: 'T0001', + name: 'Updated Member', + x_mitre_version: '1.1', + }, + { + object_ref: 'attack-pattern--new', + attack_id: 'T0002', + name: 'New Object', + x_mitre_version: '1.0', + }, + ], + candidates: [ + { + object_ref: 'attack-pattern--candidate', + attack_id: 'T0003', + name: 'Candidate', + object_status: 'work-in-progress', + }, + ], + }, + }; + + await TestBed.configureTestingModule({ + declarations: [ReleasePreviewDialogComponent], + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatTabsModule, + NoopAnimationsModule, + ], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: data }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ReleasePreviewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should calculate the draft snapshot summary from track contents', () => { + expect(component.includedObjects).toHaveLength(2); + expect(component.newObjectCount).toBe(1); + expect(component.updatedMemberCount).toBe(1); + expect(component.unchangedObjectCount).toBe(0); + expect(component.excludedCandidates).toHaveLength(1); + }); + + it('should display the canonical ATT&CK object type', () => { + data.track.staged[0].attack_type = 'technique'; + + expect(component.getObjectType(data.track.staged[0])).toBe('Technique'); + }); + + it('should return the selected version bump when all bumps are valid', () => { + component.tagVersion('minor'); + expect(dialogRef.close).toHaveBeenCalledWith('minor'); + + component.tagVersion('major'); + expect(dialogRef.close).toHaveBeenCalledWith('major'); + }); + + it('should render the objects represented by the Included and Excluded counts', () => { + const element: HTMLElement = fixture.nativeElement; + const tabs = Array.from( + element.querySelectorAll('[role="tab"]') + ); + const includedTab = tabs.find(tab => tab.textContent?.includes('Included')); + const excludedTab = tabs.find(tab => tab.textContent?.includes('Excluded')); + + includedTab?.click(); + fixture.detectChanges(); + + expect(element.textContent).toContain('Updated Member'); + expect(element.textContent).toContain('New Object'); + + excludedTab?.click(); + fixture.detectChanges(); + + expect(element.textContent).toContain('Candidate'); + expect( + element.querySelectorAll('.release-preview-table--excluded thead th') + .length + ).toBe(2); + }); + + it('should highlight and block an invalid incoming version bump', () => { + data.track.staged[0].x_mitre_version = '1.3'; + fixture.detectChanges(); + + expect(component.hasInvalidVersionBumps).toBe(true); + component.tagVersion('minor'); + expect(dialogRef.close).not.toHaveBeenCalled(); + + const element: HTMLElement = fixture.nativeElement; + const replacementsTab = Array.from( + element.querySelectorAll('[role="tab"]') + ).find(tab => tab.textContent?.includes('Replacements')); + replacementsTab?.click(); + fixture.detectChanges(); + + const invalidRow = element.querySelector( + '.release-table-row--invalid' + ); + const minorButton = element.querySelector( + '.release-preview-tag-minor-button' + ); + const majorButton = element.querySelector( + '.release-preview-tag-major-button' + ); + + expect(invalidRow).toBeTruthy(); + expect(minorButton?.disabled).toBe(true); + expect(majorButton?.disabled).toBe(true); + }); +}); diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts new file mode 100644 index 00000000..f1c17e72 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -0,0 +1,250 @@ +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { VersionNumber } from 'src/app/classes/version-number'; + +export interface ReleasePreviewDialogData { + track: any; +} + +interface ReleaseTrackObject { + object_ref?: string; + object_modified?: string; + attack_id?: string; + name?: string; + description?: string; + attack_type?: string; + version?: string; + x_mitre_version?: string; + stix?: { type?: string; x_mitre_version?: string }; + [key: string]: any; +} + +export interface IncludedReleaseObject { + object: ReleaseTrackObject; + currentMember: ReleaseTrackObject | null; + incomingStaged: ReleaseTrackObject | null; + invalidVersionBump: boolean; +} + +@Component({ + selector: 'app-release-preview-dialog', + templateUrl: './release-preview-dialog.component.html', + styleUrls: ['./release-preview-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleasePreviewDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ReleasePreviewDialogData + ) {} + + public get trackName(): string { + return this.data.track?.name || 'Release Track'; + } + + public get currentVersion(): string { + const history = this.asArray(this.data.track?.version_history); + const version = + this.data.track?.version ?? history[history.length - 1]?.version ?? '0.0'; + + return this.formatVersion(version); + } + + public get minorVersion(): string { + return this.formatVersion( + new VersionNumber(this.currentVersion.replace(/^v/i, '')) + .nextMinorVersion() + .toString() + ); + } + + public get majorVersion(): string { + return this.formatVersion( + new VersionNumber(this.currentVersion.replace(/^v/i, '')) + .nextMajorVersion() + .toString() + ); + } + + public get members(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.members); + } + + public get staged(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.staged); + } + + public get excludedCandidates(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.candidates); + } + + public get includedObjects(): IncludedReleaseObject[] { + const stagedByRef = new Map( + this.staged.map(item => [this.getObjectRef(item), item]) + ); + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + + const existingMembers = this.members.map(member => { + const incoming = stagedByRef.get(this.getObjectRef(member)) ?? null; + + return this.createIncludedObject(incoming ?? member, member, incoming); + }); + const newStaged = this.staged + .filter(item => !memberRefs.has(this.getObjectRef(item))) + .map(item => this.createIncludedObject(item, null, item)); + + return [...existingMembers, ...newStaged]; + } + + public get newObjectCount(): number { + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + return this.staged.filter(item => !memberRefs.has(this.getObjectRef(item))) + .length; + } + + public get updatedMemberCount(): number { + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + return this.staged.filter(item => memberRefs.has(this.getObjectRef(item))) + .length; + } + + public get unchangedObjectCount(): number { + return Math.max(this.members.length - this.updatedMemberCount, 0); + } + + public get replacements(): IncludedReleaseObject[] { + return this.includedObjects.filter( + item => !!item.currentMember && !!item.incomingStaged + ); + } + + public get hasInvalidVersionBumps(): boolean { + return this.includedObjects.some(item => item.invalidVersionBump); + } + + public close(): void { + this.dialogRef.close(); + } + + public tagVersion(type: 'minor' | 'major'): void { + if (this.hasInvalidVersionBumps) { + return; + } + + this.dialogRef.close(type); + } + + public getObjectName(item: ReleaseTrackObject): string { + return item?.name || item?.attack_id || item?.object_ref || 'ATT&CK object'; + } + + public getObjectVersion(item: ReleaseTrackObject | null): string { + const version = this.getRawVersion(item); + return version ? this.formatVersion(version) : 'Not available'; + } + + public getObjectType(item: ReleaseTrackObject): string { + const type = + item?.attack_type ?? + item?.type ?? + item?.stix?.type ?? + item?.object_ref?.split('--')[0] ?? + ''; + + return ( + String(type) + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()) || 'STIX Object' + ); + } + + public getIncludedSource(item: IncludedReleaseObject): string { + return item.incomingStaged ? 'staged' : 'members'; + } + + public getWorkflowState(item: ReleaseTrackObject): string { + return item?.object_status ?? item?.status ?? 'work-in-progress'; + } + + public trackByIncludedObject( + index: number, + item: IncludedReleaseObject + ): string | number { + return item.object?.object_ref || index; + } + + public trackByObject( + index: number, + item: ReleaseTrackObject + ): string | number { + return item?.object_ref || index; + } + + private createIncludedObject( + object: ReleaseTrackObject, + currentMember: ReleaseTrackObject | null, + incomingStaged: ReleaseTrackObject | null + ): IncludedReleaseObject { + return { + object, + currentMember, + incomingStaged, + invalidVersionBump: this.isInvalidVersionBump( + currentMember, + incomingStaged + ), + }; + } + + private isInvalidVersionBump( + currentMember: ReleaseTrackObject | null, + incomingStaged: ReleaseTrackObject | null + ): boolean { + const currentVersion = this.getRawVersion(currentMember); + const incomingVersion = this.getRawVersion(incomingStaged); + + if (!currentVersion || !incomingVersion) { + return false; + } + + try { + return new VersionNumber(incomingVersion).isDoubleIncrement( + new VersionNumber(currentVersion) + ); + } catch { + return false; + } + } + + private getRawVersion(item: ReleaseTrackObject | null): string | null { + const value = + item?.version ?? item?.x_mitre_version ?? item?.stix?.x_mitre_version; + + if (value === null || value === undefined || value === '') { + return null; + } + + return String(value).replace(/^v/i, ''); + } + + private formatVersion(value: unknown): string { + const version = String(value); + return version.toLowerCase().startsWith('v') ? version : `v${version}`; + } + + private getObjectRef(item: ReleaseTrackObject): string { + return String(item?.object_ref ?? ''); + } + + private asArray(value: unknown): ReleaseTrackObject[] { + return Array.isArray(value) ? value : []; + } +} diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index f8f2471d..76a43e42 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -28,7 +28,7 @@

- diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index cc433957..a7c3c46f 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -63,9 +63,7 @@ describe('ReleaseTrackPageComponent', () => { open: vi.fn(), }; mockRestApiConnector = { - getAllObjects: vi.fn(() => - createAsyncObservable(createPaginatedResponse([])) - ), + getAllObjects: vi.fn(() => of(createPaginatedResponse([]))), triggerBrowserDownload: vi.fn(), }; const mockBreadcrumbService = { @@ -646,6 +644,50 @@ describe('ReleaseTrackPageComponent', () => { conflicts: [], }) ); + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ + id: 'release-track--123', + members: [ + { + object_ref: 'malware--replacement', + object_modified: '2026-07-01T12:00:00.000Z', + }, + ], + staged: [ + { + object_ref: 'malware--replacement', + object_modified: '2026-07-23T12:00:00.000Z', + }, + ], + candidates: [], + }) + ); + mockRestApiConnector.getAllObjects.mockReturnValue( + of( + createPaginatedResponse([ + { + workspace: { attack_id: 'S0001' }, + stix: { + id: 'malware--replacement', + modified: '2026-07-01T12:00:00.000Z', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.0', + }, + }, + { + workspace: { attack_id: 'S0001' }, + stix: { + id: 'malware--replacement', + modified: '2026-07-23T12:00:00.000Z', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.1', + }, + }, + ]) + ) + ); mockReleaseTrackApiConnector.bumpByModified.mockReturnValue(of({})); mockDialog.open.mockReturnValue({ afterClosed: () => of('major'), @@ -655,12 +697,7 @@ describe('ReleaseTrackPageComponent', () => { component.onTagSnapshot({ modified: '2026-07-23T13:37:28.000Z', isTagged: false, - snapshot: { - id: 'release-track--123', - members: [], - staged: [], - candidates: [], - }, + snapshot: {}, } as any); expect(mockReleaseTrackApiConnector.previewBump).toHaveBeenCalledWith( @@ -669,6 +706,36 @@ describe('ReleaseTrackPageComponent', () => { '2026-07-23T13:37:28.000Z', 'minor' ); + expect( + mockReleaseTrackApiConnector.retrieveSnapshotByModified + ).toHaveBeenCalledWith('release-track--123', '2026-07-23T13:37:28.000Z', { + format: 'workbench', + include: 'all', + }); + expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ + revoked: true, + deprecated: true, + versions: 'all', + }); + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + track: expect.objectContaining({ + members: [ + expect.objectContaining({ + x_mitre_version: '1.0', + }), + ], + staged: [ + expect.objectContaining({ + x_mitre_version: '1.1', + }), + ], + }), + }), + }) + ); expect(mockReleaseTrackApiConnector.bumpByModified).toHaveBeenCalledWith( 'release-track--123', '2026-07-23T13:37:28.000Z', @@ -679,6 +746,54 @@ describe('ReleaseTrackPageComponent', () => { expect(historySpy).toHaveBeenCalled(); }); + it('should preview the newest draft when multiple drafts exist', () => { + mockReleaseTrackApiConnector.previewBump.mockReturnValue( + of({ version: '1.1', conflicts: [] }) + ); + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ + id: 'release-track--123', + modified: '2026-07-30T14:00:00.000Z', + members: [], + staged: [], + candidates: [], + }) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + component.snapshotHistory = [ + { + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + }, + { + modified: '2026-07-29T14:00:00.000Z', + isTagged: false, + }, + ] as any; + + component.onPreviewRelease(); + + expect(mockReleaseTrackApiConnector.previewBump).toHaveBeenCalledWith( + 'release-track--123', + 'summary', + '2026-07-30T14:00:00.000Z', + 'minor' + ); + expect( + mockReleaseTrackApiConnector.retrieveSnapshotByModified + ).toHaveBeenCalledWith('release-track--123', '2026-07-30T14:00:00.000Z', { + format: 'workbench', + include: 'all', + }); + }); + it('should not tag a release when preview returns conflicts', () => { mockReleaseTrackApiConnector.previewBump.mockReturnValue( of({ @@ -776,17 +891,34 @@ describe('ReleaseTrackPageComponent', () => { expect(mockDialog.open).not.toHaveBeenCalled(); }); - it('should not preview an already tagged latest snapshot', () => { + it('should keep Preview & Release enabled for a tagged snapshot', () => { component.id = 'release-track--123'; component.releaseTrack = { id: 'release-track--123', version: '1.0', } as any; + fixture.detectChanges(); + + const previewButton = Array.from( + fixture.nativeElement.querySelectorAll('button') + ).find((button: Element) => + button.textContent?.includes('Preview & Release') + ) as HTMLButtonElement; + + expect(previewButton).toBeTruthy(); + expect(previewButton.disabled).toBe(false); component.onPreviewRelease(); expect(mockReleaseTrackApiConnector.previewBump).not.toHaveBeenCalled(); - expect(component.canPreviewRelease).toBe(false); + expect(mockDialog.open).toHaveBeenCalledWith( + MultipleChoiceDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'No draft snapshot available', + }), + }) + ); }); it('should load release track config into the config form', () => { diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index 5185b18c..f1834ac2 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; import { finalize, take } from 'rxjs/operators'; import { ConflictPolicy, @@ -464,13 +465,8 @@ export class ReleaseTrackPageComponent implements OnInit { return !!this.id && this.isVirtualReleaseTrack && !this.isCreatingDraft; } - public get canPreviewRelease(): boolean { - return ( - !!this.id && - !!this.releaseTrack && - this.releaseTrack.version == null && - !this.isReleasing - ); + private get latestDraftSnapshot(): SnapshotHistoryViewModel | undefined { + return this.snapshotHistory.find(snapshot => !snapshot.isTagged); } public get canEditReleaseTrack(): boolean { @@ -1360,8 +1356,31 @@ export class ReleaseTrackPageComponent implements OnInit { } public onPreviewRelease(): void { - if (!this.canPreviewRelease) return; - this.previewRelease(); + if (!this.latestDraftSnapshot && this.releaseTrack?.version != null) { + this.openNoDraftSnapshotDialog(); + return; + } + + this.previewRelease(this.latestDraftSnapshot); + } + + private openNoDraftSnapshotDialog(): void { + this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + restoreFocus: true, + data: { + title: 'No draft snapshot available', + description: + 'The latest snapshot has already been released. Modify the release track to create a new draft before previewing another release.', + choices: [ + { + label: 'Close', + value: 'close', + }, + ], + }, + }); } public onTagSnapshot(item: SnapshotHistoryViewModel): void { @@ -1372,9 +1391,25 @@ export class ReleaseTrackPageComponent implements OnInit { if (!this.id || this.isReleasing) return; this.isReleasing = true; - const preview = item?.modified - ? this.connector.previewBump(this.id, 'summary', item.modified, 'minor') - : this.connector.previewBump(this.id, 'summary', undefined, 'minor'); + const preview = forkJoin({ + preview: this.connector.previewBump( + this.id, + 'summary', + item?.modified ?? undefined, + 'minor' + ), + track: item?.modified + ? this.connector.retrieveSnapshotByModified(this.id, item.modified, { + format: ExportFormat.Workbench, + include: 'all', + }) + : of(this.releaseTrack), + objects: this.restApiConnectorService.getAllObjects({ + revoked: true, + deprecated: true, + versions: 'all', + }), + }); preview .pipe( @@ -1384,9 +1419,13 @@ export class ReleaseTrackPageComponent implements OnInit { }) ) .subscribe({ - next: preview => { - if (!preview) return; - this.openReleasePreviewDialog(preview, item); + next: result => { + if (!result.preview || !result.track) return; + this.openReleasePreviewDialog( + result.preview, + this.enrichReleasePreviewTrack(result.track, result.objects), + item + ); }, error: err => { console.error('Failed to load objects for release preview', err); @@ -1746,11 +1785,9 @@ export class ReleaseTrackPageComponent implements OnInit { private openReleasePreviewDialog( preview: any, + track: ReleaseTrackSnapshot, item?: SnapshotHistoryViewModel ): void { - const track = item?.snapshot ?? this.releaseTrack; - if (!track) return; - const releaseRef = this.dialog.open(ReleasePreviewDialogComponent, { maxWidth: 'none', autoFocus: false, @@ -1775,6 +1812,76 @@ export class ReleaseTrackPageComponent implements OnInit { }); } + private enrichReleasePreviewTrack( + track: ReleaseTrackSnapshot, + response: any + ) { + const objects = Array.isArray(response) + ? response + : Array.isArray(response?.data) + ? response.data + : []; + const objectsByRevision = new Map(); + + objects.forEach((object: any) => { + const objectRef = object?.stix?.id ?? object?.stixID ?? object?.id; + const modified = + object?.stix?.modified ?? object?.modified ?? object?.object_modified; + if (objectRef && modified) { + objectsByRevision.set( + this.getReleasePreviewRevisionKey(objectRef, modified), + object + ); + } + }); + + const enrich = (entry: any) => { + const object = objectsByRevision.get( + this.getReleasePreviewRevisionKey( + entry?.object_ref, + entry?.object_modified + ) + ); + if (!object) return entry; + + const stix = object?.stix ?? object; + return { + ...entry, + name: object?.name ?? stix?.name ?? entry?.name, + attack_id: + object?.attackID ?? + object?.attack_id ?? + object?.workspace?.attack_id ?? + entry?.attack_id, + attack_type: + object?.attackType ?? + StixTypeToAttackType[stix?.type as StixType] ?? + entry?.attack_type, + type: stix?.type ?? entry?.type, + x_mitre_version: + object?.version?.toString?.() ?? + object?.version ?? + stix?.x_mitre_version ?? + entry?.x_mitre_version, + }; + }; + + return { + ...track, + members: (track.members ?? []).map(enrich), + staged: (track.staged ?? []).map(enrich), + candidates: (track.candidates ?? []).map(enrich), + } as ReleaseTrackSnapshot; + } + + private getReleasePreviewRevisionKey( + objectRef: unknown, + modified: unknown + ): string { + const timestamp = new Date(modified as any).getTime(); + return `${String(objectRef ?? '')}::${timestamp}`; + } + private bumpRelease( type: 'major' | 'minor', item?: SnapshotHistoryViewModel From b87d7266362c7a2261a47fb940803dbefed02804 Mon Sep 17 00:00:00 2001 From: avwchapman Date: Thu, 30 Jul 2026 14:05:59 -0400 Subject: [PATCH 6/8] fix(release-tracks): notify users when release preview fails --- .../release-track-page.component.spec.ts | 31 +++++++++++++++++++ .../release-track-page.component.ts | 14 ++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index a7c3c46f..f001196d 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -11,6 +11,7 @@ import { import { ActivatedRoute, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; @@ -38,6 +39,7 @@ describe('ReleaseTrackPageComponent', () => { let mockRestApiConnector: any; let mockRouter: any; let mockAuthenticationService: any; + let mockSnackbar: any; beforeEach(async () => { mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ @@ -62,6 +64,9 @@ describe('ReleaseTrackPageComponent', () => { mockDialog = { open: vi.fn(), }; + mockSnackbar = { + open: vi.fn(), + }; mockRestApiConnector = { getAllObjects: vi.fn(() => of(createPaginatedResponse([]))), triggerBrowserDownload: vi.fn(), @@ -96,6 +101,10 @@ describe('ReleaseTrackPageComponent', () => { provide: MatDialog, useValue: mockDialog, }, + { + provide: MatSnackBar, + useValue: mockSnackbar, + }, { provide: BreadcrumbService, useValue: mockBreadcrumbService, @@ -891,6 +900,28 @@ describe('ReleaseTrackPageComponent', () => { expect(mockDialog.open).not.toHaveBeenCalled(); }); + it('should notify the user when the preview response is empty', () => { + mockReleaseTrackApiConnector.previewBump.mockReturnValue(of(null)); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + + component.onPreviewRelease(); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to load the release preview. Please try again.', + null, + { + duration: 5000, + panelClass: 'error', + } + ); + expect(mockDialog.open).not.toHaveBeenCalled(); + expect(component.isReleasing).toBe(false); + }); + it('should keep Preview & Release enabled for a tagged snapshot', () => { component.id = 'release-track--123'; component.releaseTrack = { diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index f1834ac2..3c680ea1 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -2,6 +2,7 @@ import { SelectionModel } from '@angular/cdk/collections'; import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { finalize, take } from 'rxjs/operators'; @@ -199,6 +200,7 @@ export class ReleaseTrackPageComponent implements OnInit { private route: ActivatedRoute, private router: Router, private dialog: MatDialog, + private snackbar: MatSnackBar, private restApiConnectorService: RestApiConnectorService, private authenticationService: AuthenticationService, private fb: FormBuilder @@ -1420,7 +1422,17 @@ export class ReleaseTrackPageComponent implements OnInit { ) .subscribe({ next: result => { - if (!result.preview || !result.track) return; + if (!result.preview || !result.track) { + this.snackbar.open( + 'Unable to load the release preview. Please try again.', + null, + { + duration: 5000, + panelClass: 'error', + } + ); + return; + } this.openReleasePreviewDialog( result.preview, this.enrichReleasePreviewTrack(result.track, result.objects), From 1458dc40bae0605f429ed01686d85a1411ce37b1 Mon Sep 17 00:00:00 2001 From: avwchapman Date: Thu, 30 Jul 2026 14:08:10 -0400 Subject: [PATCH 7/8] fix(release-tracks): default initial preview version to 0.1 --- .../release-preview-dialog.component.spec.ts | 7 +++++++ .../release-preview-dialog.component.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts index c96c4565..e0a35824 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts @@ -84,6 +84,13 @@ describe('ReleasePreviewDialogComponent', () => { expect(component.excludedCandidates).toHaveLength(1); }); + it('should default an unversioned track to version 0.1', () => { + data.track.version = null; + data.track.version_history = []; + + expect(component.currentVersion).toBe('v0.1'); + }); + it('should use the backend diff when previewing a virtual release', () => { data.previewSummary = { type: 'virtual', diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts index 92d69a9a..884f6d8e 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -50,7 +50,7 @@ export class ReleasePreviewDialogComponent { public get currentVersion(): string { const history = this.asArray(this.data.track?.version_history); const version = - this.data.track?.version ?? history[history.length - 1]?.version ?? '0.0'; + this.data.track?.version ?? history[history.length - 1]?.version ?? '0.1'; return this.formatVersion(version); } From 204656995a2a80eaafc260502ac52a471e340052 Mon Sep 17 00:00:00 2001 From: avwchapman Date: Thu, 30 Jul 2026 20:26:40 -0400 Subject: [PATCH 8/8] fix(release-tracks): distinguish preview conflict messages --- .../release-preview-dialog.component.html | 11 +++++++++-- .../release-preview-dialog.component.spec.ts | 12 +++++++++++- .../release-preview-dialog.component.ts | 15 ++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.html b/src/app/components/release-preview-dialog/release-preview-dialog.component.html index 4ad3db08..c12fa9a6 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.html +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html @@ -224,6 +224,13 @@

Release is blocked by invalid incoming version bumps.

+
@@ -232,7 +239,7 @@

class="release-preview-tag-minor-button" type="button" color="primary" - [disabled]="hasInvalidVersionBumps" + [disabled]="isReleaseBlocked" (click)="tagVersion('minor')"> Tag Minor ({{ minorVersion }}) @@ -241,7 +248,7 @@

class="release-preview-tag-major-button" type="button" color="primary" - [disabled]="hasInvalidVersionBumps" + [disabled]="isReleaseBlocked" (click)="tagVersion('major')"> Tag Major ({{ majorVersion }}) diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts index e0a35824..82fdf5f6 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts @@ -134,11 +134,20 @@ describe('ReleasePreviewDialogComponent', () => { it('should block tagging when the backend reports release conflicts', () => { data.conflicts = [{ object_ref: 'attack-pattern--member' }]; + fixture.detectChanges(); - expect(component.hasInvalidVersionBumps).toBe(true); + expect(component.hasInvalidVersionBumps).toBe(false); + expect(component.hasPromotionConflicts).toBe(true); + expect(component.isReleaseBlocked).toBe(true); component.tagVersion('minor'); expect(dialogRef.close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + 'Release is blocked by promotion conflicts.' + ); + expect(fixture.nativeElement.textContent).not.toContain( + 'Release is blocked by invalid incoming version bumps.' + ); }); it('should provide fallbacks for incomplete object metadata', () => { @@ -189,6 +198,7 @@ describe('ReleasePreviewDialogComponent', () => { fixture.detectChanges(); expect(component.hasInvalidVersionBumps).toBe(true); + expect(component.isReleaseBlocked).toBe(true); component.tagVersion('minor'); expect(dialogRef.close).not.toHaveBeenCalled(); diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts index 884f6d8e..0eecdd36 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -167,10 +167,15 @@ export class ReleasePreviewDialogComponent { } public get hasInvalidVersionBumps(): boolean { - return ( - !!this.data.conflicts?.length || - this.includedObjects.some(item => item.invalidVersionBump) - ); + return this.includedObjects.some(item => item.invalidVersionBump); + } + + public get hasPromotionConflicts(): boolean { + return !!this.data.conflicts?.length; + } + + public get isReleaseBlocked(): boolean { + return this.hasInvalidVersionBumps || this.hasPromotionConflicts; } public close(): void { @@ -178,7 +183,7 @@ export class ReleasePreviewDialogComponent { } public tagVersion(type: 'minor' | 'major'): void { - if (this.hasInvalidVersionBumps) { + if (this.isReleaseBlocked) { return; }