Files
dashboard-infra/netlify/functions/data.js
T
kvilches1410 d7f7339fde Muchos cambios
2026-04-06 18:59:47 -03:00

407 lines
16 KiB
JavaScript

/**
* 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 })
};
}
};