Add vendor-specific issue type dropdown for Jira ticket creation
When the Project Key field contains a vendor project key (e.g. AA_VECIMA), the Issue Type dropdown switches from STEAM types (Story, Epic, Program, Project, Reservation, Automation Maintenance) to vendor types (Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation). - Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants - Add isVendorProject() and getIssueTypesForProject() pure functions - Update JiraPage modal with context-aware dropdown and reset on switch - Update Ivanti queue modal with project_key and issue_type fields - Add property-based tests for determination logic and state transitions
This commit is contained in:
211
backend/__tests__/vendor-issue-type-dropdown.property.test.js
Normal file
211
backend/__tests__/vendor-issue-type-dropdown.property.test.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Vendor Issue Type Dropdown
|
||||||
|
*
|
||||||
|
* Feature: vendor-issue-type-dropdown
|
||||||
|
*
|
||||||
|
* Tests the pure determination logic that decides which issue type list
|
||||||
|
* to display based on the project key input.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.2, 1.3, 1.4, 1.5, 2.1, 2.3, 3.4, 3.5, 4.1, 4.2, 6.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Replicate the pure functions from JiraPage.js for testing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VENDOR_PROJECT_KEYS = ['AA_VECIMA'];
|
||||||
|
|
||||||
|
const VENDOR_ISSUE_TYPES = [
|
||||||
|
'Epic',
|
||||||
|
'Story',
|
||||||
|
'Task',
|
||||||
|
'Defect',
|
||||||
|
'Production Defect/Incident Fix',
|
||||||
|
'New Feature',
|
||||||
|
'Spike',
|
||||||
|
'Release Candidate',
|
||||||
|
'Documentation',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEAM_ISSUE_TYPES = [
|
||||||
|
'Story',
|
||||||
|
'Epic',
|
||||||
|
'Program',
|
||||||
|
'Project',
|
||||||
|
'Reservation',
|
||||||
|
'Automation Maintenance',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isVendorProject(projectKey, vendorKeys) {
|
||||||
|
if (!projectKey || typeof projectKey !== 'string') return false;
|
||||||
|
const normalized = projectKey.trim().toUpperCase();
|
||||||
|
if (normalized.length === 0) return false;
|
||||||
|
return vendorKeys.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
||||||
|
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the project_key onChange logic: resets issue_type only on context switch.
|
||||||
|
*/
|
||||||
|
function simulateProjectKeyChange(oldKey, newKey, currentIssueType, vendorKeys) {
|
||||||
|
const wasVendor = isVendorProject(oldKey, vendorKeys);
|
||||||
|
const isNowVendor = isVendorProject(newKey, vendorKeys);
|
||||||
|
return (wasVendor !== isNowVendor) ? '' : currentIssueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 1: Issue type list determination
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Feature: vendor-issue-type-dropdown, Property 1: Issue type list determination', () => {
|
||||||
|
it('returns VENDOR_ISSUE_TYPES when project key matches a vendor key (case-insensitive, trimmed)', () => {
|
||||||
|
// Generate variations of the vendor key with different casing and whitespace
|
||||||
|
const vendorKeyVariants = fc.oneof(
|
||||||
|
fc.constant('AA_VECIMA'),
|
||||||
|
fc.constant('aa_vecima'),
|
||||||
|
fc.constant('Aa_Vecima'),
|
||||||
|
fc.constant(' AA_VECIMA '),
|
||||||
|
fc.constant('aa_VECIMA'),
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(vendorKeyVariants, (key) => {
|
||||||
|
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||||
|
expect(result).toBe(VENDOR_ISSUE_TYPES);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns STEAM_ISSUE_TYPES for any string that does not match a vendor key after normalization', () => {
|
||||||
|
// Generate arbitrary strings that are NOT 'AA_VECIMA' after trim+uppercase
|
||||||
|
const nonVendorKey = fc.string({ minLength: 0, maxLength: 50 }).filter(s => {
|
||||||
|
const normalized = s.trim().toUpperCase();
|
||||||
|
return normalized !== 'AA_VECIMA';
|
||||||
|
});
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(nonVendorKey, (key) => {
|
||||||
|
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||||
|
expect(result).toBe(STEAM_ISSUE_TYPES);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns STEAM_ISSUE_TYPES for null, undefined, and empty string', () => {
|
||||||
|
const emptyInputs = fc.oneof(
|
||||||
|
fc.constant(null),
|
||||||
|
fc.constant(undefined),
|
||||||
|
fc.constant(''),
|
||||||
|
fc.constant(' '),
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(emptyInputs, (key) => {
|
||||||
|
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||||
|
expect(result).toBe(STEAM_ISSUE_TYPES);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 2: Context switch resets issue type selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Feature: vendor-issue-type-dropdown, Property 2: Context switch resets issue type', () => {
|
||||||
|
it('resets issue_type to empty when switching from vendor to non-vendor context', () => {
|
||||||
|
// Generate a vendor key variant and a non-vendor key
|
||||||
|
const vendorKey = fc.oneof(
|
||||||
|
fc.constant('AA_VECIMA'),
|
||||||
|
fc.constant('aa_vecima'),
|
||||||
|
fc.constant(' AA_VECIMA '),
|
||||||
|
);
|
||||||
|
const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => {
|
||||||
|
const normalized = s.trim().toUpperCase();
|
||||||
|
return normalized !== 'AA_VECIMA' && normalized.length > 0;
|
||||||
|
});
|
||||||
|
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(vendorKey, nonVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
||||||
|
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||||
|
expect(result).toBe('');
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets issue_type to empty when switching from non-vendor to vendor context', () => {
|
||||||
|
const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => {
|
||||||
|
const normalized = s.trim().toUpperCase();
|
||||||
|
return normalized !== 'AA_VECIMA' && normalized.length > 0;
|
||||||
|
});
|
||||||
|
const vendorKey = fc.oneof(
|
||||||
|
fc.constant('AA_VECIMA'),
|
||||||
|
fc.constant('aa_vecima'),
|
||||||
|
);
|
||||||
|
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(nonVendorKey, vendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
||||||
|
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||||
|
expect(result).toBe('');
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 3: Same context preserves issue type selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Feature: vendor-issue-type-dropdown, Property 3: Same context preserves issue type', () => {
|
||||||
|
it('preserves issue_type when both old and new keys resolve to STEAM context', () => {
|
||||||
|
const nonVendorKey1 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => {
|
||||||
|
const normalized = s.trim().toUpperCase();
|
||||||
|
return normalized !== 'AA_VECIMA';
|
||||||
|
});
|
||||||
|
const nonVendorKey2 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => {
|
||||||
|
const normalized = s.trim().toUpperCase();
|
||||||
|
return normalized !== 'AA_VECIMA';
|
||||||
|
});
|
||||||
|
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(nonVendorKey1, nonVendorKey2, anyIssueType, (oldKey, newKey, issueType) => {
|
||||||
|
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||||
|
expect(result).toBe(issueType);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves issue_type when both old and new keys resolve to vendor context', () => {
|
||||||
|
// With only one vendor key, both must be variants of AA_VECIMA
|
||||||
|
const vendorKey1 = fc.oneof(
|
||||||
|
fc.constant('AA_VECIMA'),
|
||||||
|
fc.constant('aa_vecima'),
|
||||||
|
fc.constant(' AA_VECIMA '),
|
||||||
|
);
|
||||||
|
const vendorKey2 = fc.oneof(
|
||||||
|
fc.constant('AA_VECIMA'),
|
||||||
|
fc.constant('Aa_Vecima'),
|
||||||
|
fc.constant('aa_VECIMA'),
|
||||||
|
);
|
||||||
|
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(vendorKey1, vendorKey2, anyIssueType, (oldKey, newKey, issueType) => {
|
||||||
|
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||||
|
expect(result).toBe(issueType);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||||
|
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
@@ -300,7 +301,7 @@ export default function IvantiTodoQueuePage() {
|
|||||||
// Single-item Jira creation modal state (Requirement 2.4)
|
// Single-item Jira creation modal state (Requirement 2.4)
|
||||||
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
||||||
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
||||||
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
|
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
|
||||||
const [singleJiraError, setSingleJiraError] = useState(null);
|
const [singleJiraError, setSingleJiraError] = useState(null);
|
||||||
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
||||||
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
|
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
|
||||||
@@ -480,6 +481,8 @@ export default function IvantiTodoQueuePage() {
|
|||||||
summary: generateConsolidatedSummary(items),
|
summary: generateConsolidatedSummary(items),
|
||||||
description: generateConsolidatedDescription(items),
|
description: generateConsolidatedDescription(items),
|
||||||
source_context: 'ivanti_queue',
|
source_context: 'ivanti_queue',
|
||||||
|
project_key: '',
|
||||||
|
issue_type: '',
|
||||||
});
|
});
|
||||||
setSingleJiraError(null);
|
setSingleJiraError(null);
|
||||||
setSingleJiraSummaryError(null);
|
setSingleJiraSummaryError(null);
|
||||||
@@ -966,6 +969,30 @@ export default function IvantiTodoQueuePage() {
|
|||||||
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
||||||
|
<input style={STYLES.input} placeholder="Uses .env default" value={singleJiraForm.project_key} onChange={e => {
|
||||||
|
const newKey = e.target.value.toUpperCase();
|
||||||
|
const wasVendor = isVendorProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS);
|
||||||
|
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
|
||||||
|
setSingleJiraForm(f => ({
|
||||||
|
...f,
|
||||||
|
project_key: newKey,
|
||||||
|
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
|
||||||
|
}));
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
||||||
|
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={singleJiraForm.issue_type} onChange={e => setSingleJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
||||||
|
<option value="">Story (default)</option>
|
||||||
|
{getIssueTypesForProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
|
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
|
||||||
onClick={submitSingleJira}
|
onClick={submitSingleJira}
|
||||||
|
|||||||
@@ -156,6 +156,50 @@ function getStatusColor(status) {
|
|||||||
return '#F59E0B';
|
return '#F59E0B';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vendor issue type configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add new vendor project keys here to enable vendor-specific issue types
|
||||||
|
const VENDOR_PROJECT_KEYS = ['AA_VECIMA'];
|
||||||
|
|
||||||
|
const VENDOR_ISSUE_TYPES = [
|
||||||
|
'Epic',
|
||||||
|
'Story',
|
||||||
|
'Task',
|
||||||
|
'Defect',
|
||||||
|
'Production Defect/Incident Fix',
|
||||||
|
'New Feature',
|
||||||
|
'Spike',
|
||||||
|
'Release Candidate',
|
||||||
|
'Documentation',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEAM_ISSUE_TYPES = [
|
||||||
|
'Story',
|
||||||
|
'Epic',
|
||||||
|
'Program',
|
||||||
|
'Project',
|
||||||
|
'Reservation',
|
||||||
|
'Automation Maintenance',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a project key belongs to a vendor project.
|
||||||
|
*/
|
||||||
|
function isVendorProject(projectKey, vendorKeys) {
|
||||||
|
if (!projectKey || typeof projectKey !== 'string') return false;
|
||||||
|
const normalized = projectKey.trim().toUpperCase();
|
||||||
|
if (normalized.length === 0) return false;
|
||||||
|
return vendorKeys.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate issue type list for a given project key.
|
||||||
|
*/
|
||||||
|
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
||||||
|
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE_CONTEXT_CONFIG = {
|
const SOURCE_CONTEXT_CONFIG = {
|
||||||
cve: { label: 'CVE', color: '#0EA5E9' },
|
cve: { label: 'CVE', color: '#0EA5E9' },
|
||||||
archer: { label: 'Archer', color: '#8B5CF6' },
|
archer: { label: 'Archer', color: '#8B5CF6' },
|
||||||
@@ -890,18 +934,24 @@ export default function JiraPage() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
||||||
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
|
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => {
|
||||||
|
const newKey = e.target.value.toUpperCase();
|
||||||
|
const wasVendor = isVendorProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS);
|
||||||
|
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
|
||||||
|
setCreateJiraForm(f => ({
|
||||||
|
...f,
|
||||||
|
project_key: newKey,
|
||||||
|
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
|
||||||
|
}));
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
||||||
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
||||||
<option value="">Story (default)</option>
|
<option value="">Story (default)</option>
|
||||||
<option value="Story">Story</option>
|
{getIssueTypesForProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
|
||||||
<option value="Epic">Epic</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
<option value="Program">Program</option>
|
))}
|
||||||
<option value="Project">Project</option>
|
|
||||||
<option value="Reservation">Reservation</option>
|
|
||||||
<option value="Automation Maintenance">Automation Maintenance</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -916,3 +966,7 @@ export default function JiraPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Named exports for reuse by other pages (e.g., IvantiTodoQueuePage)
|
||||||
|
export { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject };
|
||||||
|
|||||||
Reference in New Issue
Block a user