feat: initialize project with core configuration files and Docker setup

This commit is contained in:
ɧσℓσ
2025-09-29 21:44:59 +02:00
commit f6a3c80d96
22 changed files with 7545 additions and 0 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Configuration Discord
DISCORD_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_discord_server_id_here
# Configuration GitLab
GITLAB_URL=https://gitlab.example.com
GITLAB_TOKEN=your_gitlab_private_token_here
# Configuration Bot
SYNC_INTERVAL_MINUTES=5
LOG_LEVEL=info
# === CONFIGURATION MULTI-PROJETS ===
# Configuration principale: Modifiez le fichier config/projects.json
# Voir config/projects.json.example pour un exemple
# Configuration legacy (rétrocompatibilité - un seul projet)
# GITLAB_PROJECT_ID=2024-2025-but-info2-a-sae34/k1/k12/but-info2-a-sae-4-docker-stack
# DISCORD_CATEGORY_ID=1234567890123456789

26
.eslintrc.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
env: {
browser: false,
commonjs: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'no-console': 'off',
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-arrow-callback': 'error',
},
};

121
.gitignore vendored Normal file
View File

@@ -0,0 +1,121 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.local
.env.production
config/projects.json
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
# Utilise l'image officielle Node.js LTS
FROM node:18-alpine
# Définit le répertoire de travail
WORKDIR /app
# Copie les fichiers de dépendances
COPY package*.json ./
# Installe les dépendances
RUN npm ci --only=production && npm cache clean --force
# Copie le code source
COPY src/ ./src/
# Crée les répertoires pour les volumes
RUN mkdir -p /app/config
# Crée le fichier projects.json
COPY config/projects.json.example /app/config/projects.json
# Crée un utilisateur non-root pour la sécurité
RUN addgroup -g 1001 -S nodejs && \
adduser -S botuser -u 1001
# Change la propriété des fichiers
RUN chown -R botuser:nodejs /app
USER botuser
# Définit les variables d'environnement par défaut
ENV NODE_ENV=production
ENV LOG_LEVEL=info
# Commande de démarrage
CMD ["node", "src/index.js"]
# Labels pour la documentation
LABEL maintainer="paulinsegura79@gmail.com"
LABEL description="Bot Discord pour synchroniser les issue boards GitLab"
LABEL version="1.0.0"

211
README.md Normal file
View File

@@ -0,0 +1,211 @@
# GitLab Discord Kanban Bot
Bot Discord qui synchronise automatiquement les issue boards GitLab avec des salons Discord.
## 🚀 Fonctionnalités
- **Multi-projets** : Supporte plusieurs projets GitLab simultanément
- **Multi-boards** : Choisissez quels issue boards synchroniser par projet
- **Multi-catégories** : Une catégorie Discord par projet/board
- **Synchronisation automatique** : Crée un salon Discord pour chaque label du board GitLab
- **Organisation par colonnes** : Chaque salon correspond à une colonne du Kanban (To Do, In Progress, Done, etc.)
- **Couleurs GitLab** : Utilise les couleurs exactes des labels GitLab
- **Mise à jour en temps réel** : Actualise les issues périodiquement sans spam
- **Interface propre** : Affichage des issues sous forme d'embeds Discord élégants
- **Gestion intelligente** : Supprime et recrée seulement les messages modifiés
- **Architecture modulaire** : Code bien structuré et maintenable
## 📋 Prérequis
- Node.js >= 18.0.0
- Un bot Discord avec les permissions appropriées
- Un token GitLab avec accès au projet
- Une catégorie Discord dédiée
## 🛠️ Installation
1. **Cloner le projet**
```bash
git clone <votre-repo>
cd Kanban_BOT
```
2. **Installer les dépendances**
```bash
npm install
```
3. **Configuration**
```bash
cp .env.example .env
```
### Configuration de base (.env)
Éditer le fichier `.env` avec vos paramètres :
```env
DISCORD_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_discord_server_id_here
GITLAB_URL=https://gitlab.example.com
GITLAB_TOKEN=your_gitlab_private_token_here
SYNC_INTERVAL_MINUTES=5
LOG_LEVEL=info
```
### Configuration Multi-Projets (config/projects.json)
Créez/modifiez le fichier `config/projects.json` :
```json
[
{
"name": "Mon Projet Principal",
"projectId": "namespace/mon-projet",
"categoryId": "1234567890123456789",
"boardIds": [1, 2]
},
{
"name": "Projet Secondaire",
"projectId": "autre-namespace/autre-projet",
"categoryId": "9876543210987654321",
"boardIds": []
}
]
```
### Configuration Legacy (Un seul projet)
Si vous préférez l'ancienne méthode, ajoutez dans `.env` :
```env
GITLAB_PROJECT_ID=your_project_id_here
DISCORD_CATEGORY_ID=your_category_id_here
```
## 🚀 Utilisation
### Démarrage en production
```bash
npm start
```
### Démarrage en développement
```bash
npm run dev
```
## 📁 Structure du projet
```
src/
├── config/
│ └── config.js # Configuration centralisée
├── services/
│ ├── GitLabClient.js # Client API GitLab
│ ├── DiscordService.js # Service Discord
│ └── SyncService.js # Service de synchronisation
├── utils/
│ └── logger.js # Système de logging
└── index.js # Point d'entrée principal
```
## ⚙️ Configuration
### Discord
1. **Créer un bot Discord** sur le [Discord Developer Portal](https://discord.com/developers/applications)
2. **Permissions requises** :
- Manage Channels
- Send Messages
- Embed Links
- Read Message History
3. **Inviter le bot** sur votre serveur avec ces permissions
### GitLab
1. **Créer un token d'accès personnel** dans GitLab
2. **Permissions requises** :
- `read_api`
- `read_repository`
3. **Récupérer l'ID du projet** depuis l'URL ou les paramètres du projet
### Catégorie Discord
1. Créer une catégorie dédiée sur votre serveur Discord
2. Récupérer l'ID de la catégorie (mode développeur requis)
## 🔄 Fonctionnement
1. **Au démarrage** : Le bot lit la configuration multi-projets
2. **Initialisation** : Pour chaque projet configuré :
- Connexion à GitLab et récupération des boards
- Filtrage des boards selon `boardIds` (si spécifié)
- Suppression des salons existants dans la catégorie Discord
3. **Création des salons** : Un salon par label/colonne de chaque board
4. **Synchronisation des issues** : Les issues sont affichées dans le salon correspondant
5. **Mise à jour périodique** : Actualisation automatique de tous les projets
### Architecture Multi-Projets
```
Projet 1 (Catégorie Discord A)
├── Board "Kanban"
│ ├── Salon "kanban-to-do" = Issues avec label "To Do"
│ ├── Salon "kanban-doing" = Issues avec label "Doing"
│ └── Salon "kanban-done" = Issues avec label "Done"
└── Board "Bugs"
├── Salon "bugs-open" = Issues avec label "Open"
└── Salon "bugs-fixed" = Issues avec label "Fixed"
Projet 2 (Catégorie Discord B)
└── Board "Features"
├── Salon "features-backlog" = Issues avec label "Backlog"
└── Salon "features-dev" = Issues avec label "Development"
```
### Configuration des Boards
- **`boardIds: []`** : Synchronise TOUS les boards du projet
- **`boardIds: [1, 2]`** : Synchronise seulement les boards avec les IDs 1 et 2
- **Noms des salons** : Format `{board-name}-{label-name}` (ex: "kanban-to-do")
## 📊 Logs et monitoring
Le bot utilise un système de logging configurable :
- `error` : Erreurs critiques uniquement
- `warn` : Avertissements et erreurs
- `info` : Informations générales (par défaut)
- `debug` : Informations détaillées pour le débogage
## 🛡️ Gestion des erreurs
- **Reconnexion automatique** Discord en cas de déconnexion
- **Retry automatique** pour les appels API GitLab
- **Logs détaillés** pour faciliter le débogage
- **Arrêt propre** avec gestion des signaux système
## 🔮 Fonctionnalités futures
- [ ] Déplacement d'issues entre labels via Discord
- [ ] Commandes Discord pour contrôler le bot
- [ ] Support de plusieurs projets GitLab
- [ ] Notifications en temps réel via webhooks
- [ ] Interface web de configuration
## 🤝 Contribution
1. Fork le projet
2. Créer une branche feature (`git checkout -b feature/AmazingFeature`)
3. Commit les changements (`git commit -m 'Add some AmazingFeature'`)
4. Push vers la branche (`git push origin feature/AmazingFeature`)
5. Ouvrir une Pull Request
## 📝 Licence
Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails.
## 🆘 Support
En cas de problème :
1. Vérifier les logs du bot
2. Valider la configuration dans `.env`
3. Tester les permissions Discord et GitLab
4. Ouvrir une issue sur GitHub avec les logs d'erreur

View File

@@ -0,0 +1,20 @@
[
{
"name": "Mon Projet Example",
"projectId": "namespace/group/project-name",
"categoryId": "123456789012345678",
"boardIds": [123, 456]
},
{
"name": "Autre Projet",
"projectId": "autre-namespace/autre-projet",
"categoryId": "987654321098765432",
"boardIds": [789]
},
{
"name": "Projet Secondaire",
"projectId": "autre-namespace/projet-secondaire",
"categoryId": "9876543210987654321",
"boardIds": []
}
]

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
version: '3.8'
services:
gitlab-issue-discord-bot:
build:
context: .
dockerfile: Dockerfile
container_name: gitlab-issue-discord-bot
restart: unless-stopped
# Variables d'environnement
environment:
- NODE_ENV=production
- LOG_LEVEL=info
- DISCORD_TOKEN=${DISCORD_TOKEN:-}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID:-}
- GITLAB_URL=${GITLAB_URL:-}
- GITLAB_TOKEN=${GITLAB_TOKEN:-}
- SYNC_INTERVAL_MINUTES=${SYNC_INTERVAL_MINUTES:-5}
# Volumes pour la configuration
volumes:
- ./config/projects.json:/app/config/projects.json:ro # Fichier projects.json en lecture seule
- bot-data:/app/config/projects.json # Données persistantes du bot
# Réseau
networks:
- gitlab-issue-discord-bot-network
# Surveillance de santé
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Limites de ressources
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
# Volumes nommés
volumes:
bot-data:
driver: local
# Réseau dédié
networks:
gitlab-issue-discord-bot-network:
driver: bridge

