/** * netlify/functions/data.js — Netlify Function (Convertido de Vercel) * * Consulta JIRA en tiempo real y devuelve datos procesados del dashboard. */ // Configuración desde variables de entorno const config = { jira: { host: process.env.JIRA_HOST || 'invera.atlassian.net', project: process.env.JIRA_PROJECT || 'LABS', email: process.env.JIRA_EMAIL, token: process.env.JIRA_TOKEN, }, fields: { spEstimate: process.env.JIRA_FIELD_SP_ESTIMATE || 'customfield_10016', spFinal: process.env.JIRA_FIELD_SP_FINAL || 'customfield_11019', developer: process.env.JIRA_FIELD_DEVELOPER || 'customfield_10622', qaAssignee: 'customfield_10632', qaEntryRtqa: 'customfield_10821', qaTotalTimeRtqa: 'customfield_10822', qaLastDurRtqa: 'customfield_10823', qaEntryQa: 'customfield_10824', qaTotalTimeQa: 'customfield_10825', qaLastDurQa: 'customfield_10826', timesReturned: 'customfield_11217', spFinalReal: 'customfield_11216', crEntry: 'customfield_10818', crTotalTime: 'customfield_10819', enCursoEntry: 'customfield_10815', enCursoTotalTime: 'customfield_10816', }, jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC', api: { cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto }, }; // ─── Cache en memoria (Netlify Functions pueden persistir entre llamadas calientes) ─── let _cache = {}; let _cacheTime = {}; // ─── JIRA Fetch ──────────────────────────────────────────────────── async function fetchJiraIssues(email, token, period) { const auth = Buffer.from(`${email}:${token}`).toString('base64'); const maxResultsWanted = 1500; let allIssues = []; let startAt = 0; let dynamicJql = config.jql .replace('${PROJECT}', config.jira.project) .replace('${FIELD_SP_ESTIMATE}', config.fields.spEstimate); if (period !== 'all') { const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`; if (dynamicJql.includes(' ORDER BY ')) { dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `); } else { dynamicJql += ` AND updated >= ${timeFilter}`; } } console.log(`📡 Consultando JIRA: ${config.jira.project} (periodo: ${period})`); let nextPageToken = null; const fieldsParam = encodeURIComponent(buildFieldsList()); const jqlParam = encodeURIComponent(dynamicJql); while (allIssues.length < maxResultsWanted) { let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`; if (nextPageToken) { searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`; } const res = await fetch(searchUrl, { method: 'GET', headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json' } }); if (!res.ok) { const text = await res.text(); throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`); } const data = await res.json(); const items = data.issues || []; allIssues = allIssues.concat(items); nextPageToken = data.nextPageToken; if (!nextPageToken || items.length === 0) { break; } } return { issues: allIssues, isTruncated: !!nextPageToken }; } function buildFieldsList() { return [ 'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated', 'duedate', config.fields.spEstimate, config.fields.spFinal, config.fields.developer, config.fields.qaAssignee, config.fields.qaEntryRtqa, config.fields.qaTotalTimeRtqa, config.fields.qaLastDurRtqa, config.fields.qaEntryQa, config.fields.qaTotalTimeQa, config.fields.qaLastDurQa, config.fields.timesReturned, config.fields.spFinalReal, config.fields.crEntry, config.fields.crTotalTime, config.fields.enCursoEntry, config.fields.enCursoTotalTime, ].join(','); } function minsToHrs(mins) { if (mins == null || mins === 0) return 0; return Math.round((mins / 60) * 10) / 10; } function processIssues(issues, period = '90d') { const assigneeData = {}; const monthlyDone = {}; const issuesList = []; const warnings = []; const now = new Date(); let cutoffDate = null; if (period !== 'all') { const periodDays = period === '1d' ? 1 : parseInt(period.replace('d', '')); if (!isNaN(periodDays)) { if (period === '1d') { cutoffDate = new Date(); cutoffDate.setHours(0,0,0,0); } else { cutoffDate = new Date(Date.now() - (periodDays * 24 * 60 * 60 * 1000)); } } } const qaData = { tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0, monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [], }; const deliveryData = { totalLeadTimeDays: 0, totalCycleTimeDays: 0, doneTicketsLead: 0, doneTicketsCycle: 0, doneSpOriginal: 0, doneSpFinal: 0 }; const crData = { tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: [], times: [] }; const currentStates = { codeReview: { count: 0, sp: 0 }, readyToQa: { count: 0, sp: 0 }, inQa: { count: 0, sp: 0 } }; for (const issue of issues) { try { const f = issue.fields; let devs = []; if (f[config.fields.developer]?.length) { devs = f[config.fields.developer].map(d => d.displayName); } else if (f.assignee) { devs = [f.assignee.displayName]; } else { devs = ['Sin asignar']; } const INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno']; devs = devs.filter(d => !INACTIVE_DEVS.includes(d)); if (devs.length === 0) continue; const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0; const statusKey = f.status?.statusCategory?.key ?? 'new'; const statusName = f.status?.name?.toLowerCase() ?? ''; const isDone = statusKey === 'done'; if (isDone && f.resolutiondate && cutoffDate) { const resDate = new Date(f.resolutiondate); if (resDate < cutoffDate) { continue; // Ticket resuelto antes del período (actualizado recientemente por otro motivo) } } const isInProgress = statusKey === 'indeterminate'; if (!isDone) { if (statusName === 'code review' || statusName.includes('code review') || statusName === 'cr') { currentStates.codeReview.count++; currentStates.codeReview.sp += sp; } else if (statusName === 'ready to qa' || statusName.includes('ready to qa') || statusName === 'listo para qa') { currentStates.readyToQa.count++; currentStates.readyToQa.sp += sp; } else if (statusName === 'qa' || statusName.includes('in qa') || statusName === 'en qa') { currentStates.inQa.count++; currentStates.inQa.sp += sp; } } const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null; const creMonth = f.created ? f.created.slice(0, 7) : null; for (const dev of devs) { if (!assigneeData[dev]) assigneeData[dev] = { done: 0, in_progress: 0, todo: 0, pending_count: 0 }; if (isDone) { assigneeData[dev].done += sp; } else if (isInProgress) { assigneeData[dev].in_progress += sp; assigneeData[dev].pending_count++; } else { assigneeData[dev].todo += sp; assigneeData[dev].pending_count++; } } if (isDone) { const month = resMonth || creMonth; if (month) { if (!monthlyDone[month]) monthlyDone[month] = {}; for (const dev of devs) { monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp; } } // Delivery Metrics const createdDate = f.created ? new Date(f.created) : null; const resolvedDate = f.resolutiondate ? new Date(f.resolutiondate) : null; const enCursoDate = f[config.fields.enCursoEntry] ? new Date(f[config.fields.enCursoEntry]) : null; const spOriginal = f[config.fields.spEstimate] || 0; const spReal = f[config.fields.spFinalReal] != null ? f[config.fields.spFinalReal] : spOriginal; if (resolvedDate) { if (createdDate) { deliveryData.totalLeadTimeDays += (resolvedDate - createdDate) / (1000 * 60 * 60 * 24); deliveryData.doneTicketsLead++; } if (enCursoDate) { deliveryData.totalCycleTimeDays += (resolvedDate - enCursoDate) / (1000 * 60 * 60 * 24); deliveryData.doneTicketsCycle++; } deliveryData.doneSpOriginal += spOriginal; deliveryData.doneSpFinal += spReal; } } issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth }); const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0; const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0; const timesRet = f[config.fields.timesReturned] ?? 0; const qaMonth = resMonth || creMonth; if (timeQaMin > 0) { qaData.tickets++; qaData.totalTimeQa += timeQaMin; if (timesRet > 1) qaData.ticketsReturned++; if (qaMonth) { qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin; qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1; } qaData.issues.push({ key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin), timesReturned: timesRet, status: statusKey, resMonth: qaMonth }); } if (timeRtqaMin > 0 && qaMonth) { qaData.totalTimeRtqa += timeRtqaMin; qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin; qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1; } const timeCrMin = f[config.fields.crTotalTime] ?? 0; if (timeCrMin > 0) { crData.tickets++; crData.totalTimeCr += timeCrMin; crData.times.push(timeCrMin); if (qaMonth) { crData.monthlyTotalCr[qaMonth] = (crData.monthlyTotalCr[qaMonth] || 0) + timeCrMin; crData.monthlyCountCr[qaMonth] = (crData.monthlyCountCr[qaMonth] || 0) + 1; } crData.issues.push({ key: issue.key, summary: f.summary, timeCr: minsToHrs(timeCrMin), status: statusKey }); } const crEntryDate = f[config.fields.crEntry]; if (crEntryDate && !isDone && !f[config.fields.qaEntryRtqa]) { const daysInCr = (now - new Date(crEntryDate)) / (1000 * 60 * 60 * 24); if (daysInCr > 2) { crData.stalledIssues.push({ key: issue.key, summary: f.summary, days: Math.round(daysInCr * 10)/10 }); } } if (isInProgress && f.updated) { const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24); if (days > 14) warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) }); } if (!isDone && f.duedate) { const dueDate = new Date(f.duedate); if (dueDate < now) { const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); warnings.push({ type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue }); } } if (isDone && sp === 0) warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 }); if (!isDone && devs.includes('Sin asignar') && sp > 0) warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp }); } catch (e) { console.warn(`Error procesando issue ${issue.key}: ${e.message}`); } } const monthlyAvgQa = {}; for (const m of Object.keys(qaData.monthlyTotalQa)) monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]); const monthlyAvgRtqa = {}; for (const m of Object.keys(qaData.monthlyTotalRtqa)) monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]); const EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca']; for (const dev of Object.keys(assigneeData)) { if (!EXCLUDED_FROM_ALERTS.includes(dev) && assigneeData[dev].pending_count === 0) { warnings.push({ type: 'free_dev', summary: `${dev} no tiene tickets pendientes ni en curso`, devs: [dev] }); } } const monthlyAvgCr = {}; for (const m of Object.keys(crData.monthlyTotalCr)) monthlyAvgCr[m] = minsToHrs(crData.monthlyTotalCr[m] / crData.monthlyCountCr[m]); const crTimesSorted = crData.times.sort((a, b) => a - b); function getPercentile(sortedArr, p) { if (sortedArr.length === 0) return 0; if (p <= 0) return sortedArr[0]; if (p >= 1) return sortedArr[sortedArr.length - 1]; const index = (sortedArr.length - 1) * p; const lower = Math.floor(index); const upper = lower + 1; const weight = index % 1; if (upper >= sortedArr.length) return sortedArr[lower]; return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight; } const p50Cr = minsToHrs(getPercentile(crTimesSorted, 0.5)); const p90Cr = minsToHrs(getPercentile(crTimesSorted, 0.9)); const p99Cr = minsToHrs(getPercentile(crTimesSorted, 0.99)); return { lastUpdated: new Date().toISOString(), assigneeData, monthlyDone, issuesList, warnings, currentStates, deliveryData: { avgLeadTime: deliveryData.doneTicketsLead > 0 ? Math.round(deliveryData.totalLeadTimeDays / deliveryData.doneTicketsLead) : 0, avgCycleTime: deliveryData.doneTicketsCycle > 0 ? Math.round(deliveryData.totalCycleTimeDays / deliveryData.doneTicketsCycle) : 0, spOriginal: deliveryData.doneSpOriginal, spFinal: deliveryData.doneSpFinal, spDeviationPct: deliveryData.doneSpOriginal > 0 ? ((deliveryData.doneSpFinal - deliveryData.doneSpOriginal) / deliveryData.doneSpOriginal * 100).toFixed(1) : 0 }, crData: { ...crData, monthlyAvgCr, p50Cr, p90Cr, p99Cr, avgTimeCr: crData.tickets > 0 ? minsToHrs(crData.totalTimeCr / crData.tickets) : 0 }, qaData: { ...qaData, monthlyAvgQa, monthlyAvgRtqa, avgTimeQa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0, avgTimeRtqa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0 } }; } // ─── Handler Netlify ─── exports.handler = async (event) => { try { const queryParams = event.queryStringParameters || {}; const forceRefresh = queryParams.refresh === '1'; const period = queryParams.period || '90d'; if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) { return { statusCode: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, body: JSON.stringify({ ..._cache[period], fromCache: true }) }; } if (!config.jira.email || !config.jira.token) throw new Error('Faltan credenciales JIRA en variables de entorno'); const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period); const data = processIssues(jiraData.issues, period); data.isTruncated = jiraData.isTruncated; data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length; data.period = period; _cache[period] = data; _cacheTime[period] = Date.now(); return { statusCode: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, fromCache: false }) }; } catch (err) { return { statusCode: 500, headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, body: JSON.stringify({ error: err.message }) }; } };