250 lines
9.0 KiB
JavaScript
250 lines
9.0 KiB
JavaScript
/**
|
|
* 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 = null;
|
|
let _cacheTime = 0;
|
|
|
|
// ─── 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) {
|
|
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
|
|
|
const url = `https://${config.jira.host}/rest/api/3/search/jql`
|
|
+ `?jql=${encodeURIComponent(config.jql)}`
|
|
+ `&fields=${encodeURIComponent(buildFieldsList())}`
|
|
+ `&maxResults=${config.api.maxResults}`;
|
|
|
|
console.log(`📡 Consultando JIRA: ${config.jira.project} (${config.api.maxResults} resultados max)`);
|
|
console.log(`📋 JQL: ${config.jql}`);
|
|
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Basic ${auth}`,
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`;
|
|
console.error(`❌ ${errorMsg}`);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const data = await res.json();
|
|
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
|
|
|
|
return data;
|
|
}
|
|
|
|
// ─── 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)
|
|
'parent',
|
|
'resolutiondate',
|
|
'created',
|
|
].join(',');
|
|
}
|
|
|
|
// ─── Process Issues ────────────────────────────────────────────────
|
|
/**
|
|
* Procesa issues de JIRA y agrupa los datos
|
|
* @param {Array} issues - Array de issues de JIRA
|
|
* @returns {Object} Datos procesados
|
|
*/
|
|
function processIssues(issues) {
|
|
const assigneeData = {};
|
|
const monthlyDone = {};
|
|
const epicMap = {};
|
|
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;
|
|
|
|
// ── Extraer Épica ─────────────────────────────────────────
|
|
const epicName = f.parent?.fields?.summary ?? 'Sin épica';
|
|
|
|
// ── 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Acumular por Épica ─────────────────────────────────────
|
|
if (!epicMap[epicName]) {
|
|
epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 };
|
|
}
|
|
|
|
if (isDone) {
|
|
epicMap[epicName].done += sp;
|
|
} else if (isInProgress) {
|
|
epicMap[epicName].in_progress += sp;
|
|
} else {
|
|
epicMap[epicName].todo += sp;
|
|
}
|
|
|
|
processedCount++;
|
|
} catch (error) {
|
|
console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`);
|
|
skippedCount++;
|
|
}
|
|
}
|
|
|
|
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
|
|
|
|
return {
|
|
lastUpdated: new Date().toISOString(),
|
|
totalIssues: processedCount,
|
|
skippedIssues: skippedCount,
|
|
assigneeData,
|
|
monthlyDone,
|
|
epicData: Object.values(epicMap)
|
|
.sort((a, b) =>
|
|
(b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)
|
|
),
|
|
};
|
|
}
|
|
|
|
// ─── 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 con parámetro ?refresh=1
|
|
const forceRefresh = req.query?.refresh === '1';
|
|
|
|
// Servir desde caché si está fresco
|
|
if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) {
|
|
console.log('📦 Sirviendo desde caché (en memoria)');
|
|
return res.status(200).json({ ..._cache, fromCache: true });
|
|
}
|
|
|
|
// ── Consultar JIRA ─────────────────────────────────────────
|
|
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token);
|
|
const data = processIssues(jiraData.issues);
|
|
|
|
// Agregar metadata
|
|
data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults)
|
|
data.config = {
|
|
project: config.jira.project,
|
|
host: config.jira.host,
|
|
maxResults: config.api.maxResults,
|
|
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
|
|
};
|
|
|
|
// Guardar en caché en memoria
|
|
_cache = data;
|
|
_cacheTime = Date.now();
|
|
|
|
console.log('✅ Datos frescos desde JIRA almacenados en caché');
|
|
return res.status(200).json({ ...data, fromCache: false });
|
|
|
|
} catch (err) {
|
|
console.error('❌ [dashboard/api/data] Error:', err.message);
|
|
|
|
// Intentar servir desde caché si hay error
|
|
if (_cache) {
|
|
console.log('⚠️ Sirviendo caché anterior debido a error');
|
|
return res.status(200).json({
|
|
..._cache,
|
|
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(),
|
|
});
|
|
}
|
|
};
|