5319
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "gitlab-issue-discord-bot",
"version": "1.0.0",
"description": "Bot Discord pour synchroniser les issue boards GitLab avec Discord",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix"
},
"keywords": ["discord", "gitlab", "kanban", "bot", "issue-board"],
"author": "Holo795",
"license": "MIT",
"dependencies": {
"discord.js": "^14.14.1",
"axios": "^1.6.2",
"dotenv": "^16.3.1",
"node-cron": "^3.0.3"
},
"devDependencies": {
"nodemon": "^3.0.2",
"eslint": "^8.56.0",
"jest": "^29.7.0"
},
"engines": {
"node": ">=18.0.0"
}
}

58
src/commands/Commands.js Normal file
View File

@@ -0,0 +1,58 @@
const CommandDefinitions = require('./definitions/CommandDefinitions');
const IssueInfoHandler = require('./handlers/IssueInfoHandler');
const SyncHandler = require('./handlers/SyncHandler');
const StatsHandler = require('./handlers/StatsHandler');
const AutocompleteService = require('./autocomplete/AutocompleteService');
/**
* Orchestrateur principal des commandes Discord
*/
class Commands {
constructor(gitlabClient, syncService) {
this.gitlabClient = gitlabClient;
this.syncService = syncService;
// Initialise les handlers
this.issueInfoHandler = new IssueInfoHandler(gitlabClient, syncService);
this.syncHandler = new SyncHandler(syncService);
this.statsHandler = new StatsHandler(syncService);
this.autocompleteService = new AutocompleteService(gitlabClient, syncService);
}
/**
* Retourne les définitions des commandes slash Discord
*/
getCommands() {
return CommandDefinitions.getAll();
}
/**
* Gère la commande d'information sur une issue
*/
async handleIssueInfo(interaction) {
return await this.issueInfoHandler.handle(interaction);
}
/**
* Gère la commande de synchronisation manuelle
*/
async handleSync(interaction) {
return await this.syncHandler.handle(interaction);
}
/**
* Gère la commande de statistiques
*/
async handleBotStats(interaction) {
return await this.statsHandler.handle(interaction);
}
/**
* Gère l'auto-complétion pour les commandes
*/
async handleAutocomplete(interaction) {
return await this.autocompleteService.handle(interaction);
}
}
module.exports = Commands;

View File

