/** * 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', }, 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', 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, ].join(','); } 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(); const qaData = { tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0, monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [], }; 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 sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0; const statusKey = f.status?.statusCategory?.key ?? 'new'; const isDone = statusKey === 'done'; const isInProgress = statusKey === 'indeterminate'; 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; } } } 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; } 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 && 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] }); } } return { lastUpdated: new Date().toISOString(), assigneeData, monthlyDone, issuesList, warnings, 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); 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 }) }; } };