feat: initialize project with core configuration files and Docker setup
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal 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
26
.eslintrc.js
Normal 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
121
.gitignore
vendored
Normal 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
40
Dockerfile
Normal 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
211
README.md
Normal 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
|
||||||
20
config/projects.json.example
Normal file
20
config/projects.json.example
Normal 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
57
docker-compose.yml
Normal 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
5319
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
58
src/commands/Commands.js
Normal 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;
|
||||||
148
src/commands/autocomplete/AutocompleteService.js
Normal file
148
src/commands/autocomplete/AutocompleteService.js
Normal 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;
|
||||||
61
src/commands/definitions/CommandDefinitions.js
Normal file
61
src/commands/definitions/CommandDefinitions.js
Normal 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;
|
||||||
111
src/commands/handlers/IssueInfoHandler.js
Normal file
111
src/commands/handlers/IssueInfoHandler.js
Normal 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;
|
||||||
80
src/commands/handlers/StatsHandler.js
Normal file
80
src/commands/handlers/StatsHandler.js
Normal 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;
|
||||||
72
src/commands/handlers/SyncHandler.js
Normal file
72
src/commands/handlers/SyncHandler.js
Normal 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
102
src/config/config.js
Normal 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
124
src/index.js
Normal 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;
|
||||||
160
src/services/CommandService.js
Normal file
160
src/services/CommandService.js
Normal 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;
|
||||||
290
src/services/DiscordService.js
Normal file
290
src/services/DiscordService.js
Normal 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;
|
||||||
123
src/services/GitLabClient.js
Normal file
123
src/services/GitLabClient.js
Normal 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
298
src/services/SyncService.js
Normal 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
75
src/utils/logger.js
Normal 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();
|
||||||
Reference in New Issue
Block a user