@@ -0,0 +1,148 @@
const logger = require('../../utils/logger');
/**
* Service pour gérer l'auto-complétion des commandes
*/
class AutocompleteService {
constructor(gitlabClient, syncService) {
this.gitlabClient = gitlabClient;
this.syncService = syncService;
}
/**
* Gère l'auto-complétion pour toutes les commandes
*/
async handle(interaction) {
try {
const focusedOption = interaction.options.getFocused(true);
switch (interaction.commandName) {
case 'issue-info':
if (focusedOption.name === 'project') {
await this.handleProjectAutocomplete(interaction, focusedOption.value);
} else if (focusedOption.name === 'issue') {
await this.handleIssueAutocomplete(interaction, focusedOption.value);
}
break;
case 'sync':
if (focusedOption.name === 'project') {
await this.handleProjectAutocomplete(interaction, focusedOption.value);
}
break;
}
} catch (error) {
logger.error('Erreur lors de l\'auto-complétion:', error);
// En cas d'erreur, on renvoie une liste vide
await interaction.respond([]);
}
}
/**
* Auto-complétion pour les noms de projets
*/
async handleProjectAutocomplete(interaction, value) {
const projects = this.getAvailableProjects();
const filtered = projects
.filter(project => project.toLowerCase().includes(value.toLowerCase()))
.slice(0, 25) // Discord limite à 25 choix
.map(project => ({
name: project,
value: project
}));
await interaction.respond(filtered);
}
/**
* Auto-complétion pour les issues
*/
async handleIssueAutocomplete(interaction, value) {
try {
// Récupère le projet sélectionné
const projectName = interaction.options.getString('project');
if (!projectName) {
await interaction.respond([{ name: 'Sélectionnez d\'abord un projet', value: 'no-project' }]);
return;
}
const projectConfig = this.findProjectConfig(projectName);
if (!projectConfig) {
await interaction.respond([{ name: 'Projet non trouvé', value: 'invalid-project' }]);
return;
}
// Recherche les issues dans GitLab
const issues = await this.searchIssues(projectConfig.projectId, value);
const choices = issues
.slice(0, 25) // Discord limite à 25 choix
.map(issue => ({
name: `#${issue.iid}${issue.title.substring(0, 80)}${issue.title.length > 80 ? '...' : ''}`,
value: `${issue.iid}:${issue.title}`
}));
if (choices.length === 0) {
choices.push({ name: 'Aucune issue trouvée', value: 'no-issues' });
}
await interaction.respond(choices);
} catch (error) {
logger.error('Erreur lors de la recherche d\'issues:', error);
await interaction.respond([{ name: 'Erreur lors de la recherche', value: 'search-error' }]);
}
}
/**
* Recherche les issues dans un projet
*/
async searchIssues(projectId, searchTerm) {
try {
// Si pas de terme de recherche, récupère les issues récentes
if (!searchTerm || searchTerm.length < 2) {
const response = await this.gitlabClient.client.get(`/projects/${projectId}/issues`, {
params: {
state: 'opened',
order_by: 'updated_at',
sort: 'desc',
per_page: 20
}
});
return response.data;
}
// Recherche par terme
const response = await this.gitlabClient.client.get(`/projects/${projectId}/issues`, {
params: {
search: searchTerm,
state: 'all', // Inclut les issues fermées dans la recherche
order_by: 'updated_at',
sort: 'desc',
per_page: 20
}
});
return response.data;
} catch (error) {
logger.error('Erreur lors de la recherche d\'issues GitLab:', error);
return [];
}
}
/**
* Méthodes utilitaires
*/
findProjectConfig(projectName) {
return this.syncService.config.projects.find(p =>
p.name.toLowerCase() === projectName.toLowerCase()
);
}
getAvailableProjects() {
return this.syncService.config.projects.map(p => p.name);
}
}
module.exports = AutocompleteService;

View File

@@ -0,0 +1,61 @@
const { SlashCommandBuilder } = require('discord.js');
/**
* Définitions des commandes slash Discord
*/
class CommandDefinitions {
/**
* Retourne toutes les définitions de commandes
*/
static getAll() {
return [
this.getIssueInfoCommand(),
this.getSyncCommand(),
this.getBotStatsCommand()
];
}
/**
* Commande pour afficher les détails d'une issue
*/
static getIssueInfoCommand() {
return new SlashCommandBuilder()
.setName('issue-info')
.setDescription('Affiche les détails d\'une issue')
.addStringOption(option =>
option.setName('project')
.setDescription('Nom du projet')
.setRequired(true)
.setAutocomplete(true))
.addStringOption(option =>
option.setName('issue')
.setDescription('Issue (tapez pour rechercher)')
.setRequired(true)
.setAutocomplete(true));
}
/**
* Commande pour forcer une synchronisation
*/
static getSyncCommand() {
return new SlashCommandBuilder()
.setName('sync')
.setDescription('Force une synchronisation manuelle des boards')
.addStringOption(option =>
option.setName('project')
.setDescription('Nom du projet (optionnel - tous si non spécifié)')
.setRequired(false)
.setAutocomplete(true));
}
/**
* Commande pour afficher les statistiques du bot
*/
static getBotStatsCommand() {
return new SlashCommandBuilder()
.setName('bot-stats')
.setDescription('Affiche les statistiques du bot');
}
}
module.exports = CommandDefinitions;

View File

@@ -0,0 +1,111 @@
const { EmbedBuilder } = require('discord.js');
const logger = require('../../utils/logger');
/**
* Handler pour la commande issue-info
*/
class IssueInfoHandler {
constructor(gitlabClient, syncService) {
this.gitlabClient = gitlabClient;
this.syncService = syncService;
}
/**
* Gère la commande d'information sur une issue
*/
async handle(interaction) {
try {
const projectName = interaction.options.getString('project');
const issueValue = interaction.options.getString('issue');
// Trouve le projet dans la configuration
const projectConfig = this.findProjectConfig(projectName);
if (!projectConfig) {
await interaction.editReply({
content: `❌ Projet "${projectName}" non trouvé. Projets disponibles: ${this.getAvailableProjects().join(', ')}`
});
return;
}
// Parse l'issue IID depuis la valeur sélectionnée (format: "123" ou "123:Titre")
const issueIid = parseInt(issueValue.split(':')[0]);
if (isNaN(issueIid)) {
await interaction.editReply({
content: '❌ Format d\'issue invalide'
});
return;
}
// Récupère l'issue depuis GitLab
logger.debug(`Récupération issue ${issueIid} pour projet ${projectConfig.projectId}`);
const issue = await this.gitlabClient.getIssue(issueIid, projectConfig.projectId);
// Crée l'embed
const embed = this.createIssueEmbed(issue, projectName);
await interaction.editReply({ embeds: [embed] });
logger.info(`Informations affichées pour l'issue ${issueIid} du projet ${projectName} (utilisateur: ${interaction.user.tag})`);
} catch (error) {
logger.error('Erreur lors de la récupération d\'informations:', error);
await interaction.editReply({
content: '❌ Erreur lors de la récupération des informations de l\'issue'
});
}
}
/**
* Crée un embed Discord pour une issue
*/
createIssueEmbed(issue, projectName) {
const embed = new EmbedBuilder()
.setTitle(`#${issue.iid}${issue.title}`)
.setURL(issue.web_url)
.setColor(issue.state === 'opened' ? 0x28a745 : 0x6c757d)
.setDescription(issue.description ?
(issue.description.length > 200 ? issue.description.substring(0, 200) + '...' : issue.description) :
'Aucune description')
.addFields(
{ name: '📊 État', value: issue.state === 'opened' ? '🟢 Ouvert' : '🔴 Fermé', inline: true },
{ name: '👤 Auteur', value: issue.author.name, inline: true },
{ name: '🎯 Assigné', value: issue.assignee ? issue.assignee.name : 'Non assigné', inline: true }
)
.setTimestamp(new Date(issue.updated_at))
.setFooter({ text: `Projet: ${projectName}` });
// Ajoute les labels si présents
if (issue.labels && issue.labels.length > 0) {
embed.addFields({
name: '🏷️ Labels',
value: issue.labels.map(label => `\`${label}\``).join(' '),
inline: false
});
}
// Ajoute le milestone si présent
if (issue.milestone) {
embed.addFields({
name: '🏆 Milestone',
value: issue.milestone.title,
inline: true
});
}
return embed;
}
/**
* Méthodes utilitaires
*/
findProjectConfig(projectName) {
return this.syncService.config.projects.find(p =>
p.name.toLowerCase() === projectName.toLowerCase()
);
}
getAvailableProjects() {
return this.syncService.config.projects.map(p => p.name);
}
}
module.exports = IssueInfoHandler;

