Release notes: create Jira tickets from the header, targeting the project and epic found in JQL, with title, dates, and assignee.
365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
import fs from 'fs';
|
|
import express from 'express';
|
|
import axios from 'axios';
|
|
import https from 'https';
|
|
import path from 'path';
|
|
import { HttpsProxyAgent } from 'https-proxy-agent'; // <-- AJOUT
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// ==========================================
|
|
// 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
|
|
});
|
|
|
|
function parseJqlContext(jql, fallbackProjectKey) {
|
|
const projectMatch = jql?.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i);
|
|
const parentMatch = jql?.match(/\bparent\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
|
const epicLinkMatch = jql?.match(/["']?Epic Link["']?\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
|
const customEpicMatch = jql?.match(/cf\[(\d+)\]\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
|
|
|
return {
|
|
projectKey: (projectMatch?.[1] || fallbackProjectKey || '').toUpperCase(),
|
|
epicKey: (parentMatch?.[1] || epicLinkMatch?.[1] || customEpicMatch?.[2] || '').toUpperCase(),
|
|
epicRelation: parentMatch ? 'parent' : (customEpicMatch ? `customfield_${customEpicMatch[1]}` : (epicLinkMatch ? 'epic-link' : null))
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// 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 à /
|
|
|
|
|
|
const app = express();
|
|
|
|
// 1. Middlewares
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
// 2. Fichiers statiques (CSS, JS client, Images)
|
|
// On ne sert QUE le contenu du dossier public
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// 3. Routes HTML
|
|
app.get('/', (req, res) => {
|
|
// On va chercher l'index dans /views/
|
|
res.sendFile(path.join(__dirname, '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 création de ticket dans le projet / epic ciblé par la JQL
|
|
app.post('/api/issues', async (req, res) => {
|
|
const { username, apiToken, projectKey, jql, summary, dueDate, goLiveDate, goLiveFieldId, assignee } = req.body;
|
|
|
|
if (!username || !apiToken || !summary) {
|
|
return res.status(400).json({ error: "Les champs username, apiToken et summary sont requis." });
|
|
}
|
|
|
|
const context = parseJqlContext(jql, projectKey);
|
|
if (!context.projectKey) {
|
|
return res.status(400).json({ error: "Aucun projet n'a été trouvé dans la JQL." });
|
|
}
|
|
|
|
const headers = {
|
|
'Authorization': `Bearer ${apiToken}`,
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json'
|
|
};
|
|
|
|
try {
|
|
const fields = {
|
|
project: { key: context.projectKey },
|
|
summary: summary.trim(),
|
|
issuetype: { name: 'Task' }
|
|
};
|
|
|
|
if (dueDate) fields.duedate = dueDate;
|
|
if (goLiveDate && goLiveFieldId?.startsWith('customfield_')) fields[goLiveFieldId] = goLiveDate;
|
|
if (assignee?.trim()) fields.assignee = { name: assignee.trim() };
|
|
|
|
if (context.epicKey) {
|
|
if (context.epicRelation === 'parent') {
|
|
fields.parent = { key: context.epicKey };
|
|
} else {
|
|
let epicFieldId = context.epicRelation;
|
|
if (epicFieldId === 'epic-link') {
|
|
const fieldResponse = await jiraClient.get('/rest/api/2/field', { headers });
|
|
epicFieldId = fieldResponse.data.find(field => field.name === 'Epic Link')?.id;
|
|
}
|
|
if (!epicFieldId) {
|
|
return res.status(400).json({ error: "Le champ Jira 'Epic Link' est introuvable." });
|
|
}
|
|
fields[epicFieldId] = context.epicKey;
|
|
}
|
|
}
|
|
|
|
const response = await jiraClient.post('/rest/api/2/issue', { fields }, { headers });
|
|
console.log(`[CREATE] Ticket ${response.data.key} créé dans ${context.projectKey}.`);
|
|
res.status(201).json({
|
|
success: true,
|
|
key: response.data.key,
|
|
url: `${config.jira.url}/browse/${response.data.key}`
|
|
});
|
|
} catch (error) {
|
|
console.error('[CREATE 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('');
|
|
}); |