first commit

This commit is contained in:
kvilches1410
2026-03-31 13:10:10 -03:00
parent 4183bb8313
commit 1928661734
4 changed files with 448 additions and 89 deletions
+216 -89
View File
@@ -1,122 +1,249 @@
// api/data.js — Vercel Serverless Function
// Consulta Jira en tiempo real y devuelve los datos procesados del dashboard.
// Variables de entorno requeridas: JIRA_EMAIL, JIRA_TOKEN
const JIRA_HOST = 'invera.atlassian.net';
const JQL = 'project = LABS AND cf[10016] is not EMPTY ORDER BY updated DESC';
const FIELDS = [
'summary', 'status', 'assignee',
'customfield_10016', // Story point estimate
'customfield_11019', // Story Points Finales
'customfield_10622', // Desarrollador (multi-user)
'parent', 'resolutiondate', 'created',
].join(',');
const MAX_RESULTS = 100;
const CACHE_MS = 24 * 60 * 60 * 1000; // 24 horas
// Cache en memoria (se reinicia con cada cold start de la función)
let _cache = null;
/**
* 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 ──────────────────────────────────────────────────────────────
// ─── 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://${JIRA_HOST}/rest/api/3/search/jql`
+ `?jql=${encodeURIComponent(JQL)}`
+ `&fields=${encodeURIComponent(FIELDS)}`
+ `&maxResults=${MAX_RESULTS}`;
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',
'Accept': 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Jira API ${res.status}: ${text.slice(0, 200)}`);
const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`;
console.error(`❌ ${errorMsg}`);
throw new Error(errorMsg);
}
return res.json();
const data = await res.json();
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
return data;
}
// ─── Data processing ─────────────────────────────────────────────────────────
// ─── 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 = {};
const monthlyDone = {};
const epicMap = {};
let processedCount = 0;
let skippedCount = 0;
for (const issue of issues) {
const f = issue.fields;
// Desarrollador (customfield_10622 multi-user), fallback a assignee
const devs = f.customfield_10622?.length
? f.customfield_10622.map(d => d.displayName)
: f.assignee
? [f.assignee.displayName]
: ['Sin asignar'];
// SP: SP Finales si está cargado, si no el estimate
const sp = (f.customfield_11019 != null ? f.customfield_11019 : f.customfield_10016) ?? 0;
// Estado
const catKey = f.status?.statusCategory?.key ?? 'new';
// Fechas (YYYY-MM)
const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null;
const creMonth = f.created ? f.created.slice(0, 7) : null;
// Épica (parent)
const epic = 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 (catKey === 'done') assigneeData[dev].done += sp;
else if (catKey === 'indeterminate') assigneeData[dev].in_progress += sp;
else assigneeData[dev].todo += sp;
}
// ── Acumular completados por mes ──
if (catKey === 'done') {
const month = resMonth || creMonth;
if (month) {
if (!monthlyDone[month]) monthlyDone[month] = {};
for (const dev of devs) {
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
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++;
}
// ── Acumular por épica ──
if (!epicMap[epic]) epicMap[epic] = { name: epic, done: 0, in_progress: 0, todo: 0 };
if (catKey === 'done') epicMap[epic].done += sp;
else if (catKey === 'indeterminate') epicMap[epic].in_progress += sp;
else epicMap[epic].todo += sp;
}
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
return {
lastUpdated: new Date().toISOString(),
totalIssues: issues.length,
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)),
.sort((a, b) =>
(b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)
),
};
}
// ─── Handler ─────────────────────────────────────────────────────────────────
module.exports = async (req, res) => {
// ─── 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');
// Force refresh si se pasa ?refresh=1
const forceRefresh = req.query?.refresh === '1';
// Servir desde caché si está fresco
if (!forceRefresh && _cache && (Date.now() - _cacheTime) < CACHE_MS) {
return res.status(200).json({ ..._cache, fromCache: true });
}
const email = process.env.JIRA_EMAIL;
const token = process.env.JIRA_TOKEN;
if (!email || !token) {
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: 'Faltan variables de entorno: JIRA_EMAIL y/o JIRA_TOKEN',
hint: 'Configurarlas en Vercel → Settings → Environment Variables',
error: err.message,
hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno',
timestamp: new Date().toISOString(),
});
}
try {
const { issues, total } = await fetchJiraIssues(email, token);
const data = processIssues(issues);
data.jiraTotal = total; // total real en Jira (puede ser > 100)
_cache = data;
_cacheTime = Date.now();
return res.status(200).json({ ...data, fromCache: false });
} catch (err) {
console.error('[dashboard/api/data]', err.message);
return res.status(500).json({ error: err.message });
}
};