View File

@@ -0,0 +1,80 @@
const { EmbedBuilder } = require('discord.js');
const logger = require('../../utils/logger');
/**
* Handler pour la commande bot-stats
*/
class StatsHandler {
constructor(syncService) {
this.syncService = syncService;
}
/**
* Gère la commande de statistiques
*/
async handle(interaction) {
try {
// Récupère les statistiques du service
const syncStats = this.syncService.getStats();
const uptime = process.uptime();
const memory = process.memoryUsage();
// Crée l'embed avec les statistiques
const embed = this.createStatsEmbed(syncStats, uptime, memory);
await interaction.editReply({ embeds: [embed] });
logger.info(`Statistiques du bot affichées via Discord (utilisateur: ${interaction.user.tag})`);
} catch (error) {
logger.error('Erreur lors de la récupération des statistiques:', error);
await interaction.editReply({
content: '❌ Erreur lors de la récupération des statistiques'
});
}
}
/**
* Crée un embed avec les statistiques du bot
*/
createStatsEmbed(syncStats, uptime, memory) {
// Formate l'uptime
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
const uptimeFormatted = `${hours}h ${minutes}m ${seconds}s`;
// Formate la mémoire
const memoryMB = (memory.rss / 1024 / 1024).toFixed(1);
// Crée un embed avec les statistiques
const embed = new EmbedBuilder()
.setTitle('Statistiques du Bot GitLab Discord Issue Board')
.setColor(0x007bff)
.addFields(
{ name: 'Uptime', value: uptimeFormatted, inline: true },
{ name: 'Mémoire', value: `${memoryMB} MB`, inline: true },
{ name: 'État', value: syncStats.initialized ? 'Actif' : 'Inactif', inline: true },
{ name: '📁 Projets', value: `${syncStats.projectsCount}`, inline: true },
{ name: '🔄 Sync périodique', value: syncStats.periodicSyncActive ? 'Active' : 'Inactive', inline: true },
{ name: 'Intervalle', value: `${syncStats.syncInterval} min`, inline: true }
)
.setTimestamp();
// Ajoute les détails par projet
if (syncStats.projects && Object.keys(syncStats.projects).length > 0) {
const projectDetails = Object.entries(syncStats.projects)
.map(([name, data]) => `**${name}**: ${data.channelsCount} salon(s)`)
.join('\n');
embed.addFields({
name: '📋 Détails des projets',
value: projectDetails,
inline: false
});
}
return embed;
}
}
module.exports = StatsHandler;

View File

@@ -0,0 +1,72 @@
const logger = require('../../utils/logger');
/**
* Handler pour la commande sync
*/
class SyncHandler {
constructor(syncService) {
this.syncService = syncService;
}
/**
* Gère la commande de synchronisation manuelle
*/
async handle(interaction) {
try {
const projectName = interaction.options.getString('project');
const startTime = Date.now();
if (projectName) {
// Synchronisation d'un projet spécifique
const projectConfig = this.findProjectConfig(projectName);
if (!projectConfig) {
await interaction.editReply({
content: `❌ Projet "${projectName}" non trouvé. Projets disponibles: ${this.getAvailableProjects().join(', ')}`
});
return;
}
await this.syncService.performProjectFullSync(projectName);
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
await interaction.editReply({
content: `✅ Synchronisation du projet **${projectName}** terminée en ${duration}s`
});
logger.info(`Synchronisation manuelle du projet ${projectName} terminée via Discord (utilisateur: ${interaction.user.tag})`);
} else {
// Synchronisation de tous les projets
await this.syncService.performIncrementalSync();
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
const projectCount = this.syncService.config.projects.length;
await interaction.editReply({
content: `✅ Synchronisation de **${projectCount} projet(s)** terminée en ${duration}s`
});
logger.info(`Synchronisation manuelle de tous les projets terminée via Discord (utilisateur: ${interaction.user.tag})`);
}
} catch (error) {
logger.error('Erreur lors de la synchronisation manuelle:', error);
await interaction.editReply({
content: '❌ Erreur lors de la synchronisation'
});
}
}
/**
* Méthodes utilitaires
*/
findProjectConfig(projectName) {
return this.syncService.config.projects.find(p =>
p.name.toLowerCase() === projectName.toLowerCase()
);
}
getAvailableProjects() {
return this.syncService.config.projects.map(p => p.name);
}
}
module.exports = SyncHandler;

102
src/config/config.js Normal file
View File

@@ -0,0 +1,102 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
/**
* Configuration centralisée de l'application
*/
const config = {
// Configuration Discord
discord: {
token: process.env.DISCORD_TOKEN,
guildId: process.env.DISCORD_GUILD_ID,
},
// Configuration GitLab
gitlab: {
url: process.env.GITLAB_URL,
token: process.env.GITLAB_TOKEN,
},
// Configuration Bot
bot: {
syncIntervalMinutes: parseInt(process.env.SYNC_INTERVAL_MINUTES) || 5,
logLevel: process.env.LOG_LEVEL || 'info',
},
// Configuration des projets et boards (format JSON)
projects: [],
// Initialise la configuration des projets
init() {
try {
// Méthode 1: Fichier JSON dédié
const projectsJsonPath = path.join(__dirname, '../../config/projects.json');
if (fs.existsSync(projectsJsonPath)) {
console.log('Chargement depuis config/projects.json...');
const projectsData = fs.readFileSync(projectsJsonPath, 'utf8');
this.projects = JSON.parse(projectsData);
console.log('Projets chargés depuis le fichier:', this.projects);
}
// Méthode 2: Configuration legacy (rétrocompatibilité)
else if (process.env.GITLAB_PROJECT_ID && process.env.DISCORD_CATEGORY_ID) {
console.log('Utilisation de la configuration legacy...');
this.projects = [{
name: "Default Project",
projectId: process.env.GITLAB_PROJECT_ID,
categoryId: process.env.DISCORD_CATEGORY_ID,
boardIds: []
}];
console.log('Configuration legacy créée:', this.projects);
}
// Aucune configuration trouvée
else {
throw new Error('Aucune configuration trouvée. Créez le fichier config/projects.json ou définissez les variables legacy (GITLAB_PROJECT_ID + DISCORD_CATEGORY_ID)');
}
// Validation des projets avant encoding
if (!this.projects || this.projects.length === 0) {
throw new Error('Aucun projet configuré');
}
// Encode les project IDs
this.projects = this.projects.map(project => ({
...project,
projectId: encodeURIComponent(project.projectId)
}));
} catch (error) {
throw new Error(`Erreur lors de l'initialisation des projets: ${error.message}`);
}
},
// Validation de la configuration
validate() {
const required = [
'DISCORD_TOKEN',
'DISCORD_GUILD_ID',
'GITLAB_URL',
'GITLAB_TOKEN'
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Variables d'environnement manquantes: ${missing.join(', ')}`);
}
// Validation des projets
if (this.projects.length === 0) {
throw new Error('Aucun projet configuré. Définissez PROJECTS_CONFIG ou les variables legacy.');
}
// Validation de chaque projet
for (const project of this.projects) {
if (!project.name || !project.projectId || !project.categoryId) {
throw new Error(`Configuration projet invalide: ${JSON.stringify(project)}`);
}
}
}
};
module.exports = config;

