/** * api/data.js — Vercel Serverless Function (Refactorizado) * * Consulta JIRA en tiempo real y devuelve datos procesados del dashboard. * * Usa: api/config.js para centralizar variables de entorno */ import { config } from './config.js'; // ─── Cache en memoria ────────────────────────────────────────────── let _cache = {}; let _cacheTime = {}; export function clearCache() { _cache = {}; _cacheTime = {}; console.log('🧹 Caché en memoria limpiada'); } // ─── JIRA Fetch ──────────────────────────────────────────────────── /** * Consulta JIRA API con Basic Auth * @param {string} email - Email JIRA * @param {string} token - API Token * @returns {Promise<{issues: Array, total: number}>} */ async function fetchJiraIssues(email, token, period) { const auth = Buffer.from(`${email}:${token}`).toString('base64'); const maxResultsWanted = 1500; // Límite seguro por usuario let allIssues = []; let totalInJira = 0; let startAt = 0; // Inyectar el filtro de fecha en el JQL base let dynamicJql = config.jql; if (period !== 'all') { const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`; // Intentamos insertarlo antes del ORDER BY 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} (${maxResultsWanted} resultados max, periodo: ${period})`); console.log(`📋 JQL: ${dynamicJql}`); 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; // No more pages } } // Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado const isTruncated = !!nextPageToken; console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`); return { issues: allIssues, isTruncated: isTruncated }; } // ─── Build Fields List ───────────────────────────────────────────── /** * Construye lista de campos a traer de JIRA */ function buildFieldsList() { return [ 'summary', 'status', 'assignee', config.fields.spEstimate, // Story points estimate config.fields.spFinal, // Story points finales config.fields.developer, // Desarrollador (multi-user) 'resolutiondate', 'created', 'updated', // ── QA Fields ────────────────────────────────────── config.fields.qaAssignee, // QA asignado config.fields.qaEntryRtqa, // Fecha entrada READY TO QA config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min) config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min) config.fields.qaEntryQa, // Fecha entrada QA config.fields.qaTotalTimeQa, // Tiempo total en QA (min) config.fields.qaLastDurQa, // Última duración en QA (min) config.fields.timesReturned, // Veces que volvió a En Curso ].join(','); } // ─── Process Issues ──────────────────────────────────────────────── /** * Procesa issues de JIRA y agrupa los datos * @param {Array} issues - Array de issues de JIRA * @returns {Object} Datos procesados */ // Helper: convierte minutos a horas redondeadas a 1 decimal function minsToHrs(mins) { if (mins == null || mins === 0) return 0; return Math.round((mins / 60) * 10) / 10; } function processIssues(issues) { const assigneeData = {}; const monthlyDone = {}; const issuesList = []; const warnings = []; const now = new Date(); // ── QA Aggregation ────────────────────────────────────────────────── const qaData = { tickets: 0, ticketsReturned: 0, totalTimeQa: 0, // minutos totalTimeRtqa: 0, // minutos monthlyTotalQa: {}, // { 'YYYY-MM': totalMin } monthlyCountQa: {}, // { 'YYYY-MM': count } monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [], }; let processedCount = 0; let skippedCount = 0; for (const issue of issues) { try { const f = issue.fields; // ── Extraer desarrolladores ──────────────────────────────── // Prioridad: customfield_10622 (multi) → assignee (single) let devs = []; if (f[config.fields.developer]?.length) { // Multi-usuario devs = f[config.fields.developer].map(d => d.displayName); } else if (f.assignee) { // Single usuario devs = [f.assignee.displayName]; } else { // Sin asignar devs = ['Sin asignar']; } // ── Extraer Story Points ─────────────────────────────────── // Prioridad: SP Finales → SP Estimate const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0; // ── Extraer Status ───────────────────────────────────────── const statusKey = f.status?.statusCategory?.key ?? 'new'; const isDone = statusKey === 'done'; const isInProgress = statusKey === 'indeterminate'; // ── Extraer Fechas (YYYY-MM) ────────────────────────────── const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null; const creMonth = f.created ? f.created.slice(0, 7) : null; // ── Acumular por Desarrollador ──────────────────────────── for (const dev of devs) { if (!assigneeData[dev]) { assigneeData[dev] = { done: 0, in_progress: 0, todo: 0 }; } if (isDone) { assigneeData[dev].done += sp; } else if (isInProgress) { assigneeData[dev].in_progress += sp; } else { assigneeData[dev].todo += sp; } } // ── Acumular completados por mes ─────────────────────────── 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; } } } // ── Generar issuesList para Filtros ─────────────────────── issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp: sp, devs: devs, creMonth }); // ── Acumular datos de QA ──────────────────────────────────── const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0; const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0; const lastDurQa = f[config.fields.qaLastDurQa] ?? 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), lastDurQa: minsToHrs(lastDurQa), 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; } // ── Detectar Warnings ─────────────────────────────────────── // 1. Tickets > 14 días in-progress 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) }); } } // 2. Done sin SP if (isDone && sp === 0) { warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 }); } // 3. SP sin asignar (todo/in-progress sin dev) if (!isDone && devs.includes('Sin asignar') && sp > 0) { warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp }); } processedCount++; } catch (error) { console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`); skippedCount++; } } console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`); // ── Calcular promedios mensuales QA ───────────────────────────────── 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]); } qaData.monthlyAvgQa = monthlyAvgQa; qaData.monthlyAvgRtqa = monthlyAvgRtqa; qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0; qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0; // Limpiar campos internos de acumulación delete qaData.monthlyTotalQa; delete qaData.monthlyCountQa; delete qaData.monthlyTotalRtqa; delete qaData.monthlyCountRtqa; console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`); return { lastUpdated: new Date().toISOString(), totalIssues: processedCount, skippedIssues: skippedCount, assigneeData, monthlyDone, issuesList, warnings, qaData, }; } // ─── Main Handler ────────────────────────────────────────────────── /** * Vercel Serverless Function Handler */ export default async (req, res) => { // ── CORS ─────────────────────────────────────────────────────── res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); // ── Caching ──────────────────────────────────────────────────── // CDN cache: 24hs fresco, sirve stale mientras recarga res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600'); try { // Force refresh y Periodo temporal const forceRefresh = req.query?.refresh === '1'; const period = req.query?.period || '90d'; // Servir desde caché segmentado por período si está fresco if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) { console.log(`📦 Sirviendo desde caché para periodo: ${period}`); return res.status(200).json({ ..._cache[period], fromCache: true }); } // ── Consultar JIRA ───────────────────────────────────────── const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period); const data = processIssues(jiraData.issues); // Agregar metadata data.isTruncated = jiraData.isTruncated; // Si hay más paginación pendiente del límite data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length; data.period = period; data.config = { project: config.jira.project, host: config.jira.host, maxResults: 1500, cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60), }; // Guardar en caché segmentado _cache[period] = data; _cacheTime[period] = Date.now(); console.log(`✅ Datos frescos almacenados en caché para periodo: ${period}`); return res.status(200).json({ ...data, fromCache: false }); } catch (err) { console.error('❌ [dashboard/api/data] Error:', err.message); // Intentar servir desde caché global si hay error const period = req.query?.period || '90d'; if (_cache[period]) { console.log('⚠️ Sirviendo caché anterior debido a error'); return res.status(200).json({ ..._cache[period], fromCache: true, error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`, }); } // Si no hay caché, retornar error return res.status(500).json({ error: err.message, hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno', timestamp: new Date().toISOString(), }); } };