Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
68 KiB
JSON
1 line
68 KiB
JSON
{"repo": "anbepa/test-plan-app", "files": {".env.example": {"3b32e64618a4906dd3f268afce4c691fe11069d9": {"t": 1785745183.4043047, "n": 2040, "h": "6c34f76b20b77c03", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# PROVEEDORES DE IA\n# ============================================\n# Proveedor activo (gemini | deepseek)\nAI_PROVIDER=deepseek\n\n# Google Gemini\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# DeepSeek\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_MODEL=deepseek-chat\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n\n# ============================================\n# SERENITY REPORT - GitHub Actions (fallback global para todos los usuarios)\n# Estos valores se usan cuando el usuario no tiene config propia en Supabase.\n# ============================================\nGH_DISPATCH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nGH_DISPATCH_OWNER=anbepa\nGH_DISPATCH_REPO=ManualTest\nGH_DISPATCH_WORKFLOW_ID=serenity-report.yml\nGH_DISPATCH_BRANCH=main\nGH_DISPATCH_GITHUB_USERNAME=anbepa\nGH_DISPATCH_WORKFLOW_NAME=Serenity Report\nGH_DISPATCH_REPOSITORY_URL=https://github.com/anbepa/ManualTest\n"}, "c81fe79e7e72b874ca585cefa7685f13f0dc6e67": {"t": 1785748447.8937042, "n": 2040, "h": "6c34f76b20b77c03", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# PROVEEDORES DE IA\n# ============================================\n# Proveedor activo (gemini | deepseek)\nAI_PROVIDER=deepseek\n\n# Google Gemini\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# DeepSeek\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_MODEL=deepseek-chat\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n\n# ============================================\n# SERENITY REPORT - GitHub Actions (fallback global para todos los usuarios)\n# Estos valores se usan cuando el usuario no tiene config propia en Supabase.\n# ============================================\nGH_DISPATCH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nGH_DISPATCH_OWNER=anbepa\nGH_DISPATCH_REPO=ManualTest\nGH_DISPATCH_WORKFLOW_ID=serenity-report.yml\nGH_DISPATCH_BRANCH=main\nGH_DISPATCH_GITHUB_USERNAME=anbepa\nGH_DISPATCH_WORKFLOW_NAME=Serenity Report\nGH_DISPATCH_REPOSITORY_URL=https://github.com/anbepa/ManualTest\n"}, "23bf6ad0e6daee62b71b86371f4e61f93397980d": {"t": 1785748448.9972963, "n": 1791, "h": "70fddec9d58b1b29", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# PROVEEDORES DE IA\n# ============================================\n# Proveedor activo (gemini | deepseek)\nAI_PROVIDER=deepseek\n\n# Google Gemini\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# DeepSeek\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_MODEL=deepseek-chat\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n\n# ============================================\n# SERENITY REPORT (GitHub Actions workflow)\n# ============================================\nGH_DISPATCH_TOKEN=your_github_personal_access_token\nGH_DISPATCH_OWNER=your_github_username_or_org\nGH_DISPATCH_REPO=your_workflow_repo_name\nGH_DISPATCH_WORKFLOW_ID=serenity-report.yml\n"}, "534bca3096e7f0e263a9e0fab5948846cdc1cbda": {"t": 1785748450.1194599, "n": 1469, "h": "a28e85bd0a32960e", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# PROVEEDORES DE IA\n# ============================================\n# Proveedor activo (gemini | deepseek)\nAI_PROVIDER=deepseek\n\n# Google Gemini\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# DeepSeek\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_MODEL=deepseek-chat\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n"}, "c019a809bef18c0d8859a0b4164d01660a032b86": {"t": 1785748451.2256422, "n": 1504, "h": "d5be024d8b07d95c", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# PROVEEDORES DE IA\n# ============================================\n# Proveedor activo (gemini | deepseek)\nAI_PROVIDER=deepseek\n\n# Google Gemini\nGEMINI_API_KEY=your_gemini_api_key_here\nGEMINI_MODEL=gemini-2.5-flash-lite\n\n# DeepSeek\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_MODEL=deepseek-chat\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n"}, "02a5a2adc652372eadb097fedfa2ff40ace30f56": {"t": 1785748452.2884445, "n": 1314, "h": "22b81abada8c2682", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# GEMINI API CONFIGURATION\n# ============================================\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# Variables para Angular (deben empezar con NG_APP_)\nNG_APP_SUPABASE_URL=your_supabase_project_url\nNG_APP_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n"}, "c8ae664660b5f71a981506b07e7ca80aedaa06c1": {"t": 1785748453.3955297, "n": 1166, "h": "6433ace3b48f6467", "c": "# =====================================================\n# VARIABLES DE ENTORNO - TEMPLATE\n# =====================================================\n# Copia este archivo como .env.local y llena con tus credenciales REALES\n# Ver ENV_SETUP.md para instrucciones completas\n\n# ============================================\n# ENCRYPTION KEY (genera con: openssl rand -base64 32)\n# ============================================\nENCRYPTION_KEY=your_32_byte_encryption_key_here\n\n# ============================================\n# GEMINI API CONFIGURATION\n# ============================================\nGEMINI_API_KEY=your_gemini_api_key_here\n\n# ============================================\n# SUPABASE CONFIGURATION (Frontend - anon key)\n# ============================================\nSUPABASE_URL=your_supabase_project_url\nSUPABASE_KEY=your_supabase_anon_key\n\n# ============================================\n# SUPABASE ADMIN (Backend - service_role key)\n# ============================================\nSUPABASE_SERVICE_KEY=your_supabase_service_role_key\n\n# ============================================\n# FEATURE FLAGS\n# ============================================\nUSE_DATABASE=true\n"}}, "local-api-server.js": {"38777f56cd096f15c6968d4898b3b430fb877df5": {"t": 1786068263.4611814, "n": 55842, "h": "84e515090e4b41ce", "c": "require('dotenv').config({ path: '.env.local' });\nconst express = require('express');\nconst cors = require('cors');\nconst fetch = require('node-fetch');\nconst { createClient } = require('@supabase/supabase-js');\nconst sanitizeHtml = require('sanitize-html');\nconst { htmlToText } = require('html-to-text');\nconst { decode } = require('he');\n\nconst app = express();\nconst PORT = 3000;\n\n// Configuración básica\napp.use(cors());\napp.use(express.json({ limit: '150mb' }));\n\n// Logging inicial\nconsole.log('\\n[0] [dotenv@17.2.3] injecting env from .env.local');\nconsole.log('[0] ');\nconsole.log(`[0] [SERVER] Local API server running on http://localhost:${PORT}`);\nconsole.log(`[0] [ENDPOINT] http://localhost:${PORT}/api/gemini-proxy`);\n\nconst GEMINI_API_KEY = process.env.GEMINI_API_KEY;\nif (GEMINI_API_KEY) {\n console.log('[0] [API_KEY] GEMINI_API_KEY: Configured');\n} else {\n console.warn('[0] [API_KEY] GEMINI_API_KEY: NOT FOUND in .env.local');\n}\n\nconst MODEL_NAME = 'gemini-2.5-flash-lite';\nconsole.log(`[0] [MODEL] Using ${MODEL_NAME} (v1 REST API)`);\nconsole.log('[0] ');\n\nconst DEEPSEEK_MIN_REQUEST_INTERVAL = 1000;\nlet deepseekLastRequestTime = 0;\n\nasync function waitForDeepSeekRateLimit() {\n const now = Date.now();\n const timeSinceLastRequest = now - deepseekLastRequestTime;\n\n if (timeSinceLastRequest < DEEPSEEK_MIN_REQUEST_INTERVAL) {\n const waitTime = DEEPSEEK_MIN_REQUEST_INTERVAL - timeSinceLastRequest;\n await new Promise(resolve => setTimeout(resolve, waitTime));\n }\n\n deepseekLastRequestTime = Date.now();\n}\n\nasync function callDeepSeekWithRetry(apiKey, apiBody, maxRetries = 3) {\n let lastError = null;\n const url = 'https://api.deepseek.com/chat/completions';\n\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n try {\n await waitForDeepSeekRateLimit();\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`\n },\n body: JSON.stringify(apiBody)\n });\n\n const responseData = await response.json();\n\n if (!response.ok) {\n throw new Error(JSON.stringify(responseData));\n }\n\n return responseData;\n } catch (error) {\n lastError = error;\n const errorMessage = error?.message || String(error);\n const isRateLimit = errorMessage.includes('429') || errorMessage.includes('Too Many Requests');\n\n if (isRateLimit && attempt < maxRetries) {\n const backoffTime = Math.pow(2, attempt) * 2000;\n await new Promise(resolve => setTimeout(resolve, backoffTime));\n continue;\n }\n\n if (attempt < maxRetries && (errorMessage.includes('500') || errorMessage.includes('Internal Server Error'))) {\n const backoffTime = Math.pow(2, attempt) * 1000;\n await new Promise(resolve => setTimeout(resolve, backoffTime));\n continue;\n }\n\n break;\n }\n }\n\n throw lastError;\n}\n\nfunction requiredEnv(name) {\n const value = process.env[name];\n if (!value || !value.trim()) {\n throw new Error(`Variable de entorno faltante: ${name}`);\n }\n return value;\n}\n\nfunction getSupabaseClients() {\n const supabaseUrl = requiredEnv('SUPABASE_URL');\n const anonKey = process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_KEY || requiredEnv('SUPABASE_ANON_KEY');\n const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY || requiredEnv('SUPABASE_SERVICE_ROLE_KEY');\n\n return {\n authClient: createClient(supabaseUrl, anonKey, {\n auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false }\n }),\n adminClient: createClient(supabaseUrl, serviceRoleKey, {\n auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false }\n })\n };\n}\n\nfunction apiError(status, message, meta = null) {\n const error = new Error(message);\n error.status = status;\n error.meta = meta;\n return error;\n}\n\nfunction normalizeOrganization(input) {\n const raw = String(input || '').trim();\n if (!raw) throw apiError(400, 'La organización de Azure DevOps es obligatoria.');\n\n const withoutProtocol = raw.replace(/^https?:\\/\\//i, '');\n const withoutDomain = withoutProtocol.replace(/^dev\\.azure\\.com\\//i, '');\n const normalized = withoutDomain.split('/')[0].trim();\n\n if (!normalized) throw apiError(400, 'La organización de Azure DevOps no es válida.');\n return normalized;\n}\n\nfunction buildAzureBaseUrl(organization) {\n return `https://dev.azure.com/${encodeURIComponent(organization)}`;\n}\n\nfunction maskSerenityTokenHint(pat) {\n const lastFour = String(pat || '').slice(-4).toUpperCase();\n return `••••${lastFour || '----'}`;\n}\n\nfunction cleanHtmlContent(inputHtml) {\n if (!inputHtml || !String(inputHtml).trim()) return '';\n\n const decoded = decode(String(inputHtml), { isAttributeValue: false });\n const sanitized = sanitizeHtml(decoded, {\n allowedTags: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'br', 'strong', 'b', 'em', 'i', 'u'],\n allowedAttributes: {},\n disallowedTagsMode: 'discard'\n });\n\n const plainText = htmlToText(sanitized, {\n wordwrap: false,\n preserveNewlines: true,\n selectors: [\n { selector: 'a', options: { ignoreHref: true } },\n { selector: 'img', format: 'skip' }\n ]\n });\n\n return plainText\n .replace(/\\u00A0/g, ' ')\n .replace(/\\t/g, ' ')\n .replace(/[ ]{2,}/g, ' ')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\nasync function azureGet(url, personalAccessToken) {\n const timeoutMs = Number(process.env.AZURE_DEVOPS_TIMEOUT_MS || 12000);\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const basicToken = Buffer.from(`:${personalAccessToken}`).toString('base64');\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n Authorization: `Basic ${basicToken}`,\n Accept: 'application/json'\n },\n signal: controller.signal\n });\n\n const data = await response.json().catch(() => ({}));\n if (!response.ok) {\n if (response.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (response.status === 403) throw apiError(403, 'No tienes permisos para consultar Azure DevOps.');\n if (response.status === 404) throw apiError(404, 'La Historia de Usuario no existe en Azure DevOps.');\n throw apiError(502, 'No fue posible consultar Azure DevOps en este momento.');\n }\n\n return data;\n } catch (error) {\n if (error.status) throw error;\n if (String(error?.message || '').includes('aborted')) {\n throw apiError(504, 'La consulta a Azure DevOps excedió el tiempo de espera.');\n }\n throw apiError(502, 'Error de conexión con Azure DevOps.');\n } finally {\n clearTimeout(timeoutId);\n }\n}\n\nasync function validateAzureConnection(organization, personalAccessToken) {\n const endpoint = `${buildAzureBaseUrl(organization)}/_apis/projects?$top=1&api-version=7.1`;\n await azureGet(endpoint, personalAccessToken);\n}\n\nasync function getAuthenticatedUser(req) {\n const authHeader = req.headers.authorization;\n const [type, token] = String(authHeader || '').split(' ');\n\n if (type?.toLowerCase() !== 'bearer' || !token) {\n throw apiError(401, 'No autorizado. Inicia sesión nuevamente.');\n }\n\n const { authClient } = getSupabaseClients();\n const { data, error } = await authClient.auth.getUser(token.trim());\n if (error || !data?.user?.id) {\n throw apiError(401, 'Sesión inválida o expirada. Inicia sesión nuevamente.');\n }\n\n return data.user;\n}\n\nasync function getAzureConnection(userId, organization = null) {\n const { adminClient } = getSupabaseClients();\n const { data, error } = await adminClient.rpc('azure_get_connection', {\n p_user_id: userId,\n p_organization: organization || null\n });\n\n if (error) throw apiError(500, 'Error al consultar la conexión de Azure DevOps.');\n return Array.isArray(data) ? data[0] || null : data || null;\n}\n\nasync function getAzureConnectionWithSecret(userId, organization = null) {\n const { adminClient } = getSupabaseClients();\n const { data, error } = await adminClient.rpc('azure_get_connection_secret', {\n p_user_id: userId,\n p_organization: organization || null\n });\n\n if (error) throw apiError(500, 'Error al consultar el secreto de Azure DevOps.');\n return Array.isArray(data) ? data[0] || null : data || null;\n}\n\nasync function upsertAzureConnection(userId, organization, personalAccessToken, status = 'connected') {\n const { adminClient } = getSupabaseClients();\n const { data, error } = await adminClient.rpc('azure_upsert_connection', {\n p_user_id: userId,\n p_organization: organization,\n p_personal_access_token: personalAccessToken,\n p_status: status\n });\n\n if (error) {\n console.error('[AZURE][UPSERT_CONNECTION][RPC_ERROR]', {\n code: error.code,\n message: error.message,\n details: error.details,\n hint: error.hint\n });\n throw apiError(\n 500,\n 'Error al crear o actualizar el secreto de Azure DevOps.',\n {\n source: 'supabase-rpc:azure_upsert_connection',\n code: error.code,\n details: error.details,\n hint: error.hint,\n message: error.message\n }\n );\n }\n const row = Array.isArray(data) ? data[0] : data;\n if (!row?.id) throw apiError(500, 'No se pudo guardar la conexión de Azure DevOps.');\n return row;\n}\n\nasync function updateAzureConnectionStatus(userId, organization, status) {\n const { adminClient } = getSupabaseClients();\n const { error } = await adminClient.rpc('azure_update_connection_status', {\n p_user_id: userId,\n p_organization: organization,\n p_status: status\n });\n if (error) throw apiError(500, 'No se pudo actualizar el estado de la conexión.');\n}\n\nasync function disconnectAzureConnection(userId, organization) {\n const { adminClient } = getSupabaseClients();\n const { error } = await adminClient.rpc('azure_disconnect_connection', {\n p_user_id: userId,\n p_organization: organization\n });\n if (error) throw apiError(500, 'No se pudo eliminar la conexión de Azure DevOps.');\n}\n\nfunction maskTokenHint(pat) {\n const value = String(pat || '').trim();\n if (!value) return '';\n return `••••${value.slice(-4).toUpperCase() || '----'}`;\n}\n\nfunction sendApiError(res, error) {\n const status = error?.status || 500;\n const message = error?.message || 'Error interno del servidor.';\n const response = { message };\n\n if (process.env.NODE_ENV !== 'production' && error?.meta) {\n response.debug = error.meta;\n }\n\n return res.status(status).json(response);\n}\n\nasync function resolveImageUrls(apiBody) {\n if (!apiBody || !apiBody.contents) return;\n\n for (const content of apiBody.contents) {\n if (!content.parts) continue;\n\n const resolvedParts = [];\n for (const part of content.parts) {\n if (part.image_url) {\n const url = part.image_url;\n try {\n console.log(`[PROXY-LOCAL] Resolviendo imagen: ${url}`);\n const res = await fetch(url);\n\n if (!res.ok) {\n console.error(`[PROXY-LOCAL] Error descarga (${res.status}): ${url}`);\n continue;\n }\n\n const buffer = await res.buffer();\n const mimeType = res.headers.get('content-type') || 'image/jpeg';\n const base64 = buffer.toString('base64');\n\n resolvedParts.push({\n inline_data: {\n mime_type: mimeType,\n data: base64\n }\n });\n } catch (e) {\n console.error(`[PROXY-LOCAL] Error procesando URL:`, e.message);\n }\n } else {\n resolvedParts.push(part);\n }\n }\n content.parts = resolvedParts;\n }\n}\n\n// Proxy para Gemini\napp.post('/api/gemini-proxy', async (req, res) => {\n const apiKey = process.env.GEMINI_API_KEY;\n\n if (!apiKey) {\n return res.status(500).json({\n error: {\n code: 500,\n message: 'GEMINI_API_KEY not configured in .env.local',\n status: 'INTERNAL_ERROR'\n }\n });\n }\n\n try {\n const { payload } = req.body;\n const apiBody = payload || req.body;\n\n // RESOLVER URLs ANTES DE LLAMAR A GEMINI\n await resolveImageUrls(apiBody);\n\n console.log(`[API] Calling Original Google API - Model: ${MODEL_NAME}`);\n\n const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL_NAME}:generateContent?key=${apiKey}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(apiBody)\n });\n\n const responseData = await response.json();\n\n // Enviar respuesta original (incluyendo errores de Google)\n if (!response.ok || responseData.error) {\n console.error('[API] Google API returned an error:', responseData.error || responseData);\n return res.status(response.status).json(responseData);\n }\n\n console.log('[API] Success response from Google');\n return res.json(responseData);\n\n } catch (error) {\n console.error('[API] Fatal Error calling Google API:', error.message);\n return res.status(500).json({\n error: {\n code: 500,\n message: 'Fatal error connecting to Google API: ' + error.message,\n status: 'INTERNAL_ERROR'\n }\n });\n }\n});\n\napp.post('/api/deepseek-proxy', async (req, res) => {\n if (!process.env.DEEPSEEK_API_KEY) {\n return res.status(500).json({\n error: 'DEEPSEEK_API_KEY not configured',\n userMessage: 'La API key de DeepSeek no está configurada.'\n });\n }\n\n try {\n const { payload } = req.body || {};\n const apiBody = payload || req.body || {};\n\n if (!apiBody.messages) {\n return res.status(400).json({\n error: 'Bad Request',\n userMessage: 'La solicitud no contiene \"messages\".'\n });\n }\n\n if (!apiBody.model) {\n apiBody.model = process.env.DEEPSEEK_MODEL || 'deepseek-chat';\n }\n\n if (apiBody.stream === true) {\n const streamResponse = await fetch('https://api.deepseek.com/chat/completions', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify(apiBody)\n });\n\n if (!streamResponse.ok) {\n const errData = await streamResponse.json().catch(() => ({}));\n return res.status(streamResponse.status).json(errData);\n }\n\n res.setHeader('Content-Type', 'text/event-stream');\n res.setHeader('Cache-Control', 'no-cache');\n res.setHeader('Connection', 'keep-alive');\n\n for await (const chunk of streamResponse.body) {\n res.write(chunk);\n }\n res.end();\n return;\n }\n\n const result = await callDeepSeekWithRetry(process.env.DEEPSEEK_API_KEY, apiBody);\n return res.status(200).json(result);\n } catch (error) {\n const message = error?.message || 'Error desconocido';\n return res.status(500).json({\n error: 'DeepSeek API error',\n userMessage: 'Error al procesar la solicitud con DeepSeek.',\n technicalDetails: String(message).substring(0, 200)\n });\n }\n});\n\napp.get('/api/integrations/azure-devops/connections', async (req, res) => {\n try {\n const user = await getAuthenticatedUser(req);\n const organization = typeof req.query.organization === 'string' && req.query.organization\n ? normalizeOrganization(req.query.organization)\n : null;\n\n const connection = await getAzureConnection(user.id, organization);\n if (!connection) {\n return res.status(200).json(null);\n }\n\n return res.status(200).json({\n id: connection.id,\n organization: connection.organization,\n status: connection.status,\n tokenHint: connection.token_hint,\n lastValidatedAt: connection.last_validated_at,\n updatedAt: connection.updated_at\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n});\n\napp.post('/api/integrations/azure-devops/connections', async (req, res) => {\n try {\n const user = await getAuthenticatedUser(req);\n const organization = normalizeOrganization(req.body?.organization);\n const personalAccessToken = String(req.body?.personalAccessToken || '').trim();\n\n if (!personalAccessToken) {\n throw apiError(400, 'El PAT es obligatorio para guardar la conexión.');\n }\n\n await validateAzureConnection(organization, personalAccessToken);\n const saved = await upsertAzureConnection(user.id, organization, personalAccessToken, 'connected');\n\n return res.status(200).json({\n id: saved.id,\n organization: saved.organization,\n status: saved.status,\n tokenHint: saved.token_hint || maskTokenHint(personalAccessToken),\n lastValidatedAt: saved.last_validated_at\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n});\n\napp.post('/api/integrations/azure-devops/connections/validate', async (req, res) => {\n try {\n const user = await getAuthenticatedUser(req);\n const organization = req.body?.organization ? normalizeOrganization(req.body.organization) : null;\n const connection = await getAzureConnectionWithSecret(user.id, organization);\n\n if (!connection) {\n throw apiError(404, 'Azure DevOps no está configurado para tu usuario.');\n }\n\n try {\n await validateAzureConnection(connection.organization, connection.personal_access_token);\n await updateAzureConnectionStatus(user.id, connection.organization, 'connected');\n return res.status(200).json({\n id: connection.id,\n organization: connection.organization,\n status: 'connected',\n tokenHint: connection.token_hint,\n lastValidatedAt: new Date().toISOString()\n });\n } catch (validationError) {\n const status = validationError?.status === 401 ? 'invalid' : 'expired';\n await updateAzureConnectionStatus(user.id, connection.organization, status);\n throw validationError;\n }\n } catch (error) {\n return sendApiError(res, error);\n }\n});\n\napp.delete('/api/integrations/azure-devops/connections', async (req, res) => {\n try {\n const user = await getAuthenticatedUser(req);\n const organization = normalizeOrganization(req.query.organization);\n\n await disconnectAzureConnection(user.id, organization);\n return res.status(200).json({ success: true });\n } catch (error) {\n return sendApiError(res, error);\n }\n});\n\napp.post('/api/integrations/azure-devops/work-items/import', async (req, res) => {\n try {\n const user = await getAuthenticatedUser(req);\n const userStoryId = Number(req.body?.userStoryId);\n\n if (!Number.isInteger(userStoryId) || userStoryId <= 0) {\n throw apiError(400, 'ID de HU inválido. Debe ser un número entero positivo.');\n }\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const endpoint = `${buildAzureBaseUrl(connection.organization)}/_apis/wit/workitems/${userStoryId}?fields=System.Title,System.Description,System.NodeName,Microsoft.VSTS.Common.AcceptanceCriteria,System.IterationLevel3&api-version=7.1`;\n const payload = await azureGet(endpoint, connection.personal_access_token);\n\n const fields = payload?.fields || {};\n const description = cleanHtmlContent(fields['System.Description'] || '');\n const acceptanceCriteria = cleanHtmlContent(fields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '');\n\n if (!description) {\n throw apiError(422, 'La HU importada no contiene descripción válida.');\n }\n if (!acceptanceCriteria) {\n throw apiError(422, 'La HU importada no contiene criterios de aceptación válidos.');\n }\n\n return res.status(200).json({\n id: Number(payload?.id || userStoryId),\n title: String(fields['System.Title'] || '').trim(),\n nodeName: String(fields['System.NodeName'] || '').trim(),\n sprint: String(fields['System.IterationLevel3'] || '').trim(),\n description,\n acceptanceCriteria\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n});\n\nasync function handleGetWorkItem(req, res) {\n try {\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n\n if (!workItemId) {\n throw apiError(400, 'workItemId requerido');\n }\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const endpoint = `${buildAzureBaseUrl(connection.organization)}/_apis/wit/workitems/${encodeURIComponent(workItemId)}?$expand=all&api-version=7.1`;\n const payload = await azureGet(endpoint, connection.personal_access_token);\n\n return res.status(200).json(payload);\n } catch (error) {\n if (error?.status === 404) {\n return res.status(404).json({ error: 'Work Item no encontrado' });\n }\n return sendApiError(res, error);\n }\n}\n\nasync function handleUploadAttachment(req, res) {\n try {\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n const fileName = String(req.body?.fileName || '').trim();\n const areaPath = String(req.body?.areaPath || '').trim();\n const fileBlob = req.body?.fileBlob;\n\n if (!workItemId) throw apiError(400, 'workItemId requerido');\n if (!fileName) throw apiError(400, 'fileName requerido');\n if (!fileBlob || typeof fileBlob !== 'string') throw apiError(400, 'fileBlob requerido en base64');\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const rawBase64 = fileBlob.includes(',') ? fileBlob.split(',')[1] : fileBlob;\n const fileBuffer = Buffer.from(rawBase64, 'base64');\n\n const attachmentUrl = `${buildAzureBaseUrl(connection.organization)}/_apis/wit/attachments?fileName=${encodeURIComponent(fileName)}&uploadType=Simple&areaPath=${encodeURIComponent(areaPath)}&api-version=7.1`;\n const basicToken = Buffer.from(`:${connection.personal_access_token}`).toString('base64');\n\n const response = await fetch(attachmentUrl, {\n method: 'POST',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/octet-stream',\n Accept: 'application/json'\n },\n body: fileBuffer\n });\n\n const data = await response.json().catch(() => ({}));\n if (!response.ok) {\n if (response.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (response.status === 403) throw apiError(403, 'No tienes permisos para cargar adjuntos en Azure DevOps.');\n if (response.status === 404) throw apiError(404, 'No fue posible cargar el archivo de evidencia.');\n throw apiError(502, 'No fue posible cargar la evidencia en Azure DevOps.');\n }\n\n return res.status(201).json({\n id: data.id,\n url: data.url,\n size: data.size\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n}\n\nasync function handleLinkAttachment(req, res) {\n try {\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n const attachmentUrl = String(req.body?.attachmentUrl || '').trim();\n const planTitle = String(req.body?.planTitle || '').trim();\n\n if (!workItemId) throw apiError(400, 'workItemId requerido');\n if (!attachmentUrl) throw apiError(400, 'attachmentUrl requerido');\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const endpoint = `${buildAzureBaseUrl(connection.organization)}/_apis/wit/workitems/${encodeURIComponent(workItemId)}?api-version=7.1`;\n const basicToken = Buffer.from(`:${connection.personal_access_token}`).toString('base64');\n\n const patchBody = [\n {\n op: 'add',\n path: '/relations/-',\n value: {\n rel: 'AttachedFile',\n url: attachmentUrl,\n attributes: {\n comment: `Evidencia adjunta al plan: ${planTitle || workItemId}`\n }\n }\n }\n ];\n\n const response = await fetch(endpoint, {\n method: 'PATCH',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/json-patch+json',\n Accept: 'application/json'\n },\n body: JSON.stringify(patchBody)\n });\n\n const data = await response.json().catch(() => ({}));\n if (!response.ok) {\n if (response.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (response.status === 403) throw apiError(403, 'No tienes permisos para vincular adjuntos en Azure DevOps.');\n if (response.status === 404) throw apiError(404, 'No se encontró el plan para vincular la evidencia.');\n throw apiError(502, 'No fue posible vincular la evidencia al plan.');\n }\n\n return res.status(200).json({\n success: true,\n id: data.id,\n message: `Evidencia vinculada al plan ${workItemId}`\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n}\n\nasync function handleUploadSerenity(req, res) {\n try {\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n const artifactDownloadUrl = String(req.body?.artifactDownloadUrl || '').trim();\n const projectId = String(req.body?.projectId || '').trim();\n const areaPath = String(req.body?.areaPath || '').trim();\n const planTitle = String(req.body?.planTitle || '').trim();\n const fileName = String(req.body?.fileName || 'Evidencia.zip').trim();\n\n if (!workItemId) throw apiError(400, 'workItemId requerido');\n if (!artifactDownloadUrl) throw apiError(400, 'artifactDownloadUrl requerido');\n if (!projectId) throw apiError(400, 'projectId requerido');\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const basicToken = Buffer.from(`:${connection.personal_access_token}`).toString('base64');\n const baseUrl = buildAzureBaseUrl(connection.organization);\n\n console.log('[upload-serenity] Descargando artifact:', artifactDownloadUrl.substring(0, 80) + '...');\n const artifactRes = await fetch(artifactDownloadUrl);\n if (!artifactRes.ok) {\n throw apiError(502, `No fue posible descargar el reporte de Serenity (${artifactRes.status}).`);\n }\n const artifactBuffer = Buffer.from(await artifactRes.arrayBuffer());\n console.log('[upload-serenity] Artifact descargado:', artifactBuffer.length, 'bytes');\n\n const attachmentUrl = `${baseUrl}/${encodeURIComponent(projectId)}/_apis/wit/attachments?fileName=${encodeURIComponent(fileName)}&uploadType=Simple&areaPath=${encodeURIComponent(areaPath)}&api-version=7.1`;\n const uploadRes = await fetch(attachmentUrl, {\n method: 'POST',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/octet-stream',\n Accept: 'application/json'\n },\n body: artifactBuffer\n });\n\n const uploadData = await uploadRes.json().catch(() => ({}));\n if (!uploadRes.ok) {\n if (uploadRes.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (uploadRes.status === 403) throw apiError(403, 'No tienes permisos para cargar adjuntos en Azure DevOps.');\n throw apiError(502, 'No fue posible cargar la evidencia en Azure DevOps.');\n }\n console.log('[upload-serenity] Attachment creado:', uploadData.url);\n\n const linkEndpoint = `${baseUrl}/_apis/wit/workitems/${encodeURIComponent(workItemId)}?api-version=7.1`;\n const patchBody = [\n {\n op: 'add',\n path: '/relations/-',\n value: {\n rel: 'AttachedFile',\n url: uploadData.url,\n attributes: {\n comment: `Evidencia adjunta al plan: ${planTitle || workItemId}`\n }\n }\n }\n ];\n\n const linkRes = await fetch(linkEndpoint, {\n method: 'PATCH',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/json-patch+json',\n Accept: 'application/json'\n },\n body: JSON.stringify(patchBody)\n });\n\n const linkData = await linkRes.json().catch(() => ({}));\n if (!linkRes.ok) {\n if (linkRes.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (linkRes.status === 403) throw apiError(403, 'No tienes permisos para vincular adjuntos en Azure DevOps.');\n throw apiError(502, 'No fue posible vincular la evidencia al plan.');\n }\n console.log('[upload-serenity] Evidencia vinculada al plan', workItemId);\n\n return res.status(200).json({\n success: true,\n attachmentId: uploadData.id,\n attachmentUrl: uploadData.url,\n fileName,\n workItemId: linkData.id,\n message: `Evidencia \"${fileName}\" cargada y vinculada al plan ${workItemId}`\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n}\n\nasync function handleUploadEvidence(req, res) {\n try {\n const JSZip = require('jszip');\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n const artifactDownloadUrl = String(req.body?.artifactDownloadUrl || '').trim();\n const extraFiles = Array.isArray(req.body?.extraFiles) ? req.body.extraFiles : [];\n const projectId = String(req.body?.projectId || '').trim();\n const areaPath = String(req.body?.areaPath || '').trim();\n const planTitle = String(req.body?.planTitle || '').trim();\n let fileName = String(req.body?.fileName || 'Evidencia.zip').trim();\n if (!/\\.zip$/i.test(fileName)) fileName += '.zip';\n\n if (!workItemId) throw apiError(400, 'workItemId requerido');\n if (!projectId) throw apiError(400, 'projectId requerido');\n if (!artifactDownloadUrl && extraFiles.length === 0) {\n throw apiError(400, 'No hay evidencias para cargar');\n }\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const basicToken = Buffer.from(`:${connection.personal_access_token}`).toString('base64');\n const baseUrl = buildAzureBaseUrl(connection.organization);\n\n const zip = new JSZip();\n\n if (artifactDownloadUrl) {\n console.log('[upload-evidence] Descargando artifact Serenity:', artifactDownloadUrl.substring(0, 80) + '...');\n const artifactRes = await fetch(artifactDownloadUrl);\n if (!artifactRes.ok) {\n throw apiError(502, `No fue posible descargar el reporte de Serenity (${artifactRes.status}).`);\n }\n const artifactBuffer = Buffer.from(await artifactRes.arrayBuffer());\n console.log('[upload-evidence] Serenity descargado:', artifactBuffer.length, 'bytes');\n zip.file('target.zip', artifactBuffer);\n }\n\n for (const f of extraFiles) {\n const name = String(f?.name || '').trim();\n const base64 = String(f?.base64 || '').trim();\n if (!name || !base64) continue;\n zip.file(name, Buffer.from(base64, 'base64'));\n }\n\n const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });\n console.log('[upload-evidence] ZIP final generado:', zipBuffer.length, 'bytes, nombre:', fileName);\n\n const attachmentUrl = `${baseUrl}/${encodeURIComponent(projectId)}/_apis/wit/attachments?fileName=${encodeURIComponent(fileName)}&uploadType=Simple&areaPath=${encodeURIComponent(areaPath)}&api-version=7.1`;\n const uploadRes = await fetch(attachmentUrl, {\n method: 'POST',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/octet-stream',\n Accept: 'application/json'\n },\n body: zipBuffer\n });\n\n const uploadData = await uploadRes.json().catch(() => ({}));\n if (!uploadRes.ok) {\n if (uploadRes.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (uploadRes.status === 403) throw apiError(403, 'No tienes permisos para cargar adjuntos en Azure DevOps.');\n throw apiError(502, 'No fue posible cargar la evidencia en Azure DevOps.');\n }\n console.log('[upload-evidence] Attachment creado:', uploadData.url);\n\n const linkEndpoint = `${baseUrl}/_apis/wit/workitems/${encodeURIComponent(workItemId)}?api-version=7.1`;\n const patchBody = [\n {\n op: 'add',\n path: '/relations/-',\n value: {\n rel: 'AttachedFile',\n url: uploadData.url,\n attributes: {\n comment: `Evidencia adjunta al plan: ${planTitle || workItemId}`\n }\n }\n }\n ];\n\n const linkRes = await fetch(linkEndpoint, {\n method: 'PATCH',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/json-patch+json',\n Accept: 'application/json'\n },\n body: JSON.stringify(patchBody)\n });\n\n const linkData = await linkRes.json().catch(() => ({}));\n if (!linkRes.ok) {\n if (linkRes.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (linkRes.status === 403) throw apiError(403, 'No tienes permisos para vincular adjuntos en Azure DevOps.');\n throw apiError(502, 'No fue posible vincular la evidencia al plan.');\n }\n console.log('[upload-evidence] Evidencia vinculada al plan', workItemId);\n\n return res.status(200).json({\n success: true,\n attachmentId: uploadData.id,\n attachmentUrl: uploadData.url,\n fileName,\n workItemId: linkData.id,\n message: `Evidencia \"${fileName}\" cargada y vinculada al plan ${workItemId}`\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n}\n\nasync function handleUpdateFields(req, res) {\n try {\n const user = await getAuthenticatedUser(req);\n const workItemId = String(req.params.workItemId || req.query.workItemId || req.body?.workItemId || '').trim();\n const title = req.body?.title;\n const description = req.body?.description;\n\n if (!workItemId) throw apiError(400, 'workItemId requerido');\n\n const connection = await getAzureConnectionWithSecret(user.id, null);\n if (!connection || connection.status === 'disconnected') {\n throw apiError(404, 'Azure DevOps no está configurado para este usuario.');\n }\n\n const patchBody = [];\n if (title !== undefined && title !== null && String(title).trim() !== '') {\n patchBody.push({\n op: 'add',\n path: '/fields/System.Title',\n value: String(title).trim()\n });\n }\n if (description !== undefined && description !== null && String(description).trim() !== '') {\n patchBody.push({\n op: 'add',\n path: '/fields/System.Description',\n value: String(description).trim()\n });\n }\n\n if (patchBody.length === 0) {\n throw apiError(400, 'No se proporcionaron campos para actualizar en Azure DevOps.');\n }\n\n const endpoint = `${buildAzureBaseUrl(connection.organization)}/_apis/wit/workitems/${encodeURIComponent(workItemId)}?api-version=7.1`;\n const basicToken = Buffer.from(`:${connection.personal_access_token}`).toString('base64');\n\n const response = await fetch(endpoint, {\n method: 'PATCH',\n headers: {\n Authorization: `Basic ${basicToken}`,\n 'Content-Type': 'application/json-patch+json',\n Accept: 'application/json'\n },\n body: JSON.stringify(patchBody)\n });\n\n const data = await response.json().catch(() => ({}));\n if (!response.ok) {\n if (response.status === 401) throw apiError(401, 'PAT inválido, vencido o revocado.');\n if (response.status === 403) throw apiError(403, 'No tienes permisos para actualizar campos en Azure DevOps.');\n if (response.status === 404) throw apiError(404, 'No se encontró el Work Item en Azure DevOps.');\n throw apiError(502, 'No fue posible actualizar el plan en Azure DevOps.');\n }\n\n return res.status(200).json({\n success: true,\n id: data.id,\n rev: data.rev,\n message: `Plan ${workItemId} actualizado correctamente en Azure DevOps`\n });\n } catch (error) {\n return sendApiError(res, error);\n }\n}\n\n// Rutas GET para work-items (query params & path params)\napp.get('/api/integrations/azure-devops/work-items', handleGetWorkItem);\napp.get('/api/integrations/azure-devops/work-items/:workItemId', handleGetWorkItem);\n\n// Rutas POST para work-items\napp.post('/api/integrations/azure-devops/work-items', async (req, res) => {\n const action = String(req.query.action || req.body?.action || '').trim();\n if (action === 'attachments') {\n return handleUploadAttachment(req, res);\n } else if (action === 'upload-evidence') {\n return handleUploadEvidence(req, res);\n } else if (action === 'upload-serenity') {\n return handleUploadSerenity(req, res);\n } else {\n return res.status(400).json({ error: `Acción '${action}' no válida en POST /work-items` });\n }\n});\napp.post('/api/integrations/azure-devops/work-items/:workItemId/attachments', handleUploadAttachment);\napp.post('/api/integrations/azure-devops/work-items/:workItemId/upload-serenity', handleUploadSerenity);\napp.post('/api/integrations/azure-devops/work-items/:workItemId/upload-evidence', handleUploadEvidence);\n\n// Rutas PATCH para work-items\napp.patch('/api/integrations/azure-devops/work-items', async (req, res) => {\n const action = String(req.query.action || req.body?.action || '').trim();\n if (action === 'update-fields') {\n return handleUpdateFields(req, res);\n } else if (action === 'link-attachment') {\n return handleLinkAttachment(req, res);\n } else {\n return res.status(400).json({ error: `Acción '${action}' no válida en PATCH /work-items` });\n }\n});\napp.patch('/api/integrations/azure-devops/work-items/:workItemId/link-attachment', handleLinkAttachment);\napp.patch('/api/integrations/azure-devops/work-items/:workItemId/update-fields', handleUpdateFields);\n\n// ─── Serenity + Azure DevOps (Release) ───────────────────────────────────\n\nasync function getAzureSerenityConnectionWithSecret(userId) {\n const { adminClient } = getSupabaseClients();\n const { data, error } = await adminClient.rpc('azure_serenity_get_connection_secret', {\n p_user_id: userId\n });\n\n if (error) throw apiError(500, 'Error al consultar la configuración de Serenity Azure.');\n return Array.isArray(data) ? data[0] || null : data || null;\n}\n\nfunction azureReleaseRequest(url, personalAccessToken, method = 'GET', body = null) {\n const basicToken = Buffer.from(`:${personalAccessToken}`).toString('base64');\n return fetch(url, {\n method,\n headers: {\n Authorization: `Basic ${basicToken}`,\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n 'User-Agent': 'test-plan-app'\n },\n ...(body ? { body: JSON.stringify(body) } : {})\n });\n}\n\nasync function resolveAzureSerenityRuntimeConfig(req) {\n const user = await getAuthenticatedUser(req);\n const row = await getAzureSerenityConnectionWithSecret(user.id);\n\n // Resuelve el PAT del usuario desde su conexión principal de Azure DevOps.\n const resolveUserAzurePat = async () => {\n try {\n const { adminClient } = getSupabaseClients();\n const { data, error } = await adminClient.rpc('azure_get_connection_secret', {\n p_user_id: user.id,\n p_organization: null,\n });\n if (!error && data) {\n const azRow = Array.isArray(data) ? data[0] : data;\n if (azRow?.personal_access_token) {\n return azRow.personal_access_token;\n }\n }\n } catch (_) {\n // no-op\n }\n return '';\n };\n\n // Sin config de Serenity por usuario: usar variables de entorno para\n // organización/proyecto/releaseDefId y el PAT de la conexión principal del usuario.\n if (!row?.azure_organization) {\n const envConfig = resolveAzureSerenityConfigFromEnv();\n if (!envConfig) return null;\n const userPat = await resolveUserAzurePat();\n return {\n userId: user.id,\n ...envConfig,\n personalAccessToken: userPat || envConfig.personalAccessToken,\n };\n }\n\n let pat = row.personal_access_token || '';\n\n const userPat = await resolveUserAzurePat();\n if (userPat) {\n pat = userPat;\n }\n\n // Si el PAT sigue vacío, intenta usar el configurado por variables de entorno.\n if (!pat) {\n const envConfig = resolveAzureSerenityConfigFromEnv();\n if (envConfig?.personalAccessToken) {\n pat = envConfig.personalAccessToken;\n }\n }\n\n return {\n userId: user.id,\n azureOrganization: String(row.azure_organization || ''),\n azureProject: String(row.azure_project || ''),\n releaseDefinitionId: Number(row.release_definition_id || 0),\n pipelineName: String(row.pipeline_name || 'Serenity Report CD'),\n branch: String(row.branch || 'trunk'),\n personalAccessToken: pat\n };\n}\n\n/**\n * Lee la configuración de Azure DevOps + Serenity desde variables de entorno.\n * Se usa como fallback cuando no existe configuración por usuario en la base de datos.\n * Devuelve null si faltan las variables mínimas (organización, proyecto y release definition id).\n */\nfunction resolveAzureSerenityConfigFromEnv() {\n const azureOrganization = String(process.env.AZURE_SERENITY_ORGANIZATION || '').trim();\n const azureProject = String(process.env.AZURE_SERENITY_PROJECT || '').trim();\n const releaseDefinitionId = Number(process.env.AZURE_SERENITY_RELEASE_DEFINITION_ID || 0);\n const pipelineName = String(process.env.AZURE_SERENITY_PIPELINE_NAME || 'Serenity Report CD').trim() || 'Serenity Report CD';\n const branch = String(process.env.AZURE_SERENITY_BRANCH || 'trunk').trim() || 'trunk';\n const personalAccessToken = String(process.env.AZURE_SERENITY_PAT || process.env.AZURE_DEVOPS_PAT || '').trim();\n\n if (!azureOrganization || !azureProject || !releaseDefinitionId || releaseDefinitionId <= 0) {\n return null;\n }\n\n return {\n azureOrganization,\n azureProject,\n releaseDefinitionId,\n pipelineName,\n branch,\n personalAccessToken,\n };\n}\n\nfunction resolveAzureArtifactDownloadUrlFromRelease(data) {\n const candidates = [\n data?.artifactDownloadUrl,\n data?.reportZipUrl,\n data?.variables?.SERENITY_REPORT_ZIP_URL?.value,\n data?.variables?.REPORT_ZIP_URL?.value,\n data?.variables?.ARTIFACT_DOWNLOAD_URL?.value,\n data?.environments?.[0]?.variables?.SERENITY_REPORT_ZIP_URL?.value,\n data?.environments?.[0]?.variables?.REPORT_ZIP_URL?.value,\n data?.environments?.[0]?.variables?.ARTIFACT_DOWNLOAD_URL?.value,\n ];\n\n for (const v of candidates) {\n const s = String(v || '').trim();\n if (/^https?:\\/\\//i.test(s)) return s;\n }\n return null;\n}\n\nconst SERENITY_BUNDLE_BUCKET = 'execution-evidence';\n\nfunction serenityBundlePath(userId, jobId) {\n return `serenity-bundles/${userId}/${jobId}.json`;\n}\n\nasync function deleteSerenityBundle(userId, jobId) {\n try {\n const { adminClient } = getSupabaseClients();\n await adminClient.storage.from(SERENITY_BUNDLE_BUCKET).remove([serenityBundlePath(userId, jobId)]);\n } catch (_) { /* no-op */ }\n}\n\n/**\n * Elimina reportes previos del mismo usuario/ejecución antes de insertar uno nuevo.\n * Solo se conserva el último reporte generado (no se guarda histórico).\n */\nasync function replacePreviousSerenityReports(userId, executionId) {\n try {\n const { adminClient } = getSupabaseClients();\n\n let query = adminClient\n .from('serenity_report_results')\n .select('id')\n .eq('user_id', userId);\n\n query = executionId ? query.eq('execution_id', executionId) : query.is('execution_id', null);\n\n const { data: oldRows } = await query;\n\n if (oldRows && oldRows.length > 0) {\n await Promise.all(oldRows.map((row) => deleteSerenityBundle(userId, row.id).catch(() => {})));\n\n let deleteQuery = adminClient\n .from('serenity_report_results')\n .delete()\n .eq('user_id', userId);\n\n deleteQuery = executionId ? deleteQuery.eq('execution_id', executionId) : deleteQuery.is('execution_id', null);\n\n await deleteQuery;\n }\n } catch (_) { /* no-op */ }\n}\n\napp.post('/api/serenity-report-azure', async (req, res) => {\n try {\n const config = await resolveAzureSerenityRuntimeConfig(req);\n if (!config) {\n return res.status(400).json({ error: 'No hay configuración de Serenity Azure para este usuario.' });\n }\n\n const { bundle, executionId } = req.body || {};\n if (!bundle) {\n return res.status(400).json({ error: 'Se requiere un bundle' });\n }\n\n const jobId = `serenity-azure-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;\n const bundleJson = JSON.stringify(bundle);\n const { adminClient } = getSupabaseClients();\n const path = serenityBundlePath(config.userId, jobId);\n\n const { error: uploadError } = await adminClient.storage\n .from(SERENITY_BUNDLE_BUCKET)\n .upload(path, bundleJson, { contentType: 'application/json', upsert: true });\n\n if (uploadError) {\n console.error('[serenity-report-azure][local] Error subiendo bundle:', uploadError);\n return res.status(502).json({ error: 'Error al almacenar el bundle.' });\n }\n\n const { data: signedData, error: signedError } = await adminClient.storage\n .from(SERENITY_BUNDLE_BUCKET)\n .createSignedUrl(path, 3600);\n\n if (signedError || !signedData?.signedUrl) {\n await deleteSerenityBundle(config.userId, jobId);\n return res.status(502).json({ error: 'Error al generar URL firmada para el bundle.' });\n }\n\n const bundleUrl = signedData.signedUrl;\n console.log(`[serenity-report-azure][local] Bundle subido (${(bundleJson.length / 1024).toFixed(0)} KB): ${path}`);\n\n const releaseUrlApi = `https://vsrm.dev.azure.com/${encodeURIComponent(config.azureOrganization)}/${encodeURIComponent(config.azureProject)}/_apis/release/releases?api-version=7.1`;\n const releaseResponse = await azureReleaseRequest(releaseUrlApi, config.personalAccessToken, 'POST', {\n definitionId: config.releaseDefinitionId,\n description: `Serenity report — ${jobId}`,\n variables: {\n BUNDLE_URL: { value: bundleUrl },\n RUN_ID: { value: jobId },\n RUN_NAME: { value: jobId }\n }\n });\n\n if (!releaseResponse.ok) {\n const errText = await releaseResponse.text();\n console.error('[serenity-report-azure][local] Error al crear release:', releaseResponse.status, errText);\n await deleteSerenityBundle(config.userId, jobId);\n const hint = releaseResponse.status === 401 || releaseResponse.status === 403\n ? ' Verifica que el PAT tenga permisos Release (Read, Write, & Execute).'\n : '';\n return res.status(502).json({ error: `Error al crear release: ${releaseResponse.status}.${hint}`.trim() });\n }\n\n const releaseData = await releaseResponse.json();\n const releaseId = releaseData?.id;\n if (!releaseId) {\n await deleteSerenityBundle(config.userId, jobId);\n return res.status(502).json({ error: 'No se pudo obtener el ID del release de Azure DevOps.' });\n }\n\n console.log(`[serenity-report-azure][local] Release creado: ${releaseId}`);\n\n // Insertar fila pendiente en BD para que el pipeline haga PATCH luego\n try {\n // Solo se conserva el último reporte: se elimina cualquier reporte previo\n // de este usuario/ejecución antes de insertar el nuevo.\n await replacePreviousSerenityReports(config.userId, executionId || null);\n\n await adminClient\n .from('serenity_report_results')\n .upsert({\n id: jobId,\n user_id: config.userId,\n execution_id: executionId || null,\n backend: 'azure',\n status: 'pending',\n progress: 0,\n }, { onConflict: 'id' });\n } catch (_) { /* no-op */ }\n\n return res.status(200).json({\n success: true,\n phase: 'running',\n jobId,\n releaseId,\n message: 'Release de Azure DevOps creado.'\n });\n } catch (error) {\n console.error('[serenity-report-azure][local] Error fatal:', error);\n return sendApiError(res, error);\n }\n});\n\napp.get('/api/serenity-report-azure', async (req, res) => {\n try {\n const config = await resolveAzureSerenityRuntimeConfig(req);\n if (!config) {\n return res.status(400).json({ error: 'No hay configuración de Serenity Azure para este usuario.' });\n }\n\n const releaseId = String(req.query.releaseId || '').trim();\n const jobId = String(req.query.jobId || '').trim();\n if (!releaseId) {\n return res.status(400).json({ error: 'Falta releaseId.' });\n }\n\n const url = `https://vsrm.dev.azure.com/${encodeURIComponent(config.azureOrganization)}/${encodeURIComponent(config.azureProject)}/_apis/release/releases/${releaseId}?api-version=7.1`;\n const response = await azureReleaseRequest(url, config.personalAccessToken);\n\n if (!response.ok) {\n return res.status(502).json({ status: 'error', message: `Error consultando release: ${response.status}` });\n }\n\n const data = await response.json();\n const environments = data?.environments || [];\n const status = environments[0]?.status || 'unknown';\n\n if (status === 'succeeded' || status === 'rejected') {\n if (jobId) await deleteSerenityBundle(config.userId, jobId);\n\n if (status === 'succeeded') {\n const releaseUrl = `https://dev.azure.com/${encodeURIComponent(config.azureOrganization)}/${encodeURIComponent(config.azureProject)}/_releaseProgress?_a=release-pipeline-progress&releaseId=${releaseId}`;\n const artifactDownloadUrl = resolveAzureArtifactDownloadUrlFromRelease(data);\n return res.status(200).json({\n status: 'done',\n phase: 'completed',\n result: status,\n artifactDownloadUrl,\n releaseUrl,\n message: 'Release completado exitosamente.'\n });\n }\n\n return res.status(200).json({ status: 'done', phase: 'failed', result: status });\n }\n\n return res.status(200).json({ status: 'running', phase: status, result: null });\n } catch (error) {\n console.error('[serenity-report-azure][local] Error en poll:', error);\n return sendApiError(res, error);\n }\n});\n\napp.listen(PORT, () => {\n // Servidor listo\n});\n"}}}} |