Add multi-item Jira ticket creation from Ivanti Queue

Select multiple queue items and create a single consolidated Jira ticket
with aggregated summary and description. Adds multi-select mode with
checkboxes, floating action bar, consolidation modal, and junction table
to track which queue items contributed to each ticket.

- Migration: jira_ticket_queue_items junction table
- POST /api/jira-tickets/:id/queue-items endpoint
- GET /api/ivanti/todo-queue/ticket-links endpoint
- ConsolidationModal component with aggregation logic
- IvantiTodoQueuePage with selection mode and ticket link badges
- Pure utility functions for summary/description generation
- 34 tests passing (backend + frontend)
This commit is contained in:
Jordan Ramos
2026-05-22 11:12:45 -06:00
parent 704432788c
commit 6b805ee633
10 changed files with 2281 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
/**
* Pure utility functions for consolidating multiple Ivanti queue items
* into a single Jira ticket's summary, description, CVE, and vendor fields.
*/
/**
* Generate a consolidated summary for a multi-item Jira ticket.
* Format: "[N findings] vendor - first_finding_title", truncated to 255 chars.
*
* @param {Array} items - Array of queue item objects
* @returns {string} Generated summary, at most 255 characters
*/
export function generateConsolidatedSummary(items) {
const count = items.length;
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
const firstTitle = items[0]?.finding_title || 'Untitled';
const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
return raw.slice(0, 255);
}
/**
* Generate a structured description grouped by vendor for a consolidated Jira ticket.
*
* @param {Array} items - Array of queue item objects
* @returns {string} Structured description with header and vendor-grouped items
*/
export function generateConsolidatedDescription(items) {
const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
// Group by vendor
const grouped = {};
for (const item of items) {
const vendor = item.vendor || 'Unknown Vendor';
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(item);
}
let body = '';
for (const [vendor, vendorItems] of Object.entries(grouped)) {
body += `== ${vendor} ==\n`;
for (const item of vendorItems) {
let cves = 'None';
if (item.cves_json) {
try {
const parsed = JSON.parse(item.cves_json);
if (Array.isArray(parsed) && parsed.length > 0) {
cves = parsed.join(', ');
}
} catch (e) {
cves = 'None';
}
}
body += `- ${item.finding_title}\n`;
body += ` CVEs: ${cves}\n`;
body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
}
}
return header + body;
}
/**
* Extract the first CVE from the first item that has a non-empty cves_json array.
*
* @param {Array} items - Array of queue item objects
* @returns {string} First CVE ID found, or empty string if none
*/
export function extractFirstCve(items) {
for (const item of items) {
if (item.cves_json) {
try {
const cves = JSON.parse(item.cves_json);
if (Array.isArray(cves) && cves.length > 0) return cves[0];
} catch (e) {
// Skip items with invalid JSON
}
}
}
return '';
}
/**
* Extract the common vendor if all items share the same vendor.
*
* @param {Array} items - Array of queue item objects
* @returns {string} Common vendor name if all items share it, empty string otherwise
*/
export function extractCommonVendor(items) {
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
return vendors.length === 1 ? vendors[0] : '';
}

View File

