diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6cf29706..430dec39 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 { MembershipSectionComponent } from './components/stix/stix-page-tabs/membership-section/membership-section.component'; import { ReferenceSidebarComponent } from './components/resources-drawer/reference-sidebar/reference-sidebar.component'; @@ -268,6 +269,7 @@ export function initConfig(appConfigService: AppConfigService) { MembershipSectionComponent, ReferenceSidebarComponent, ReferenceEditDialogComponent, + ReleasePreviewDialogComponent, MultipleChoiceDialogComponent, ValidationResultsComponent, AddRelationshipButtonComponent, diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts index 7d915ca5..e4b246a7 100644 --- a/src/app/classes/release-tracks/api.ts +++ b/src/app/classes/release-tracks/api.ts @@ -34,6 +34,10 @@ export interface UpdateMetadataPayload { object_marking_refs?: string[]; } +export interface UpdateContentsPayload { + x_mitre_contents: string[]; +} + export type ReleasePayload = | { increment: 'major' | 'minor'; version?: never } | { increment?: never; version: string } 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..c12fa9a6 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html @@ -0,0 +1,257 @@ +
+
+
+

+ Release Preview: {{ trackName }} +

+

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

+
+ + +
+ + + + + + Summary + + +
+
+ {{ totalIncludedCount }} + Total objects included +
+
+ {{ newObjectCount }} + New objects +
+
+ {{ updatedMemberCount }} + Updated members +
+
+ {{ unchangedObjectCount }} + Unchanged objects +
+
+ {{ excludedCandidates.length }} + Candidates not included +
+
+ {{ removedObjectCount }} + Removed objects +
+
+ {{ quarantinedObjectCount }} + Quarantined objects +
+
+
+ + + + + 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..82fdf5f6 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts @@ -0,0 +1,226 @@ +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 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', + after: { members_count: 870, quarantine_count: 0 }, + changes: { + new_count: 30, + updated_count: 12, + removed_count: 10, + quarantined_count: 0, + }, + }; + + expect(component.isVirtualTrack).toBe(true); + expect(component.totalIncludedCount).toBe(870); + expect(component.newObjectCount).toBe(30); + expect(component.updatedMemberCount).toBe(12); + expect(component.unchangedObjectCount).toBe(828); + expect(component.removedObjectCount).toBe(10); + expect(component.quarantinedObjectCount).toBe(0); + }); + + 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 close without selecting a release version', () => { + component.close(); + + expect(dialogRef.close).toHaveBeenCalledWith(); + }); + + it('should block tagging when the backend reports release conflicts', () => { + data.conflicts = [{ object_ref: 'attack-pattern--member' }]; + fixture.detectChanges(); + + 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', () => { + expect(component.getObjectName({})).toBe('ATT&CK object'); + expect(component.getObjectVersion(null)).toBe('Not available'); + expect(component.getObjectType({})).toBe('STIX Object'); + expect( + component.getObjectType({ object_ref: 'course-of-action--123' }) + ).toBe('Course Of Action'); + expect(component.getWorkflowState({})).toBe('work-in-progress'); + }); + + it('should allow tagging when an object has an invalid version string', () => { + data.track.staged[0].x_mitre_version = 'invalid'; + + expect(component.hasInvalidVersionBumps).toBe(false); + component.tagVersion('minor'); + + expect(dialogRef.close).toHaveBeenCalledWith('minor'); + }); + + 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); + expect(component.isReleaseBlocked).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..0eecdd36 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -0,0 +1,294 @@ +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; + conflicts?: any[]; + proposedMinorVersion?: string; + previewSummary?: 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.1'; + + return this.formatVersion(version); + } + + public get minorVersion(): string { + if (this.data.proposedMinorVersion) { + return this.formatVersion(this.data.proposedMinorVersion); + } + + 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 isVirtualTrack(): boolean { + return this.data.previewSummary?.type === 'virtual'; + } + + public get totalIncludedCount(): number { + return this.isVirtualTrack + ? (this.data.previewSummary?.after?.members_count ?? this.members.length) + : this.includedObjects.length; + } + + 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 { + if (this.isVirtualTrack) { + return this.data.previewSummary?.changes?.new_count ?? 0; + } + + 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 { + if (this.isVirtualTrack) { + return this.data.previewSummary?.changes?.updated_count ?? 0; + } + + 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 { + if (this.isVirtualTrack) { + return Math.max( + this.totalIncludedCount - this.newObjectCount - this.updatedMemberCount, + 0 + ); + } + + return Math.max(this.members.length - this.updatedMemberCount, 0); + } + + public get removedObjectCount(): number { + return this.data.previewSummary?.changes?.removed_count ?? 0; + } + + public get quarantinedObjectCount(): number { + return this.data.previewSummary?.changes?.quarantined_count ?? 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 get hasPromotionConflicts(): boolean { + return !!this.data.conflicts?.length; + } + + public get isReleaseBlocked(): boolean { + return this.hasInvalidVersionBumps || this.hasPromotionConflicts; + } + + public close(): void { + this.dialogRef.close(); + } + + public tagVersion(type: 'minor' | 'major'): void { + if (this.isReleaseBlocked) { + 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; + } + + return new VersionNumber(incomingVersion).isDoubleIncrement( + new VersionNumber(currentVersion) + ); + } + + 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/services/connectors/rest-api/rest-api-connector.service.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.ts index 7126e636..98b56003 100644 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.ts +++ b/src/app/services/connectors/rest-api/rest-api-connector.service.ts @@ -527,6 +527,7 @@ export class RestApiConnectorService extends ApiConnector { revoked?: boolean; deprecated?: boolean; deserialize?: boolean; + versions?: 'all' | 'latest'; lastUpdatedBy?: string[]; search?: string; }) { @@ -550,6 +551,7 @@ export class RestApiConnectorService extends ApiConnector { 'includeDeprecated', options.deprecated ? 'true' : 'false' ); + if (options?.versions) query = query.set('versions', options.versions); // searching if (options?.search) query = query.set('search', options.search); // lastUpdatedBy 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 7353e6ad..9d0818a4 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 @@ -25,11 +25,7 @@

Create Draft local_see - 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 d4873c0f..56b2e2c5 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 @@ -9,13 +9,15 @@ import { createPaginatedResponse, } from 'src/app/testing/mocks/rest-api-connector.mock'; import { ActivatedRoute, Router } from '@angular/router'; -import { of } from 'rxjs'; +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'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; +import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { @@ -37,6 +39,7 @@ describe('ReleaseTrackPageComponent', () => { let mockRestApiConnector: any; let mockRouter: any; let mockAuthenticationService: any; + let mockSnackbar: any; beforeEach(async () => { mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ @@ -66,10 +69,11 @@ describe('ReleaseTrackPageComponent', () => { mockDialog = { open: vi.fn(), }; + mockSnackbar = { + open: vi.fn(), + }; mockRestApiConnector = { - getAllObjects: vi.fn(() => - createAsyncObservable(createPaginatedResponse([])) - ), + getAllObjects: vi.fn(() => of(createPaginatedResponse([]))), triggerBrowserDownload: vi.fn(), }; const mockBreadcrumbService = { @@ -102,6 +106,10 @@ describe('ReleaseTrackPageComponent', () => { provide: MatDialog, useValue: mockDialog, }, + { + provide: MatSnackBar, + useValue: mockSnackbar, + }, { provide: BreadcrumbService, useValue: mockBreadcrumbService, @@ -633,13 +641,25 @@ describe('ReleaseTrackPageComponent', () => { }) ); mockReleaseTrackApiConnector.releaseLatest.mockReturnValue(of({})); - mockDialog.open - .mockReturnValueOnce({ - afterClosed: () => of('minor'), - }) - .mockReturnValueOnce({ - afterClosed: () => of('release'), - }); + component.releaseTrack = { + id: 'release-track--123', + name: 'Core Objects', + version: null, + members: [], + staged: [], + candidates: [ + { + object_ref: 'attack-pattern--candidate', + name: 'Canonical Candidate', + attack_type: 'technique', + x_mitre_version: '2.1', + object_status: 'awaiting-review', + }, + ], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('minor'), + }); component.id = 'release-track--123'; component.onPreviewRelease(); @@ -648,11 +668,26 @@ describe('ReleaseTrackPageComponent', () => { 'release-track--123', { format: 'summary', increment: 'minor' } ); + expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ + revoked: true, + deprecated: true, + versions: 'all', + }); expect(mockDialog.open).toHaveBeenCalledWith( - MultipleChoiceDialogComponent, + ReleasePreviewDialogComponent, expect.objectContaining({ data: expect.objectContaining({ - title: 'Preview & release', + track: expect.objectContaining({ + id: 'release-track--123', + candidates: [ + expect.objectContaining({ + name: 'Canonical Candidate', + attack_type: 'technique', + x_mitre_version: '2.1', + object_status: 'awaiting-review', + }), + ], + }), }), }) ); @@ -693,25 +728,96 @@ describe('ReleaseTrackPageComponent', () => { }) ); mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(of({})); - mockDialog.open - .mockReturnValueOnce({ - afterClosed: () => of('major'), + 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: [], }) - .mockReturnValueOnce({ - afterClosed: () => of('release'), - }); + ); + 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', + }, + }, + ]) + ) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => of('major'), + }); component.id = 'release-track--123'; component.onTagSnapshot({ modified: '2026-07-23T13:37:28.000Z', isTagged: false, + snapshot: {}, } as any); expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( 'release-track--123', - { format: 'summary', increment: 'major' }, + { format: 'summary', increment: 'minor' }, '2026-07-23T13:37:28.000Z' ); + 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.releaseSnapshot).toHaveBeenCalledWith( 'release-track--123', '2026-07-23T13:37:28.000Z', @@ -722,6 +828,53 @@ describe('ReleaseTrackPageComponent', () => { expect(historySpy).toHaveBeenCalled(); }); + it('should preview the newest draft when multiple drafts exist', () => { + mockReleaseTrackApiConnector.previewRelease.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.previewRelease).toHaveBeenCalledWith( + 'release-track--123', + { format: 'summary', increment: 'minor' }, + '2026-07-30T14:00:00.000Z' + ); + 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.previewRelease.mockReturnValue( of({ @@ -736,26 +889,142 @@ describe('ReleaseTrackPageComponent', () => { ], }) ); - mockDialog.open - .mockReturnValueOnce({ - afterClosed: () => of('minor'), + component.releaseTrack = { + id: 'release-track--123', + members: [], + staged: [], + candidates: [], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); + component.id = 'release-track--123'; + + component.onPreviewRelease(); + + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + conflicts: expect.arrayContaining([ + expect.objectContaining({ + object_ref: 'attack-pattern--123', + }), + ]), + }), + }) + ); + expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + }); + + it('should not create a snapshot when the preview is cancelled', () => { + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ + version: '1.1', + conflicts: [], }) - .mockReturnValueOnce({}); + ); + component.releaseTrack = { + id: 'release-track--123', + members: [], + staged: [], + candidates: [], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); component.id = 'release-track--123'; component.onPreviewRelease(); expect(mockDialog.open).toHaveBeenCalledWith( - MultipleChoiceDialogComponent, + ReleasePreviewDialogComponent, expect.objectContaining({ data: expect.objectContaining({ - title: 'Release conflicts detected', + track: expect.objectContaining({ + id: 'release-track--123', + }), }), }) ); expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); }); + it('should stop releasing when the preview request fails', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + throwError(() => new Error('preview failed')) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + + component.onPreviewRelease(); + + expect(component.isReleasing).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load objects for release preview', + expect.any(Error) + ); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should notify the user when the preview response is empty', () => { + mockReleaseTrackApiConnector.previewRelease.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 = { + 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.previewRelease).not.toHaveBeenCalled(); + 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', () => { mockReleaseTrackApiConnector.getConfig.mockReturnValue( of({ 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 950b22c6..81239bc3 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, Observable, of } from 'rxjs'; import { finalize, map, take } from 'rxjs/operators'; @@ -32,9 +33,11 @@ import { SnapshotTierType, StixObjectRef, } from 'src/app/classes/release-tracks'; +import { StixObject } from 'src/app/classes/stix'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; @@ -51,6 +54,7 @@ import { } from 'src/app/utils/types'; import { ALL_OBJECTS_STIX_LIST_CONFIG } from 'src/app/views/stix/all-objects-page/all-objects-page.component'; +import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; type ReleaseTrackLaneType = 'candidate' | 'staged' | 'member'; @@ -143,9 +147,6 @@ const VIRTUAL_OBJECT_TYPE_OPTIONS: StixType[] = [ 'x-mitre-matrix', 'x-mitre-tactic', ]; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { StixObject } from 'src/app/classes/stix'; -import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; const VIRTUAL_DOMAIN_FILTER_OPTIONS = ['enterprise', 'ics', 'mobile']; @@ -204,10 +205,10 @@ 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, - private snackbar: MatSnackBar + private fb: FormBuilder ) { this.configForm = this.fb.group({ autoPromote: [true], @@ -471,6 +472,10 @@ export class ReleaseTrackPageComponent implements OnInit { return !!this.id && this.isVirtualReleaseTrack && !this.isCreatingDraft; } + private get latestDraftSnapshot(): SnapshotHistoryViewModel | undefined { + return this.snapshotHistory.find(snapshot => !snapshot.isTagged); + } + public get canEditReleaseTrack(): boolean { return this.authenticationService.canEdit(); } @@ -1546,60 +1551,65 @@ export class ReleaseTrackPageComponent implements OnInit { } public onPreviewRelease(): void { - this.chooseReleaseIncrement(); - } + if (!this.latestDraftSnapshot && this.releaseTrack?.version != null) { + this.openNoDraftSnapshotDialog(); + return; + } - public onTagSnapshot(item: SnapshotHistoryViewModel): void { - this.chooseReleaseIncrement(item); + this.previewRelease(this.latestDraftSnapshot); } - private chooseReleaseIncrement(item?: SnapshotHistoryViewModel): void { - if (!this.id || this.isReleasing) return; - - const selectionRef = this.dialog.open(MultipleChoiceDialogComponent, { - width: '34em', + private openNoDraftSnapshotDialog(): void { + this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', autoFocus: false, + restoreFocus: true, data: { - title: 'Choose a release version', + title: 'No draft snapshot available', description: - 'Use the next minor or major version for the release preview.', + 'The latest snapshot has already been released. Modify the release track to create a new draft before previewing another release.', choices: [ { - label: 'Next minor version', - value: 'minor', - }, - { - label: 'Next major version', - value: 'major', + label: 'Close', + value: 'close', }, ], }, }); + } - selectionRef - .afterClosed() - .pipe(take(1)) - .subscribe((increment: 'major' | 'minor' | undefined) => { - if (increment !== 'major' && increment !== 'minor') return; - this.previewRelease({ increment }, item); - }); + public onTagSnapshot(item: SnapshotHistoryViewModel): void { + this.previewRelease(item); } - private previewRelease( - selection: ReleasePayload, - item?: SnapshotHistoryViewModel - ): void { + private previewRelease(item?: SnapshotHistoryViewModel): void { + if (!this.id || this.isReleasing) return; + this.isReleasing = true; - const preview = item?.modified - ? this.connector.previewRelease( - this.id, - { format: ReleasePreviewFormat.Summary, ...selection }, - item.modified - ) - : this.connector.previewRelease(this.id, { - format: ReleasePreviewFormat.Summary, - ...selection, - }); + const selection: ReleasePayload = { increment: 'minor' }; + const preview = forkJoin({ + preview: item?.modified + ? this.connector.previewRelease( + this.id, + { format: ReleasePreviewFormat.Summary, ...selection }, + item.modified + ) + : this.connector.previewRelease(this.id, { + format: ReleasePreviewFormat.Summary, + ...selection, + }), + 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( @@ -1609,10 +1619,27 @@ export class ReleaseTrackPageComponent implements OnInit { }) ) .subscribe({ - next: preview => - this.openReleasePreviewDialog(preview, selection, item), + next: result => { + 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), + item + ); + }, error: err => { - console.error('Failed to preview release track release', err); + console.error('Failed to load objects for release preview', err); }, }); } @@ -2185,61 +2212,103 @@ export class ReleaseTrackPageComponent implements OnInit { private openReleasePreviewDialog( preview: any, - selection: ReleasePayload, + track: ReleaseTrackSnapshot, item?: SnapshotHistoryViewModel ): void { - const conflicts = this.getReleaseConflicts(preview); - - if (conflicts.length) { - this.dialog.open(MultipleChoiceDialogComponent, { - width: '34em', - autoFocus: false, - data: { - title: 'Release conflicts detected', - description: `${conflicts.length} conflict${ - conflicts.length === 1 ? '' : 's' - } must be resolved before this release can be tagged.`, - choices: [ - { - label: 'Close', - value: 'close', - description: - 'Review the staged and member object versions before retrying the release.', - }, - ], - }, - }); - return; - } - - const choices = [ - { - label: `Release${preview?.version ? ` (${preview.version})` : ''}`, - value: 'release', - description: this.getReleasePreviewDescription(preview), - }, - ]; - - const releaseRef = this.dialog.open(MultipleChoiceDialogComponent, { - width: '34em', + const releaseRef = this.dialog.open(ReleasePreviewDialogComponent, { + maxWidth: 'none', autoFocus: false, + restoreFocus: true, + ariaLabelledBy: 'release-preview-dialog-title', + panelClass: 'release-preview-dialog-panel', + backdropClass: 'release-preview-dialog-backdrop', data: { - title: 'Preview & release', - description: - 'The release preview found no blocking conflicts. Confirm to tag the draft snapshot.', - choices, + track, + conflicts: this.getReleaseConflicts(preview), + proposedMinorVersion: preview?.version, + previewSummary: preview, }, }); releaseRef .afterClosed() .pipe(take(1)) - .subscribe((action: 'release' | undefined) => { - if (action !== 'release') return; - this.releaseSnapshot(selection, item); + .subscribe((action: 'major' | 'minor' | undefined) => { + if (action !== 'major' && action !== 'minor') return; + this.releaseSnapshot({ increment: action }, item); }); } + 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 releaseSnapshot( selection: ReleasePayload, item?: SnapshotHistoryViewModel @@ -2270,25 +2339,6 @@ export class ReleaseTrackPageComponent implements OnInit { return Array.isArray(preview?.conflicts) ? preview.conflicts : []; } - private getReleasePreviewDescription(preview: any): string { - if (preview?.type === ReleaseTrackType.Virtual) { - const changes = preview?.changes || {}; - return `${changes.new_count || 0} new, ${ - changes.updated_count || 0 - } updated, ${changes.removed_count || 0} removed, and ${ - changes.quarantined_count || 0 - } quarantined objects.`; - } - - const promoted = preview?.changes?.promoted_count || 0; - const candidates = preview?.after?.candidates_count || 0; - return `${promoted} staged object${ - promoted === 1 ? '' : 's' - } will become members. ${candidates} candidate object${ - candidates === 1 ? '' : 's' - } will remain outside the release.`; - } - private buildSnapshotHistory( snapshots: ReleaseTrackSnapshotHistoryItem[] ): SnapshotHistoryViewModel[] {