diff --git a/TFS_UPGRADE_SUMMARY.md b/TFS_UPGRADE_SUMMARY.md new file mode 100644 index 0000000..4f0b229 --- /dev/null +++ b/TFS_UPGRADE_SUMMARY.md @@ -0,0 +1,103 @@ +# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur + +## 🎯 Objectif +Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique. + +## ⚡ Changements apportés + +### 1. Service TFS (`src/services/tfs.ts`) + +#### Nouvelles méthodes ajoutées : +- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur +- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur +- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer +- **`filterPullRequests()`** : Applique les filtres de configuration + +#### Méthode syncTasks refactorisée : +- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories +- Plus efficace et centrée sur l'utilisateur +- Récupération directe via l'API Azure DevOps avec critères `@me` + +#### Configuration mise à jour : +- **`projectName`** devient **optionnel** +- Validation assouplie dans les factories +- Comportement adaptatif : projet spécifique OU toute l'organisation + +### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`) + +#### Modifications du formulaire : +- Champ "Nom du projet" marqué comme **optionnel** +- Validation `required` supprimée +- Placeholder mis à jour : *"laisser vide pour toute l'organisation"* +- Affichage du statut : *"Toute l'organisation"* si pas de projet + +#### Instructions mises à jour : +- Explique le nouveau comportement **synchronisation intelligente** +- Précise que les PRs sont récupérées automatiquement selon l'assignation +- Note sur la portée projet vs organisation + +### 3. Endpoints API + +#### `/api/tfs/test/route.ts` +- Validation mise à jour (projectName optionnel) +- Message de réponse enrichi avec portée (projet/organisation) +- Retour détaillé du scope de synchronisation + +#### `/api/tfs/sync/route.ts` +- Validation assouplie pour les deux méthodes GET/POST +- Configuration adaptative selon la présence du projectName + +## 🔧 API Azure DevOps utilisées + +### Nouvelles requêtes : +```typescript +// PRs créées par l'utilisateur +/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active + +// PRs où je suis reviewer +/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active +``` + +### Comportement intelligent : +- **Fusion automatique** des deux types de PRs +- **Déduplication** basée sur `pullRequestId` +- **Filtrage** selon la configuration (repositories, branches, projet) + +## 📊 Avantages + +1. **Centré utilisateur** : Récupère seulement les PRs pertinentes +2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos +3. **Flexibilité** : Projet spécifique OU toute l'organisation +4. **Scalabilité** : Fonctionne avec des organisations de grande taille +5. **Simplicité** : Configuration minimale requise + +## 🎨 Interface utilisateur + +### Avant : +- Champ projet **obligatoire** +- Synchronisation limitée à UN projet +- Configuration rigide + +### Après : +- Champ projet **optionnel** +- Synchronisation intelligente de TOUTES les PRs assignées +- Configuration flexible et adaptative +- Instructions claires sur le comportement + +## ✅ Tests recommandés + +1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet +2. **Configuration sans projet** : Vérifier la récupération organisation complète +3. **Test de connexion** : Valider le nouveau comportement API +4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées + +## 🚀 Déploiement + +La migration est **transparente** : +- Les configurations existantes continuent à fonctionner +- Possibilité de supprimer le `projectName` pour étendre la portée +- Pas de rupture de compatibilité + +--- + +*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯 \ No newline at end of file diff --git a/TODO.md b/TODO.md index 9b7df42..3677ae2 100644 --- a/TODO.md +++ b/TODO.md @@ -37,15 +37,15 @@ ## 🚀 Nouvelles idées & fonctionnalités futures ### 🔄 Intégration TFS/Azure DevOps -- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches - - [ ] PR arrivent en backlog avec filtrage par team project - - [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires) - - [ ] Filtrage par team project, repository, auteur -- [ ] **Architecture plug-and-play pour intégrations** - - [ ] Refactoriser pour interfaces génériques d'intégration - - [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.) - - [ ] UI générique de configuration des intégrations - - [ ] Système de plugins pour ajouter facilement de nouveaux services +- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches + - [x] PR arrivent en backlog avec filtrage par team project + - [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires) + - [x] Filtrage par team project, repository, auteur +- [x] **Architecture plug-and-play pour intégrations** + - [x] Refactoriser pour interfaces génériques d'intégration + - [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.) + - [x] UI générique de configuration des intégrations + - [x] Système de plugins pour ajouter facilement de nouveaux services ### 📋 Daily - Gestion des tâches non cochées - [x] **Section des tâches en attente** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65cc138..ab1c360 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -11,27 +8,28 @@ datasource db { } model Task { - id String @id @default(cuid()) - title String - description String? - status String @default("todo") - priority String @default("medium") - source String // "reminders" | "jira" - sourceId String? // ID dans le système source - dueDate DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Métadonnées Jira - jiraProject String? - jiraKey String? - jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc. - assignee String? - - // Relations - taskTags TaskTag[] - dailyCheckboxes DailyCheckbox[] + id String @id @default(cuid()) + title String + description String? + status String @default("todo") + priority String @default("medium") + source String + sourceId String? + dueDate DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + jiraProject String? + jiraKey String? + assignee String? + jiraType String? + tfsProject String? + tfsPullRequestId Int? + tfsRepository String? + tfsSourceBranch String? + tfsTargetBranch String? + dailyCheckboxes DailyCheckbox[] + taskTags TaskTag[] @@unique([source, sourceId]) @@map("tasks") @@ -41,7 +39,7 @@ model Tag { id String @id @default(cuid()) name String @unique color String @default("#6b7280") - isPinned Boolean @default(false) // Tag pour objectifs principaux + isPinned Boolean @default(false) taskTags TaskTag[] @@map("tags") @@ -50,8 +48,8 @@ model Tag { model TaskTag { taskId String tagId String - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) @@id([taskId, tagId]) @@map("task_tags") @@ -59,8 +57,8 @@ model TaskTag { model SyncLog { id String @id @default(cuid()) - source String // "reminders" | "jira" - status String // "success" | "error" + source String + status String message String? tasksSync Int @default(0) createdAt DateTime @default(now()) @@ -70,43 +68,33 @@ model SyncLog { model DailyCheckbox { id String @id @default(cuid()) - date DateTime // Date de la checkbox (YYYY-MM-DD) - text String // Texte de la checkbox + date DateTime + text String isChecked Boolean @default(false) - type String @default("task") // "task" | "meeting" - order Int @default(0) // Ordre d'affichage pour cette date - taskId String? // Liaison optionnelle vers une tâche + type String @default("task") + order Int @default(0) + taskId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - // Relations - task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) + task Task? @relation(fields: [taskId], references: [id]) @@index([date]) @@map("daily_checkboxes") } model UserPreferences { - id String @id @default(cuid()) - - // Filtres Kanban (JSON) - kanbanFilters Json? - - // Préférences de vue (JSON) - viewPreferences Json? - - // Visibilité des colonnes (JSON) + id String @id @default(cuid()) + kanbanFilters Json? + viewPreferences Json? columnVisibility Json? - - // Configuration Jira (JSON) - jiraConfig Json? - - // Configuration du scheduler Jira - jiraAutoSync Boolean @default(false) - jiraSyncInterval String @default("daily") // hourly, daily, weekly - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - + jiraConfig Json? + jiraAutoSync Boolean @default(false) + jiraSyncInterval String @default("daily") + tfsConfig Json? + tfsAutoSync Boolean @default(false) + tfsSyncInterval String @default("daily") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@map("user_preferences") } diff --git a/src/actions/jira-analytics.ts b/src/actions/jira-analytics.ts index 10ca38a..ef309ea 100644 --- a/src/actions/jira-analytics.ts +++ b/src/actions/jira-analytics.ts @@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise 0 + ? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès` + : 'Aucune tâche TFS trouvée à supprimer', + data: { + deletedCount: result.deletedCount + } + }); + } else { + return NextResponse.json({ + success: false, + error: result.error || 'Erreur lors de la suppression', + }, { status: 500 }); + } + + } catch (error) { + console.error('❌ Erreur lors de la suppression des tâches TFS:', error); + + return NextResponse.json({ + success: false, + error: 'Erreur lors de la suppression des tâches TFS', + details: error instanceof Error ? error.message : 'Erreur inconnue' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/tfs/sync/route.ts b/src/app/api/tfs/sync/route.ts new file mode 100644 index 0000000..e22092b --- /dev/null +++ b/src/app/api/tfs/sync/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { tfsService } from '@/services/tfs'; + +/** + * Route POST /api/tfs/sync + * Synchronise les Pull Requests TFS/Azure DevOps avec la base locale + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function POST(_request: Request) { + try { + console.log('🔄 Début de la synchronisation TFS manuelle...'); + + // Effectuer la synchronisation via le service singleton + const result = await tfsService.syncTasks(); + + if (result.success) { + return NextResponse.json({ + message: 'Synchronisation TFS terminée avec succès', + data: result, + }); + } else { + return NextResponse.json( + { + error: 'Synchronisation TFS terminée avec des erreurs', + data: result, + }, + { status: 207 } // Multi-Status + ); + } + } catch (error) { + console.error('❌ Erreur API sync TFS:', error); + + return NextResponse.json( + { + error: 'Erreur interne lors de la synchronisation', + details: error instanceof Error ? error.message : 'Erreur inconnue', + }, + { status: 500 } + ); + } +} + +/** + * Route GET /api/tfs/sync + * Teste la connexion TFS + */ +export async function GET() { + try { + // Tester la connexion via le service singleton + const isConnected = await tfsService.testConnection(); + + if (isConnected) { + return NextResponse.json({ + message: 'Connexion TFS OK', + connected: true, + }); + } else { + return NextResponse.json( + { + error: 'Connexion TFS échouée', + connected: false, + }, + { status: 401 } + ); + } + } catch (error) { + console.error('❌ Erreur test connexion TFS:', error); + + return NextResponse.json( + { + error: 'Erreur interne', + details: error instanceof Error ? error.message : 'Erreur inconnue', + connected: false, + }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/tfs/test/route.ts b/src/app/api/tfs/test/route.ts new file mode 100644 index 0000000..4b9adc8 --- /dev/null +++ b/src/app/api/tfs/test/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server'; +import { tfsService } from '@/services/tfs'; + +/** + * Route GET /api/tfs/test + * Teste uniquement la connexion TFS/Azure DevOps sans effectuer de synchronisation + */ +export async function GET() { + try { + console.log('🔄 Test de connexion TFS...'); + + // Valider la configuration via le service singleton + const configValidation = await tfsService.validateConfig(); + if (!configValidation.valid) { + return NextResponse.json( + { + error: 'Configuration TFS invalide', + connected: false, + details: configValidation.error, + }, + { status: 400 } + ); + } + + // Tester la connexion + const isConnected = await tfsService.testConnection(); + + if (isConnected) { + // Test approfondi : récupérer des métadonnées + try { + const repositories = await tfsService.getMetadata(); + return NextResponse.json({ + message: 'Connexion Azure DevOps réussie', + connected: true, + details: { + repositoriesCount: repositories.repositories.length, + }, + }); + } catch (repoError) { + return NextResponse.json( + { + error: 'Connexion OK mais accès aux repositories limité', + connected: false, + details: `Vérifiez les permissions du token PAT: ${repoError instanceof Error ? repoError.message : 'Erreur inconnue'}`, + }, + { status: 403 } + ); + } + } else { + return NextResponse.json( + { + error: 'Connexion Azure DevOps échouée', + connected: false, + details: "Vérifiez l'URL d'organisation et le token PAT", + }, + { status: 401 } + ); + } + } catch (error) { + console.error('❌ Erreur test connexion TFS:', error); + + return NextResponse.json( + { + error: 'Erreur interne', + connected: false, + details: error instanceof Error ? error.message : 'Erreur inconnue', + }, + { status: 500 } + ); + } +} diff --git a/src/app/settings/integrations/page.tsx b/src/app/settings/integrations/page.tsx index 13aba65..bae65a8 100644 --- a/src/app/settings/integrations/page.tsx +++ b/src/app/settings/integrations/page.tsx @@ -7,11 +7,15 @@ export const dynamic = 'force-dynamic'; export default async function IntegrationsSettingsPage() { // Fetch data server-side // Preferences are now available via context - const jiraConfig = await userPreferencesService.getJiraConfig(); + const [jiraConfig, tfsConfig] = await Promise.all([ + userPreferencesService.getJiraConfig(), + userPreferencesService.getTfsConfig() + ]); return ( ); } diff --git a/src/clients/tfs-client.ts b/src/clients/tfs-client.ts new file mode 100644 index 0000000..41ecd2f --- /dev/null +++ b/src/clients/tfs-client.ts @@ -0,0 +1,66 @@ +/** + * Client HTTP pour TFS/Azure DevOps + * Gère les appels API côté frontend + */ + +import { HttpClient } from './base/http-client'; + +export interface TfsSchedulerStatus { + running: boolean; + lastSync?: string; + nextSync?: string; + interval: 'hourly' | 'daily' | 'weekly'; + tfsConfigured: boolean; +} + +export class TfsClient extends HttpClient { + constructor() { + super(); + } + + /** + * Lance la synchronisation manuelle des Pull Requests TFS + */ + async syncPullRequests() { + return this.post('/api/tfs/sync', {}); + } + + /** + * Teste la connexion TFS + */ + async testConnection() { + return this.get('/api/tfs/sync'); + } + + /** + * Configure le scheduler TFS + */ + async configureScheduler(enabled: boolean, interval: 'hourly' | 'daily' | 'weekly') { + return this.post('/api/tfs/sync', { + action: 'config', + tfsAutoSync: enabled, + tfsSyncInterval: interval + }); + } + + /** + * Démarre/arrête le scheduler TFS + */ + async toggleScheduler(enabled: boolean) { + return this.post('/api/tfs/sync', { + action: 'scheduler', + enabled + }); + } + + /** + * Récupère le statut du scheduler TFS + */ + async getSchedulerStatus(): Promise { + return this.get('/api/tfs/scheduler/status'); + } +} + +// Export d'une instance singleton +export const tfsClient = new TfsClient(); + diff --git a/src/components/forms/EditTaskForm.tsx b/src/components/forms/EditTaskForm.tsx index 434059e..5081dad 100644 --- a/src/components/forms/EditTaskForm.tsx +++ b/src/components/forms/EditTaskForm.tsx @@ -6,17 +6,32 @@ import { Button } from '@/components/ui/Button'; import { Task, TaskPriority, TaskStatus } from '@/lib/types'; import { TaskBasicFields } from './task/TaskBasicFields'; import { TaskJiraInfo } from './task/TaskJiraInfo'; +import { TaskTfsInfo } from './task/TaskTfsInfo'; import { TaskTagsSection } from './task/TaskTagsSection'; interface EditTaskFormProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise; + onSubmit: (data: { + taskId: string; + title?: string; + description?: string; + status?: TaskStatus; + priority?: TaskPriority; + tags?: string[]; + dueDate?: Date; + }) => Promise; task: Task | null; loading?: boolean; } -export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) { +export function EditTaskForm({ + isOpen, + onClose, + onSubmit, + task, + loading = false, +}: EditTaskFormProps) { const [formData, setFormData] = useState<{ title: string; description: string; @@ -30,7 +45,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false status: 'todo' as TaskStatus, priority: 'medium' as TaskPriority, tags: [], - dueDate: undefined + dueDate: undefined, }); const [errors, setErrors] = useState>({}); @@ -44,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false status: task.status, priority: task.priority, tags: task.tags || [], - dueDate: task.dueDate + dueDate: task.dueDate, }); } }, [task]); @@ -61,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false } if (formData.description && formData.description.length > 1000) { - newErrors.description = 'La description ne peut pas dépasser 1000 caractères'; + newErrors.description = + 'La description ne peut pas dépasser 1000 caractères'; } setErrors(newErrors); @@ -70,13 +86,13 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!validateForm() || !task) return; try { await onSubmit({ taskId: task.id, - ...formData + ...formData, }); handleClose(); } catch (error) { @@ -89,33 +105,50 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false onClose(); }; - if (!task) return null; return ( - -
+ + setFormData(prev => ({ ...prev, title }))} - onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))} - onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))} - onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))} - onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))} + onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))} + onDescriptionChange={(description) => + setFormData((prev) => ({ ...prev, description })) + } + onPriorityChange={(priority) => + setFormData((prev) => ({ ...prev, priority })) + } + onStatusChange={(status) => + setFormData((prev) => ({ ...prev, status })) + } + onDueDateChange={(dueDate) => + setFormData((prev) => ({ ...prev, dueDate })) + } errors={errors} loading={loading} /> + + setFormData(prev => ({ ...prev, tags }))} + onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))} /> {/* Actions */} @@ -128,11 +161,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false > Annuler - diff --git a/src/components/forms/task/TaskTfsInfo.tsx b/src/components/forms/task/TaskTfsInfo.tsx new file mode 100644 index 0000000..898113f --- /dev/null +++ b/src/components/forms/task/TaskTfsInfo.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { Badge } from '@/components/ui/Badge'; +import { Task } from '@/lib/types'; +import { TfsConfig } from '@/services/tfs'; +import { useUserPreferences } from '@/contexts/UserPreferencesContext'; + +interface TaskTfsInfoProps { + task: Task; +} + +export function TaskTfsInfo({ task }: TaskTfsInfoProps) { + const { preferences } = useUserPreferences(); + + // Helper pour construire l'URL TFS + const getTfsPullRequestUrl = (pullRequestId: number, project: string, repository: string): string => { + const organizationUrl = (preferences.tfsConfig as TfsConfig)?.organizationUrl; + if (!organizationUrl || !pullRequestId || !project || !repository) return ''; + return `${organizationUrl}/${project}/_git/${repository}/pullrequest/${pullRequestId}`; + }; + + if (task.source !== 'tfs' || !task.tfsPullRequestId) { + return null; + } + + return ( +
+ + +
+ {preferences.tfsConfig && (preferences.tfsConfig as TfsConfig).organizationUrl ? ( + + + PR-{task.tfsPullRequestId} + + + ) : ( + + PR-{task.tfsPullRequestId} + + )} + + {task.tfsRepository && ( + + {task.tfsRepository} + + )} + + {task.tfsProject && ( + + {task.tfsProject} + + )} + + {task.tfsSourceBranch && ( + + {task.tfsSourceBranch.replace('refs/heads/', '')} + + )} + + {task.tfsTargetBranch && task.tfsTargetBranch !== task.tfsSourceBranch && ( + + → {task.tfsTargetBranch.replace('refs/heads/', '')} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/kanban/TaskCard.tsx b/src/components/kanban/TaskCard.tsx index 5e34ac6..2456b54 100644 --- a/src/components/kanban/TaskCard.tsx +++ b/src/components/kanban/TaskCard.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useTransition } from 'react'; import { Task } from '@/lib/types'; +import { TfsConfig } from '@/services/tfs'; import { formatDistanceToNow } from 'date-fns'; import { fr } from 'date-fns/locale'; import { Card } from '@/components/ui/Card'; @@ -24,7 +25,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { const timeoutRef = useRef(null); const { tags: availableTags, refreshTasks } = useTasksContext(); const { preferences } = useUserPreferences(); - + // Classes CSS pour les différentes tailles de police const getFontSizeClasses = () => { switch (preferences.viewPreferences.fontSize) { @@ -32,23 +33,23 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { return { title: 'text-xs', description: 'text-xs', - meta: 'text-xs' + meta: 'text-xs', }; case 'large': return { title: 'text-base', description: 'text-sm', - meta: 'text-sm' + meta: 'text-sm', }; default: // medium return { title: 'text-sm', description: 'text-xs', - meta: 'text-xs' + meta: 'text-xs', }; } }; - + const fontClasses = getFontSizeClasses(); // Helper pour construire l'URL Jira @@ -58,16 +59,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { return `${baseUrl}/browse/${jiraKey}`; }; + // Helper pour construire l'URL TFS Pull Request + const getTfsPullRequestUrl = ( + tfsPullRequestId: number, + tfsProject: string, + tfsRepository: string + ): string => { + const tfsConfig = preferences.tfsConfig as TfsConfig; + const baseUrl = tfsConfig?.organizationUrl; + if (!baseUrl || !tfsPullRequestId || !tfsProject || !tfsRepository) + return ''; + return `${baseUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`; + }; + // Configuration du draggable - const { - attributes, - listeners, - setNodeRef, - transform, - isDragging, - } = useDraggable({ - id: task.id, - }); + const { attributes, listeners, setNodeRef, transform, isDragging } = + useDraggable({ + id: task.id, + }); // Mettre à jour le titre local quand la tâche change useEffect(() => { @@ -76,9 +85,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { // Nettoyer le timeout au démontage useEffect(() => { + const currentTimeout = timeoutRef.current; return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); + if (currentTimeout) { + clearTimeout(currentTimeout); } }; }, []); @@ -86,7 +96,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { const handleDelete = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) { startTransition(async () => { const result = await deleteTask(task.id); @@ -151,35 +161,36 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { } }; - // Style de transformation pour le drag - const style = transform ? { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, - } : undefined; + const style = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } + : undefined; - // Extraire les emojis du titre pour les afficher comme tags visuels - const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu; + // Extraire les emojis du titre pour les afficher comme tags visuels + const emojiRegex = + /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu; const titleEmojis = task.title.match(emojiRegex) || []; const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim(); // Composant titre avec tooltip const TitleWithTooltip = () => (
-

{titleWithoutEmojis}

-
); - + // Si pas d'emoji dans le titre, utiliser l'emoji du premier tag let displayEmojis: string[] = titleEmojis; if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) { - const firstTag = availableTags.find(tag => tag.name === task.tags[0]); + const firstTag = availableTags.find((tag) => tag.name === task.tags[0]); if (firstTag) { const tagEmojis = firstTag.name.match(emojiRegex); if (tagEmojis && tagEmojis.length > 0) { @@ -188,30 +199,44 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { } } - // Styles spéciaux pour les tâches Jira const isJiraTask = task.source === 'jira'; - const jiraStyles = isJiraTask ? { - border: '1px solid rgba(0, 130, 201, 0.3)', - borderLeft: '3px solid #0082C9', - background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)' - } : {}; + const jiraStyles = isJiraTask + ? { + border: '1px solid rgba(0, 130, 201, 0.3)', + borderLeft: '3px solid #0082C9', + background: + 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)', + } + : {}; + + // Styles spéciaux pour les tâches TFS + const isTfsTask = task.source === 'tfs'; + const tfsStyles = isTfsTask + ? { + border: '1px solid rgba(255, 165, 0, 0.3)', + borderLeft: '3px solid #FFA500', + background: + 'linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 165, 0, 0.02) 100%)', + } + : {}; + + // Combiner les styles spéciaux + const specialStyles = { ...jiraStyles, ...tfsStyles }; // Vue compacte : seulement le titre if (compactView) { return ( - @@ -219,12 +244,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { {displayEmojis.length > 0 && (
{displayEmojis.slice(0, 1).map((emoji, index) => ( - {emoji} @@ -232,7 +258,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { ))}
)} - + {isEditingTitle ? ( )} - +
{/* Boutons d'action compacts - masqués en mode édition */} {!isEditingTitle && onEdit && ( @@ -259,7 +285,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { ✎ )} - + {!isEditingTitle && (
@@ -284,18 +314,16 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { // Vue détaillée : version complète return ( - @@ -304,12 +332,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { {displayEmojis.length > 0 && (
{displayEmojis.slice(0, 2).map((emoji, index) => ( - {emoji} @@ -317,7 +346,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { ))}
)} - + {isEditingTitle ? ( )} - +
{/* Bouton d'édition discret - masqué en mode édition */} {!isEditingTitle && onEdit && ( @@ -344,7 +373,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { ✎ )} - + {/* Bouton de suppression discret - masqué en mode édition */} {!isEditingTitle && (
diff --git a/src/components/settings/TfsConfigForm.tsx b/src/components/settings/TfsConfigForm.tsx new file mode 100644 index 0000000..8d2e61d --- /dev/null +++ b/src/components/settings/TfsConfigForm.tsx @@ -0,0 +1,638 @@ +'use client'; + +import { useState, useEffect, useTransition } from 'react'; +import { TfsConfig } from '@/services/tfs'; +import { getTfsConfig, saveTfsConfig, deleteAllTfsTasks } from '@/actions/tfs'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; + +export function TfsConfigForm() { + const [config, setConfig] = useState({ + enabled: false, + organizationUrl: '', + projectName: '', + personalAccessToken: '', + repositories: [], + ignoredRepositories: [], + }); + const [isPending, startTransition] = useTransition(); + const [message, setMessage] = useState<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + const [testingConnection, setTestingConnection] = useState(false); + const [showForm, setShowForm] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [deletingTasks, setDeletingTasks] = useState(false); + + // Charger la configuration existante + useEffect(() => { + loadConfig(); + }, []); + + const loadConfig = async () => { + try { + setIsLoading(true); + const result = await getTfsConfig(); + if (result.success) { + setConfig(result.data); + // Afficher le formulaire par défaut si TFS n'est pas configuré + const isConfigured = + result.data?.enabled && + result.data?.organizationUrl && + result.data?.personalAccessToken; + if (!isConfigured) { + setShowForm(true); + } + } else { + setMessage({ + type: 'error', + text: result.error || 'Erreur lors du chargement de la configuration', + }); + setShowForm(true); // Afficher le formulaire en cas d'erreur + } + } catch (error) { + console.error('Erreur chargement config TFS:', error); + setMessage({ + type: 'error', + text: 'Erreur lors du chargement de la configuration', + }); + setShowForm(true); + } finally { + setIsLoading(false); + } + }; + + const handleSaveConfig = () => { + startTransition(async () => { + setMessage(null); + const result = await saveTfsConfig(config); + + if (result.success) { + setMessage({ + type: 'success', + text: result.message || 'Configuration sauvegardée', + }); + // Masquer le formulaire après une sauvegarde réussie + setShowForm(false); + } else { + setMessage({ + type: 'error', + text: result.error || 'Erreur lors de la sauvegarde', + }); + } + }); + }; + + const handleDelete = async () => { + if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration TFS ?')) { + return; + } + + startTransition(async () => { + setMessage(null); + // Réinitialiser la config + const resetConfig = { + enabled: false, + organizationUrl: '', + projectName: '', + personalAccessToken: '', + repositories: [], + ignoredRepositories: [], + }; + + const result = await saveTfsConfig(resetConfig); + + if (result.success) { + setConfig(resetConfig); + setMessage({ type: 'success', text: 'Configuration TFS supprimée' }); + setShowForm(true); // Afficher le formulaire pour reconfigurer + } else { + setMessage({ + type: 'error', + text: result.error || 'Erreur lors de la suppression', + }); + } + }); + }; + + const testConnection = async () => { + try { + setTestingConnection(true); + setMessage(null); + + // Sauvegarder d'abord la config + const saveResult = await saveTfsConfig(config); + if (!saveResult.success) { + setMessage({ + type: 'error', + text: saveResult.error || 'Erreur lors de la sauvegarde', + }); + return; + } + + // Attendre un peu que la configuration soit prise en compte + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Tester la connexion avec la route dédiée + const response = await fetch('/api/tfs/test', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const result = await response.json(); + console.log('Test TFS - Réponse:', { status: response.status, result }); + + if (response.ok && result.connected) { + setMessage({ + type: 'success', + text: `Connexion Azure DevOps réussie ! ${result.message || ''}`, + }); + } else { + const errorMessage = + result.error || result.details || 'Erreur de connexion inconnue'; + setMessage({ + type: 'error', + text: `Connexion échouée: ${errorMessage}`, + }); + console.error('Test TFS échoué:', result); + } + } catch (error) { + console.error('Erreur test connexion TFS:', error); + setMessage({ + type: 'error', + text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`, + }); + } finally { + setTestingConnection(false); + } + }; + + const handleDeleteAllTasks = async () => { + const confirmation = confirm( + 'Êtes-vous sûr de vouloir supprimer TOUTES les tâches TFS de la base locale ?\n\n' + + 'Cette action est irréversible et supprimera définitivement toutes les tâches ' + + 'synchronisées depuis Azure DevOps/TFS.\n\n' + + 'Cliquez sur OK pour confirmer la suppression.' + ); + + if (!confirmation) { + return; + } + + try { + setDeletingTasks(true); + setMessage(null); + + const result = await deleteAllTfsTasks(); + + if (result.success) { + setMessage({ + type: 'success', + text: + result.message || + 'Toutes les tâches TFS ont été supprimées avec succès', + }); + } else { + setMessage({ + type: 'error', + text: result.error || 'Erreur lors de la suppression des tâches TFS', + }); + } + } catch (error) { + console.error('Erreur suppression tâches TFS:', error); + setMessage({ + type: 'error', + text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`, + }); + } finally { + setDeletingTasks(false); + } + }; + + const updateConfig = ( + field: keyof TfsConfig, + value: string | boolean | string[] + ) => { + setConfig((prev) => ({ ...prev, [field]: value })); + }; + + const updateArrayField = ( + field: 'repositories' | 'ignoredRepositories', + value: string + ) => { + const array = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item); + updateConfig(field, array); + }; + + const isTfsConfigured = + config?.enabled && config?.organizationUrl && config?.personalAccessToken; + const isLoadingState = isLoading || isPending || deletingTasks; + + if (isLoading) { + return ( +
+
+ Chargement... +
+
+ ); + } + + return ( +
+ {/* Statut actuel */} +
+
+

Statut de l'intégration

+

+ {isTfsConfigured + ? 'Azure DevOps est configuré et prêt à être utilisé' + : "Azure DevOps n'est pas configuré"} +

+
+
+ + {isTfsConfigured ? '✓ Configuré' : '✗ Non configuré'} + + +
+
+ + {isTfsConfigured && ( +
+

Configuration actuelle

+
+
+ + URL d'organisation: + {' '} + + {config?.organizationUrl || 'Non définie'} + +
+
+ Projet:{' '} + + {config?.projectName || "Toute l'organisation"} + +
+
+ Token PAT:{' '} + + {config?.personalAccessToken ? '••••••••' : 'Non défini'} + +
+
+ + Repositories surveillés: + {' '} + {config?.repositories && config.repositories.length > 0 ? ( +
+ {config.repositories.map((repo) => ( + + {repo} + + ))} +
+ ) : ( + Tous les repositories + )} +
+
+ + Repositories ignorés: + {' '} + {config?.ignoredRepositories && + config.ignoredRepositories.length > 0 ? ( +
+ {config.ignoredRepositories.map((repo) => ( + + {repo} + + ))} +
+ ) : ( + Aucun + )} +
+
+
+ )} + + {/* Actions de gestion des données TFS */} + {isTfsConfigured && ( +
+
+
+

+ ⚠️ Gestion des données +

+

+ Supprimez toutes les tâches TFS synchronisées de la base locale +

+

+ Attention: Cette action est irréversible et + supprimera définitivement toutes les tâches importées depuis + Azure DevOps. +

+
+ +
+
+ )} + + {/* Formulaire de configuration */} + {showForm && ( + { + e.preventDefault(); + handleSaveConfig(); + }} + className="space-y-4" + > + {/* Toggle d'activation */} +
+
+

Activer l'intégration TFS

+

+ Synchroniser les Pull Requests depuis Azure DevOps +

+
+ +
+ + {config.enabled && ( + <> +
+ + + updateConfig('organizationUrl', e.target.value) + } + placeholder="https://dev.azure.com/votre-organisation" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + required + /> +

+ L'URL de base de votre organisation Azure DevOps (ex: + https://dev.azure.com/monentreprise) +

+
+ +
+ + updateConfig('projectName', e.target.value)} + placeholder="MonProjet (laisser vide pour toute l'organisation)" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + /> +

+ Nom du projet spécifique ou laisser vide pour synchroniser les + PRs de toute l'organisation +

+
+ +
+ + + updateConfig('personalAccessToken', e.target.value) + } + placeholder="Votre token d'accès personnel" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + required + /> +

+ Créez un PAT depuis{' '} + + Azure DevOps + {' '} + avec les permissions Code (read) et Pull Request (read) +

+
+ +
+ + + updateArrayField('repositories', e.target.value) + } + placeholder="repo1, repo2, repo3" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + /> +

+ Liste séparée par des virgules. Laisser vide pour surveiller + tous les repositories. +

+ {config.repositories && config.repositories.length > 0 && ( +
+ + Repositories surveillés: + + {config.repositories.map((repo) => ( + + {repo} + + ))} +
+ )} +
+ +
+ + + updateArrayField('ignoredRepositories', e.target.value) + } + placeholder="test-repo, demo-repo" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + /> +

+ Repositories à exclure de la synchronisation, séparés par des + virgules (ex: test-repo, demo-repo). +

+ {config.ignoredRepositories && + config.ignoredRepositories.length > 0 && ( +
+ + Repositories ignorés: + + {config.ignoredRepositories.map((repo) => ( + + {repo} + + ))} +
+ )} +
+ + )} + +
+ + + + + {isTfsConfigured && ( + + )} +
+ + {/* Instructions */} +
+

+ 💡 Instructions de configuration +

+
+

+ 1. URL d'organisation: Votre domaine Azure + DevOps (ex: https://dev.azure.com/monentreprise) +

+

+ 2. Nom du projet (optionnel): Spécifiez un + projet pour limiter la synchronisation, ou laissez vide pour + toute l'organisation +

+

+ 3. Personal Access Token: Créez un PAT depuis + Azure DevOps : +

+
    +
  • + Allez sur{' '} + + dev.azure.com + +
  • +
  • Cliquez sur votre profil » Personal access tokens
  • +
  • Cliquez sur "New Token"
  • +
  • + Sélectionnez les scopes: Code (read) et Pull Request (read) +
  • +
  • Copiez le token généré
  • +
+

+ 🎯 Synchronisation intelligente: TowerControl + récupère automatiquement toutes les Pull Requests vous + concernant (créées par vous ou où vous êtes reviewer) dans + l'organisation ou le projet configuré. +

+

+ Note: Les PRs seront synchronisées comme tâches + pour un suivi centralisé de vos activités. +

+
+
+ + )} + + {message && ( +
+ {message.text} +
+ )} +
+ ); +} diff --git a/src/components/tfs/TfsSync.tsx b/src/components/tfs/TfsSync.tsx new file mode 100644 index 0000000..646f37b --- /dev/null +++ b/src/components/tfs/TfsSync.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { syncTfsPullRequests } from '@/actions/tfs'; + +export function TfsSync() { + const [isPending, startTransition] = useTransition(); + const [lastSync, setLastSync] = useState<{ + success: boolean; + message: string; + stats?: { + created: number; + updated: number; + skipped: number; + deleted: number; + } + } | null>(null); + + const handleSync = () => { + startTransition(async () => { + setLastSync(null); + + const result = await syncTfsPullRequests(); + + if (result.success) { + setLastSync({ + success: true, + message: result.message || 'Synchronisation réussie', + stats: result.data ? { + created: result.data.pullRequestsCreated, + updated: result.data.pullRequestsUpdated, + skipped: result.data.pullRequestsSkipped, + deleted: result.data.pullRequestsDeleted + } : undefined + }); + } else { + setLastSync({ + success: false, + message: result.error || 'Erreur lors de la synchronisation' + }); + } + }); + }; + + return ( + + +

+ 🔄 + Synchronisation TFS +

+
+ +

+ Synchronise manuellement les Pull Requests depuis Azure DevOps +

+ + {/* Résultat de la dernière synchronisation */} + {lastSync && ( +
+
+ {lastSync.success ? '✅' : '❌'} {lastSync.message} +
+ {lastSync.stats && ( +
+ Créées: {lastSync.stats.created} | + Mises à jour: {lastSync.stats.updated} | + Ignorées: {lastSync.stats.skipped} | + Supprimées: {lastSync.stats.deleted} +
+ )} +
+ )} + + + +
+ Les Pull Requests seront importées comme tâches dans le tableau Kanban +
+
+
+ ); +} diff --git a/src/contexts/UserPreferencesContext.tsx b/src/contexts/UserPreferencesContext.tsx index 01a3fc2..4b4de66 100644 --- a/src/contexts/UserPreferencesContext.tsx +++ b/src/contexts/UserPreferencesContext.tsx @@ -66,7 +66,12 @@ const defaultPreferences: UserPreferences = { enabled: false }, jiraAutoSync: false, - jiraSyncInterval: 'daily' + jiraSyncInterval: 'daily', + tfsConfig: { + enabled: false + }, + tfsAutoSync: false, + tfsSyncInterval: 'daily' }; export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) { diff --git a/src/lib/types.ts b/src/lib/types.ts index c40e1b8..2381d66 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,8 +1,17 @@ +import { TfsConfig } from '@/services/tfs'; + // Types de base pour les tâches // Note: TaskStatus et TaskPriority sont maintenant gérés par la configuration centralisée dans lib/status-config.ts -export type TaskStatus = 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled' | 'freeze' | 'archived'; +export type TaskStatus = + | 'backlog' + | 'todo' + | 'in_progress' + | 'done' + | 'cancelled' + | 'freeze' + | 'archived'; export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; -export type TaskSource = 'reminders' | 'jira' | 'manual'; +export type TaskSource = 'reminders' | 'jira' | 'tfs' | 'manual'; // Interface centralisée pour les statistiques export interface TaskStats { @@ -31,11 +40,19 @@ export interface Task { completedAt?: Date; createdAt: Date; updatedAt: Date; - + // Métadonnées Jira jiraProject?: string; jiraKey?: string; jiraType?: string; // Type de ticket Jira: Story, Task, Bug, Epic, etc. + + // Métadonnées TFS/Azure DevOps + tfsProject?: string; + tfsPullRequestId?: number; + tfsRepository?: string; + tfsSourceBranch?: string; + tfsTargetBranch?: string; + assignee?: string; } @@ -71,7 +88,16 @@ export interface ViewPreferences { objectivesCollapsed: boolean; theme: 'light' | 'dark'; fontSize: 'small' | 'medium' | 'large'; - [key: string]: boolean | 'tags' | 'priority' | 'light' | 'dark' | 'small' | 'medium' | 'large' | undefined; + [key: string]: + | boolean + | 'tags' + | 'priority' + | 'light' + | 'dark' + | 'small' + | 'medium' + | 'large' + | undefined; } export interface ColumnVisibility { @@ -88,6 +114,7 @@ export interface JiraConfig { ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"]) } + export interface UserPreferences { kanbanFilters: KanbanFilters; viewPreferences: ViewPreferences; @@ -95,6 +122,9 @@ export interface UserPreferences { jiraConfig: JiraConfig; jiraAutoSync: boolean; jiraSyncInterval: 'hourly' | 'daily' | 'weekly'; + tfsConfig: TfsConfig; + tfsAutoSync: boolean; + tfsSyncInterval: 'hourly' | 'daily' | 'weekly'; } // Interface pour les logs de synchronisation @@ -161,6 +191,45 @@ export interface JiraTask { storyPoints?: number; // Ajout pour les story points } +// Types pour TFS/Azure DevOps +export interface TfsPullRequest { + pullRequestId: number; + title: string; + description?: string; + status: 'active' | 'completed' | 'abandoned'; + createdBy: { + displayName: string; + uniqueName: string; // email + id: string; + }; + creationDate: string; + lastMergeSourceCommit?: { + commitId: string; + }; + sourceRefName: string; // refs/heads/feature-branch + targetRefName: string; // refs/heads/main + repository: { + id: string; + name: string; + project: { + id: string; + name: string; + }; + }; + reviewers?: Array<{ + displayName: string; + uniqueName: string; + vote: number; // -10=rejected, -5=waiting for author, 0=no vote, 5=approved, 10=approved with suggestions + }>; + labels?: Array<{ + id: string; + name: string; + }>; + isDraft: boolean; + mergeStatus: 'succeeded' | 'failed' | 'conflicts' | 'queued'; + closedDate?: string; +} + // Types pour l'analytics Jira export interface JiraAnalytics { project: { @@ -315,14 +384,20 @@ export interface MemberStats { // Types d'erreur export class BusinessError extends Error { - constructor(message: string, public code?: string) { + constructor( + message: string, + public code?: string + ) { super(message); this.name = 'BusinessError'; } } export class ValidationError extends Error { - constructor(message: string, public field?: string) { + constructor( + message: string, + public field?: string + ) { super(message); this.name = 'ValidationError'; } @@ -367,5 +442,5 @@ export interface UpdateDailyCheckboxData { export interface DailyView { date: Date; yesterday: DailyCheckbox[]; // Checkboxes de la veille - today: DailyCheckbox[]; // Checkboxes du jour + today: DailyCheckbox[]; // Checkboxes du jour } diff --git a/src/services/jira-analytics.ts b/src/services/jira-analytics.ts index 415def3..3992947 100644 --- a/src/services/jira-analytics.ts +++ b/src/services/jira-analytics.ts @@ -17,6 +17,7 @@ import { } from '@/lib/types'; export interface JiraAnalyticsConfig { + enabled: boolean; baseUrl: string; email: string; apiToken: string; diff --git a/src/services/jira-scheduler.ts b/src/services/jira-scheduler.ts index 62bfc75..08d20b7 100644 --- a/src/services/jira-scheduler.ts +++ b/src/services/jira-scheduler.ts @@ -93,6 +93,7 @@ export class JiraScheduler { // Créer le service Jira const jiraService = new JiraService({ + enabled: jiraConfig.enabled, baseUrl: jiraConfig.baseUrl, email: jiraConfig.email, apiToken: jiraConfig.apiToken, @@ -111,7 +112,7 @@ export class JiraScheduler { const result = await jiraService.syncTasks(); if (result.success) { - console.log(`✅ Scheduled Jira sync completed: ${result.tasksCreated} created, ${result.tasksUpdated} updated, ${result.tasksSkipped} skipped`); + console.log(`✅ Scheduled Jira sync completed: ${result.stats.created} created, ${result.stats.updated} updated, ${result.stats.skipped} skipped`); } else { console.error(`❌ Scheduled Jira sync failed: ${result.errors.join(', ')}`); } diff --git a/src/services/jira-summary.ts b/src/services/jira-summary.ts index 5f6a052..cebf962 100644 --- a/src/services/jira-summary.ts +++ b/src/services/jira-summary.ts @@ -84,7 +84,7 @@ export class JiraSummaryService { title: task.title, status: task.status, type: task.jiraType || 'Unknown', - url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`, + url: `${jiraConfig.baseUrl!.replace('/rest/api/3', '')}/browse/${task.jiraKey}`, estimatedPoints: estimateStoryPoints(task.jiraType || '') })); @@ -114,6 +114,7 @@ export class JiraSummaryService { } return { + enabled: preferences.jiraConfig.enabled, baseUrl: preferences.jiraConfig.baseUrl, email: preferences.jiraConfig.email, apiToken: preferences.jiraConfig.apiToken, diff --git a/src/services/jira.ts b/src/services/jira.ts index 4eaf516..58cc4c0 100644 --- a/src/services/jira.ts +++ b/src/services/jira.ts @@ -8,9 +8,10 @@ import { prisma } from './database'; import { parseDate, formatDateForDisplay } from '@/lib/date-utils'; export interface JiraConfig { - baseUrl: string; - email: string; - apiToken: string; + enabled: boolean; + baseUrl?: string; + email?: string; + apiToken?: string; projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM") ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"]) } @@ -23,6 +24,27 @@ export interface JiraSyncAction { changes?: string[]; // Liste des champs modifiés pour les updates } +// Type générique pour compatibilité avec d'autres services +export interface SyncAction { + type: 'created' | 'updated' | 'skipped' | 'deleted'; + itemId: string | number; + title: string; + message?: string; +} + +export interface SyncResult { + success: boolean; + totalItems: number; + actions: SyncAction[]; + errors: string[]; + stats: { + created: number; + updated: number; + skipped: number; + deleted: number; + }; +} + export interface JiraSyncResult { success: boolean; tasksFound: number; @@ -35,7 +57,7 @@ export interface JiraSyncResult { } export class JiraService { - private config: JiraConfig; + readonly config: JiraConfig; constructor(config: JiraConfig) { this.config = config; @@ -89,6 +111,32 @@ export class JiraService { } } + /** + * Valide la configuration Jira + */ + async validateConfig(): Promise<{ valid: boolean; error?: string }> { + if (!this.config.enabled) { + return { valid: false, error: 'Jira désactivé' }; + } + if (!this.config.baseUrl) { + return { valid: false, error: 'URL de base Jira manquante' }; + } + if (!this.config.email) { + return { valid: false, error: 'Email Jira manquant' }; + } + if (!this.config.apiToken) { + return { valid: false, error: 'Token API Jira manquant' }; + } + + // Tester la connexion pour validation complète + const connectionOk = await this.testConnection(); + if (!connectionOk) { + return { valid: false, error: 'Impossible de se connecter avec ces paramètres' }; + } + + return { valid: true }; + } + /** * Filtre les tâches Jira selon les projets ignorés */ @@ -245,17 +293,18 @@ export class JiraService { /** * Synchronise les tickets Jira avec la base locale */ - async syncTasks(): Promise { - const result: JiraSyncResult = { + async syncTasks(): Promise { + const result: SyncResult = { success: false, - tasksFound: 0, - tasksCreated: 0, - tasksUpdated: 0, - tasksSkipped: 0, - tasksDeleted: 0, + totalItems: 0, + actions: [], errors: [], - actions: [] + stats: { created: 0, updated: 0, skipped: 0, deleted: 0 } }; + + // Variables locales pour compatibilité avec l'ancien code + let tasksDeleted = 0; + const jiraActions: JiraSyncAction[] = []; try { console.log('🔄 Début de la synchronisation Jira...'); @@ -265,7 +314,7 @@ export class JiraService { // Récupérer les tickets Jira actuellement assignés const jiraTasks = await this.getAssignedIssues(); - result.tasksFound = jiraTasks.length; + result.totalItems = jiraTasks.length; console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`); @@ -281,16 +330,25 @@ export class JiraService { try { const syncAction = await this.syncSingleTask(jiraTask); + // Convertir JiraSyncAction vers SyncAction + const standardAction: SyncAction = { + type: syncAction.type, + itemId: syncAction.taskKey, + title: syncAction.taskTitle, + message: syncAction.reason || syncAction.changes?.join('; ') + }; + // Ajouter l'action au résultat - result.actions.push(syncAction); + result.actions.push(standardAction); + jiraActions.push(syncAction); // Compter les actions if (syncAction.type === 'created') { - result.tasksCreated++; + result.stats.created++; } else if (syncAction.type === 'updated') { - result.tasksUpdated++; + result.stats.updated++; } else { - result.tasksSkipped++; + result.stats.skipped++; } } catch (error) { console.error(`Erreur sync ticket ${jiraTask.key}:`, error); @@ -300,8 +358,19 @@ export class JiraService { // Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds); - result.tasksDeleted = deletedActions.length; - result.actions.push(...deletedActions); + tasksDeleted = deletedActions.length; + result.stats.deleted = tasksDeleted; + + // Convertir les actions de suppression + for (const action of deletedActions) { + const standardAction: SyncAction = { + type: 'deleted', + itemId: action.taskKey, + title: action.taskTitle, + message: action.reason + }; + result.actions.push(standardAction); + } // Déterminer le succès et enregistrer le log result.success = result.errors.length === 0; @@ -721,14 +790,14 @@ export class JiraService { /** * Enregistre un log de synchronisation */ - private async logSync(result: JiraSyncResult): Promise { + private async logSync(result: SyncResult): Promise { try { await prisma.syncLog.create({ data: { source: 'jira', status: result.success ? 'success' : 'error', message: result.errors.length > 0 ? result.errors.join('; ') : null, - tasksSync: result.tasksCreated + result.tasksUpdated + tasksSync: result.stats.created + result.stats.updated } }); } catch (error) { @@ -750,5 +819,10 @@ export function createJiraService(): JiraService | null { return null; } - return new JiraService({ baseUrl, email, apiToken }); + return new JiraService({ + enabled: true, + baseUrl, + email, + apiToken + }); } diff --git a/src/services/tasks.ts b/src/services/tasks.ts index 1f44efa..a01ae05 100644 --- a/src/services/tasks.ts +++ b/src/services/tasks.ts @@ -365,6 +365,12 @@ export class TasksService { jiraProject: prismaTask.jiraProject ?? undefined, jiraKey: prismaTask.jiraKey ?? undefined, jiraType: prismaTask.jiraType ?? undefined, + // Champs TFS + tfsProject: prismaTask.tfsProject ?? undefined, + tfsPullRequestId: prismaTask.tfsPullRequestId ?? undefined, + tfsRepository: prismaTask.tfsRepository ?? undefined, + tfsSourceBranch: prismaTask.tfsSourceBranch ?? undefined, + tfsTargetBranch: prismaTask.tfsTargetBranch ?? undefined, assignee: prismaTask.assignee ?? undefined }; } diff --git a/src/services/tfs.ts b/src/services/tfs.ts new file mode 100644 index 0000000..975e83a --- /dev/null +++ b/src/services/tfs.ts @@ -0,0 +1,1117 @@ +/** + * Service de gestion TFS/Azure DevOps + * Intégration unidirectionnelle Azure DevOps → TowerControl + * Focus sur les Pull Requests comme tâches + */ + +import { TfsPullRequest } from '@/lib/types'; +import { prisma } from './database'; +import { parseDate, formatDateForDisplay } from '@/lib/date-utils'; +import { userPreferencesService } from './user-preferences'; + +export interface TfsConfig { + enabled: boolean; + organizationUrl?: string; // https://dev.azure.com/myorg + projectName?: string; // Optionnel: pour filtrer un projet spécifique + personalAccessToken?: string; + repositories?: string[]; // Liste des repos à surveiller + ignoredRepositories?: string[]; // Liste des repos à ignorer +} + +export interface TfsSyncAction { + type: 'created' | 'updated' | 'skipped' | 'deleted'; + pullRequestId: number; + prTitle: string; + reason?: string; + changes?: string[]; +} + +// Types génériques pour compatibilité avec d'autres services +export interface SyncAction { + type: 'created' | 'updated' | 'skipped' | 'deleted'; + itemId: string | number; + title: string; + message?: string; +} + +export interface SyncResult { + success: boolean; + totalItems: number; + actions: SyncAction[]; + errors: string[]; + stats: { + created: number; + updated: number; + skipped: number; + deleted: number; + }; +} + +export interface TfsSyncResult { + success: boolean; + totalPullRequests: number; + pullRequestsCreated: number; + pullRequestsUpdated: number; + pullRequestsSkipped: number; + pullRequestsDeleted: number; + errors: string[]; + actions: TfsSyncAction[]; +} + +export class TfsService { + readonly config: TfsConfig; + + constructor(config: TfsConfig) { + this.config = config; + } + + /** + * Teste la connexion à Azure DevOps + */ + async testConnection(): Promise { + try { + // Tester avec l'endpoint des projets pour valider l'accès à l'organisation + const response = await this.makeApiRequest( + '/_apis/projects?api-version=6.0&$top=1' + ); + + if (response.ok) { + console.log('✓ Connexion TFS réussie et organisation accessible'); + return true; + } else if (response.status === 401) { + console.error( + '❗️ Erreur TFS: Authentification échouée (token invalide)' + ); + return false; + } else if (response.status === 403) { + console.error( + '❗️ Erreur TFS: Accès refusé (permissions insuffisantes)' + ); + return false; + } else { + console.error( + `❗️ Erreur TFS: ${response.status} ${response.statusText}` + ); + return false; + } + } catch (error) { + console.error('❗️ Erreur connexion TFS:', error); + return false; + } + } + + /** + * Valide la configuration TFS + */ + async validateConfig(): Promise<{ valid: boolean; error?: string }> { + if (!this.config.enabled) { + return { valid: false, error: 'TFS désactivé' }; + } + if (!this.config.organizationUrl) { + return { valid: false, error: "URL de l'organisation manquante" }; + } + if (!this.config.personalAccessToken) { + return { valid: false, error: "Token d'accès personnel manquant" }; + } + + // Tester la connexion pour validation complète + const connectionOk = await this.testConnection(); + if (!connectionOk) { + return { + valid: false, + error: 'Impossible de se connecter avec ces paramètres', + }; + } + + return { valid: true }; + } + + /** + * Valide l'existence d'un projet Azure DevOps + */ + async validateProject( + projectName: string + ): Promise<{ exists: boolean; name?: string; error?: string }> { + try { + const response = await this.makeApiRequest( + `/_apis/projects/${encodeURIComponent(projectName)}?api-version=6.0` + ); + + if (response.ok) { + const projectData = await response.json(); + return { + exists: true, + name: projectData.name, + }; + } else if (response.status === 404) { + return { + exists: false, + error: `Projet "${projectName}" non trouvé`, + }; + } else { + const errorData = await response.json().catch(() => ({})); + return { + exists: false, + error: errorData.message || `Erreur ${response.status}`, + }; + } + } catch (error) { + console.error('❌ Erreur validation projet TFS:', error); + return { + exists: false, + error: error instanceof Error ? error.message : 'Erreur de connexion', + }; + } + } + + /** + * Récupère la liste des repositories d'un projet (ou de toute l'organisation si pas de projet spécifié) + */ + async getRepositories(): Promise< + Array<{ id: string; name: string; project?: string }> + > { + try { + // Si un projet spécifique est configuré, récupérer uniquement ses repos + let endpoint: string; + if (this.config.projectName) { + endpoint = `/_apis/git/repositories?api-version=6.0&$top=1000`; + } else { + // Récupérer tous les repositories de l'organisation + endpoint = `/_apis/git/repositories?api-version=6.0&includeAllProjects=true&$top=1000`; + } + + const response = await this.makeApiRequest(endpoint); + + if (!response.ok) { + throw new Error(`Erreur API: ${response.status}`); + } + + const data = await response.json(); + return ( + data.value?.map((repo: { id: string; name: string; project?: { name: string } }) => ({ + id: repo.id, + name: repo.name, + project: repo.project?.name, + })) || [] + ); + } catch (error) { + console.error('❗️ Erreur récupération repositories TFS:', error); + return []; + } + } + + /** + * Récupère toutes les Pull Requests assignées à l'utilisateur actuel dans l'organisation + */ + async getMyPullRequests(): Promise { + try { + console.log("🔍 Récupération des PRs créées par l'utilisateur..."); + + // Uniquement les PRs créées par l'utilisateur (simplifié) + const createdPrs = await this.getPullRequestsByCreator(); + console.log(`👤 ${createdPrs.length} PR(s) créées par l'utilisateur`); + + // Filtrer les PRs selon la configuration + const filteredPrs = this.filterPullRequests(createdPrs); + + console.log( + `🎫 ${filteredPrs.length} PR(s) après filtrage de configuration` + ); + + return filteredPrs; + } catch (error) { + console.error('❗️ Erreur récupération PRs utilisateur:', error); + return []; + } + } + + /** + * Récupère les PRs créées par l'utilisateur + */ + private async getPullRequestsByCreator(): Promise { + try { + // Récupérer l'ID utilisateur réel pour le filtrage + const currentUserId = await this.getCurrentUserId(); + if (!currentUserId) { + console.error( + "❌ Impossible de récupérer l'ID utilisateur pour filtrer les PRs" + ); + return []; + } + + console.log( + `🎯 Recherche des PRs créées par l'utilisateur ID: ${currentUserId}` + ); + + const searchParams = new URLSearchParams({ + 'api-version': '6.0', + 'searchCriteria.creatorId': currentUserId, // Utiliser l'ID réel au lieu de @me + 'searchCriteria.status': 'all', // Inclut active, completed, abandoned + $top: '1000', + }); + + const url = `/_apis/git/pullrequests?${searchParams.toString()}`; + const response = await this.makeApiRequest(url); + + if (!response.ok) { + const errorText = await response.text(); + console.error('❌ Erreur API créateur:', response.status, errorText); + throw new Error( + `Erreur API créateur: ${response.status} - ${errorText}` + ); + } + + const data = await response.json(); + + const prs = data.value || []; + console.log(`🚀 ${prs.length} PR(s) créée(s) par l'utilisateur`); + return prs; + } catch (error) { + console.error('❗️ Erreur récupération PRs créateur:', error); + return []; + } + } + + /** + * Récupère l'ID de l'utilisateur courant + */ + private async getCurrentUserId(): Promise { + try { + // Essayer d'abord avec l'endpoint ConnectionData (plus fiable) + const response = await this.makeApiRequest('/_apis/connectionData'); + + if (response.ok) { + const connectionData = await response.json(); + const userId = connectionData?.authenticatedUser?.id; + if (userId) { + console.log('✅ ID utilisateur récupéré via ConnectionData:', userId); + return userId; + } + } + + console.error( + "❌ Impossible de récupérer l'ID utilisateur par aucune méthode" + ); + return null; + } catch (error) { + console.error('❌ Erreur récupération ID utilisateur:', error); + return null; + } + } + + /** + * Filtre les Pull Requests selon la configuration + */ + private filterPullRequests(pullRequests: TfsPullRequest[]): TfsPullRequest[] { + console.log('🗺 Configuration de filtrage:', { + projectName: this.config.projectName, + repositories: this.config.repositories, + ignoredRepositories: this.config.ignoredRepositories, + }); + + // console.log( + // '📋 PRs avant filtrage:', + // pullRequests.map((pr) => ({ + // id: pr.pullRequestId, + // title: pr.title, + // project: pr.repository.project.name, + // repository: pr.repository.name, + // status: pr.status, + // closedDate: pr.closedDate, + // })) + // ); + + let filtered = pullRequests; + const initialCount = filtered.length; + + // 1. Filtrer par statut pertinent (exclure les abandoned, limiter les completed récentes) + const beforeStatusFilter = filtered.length; + filtered = this.filterByRelevantStatus(filtered); + console.log( + `📋 Filtrage statut pertinent: ${beforeStatusFilter} -> ${filtered.length}` + ); + + // 2. Filtrer par projet si spécifié + if (this.config.projectName) { + const beforeProjectFilter = filtered.length; + filtered = filtered.filter( + (pr) => pr.repository.project.name === this.config.projectName + ); + console.log( + `🎯 Filtrage projet "${this.config.projectName}": ${beforeProjectFilter} -> ${filtered.length}` + ); + } + + // Filtrer par repositories autorisés + if (this.config.repositories?.length) { + const beforeRepoFilter = filtered.length; + filtered = filtered.filter((pr) => + this.config.repositories!.includes(pr.repository.name) + ); + console.log( + `📋 Filtrage repositories autorisés ${JSON.stringify(this.config.repositories)}: ${beforeRepoFilter} -> ${filtered.length}` + ); + } + + // Exclure les repositories ignorés + if (this.config.ignoredRepositories?.length) { + const beforeIgnoreFilter = filtered.length; + filtered = filtered.filter( + (pr) => !this.config.ignoredRepositories!.includes(pr.repository.name) + ); + console.log( + `❌ Exclusion repositories ignorés ${JSON.stringify(this.config.ignoredRepositories)}: ${beforeIgnoreFilter} -> ${filtered.length}` + ); + } + + console.log( + `🎟️ Résultat filtrage final: ${initialCount} -> ${filtered.length}` + ); + // console.log( + // '📋 PRs après filtrage:', + // filtered.map((pr) => ({ + // id: pr.pullRequestId, + // title: pr.title, + // project: pr.repository.project.name, + // repository: pr.repository.name, + // status: pr.status, + // })) + // ); + + return filtered; + } + + /** + * Filtre les PRs par statut pertinent + * - Garde toutes les PRs actives créées dans les 90 derniers jours + * - Garde les PRs completed récentes (moins de 30 jours) + * - Exclut les PRs abandoned + * - Exclut les PRs trop anciennes + * - Exclut les PRs automatiques (Renovate, etc.) + */ + private filterByRelevantStatus( + pullRequests: TfsPullRequest[] + ): TfsPullRequest[] { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + return pullRequests.filter((pr) => { + // Exclure les PRs automatiques (Renovate, Dependabot, etc.) + if (this.isAutomaticPR(pr)) { + console.log( + `🤖 PR ${pr.pullRequestId} (${pr.title}): PR automatique - EXCLUE` + ); + return false; + } + + // Filtrer d'abord par âge - exclure les PRs trop anciennes + // const createdDate = parseDate(pr.creationDate); + // if (createdDate < ninetyDaysAgo) { + // console.log( + // `🗺 PR ${pr.pullRequestId} (${pr.title}): Trop ancienne (${formatDateForDisplay(createdDate)}) - EXCLUE` + // ); + // return false; + // } + + switch (pr.status.toLowerCase()) { + case 'active': + // PRs actives récentes + console.log( + `✅ PR ${pr.pullRequestId} (${pr.title}): Active récente - INCLUSE` + ); + return true; + + case 'completed': + // PRs completed récentes (moins de 30 jours) + if (pr.closedDate) { + const closedDate = parseDate(pr.closedDate); + const isRecent = closedDate >= thirtyDaysAgo; + console.log( + `📅 PR ${pr.pullRequestId} (${pr.title}): Completed ${formatDateForDisplay(closedDate)} - ${isRecent ? 'INCLUSE (récente)' : 'EXCLUE (âgée)'}` + ); + return isRecent; + } else { + // Si pas de date de fermeture, on l'inclut par sécurité + console.log( + `❓ PR ${pr.pullRequestId} (${pr.title}): Completed sans date - INCLUSE` + ); + return true; + } + + case 'abandoned': + // PRs abandonnées ne sont pas pertinentes + console.log( + `❌ PR ${pr.pullRequestId} (${pr.title}): Abandoned - EXCLUE` + ); + return false; + + default: + // Statut inconnu, on l'inclut par précaution + console.log( + `❓ PR ${pr.pullRequestId} (${pr.title}): Statut inconnu "${pr.status}" - INCLUSE` + ); + return true; + } + }); + } + + /** + * Détermine si une PR est automatique (bot, renovate, dependabot, etc.) + */ + private isAutomaticPR(pr: TfsPullRequest): boolean { + // Patterns dans le titre + const automaticTitlePatterns = [ + /configure renovate/i, + /update dependency/i, + /bump .+ from .+ to/i, + /\[dependabot\]/i, + /\[renovate\]/i, + /automated pr/i, + /auto.update/i, + /security update/i, + ]; + + // Vérifier le titre + for (const pattern of automaticTitlePatterns) { + if (pattern.test(pr.title)) { + return true; + } + } + + // Patterns dans la description + const automaticDescPatterns = [ + /this pr was automatically created/i, + /renovate bot/i, + /dependabot/i, + /automated dependency update/i, + ]; + + // Vérifier la description + if (pr.description) { + for (const pattern of automaticDescPatterns) { + if (pattern.test(pr.description)) { + return true; + } + } + } + + // Vérifier l'auteur (noms de bots courants) + const botAuthors = [ + 'renovate[bot]', + 'dependabot[bot]', + 'dependabot', + 'renovate', + 'greenkeeper[bot]', + 'snyk-bot', + ]; + + const authorName = pr.createdBy.displayName?.toLowerCase() || ''; + for (const botName of botAuthors) { + if (authorName.includes(botName.toLowerCase())) { + return true; + } + } + + // Vérifier la branche source (patterns de bots) + const automaticBranchPatterns = [ + /renovate\//i, + /dependabot\//i, + /update\/.+dependency/i, + /bump\//i, + ]; + + const sourceBranch = pr.sourceRefName.replace('refs/heads/', ''); + for (const pattern of automaticBranchPatterns) { + if (pattern.test(sourceBranch)) { + return true; + } + } + + return false; + } + + /** + * Synchronise les Pull Requests avec les tâches locales + */ + async syncTasks(): Promise { + const result: TfsSyncResult = { + success: true, + totalPullRequests: 0, + pullRequestsCreated: 0, + pullRequestsUpdated: 0, + pullRequestsSkipped: 0, + pullRequestsDeleted: 0, + actions: [], + errors: [], + }; + + try { + console.log('🔄 Début synchronisation TFS Pull Requests...'); + + // S'assurer que le tag TFS existe + await this.ensureTfsTagExists(); + + // Récupérer toutes les PRs assignées à l'utilisateur + const allPullRequests = await this.getMyPullRequests(); + result.totalPullRequests = allPullRequests.length; + + console.log(`📋 ${allPullRequests.length} Pull Requests trouvées`); + + if (allPullRequests.length === 0) { + console.log('ℹ️ Aucune PR assignée trouvée'); + return result; + } + + // Récupérer les IDs des PRs actuelles pour le nettoyage + const currentPrIds = new Set(allPullRequests.map(pr => pr.pullRequestId)); + + // Synchroniser chaque PR + for (const pr of allPullRequests) { + try { + const syncAction = await this.syncSinglePullRequest(pr); + result.actions.push(syncAction); + + // Compter les actions + if (syncAction.type === 'created') { + result.pullRequestsCreated++; + } else if (syncAction.type === 'updated') { + result.pullRequestsUpdated++; + } else { + result.pullRequestsSkipped++; + } + } catch (error) { + const errorMsg = `Erreur sync PR ${pr.pullRequestId}: ${error instanceof Error ? error.message : 'Erreur inconnue'}`; + result.errors.push(errorMsg); + console.error('❌', errorMsg); + } + } + + // Nettoyer les tâches TFS qui ne sont plus actives + const deletedActions = await this.cleanupInactivePullRequests(currentPrIds); + result.pullRequestsDeleted = deletedActions.length; + result.actions.push(...deletedActions); + + console.log(`✅ Synchronisation TFS terminée:`, { + créées: result.pullRequestsCreated, + mises_a_jour: result.pullRequestsUpdated, + ignorées: result.pullRequestsSkipped, + supprimées: result.pullRequestsDeleted + }); + + result.success = result.errors.length === 0; + } catch (error) { + result.success = false; + const errorMsg = error instanceof Error ? error.message : 'Erreur inconnue'; + result.errors.push(errorMsg); + console.error('❌ Erreur sync TFS:', errorMsg); + } + + return result; + } + + /** + * Synchronise une Pull Request unique + */ + private async syncSinglePullRequest(pr: TfsPullRequest): Promise { + const pullRequestId = pr.pullRequestId; + const sourceId = `tfs-pr-${pullRequestId}`; + + // Chercher la tâche existante + const existingTask = await prisma.task.findFirst({ + where: { sourceId }, + }); + + const taskData = this.mapPullRequestToTask(pr); + + if (!existingTask) { + // Créer nouvelle tâche + const newTask = await prisma.task.create({ + data: { + ...taskData, + sourceId, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + // Assigner le tag TFS + await this.assignTfsTag(newTask.id); + + console.log(`➡️ Nouvelle tâche créée: PR-${pullRequestId}`); + + return { + type: 'created', + pullRequestId, + prTitle: pr.title, + }; + } else { + // Détecter les changements + const changes: string[] = []; + + if (existingTask.title !== taskData.title) { + changes.push(`Titre: ${existingTask.title} → ${taskData.title}`); + } + if (existingTask.status !== taskData.status) { + changes.push(`Statut: ${existingTask.status} → ${taskData.status}`); + } + if (existingTask.description !== taskData.description) { + changes.push('Description modifiée'); + } + if (existingTask.assignee !== taskData.assignee) { + changes.push(`Assigné: ${existingTask.assignee} → ${taskData.assignee}`); + } + + if (changes.length === 0) { + console.log(`⏭️ Aucun changement pour PR-${pullRequestId}`); + + // S'assurer que le tag TFS est assigné (pour les anciennes tâches) + await this.assignTfsTag(existingTask.id); + + return { + type: 'skipped', + pullRequestId, + prTitle: pr.title, + reason: 'Aucun changement détecté' + }; + } + + // Mettre à jour la tâche + await prisma.task.update({ + where: { id: existingTask.id }, + data: { + ...taskData, + updatedAt: new Date(), + }, + }); + + console.log(`🔄 Tâche mise à jour: PR-${pullRequestId} (${changes.length} changements)`); + + return { + type: 'updated', + pullRequestId, + prTitle: pr.title, + changes + }; + } + } + + /** + * S'assure que le tag TFS existe + */ + private async ensureTfsTagExists(): Promise { + try { + const existingTag = await prisma.tag.findFirst({ + where: { name: '🧑‍💻 TFS' }, + }); + + if (!existingTag) { + await prisma.tag.create({ + data: { + name: '🧑‍💻 TFS', + color: '#0066cc', // Bleu Azure DevOps + }, + }); + console.log('✅ Tag TFS créé'); + } + } catch (error) { + console.warn('Erreur création tag TFS:', error); + } + } + + /** + * Assigne automatiquement le tag "TFS" aux tâches importées + */ + private async assignTfsTag(taskId: string): Promise { + try { + let tfsTag = await prisma.tag.findFirst({ + where: { name: '🧑‍💻 TFS' }, + }); + + if (!tfsTag) { + tfsTag = await prisma.tag.create({ + data: { + name: '🧑‍💻 TFS', + color: '#0078d4', // Couleur Azure + isPinned: false, + }, + }); + } + + // Vérifier si la relation existe déjà + const existingRelation = await prisma.taskTag.findFirst({ + where: { taskId, tagId: tfsTag.id }, + }); + + if (!existingRelation) { + await prisma.taskTag.create({ + data: { taskId, tagId: tfsTag.id }, + }); + } + } catch (error) { + console.error('❌ Erreur assignation tag TFS:', error); + // Ne pas faire échouer la sync pour un problème de tag + } + } + + /** + * Mappe une Pull Request TFS vers le format Task + */ + private mapPullRequestToTask(pr: TfsPullRequest) { + const status = this.mapTfsStatusToInternal(pr.status); + const sourceBranch = pr.sourceRefName.replace('refs/heads/', ''); + const targetBranch = pr.targetRefName.replace('refs/heads/', ''); + + return { + title: `PR: ${pr.title}`, + description: this.formatPullRequestDescription(pr), + status, + priority: this.determinePrPriority(pr), + source: 'tfs' as const, + dueDate: null, + completedAt: + pr.status === 'completed' && pr.closedDate + ? parseDate(pr.closedDate) + : null, + + // Métadonnées TFS + tfsProject: pr.repository.project.name, + tfsPullRequestId: pr.pullRequestId, + tfsRepository: pr.repository.name, + tfsSourceBranch: sourceBranch, + tfsTargetBranch: targetBranch, + assignee: pr.createdBy.displayName, + }; + } + + /** + * Formate la description d'une Pull Request + */ + private formatPullRequestDescription(pr: TfsPullRequest): string { + const parts = []; + + if (pr.description) { + parts.push(pr.description); + } + + parts.push(`**Repository:** ${pr.repository.name}`); + parts.push( + `**Branch:** ${pr.sourceRefName.replace('refs/heads/', '')} → ${pr.targetRefName.replace('refs/heads/', '')}` + ); + parts.push(`**Auteur:** ${pr.createdBy.displayName}`); + parts.push( + `**Créé le:** ${formatDateForDisplay(parseDate(pr.creationDate))}` + ); + + if (pr.reviewers && pr.reviewers.length > 0) { + const reviewersInfo = pr.reviewers.map((r) => { + let status = ''; + switch (r.vote) { + case 10: + status = '✅ Approuvé avec suggestions'; + break; + case 5: + status = '✅ Approuvé'; + break; + case -5: + status = "⏳ En attente de l'auteur"; + break; + case -10: + status = '❌ Rejeté'; + break; + default: + status = '⏳ Pas de vote'; + } + return `${r.displayName}: ${status}`; + }); + parts.push(`**Reviewers:**\n${reviewersInfo.join('\n')}`); + } + + if (pr.isDraft) { + parts.push('**🚧 Draft**'); + } + + return parts.join('\n\n'); + } + + /** + * Mappe les statuts TFS vers les statuts internes + */ + private mapTfsStatusToInternal(tfsStatus: string): string { + switch (tfsStatus.toLowerCase()) { + case 'active': + return 'in_progress'; + case 'completed': + return 'done'; + case 'abandoned': + return 'cancelled'; + default: + return 'todo'; + } + } + + /** + * Détermine la priorité d'une PR basée sur divers critères + */ + private determinePrPriority(pr: TfsPullRequest): string { + // PR en Draft = Low + if (pr.isDraft) return 'low'; + + // PR avec des conflits = High + if (pr.mergeStatus === 'conflicts' || pr.mergeStatus === 'failed') + return 'high'; + + // PR vers main/master = Medium par défaut + const targetBranch = pr.targetRefName.replace('refs/heads/', ''); + if (['main', 'master', 'production'].includes(targetBranch)) + return 'medium'; + + // Défaut + return 'low'; + } + + /** + * Nettoie les tâches TFS qui ne correspondent plus aux PRs actives + */ + private async cleanupInactivePullRequests( + currentPrIds: Set + ): Promise { + const deletedActions: TfsSyncAction[] = []; + + try { + console.log('🧹 Nettoyage des tâches TFS inactives...'); + + // Récupérer toutes les tâches TFS existantes + const existingTfsTasks = await prisma.task.findMany({ + where: { source: 'tfs' }, + select: { + id: true, + sourceId: true, + tfsPullRequestId: true, + title: true, + }, + }); + + console.log(`📋 ${existingTfsTasks.length} tâches TFS existantes`); + + // Identifier les tâches à supprimer + const tasksToDelete = existingTfsTasks.filter((task) => { + const prId = task.tfsPullRequestId; + if (!prId) { + console.log(`🤷 Tâche ${task.id} sans PR ID - à supprimer`); + return true; + } + + const shouldKeep = currentPrIds.has(prId); + if (!shouldKeep) { + console.log(`❌ PR ${prId} plus active - à supprimer`); + } + return !shouldKeep; + }); + + console.log(`🗑️ ${tasksToDelete.length} tâches à supprimer`); + + // Supprimer les tâches obsolètes + for (const task of tasksToDelete) { + try { + await prisma.task.delete({ where: { id: task.id } }); + + deletedActions.push({ + type: 'deleted', + pullRequestId: task.tfsPullRequestId || 0, + prTitle: task.title || `Tâche ${task.id}`, + reason: 'Pull Request plus active ou supprimée', + }); + + console.log(`🗑️ Supprimé: ${task.title}`); + } catch (error) { + console.error(`❌ Erreur suppression tâche ${task.id}:`, error); + // Continue avec les autres tâches + } + } + + if (tasksToDelete.length > 0) { + console.log(`✨ ${tasksToDelete.length} tâches TFS obsolètes supprimées`); + } + } catch (error) { + console.error('❌ Erreur nettoyage tâches TFS:', error); + } + + return deletedActions; + } + + /** + * Supprime toutes les tâches TFS de la base de données locale + */ + async deleteAllTasks(): Promise<{ + success: boolean; + deletedCount: number; + error?: string; + }> { + try { + console.log('🗑️ Début suppression de toutes les tâches TFS...'); + + // Récupérer toutes les tâches TFS + const tfsTasks = await prisma.task.findMany({ + where: { source: 'tfs' }, + select: { id: true, title: true }, + }); + + console.log(`📋 ${tfsTasks.length} tâches TFS trouvées`); + + if (tfsTasks.length === 0) { + return { + success: true, + deletedCount: 0, + }; + } + + // Supprimer toutes les tâches TFS en une seule opération + const deleteResult = await prisma.task.deleteMany({ + where: { source: 'tfs' }, + }); + + console.log(`✅ ${deleteResult.count} tâches TFS supprimées avec succès`); + + return { + success: true, + deletedCount: deleteResult.count, + }; + } catch (error) { + console.error('❌ Erreur suppression tâches TFS:', error); + return { + success: false, + deletedCount: 0, + error: error instanceof Error ? error.message : 'Erreur inconnue', + }; + } + } + + /** + * Récupère les métadonnées du projet (repositories, branches, etc.) + */ + async getMetadata(): Promise<{ + repositories: Array<{ id: string; name: string }>; + }> { + const repositories = await this.getRepositories(); + return { repositories }; + } + + /** + * Effectue une requête vers l'API Azure DevOps + */ + private async makeApiRequest(endpoint: string): Promise { + if (!this.config.organizationUrl || !this.config.personalAccessToken) { + throw new Error('Configuration TFS manquante'); + } + + // Si l'endpoint commence par /_apis, c'est un endpoint organisation + // Sinon, on peut inclure le projet si spécifié + let url: string; + if (endpoint.startsWith('/_apis')) { + url = `${this.config.organizationUrl}${endpoint}`; + } else { + // Pour compatibilité avec d'autres endpoints + const project = this.config.projectName + ? `/${this.config.projectName}` + : ''; + url = `${this.config.organizationUrl}${project}${endpoint}`; + } + + const headers: Record = { + Authorization: `Basic ${Buffer.from(`:${this.config.personalAccessToken}`).toString('base64')}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + // console.log('🌐 Requête API Azure DevOps:', { + // url, + // method: 'GET', + // headers: { + // ...headers, + // 'Authorization': 'Basic [MASQUÉ]' // Masquer le token pour la sécurité + // } + // }); + + const response = await fetch(url, { headers }); + + // console.log('🔄 Réponse brute Azure DevOps:', { + // status: response.status, + // statusText: response.statusText, + // url: response.url, + // headers: Object.fromEntries(response.headers.entries()) + // }); + + return response; + } +} + +/** + * Instance TFS préconfigurée avec les préférences utilisateur + */ +class TfsServiceInstance extends TfsService { + constructor() { + super({ enabled: false }); // Config vide par défaut + } + + private async getConfig(): Promise { + const userConfig = await userPreferencesService.getTfsConfig(); + return userConfig; + } + + async testConnection(): Promise { + const config = await this.getConfig(); + if (!config.enabled || !config.organizationUrl || !config.personalAccessToken) { + return false; + } + + const service = new TfsService(config); + return service.testConnection(); + } + + async validateConfig(): Promise<{ valid: boolean; error?: string }> { + const config = await this.getConfig(); + const service = new TfsService(config); + return service.validateConfig(); + } + + async syncTasks(): Promise { + const config = await this.getConfig(); + const service = new TfsService(config); + return service.syncTasks(); + } + + async deleteAllTasks(): Promise<{ + success: boolean; + deletedCount: number; + error?: string; + }> { + const config = await this.getConfig(); + const service = new TfsService(config); + return service.deleteAllTasks(); + } + + async getMetadata(): Promise<{ + repositories: Array<{ id: string; name: string }>; + }> { + const config = await this.getConfig(); + const service = new TfsService(config); + return service.getMetadata(); + } + + async validateProject( + projectName: string + ): Promise<{ exists: boolean; name?: string; error?: string }> { + const config = await this.getConfig(); + const service = new TfsService(config); + return service.validateProject(projectName); + } + + reset(): void { + // Pas besoin de reset, la config est récupérée à chaque fois + } +} + +/** + * Service TFS préconfiguré avec récupération automatique des préférences + */ +export const tfsService = new TfsServiceInstance(); diff --git a/src/services/user-preferences.ts b/src/services/user-preferences.ts index e6b7481..b7f1eed 100644 --- a/src/services/user-preferences.ts +++ b/src/services/user-preferences.ts @@ -1,4 +1,12 @@ -import { TaskStatus, KanbanFilters, ViewPreferences, ColumnVisibility, UserPreferences, JiraConfig } from '@/lib/types'; +import { + TaskStatus, + KanbanFilters, + ViewPreferences, + ColumnVisibility, + UserPreferences, + JiraConfig, +} from '@/lib/types'; +import { TfsConfig } from '@/services/tfs'; import { prisma } from './database'; import { getConfig } from '@/lib/config'; @@ -32,7 +40,17 @@ const DEFAULT_PREFERENCES: UserPreferences = { ignoredProjects: [] }, jiraAutoSync: false, - jiraSyncInterval: 'daily' + jiraSyncInterval: 'daily', + tfsConfig: { + enabled: false, + organizationUrl: '', + projectName: '', + personalAccessToken: '', + repositories: [], + ignoredRepositories: [], + }, + tfsAutoSync: false, + tfsSyncInterval: 'daily', }; /** @@ -57,10 +75,10 @@ class UserPreferencesService { jiraConfig: DEFAULT_PREFERENCES.jiraConfig as any, // eslint-disable-line @typescript-eslint/no-explicit-any } }); - + // S'assurer que les nouveaux champs existent (migration douce) await this.ensureJiraSchedulerFields(); - + return userPrefs; } @@ -82,7 +100,7 @@ class UserPreferencesService { } // === FILTRES KANBAN === - + /** * Sauvegarde les filtres Kanban */ @@ -126,7 +144,10 @@ class UserPreferencesService { data: { viewPreferences: preferences } }); } catch (error) { - console.warn('Erreur lors de la sauvegarde des préférences de vue:', error); + console.warn( + 'Erreur lors de la sauvegarde des préférences de vue:', + error + ); throw error; } } @@ -140,7 +161,10 @@ class UserPreferencesService { const preferences = userPrefs.viewPreferences as ViewPreferences | null; return { ...DEFAULT_PREFERENCES.viewPreferences, ...(preferences || {}) }; } catch (error) { - console.warn('Erreur lors de la récupération des préférences de vue:', error); + console.warn( + 'Erreur lors de la récupération des préférences de vue:', + error + ); return DEFAULT_PREFERENCES.viewPreferences; } } @@ -155,10 +179,13 @@ class UserPreferencesService { const userPrefs = await this.getOrCreateUserPreferences(); await prisma.userPreferences.update({ where: { id: userPrefs.id }, - data: { columnVisibility: visibility } + data: { columnVisibility: visibility }, }); } catch (error) { - console.warn('Erreur lors de la sauvegarde de la visibilité des colonnes:', error); + console.warn( + 'Erreur lors de la sauvegarde de la visibilité des colonnes:', + error + ); throw error; } } @@ -172,7 +199,10 @@ class UserPreferencesService { const visibility = userPrefs.columnVisibility as ColumnVisibility | null; return { ...DEFAULT_PREFERENCES.columnVisibility, ...(visibility || {}) }; } catch (error) { - console.warn('Erreur lors de la récupération de la visibilité des colonnes:', error); + console.warn( + 'Erreur lors de la récupération de la visibilité des colonnes:', + error + ); return DEFAULT_PREFERENCES.columnVisibility; } } @@ -218,19 +248,22 @@ class UserPreferencesService { try { const userPrefs = await this.getOrCreateUserPreferences(); const dbConfig = userPrefs.jiraConfig as JiraConfig | null; - + // Si config en DB, l'utiliser - if (dbConfig && (dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken)) { + if ( + dbConfig && + (dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken) + ) { return { ...DEFAULT_PREFERENCES.jiraConfig, ...dbConfig }; } - + // Sinon fallback sur les variables d'environnement (existant) const config = getConfig(); return { baseUrl: config.integrations.jira.baseUrl, email: config.integrations.jira.email, apiToken: '', // On ne retourne pas le token des env vars pour la sécurité - enabled: config.integrations.jira.enabled + enabled: config.integrations.jira.enabled, }; } catch (error) { console.warn('Erreur lors de la récupération de la config Jira:', error); @@ -238,12 +271,120 @@ class UserPreferencesService { } } + // === CONFIGURATION TFS === + + /** + * Sauvegarde la configuration TFS + */ + async saveTfsConfig(config: TfsConfig): Promise { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + await prisma.userPreferences.update({ + where: { id: userPrefs.id }, + data: { tfsConfig: config as any }, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + } catch (error) { + console.warn('Erreur lors de la sauvegarde de la config TFS:', error); + throw error; + } + } + + /** + * Récupère la configuration TFS depuis la base de données + */ + async getTfsConfig(): Promise { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + const dbConfig = userPrefs.tfsConfig as TfsConfig | null; + + if ( + dbConfig && + (dbConfig.organizationUrl || + dbConfig.projectName || + dbConfig.personalAccessToken) + ) { + return { ...DEFAULT_PREFERENCES.tfsConfig, ...dbConfig }; + } + + return DEFAULT_PREFERENCES.tfsConfig; + } catch (error) { + console.warn('Erreur lors de la récupération de la config TFS:', error); + return DEFAULT_PREFERENCES.tfsConfig; + } + } + + /** + * Sauvegarde les préférences du scheduler TFS + */ + async saveTfsSchedulerConfig( + tfsAutoSync: boolean, + tfsSyncInterval: 'hourly' | 'daily' | 'weekly' + ): Promise { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + await prisma.$executeRaw` + UPDATE user_preferences + SET tfsAutoSync = ${tfsAutoSync}, tfsSyncInterval = ${tfsSyncInterval} + WHERE id = ${userPrefs.id} + `; + } catch (error) { + console.warn( + 'Erreur lors de la sauvegarde de la config scheduler TFS:', + error + ); + throw error; + } + } + + /** + * Récupère les préférences du scheduler TFS + */ + async getTfsSchedulerConfig(): Promise<{ + tfsAutoSync: boolean; + tfsSyncInterval: 'hourly' | 'daily' | 'weekly'; + }> { + try { + const userPrefs = await this.getOrCreateUserPreferences(); + const result = await prisma.$queryRaw< + Array<{ tfsAutoSync: number; tfsSyncInterval: string }> + >` + SELECT tfsAutoSync, tfsSyncInterval FROM user_preferences WHERE id = ${userPrefs.id} + `; + + if (result.length > 0) { + return { + tfsAutoSync: Boolean(result[0].tfsAutoSync), + tfsSyncInterval: + (result[0].tfsSyncInterval as 'hourly' | 'daily' | 'weekly') || + DEFAULT_PREFERENCES.tfsSyncInterval, + }; + } + + return { + tfsAutoSync: DEFAULT_PREFERENCES.tfsAutoSync, + tfsSyncInterval: DEFAULT_PREFERENCES.tfsSyncInterval, + }; + } catch (error) { + console.warn( + 'Erreur lors de la récupération de la config scheduler TFS:', + error + ); + return { + tfsAutoSync: DEFAULT_PREFERENCES.tfsAutoSync, + tfsSyncInterval: DEFAULT_PREFERENCES.tfsSyncInterval, + }; + } + } + // === CONFIGURATION SCHEDULER JIRA === /** * Sauvegarde les préférences du scheduler Jira */ - async saveJiraSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise { + async saveJiraSchedulerConfig( + jiraAutoSync: boolean, + jiraSyncInterval: 'hourly' | 'daily' | 'weekly' + ): Promise { try { const userPrefs = await this.getOrCreateUserPreferences(); // Utiliser une requête SQL brute temporairement pour éviter les problèmes de types @@ -253,7 +394,10 @@ class UserPreferencesService { WHERE id = ${userPrefs.id} `; } catch (error) { - console.warn('Erreur lors de la sauvegarde de la config scheduler Jira:', error); + console.warn( + 'Erreur lors de la sauvegarde de la config scheduler Jira:', + error + ); throw error; } } @@ -261,24 +405,31 @@ class UserPreferencesService { /** * Récupère les préférences du scheduler Jira */ - async getJiraSchedulerConfig(): Promise<{ jiraAutoSync: boolean; jiraSyncInterval: 'hourly' | 'daily' | 'weekly' }> { + async getJiraSchedulerConfig(): Promise<{ + jiraAutoSync: boolean; + jiraSyncInterval: 'hourly' | 'daily' | 'weekly'; + }> { try { const userPrefs = await this.getOrCreateUserPreferences(); // Utiliser une requête SQL brute pour récupérer les nouveaux champs - const result = await prisma.$queryRaw>` + const result = await prisma.$queryRaw< + Array<{ jiraAutoSync: number; jiraSyncInterval: string }> + >` SELECT jiraAutoSync, jiraSyncInterval FROM user_preferences WHERE id = ${userPrefs.id} `; - + if (result.length > 0) { return { jiraAutoSync: Boolean(result[0].jiraAutoSync), - jiraSyncInterval: (result[0].jiraSyncInterval as 'hourly' | 'daily' | 'weekly') || DEFAULT_PREFERENCES.jiraSyncInterval + jiraSyncInterval: + (result[0].jiraSyncInterval as 'hourly' | 'daily' | 'weekly') || + DEFAULT_PREFERENCES.jiraSyncInterval, }; } - + return { jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync, - jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval + jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval, }; } catch (error) { console.warn('Erreur lors de la récupération de la config scheduler Jira:', error); @@ -289,16 +440,33 @@ class UserPreferencesService { } } + /** + * Récupère les préférences utilisateur (alias pour getAllPreferences) + */ + async getUserPreferences(): Promise { + return this.getAllPreferences(); + } + /** * Récupère toutes les préférences utilisateur */ async getAllPreferences(): Promise { - const [kanbanFilters, viewPreferences, columnVisibility, jiraConfig, jiraSchedulerConfig] = await Promise.all([ + const [ + kanbanFilters, + viewPreferences, + columnVisibility, + jiraConfig, + jiraSchedulerConfig, + tfsConfig, + tfsSchedulerConfig, + ] = await Promise.all([ this.getKanbanFilters(), this.getViewPreferences(), this.getColumnVisibility(), this.getJiraConfig(), - this.getJiraSchedulerConfig() + this.getJiraSchedulerConfig(), + this.getTfsConfig(), + this.getTfsSchedulerConfig(), ]); return { @@ -307,7 +475,10 @@ class UserPreferencesService { columnVisibility, jiraConfig, jiraAutoSync: jiraSchedulerConfig.jiraAutoSync, - jiraSyncInterval: jiraSchedulerConfig.jiraSyncInterval + jiraSyncInterval: jiraSchedulerConfig.jiraSyncInterval, + tfsConfig, + tfsAutoSync: tfsSchedulerConfig.tfsAutoSync, + tfsSyncInterval: tfsSchedulerConfig.tfsSyncInterval, }; } @@ -320,7 +491,15 @@ class UserPreferencesService { this.saveViewPreferences(preferences.viewPreferences), this.saveColumnVisibility(preferences.columnVisibility), this.saveJiraConfig(preferences.jiraConfig), - this.saveJiraSchedulerConfig(preferences.jiraAutoSync, preferences.jiraSyncInterval) + this.saveJiraSchedulerConfig( + preferences.jiraAutoSync, + preferences.jiraSyncInterval + ), + this.saveTfsConfig(preferences.tfsConfig), + this.saveTfsSchedulerConfig( + preferences.tfsAutoSync, + preferences.tfsSyncInterval + ), ]); } @@ -369,13 +548,13 @@ class UserPreferencesService { async toggleColumnVisibility(status: TaskStatus): Promise { const current = await this.getColumnVisibility(); const hiddenStatuses = new Set(current.hiddenStatuses); - + if (hiddenStatuses.has(status)) { hiddenStatuses.delete(status); } else { hiddenStatuses.add(status); } - + await this.saveColumnVisibility({ hiddenStatuses: Array.from(hiddenStatuses) });