diff --git a/.gitignore b/.gitignore index e985853..013f752 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .vercel +.env +ex.env diff --git a/api/config.js b/api/config.js new file mode 100644 index 0000000..a6d13e9 --- /dev/null +++ b/api/config.js @@ -0,0 +1,108 @@ +/** + * config.js — Configuración centralizada del Dashboard JIRA + * + * Carga y valida todas las variables de entorno. + * Uso: import { config } from './config.js' + */ + +// Función helper para obtener variable o lanzar error +function getEnv(key, defaultValue = null) { + const value = process.env[key] || defaultValue; + if (!value) { + throw new Error(`❌ Variable de entorno requerida no configurada: ${key}`); + } + return value; +} + +// Función para convertir string a número +function getEnvNumber(key, defaultValue) { + const value = process.env[key] || defaultValue; + const num = parseInt(value, 10); + if (isNaN(num)) { + throw new Error(`❌ ${key} debe ser un número válido, recibió: ${value}`); + } + return num; +} + +// Validar que las credenciales estén presentes +function validateCredentials() { + const email = process.env.JIRA_EMAIL; + const token = process.env.JIRA_TOKEN; + + if (!email) { + throw new Error('❌ Falta JIRA_EMAIL en variables de entorno'); + } + if (!token) { + throw new Error('❌ Falta JIRA_TOKEN en variables de entorno'); + } + + // Validar formato de email + if (!email.includes('@')) { + throw new Error(`❌ JIRA_EMAIL parece inválido: ${email}`); + } + + // Validar que el token no esté vacío + if (token.length < 10) { + throw new Error('❌ JIRA_TOKEN parece muy corto, verifica que sea válido'); + } +} + +// Construir JQL dinámicamente reemplazando variables +function buildJQL() { + const baseJQL = getEnv('JIRA_JQL', + 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC' + ); + + return baseJQL + .replace('${PROJECT}', getEnv('JIRA_PROJECT', 'LABS')) + .replace('${FIELD_SP_ESTIMATE}', getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016')); +} + +// Exportar configuración +export const config = { + // ── JIRA Host ────────────────────────────────────────────────────── + jira: { + host: getEnv('JIRA_HOST', 'invera.atlassian.net'), + email: getEnv('JIRA_EMAIL'), + token: getEnv('JIRA_TOKEN'), + project: getEnv('JIRA_PROJECT', 'LABS'), + }, + + // ── Custom Fields (IDs) ──────────────────────────────────────────── + fields: { + spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'), + spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'), + developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'), + }, + + // ── JQL Query ────────────────────────────────────────────────────── + jql: buildJQL(), + + // ── API Settings ─────────────────────────────────────────────────── + api: { + maxResults: getEnvNumber('MAX_RESULTS', 500), + cacheMsec: getEnvNumber('CACHE_MS', 86400000), // 24 horas por defecto + }, + + // ── Environment ──────────────────────────────────────────────────── + env: { + nodeEnv: getEnv('NODE_ENV', 'development'), + isProduction: getEnv('NODE_ENV', 'development') === 'production', + isDevelopment: getEnv('NODE_ENV', 'development') === 'development', + }, +}; + +// ── Validación al cargar ─────────────────────────────────────────── +try { + validateCredentials(); + console.log('✅ Configuración cargada correctamente'); + console.log(` Host JIRA: ${config.jira.host}`); + console.log(` Proyecto: ${config.jira.project}`); + console.log(` Max Results: ${config.api.maxResults}`); + console.log(` Cache: ${config.api.cacheMsec / 1000 / 60}min`); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +export default config; diff --git a/api/data.js b/api/data.js index 8a5fcbc..42d72fa 100644 --- a/api/data.js +++ b/api/data.js @@ -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 }); - } }; diff --git a/api/ex-data.js b/api/ex-data.js new file mode 100644 index 0000000..24caec7 --- /dev/null +++ b/api/ex-data.js @@ -0,0 +1,122 @@ +// 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_11217', // Story Points Finales + 'customfield_10628', // Desarrollador (multi-user) + 'parent', 'resolutiondate', 'created', +].join(','); +const MAX_RESULTS = 500; +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; +let _cacheTime = 0; +// ─── Jira fetch ────────────────────────────────────────────────────────────── +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 res = await fetch(url, { + 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)}`); + } + return res.json(); +} +// ─── Data processing ───────────────────────────────────────────────────────── +function processIssues(issues) { + const assigneeData = {}; + const monthlyDone = {}; + const epicMap = {}; + for (const issue of issues) { + const f = issue.fields; + // Desarrollador (customfield_10628 multi-user), fallback a assignee + const devs = f.customfield_10628?.length + ? f.customfield_10628.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_11217 != null ? f.customfield_11217 : 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; + } + } + } + // ── 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; + } + return { + lastUpdated: new Date().toISOString(), + totalIssues: issues.length, + assigneeData, + monthlyDone, + epicData: Object.values(epicMap) + .sort((a, b) => (b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)), + }; +} +// ─── Handler ───────────────────────────────────────────────────────────────── +module.exports = async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET'); + // 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) { + return res.status(500).json({ + error: 'Faltan variables de entorno: JIRA_EMAIL y/o JIRA_TOKEN', + hint: 'Configurarlas en Vercel → Settings → Environment Variables', + }); + } + 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 }); + } +};