Files
towercontrol/src/services/tfs.ts
Julien Froidefond 723a44df32 feat: TFS Sync
2025-09-22 21:51:12 +02:00

1118 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<boolean> {
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<TfsPullRequest[]> {
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<TfsPullRequest[]> {
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<string | null> {
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<TfsSyncResult> {
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<TfsSyncAction> {
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<void> {
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<void> {
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<number>
): Promise<TfsSyncAction[]> {
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<Response> {
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<string, string> = {
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<TfsConfig> {
const userConfig = await userPreferencesService.getTfsConfig();
return userConfig;
}
async testConnection(): Promise<boolean> {
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<TfsSyncResult> {
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();