@@ -0,0 +1,175 @@
import {
generateConsolidatedSummary,
generateConsolidatedDescription,
extractFirstCve,
extractCommonVendor,
} from './jiraConsolidation';
describe('generateConsolidatedSummary', () => {
it('formats summary with count, common vendor, and first title', () => {
const items = [
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
{ vendor: 'Microsoft', finding_title: 'XSS in Outlook' },
];
expect(generateConsolidatedSummary(items)).toBe(
'[2 findings] Microsoft - RCE in Exchange'
);
});
it('uses "Multiple Vendors" when vendors differ', () => {
const items = [
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
{ vendor: 'Adobe', finding_title: 'Buffer overflow' },
];
expect(generateConsolidatedSummary(items)).toBe(
'[2 findings] Multiple Vendors - RCE in Exchange'
);
});
it('uses "Multiple Vendors" when vendor is null/empty', () => {
const items = [
{ vendor: null, finding_title: 'Finding A' },
{ vendor: '', finding_title: 'Finding B' },
];
expect(generateConsolidatedSummary(items)).toBe(
'[2 findings] Multiple Vendors - Finding A'
);
});
it('uses "Untitled" when first item has no finding_title', () => {
const items = [{ vendor: 'Cisco' }, { vendor: 'Cisco', finding_title: 'Bug' }];
expect(generateConsolidatedSummary(items)).toBe(
'[2 findings] Cisco - Untitled'
);
});
it('truncates to 255 characters', () => {
const longTitle = 'A'.repeat(300);
const items = [{ vendor: 'V', finding_title: longTitle }];
const result = generateConsolidatedSummary(items);
expect(result.length).toBeLessThanOrEqual(255);
expect(result).toMatch(/^\[1 findings\] V - /);
});
});
describe('generateConsolidatedDescription', () => {
it('includes header with item count', () => {
const items = [
{ vendor: 'Microsoft', finding_title: 'Bug', cves_json: '["CVE-2024-001"]', hostname: 'host1', ip_address: '10.0.0.1' },
];
const result = generateConsolidatedDescription(items);
expect(result).toContain('Consolidated Jira ticket covering 1 Ivanti queue findings.');
});
it('groups items by vendor', () => {
const items = [
{ vendor: 'Microsoft', finding_title: 'Bug A', hostname: 'h1', ip_address: '10.0.0.1' },
{ vendor: 'Adobe', finding_title: 'Bug B', hostname: 'h2', ip_address: '10.0.0.2' },
{ vendor: 'Microsoft', finding_title: 'Bug C', hostname: 'h3', ip_address: '10.0.0.3' },
];
const result = generateConsolidatedDescription(items);
expect(result).toContain('== Microsoft ==');
expect(result).toContain('== Adobe ==');
expect(result).toContain('Bug A');
expect(result).toContain('Bug B');
expect(result).toContain('Bug C');
});
it('uses "Unknown Vendor" for null/empty vendor', () => {
const items = [
{ vendor: null, finding_title: 'Bug', hostname: 'h1', ip_address: '10.0.0.1' },
];
const result = generateConsolidatedDescription(items);
expect(result).toContain('== Unknown Vendor ==');
});
it('includes CVEs, hostname, and IP for each item', () => {
const items = [
{ vendor: 'Cisco', finding_title: 'Vuln', cves_json: '["CVE-2024-100","CVE-2024-101"]', hostname: 'server1', ip_address: '192.168.1.1' },
];
const result = generateConsolidatedDescription(items);
expect(result).toContain('CVEs: CVE-2024-100, CVE-2024-101');
expect(result).toContain('Host: server1 (192.168.1.1)');
});
it('shows "None" for items without CVEs', () => {
const items = [
{ vendor: 'Cisco', finding_title: 'Vuln', hostname: 'h1', ip_address: '10.0.0.1' },
];
const result = generateConsolidatedDescription(items);
expect(result).toContain('CVEs: None');
});
it('shows "N/A" for missing hostname and ip_address', () => {
const items = [{ vendor: 'Cisco', finding_title: 'Vuln' }];
const result = generateConsolidatedDescription(items);
expect(result).toContain('Host: N/A (N/A)');
});
});
describe('extractFirstCve', () => {
it('returns first CVE from first item with non-empty cves_json', () => {
const items = [
{ cves_json: null },
{ cves_json: '["CVE-2024-200","CVE-2024-201"]' },
{ cves_json: '["CVE-2024-300"]' },
];
expect(extractFirstCve(items)).toBe('CVE-2024-200');
});
it('returns empty string when no items have CVEs', () => {
const items = [
{ cves_json: null },
{ cves_json: '[]' },
];
expect(extractFirstCve(items)).toBe('');
});
it('returns empty string for empty array', () => {
expect(extractFirstCve([])).toBe('');
});
it('skips items with invalid JSON in cves_json', () => {
const items = [
{ cves_json: 'not-json' },
{ cves_json: '["CVE-2024-500"]' },
];
expect(extractFirstCve(items)).toBe('CVE-2024-500');
});
});
describe('extractCommonVendor', () => {
it('returns vendor when all items share the same vendor', () => {
const items = [
{ vendor: 'Microsoft' },
{ vendor: 'Microsoft' },
{ vendor: 'Microsoft' },
];
expect(extractCommonVendor(items)).toBe('Microsoft');
});
it('returns empty string when vendors differ', () => {
const items = [
{ vendor: 'Microsoft' },
{ vendor: 'Adobe' },
];
expect(extractCommonVendor(items)).toBe('');
});
it('returns empty string when all vendors are null/empty', () => {
const items = [
{ vendor: null },
{ vendor: '' },
];
expect(extractCommonVendor(items)).toBe('');
});
it('returns vendor when some items have null vendor but all non-null are same', () => {
const items = [
{ vendor: 'Cisco' },
{ vendor: null },
{ vendor: 'Cisco' },
];
expect(extractCommonVendor(items)).toBe('Cisco');
});
});