124
src/index.js Normal file
View File

@@ -0,0 +1,124 @@
const config = require('./config/config');
const logger = require('./utils/logger');
const SyncService = require('./services/SyncService');
const CommandService = require('./services/CommandService');
const GitLabClient = require('./services/GitLabClient');
/**
* Bot principal GitLab-Discord Kanban
*/
class KanbanBot {
constructor() {
this.syncService = null;
this.commandService = null;
this.gitlabClient = null;
this.isRunning = false;
}
/**
* Démarre le bot
*/
async start() {
try {
logger.info('=== Démarrage du Bot GitLab-Discord Kanban Multi-Projets ===');
// Initialisation de la configuration
config.init();
config.validate();
logger.info('Configuration validée');
logger.info(`${config.projects.length} projet(s) configuré(s):`);
config.projects.forEach(project => {
logger.info(` - ${project.name} (${project.projectId}) -> Catégorie ${project.categoryId}`);
});
// Initialisation des services
this.gitlabClient = new GitLabClient(config);
this.syncService = new SyncService(config);
this.commandService = new CommandService(config, this.gitlabClient, this.syncService);
// Démarrage du service de synchronisation
await this.syncService.initialize();
// Initialisation des commandes Discord (récupère le client du premier projet)
const firstProjectSync = Array.from(this.syncService.projectSyncs.values())[0];
if (firstProjectSync && firstProjectSync.discordService.client) {
await this.commandService.initialize(firstProjectSync.discordService.client);
}
this.isRunning = true;
logger.info('=== Bot multi-projets démarré avec succès ===');
// Affichage des statistiques
const stats = this.syncService.getStats();
logger.info('Statistiques du bot:', stats);
} catch (error) {
logger.error('Erreur lors du démarrage du bot:', error);
await this.shutdown();
process.exit(1);
}
}
/**
* Arrête le bot proprement
*/
async shutdown() {
try {
logger.info('=== Arrêt du bot en cours ===');
if (this.syncService) {
await this.syncService.shutdown();
}
this.isRunning = false;
logger.info('=== Bot arrêté ===');
} catch (error) {
logger.error('Erreur lors de l\'arrêt:', error);
}
}
/**
* Retourne l'état du bot
*/
getStatus() {
return {
running: this.isRunning,
stats: this.syncService ? this.syncService.getStats() : null
};
}
}
// Gestion des signaux système pour un arrêt propre
const bot = new KanbanBot();
process.on('SIGINT', async () => {
logger.info('Signal SIGINT reçu');
await bot.shutdown();
process.exit(0);
});
process.on('SIGTERM', async () => {
logger.info('Signal SIGTERM reçu');
await bot.shutdown();
process.exit(0);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Promesse rejetée non gérée:', { reason, promise });
});
process.on('uncaughtException', (error) => {
logger.error('Exception non capturée:', error);
process.exit(1);
});
// Démarrage du bot
if (require.main === module) {
bot.start().catch(error => {
logger.error('Erreur fatale:', error);
process.exit(1);
});
}
module.exports = KanbanBot;

View File

@@ -0,0 +1,160 @@
const { REST, Routes } = require('discord.js');
const logger = require('../utils/logger');
const Commands = require('../commands/Commands');
/**
* Service pour gérer les commandes Discord
*/
class CommandService {
constructor(config, gitlabClient, syncService) {
this.config = config;
this.gitlabClient = gitlabClient;
this.syncService = syncService;
this.commands = new Commands(gitlabClient, syncService);
this.client = null;
}
/**
* Initialise le service de commandes
*/
async initialize(discordClient) {
try {
this.client = discordClient;
// Enregistre les commandes slash
await this.registerSlashCommands();
// Configure les gestionnaires d'événements
this.setupEventHandlers();
logger.info('Service de commandes initialisé avec succès');
} catch (error) {
logger.error('Erreur lors de l\'initialisation du service de commandes:', error);
throw error;
}
}
/**
* Enregistre les commandes slash auprès de Discord
*/
async registerSlashCommands() {
try {
const commands = this.commands.getCommands().map(command => command.toJSON());
const rest = new REST({ version: '10' }).setToken(this.config.discord.token);
logger.info('Début de l\'enregistrement des commandes slash...');
await rest.put(
Routes.applicationGuildCommands(this.client.user.id, this.config.discord.guildId),
{ body: commands }
);
logger.info(`${commands.length} commande(s) slash enregistrée(s) avec succès`);
} catch (error) {
logger.error('Erreur lors de l\'enregistrement des commandes slash:', error);
throw error;
}
}
/**
* Configure les gestionnaires d'événements pour les interactions
*/
setupEventHandlers() {
// Gestionnaire pour les commandes slash
this.client.on('interactionCreate', async (interaction) => {
if (interaction.isChatInputCommand()) {
await this.handleSlashCommand(interaction);
} else if (interaction.isAutocomplete()) {
await this.handleAutocomplete(interaction);
}
});
logger.info('Gestionnaires d\'événements configurés');
}
/**
* Gère les commandes slash
*/
async handleSlashCommand(interaction) {
try {
// Toutes les commandes sont privées (ephemeral)
await interaction.deferReply({
flags: require('discord.js').MessageFlags.Ephemeral
});
switch (interaction.commandName) {
case 'issue-info':
await this.commands.handleIssueInfo(interaction);
break;
case 'sync':
await this.commands.handleSync(interaction);
break;
case 'bot-stats':
await this.commands.handleBotStats(interaction);
break;
default:
await interaction.editReply({
content: '❌ Commande non reconnue'
});
}
} catch (error) {
logger.error(`Erreur lors du traitement de la commande ${interaction.commandName}:`, error);
const errorMessage = '❌ Une erreur est survenue lors du traitement de la commande';
if (interaction.replied || interaction.deferred) {
await interaction.editReply({ content: errorMessage });
} else {
await interaction.reply({
content: errorMessage,
flags: require('discord.js').MessageFlags.Ephemeral
});
}
}
}
/**
* Gère l'auto-complétion
*/
async handleAutocomplete(interaction) {
try {
await this.commands.handleAutocomplete(interaction);
} catch (error) {
logger.error(`Erreur lors de l'auto-complétion pour ${interaction.commandName}:`, error);
// En cas d'erreur, on renvoie une liste vide
try {
await interaction.respond([]);
} catch (respondError) {
logger.error('Erreur lors de la réponse d\'auto-complétion:', respondError);
}
}
}
/**
* Nettoie les commandes enregistrées (utile pour le développement)
*/
async clearCommands() {
try {
const rest = new REST({ version: '10' }).setToken(this.config.discord.token);
await rest.put(
Routes.applicationGuildCommands(this.client.user.id, this.config.discord.guildId),
{ body: [] }
);
logger.info('Commandes slash supprimées');
} catch (error) {
logger.error('Erreur lors de la suppression des commandes:', error);
}
}
}
module.exports = CommandService;

