import fs from 'fs'; import express from 'express'; import axios from 'axios'; import https from 'https'; import { HttpsProxyAgent } from 'https-proxy-agent'; // <-- AJOUT // ========================================== // Lecture du fichier config.ini (inchangé) // ========================================== function parseIni(filePath) { const config = {}; let section = ''; const content = fs.readFileSync(filePath, 'utf8'); content.split('\n').forEach(line => { line = line.trim(); if (!line || line.startsWith('#') || line.startsWith(';')) return; const sectionMatch = line.match(/^\[(.+)\]$/); if (sectionMatch) { section = sectionMatch[1]; config[section] = {}; return; } const kv = line.match(/^([^=]+)=(.*)$/); if (kv && section) config[section][kv[1].trim()] = kv[2].trim(); }); return config; } const config = parseIni('./config.ini'); if (!config.jira?.url || !config.proxy?.host) { console.error('ERREUR: config.ini mal rempli.'); process.exit(1); } // ========================================== // Configuration du client HTTP (Axios) // ========================================== // Construire l'URL du proxy const proxyUrl = new URL(`http://${config.proxy.host}:${config.proxy.port}`); if (config.proxy.user && config.proxy.pass) { proxyUrl.username = config.proxy.user; proxyUrl.password = config.proxy.pass; } // Créer l'agent de tunneling CONNECT const proxyAgent = new HttpsProxyAgent(proxyUrl); const jiraClient = axios.create({ baseURL: config.jira.url, // IMPORTANT : désactiver le proxy interne d'Axios pour utiliser le nôtre proxy: false, httpsAgent: proxyAgent, httpAgent: proxyAgent, timeout: 60000 }); // ========================================== // DEBUG : Intercepteurs de requêtes // ========================================== jiraClient.interceptors.request.use(request => { const authHeader = request.headers['Authorization'] || ''; // On masque le token pour pas le laisser en clair dans les logs const masked = authHeader.substring(0, 15) + '...[MASQUE]...' + authHeader.substring(authHeader.length - 5); console.log(`\n[DEBUG REQUETE] ${request.method?.toUpperCase()} ${request.baseURL}${request.url}`); console.log(`[DEBUG HEADER] Authorization: ${masked}`); return request; }); jiraClient.interceptors.response.use( response => response, error => { if (error.response) { console.error(`\n[DEBUG ERREUR JIRA] Statut : ${error.response.status}`); console.error(`[DEBUG ERREUR JIRA] Headers:`, JSON.stringify(error.response.headers, null, 2)); console.error(`[DEBUG ERREUR JIRA] Body :`, JSON.stringify(error.response.data, null, 2)); } else { console.error(`\n[DEBUG ERREUR RESEAU]`, error.message); } return Promise.reject(error); } ); // ========================================== // Serveur Express // ========================================== const app = express(); app.use(express.json({ limit: '10mb' })); app.use(express.static('.')); // Sert index.html à / // Endpoint d'extraction app.post('/api/extract', async (req, res) => { const { username, apiToken, projectKey, jql, customFields } = req.body; if (!username || !apiToken || !projectKey) { return res.status(400).json({ error: "Les champs username, apiToken et projectKey sont requis." }); } const auth = Buffer.from(`${username}:${apiToken}`).toString('base64'); // Construction de la liste des champs Jira à demander const standardFields = [ 'key','summary','description','status','assignee','reporter','priority', 'created','updated','duedate','resolution','resolutiondate','issuetype', 'labels','components','fixVersions','versions','project' ]; // Parser les custom fields (ex: "customfield_10001:Production Date") const customFieldsMap = []; if (customFields) { customFields.split(',').forEach(cf => { const [id, label] = cf.split(':').map(s => s.trim()); if (id) customFieldsMap.push({ id, label: label || id }); }); } const fieldsToRequest = [ ...standardFields, ...customFieldsMap.map(cf => cf.id) ].join(','); const queryJql = jql || `project = "${projectKey}" ORDER BY created DESC`; let startAt = 0; let total = 0; let allTickets = []; try { console.log(`[EXTRACT] Début pour ${projectKey} (JQL: ${queryJql.substring(0, 50)}...)`); // Pagination automatique while (true) { const response = await jiraClient.get('/rest/api/2/search', { params: { jql: queryJql, startAt: startAt, maxResults: 100, // Max standard pour Jira fields: fieldsToRequest }, headers: { //'Authorization': `Basic ${auth}`, 'Authorization': `Bearer ${apiToken}`, 'Accept': 'application/json' } }); if (startAt === 0) { total = response.data.total; console.log(`[EXTRACT] ${total} tickets trouvés. Récupération en cours...`); } if (total === 0) break; // Mapping des tickets for (const issue of response.data.issues) { const f = issue.fields; const ticket = { key: issue.key, url: `${config.jira.url}/browse/${issue.key}`, summary: f.summary, description: f.description, status: f.status?.name, statusCategory: f.status?.statusCategory?.name, assignee: f.assignee?.displayName || f.assignee?.name, reporter: f.reporter?.displayName || f.reporter?.name, priority: f.priority?.name, issueType: f.issuetype?.name, created: f.created, updated: f.updated, dueDate: f.duedate, resolution: f.resolution?.name, resolutionDate: f.resolutiondate, labels: f.labels || [], components: f.components?.map(c => c.name).join(', ') || null, fixVersions: f.fixVersions?.map(v => v.name).join(', ') || null, projectName: f.project?.name }; // Ajout des champs personnalisés customFieldsMap.forEach(cf => { const val = f[cf.id]; if (val !== null && val !== undefined) { // Pour les dates Jira, formater proprement if (typeof val === 'string' && (val.match(/^\d{4}-\d{2}-\d{2}/) || val.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/))) { ticket[cf.label] = val; } else { ticket[cf.label] = typeof val === 'object' ? (val.name || JSON.stringify(val)) : val; } } else { ticket[cf.label] = null; } }); allTickets.push(ticket); } startAt += response.data.issues.length; if (startAt >= total) break; } console.log(`[EXTRACT] Terminé. ${allTickets.length} tickets extraits.`); res.json({ success: true, total: allTickets.length, tickets: allTickets }); } catch (error) { console.error('[EXTRACT ERROR]', error.response?.data || error.message); res.status(error.response?.status || 500).json({ error: error.response?.data?.errorMessages?.[0] || error.message, details: error.response?.data || null }); } }); // Endpoint de mise à jour de ticket app.put('/api/update', async (req, res) => { const { username, apiToken, ticketKey, updates } = req.body; if (!username || !apiToken || !ticketKey || !updates) { return res.status(400).json({ error: "Les champs username, apiToken, ticketKey et updates sont requis." }); } // Construire le payload pour l'API Jira const fields = {}; const updateFields = {}; for (const [key, value] of Object.entries(updates)) { if (key === 'duedate') { // Due date est un champ standard fields[key] = value || null; } else if (key.startsWith('customfield_')) { // Custom field fields[key] = value || null; } } try { console.log(`[UPDATE] Mise à jour du ticket ${ticketKey}:`, updates); const response = await jiraClient.put(`/rest/api/2/issue/${ticketKey}`, { fields: fields }, { headers: { 'Authorization': `Bearer ${apiToken}`, 'Accept': 'application/json' } }); console.log(`[UPDATE] Ticket ${ticketKey} mis à jour avec succès.`); res.json({ success: true, ticketKey, updatedFields: updates }); } catch (error) { console.error('[UPDATE ERROR]', error.response?.data || error.message); res.status(error.response?.status || 500).json({ error: error.response?.data?.errorMessages?.[0] || error.message, details: error.response?.data || null }); } }); // Démarrage const PORT = config.server?.port || 3000; app.listen(PORT, '0.0.0.0', () => { console.log(''); console.log('============================================'); console.log(' Jira Extractor API'); console.log(` Local : http://localhost:${PORT}`); console.log(` Jira : ${config.jira.url}`); console.log(` Proxy : ${config.proxy.host}:${config.proxy.port}`); console.log('============================================'); console.log(''); });