1118 lines
32 KiB
TypeScript
1118 lines
32 KiB
TypeScript
/**
|
||
* 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();
|