342 lines
13 KiB
JavaScript
342 lines
13 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',
|
|
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) {
|
|
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: [],
|
|
};
|
|
|
|
const deliveryData = {
|
|
totalLeadTimeDays: 0, totalCycleTimeDays: 0, doneTicketsLead: 0, doneTicketsCycle: 0,
|
|
doneSpOriginal: 0, doneSpFinal: 0
|
|
};
|
|
|
|
const crData = {
|
|
tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: []
|
|
};
|
|
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
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 && 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]);
|
|
|
|
return {
|
|
lastUpdated: new Date().toISOString(),
|
|
assigneeData, monthlyDone, issuesList, warnings,
|
|
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,
|
|
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);
|
|
|
|
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 })
|
|
};
|
|
}
|
|
};
|