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 @@
+
+
+
+
+
+
+ info_outline
+ 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
+
+
+
+
+
+
+ verified
+ Included ({{ includedObjects.length }})
+
+
+
+
+ No members or staged objects will be included.
+
+
+
+
+
+ | Name / ID |
+ Type |
+ Version |
+ Source |
+
+
+
+
+ |
+ {{ getObjectName(item.object) }}
+
+ {{ item.object.attack_id || item.object.object_ref }}
+
+ |
+ {{ getObjectType(item.object) }} |
+ {{ getObjectVersion(item.object) }} |
+
+
+ {{ getIncludedSource(item) }}
+
+ |
+
+
+
+
+
+
+
+
+
+ cancel
+ Excluded ({{ excludedCandidates.length }})
+
+
+
+
+ No candidates are excluded from this snapshot.
+
+
+
+
+
+ | Name / ID |
+ Workflow State |
+
+
+
+
+ |
+ {{ getObjectName(item) }}
+ {{ item.attack_id || item.object_ref }}
+ |
+
+
+ {{ getWorkflowState(item) }}
+
+ |
+
+
+
+
+
+
+
+
+
+ swap_horiz
+ Replacements ({{ replacements.length }})
+
+
+
+
+ No existing members will be replaced.
+
+
+
+
+
+ | Name / ID |
+ Current Member Version |
+ Incoming 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
-