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:
92
frontend/src/utils/jiraConsolidation.js
Normal file
92
frontend/src/utils/jiraConsolidation.js
Normal 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] : '';
|
||||
}
|
||||
175
frontend/src/utils/jiraConsolidation.test.js
Normal file
175
frontend/src/utils/jiraConsolidation.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user