diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 00a5178..1278eb3 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -2,7 +2,7 @@ name: Sync Issues to JSON on: issues: - types: [opened, edited, closed] + types: [opened, labeled, edited, reopened, closed] permissions: contents: write @@ -15,58 +15,64 @@ concurrency: jobs: sync-issues: runs-on: ubuntu-latest - + steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Create issues directory if not exists + run: mkdir -p public/data - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' + - name: Install action dependencies (sync-issue) + working-directory: actions/sync-issue + run: npm ci - - name: Create issues directory if not exists - run: mkdir -p public/data + - name: Sync issue to JSON (local action) + uses: ./actions/sync-issue + with: + json-path: public/data/issues.json - - name: Sync issue to JSON - env: - ISSUE_JSON: ${{ toJSON(github.event.issue) }} - ISSUE_ACTION: ${{ github.event.action }} - OUTPUT_PATH: public/data/issues.json - run: node scripts/sync-issue.js + - name: Check for changes in data file + id: verify + run: | + if git diff --quiet -- public/data/issues.json; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi - - name: Commit and push changes - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - if [[ -n $(git status --porcelain) ]]; then + - name: Commit and push changes to main + if: steps.verify.outputs.changed == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" git add public/data/issues.json git commit -m "🤖 Sync issue #${{ github.event.issue.number }} (${{ github.event.action }})" git pull --rebase origin main - git push - echo "✅ Cambios commiteados y pusheados" - else - echo "ℹ️ No hay cambios para commitear" - fi + git push origin HEAD:main - - name: Save issues.json to temp before switching branch - run: cp public/data/issues.json /tmp/issues.json + - name: Save issues.json to temp before switching branch + if: steps.verify.outputs.changed == 'true' + run: cp public/data/issues.json /tmp/issues.json - - name: Push issues.json to gh-pages - run: | - git fetch origin gh-pages - git checkout gh-pages - mkdir -p data - cp /tmp/issues.json data/issues.json - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - if [[ -n $(git status --porcelain data/issues.json) ]]; then - git add data/issues.json - git commit -m "🤖 Sync issue #${{ github.event.issue.number }} (${{ github.event.action }}) [gh-pages]" - git push origin gh-pages - echo "✅ issues.json actualizado en gh-pages" - else - echo "ℹ️ No hay cambios para commitear en gh-pages" - fi + - name: Push issues.json to gh-pages + if: steps.verify.outputs.changed == 'true' + run: | + git fetch origin gh-pages || true + git checkout gh-pages || git switch -c gh-pages + mkdir -p data + cp /tmp/issues.json data/issues.json + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + if [[ -n $(git status --porcelain data/issues.json) ]]; then + git add data/issues.json + git commit -m "🤖 Sync issue #${{ github.event.issue.number }} (${{ github.event.action }}) [gh-pages]" + git push origin gh-pages + fi diff --git a/actions/sync-issue/action.yml b/actions/sync-issue/action.yml new file mode 100644 index 0000000..139558d --- /dev/null +++ b/actions/sync-issue/action.yml @@ -0,0 +1,10 @@ +name: 'Sync Single Issue to JSON' +description: 'Parse a GitHub issue and update public/data/issues.json with an event entry' +inputs: + json-path: + description: 'Path to the JSON file to update' + required: false + default: 'public/data/issues.json' +runs: + using: 'node20' + main: 'index.js' diff --git a/actions/sync-issue/index.js b/actions/sync-issue/index.js new file mode 100644 index 0000000..ae9947e --- /dev/null +++ b/actions/sync-issue/index.js @@ -0,0 +1,16 @@ +const core = require('@actions/core'); +const github = require('@actions/github'); +const { syncIssue } = require('./lib/core'); + +async function run() { + try { + const jsonPath = core.getInput('json-path') || 'public/data/issues.json'; + const payload = github.context.payload; + if (!payload || !payload.issue) throw new Error('No issue payload available'); + await syncIssue(payload.issue, payload.action || 'unknown', jsonPath); + } catch (error) { + core.setFailed(error.message); + } +} + +run(); diff --git a/actions/sync-issue/lib/core.js b/actions/sync-issue/lib/core.js new file mode 100644 index 0000000..8bdafbc --- /dev/null +++ b/actions/sync-issue/lib/core.js @@ -0,0 +1,153 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const cheerio = require('cheerio'); + +// Fetch OG image from a Meetup event page +function getEventImage(eventLink) { + return new Promise((resolve) => { + if (!eventLink || !eventLink.includes('meetup.com')) { + resolve(null); + return; + } + https + .get(eventLink, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + const $ = cheerio.load(data); + const ogImage = $('meta[property="og:image"]').attr('content'); + resolve(ogImage || null); + } catch (error) { + console.warn(`Error parsing HTML for ${eventLink}: ${error.message}`); + resolve(null); + } + }); + }) + .on('error', (error) => { + console.warn(`Error fetching ${eventLink}: ${error.message}`); + resolve(null); + }); + }); +} + +// From issue payload: title -> id; body: name;link;date +async function parseIssueContent(issue) { + const eventId = (issue.title || '').trim(); + const body = issue.body || ''; + const parts = body.split(';').map((p) => p.trim()); + const eventName = parts[0] || ''; + const eventLink = parts[1] || ''; + const eventDate = parts[2] || ''; + const eventImage = await getEventImage(eventLink); + return { eventId, eventName, eventLink, eventDate, eventImage }; +} + +function shouldProcessIssue(issueData) { + const isCorrectAuthor = + issueData.user && (issueData.user.login === 'ghspain-user' || issueData.user.login === 'alexcerezo'); + const hasEventLabel = + issueData.labels && issueData.labels.some((label) => label.name && label.name.toLowerCase() === 'event'); + const ok = !!(isCorrectAuthor && hasEventLabel); + if (!ok) { + console.log( + `⚠️ Issue #${issueData.number} no cumple filtros: autor=${issueData.user?.login || 'unknown'}, label Event=${hasEventLabel}` + ); + } + return ok; +} + +function loadExistingIssues(jsonPath) { + if (fs.existsSync(jsonPath)) { + try { + const content = fs.readFileSync(jsonPath, 'utf8'); + return JSON.parse(content); + } catch (e) { + console.warn(`⚠️ Error al leer JSON existente en ${jsonPath}: ${e.message}`); + } + } + return []; +} + +function saveIssues(jsonPath, issues) { + const dir = path.dirname(jsonPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify(issues, null, 2), 'utf8'); +} + +async function syncIssue(issueData, action, jsonPath = 'public/data/issues.json') { + console.log(`🔄 Procesando issue #${issueData.number} (acción: ${action})`); + if (!shouldProcessIssue(issueData)) { + console.log(`⏭️ Issue #${issueData.number} omitido (no cumple los filtros)`); + return loadExistingIssues(jsonPath); + } + + const existingIssues = loadExistingIssues(jsonPath); + const parsed = await parseIssueContent(issueData); + const eventData = { + event_id: parsed.eventId, + event_name: parsed.eventName, + event_link: parsed.eventLink, + event_date: parsed.eventDate, + event_image: parsed.eventImage, + }; + + const idx = existingIssues.findIndex((e) => e.event_id === parsed.eventId); + if (idx >= 0) { + existingIssues[idx] = eventData; + console.log(`✅ Evento actualizado: ${parsed.eventId} - ${parsed.eventName}`); + } else { + existingIssues.push(eventData); + console.log(`➕ Evento añadido: ${parsed.eventId} - ${parsed.eventName}`); + } + + existingIssues.sort((a, b) => { + const aId = isNaN(a.event_id) ? a.event_id : parseInt(a.event_id, 10); + const bId = isNaN(b.event_id) ? b.event_id : parseInt(b.event_id, 10); + if (typeof aId === 'number' && typeof bId === 'number') return aId - bId; + return String(aId).localeCompare(String(bId)); + }); + + saveIssues(jsonPath, existingIssues); + console.log(`📊 Total de eventos en el archivo: ${existingIssues.length}`); + return existingIssues; +} + +// Batch utility for images +async function updateEventImages(jsonPath = 'public/data/issues.json') { + console.log(`🔄 Actualizando imágenes de eventos en ${jsonPath}...`); + const events = loadExistingIssues(jsonPath); + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.event_link) { + console.log(`📸 Obteniendo imagen para evento: ${event.event_name}`); + try { + const imageUrl = await getEventImage(event.event_link); + if (imageUrl) { + event.event_image = imageUrl; + console.log(`✅ Imagen obtenida: ${imageUrl}`); + } else { + console.log(`⚠️ No se encontró imagen para ${event.event_name}`); + } + } catch (error) { + console.error(`❌ Error obteniendo imagen para ${event.event_name}: ${error.message}`); + } + } + } + saveIssues(jsonPath, events); + console.log(`📁 Archivo actualizado: ${jsonPath}`); + return events; +} + +module.exports = { + getEventImage, + parseIssueContent, + shouldProcessIssue, + loadExistingIssues, + saveIssues, + syncIssue, + updateEventImages, +}; diff --git a/actions/sync-issue/package.json b/actions/sync-issue/package.json new file mode 100644 index 0000000..a602c97 --- /dev/null +++ b/actions/sync-issue/package.json @@ -0,0 +1,12 @@ +{ + "name": "sync-issue", + "version": "1.0.0", + "private": true, + "main": "index.js", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "cheerio": "^1.0.0" + } +} diff --git a/scripts/sync-all-issues.js b/scripts/sync-all-issues.js deleted file mode 100644 index 253c795..0000000 --- a/scripts/sync-all-issues.js +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const { syncIssue, shouldProcessIssue } = require('./sync-issue'); - -/** - * Script para sincronizar TODOS los issues existentes de un repositorio de GitHub - * Se puede ejecutar manualmente o desde GitHub Actions - */ - -// Configuración por defecto -const DEFAULT_CONFIG = { - owner: 'ghspain-user', - repo: 'ghspain', - outputPath: 'public/data/issues.json' -}; - -function makeGitHubRequest(endpoint, token = null) { - return new Promise((resolve, reject) => { - const options = { - hostname: 'api.github.com', - path: endpoint, - method: 'GET', - headers: { - 'User-Agent': 'GitHub-Issues-Sync/1.0', - 'Accept': 'application/vnd.github.v3+json' - } - }; - - if (token) { - options.headers['Authorization'] = `Bearer ${token}`; - } - - const req = https.request(options, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - const parsedData = JSON.parse(data); - - if (res.statusCode !== 200) { - reject(new Error(`GitHub API error: ${res.statusCode} - ${parsedData.message || 'Unknown error'}`)); - return; - } - - resolve(parsedData); - } catch (error) { - reject(new Error(`Error parsing JSON: ${error.message}`)); - } - }); - }); - - req.on('error', (error) => { - reject(error); - }); - - req.end(); - }); -} - -async function fetchAllIssues(owner, repo, token = null) { - const issues = []; - let page = 1; - const perPage = 100; - - console.log(`🔍 Obteniendo issues del repositorio ${owner}/${repo}...`); - - while (true) { - const endpoint = `/repos/${owner}/${repo}/issues?state=all&per_page=${perPage}&page=${page}`; - - try { - const pageIssues = await makeGitHubRequest(endpoint, token); - - if (!Array.isArray(pageIssues) || pageIssues.length === 0) { - break; - } - - // Filtrar pull requests - const realIssues = pageIssues.filter(issue => !issue.pull_request); - issues.push(...realIssues); - - console.log(`📄 Página ${page}: ${realIssues.length} issues encontrados (${issues.length} total)`); - - if (pageIssues.length < perPage) { - break; - } - - page++; - } catch (error) { - throw new Error(`Error fetching page ${page}: ${error.message}`); - } - } - - return issues; -} - -async function syncAllIssues() { - try { - // Obtener configuración desde variables de entorno o usar por defecto - const owner = process.env.GITHUB_REPOSITORY_OWNER || process.env.REPO_OWNER || DEFAULT_CONFIG.owner; - const repo = process.env.GITHUB_REPOSITORY?.split('/')[1] || process.env.REPO_NAME || DEFAULT_CONFIG.repo; - const outputPath = process.env.OUTPUT_PATH || DEFAULT_CONFIG.outputPath; - const token = process.env.GITHUB_TOKEN; - - console.log(`🚀 Iniciando sincronización completa de issues`); - console.log(`📂 Repositorio: ${owner}/${repo}`); - console.log(`💾 Archivo de salida: ${outputPath}`); - - // Obtener todos los issues del repositorio - const allIssues = await fetchAllIssues(owner, repo, token); - - if (allIssues.length === 0) { - console.log('ℹ️ No se encontraron issues en el repositorio'); - return; - } - - console.log(`📊 Total de issues encontrados: ${allIssues.length}`); - console.log('🔄 Iniciando sincronización...'); - - // Borrar el archivo existente para empezar de cero - if (fs.existsSync(outputPath)) { - fs.unlinkSync(outputPath); - console.log('🗑️ Archivo existente eliminado'); - } - - // Filtrar issues que cumplen los criterios (autor: ghspain-user, label: Event) - const filteredIssues = allIssues.filter(issue => shouldProcessIssue(issue)); - - console.log(`📊 Issues filtrados: ${filteredIssues.length}/${allIssues.length} (autor: ghspain-user o alexcerezo, label: Event)`); - - if (filteredIssues.length === 0) { - console.log('ℹ️ No se encontraron issues que cumplan los filtros (autor: ghspain-user o alexcerezo, label: Event)'); - return; - } - - // Sincronizar cada issue filtrado - let syncedCount = 0; - for (const issue of filteredIssues) { - try { - await syncIssue(issue, 'bulk_sync', outputPath); - syncedCount++; - - // Mostrar progreso cada 10 issues - if (syncedCount % 10 === 0) { - console.log(`⏳ Progreso: ${syncedCount}/${filteredIssues.length} issues sincronizados`); - } - } catch (error) { - console.error(`❌ Error sincronizando issue #${issue.number}:`, error.message); - } - } - - console.log(`✅ Sincronización completa: ${syncedCount}/${filteredIssues.length} issues sincronizados`); - console.log(`📁 Archivo guardado en: ${outputPath}`); - - } catch (error) { - console.error('❌ Error durante la sincronización:', error.message); - - if (error.message.includes('rate limit')) { - console.log('💡 Consejo: El token de GitHub ayuda a evitar límites de API'); - } - - if (error.message.includes('Not Found')) { - console.log('💡 Verifica que el repositorio existe y es accesible'); - } - - process.exit(1); - } -} - -// Ejecutar si se llama directamente -if (require.main === module) { - syncAllIssues(); -} - -module.exports = { syncAllIssues, fetchAllIssues }; diff --git a/scripts/sync-issue.js b/scripts/sync-issue.js deleted file mode 100644 index d5406a3..0000000 --- a/scripts/sync-issue.js +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); - -/** - * Script para sincronizar un issue de GitHub al archivo JSON - * Parsea el formato específico donde: - * - El título contiene el ID del evento - * - El cuerpo contiene: nombre_evento;enlace;fecha - */ - -function parseIssueContent(issue) { - // El título es el ID del evento - const eventId = issue.title.trim(); - - // Parsear el cuerpo: nombre_evento;enlace;fecha - const body = issue.body || ''; - const parts = body.split(';').map(part => part.trim()); - - const eventName = parts[0] || ''; - const eventLink = parts[1] || ''; - const eventDate = parts[2] || ''; - - return { - eventId, - eventName, - eventLink, - eventDate - }; -} - -function shouldProcessIssue(issueData) { - // Verificar que el autor sea el correcto - const isCorrectAuthor = issueData.user && (issueData.user.login === 'ghspain-user' || issueData.user.login === 'alexcerezo'); - - // Verificar que tenga el label "Event" - const hasEventLabel = issueData.labels && issueData.labels.some(label => - label.name.toLowerCase() === 'event' - ); - - const shouldProcess = isCorrectAuthor && hasEventLabel; - - if (!shouldProcess) { - console.log(`⚠️ Issue #${issueData.number} no cumple filtros:`); - console.log(` - Autor: ${issueData.user?.login || 'unknown'} ${isCorrectAuthor ? '✅' : '❌'}`); - console.log(` - Label "Event": ${hasEventLabel ? '✅' : '❌'} (labels: ${issueData.labels?.map(l => l.name).join(', ') || 'ninguno'})`); - } - - return shouldProcess; -} - -function loadExistingIssues(jsonPath) { - if (fs.existsSync(jsonPath)) { - try { - const content = fs.readFileSync(jsonPath, 'utf8'); - return JSON.parse(content); - } catch (error) { - console.warn('⚠️ Error al leer JSON existente, creando nuevo:', error.message); - } - } - return []; -} - -function saveIssues(jsonPath, issues) { - // Crear el directorio si no existe - const dir = path.dirname(jsonPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - // Guardar el archivo - fs.writeFileSync(jsonPath, JSON.stringify(issues, null, 2), 'utf8'); -} - -function syncIssue(issueData, action, jsonPath = 'public/data/issues.json') { - console.log(`🔄 Procesando issue #${issueData.number} (acción: ${action})`); - - // Verificar filtros antes de procesar - if (!shouldProcessIssue(issueData)) { - console.log(`⏭️ Issue #${issueData.number} omitido (no cumple los filtros)`); - return loadExistingIssues(jsonPath); // Devolver issues existentes sin cambios - } - - // Cargar issues existentes - const existingIssues = loadExistingIssues(jsonPath); - - // Parsear el contenido del issue - const parsedContent = parseIssueContent(issueData); - - // Crear el objeto del evento (solo los datos esenciales) - const eventData = { - event_id: parsedContent.eventId, - event_name: parsedContent.eventName, - event_link: parsedContent.eventLink, - event_date: parsedContent.eventDate - }; - - // Buscar si el evento ya existe (por event_id) - const existingIndex = existingIssues.findIndex(item => item.event_id === parsedContent.eventId); - - if (existingIndex >= 0) { - // Actualizar evento existente - existingIssues[existingIndex] = eventData; - console.log(`✅ Evento actualizado: ${parsedContent.eventId} - ${parsedContent.eventName}`); - } else { - // Añadir nuevo evento - existingIssues.push(eventData); - console.log(`➕ Evento añadido: ${parsedContent.eventId} - ${parsedContent.eventName}`); - } - - // Ordenar por event_id (que viene del título del issue) - existingIssues.sort((a, b) => { - // Intentar ordenar numéricamente si es posible, sino alfabéticamente - const aId = isNaN(a.event_id) ? a.event_id : parseInt(a.event_id); - const bId = isNaN(b.event_id) ? b.event_id : parseInt(b.event_id); - - if (typeof aId === 'number' && typeof bId === 'number') { - return aId - bId; - } - return String(aId).localeCompare(String(bId)); - }); - - // Guardar el archivo actualizado - saveIssues(jsonPath, existingIssues); - - console.log(`📊 Total de eventos en el archivo: ${existingIssues.length}`); - - return existingIssues; -} - -// Si se ejecuta directamente (desde línea de comandos) -if (require.main === module) { - try { - // Obtener los datos del issue desde las variables de entorno - const issueJson = process.env.ISSUE_JSON; - const action = process.env.ISSUE_ACTION || 'unknown'; - const outputPath = process.env.OUTPUT_PATH || 'public/data/issues.json'; - - if (!issueJson) { - throw new Error('ISSUE_JSON environment variable is required'); - } - - const issueData = JSON.parse(issueJson); - syncIssue(issueData, action, outputPath); - - } catch (error) { - console.error('❌ Error:', error.message); - process.exit(1); - } -} - -module.exports = { syncIssue, parseIssueContent, shouldProcessIssue }; diff --git a/scripts/test-filters.js b/scripts/test-filters.js deleted file mode 100644 index f21a6aa..0000000 --- a/scripts/test-filters.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node - -// Test simple para verificar los filtros -const { shouldProcessIssue } = require('./sync-issue'); - -console.log('🧪 Testeando filtros de issues...\n'); - -// Test 1: Issue válido (autor correcto + label correcto) -const validIssue = { - number: 1, - user: { login: 'ghspain-user' }, - labels: [{ name: 'Event' }, { name: 'important' }] -}; - -const validIssue2 = { - number: 6, - user: { login: 'alexcerezo' }, - labels: [{ name: 'Event' }] -}; - - - console.log('Test 1 - Issue válido (ghspain-user):'); - console.log('Resultado:', shouldProcessIssue(validIssue) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log(); - - console.log('Test 1b - Issue válido (alexcerezo):'); - console.log('Resultado:', shouldProcessIssue(validIssue2) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log(); - - // Test 2: Autor incorrecto - const wrongAuthor = { - number: 2, - user: { login: 'otrapersona' }, - labels: [{ name: 'Event' }] - }; - - console.log('Test 2 - Autor incorrecto:'); - console.log('Resultado:', shouldProcessIssue(wrongAuthor) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log(); - - // Test 3: Sin label "Event" - const wrongLabel = { - number: 3, - user: { login: 'ghspain-user' }, - labels: [{ name: 'bug' }, { name: 'important' }] - }; - - console.log('Test 3 - Sin label "Event":'); - console.log('Resultado:', shouldProcessIssue(wrongLabel) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log(); - - // Test 4: Autor correcto pero sin labels - const noLabels = { - number: 4, - user: { login: 'ghspain-user' }, - labels: [] - }; - - console.log('Test 4 - Sin labels:'); - console.log('Resultado:', shouldProcessIssue(noLabels) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log(); - - // Test 5: Label "event" en minúsculas (debería funcionar) - const lowercaseLabel = { - number: 5, - user: { login: 'ghspain-user' }, - labels: [{ name: 'event' }] - }; - - console.log('Test 5 - Label "event" en minúsculas:'); - console.log('Resultado:', shouldProcessIssue(lowercaseLabel) ? '✅ VÁLIDO' : '❌ RECHAZADO'); - console.log();