View File

@@ -0,0 +1,290 @@
const { Client, GatewayIntentBits, ChannelType, EmbedBuilder } = require('discord.js');
const logger = require('../utils/logger');
/**
* Service pour gérer les interactions Discord
*/
class DiscordService {
constructor(config) {
this.config = config;
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
});
this.guild = null;
this.category = null;
this.channelMessageMap = new Map(); // Stocke les messages par canal pour éviter les doublons
}
/**
* Initialise le bot Discord
*/
async initialize() {
return new Promise((resolve, reject) => {
this.client.once('clientReady', async () => {
try {
logger.info(`Bot connecté en tant que ${this.client.user.tag}`);
// Récupère le serveur et la catégorie
this.guild = await this.client.guilds.fetch(this.config.discord.guildId);
this.category = await this.guild.channels.fetch(this.config.discord.categoryId);
if (!this.category || this.category.type !== ChannelType.GuildCategory) {
throw new Error('Catégorie Discord introuvable ou invalide');
}
logger.info(`Catégorie trouvée: ${this.category.name}`);
resolve();
} catch (error) {
reject(error);
}
});
this.client.on('error', error => {
logger.error('Erreur Discord:', error);
});
this.client.login(this.config.discord.token).catch(reject);
});
}
/**
* Supprime tous les salons de la catégorie
*/
async clearCategoryChannels() {
try {
if (!this.category) {
throw new Error('Service Discord non initialisé - catégorie introuvable');
}
const channels = this.category.children.cache;
logger.info(`Suppression de ${channels.size} salon(s) existant(s)`);
for (const [, channel] of channels) {
await channel.delete('Nettoyage pour synchronisation GitLab');
logger.debug(`Salon supprimé: ${channel.name}`);
}
this.channelMessageMap.clear();
} catch (error) {
logger.error('Erreur lors de la suppression des salons:', error);
throw error;
}
}
/**
* Crée un salon pour un label GitLab
*/
async createChannelForLabel(label) {
try {
const channelName = this.sanitizeChannelName(label.name);
const channel = await this.guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: this.category.id,
topic: `Label GitLab: ${label.name}`,
// Utilise la couleur du label si disponible
...(label.color && {
// Note: Discord ne supporte pas les couleurs de salon, mais on peut l'utiliser dans l'embed
})
});
logger.info(`Salon créé: ${channel.name} pour le label ${label.name}`);
this.channelMessageMap.set(channel.id, new Map());
return channel;
} catch (error) {
logger.error(`Erreur lors de la création du salon pour le label ${label.name}:`, error);
throw error;
}
}
/**
* Met à jour les messages d'issues dans un salon
*/
async updateChannelIssues(channel, issues, labelObj) {
try {
const channelMessages = this.channelMessageMap.get(channel.id) || new Map();
const currentIssueIds = new Set(issues.map(issue => issue.id));
// Supprime les messages des issues qui n'existent plus
for (const [issueId, messageId] of channelMessages) {
if (!currentIssueIds.has(issueId)) {
try {
const message = await channel.messages.fetch(messageId);
await message.delete();
channelMessages.delete(issueId);
logger.debug(`Message supprimé pour l'issue ${issueId}`);
} catch (error) {
// Message déjà supprimé ou introuvable
channelMessages.delete(issueId);
}
}
}
// Crée ou met à jour les messages pour les issues actuelles
for (const issue of issues) {
const embed = this.createIssueEmbed(issue, labelObj);
if (channelMessages.has(issue.id)) {
// Met à jour le message existant
try {
const messageId = channelMessages.get(issue.id);
const message = await channel.messages.fetch(messageId);
await message.edit({ embeds: [embed] });
logger.debug(`Message mis à jour pour l'issue ${issue.id}`);
} catch (error) {
// Message introuvable, on en crée un nouveau
const newMessage = await channel.send({ embeds: [embed] });
channelMessages.set(issue.id, newMessage.id);
}
} else {
// Crée un nouveau message
const newMessage = await channel.send({ embeds: [embed] });
channelMessages.set(issue.id, newMessage.id);
logger.debug(`Nouveau message créé pour l'issue ${issue.id}`);
}
}
this.channelMessageMap.set(channel.id, channelMessages);
logger.info(`Salon ${channel.name} mis à jour avec ${issues.length} issue(s)`);
} catch (error) {
logger.error(`Erreur lors de la mise à jour du salon ${channel.name}:`, error);
throw error;
}
}
/**
* Crée un embed Discord pour une issue GitLab
*/
createIssueEmbed(issue, labelObj) {
// Couleur basée sur la couleur du label GitLab
const embedColor = this.convertGitLabColor(labelObj.color);
// Description formatée
let description = '';
if (issue.description && issue.description.trim()) {
description = issue.description.length > 150 ?
issue.description.substring(0, 150) + '...' :
issue.description;
} else {
description = 'Aucune description';
}
//remove all #, **, *, `
description = description.replace(/\#|\*\*|\*|`/g, '');
const embed = new EmbedBuilder()
.setTitle(`#${issue.iid}${issue.title}`)
.setURL(issue.web_url)
.setDescription(`${description}`)
.setColor(embedColor)
.setTimestamp(new Date(issue.updated_at));
if (issue.task_completion_status && issue.task_completion_status.count > 0) {
embed.addFields({
name: 'Tâches',
value: `\`${issue.task_completion_status.completed_count}/${issue.task_completion_status.count}\``,
inline: false
});
}
// Labels
if (issue.labels && issue.labels.length > 0) {
const labelsList = issue.labels
.map(label => `\`${label}\``)
.join(' • ');
embed.addFields({
name: 'Labels',
value: labelsList,
inline: true
});
}
//Milestone
if (issue.milestone && issue.milestone.title) {
embed.addFields({
name: 'Milestone',
value: `\`${issue.milestone.title}\``,
inline: true
});
}
// Liens d'accès rapide
const quickLinks = [
`[Voir l'issue](${issue.web_url})`,
].join(' • ');
embed.addFields({
name: 'Accès rapide',
value: quickLinks,
inline: false
});
// Footer avec assigné si présent
if (issue.assignee) {
embed.setFooter({
text: issue.assignee.name,
iconURL: issue.assignee.avatar_url || undefined
});
} else {
embed.setFooter({
text: 'Non assigné'
});
}
return embed;
}
/**
* Convertit une couleur GitLab (format #RRGGBB) en couleur Discord (format 0xRRGGBB)
*/
convertGitLabColor(gitlabColor) {
if (!gitlabColor) {
return 0x6f42c1; // Couleur par défaut (violet)
}
// Supprime le # si présent et convertit en nombre hexadécimal
const cleanColor = gitlabColor.replace('#', '');
const colorInt = parseInt(cleanColor, 16);
// Vérification que c'est un nombre valide
if (isNaN(colorInt)) {
return 0x6f42c1; // Couleur par défaut si invalide
}
return colorInt;
}
/**
* Nettoie le nom d'un salon Discord
*/
sanitizeChannelName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9-_]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 100);
}
/**
* Ferme la connexion Discord
*/
async disconnect() {
if (this.client) {
await this.client.destroy();
logger.info('Bot Discord déconnecté');
}
}
}
module.exports = DiscordService;

