diff --git a/src/components/tfs/TfsSync.tsx b/src/components/tfs/TfsSync.tsx new file mode 100644 index 0000000..5378df9 --- /dev/null +++ b/src/components/tfs/TfsSync.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { getToday } from '@/lib/date-utils'; +import { Modal } from '@/components/ui/Modal'; +import { TfsSyncResult, TfsSyncAction } from '@/services/integrations/tfs'; + +interface TfsSyncProps { + onSyncComplete?: () => void; + className?: string; +} + +export function TfsSync({ onSyncComplete, className = "" }: TfsSyncProps) { + const [isConnected, setIsConnected] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [lastSyncResult, setLastSyncResult] = useState(null); + const [error, setError] = useState(null); + const [showDetails, setShowDetails] = useState(false); + + const testConnection = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/tfs/test'); + const result = await response.json(); + setIsConnected(result.connected); + if (!result.connected) { + setError(result.error || result.message); + } + } catch (err) { + setIsConnected(false); + setError(err instanceof Error ? err.message : 'Erreur de connexion'); + } finally { + setIsLoading(false); + } + }; + + const startSync = async () => { + setIsSyncing(true); + setError(null); + + try { + const response = await fetch('/api/tfs/sync', { method: 'POST' }); + const result = await response.json(); + + console.log('TFS Sync API Response:', result); + + // L'API retourne { message: '...', data: result } ou { error: '...', data: result } + const syncResult = result.data || result; + console.log('TFS Sync Result:', syncResult); + + setLastSyncResult(syncResult); + + // Considérer comme succès si on a une réponse (même avec status 207) + if (response.ok || response.status === 207) { + onSyncComplete?.(); + } + } catch (err) { + console.error('TFS Sync Error:', err); + setError(err instanceof Error ? err.message : 'Erreur de synchronisation'); + } finally { + setIsSyncing(false); + } + }; + + const getConnectionStatus = () => { + if (isConnected === null) return null; + return isConnected ? ( + ✓ Connecté + ) : ( + ✗ Déconnecté + ); + }; + + const getSyncStatus = () => { + if (!lastSyncResult) return null; + + const { + success, + totalPullRequests = 0, + pullRequestsCreated = 0, + pullRequestsUpdated = 0, + pullRequestsSkipped = 0, + pullRequestsDeleted = 0, + errors = [], + actions = [] + } = lastSyncResult; + + return ( +
+
+
+ 0 ? "danger" : + (totalPullRequests > 0 ? "warning" : "success")) + } size="sm"> + {success ? "✓ Succès" : + (errors.length > 0 ? "⚠ Erreurs" : + (totalPullRequests > 0 ? "✓ À jour" : "✓ Synchronisé"))} + + + {getToday().toLocaleTimeString()} + +
+
+ {totalPullRequests > 0 + ? `${totalPullRequests} PR${totalPullRequests > 1 ? 's' : ''} trouvée${totalPullRequests > 1 ? 's' : ''}` + : 'Aucune PR trouvée' + } +
+
+ +
+
+
{pullRequestsCreated}
+
Créées
+
+
+
{pullRequestsUpdated}
+
Mises à jour
+
+
+
{pullRequestsSkipped}
+
Ignorées
+
+
+
{pullRequestsDeleted}
+
Supprimées
+
+
+ + {/* Résumé textuel avec bouton détails */} +
+
+
Résumé:
+ {actions && actions.length > 0 && ( + + )} +
+
+ {pullRequestsCreated > 0 && `${pullRequestsCreated} nouvelle${pullRequestsCreated > 1 ? 's' : ''} • `} + {pullRequestsUpdated > 0 && `${pullRequestsUpdated} mise${pullRequestsUpdated > 1 ? 's' : ''} à jour • `} + {pullRequestsDeleted > 0 && `${pullRequestsDeleted} supprimée${pullRequestsDeleted > 1 ? 's' : ''} (fermées) • `} + {pullRequestsSkipped > 0 && `${pullRequestsSkipped} déjà synchronisée${pullRequestsSkipped > 1 ? 's' : ''} • `} + {(pullRequestsCreated + pullRequestsUpdated + pullRequestsDeleted + pullRequestsSkipped) === 0 && 'Aucune PR trouvée'} +
+
+ + {errors && errors.length > 0 && ( +
+
Erreurs ({errors.length}):
+
+ {errors.map((err, i) => ( +
{err}
+ ))} +
+
+ )} +
+ ); + }; + + return ( + + +
+
+
+

+ TFS SYNC +

+
+ {getConnectionStatus()} +
+
+ + + {/* Test de connexion */} +
+ + + +
+ + {/* Messages d'erreur */} + {error && ( +
+ {error} +
+ )} + + {/* Résultats de sync */} + {getSyncStatus()} + + {/* Info */} +
+
• Synchronisation unidirectionnelle (TFS → TowerControl)
+
• Les modifications locales sont préservées
+
• Seules les Pull Requests assignées sont synchronisées
+
• Les PRs fermées sont automatiquement supprimées
+
+
+ + {/* Modal détails de synchronisation */} + {lastSyncResult && ( + setShowDetails(false)} + title="📋 DÉTAILS DE SYNCHRONISATION" + size="xl" + > +
+

+ {(lastSyncResult.actions || []).length} action{(lastSyncResult.actions || []).length > 1 ? 's' : ''} effectuée{(lastSyncResult.actions || []).length > 1 ? 's' : ''} +

+ +
+ {(lastSyncResult.actions || []).length > 0 ? ( + + ) : ( +
+
📝
+
Aucun détail disponible pour cette synchronisation
+
Les détails sont disponibles pour les nouvelles synchronisations
+
+ )} +
+
+
+ )} +
+ ); +} + +// Composant pour afficher la liste des actions +function SyncActionsList({ actions }: { actions: TfsSyncAction[] }) { + const getActionIcon = (type: TfsSyncAction['type']) => { + switch (type) { + case 'created': return '➕'; + case 'updated': return '🔄'; + case 'skipped': return '⏭️'; + case 'deleted': return '🗑️'; + default: return '❓'; + } + }; + + const getActionColor = (type: TfsSyncAction['type']) => { + switch (type) { + case 'created': return 'text-emerald-400'; + case 'updated': return 'text-blue-400'; + case 'skipped': return 'text-orange-400'; + case 'deleted': return 'text-red-400'; + default: return 'text-gray-400'; + } + }; + + const getActionLabel = (type: TfsSyncAction['type']) => { + switch (type) { + case 'created': return 'Créée'; + case 'updated': return 'Mise à jour'; + case 'skipped': return 'Ignorée'; + case 'deleted': return 'Supprimée'; + default: return 'Inconnue'; + } + }; + + // Grouper les actions par type + const groupedActions = actions.reduce((acc, action) => { + if (!acc[action.type]) acc[action.type] = []; + acc[action.type].push(action); + return acc; + }, {} as Record); + + return ( +
+ {Object.entries(groupedActions).map(([type, typeActions]) => ( +
+

+ {getActionIcon(type as TfsSyncAction['type'])} + {getActionLabel(type as TfsSyncAction['type'])} ({typeActions.length}) +

+ +
+ {typeActions.map((action, index) => ( +
+
+
+
+ + PR #{action.pullRequestId} + + + {action.prTitle} + +
+
+ + {getActionLabel(action.type)} + +
+ + {action.reason && ( +
+ 💡 {action.reason} +
+ )} + + {action.changes && action.changes.length > 0 && ( +
+
+ Modifications: +
+ {action.changes.map((change, changeIndex) => ( +
+ {change} +
+ ))} +
+ )} +
+ ))} +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/services/integrations/tfs.ts b/src/services/integrations/tfs.ts new file mode 100644 index 0000000..1f88a4e --- /dev/null +++ b/src/services/integrations/tfs.ts @@ -0,0 +1,1076 @@ +/** + * 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 '@/services/core/database'; +import { parseDate, formatDateForDisplay } from '@/lib/date-utils'; +import { userPreferencesService } from '@/services/core/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 { + // Uniquement les PRs créées par l'utilisateur (simplifié) + const createdPrs = await this.getPullRequestsByCreator(); + + // Filtrer les PRs selon la configuration + const filteredPrs = this.filterPullRequests(createdPrs); + + 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 || []; + 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; + return isRecent; + } else { + // Si pas de date de fermeture, on l'inclut par sécurité + return true; + } + + case 'abandoned': + // PRs abandonnées ne sont pas pertinentes + return false; + + default: + // Statut inconnu, on l'inclut par précaution + 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; + + if (allPullRequests.length === 0) { + 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); + + 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) { + // 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(), + }, + }); + + 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, + }, + }); + + // Identifier les tâches à supprimer + const tasksToDelete = existingTfsTasks.filter((task) => { + const prId = task.tfsPullRequestId; + if (!prId) { + return true; + } + + const shouldKeep = currentPrIds.has(prId); + return !shouldKeep; + }); + + // 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', + }); + + } 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 { + // Récupérer toutes les tâches TFS + const tfsTasks = await prisma.task.findMany({ + where: { source: 'tfs' }, + select: { id: true, title: true }, + }); + + 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(userId?: string): Promise { + const targetUserId = userId || 'default'; + const userConfig = await userPreferencesService.getTfsConfig(targetUserId); + return userConfig; + } + + async testConnection(userId?: string): Promise { + const config = await this.getConfig(userId); + if (!config.enabled || !config.organizationUrl || !config.personalAccessToken) { + return false; + } + + const service = new TfsService(config); + return service.testConnection(); + } + + async validateConfig(userId?: string): Promise<{ valid: boolean; error?: string }> { + const config = await this.getConfig(userId); + const service = new TfsService(config); + return service.validateConfig(); + } + + async syncTasks(userId?: string): Promise { + const config = await this.getConfig(userId); + 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();