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,576 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
import {
generateConsolidatedSummary,
generateConsolidatedDescription,
extractFirstCve,
extractCommonVendor,
} from '../utils/jiraConsolidation';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
// ---------------------------------------------------------------------------
const STYLES = {
overlay: {
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(10, 14, 39, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
},
modal: {
position: 'relative',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '16px',
padding: '2rem',
width: '90%',
maxWidth: '640px',
maxHeight: '85vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
},
header: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '1.25rem',
},
title: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.9rem',
fontWeight: 700,
color: '#0EA5E9',
textTransform: 'uppercase',
letterSpacing: '0.08em',
},
subtitle: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#94A3B8',
marginTop: '0.25rem',
},
closeBtn: {
background: 'transparent',
border: 'none',
color: '#64748B',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
section: {
marginBottom: '1.25rem',
},
label: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.68rem',
fontWeight: 600,
color: '#7DD3FC',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '0.4rem',
display: 'block',
},
input: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.82rem',
fontFamily: "'JetBrains Mono', monospace",
width: '100%',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
},
inputError: {
borderColor: 'rgba(239, 68, 68, 0.6)',
},
textarea: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.78rem',
fontFamily: "'JetBrains Mono', monospace",
width: '100%',
minHeight: '120px',
resize: 'vertical',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
},
readOnlyBadge: {
display: 'inline-block',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
fontWeight: 600,
color: '#0EA5E9',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '4px',
padding: '0.25rem 0.6rem',
},
previewList: {
maxHeight: '160px',
overflowY: 'auto',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '8px',
background: 'rgba(15, 23, 42, 0.6)',
},
previewItem: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.4rem 0.6rem',
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
},
previewItemText: {
flex: 1,
minWidth: 0,
overflow: 'hidden',
},
previewTitle: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
previewHost: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.62rem',
color: '#64748B',
marginTop: '1px',
},
removeBtn: {
background: 'transparent',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '4px',
color: '#EF4444',
cursor: 'pointer',
padding: '0.15rem 0.3rem',
marginLeft: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'all 0.2s',
},
errorMsg: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
color: '#EF4444',
marginTop: '0.3rem',
},
warningMsg: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.08)',
border: '1px solid rgba(245, 158, 11, 0.2)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
},
apiError: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#EF4444',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
},
actions: {
display: 'flex',
gap: '0.75rem',
marginTop: '1.5rem',
},
cancelBtn: {
flex: 1,
padding: '0.625rem',
background: 'transparent',
border: '1px solid rgba(100, 116, 139, 0.4)',
borderRadius: '0.375rem',
color: '#94A3B8',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.78rem',
transition: 'all 0.2s ease',
},
submitBtn: {
flex: 1.5,
padding: '0.625rem',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid #0EA5E9',
borderRadius: '0.375rem',
color: '#0EA5E9',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.78rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
},
submitBtnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
charCount: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.62rem',
color: '#64748B',
textAlign: 'right',
marginTop: '0.2rem',
},
};
/**
* ConsolidationModal — Creates a single Jira ticket from multiple selected
* Ivanti queue items. Pre-populates summary, description, CVE, and vendor
* using aggregation functions.
*
* Props:
* items {Array} — The selected queue items (full objects)
* onClose {Function} — Close handler
* onSuccess {Function} — Called with created ticket data on success
*/
export default function ConsolidationModal({ items, onClose, onSuccess }) {
// Internal state — copy of items that can be modified (items removed)
const [selectedItems, setSelectedItems] = useState(items);
// Form fields
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [cveId, setCveId] = useState('');
const [vendor, setVendor] = useState('');
// Locked source context
const sourceContext = 'ivanti_queue';
// UI state
const [summaryError, setSummaryError] = useState(null);
const [apiError, setApiError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// ---------------------------------------------------------------------------
// Initialize / regenerate form fields when selectedItems changes
// ---------------------------------------------------------------------------
useEffect(() => {
if (selectedItems.length >= 2) {
setSummary(generateConsolidatedSummary(selectedItems));
setDescription(generateConsolidatedDescription(selectedItems));
setCveId(extractFirstCve(selectedItems));
setVendor(extractCommonVendor(selectedItems));
}
}, []); // Only on mount — user edits are preserved after item removal
// ---------------------------------------------------------------------------
// Remove an item from the selection (minimum 2 required)
// ---------------------------------------------------------------------------
const removeItem = useCallback((itemId) => {
setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
}, []);
// ---------------------------------------------------------------------------
// Escape key handler
// ---------------------------------------------------------------------------
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
// ---------------------------------------------------------------------------
// Form validation
// ---------------------------------------------------------------------------
const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
// ---------------------------------------------------------------------------
// Submit handler
// ---------------------------------------------------------------------------
const handleSubmit = async () => {
// Validate summary
if (!summary.trim()) {
setSummaryError('Summary is required.');
return;
}
if (summary.length > 255) {
setSummaryError('Summary must be 255 characters or fewer.');
return;
}
setSummaryError(null);
setApiError(null);
setSubmitting(true);
try {
// Step 1: Create the Jira ticket
const createPayload = {
summary: summary.trim(),
description,
cve_id: cveId.trim() || null,
vendor: vendor.trim() || null,
source_context: sourceContext,
};
const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(createPayload),
});
const createData = await createRes.json();
if (!createRes.ok && createRes.status !== 207) {
throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
}
const ticketId = createData.id;
// Step 2: Link queue items to the ticket via junction endpoint
const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
});
if (!linkRes.ok) {
const linkData = await linkRes.json();
// Ticket was created but linking failed — partial success
console.warn('Junction link failed:', linkData.error || linkRes.status);
}
// Success — close modal and notify parent
onSuccess?.(createData);
} catch (err) {
setApiError(err.message);
} finally {
setSubmitting(false);
}
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="consolidation-modal-title"
style={STYLES.overlay}
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
>
<div style={STYLES.modal}>
{/* Header */}
<div style={STYLES.header}>
<div>
<div id="consolidation-modal-title" style={STYLES.title}>
<FileText style={{ width: '16px', height: '16px', display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
Create Consolidated Jira Ticket
</div>
<div style={STYLES.subtitle}>
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
</div>
</div>
<button
onClick={onClose}
style={STYLES.closeBtn}
title="Close"
aria-label="Close modal"
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Minimum items warning */}
{selectedItems.length < 2 && (
<div style={STYLES.warningMsg}>
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
At least 2 items are required for consolidation. Add more items or close this modal.
</div>
)}
{/* API error */}
{apiError && (
<div style={STYLES.apiError}>
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
{apiError}
</div>
)}
{/* Selected items preview list */}
<div style={STYLES.section}>
<label style={STYLES.label}>Selected Items</label>
<div style={STYLES.previewList}>
{selectedItems.map((item) => (
<div key={item.id} style={STYLES.previewItem}>
<div style={STYLES.previewItemText}>
<div style={STYLES.previewTitle} title={item.finding_title}>
{item.finding_title || item.finding_id || 'Untitled'}
</div>
<div style={STYLES.previewHost}>
{item.hostname || 'No hostname'}
</div>
</div>
<button
onClick={() => removeItem(item.id)}
style={STYLES.removeBtn}
title="Remove from selection"
aria-label={`Remove ${item.finding_title || 'item'}`}
disabled={selectedItems.length <= 2}
>
<X style={{ width: '12px', height: '12px' }} />
</button>
</div>
))}
</div>
</div>
{/* Summary field (required, max 255 chars) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-summary">
Summary <span style={{ color: '#EF4444' }}>*</span>
</label>
<input
id="consolidation-summary"
type="text"
value={summary}
onChange={(e) => {
setSummary(e.target.value);
if (summaryError) setSummaryError(null);
}}
maxLength={255}
placeholder="Ticket summary (required)"
style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
/>
<div style={STYLES.charCount}>{summary.length}/255</div>
{summaryError && <div style={STYLES.errorMsg}>{summaryError}</div>}
</div>
{/* Description textarea */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-description">
Description
</label>
<textarea
id="consolidation-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Aggregated description of selected findings"
style={STYLES.textarea}
/>
</div>
{/* CVE ID (optional) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-cve">
CVE ID <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
</label>
<input
id="consolidation-cve"
type="text"
value={cveId}
onChange={(e) => setCveId(e.target.value)}
placeholder="e.g. CVE-2024-12345"
style={STYLES.input}
/>
</div>
{/* Vendor (optional) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-vendor">
Vendor <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
</label>
<input
id="consolidation-vendor"
type="text"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
placeholder="e.g. Microsoft"
style={STYLES.input}
/>
</div>
{/* Source Context (read-only) */}
<div style={STYLES.section}>
<label style={STYLES.label}>Source Context</label>
<span style={STYLES.readOnlyBadge}>{sourceContext}</span>
</div>
{/* Actions */}
<div style={STYLES.actions}>
<button
onClick={onClose}
style={STYLES.cancelBtn}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
e.currentTarget.style.color = '#CBD5E1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
e.currentTarget.style.color = '#94A3B8';
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
style={{
...STYLES.submitBtn,
...(!canSubmit ? STYLES.submitBtnDisabled : {}),
}}
onMouseEnter={(e) => {
if (canSubmit) {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.15)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{submitting ? (
<>
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
Creating
</>
) : (
'Create Ticket'
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,902 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches dark theme tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
page: {
minHeight: '60vh',
},
card: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '1rem',
},
header: {
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#0EA5E9',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: '1rem',
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
},
toolbarLeft: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
},
toolbarRight: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
},
btn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.3)',
background: 'rgba(14, 165, 233, 0.1)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnActive: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.6)',
background: 'rgba(14, 165, 233, 0.25)',
color: '#0EA5E9',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
boxShadow: '0 0 12px rgba(14, 165, 233, 0.2)',
},
selectionCount: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
},
tableHeader: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
marginBottom: '0.5rem',
},
tableHeaderLabel: {
fontFamily: 'monospace',
fontSize: '0.65rem',
fontWeight: 700,
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
queueItem: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.04)',
border: '1px solid rgba(14, 165, 233, 0.1)',
transition: 'background 0.15s, border-color 0.15s',
},
queueItemSelected: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.08)',
border: '1px solid rgba(14, 165, 233, 0.3)',
transition: 'background 0.15s, border-color 0.15s',
},
checkbox: {
accentColor: '#0EA5E9',
width: '16px',
height: '16px',
flexShrink: 0,
marginTop: '2px',
cursor: 'pointer',
},
selectAllCheckbox: {
accentColor: '#0EA5E9',
width: '14px',
height: '14px',
cursor: 'pointer',
},
floatingBar: {
position: 'fixed',
bottom: '1.5rem',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.25rem',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 0.99))',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 16px rgba(14, 165, 233, 0.1)',
zIndex: 50,
},
floatingBarBadge: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
},
btnSuccess: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(16, 185, 129, 0.4)',
background: 'rgba(16, 185, 129, 0.15)',
color: '#6EE7B7',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
btnCancel: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(148, 163, 184, 0.3)',
background: 'rgba(148, 163, 184, 0.08)',
color: '#94A3B8',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
modal: {
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
modalBackdrop: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
},
modalContent: {
position: 'relative',
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '16px',
padding: '2rem',
width: '90%',
maxWidth: '520px',
maxHeight: '85vh',
overflowY: 'auto',
zIndex: 101,
},
input: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.85rem',
width: '100%',
outline: 'none',
},
};
// ---------------------------------------------------------------------------
// IvantiTodoQueuePage — Full-page Ivanti queue with multi-select support
// ---------------------------------------------------------------------------
export default function IvantiTodoQueuePage() {
const { canWrite } = useAuth();
// Queue data state
const [queueItems, setQueueItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Ticket link badges state (Requirement 6.3, 6.4, 6.5)
const [ticketLinks, setTicketLinks] = useState({});
// Selection mode state (Requirement 1.1)
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
// Consolidation modal state (Requirement 2.3)
const [showConsolidationModal, setShowConsolidationModal] = useState(false);
// Single-item Jira creation modal state (Requirement 2.4)
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
const [singleJiraItem, setSingleJiraItem] = useState(null);
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
const [singleJiraError, setSingleJiraError] = useState(null);
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) {
// Parse cves from cves_json if not already parsed
const parsed = data.map((item) => {
if (item.cves) return item;
let cves = [];
if (item.cves_json) {
try { cves = JSON.parse(item.cves_json); } catch { cves = []; }
}
return { ...item, cves };
});
setQueueItems(parsed);
} else {
setError(data.error || 'Failed to fetch queue items.');
}
} catch (e) {
setError('Network error — could not fetch queue items.');
console.error('Error fetching queue:', e);
} finally {
setLoading(false);
}
}, []);
// ---------------------------------------------------------------------------
// Fetch ticket link associations (Requirements 6.3, 6.4, 6.5)
// ---------------------------------------------------------------------------
const fetchTicketLinks = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/ticket-links`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setTicketLinks(data.links || {});
}
} catch (e) {
console.error('Error fetching ticket links:', e);
}
}, []);
useEffect(() => {
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Visible items — only pending items are selectable
// ---------------------------------------------------------------------------
const visibleItems = useMemo(() => {
return queueItems.filter((item) => item.status === 'pending');
}, [queueItems]);
// ---------------------------------------------------------------------------
// Selection mode toggle (Requirement 1.1, 1.5)
// When deactivated, clear all selections
// ---------------------------------------------------------------------------
const toggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => {
if (prev) {
// Deactivating — clear selections (Requirement 1.5)
setSelectedIds(new Set());
}
return !prev;
});
}, []);
// ---------------------------------------------------------------------------
// Individual item selection toggle (Requirement 1.2)
// ---------------------------------------------------------------------------
const toggleItemSelection = useCallback((id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// ---------------------------------------------------------------------------
// Select All toggle (Requirement 1.4)
// Toggles all visible (filtered) queue item IDs into/out of selectedIds
// ---------------------------------------------------------------------------
const allVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.every((item) => selectedIds.has(item.id));
}, [visibleItems, selectedIds]);
const someVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.some((item) => selectedIds.has(item.id)) && !allVisibleSelected;
}, [visibleItems, selectedIds, allVisibleSelected]);
const toggleSelectAll = useCallback(() => {
if (allVisibleSelected) {
// Deselect all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.delete(item.id));
return next;
});
} else {
// Select all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.add(item.id));
return next;
});
}
}, [allVisibleSelected, visibleItems]);
// ---------------------------------------------------------------------------
// Preserve selections on scroll/re-render (Requirement 1.6)
// Clean up selectedIds that no longer exist in the queue
// ---------------------------------------------------------------------------
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const validIds = new Set(queueItems.map((i) => i.id));
const next = new Set([...prev].filter((id) => validIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [queueItems]);
// ---------------------------------------------------------------------------
// Selected queue items (full objects) for modal use
// ---------------------------------------------------------------------------
const selectedQueueItems = useMemo(() => {
return queueItems.filter(item => selectedIds.has(item.id));
}, [queueItems, selectedIds]);
// ---------------------------------------------------------------------------
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
// ---------------------------------------------------------------------------
const handleCreateJiraTicket = useCallback(() => {
if (selectedIds.size === 0) return;
if (selectedIds.size === 1) {
// Single item — open single-item Jira creation modal (Requirement 2.4)
const item = queueItems.find(i => selectedIds.has(i.id));
if (!item) return;
setSingleJiraItem(item);
const items = [item];
setSingleJiraForm({
cve_id: extractFirstCve(items),
vendor: extractCommonVendor(items),
summary: generateConsolidatedSummary(items),
description: generateConsolidatedDescription(items),
source_context: 'ivanti_queue',
});
setSingleJiraError(null);
setSingleJiraSummaryError(null);
setShowSingleJiraModal(true);
} else {
// Multiple items — open Consolidation Modal (Requirement 2.3)
setShowConsolidationModal(true);
}
}, [selectedIds, queueItems]);
// ---------------------------------------------------------------------------
// Consolidation modal success handler
// ---------------------------------------------------------------------------
const handleConsolidationSuccess = useCallback(() => {
setShowConsolidationModal(false);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Single-item Jira creation — submit handler
// ---------------------------------------------------------------------------
const submitSingleJira = useCallback(async () => {
setSingleJiraSummaryError(null);
const trimmedSummary = (singleJiraForm.summary || '').trim();
if (!trimmedSummary) {
setSingleJiraSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setSingleJiraSummaryError('Summary must be 255 characters or fewer.');
return;
}
setSingleJiraError(null);
setSingleJiraSaving(true);
try {
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(singleJiraForm),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// If we have a ticket ID and a queue item, link them via junction table
if (data.id && singleJiraItem) {
try {
await fetch(`${API_BASE}/jira-tickets/${data.id}/queue-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ queue_item_ids: [singleJiraItem.id] }),
});
} catch (_) { /* junction link is best-effort */ }
}
setShowSingleJiraModal(false);
setSingleJiraItem(null);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
} catch (err) {
setSingleJiraError(err.message);
} finally {
setSingleJiraSaving(false);
}
}, [singleJiraForm, singleJiraItem, fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Cancel selection mode from floating bar
// ---------------------------------------------------------------------------
const cancelSelection = useCallback(() => {
setSelectedIds(new Set());
setSelectionMode(false);
}, []);
// ---------------------------------------------------------------------------
// Workflow type color helper
// ---------------------------------------------------------------------------
const getWorkflowColor = (workflowType) => {
switch (workflowType) {
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
default: return { col: '#94A3B8', rgb: '148,163,184' };
}
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div style={STYLES.page}>
<div style={STYLES.card}>
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1.25rem' }}>
<ListTodo style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
<span style={STYLES.header}>Ivanti Todo Queue</span>
</div>
{/* Toolbar */}
<div style={STYLES.toolbar}>
<div style={STYLES.toolbarLeft}>
{/* Select toggle button (Requirement 1.1) */}
{canWrite() && (
<button
onClick={toggleSelectionMode}
style={selectionMode ? STYLES.btnActive : STYLES.btn}
title={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
>
{selectionMode
? <><CheckSquare style={{ width: '14px', height: '14px' }} /> Selecting</>
: <><Square style={{ width: '14px', height: '14px' }} /> Select</>
}
</button>
)}
{/* Selection count indicator (Requirement 1.3) */}
{selectionMode && selectedIds.size > 0 && (
<span style={STYLES.selectionCount}>
{selectedIds.size} selected
</span>
)}
</div>
<div style={STYLES.toolbarRight}>
{/* Refresh button */}
<button
onClick={fetchQueue}
style={STYLES.btn}
disabled={loading}
title="Refresh queue"
>
<RefreshCw style={{ width: '14px', height: '14px', animation: loading ? 'spin 1s linear infinite' : 'none' }} />
Refresh
</button>
</div>
</div>
{/* Error state */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '0.5rem',
marginBottom: '1rem',
}}>
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
</div>
)}
{/* Loading state */}
{loading && queueItems.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', marginTop: '0.75rem' }}>
Loading queue items...
</div>
</div>
)}
{/* Empty state */}
{!loading && queueItems.length === 0 && !error && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<ListTodo style={{ width: '32px', height: '32px', color: '#1E293B', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155', marginTop: '0.75rem' }}>
No items in queue.
</div>
</div>
)}
{/* Queue items table */}
{!loading && visibleItems.length > 0 && (
<>
{/* Table header with Select All (Requirement 1.4) */}
<div style={STYLES.tableHeader}>
{selectionMode && (
<input
type="checkbox"
checked={allVisibleSelected}
ref={(el) => { if (el) el.indeterminate = someVisibleSelected; }}
onChange={toggleSelectAll}
style={STYLES.selectAllCheckbox}
title={allVisibleSelected ? 'Deselect all' : 'Select all'}
aria-label="Select all queue items"
/>
)}
<span style={{ ...STYLES.tableHeaderLabel, flex: 1 }}>Finding</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '80px', textAlign: 'center' }}>Type</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Vendor</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
</div>
{/* Queue item rows */}
{visibleItems.map((item) => {
const isSelected = selectedIds.has(item.id);
const wfColor = getWorkflowColor(item.workflow_type);
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
: '';
return (
<div
key={item.id}
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
role={selectionMode ? 'button' : undefined}
tabIndex={selectionMode ? 0 : undefined}
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
>
{/* Selection checkbox (Requirement 1.2) */}
{selectionMode && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
onClick={(e) => e.stopPropagation()}
style={STYLES.checkbox}
aria-label={`Select ${item.finding_title || item.finding_id}`}
/>
)}
{/* Finding info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.finding_title || item.finding_id}>
{item.finding_title || item.finding_id}
</div>
{cveDisplay && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#64748B',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</div>
{/* Ticket link badge (Requirements 6.3, 6.4) */}
{ticketLinks[item.id] && (
<a
href={ticketLinks[item.id].jira_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: '#6EE7B7',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
borderRadius: '999px',
padding: '0.15rem 0.5rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
transition: 'all 0.2s',
}}
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
>
{ticketLinks[item.id].ticket_key}
</a>
)}
{/* Workflow type badge */}
<div style={{
width: '80px',
textAlign: 'center',
flexShrink: 0,
}}>
<span style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: wfColor.col,
background: `rgba(${wfColor.rgb}, 0.1)`,
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
borderRadius: '4px',
padding: '0.15rem 0.4rem',
textTransform: 'uppercase',
}}>
{item.workflow_type}
</span>
</div>
{/* Vendor */}
<div style={{
width: '120px',
flexShrink: 0,
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.vendor}>
{item.vendor || '—'}
</div>
{/* Hostname / IP */}
<div style={{
width: '120px',
flexShrink: 0,
minWidth: 0,
}}>
{item.hostname && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.hostname}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.62rem',
color: '#10B981',
marginTop: item.hostname ? '1px' : 0,
}}>
{item.ip_address}
</div>
)}
</div>
</div>
);
})}
</>
)}
{/* Completed items count */}
{!loading && queueItems.filter(i => i.status === 'complete').length > 0 && (
<div style={{
marginTop: '1rem',
padding: '0.5rem 0.75rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#334155',
}}>
{queueItems.filter(i => i.status === 'complete').length} completed item(s) hidden
</div>
)}
</div>
{/* Floating Action Bar (Requirements 2.1, 2.2) */}
{selectionMode && selectedIds.size > 0 && (
<div style={STYLES.floatingBar}>
<span style={STYLES.floatingBarBadge}>
{selectedIds.size} selected
</span>
<button
onClick={handleCreateJiraTicket}
disabled={selectedIds.size === 0}
style={selectedIds.size === 0 ? { ...STYLES.btnSuccess, ...STYLES.btnDisabled } : STYLES.btnSuccess}
title={selectedIds.size === 1 ? 'Create Jira ticket for selected item' : `Create consolidated Jira ticket for ${selectedIds.size} items`}
>
<Plus style={{ width: '14px', height: '14px' }} />
Create Jira Ticket
</button>
<button
onClick={cancelSelection}
style={STYLES.btnCancel}
title="Cancel selection"
>
<X style={{ width: '14px', height: '14px' }} />
Cancel
</button>
</div>
)}
{/* Single-item Jira Creation Modal (Requirement 2.4) */}
{showSingleJiraModal && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowSingleJiraModal(false)} />
<div style={STYLES.modalContent}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Jira Ticket</h3>
<button onClick={() => setShowSingleJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X style={{ width: '18px', height: '18px' }} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Create a Jira issue for: <span style={{ color: '#7DD3FC' }}>{singleJiraItem?.finding_title || singleJiraItem?.finding_id}</span>
</p>
{singleJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{singleJiraError}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={singleJiraForm.cve_id} onChange={e => setSingleJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
<input style={STYLES.input} placeholder="e.g. Microsoft" value={singleJiraForm.vendor} onChange={e => setSingleJiraForm(f => ({ ...f, vendor: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
<input
style={{ ...STYLES.input, ...(singleJiraSummaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
placeholder="Issue summary (max 255 chars)"
value={singleJiraForm.summary}
onChange={e => { setSingleJiraForm(f => ({ ...f, summary: e.target.value })); if (singleJiraSummaryError) setSingleJiraSummaryError(null); }}
maxLength={255}
/>
{singleJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{singleJiraSummaryError}</div>}
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<input
style={{ ...STYLES.input, opacity: 0.7, cursor: 'not-allowed' }}
value="ivanti_queue"
disabled
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description</label>
<textarea
style={{ ...STYLES.input, minHeight: '100px', resize: 'vertical' }}
placeholder="Detailed description..."
value={singleJiraForm.description}
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<button
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
onClick={submitSingleJira}
disabled={singleJiraSaving}
>
{singleJiraSaving ? <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> : <CheckCircle style={{ width: '14px', height: '14px' }} />}
Create in Jira
</button>
</div>
</div>
</div>
)}
{/* Consolidation Modal (Requirement 2.3) */}
{showConsolidationModal && (
<ConsolidationModal
items={selectedQueueItems}
onClose={() => setShowConsolidationModal(false)}
onSuccess={handleConsolidationSuccess}
/>
)}
</div>
);
}

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');
});
});