View File

@@ -0,0 +1,123 @@
const axios = require('axios');
const logger = require('../utils/logger');
/**
* Client pour interagir avec l'API GitLab
*/
class GitLabClient {
constructor(config) {
this.baseURL = config.gitlab.url;
this.token = config.gitlab.token;
this.projectId = config.gitlab.projectId;
this.client = axios.create({
baseURL: `${this.baseURL}/api/v4`,
headers: {
'PRIVATE-TOKEN': this.token,
'Content-Type': 'application/json'
},
timeout: 10000
});
// Intercepteur pour logger les erreurs
this.client.interceptors.response.use(
response => response,
error => {
logger.error('Erreur API GitLab:', {
url: error.config?.url,
status: error.response?.status,
message: error.response?.data?.message || error.message
});
throw error;
}
);
}
/**
* Récupère les listes d'un board spécifique
*/
async getBoardLists(boardId, projectId = null) {
const targetProjectId = projectId || this.projectId;
try {
logger.debug(`Récupération des listes pour le board ${boardId} du projet ${targetProjectId}`);
const response = await this.client.get(`/projects/${targetProjectId}/boards/${boardId}/lists`);
logger.info(`${response.data.length} liste(s) trouvée(s) pour le board ${boardId}`);
return response.data;
} catch (error) {
logger.error(`Impossible de récupérer les listes du board ${boardId} (projet: ${targetProjectId})`);
throw error;
}
}
/**
* Récupère les issues d'un projet filtrées par label
*/
async getIssuesByLabel(labelName, projectId = null) {
const targetProjectId = projectId || this.projectId;
try {
logger.debug(`Récupération des issues avec le label: ${labelName} (projet: ${targetProjectId})`);
const response = await this.client.get(`/projects/${targetProjectId}/issues`, {
params: {
labels: labelName,
state: 'opened',
per_page: 100
}
});
logger.debug(`${response.data.length} issue(s) trouvée(s) pour le label ${labelName} (projet: ${targetProjectId})`);
return response.data;
} catch (error) {
logger.error(`Impossible de récupérer les issues pour le label ${labelName} (projet: ${targetProjectId})`);
throw error;
}
}
/**
* Récupère les détails d'une issue spécifique
*/
async getIssue(issueIid, projectId = null) {
const targetProjectId = projectId || this.projectId;
try {
logger.debug(`Récupération de l'issue ${issueIid} (projet: ${targetProjectId})`);
const response = await this.client.get(`/projects/${targetProjectId}/issues/${issueIid}`);
return response.data;
} catch (error) {
logger.error(`Impossible de récupérer l'issue ${issueIid} (projet: ${targetProjectId})`);
throw error;
}
}
/**
* Met à jour les labels d'une issue
*/
async updateIssueLabels(issueIid, labels) {
try {
logger.info(`Mise à jour des labels de l'issue ${issueIid}:`, labels);
const response = await this.client.put(`/projects/${this.projectId}/issues/${issueIid}`, {
labels: labels.join(',')
});
return response.data;
} catch (error) {
logger.error(`Impossible de mettre à jour l'issue ${issueIid}`);
throw error;
}
}
/**
* Teste la connexion à GitLab
*/
async testConnection() {
try {
const response = await this.client.get(`/projects/${this.projectId}`);
logger.info('Connexion GitLab réussie:', {
project: response.data.name,
namespace: response.data.path_with_namespace
});
return true;
} catch (error) {
logger.error('Échec de la connexion GitLab');
return false;
}
}
}
module.exports = GitLabClient;

298
src/services/SyncService.js Normal file
View File

