feat: TFS Sync

This commit is contained in:
Julien Froidefond
2025-09-22 21:51:12 +02:00
parent 472135a97f
commit 723a44df32
27 changed files with 3309 additions and 364 deletions

View File

@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { TfsConfig } from '@/services/tfs';
import { getTfsConfig, saveTfsConfig, deleteAllTfsTasks } from '@/actions/tfs';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
export function TfsConfigForm() {
const [config, setConfig] = useState<TfsConfig>({
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
});
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
const [testingConnection, setTestingConnection] = useState(false);
const [showForm, setShowForm] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [deletingTasks, setDeletingTasks] = useState(false);
// Charger la configuration existante
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
const result = await getTfsConfig();
if (result.success) {
setConfig(result.data);
// Afficher le formulaire par défaut si TFS n'est pas configuré
const isConfigured =
result.data?.enabled &&
result.data?.organizationUrl &&
result.data?.personalAccessToken;
if (!isConfigured) {
setShowForm(true);
}
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors du chargement de la configuration',
});
setShowForm(true); // Afficher le formulaire en cas d'erreur
}
} catch (error) {
console.error('Erreur chargement config TFS:', error);
setMessage({
type: 'error',
text: 'Erreur lors du chargement de la configuration',
});
setShowForm(true);
} finally {
setIsLoading(false);
}
};
const handleSaveConfig = () => {
startTransition(async () => {
setMessage(null);
const result = await saveTfsConfig(config);
if (result.success) {
setMessage({
type: 'success',
text: result.message || 'Configuration sauvegardée',
});
// Masquer le formulaire après une sauvegarde réussie
setShowForm(false);
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la sauvegarde',
});
}
});
};
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration TFS ?')) {
return;
}
startTransition(async () => {
setMessage(null);
// Réinitialiser la config
const resetConfig = {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
};
const result = await saveTfsConfig(resetConfig);
if (result.success) {
setConfig(resetConfig);
setMessage({ type: 'success', text: 'Configuration TFS supprimée' });
setShowForm(true); // Afficher le formulaire pour reconfigurer
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression',
});
}
});
};
const testConnection = async () => {
try {
setTestingConnection(true);
setMessage(null);
// Sauvegarder d'abord la config
const saveResult = await saveTfsConfig(config);
if (!saveResult.success) {
setMessage({
type: 'error',
text: saveResult.error || 'Erreur lors de la sauvegarde',
});
return;
}
// Attendre un peu que la configuration soit prise en compte
await new Promise((resolve) => setTimeout(resolve, 1000));
// Tester la connexion avec la route dédiée
const response = await fetch('/api/tfs/test', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
console.log('Test TFS - Réponse:', { status: response.status, result });
if (response.ok && result.connected) {
setMessage({
type: 'success',
text: `Connexion Azure DevOps réussie ! ${result.message || ''}`,
});
} else {
const errorMessage =
result.error || result.details || 'Erreur de connexion inconnue';
setMessage({
type: 'error',
text: `Connexion échouée: ${errorMessage}`,
});
console.error('Test TFS échoué:', result);
}
} catch (error) {
console.error('Erreur test connexion TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setTestingConnection(false);
}
};
const handleDeleteAllTasks = async () => {
const confirmation = confirm(
'Êtes-vous sûr de vouloir supprimer TOUTES les tâches TFS de la base locale ?\n\n' +
'Cette action est irréversible et supprimera définitivement toutes les tâches ' +
'synchronisées depuis Azure DevOps/TFS.\n\n' +
'Cliquez sur OK pour confirmer la suppression.'
);
if (!confirmation) {
return;
}
try {
setDeletingTasks(true);
setMessage(null);
const result = await deleteAllTfsTasks();
if (result.success) {
setMessage({
type: 'success',
text:
result.message ||
'Toutes les tâches TFS ont été supprimées avec succès',
});
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression des tâches TFS',
});
}
} catch (error) {
console.error('Erreur suppression tâches TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setDeletingTasks(false);
}
};
const updateConfig = (
field: keyof TfsConfig,
value: string | boolean | string[]
) => {
setConfig((prev) => ({ ...prev, [field]: value }));
};
const updateArrayField = (
field: 'repositories' | 'ignoredRepositories',
value: string
) => {
const array = value
.split(',')
.map((item) => item.trim())
.filter((item) => item);
updateConfig(field, array);
};
const isTfsConfigured =
config?.enabled && config?.organizationUrl && config?.personalAccessToken;
const isLoadingState = isLoading || isPending || deletingTasks;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-[var(--muted-foreground)]">
Chargement...
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Statut actuel */}
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
<div>
<h3 className="font-medium">Statut de l&apos;intégration</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{isTfsConfigured
? 'Azure DevOps est configuré et prêt à être utilisé'
: "Azure DevOps n'est pas configuré"}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={isTfsConfigured ? 'success' : 'danger'}>
{isTfsConfigured ? '✓ Configuré' : '✗ Non configuré'}
</Badge>
<Button
variant="secondary"
size="sm"
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Masquer' : isTfsConfigured ? 'Modifier' : 'Configurer'}
</Button>
</div>
</div>
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border">
<h3 className="font-medium mb-2">Configuration actuelle</h3>
<div className="space-y-2 text-sm">
<div>
<span className="text-[var(--muted-foreground)]">
URL d&apos;organisation:
</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.organizationUrl || 'Non définie'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Projet:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.projectName || "Toute l'organisation"}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Token PAT:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.personalAccessToken ? '••••••••' : 'Non défini'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories surveillés:
</span>{' '}
{config?.repositories && config.repositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Tous les repositories</span>
)}
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories ignorés:
</span>{' '}
{config?.ignoredRepositories &&
config.ignoredRepositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Aucun</span>
)}
</div>
</div>
</div>
)}
{/* Actions de gestion des données TFS */}
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-orange-800 dark:text-orange-200">
Gestion des données
</h3>
<p className="text-sm text-orange-600 dark:text-orange-300">
Supprimez toutes les tâches TFS synchronisées de la base locale
</p>
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1">
<strong>Attention:</strong> Cette action est irréversible et
supprimera définitivement toutes les tâches importées depuis
Azure DevOps.
</p>
</div>
<Button
type="button"
variant="danger"
onClick={handleDeleteAllTasks}
disabled={deletingTasks}
className="px-6"
>
{deletingTasks
? 'Suppression...'
: '🗑️ Supprimer toutes les tâches TFS'}
</Button>
</div>
</div>
)}
{/* Formulaire de configuration */}
{showForm && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveConfig();
}}
className="space-y-4"
>
{/* Toggle d'activation */}
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
<div>
<h4 className="font-medium">Activer l&apos;intégration TFS</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les Pull Requests depuis Azure DevOps
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig('enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{config.enabled && (
<>
<div>
<label className="block text-sm font-medium mb-2">
URL de l&apos;organisation Azure DevOps
</label>
<input
type="url"
value={config.organizationUrl || ''}
onChange={(e) =>
updateConfig('organizationUrl', e.target.value)
}
placeholder="https://dev.azure.com/votre-organisation"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
required
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
L&apos;URL de base de votre organisation Azure DevOps (ex:
https://dev.azure.com/monentreprise)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Nom du projet (optionnel)
</label>
<input
type="text"
value={config.projectName || ''}
onChange={(e) => updateConfig('projectName', e.target.value)}
placeholder="MonProjet (laisser vide pour toute l'organisation)"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Nom du projet spécifique ou laisser vide pour synchroniser les
PRs de toute l&apos;organisation
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Personal Access Token (PAT)
</label>
<input
type="password"
value={config.personalAccessToken || ''}
onChange={(e) =>
updateConfig('personalAccessToken', e.target.value)
}
placeholder="Votre token d'accès personnel"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
required
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Créez un PAT depuis{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
Azure DevOps
</a>{' '}
avec les permissions Code (read) et Pull Request (read)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à surveiller (optionnel)
</label>
<input
type="text"
value={config.repositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('repositories', e.target.value)
}
placeholder="repo1, repo2, repo3"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Liste séparée par des virgules. Laisser vide pour surveiller
tous les repositories.
</p>
{config.repositories && config.repositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories surveillés:
</span>
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à ignorer (optionnel)
</label>
<input
type="text"
value={config.ignoredRepositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('ignoredRepositories', e.target.value)
}
placeholder="test-repo, demo-repo"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Repositories à exclure de la synchronisation, séparés par des
virgules (ex: test-repo, demo-repo).
</p>
{config.ignoredRepositories &&
config.ignoredRepositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories ignorés:
</span>
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
</>
)}
<div className="flex gap-3">
<Button type="submit" disabled={isLoadingState} className="flex-1">
{isLoadingState
? 'Sauvegarde...'
: 'Sauvegarder la configuration'}
</Button>
<Button
type="button"
variant="secondary"
onClick={testConnection}
disabled={
testingConnection ||
!config.organizationUrl ||
!config.personalAccessToken
}
className="px-6"
>
{testingConnection ? 'Test...' : 'Tester'}
</Button>
{isTfsConfigured && (
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={isLoadingState}
className="px-6"
>
Supprimer
</Button>
)}
</div>
{/* Instructions */}
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<h3 className="font-medium mb-2">
💡 Instructions de configuration
</h3>
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
<p>
<strong>1. URL d&apos;organisation:</strong> Votre domaine Azure
DevOps (ex: https://dev.azure.com/monentreprise)
</p>
<p>
<strong>2. Nom du projet (optionnel):</strong> Spécifiez un
projet pour limiter la synchronisation, ou laissez vide pour
toute l&apos;organisation
</p>
<p>
<strong>3. Personal Access Token:</strong> Créez un PAT depuis
Azure DevOps :
</p>
<ul className="ml-4 space-y-1 list-disc">
<li>
Allez sur{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
dev.azure.com
</a>
</li>
<li>Cliquez sur votre profil » Personal access tokens</li>
<li>Cliquez sur &quot;New Token&quot;</li>
<li>
Sélectionnez les scopes: Code (read) et Pull Request (read)
</li>
<li>Copiez le token généré</li>
</ul>
<p className="mt-3 text-xs">
<strong>🎯 Synchronisation intelligente:</strong> TowerControl
récupère automatiquement toutes les Pull Requests vous
concernant (créées par vous ou vous êtes reviewer) dans
l&apos;organisation ou le projet configuré.
</p>
<p className="text-xs">
<strong>Note:</strong> Les PRs seront synchronisées comme tâches
pour un suivi centralisé de vos activités.
</p>
</div>
</div>
</form>
)}
{message && (
<div
className={`p-4 rounded border ${
message.type === 'success'
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
}`}
>
{message.text}
</div>
)}
</div>
);
}