/** * 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();