@@ -0,0 +1,298 @@
const cron = require('node-cron');
const logger = require('../utils/logger');
const GitLabClient = require('./GitLabClient');
const DiscordService = require('./DiscordService');
/**
* Service de synchronisation pour plusieurs projets GitLab
*/
class SyncService {
constructor(config) {
this.config = config;
this.gitlabClient = new GitLabClient(config);
this.projectSyncs = new Map(); // Map projectConfig -> { discordService, boardChannelMap }
this.cronJob = null;
this.isInitialized = false;
}
/**
* Initialise le service pour tous les projets
*/
async initialize() {
try {
logger.info('Initialisation du service multi-projets...');
// Initialise chaque projet (le test de connexion se fait par projet)
for (const projectConfig of this.config.projects) {
await this.initializeProject(projectConfig);
}
// Démarrage de la synchronisation périodique
this.startPeriodicSync();
this.isInitialized = true;
logger.info(`Service multi-projets initialisé avec succès - ${this.config.projects.length} projet(s)`);
} catch (error) {
logger.error('Erreur lors de l\'initialisation multi-projets:', error);
throw error;
}
}
/**
* Initialise un projet spécifique
*/
async initializeProject(projectConfig) {
try {
logger.info(`Initialisation du projet: ${projectConfig.name}`);
// Test de la connexion GitLab pour ce projet spécifique
try {
const response = await this.gitlabClient.client.get(`/projects/${projectConfig.projectId}`);
logger.info(`Connexion GitLab réussie pour le projet ${projectConfig.name}:`, {
project: response.data.name,
namespace: response.data.path_with_namespace
});
} catch (error) {
logger.error(`Impossible de se connecter au projet GitLab ${projectConfig.name} (${projectConfig.projectId})`);
throw new Error(`Connexion GitLab échouée pour le projet ${projectConfig.name}`);
}
// Crée un service Discord dédié pour ce projet
const discordConfig = {
...this.config,
discord: {
...this.config.discord,
categoryId: projectConfig.categoryId
}
};
const discordService = new DiscordService(discordConfig);
await discordService.initialize();
// Stocke les informations du projet
const projectSync = {
discordService,
boardChannelMap: new Map(),
config: projectConfig
};
this.projectSyncs.set(projectConfig.name, projectSync);
// Synchronisation initiale pour ce projet
await this.performProjectFullSync(projectConfig.name);
logger.info(`Projet ${projectConfig.name} initialisé avec succès`);
} catch (error) {
logger.error(`Erreur lors de l'initialisation du projet ${projectConfig.name}:`, error);
throw error;
}
}
/**
* Effectue une synchronisation complète pour un projet
*/
async performProjectFullSync(projectName) {
try {
const projectSync = this.projectSyncs.get(projectName);
if (!projectSync) {
throw new Error(`Projet ${projectName} non trouvé`);
}
const { discordService, config: projectConfig } = projectSync;
logger.info(`Synchronisation complète du projet: ${projectName}`);
// Vérifie qu'on a des board IDs configurés
if (!projectConfig.boardIds || projectConfig.boardIds.length === 0) {
logger.warn(`Aucun boardId configuré pour le projet ${projectName}. Ajoutez des IDs dans config/projects.json`);
return;
}
// Supprime tous les salons existants dans la catégorie
await discordService.clearCategoryChannels();
// Synchronise chaque board configuré
projectSync.boardChannelMap.clear();
for (const boardId of projectConfig.boardIds) {
await this.syncBoardById(projectName, boardId);
}
logger.info(`Synchronisation complète terminée pour ${projectName} - ${projectConfig.boardIds.length} board(s)`);
} catch (error) {
logger.error(`Erreur lors de la synchronisation complète du projet ${projectName}:`, error);
throw error;
}
}
/**
* Synchronise un board spécifique par son ID
*/
async syncBoardById(projectName, boardId) {
try {
const projectSync = this.projectSyncs.get(projectName);
const { discordService, config: projectConfig } = projectSync;
logger.debug(`Synchronisation du board ID ${boardId} (projet: ${projectName})`);
// Récupère les listes du board spécifique
const boardLists = await this.gitlabClient.getBoardLists(boardId, projectConfig.projectId);
if (boardLists.length === 0) {
logger.warn(`Aucune liste trouvée pour le board ${boardId}`);
return;
}
// Crée un salon pour chaque liste du board
for (const list of boardLists) {
if (list.label && list.label.name) {
// Nom de salon simple basé sur le label
const channelName = list.label.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
// Crée le salon
const channel = await discordService.createChannelForLabel({
...list.label,
name: channelName
});
const channelKey = `${boardId}-${list.label.name}`;
projectSync.boardChannelMap.set(channelKey, {
channel,
labelObj: list.label,
boardId: boardId,
projectConfig: projectSync.config
});
// Synchronise les issues pour ce label
await this.syncLabelIssues(projectName, channelKey);
}
}
} catch (error) {
logger.error(`Erreur lors de la synchronisation du board ${boardId}:`, error);
}
}
/**
* Synchronise les issues d'un label spécifique
*/
async syncLabelIssues(projectName, channelKey) {
try {
const projectSync = this.projectSyncs.get(projectName);
const channelData = projectSync.boardChannelMap.get(channelKey);
if (!channelData) {
logger.warn(`Canal ${channelKey} non trouvé pour le projet ${projectName}`);
return;
}
const { channel, labelObj, projectConfig } = channelData;
// Récupère les issues pour ce label dans ce projet
const issues = await this.gitlabClient.getIssuesByLabel(labelObj.name, projectConfig.projectId);
// Met à jour le salon avec les issues de ce label
await projectSync.discordService.updateChannelIssues(channel, issues, labelObj);
} catch (error) {
logger.error(`Erreur lors de la synchronisation du label ${channelKey} (projet: ${projectName}):`, error);
}
}
/**
* Effectue une synchronisation incrémentale pour tous les projets
*/
async performIncrementalSync() {
try {
logger.debug('Début de la synchronisation incrémentale multi-projets...');
for (const [projectName, projectSync] of this.projectSyncs) {
logger.debug(`Synchronisation incrémentale du projet: ${projectName}`);
for (const [channelKey] of projectSync.boardChannelMap) {
await this.syncLabelIssues(projectName, channelKey);
}
}
logger.debug('Synchronisation incrémentale multi-projets terminée');
} catch (error) {
logger.error('Erreur lors de la synchronisation incrémentale multi-projets:', error);
}
}
/**
* Démarre la synchronisation périodique
*/
startPeriodicSync() {
const cronExpression = `*/${this.config.bot.syncIntervalMinutes} * * * *`;
this.cronJob = cron.schedule(cronExpression, async () => {
logger.debug('Déclenchement de la synchronisation périodique multi-projets');
await this.performIncrementalSync();
}, {
scheduled: false
});
this.cronJob.start();
logger.info(`Synchronisation périodique multi-projets démarrée (toutes les ${this.config.bot.syncIntervalMinutes} minutes)`);
}
/**
* Arrête la synchronisation périodique
*/
stopPeriodicSync() {
if (this.cronJob) {
this.cronJob.stop();
this.cronJob = null;
logger.info('Synchronisation périodique multi-projets arrêtée');
}
}
/**
* Arrête le service
*/
async shutdown() {
try {
logger.info('Arrêt du service multi-projets...');
this.stopPeriodicSync();
// Déconnecte tous les services Discord
for (const [projectName, projectSync] of this.projectSyncs) {
await projectSync.discordService.disconnect();
logger.debug(`Service Discord déconnecté pour le projet: ${projectName}`);
}
logger.info('Service multi-projets arrêté');
} catch (error) {
logger.error('Erreur lors de l\'arrêt multi-projets:', error);
}
}
/**
* Retourne les statistiques du service
*/
getStats() {
const projectStats = {};
for (const [projectName, projectSync] of this.projectSyncs) {
projectStats[projectName] = {
channelsCount: projectSync.boardChannelMap.size,
categoryId: projectSync.config.categoryId
};
}
return {
initialized: this.isInitialized,
projectsCount: this.projectSyncs.size,
syncInterval: this.config.bot.syncIntervalMinutes,
periodicSyncActive: this.cronJob !== null,
projects: projectStats
};
}
}
module.exports = SyncService;

75
src/utils/logger.js Normal file
View File

@@ -0,0 +1,75 @@
const config = require('../config/config');
/**
* Logger simple et efficace
*/
class Logger {
constructor() {
this.levels = {
error: 0,
warn: 1,
info: 2,
debug: 3
};
this.currentLevel = this.levels[config.bot.logLevel] || this.levels.info;
}
/**
* Formate un message de log avec timestamp
*/
formatMessage(level, message, data = null) {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
if (data) {
// Gestion spéciale pour les erreurs
if (data instanceof Error) {
return `${prefix} ${message}\nError: ${data.message}\nStack: ${data.stack}`;
}
// Évite les objets vides dans les logs
if (typeof data === 'object' && Object.keys(data).length === 0) {
return `${prefix} ${message} [Objet vide]`;
}
return `${prefix} ${message} ${JSON.stringify(data, null, 2)}`;
}
return `${prefix} ${message}`;
}
/**
* Log une erreur
*/
error(message, data = null) {
if (this.currentLevel >= this.levels.error) {
console.error(this.formatMessage('error', message, data));
}
}
/**
* Log un avertissement
*/
warn(message, data = null) {
if (this.currentLevel >= this.levels.warn) {
console.warn(this.formatMessage('warn', message, data));
}
}
/**
* Log une information
*/
info(message, data = null) {
if (this.currentLevel >= this.levels.info) {
console.log(this.formatMessage('info', message, data));
}
}
/**
* Log de debug
*/
debug(message, data = null) {
if (this.currentLevel >= this.levels.debug) {
console.log(this.formatMessage('debug', message, data));
}
}
}
module.exports = new Logger();