feat: enhance Jira dashboard with advanced filtering and sprint details
- Updated `TODO.md` to mark several tasks as complete, including anomaly detection and sprint detail integration. - Enhanced `VelocityChart` to support click events for sprint details, improving user interaction. - Added `FilterBar` and `AnomalyDetectionPanel` components to `JiraDashboardPageClient` for advanced filtering capabilities. - Implemented state management for selected sprints and modal display for detailed sprint information. - Introduced new types for advanced filtering in `types.ts`, expanding the filtering options available in the analytics.
This commit is contained in:
320
services/jira-advanced-filters.ts
Normal file
320
services/jira-advanced-filters.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Service pour les filtres avancés Jira
|
||||
* Gère le filtrage par composant, version, type de ticket, etc.
|
||||
*/
|
||||
|
||||
import { JiraTask, JiraAnalytics, JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
||||
|
||||
export class JiraAdvancedFiltersService {
|
||||
|
||||
/**
|
||||
* Extrait toutes les options de filtrage disponibles depuis les données
|
||||
*/
|
||||
static extractAvailableFilters(issues: JiraTask[]): AvailableFilters {
|
||||
const componentCounts = new Map<string, number>();
|
||||
const fixVersionCounts = new Map<string, number>();
|
||||
const issueTypeCounts = new Map<string, number>();
|
||||
const statusCounts = new Map<string, number>();
|
||||
const assigneeCounts = new Map<string, number>();
|
||||
const labelCounts = new Map<string, number>();
|
||||
const priorityCounts = new Map<string, number>();
|
||||
|
||||
issues.forEach(issue => {
|
||||
// Components
|
||||
if (issue.components) {
|
||||
issue.components.forEach(component => {
|
||||
componentCounts.set(component.name, (componentCounts.get(component.name) || 0) + 1);
|
||||
});
|
||||
}
|
||||
|
||||
// Fix Versions
|
||||
if (issue.fixVersions) {
|
||||
issue.fixVersions.forEach(version => {
|
||||
fixVersionCounts.set(version.name, (fixVersionCounts.get(version.name) || 0) + 1);
|
||||
});
|
||||
}
|
||||
|
||||
// Issue Types
|
||||
issueTypeCounts.set(issue.issuetype.name, (issueTypeCounts.get(issue.issuetype.name) || 0) + 1);
|
||||
|
||||
// Statuses
|
||||
statusCounts.set(issue.status.name, (statusCounts.get(issue.status.name) || 0) + 1);
|
||||
|
||||
// Assignees
|
||||
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
||||
assigneeCounts.set(assigneeName, (assigneeCounts.get(assigneeName) || 0) + 1);
|
||||
|
||||
// Labels
|
||||
issue.labels.forEach(label => {
|
||||
labelCounts.set(label, (labelCounts.get(label) || 0) + 1);
|
||||
});
|
||||
|
||||
// Priorities
|
||||
if (issue.priority) {
|
||||
priorityCounts.set(issue.priority.name, (priorityCounts.get(issue.priority.name) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
components: this.mapToFilterOptions(componentCounts),
|
||||
fixVersions: this.mapToFilterOptions(fixVersionCounts),
|
||||
issueTypes: this.mapToFilterOptions(issueTypeCounts),
|
||||
statuses: this.mapToFilterOptions(statusCounts),
|
||||
assignees: this.mapToFilterOptions(assigneeCounts),
|
||||
labels: this.mapToFilterOptions(labelCounts),
|
||||
priorities: this.mapToFilterOptions(priorityCounts)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique les filtres aux données analytics
|
||||
*/
|
||||
static applyFiltersToAnalytics(analytics: JiraAnalytics, filters: Partial<JiraAnalyticsFilters>, allIssues: JiraTask[]): JiraAnalytics {
|
||||
// Filtrer les issues d'abord
|
||||
const filteredIssues = this.filterIssues(allIssues, filters);
|
||||
|
||||
// Recalculer les métriques avec les issues filtrées
|
||||
return this.recalculateAnalytics(analytics, filteredIssues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre la liste des issues selon les critères
|
||||
*/
|
||||
static filterIssues(issues: JiraTask[], filters: Partial<JiraAnalyticsFilters>): JiraTask[] {
|
||||
return issues.filter(issue => {
|
||||
// Filtrage par composants
|
||||
if (filters.components && filters.components.length > 0) {
|
||||
const issueComponents = issue.components?.map(c => c.name) || [];
|
||||
if (!filters.components.some(comp => issueComponents.includes(comp))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par versions
|
||||
if (filters.fixVersions && filters.fixVersions.length > 0) {
|
||||
const issueVersions = issue.fixVersions?.map(v => v.name) || [];
|
||||
if (!filters.fixVersions.some(version => issueVersions.includes(version))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par types
|
||||
if (filters.issueTypes && filters.issueTypes.length > 0) {
|
||||
if (!filters.issueTypes.includes(issue.issuetype.name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par statuts
|
||||
if (filters.statuses && filters.statuses.length > 0) {
|
||||
if (!filters.statuses.includes(issue.status.name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par assignees
|
||||
if (filters.assignees && filters.assignees.length > 0) {
|
||||
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
||||
if (!filters.assignees.includes(assigneeName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par labels
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
if (!filters.labels.some(label => issue.labels.includes(label))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par priorités
|
||||
if (filters.priorities && filters.priorities.length > 0) {
|
||||
const priorityName = issue.priority?.name;
|
||||
if (!priorityName || !filters.priorities.includes(priorityName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrage par date
|
||||
if (filters.dateRange) {
|
||||
const issueDate = new Date(issue.created);
|
||||
if (issueDate < filters.dateRange.from || issueDate > filters.dateRange.to) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule les analytics avec un subset d'issues filtrées
|
||||
*/
|
||||
private static recalculateAnalytics(originalAnalytics: JiraAnalytics, filteredIssues: JiraTask[]): JiraAnalytics {
|
||||
// Pour une implémentation complète, il faudrait recalculer toutes les métriques
|
||||
// Ici on fait une version simplifiée qui garde la structure mais met à jour les counts
|
||||
|
||||
const totalFilteredIssues = filteredIssues.length;
|
||||
|
||||
// Calculer la nouvelle distribution par assignee
|
||||
const assigneeMap = new Map<string, { completed: number; inProgress: number; total: number }>();
|
||||
|
||||
filteredIssues.forEach(issue => {
|
||||
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
||||
const current = assigneeMap.get(assigneeName) || { completed: 0, inProgress: 0, total: 0 };
|
||||
current.total++;
|
||||
|
||||
if (issue.status.category === 'Done') {
|
||||
current.completed++;
|
||||
} else if (issue.status.category === 'In Progress') {
|
||||
current.inProgress++;
|
||||
}
|
||||
|
||||
assigneeMap.set(assigneeName, current);
|
||||
});
|
||||
|
||||
const newIssuesDistribution = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({
|
||||
assignee: assignee === 'Non assigné' ? '' : assignee,
|
||||
displayName: assignee,
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
inProgressIssues: stats.inProgress,
|
||||
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0
|
||||
}));
|
||||
|
||||
// Calculer la nouvelle distribution par statut
|
||||
const statusMap = new Map<string, number>();
|
||||
filteredIssues.forEach(issue => {
|
||||
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1);
|
||||
});
|
||||
|
||||
const newStatusDistribution = Array.from(statusMap.entries()).map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
percentage: totalFilteredIssues > 0 ? (count / totalFilteredIssues) * 100 : 0
|
||||
}));
|
||||
|
||||
// Calculer la nouvelle charge par assignee
|
||||
const newAssigneeWorkload = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({
|
||||
assignee: assignee === 'Non assigné' ? '' : assignee,
|
||||
displayName: assignee,
|
||||
todoCount: stats.total - stats.completed - stats.inProgress,
|
||||
inProgressCount: stats.inProgress,
|
||||
reviewCount: 0, // Simplified
|
||||
totalActive: stats.total - stats.completed
|
||||
}));
|
||||
|
||||
return {
|
||||
...originalAnalytics,
|
||||
project: {
|
||||
...originalAnalytics.project,
|
||||
totalIssues: totalFilteredIssues
|
||||
},
|
||||
teamMetrics: {
|
||||
...originalAnalytics.teamMetrics,
|
||||
issuesDistribution: newIssuesDistribution
|
||||
},
|
||||
workInProgress: {
|
||||
byStatus: newStatusDistribution,
|
||||
byAssignee: newAssigneeWorkload
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une Map de counts en options de filtre triées
|
||||
*/
|
||||
private static mapToFilterOptions(countMap: Map<string, number>): FilterOption[] {
|
||||
return Array.from(countMap.entries())
|
||||
.map(([value, count]) => ({
|
||||
value,
|
||||
label: value,
|
||||
count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count); // Trier par count décroissant
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un filtre vide
|
||||
*/
|
||||
static createEmptyFilters(): JiraAnalyticsFilters {
|
||||
return {
|
||||
components: [],
|
||||
fixVersions: [],
|
||||
issueTypes: [],
|
||||
statuses: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
priorities: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si des filtres sont actifs
|
||||
*/
|
||||
static hasActiveFilters(filters: Partial<JiraAnalyticsFilters>): boolean {
|
||||
return !!(
|
||||
filters.components?.length ||
|
||||
filters.fixVersions?.length ||
|
||||
filters.issueTypes?.length ||
|
||||
filters.statuses?.length ||
|
||||
filters.assignees?.length ||
|
||||
filters.labels?.length ||
|
||||
filters.priorities?.length ||
|
||||
filters.dateRange
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre total de filtres actifs
|
||||
*/
|
||||
static countActiveFilters(filters: Partial<JiraAnalyticsFilters>): number {
|
||||
let count = 0;
|
||||
if (filters.components?.length) count += filters.components.length;
|
||||
if (filters.fixVersions?.length) count += filters.fixVersions.length;
|
||||
if (filters.issueTypes?.length) count += filters.issueTypes.length;
|
||||
if (filters.statuses?.length) count += filters.statuses.length;
|
||||
if (filters.assignees?.length) count += filters.assignees.length;
|
||||
if (filters.labels?.length) count += filters.labels.length;
|
||||
if (filters.priorities?.length) count += filters.priorities.length;
|
||||
if (filters.dateRange) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un résumé textuel des filtres actifs
|
||||
*/
|
||||
static getFiltersSummary(filters: Partial<JiraAnalyticsFilters>): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (filters.components?.length) {
|
||||
parts.push(`${filters.components.length} composant${filters.components.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.fixVersions?.length) {
|
||||
parts.push(`${filters.fixVersions.length} version${filters.fixVersions.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.issueTypes?.length) {
|
||||
parts.push(`${filters.issueTypes.length} type${filters.issueTypes.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.statuses?.length) {
|
||||
parts.push(`${filters.statuses.length} statut${filters.statuses.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.assignees?.length) {
|
||||
parts.push(`${filters.assignees.length} assigné${filters.assignees.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.labels?.length) {
|
||||
parts.push(`${filters.labels.length} label${filters.labels.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.priorities?.length) {
|
||||
parts.push(`${filters.priorities.length} priorité${filters.priorities.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (filters.dateRange) {
|
||||
parts.push('période personnalisée');
|
||||
}
|
||||
|
||||
if (parts.length === 0) return 'Aucun filtre actif';
|
||||
if (parts.length === 1) return `Filtré par ${parts[0]}`;
|
||||
if (parts.length === 2) return `Filtré par ${parts[0]} et ${parts[1]}`;
|
||||
return `Filtré par ${parts.slice(0, -1).join(', ')} et ${parts[parts.length - 1]}`;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ class JiraAnalyticsCacheService {
|
||||
totalEntries: number;
|
||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
||||
} {
|
||||
const projects = Array.from(this.cache.entries()).map(([key, entry]) => ({
|
||||
const projects = Array.from(this.cache.entries()).map(([, entry]) => ({
|
||||
projectKey: entry.projectKey,
|
||||
age: this.getAgeDescription(entry.timestamp),
|
||||
size: JSON.stringify(entry.data).length
|
||||
|
||||
@@ -33,6 +33,21 @@ export class JiraAnalyticsService {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les issues du projet pour filtrage
|
||||
*/
|
||||
async getAllProjectIssues(): Promise<JiraTask[]> {
|
||||
try {
|
||||
const jql = `project = "${this.projectKey}" ORDER BY created DESC`;
|
||||
const issues = await this.jiraService.searchIssues(jql);
|
||||
console.log(`📋 Récupéré ${issues.length} issues pour filtrage`);
|
||||
return issues;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des issues:', error);
|
||||
throw new Error(`Impossible de récupérer les issues: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les analytics du projet avec cache
|
||||
*/
|
||||
@@ -109,24 +124,6 @@ export class JiraAnalyticsService {
|
||||
return { name: validation.name || this.projectKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur)
|
||||
*/
|
||||
private async getAllProjectIssues(): Promise<JiraTask[]> {
|
||||
try {
|
||||
const jql = `project = "${this.projectKey}" ORDER BY created DESC`;
|
||||
|
||||
// Utiliser la nouvelle méthode searchIssues qui gère la pagination correctement
|
||||
const jiraTasks = await this.jiraService.searchIssues(jql);
|
||||
|
||||
// Retourner les tâches mappées (elles sont déjà converties par searchIssues)
|
||||
return jiraTasks;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des tickets du projet:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques d'équipe (répartition par assignee)
|
||||
|
||||
297
services/jira-anomaly-detection.ts
Normal file
297
services/jira-anomaly-detection.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Service de détection d'anomalies dans les métriques Jira
|
||||
* Analyse les patterns et tendances pour identifier des problèmes potentiels
|
||||
*/
|
||||
|
||||
import { JiraAnalytics, SprintVelocity, CycleTimeByType, AssigneeWorkload } from '@/lib/types';
|
||||
|
||||
export interface JiraAnomaly {
|
||||
id: string;
|
||||
type: 'velocity' | 'cycle_time' | 'workload' | 'completion' | 'blockers';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
title: string;
|
||||
description: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
recommendation: string;
|
||||
affectedItems: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnomalyDetectionConfig {
|
||||
velocityVarianceThreshold: number; // % de variance acceptable
|
||||
cycleTimeThreshold: number; // multiplicateur du cycle time moyen
|
||||
workloadImbalanceThreshold: number; // ratio max entre assignees
|
||||
completionRateThreshold: number; // % minimum de completion
|
||||
stalledItemsThreshold: number; // jours sans changement
|
||||
}
|
||||
|
||||
export class JiraAnomalyDetectionService {
|
||||
private readonly defaultConfig: AnomalyDetectionConfig = {
|
||||
velocityVarianceThreshold: 30, // 30% de variance
|
||||
cycleTimeThreshold: 2.0, // 2x le cycle time moyen
|
||||
workloadImbalanceThreshold: 3.0, // 3:1 ratio max
|
||||
completionRateThreshold: 70, // 70% completion minimum
|
||||
stalledItemsThreshold: 7 // 7 jours
|
||||
};
|
||||
|
||||
constructor(private config: Partial<AnomalyDetectionConfig> = {}) {
|
||||
this.config = { ...this.defaultConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse toutes les métriques et détecte les anomalies
|
||||
*/
|
||||
async detectAnomalies(analytics: JiraAnalytics): Promise<JiraAnomaly[]> {
|
||||
const anomalies: JiraAnomaly[] = [];
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 1. Détection d'anomalies de vélocité
|
||||
const velocityAnomalies = this.detectVelocityAnomalies(analytics.velocityMetrics, timestamp);
|
||||
anomalies.push(...velocityAnomalies);
|
||||
|
||||
// 2. Détection d'anomalies de cycle time
|
||||
const cycleTimeAnomalies = this.detectCycleTimeAnomalies(analytics.cycleTimeMetrics, timestamp);
|
||||
anomalies.push(...cycleTimeAnomalies);
|
||||
|
||||
// 3. Détection de déséquilibres de charge
|
||||
const workloadAnomalies = this.detectWorkloadAnomalies(analytics.workInProgress.byAssignee, timestamp);
|
||||
anomalies.push(...workloadAnomalies);
|
||||
|
||||
// 4. Détection de problèmes de completion
|
||||
const completionAnomalies = this.detectCompletionAnomalies(analytics.velocityMetrics, timestamp);
|
||||
anomalies.push(...completionAnomalies);
|
||||
|
||||
// Trier par sévérité
|
||||
return anomalies.sort((a, b) => this.getSeverityWeight(b.severity) - this.getSeverityWeight(a.severity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte les anomalies de vélocité (variance excessive, tendance négative)
|
||||
*/
|
||||
private detectVelocityAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[]; averageVelocity: number }, timestamp: string): JiraAnomaly[] {
|
||||
const anomalies: JiraAnomaly[] = [];
|
||||
const { sprintHistory, averageVelocity } = velocityMetrics;
|
||||
|
||||
if (sprintHistory.length < 3) return anomalies;
|
||||
|
||||
// Calcul de la variance de vélocité
|
||||
const velocities = sprintHistory.map((s: SprintVelocity) => s.completedPoints);
|
||||
const variance = this.calculateVariance(velocities);
|
||||
const variancePercent = (Math.sqrt(variance) / averageVelocity) * 100;
|
||||
|
||||
if (variancePercent > (this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold)) {
|
||||
anomalies.push({
|
||||
id: `velocity-variance-${Date.now()}`,
|
||||
type: 'velocity',
|
||||
severity: variancePercent > 50 ? 'high' : 'medium',
|
||||
title: 'Vélocité très variable',
|
||||
description: `La vélocité de l'équipe varie de ${variancePercent.toFixed(1)}% autour de la moyenne`,
|
||||
value: variancePercent,
|
||||
threshold: this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold,
|
||||
recommendation: 'Analysez les facteurs causant cette instabilité : estimation, complexité, blockers',
|
||||
affectedItems: sprintHistory.slice(-3).map((s: SprintVelocity) => s.sprintName),
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
// Détection de tendance décroissante
|
||||
const recentSprints = sprintHistory.slice(-3);
|
||||
const isDecreasing = recentSprints.every((sprint: SprintVelocity, i: number) =>
|
||||
i === 0 || sprint.completedPoints < recentSprints[i - 1].completedPoints
|
||||
);
|
||||
|
||||
if (isDecreasing && recentSprints.length >= 3) {
|
||||
const decline = ((recentSprints[0].completedPoints - recentSprints[recentSprints.length - 1].completedPoints) / recentSprints[0].completedPoints) * 100;
|
||||
|
||||
anomalies.push({
|
||||
id: `velocity-decline-${Date.now()}`,
|
||||
type: 'velocity',
|
||||
severity: decline > 30 ? 'critical' : 'high',
|
||||
title: 'Vélocité en déclin',
|
||||
description: `La vélocité a diminué de ${decline.toFixed(1)}% sur les 3 derniers sprints`,
|
||||
value: decline,
|
||||
threshold: 0,
|
||||
recommendation: 'Identifiez les causes : technical debt, complexité croissante, ou problèmes d\'équipe',
|
||||
affectedItems: recentSprints.map((s: SprintVelocity) => s.sprintName),
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte les anomalies de cycle time (temps excessifs, types problématiques)
|
||||
*/
|
||||
private detectCycleTimeAnomalies(cycleTimeMetrics: { averageCycleTime: number; cycleTimeByType: CycleTimeByType[] }, timestamp: string): JiraAnomaly[] {
|
||||
const anomalies: JiraAnomaly[] = [];
|
||||
const { averageCycleTime, cycleTimeByType } = cycleTimeMetrics;
|
||||
|
||||
// Détection des types avec cycle time excessif
|
||||
cycleTimeByType.forEach((typeMetrics: CycleTimeByType) => {
|
||||
const ratio = typeMetrics.averageDays / averageCycleTime;
|
||||
|
||||
if (ratio > (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold)) {
|
||||
anomalies.push({
|
||||
id: `cycle-time-${typeMetrics.issueType}-${Date.now()}`,
|
||||
type: 'cycle_time',
|
||||
severity: ratio > 3 ? 'high' : 'medium',
|
||||
title: `Cycle time excessif - ${typeMetrics.issueType}`,
|
||||
description: `Le type "${typeMetrics.issueType}" prend ${ratio.toFixed(1)}x plus de temps que la moyenne`,
|
||||
value: typeMetrics.averageDays,
|
||||
threshold: averageCycleTime * (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold),
|
||||
recommendation: 'Analysez les blockers spécifiques à ce type de ticket',
|
||||
affectedItems: [typeMetrics.issueType],
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Détection cycle time global excessif (> 14 jours)
|
||||
if (averageCycleTime > 14) {
|
||||
anomalies.push({
|
||||
id: `global-cycle-time-${Date.now()}`,
|
||||
type: 'cycle_time',
|
||||
severity: averageCycleTime > 21 ? 'critical' : 'high',
|
||||
title: 'Cycle time global élevé',
|
||||
description: `Le cycle time moyen de ${averageCycleTime.toFixed(1)} jours est préoccupant`,
|
||||
value: averageCycleTime,
|
||||
threshold: 14,
|
||||
recommendation: 'Réduisez la taille des tâches et identifiez les goulots d\'étranglement',
|
||||
affectedItems: ['Projet global'],
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte les déséquilibres de charge de travail
|
||||
*/
|
||||
private detectWorkloadAnomalies(assigneeWorkloads: AssigneeWorkload[], timestamp: string): JiraAnomaly[] {
|
||||
const anomalies: JiraAnomaly[] = [];
|
||||
|
||||
if (assigneeWorkloads.length < 2) return anomalies;
|
||||
|
||||
const workloads = assigneeWorkloads.map(a => a.totalActive);
|
||||
const maxWorkload = Math.max(...workloads);
|
||||
const minWorkload = Math.min(...workloads.filter(w => w > 0));
|
||||
|
||||
if (minWorkload === 0) return anomalies; // Éviter division par zéro
|
||||
|
||||
const imbalanceRatio = maxWorkload / minWorkload;
|
||||
|
||||
if (imbalanceRatio > (this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold)) {
|
||||
const overloadedMember = assigneeWorkloads.find(a => a.totalActive === maxWorkload);
|
||||
const underloadedMember = assigneeWorkloads.find(a => a.totalActive === minWorkload);
|
||||
|
||||
anomalies.push({
|
||||
id: `workload-imbalance-${Date.now()}`,
|
||||
type: 'workload',
|
||||
severity: imbalanceRatio > 5 ? 'high' : 'medium',
|
||||
title: 'Déséquilibre de charge',
|
||||
description: `Ratio de ${imbalanceRatio.toFixed(1)}:1 entre membres les plus/moins chargés`,
|
||||
value: imbalanceRatio,
|
||||
threshold: this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold,
|
||||
recommendation: 'Redistribuez les tâches pour équilibrer la charge de travail',
|
||||
affectedItems: [
|
||||
`Surchargé: ${overloadedMember?.displayName} (${maxWorkload} tâches)`,
|
||||
`Sous-chargé: ${underloadedMember?.displayName} (${minWorkload} tâches)`
|
||||
],
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
// Détection de membres avec trop de tâches en cours
|
||||
assigneeWorkloads.forEach(assignee => {
|
||||
if (assignee.inProgressCount > 5) {
|
||||
anomalies.push({
|
||||
id: `wip-limit-${assignee.assignee}-${Date.now()}`,
|
||||
type: 'workload',
|
||||
severity: assignee.inProgressCount > 8 ? 'high' : 'medium',
|
||||
title: 'WIP limite dépassée',
|
||||
description: `${assignee.displayName} a ${assignee.inProgressCount} tâches en cours`,
|
||||
value: assignee.inProgressCount,
|
||||
threshold: 5,
|
||||
recommendation: 'Limitez le WIP à 3-5 tâches par personne pour améliorer le focus',
|
||||
affectedItems: [assignee.displayName],
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte les problèmes de completion rate
|
||||
*/
|
||||
private detectCompletionAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[] }, timestamp: string): JiraAnomaly[] {
|
||||
const anomalies: JiraAnomaly[] = [];
|
||||
const { sprintHistory } = velocityMetrics;
|
||||
|
||||
if (sprintHistory.length === 0) return anomalies;
|
||||
|
||||
// Analyse des 3 derniers sprints
|
||||
const recentSprints = sprintHistory.slice(-3);
|
||||
const avgCompletionRate = recentSprints.reduce((sum: number, sprint: SprintVelocity) =>
|
||||
sum + sprint.completionRate, 0) / recentSprints.length;
|
||||
|
||||
if (avgCompletionRate < (this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold)) {
|
||||
anomalies.push({
|
||||
id: `low-completion-rate-${Date.now()}`,
|
||||
type: 'completion',
|
||||
severity: avgCompletionRate < 50 ? 'critical' : 'high',
|
||||
title: 'Taux de completion faible',
|
||||
description: `Taux de completion moyen de ${avgCompletionRate.toFixed(1)}% sur les derniers sprints`,
|
||||
value: avgCompletionRate,
|
||||
threshold: this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold,
|
||||
recommendation: 'Revoyez la planification et l\'estimation des sprints',
|
||||
affectedItems: recentSprints.map((s: SprintVelocity) => `${s.sprintName}: ${s.completionRate.toFixed(1)}%`),
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la variance d'un tableau de nombres
|
||||
*/
|
||||
private calculateVariance(numbers: number[]): number {
|
||||
const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
|
||||
const squaredDiffs = numbers.map(num => Math.pow(num - mean, 2));
|
||||
return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / numbers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le poids numérique d'une sévérité pour le tri
|
||||
*/
|
||||
private getSeverityWeight(severity: string): number {
|
||||
switch (severity) {
|
||||
case 'critical': return 4;
|
||||
case 'high': return 3;
|
||||
case 'medium': return 2;
|
||||
case 'low': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration de détection
|
||||
*/
|
||||
updateConfig(newConfig: Partial<AnomalyDetectionConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la configuration actuelle
|
||||
*/
|
||||
getConfig(): AnomalyDetectionConfig {
|
||||
return { ...this.defaultConfig, ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
export const jiraAnomalyDetection = new JiraAnomalyDetectionService();
|
||||
@@ -115,7 +115,7 @@ export class JiraService {
|
||||
*/
|
||||
async searchIssues(jql: string): Promise<JiraTask[]> {
|
||||
try {
|
||||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'duedate', 'created', 'updated', 'labels'];
|
||||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels'];
|
||||
|
||||
const allIssues: unknown[] = [];
|
||||
let nextPageToken: string | undefined = undefined;
|
||||
|
||||
Reference in New Issue
Block a user