123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
// 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 });
|
|
}
|
|
};
|