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 @@
+
+
+
+
+
+
+ info_outline
+ Summary
+
+
+
+
+ {{ includedObjects.length }}
+ Total objects included
+
+
+ {{ newObjectCount }}
+ New objects
+
+
+ {{ updatedMemberCount }}
+ Updated members
+
+
+ {{ unchangedObjectCount }}
+ Unchanged objects
+
+
+ {{ excludedCandidates.length }}
+ Candidates not included
+
+
+
+
+
+
+ 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..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 @@
+
+ warning_amber
+ Release is blocked by promotion conflicts.
+
Cancel
@@ -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;
}