From 5ffd56b020e9672cccd6c8e811696edc1fdb470d Mon Sep 17 00:00:00 2001 From: Laurent Deleers Date: Mon, 28 Sep 2026 15:57:41 +0200 Subject: [PATCH] feat: add Jira ticket creation modal Release notes: create Jira tickets from the header, targeting the project and epic found in JQL, with title, dates, and assignee. --- README.md | 7 +++ index.html | 39 ++++++++++++++++ public/css/style.css | 28 ++++++++++++ public/js/script.js | 106 ++++++++++++++++++++++++++++++++++++++++++- server.js | 75 ++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 11ac4cd..bd7244c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Jira Ticket Extractor - Mise à jour +## Notes de version + +### Création de tickets Jira +- Nouveau bouton dans le header pour créer un ticket Jira depuis l'interface. +- Le projet et l'epic parent sont déduits de la JQL configurée. +- La modale permet de renseigner le titre, la Due Date, un champ personnalisé de date et l'assigné. + ## Nouvelles fonctionnalités ### 1. Labels des tickets diff --git a/index.html b/index.html index e532f06..d996494 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,9 @@
API : localhost:3000
+
+ + diff --git a/public/css/style.css b/public/css/style.css index ad58f69..24ddc1c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1469,6 +1469,23 @@ GANTT margin-top: -10px; } +.modal-target { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input); + color: var(--accent); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; +} + +.modal-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + .modal-date-input { width: 100%; } @@ -1481,4 +1498,15 @@ GANTT border-top: 1px solid var(--border); } +@media (max-width: 520px) { + .header-actions { + width: 100%; + margin-left: 0; + } + + .modal-form-grid { + grid-template-columns: 1fr; + } +} + diff --git a/public/js/script.js b/public/js/script.js index a6ac577..3bbc334 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -408,6 +408,102 @@ function enableUpdateBtn(ticketKey) { if (btn) btn.disabled = false; } +// --- TICKET CREATION --- +function getCreateIssueContext() { + const jql = document.getElementById('jqlQuery').value.trim(); + const fallbackProject = document.getElementById('projectKey').value.trim().toUpperCase(); + const projectMatch = jql.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i); + const epicMatch = jql.match(/(?:["']?Epic Link["']?|\bparent|cf\[\d+\])\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i); + const configuredFields = parseCustomFields(document.getElementById('customFields').value.trim()); + const dateField = configuredFields.find(field => /date|go live|production/i.test(field.label)) || configuredFields[0] || null; + + return { + jql, + projectKey: (projectMatch?.[1] || fallbackProject).toUpperCase(), + epicKey: epicMatch?.[1]?.toUpperCase() || '', + dateField + }; +} + +function openCreateIssueModal() { + const username = document.getElementById('username').value.trim(); + const apiToken = document.getElementById('apiToken').value.trim(); + const context = getCreateIssueContext(); + + if (!username || !apiToken) { + showToast('Enter your Jira credentials first.', 'error'); + return; + } + if (!context.projectKey) { + showToast('Add a project to the JQL query first.', 'error'); + return; + } + + saveSettings(); + document.getElementById('createIssueTarget').textContent = context.epicKey + ? `Project ${context.projectKey} · Epic ${context.epicKey}` + : `Project ${context.projectKey}`; + document.getElementById('createIssueSummary').value = ''; + document.getElementById('createIssueDueDate').value = ''; + document.getElementById('createIssueGoLiveDate').value = ''; + document.getElementById('createIssueAssignee').value = ''; + + const customDateInput = document.getElementById('createIssueGoLiveDate'); + document.getElementById('createIssueGoLiveLabel').textContent = context.dateField?.label || 'Custom Date'; + customDateInput.disabled = !context.dateField; + customDateInput.title = context.dateField ? '' : 'Configure a custom date field in Extraction Settings'; + + document.getElementById('createIssueModal').classList.add('visible'); + document.getElementById('createIssueSummary').focus(); +} + +function closeCreateIssueModal() { + document.getElementById('createIssueModal').classList.remove('visible'); +} + +async function createIssue() { + const summary = document.getElementById('createIssueSummary').value.trim(); + if (!summary) { + showToast('The ticket title is required.', 'error'); + document.getElementById('createIssueSummary').focus(); + return; + } + + const context = getCreateIssueContext(); + const btn = document.getElementById('createIssueSaveBtn'); + const originalContent = btn.innerHTML; + btn.innerHTML = ' Creating...'; + btn.disabled = true; + + try { + const response = await fetch(`${API_BASE}/api/issues`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: document.getElementById('username').value.trim(), + apiToken: document.getElementById('apiToken').value.trim(), + projectKey: context.projectKey, + jql: context.jql, + summary, + dueDate: document.getElementById('createIssueDueDate').value || null, + goLiveDate: document.getElementById('createIssueGoLiveDate').value || null, + goLiveFieldId: context.dateField?.id || null, + assignee: document.getElementById('createIssueAssignee').value.trim() || null + }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `HTTP Error ${response.status}`); + + closeCreateIssueModal(); + showToast(`Ticket ${data.key} created!`, 'success'); + } catch (error) { + showToast(error.message, 'error'); + } finally { + btn.innerHTML = originalContent; + btn.disabled = false; + } +} + function handleUpdateClick(ticketKey, goLiveColName) { const dueInput = document.getElementById(`due-${ticketKey}`); const dueDate = dueInput && dueInput.value ? dueInput.value : null; @@ -964,7 +1060,10 @@ function clearResults() { allTickets=[]; filteredTickets=[]; document.getElement document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} - if(e.key === 'Escape') closeDateEditModal(); + if(e.key === 'Escape') { + closeDateEditModal(); + closeCreateIssueModal(); + } }); document.addEventListener('DOMContentLoaded', () => { initTheme(); @@ -975,5 +1074,10 @@ document.addEventListener('DOMContentLoaded', () => { modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeDateEditModal(); }); } + const createIssueOverlay = document.getElementById('createIssueModal'); + if (createIssueOverlay) { + createIssueOverlay.addEventListener('click', e => { if (e.target === createIssueOverlay) closeCreateIssueModal(); }); + } + console.log("Settings loaded from local storage."); }); \ No newline at end of file diff --git a/server.js b/server.js index 935272b..3b7f6d6 100644 --- a/server.js +++ b/server.js @@ -57,6 +57,19 @@ const jiraClient = axios.create({ timeout: 60000 }); +function parseJqlContext(jql, fallbackProjectKey) { + const projectMatch = jql?.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i); + const parentMatch = jql?.match(/\bparent\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i); + const epicLinkMatch = jql?.match(/["']?Epic Link["']?\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i); + const customEpicMatch = jql?.match(/cf\[(\d+)\]\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i); + + return { + projectKey: (projectMatch?.[1] || fallbackProjectKey || '').toUpperCase(), + epicKey: (parentMatch?.[1] || epicLinkMatch?.[1] || customEpicMatch?.[2] || '').toUpperCase(), + epicRelation: parentMatch ? 'parent' : (customEpicMatch ? `customfield_${customEpicMatch[1]}` : (epicLinkMatch ? 'epic-link' : null)) + }; +} + // ========================================== // DEBUG : Intercepteurs de requêtes // ========================================== @@ -230,6 +243,68 @@ app.post('/api/extract', async (req, res) => { } }); +// Endpoint de création de ticket dans le projet / epic ciblé par la JQL +app.post('/api/issues', async (req, res) => { + const { username, apiToken, projectKey, jql, summary, dueDate, goLiveDate, goLiveFieldId, assignee } = req.body; + + if (!username || !apiToken || !summary) { + return res.status(400).json({ error: "Les champs username, apiToken et summary sont requis." }); + } + + const context = parseJqlContext(jql, projectKey); + if (!context.projectKey) { + return res.status(400).json({ error: "Aucun projet n'a été trouvé dans la JQL." }); + } + + const headers = { + 'Authorization': `Bearer ${apiToken}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + try { + const fields = { + project: { key: context.projectKey }, + summary: summary.trim(), + issuetype: { name: 'Task' } + }; + + if (dueDate) fields.duedate = dueDate; + if (goLiveDate && goLiveFieldId?.startsWith('customfield_')) fields[goLiveFieldId] = goLiveDate; + if (assignee?.trim()) fields.assignee = { name: assignee.trim() }; + + if (context.epicKey) { + if (context.epicRelation === 'parent') { + fields.parent = { key: context.epicKey }; + } else { + let epicFieldId = context.epicRelation; + if (epicFieldId === 'epic-link') { + const fieldResponse = await jiraClient.get('/rest/api/2/field', { headers }); + epicFieldId = fieldResponse.data.find(field => field.name === 'Epic Link')?.id; + } + if (!epicFieldId) { + return res.status(400).json({ error: "Le champ Jira 'Epic Link' est introuvable." }); + } + fields[epicFieldId] = context.epicKey; + } + } + + const response = await jiraClient.post('/rest/api/2/issue', { fields }, { headers }); + console.log(`[CREATE] Ticket ${response.data.key} créé dans ${context.projectKey}.`); + res.status(201).json({ + success: true, + key: response.data.key, + url: `${config.jira.url}/browse/${response.data.key}` + }); + } catch (error) { + console.error('[CREATE ERROR]', error.response?.data || error.message); + res.status(error.response?.status || 500).json({ + error: error.response?.data?.errorMessages?.[0] || error.message, + details: error.response?.data || null + }); + } +}); + // Endpoint de mise à jour de ticket app.put('/api/update', async (req, res) => { const { username, apiToken, ticketKey, updates } = req.body;