Spaces:
Runtime error
Runtime error
Commit
·
a7174ff
0
Parent(s):
Initial commit
Browse files- .gitignore +9 -0
- README.md +0 -0
- app.py +595 -0
- config.json +13 -0
- config.py +88 -0
- modules/ai_analysis/llm_connector.py +464 -0
- modules/core/app_manager.py +648 -0
- modules/data_analysis/statistics.py +266 -0
- modules/data_analysis/visualizations.py +450 -0
- modules/data_import/csv_importer.py +217 -0
- modules/data_import/jira_api.py +384 -0
- modules/reporting/report_generator.py +370 -0
- reports/jira_report_20250228_094906.md +54 -0
- requirements.txt +9 -0
- temp/temp_20250228_093950.csv +0 -0
- temp/temp_20250228_094358.csv +0 -0
.gitignore
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
venv/
|
2 |
+
__pycache__/
|
3 |
+
*.py[cod]
|
4 |
+
*.class
|
5 |
+
.env
|
6 |
+
.DS_Store
|
7 |
+
.idea/
|
8 |
+
.vscode/
|
9 |
+
*.log
|
README.md
ADDED
File without changes
|
app.py
ADDED
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import gradio as gr
|
3 |
+
from datetime import datetime
|
4 |
+
import pandas as pd
|
5 |
+
import numpy as np
|
6 |
+
import matplotlib.pyplot as plt
|
7 |
+
import seaborn as sns
|
8 |
+
from pathlib import Path
|
9 |
+
import tempfile
|
10 |
+
import traceback
|
11 |
+
import logging
|
12 |
+
|
13 |
+
# Налаштування логування
|
14 |
+
logging.basicConfig(
|
15 |
+
level=logging.INFO,
|
16 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
17 |
+
handlers=[
|
18 |
+
logging.FileHandler("jira_assistant.log"),
|
19 |
+
logging.StreamHandler()
|
20 |
+
]
|
21 |
+
)
|
22 |
+
logger = logging.getLogger("jira_assistant")
|
23 |
+
|
24 |
+
# Створення необхідних директорій
|
25 |
+
for directory in ["data", "reports", "temp", "logs"]:
|
26 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
27 |
+
|
28 |
+
# Клас для аналізу даних Jira
|
29 |
+
class JiraAnalyzer:
|
30 |
+
def __init__(self):
|
31 |
+
self.df = None
|
32 |
+
self.stats = None
|
33 |
+
self.inactive_issues = None
|
34 |
+
|
35 |
+
def load_csv(self, file_path):
|
36 |
+
"""Завантаження даних з CSV-файлу"""
|
37 |
+
try:
|
38 |
+
logger.info(f"Завантаження CSV-файлу: {file_path}")
|
39 |
+
|
40 |
+
# Спробуємо різні кодування
|
41 |
+
try:
|
42 |
+
self.df = pd.read_csv(file_path, encoding='utf-8')
|
43 |
+
except UnicodeDecodeError:
|
44 |
+
logger.warning("Помилка декодування UTF-8, спроба з latin1")
|
45 |
+
self.df = pd.read_csv(file_path, encoding='latin1')
|
46 |
+
|
47 |
+
logger.info(f"Успішно завантажено {len(self.df)} записів")
|
48 |
+
return self.df
|
49 |
+
except Exception as e:
|
50 |
+
logger.error(f"Помилка при завантаженні CSV-файлу: {e}")
|
51 |
+
raise
|
52 |
+
|
53 |
+
def process_dates(self):
|
54 |
+
"""Обробка дат у DataFrame"""
|
55 |
+
date_columns = ['Created', 'Updated', 'Resolved', 'Due Date']
|
56 |
+
|
57 |
+
for col in date_columns:
|
58 |
+
if col in self.df.columns:
|
59 |
+
try:
|
60 |
+
self.df[col] = pd.to_datetime(self.df[col], errors='coerce')
|
61 |
+
except Exception as e:
|
62 |
+
logger.warning(f"Не вдалося конвертувати колонку {col} до datetime: {e}")
|
63 |
+
|
64 |
+
def prepare_data(self):
|
65 |
+
"""Підготовка даних для аналізу"""
|
66 |
+
# Обробка дат
|
67 |
+
self.process_dates()
|
68 |
+
|
69 |
+
# Додаткова обробка
|
70 |
+
if 'Status' in self.df.columns:
|
71 |
+
self.df['Status'] = self.df['Status'].fillna('Unknown')
|
72 |
+
|
73 |
+
if 'Priority' in self.df.columns:
|
74 |
+
self.df['Priority'] = self.df['Priority'].fillna('Not set')
|
75 |
+
|
76 |
+
# Створення додаткових колонок
|
77 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
78 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
79 |
+
self.df['Created_Month'] = self.df['Created'].dt.to_period('M')
|
80 |
+
|
81 |
+
if 'Updated' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
82 |
+
self.df['Updated_Date'] = self.df['Updated'].dt.date
|
83 |
+
self.df['Days_Since_Update'] = (datetime.now() - self.df['Updated']).dt.days
|
84 |
+
|
85 |
+
def generate_stats(self):
|
86 |
+
"""Генерація базової статистики"""
|
87 |
+
self.stats = {
|
88 |
+
'total_tickets': len(self.df),
|
89 |
+
'status_counts': {},
|
90 |
+
'type_counts': {},
|
91 |
+
'priority_counts': {}
|
92 |
+
}
|
93 |
+
|
94 |
+
# Статистика за статусами
|
95 |
+
if 'Status' in self.df.columns:
|
96 |
+
self.stats['status_counts'] = self.df['Status'].value_counts().to_dict()
|
97 |
+
|
98 |
+
# Статистика за типами
|
99 |
+
if 'Issue Type' in self.df.columns:
|
100 |
+
self.stats['type_counts'] = self.df['Issue Type'].value_counts().to_dict()
|
101 |
+
|
102 |
+
# Статистика за пріоритетами
|
103 |
+
if 'Priority' in self.df.columns:
|
104 |
+
self.stats['priority_counts'] = self.df['Priority'].value_counts().to_dict()
|
105 |
+
|
106 |
+
return self.stats
|
107 |
+
|
108 |
+
def find_inactive_issues(self, days=14):
|
109 |
+
"""Аналіз неактивних тікетів"""
|
110 |
+
self.inactive_issues = {
|
111 |
+
'total_count': 0,
|
112 |
+
'percentage': 0,
|
113 |
+
'by_status': {},
|
114 |
+
'top_inactive': []
|
115 |
+
}
|
116 |
+
|
117 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
118 |
+
return self.inactive_issues
|
119 |
+
|
120 |
+
# Визначення неактивних тікетів
|
121 |
+
cutoff_date = datetime.now() - pd.Timedelta(days=days)
|
122 |
+
inactive = self.df[self.df['Updated'] < cutoff_date]
|
123 |
+
|
124 |
+
if len(inactive) == 0:
|
125 |
+
return self.inactive_issues
|
126 |
+
|
127 |
+
self.inactive_issues['total_count'] = len(inactive)
|
128 |
+
self.inactive_issues['percentage'] = round(len(inactive) / len(self.df) * 100, 2) if len(self.df) > 0 else 0
|
129 |
+
|
130 |
+
# Розподіл за статусами
|
131 |
+
if 'Status' in inactive.columns:
|
132 |
+
self.inactive_issues['by_status'] = inactive['Status'].value_counts().to_dict()
|
133 |
+
|
134 |
+
# Топ 5 неактивних тікетів
|
135 |
+
top_inactive = []
|
136 |
+
for _, row in inactive.sort_values('Updated', ascending=True).head(5).iterrows():
|
137 |
+
issue_data = {
|
138 |
+
'key': row.get('Issue key', 'Невідомо'),
|
139 |
+
'summary': row.get('Summary', 'Невідомо'),
|
140 |
+
'status': row.get('Status', 'Невідомо'),
|
141 |
+
'days_inactive': (datetime.now() - row['Updated']).days if pd.notna(row['Updated']) else 'Невідомо'
|
142 |
+
}
|
143 |
+
top_inactive.append(issue_data)
|
144 |
+
|
145 |
+
self.inactive_issues['top_inactive'] = top_inactive
|
146 |
+
return self.inactive_issues
|
147 |
+
|
148 |
+
def plot_status_counts(self):
|
149 |
+
"""Діаграма розподілу за статусами"""
|
150 |
+
if 'Status' not in self.df.columns:
|
151 |
+
return None
|
152 |
+
|
153 |
+
status_counts = self.df['Status'].value_counts()
|
154 |
+
|
155 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
156 |
+
bars = sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax)
|
157 |
+
|
158 |
+
# Додаємо підписи значень над стовпцями
|
159 |
+
for i, v in enumerate(status_counts.values):
|
160 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
161 |
+
|
162 |
+
ax.set_title('Розподіл тікетів за статусами')
|
163 |
+
ax.set_xlabel('Статус')
|
164 |
+
ax.set_ylabel('Кількість')
|
165 |
+
plt.xticks(rotation=45)
|
166 |
+
plt.tight_layout()
|
167 |
+
|
168 |
+
return fig
|
169 |
+
|
170 |
+
def plot_priority_counts(self):
|
171 |
+
"""Діаграма розподілу за пріоритетами"""
|
172 |
+
if 'Priority' not in self.df.columns:
|
173 |
+
return None
|
174 |
+
|
175 |
+
priority_counts = self.df['Priority'].value_counts()
|
176 |
+
|
177 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
178 |
+
bars = sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax)
|
179 |
+
|
180 |
+
# Додаємо підписи значень над стовпцями
|
181 |
+
for i, v in enumerate(priority_counts.values):
|
182 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
183 |
+
|
184 |
+
ax.set_title('Розподіл тікетів за пріоритетами')
|
185 |
+
ax.set_xlabel('Пріоритет')
|
186 |
+
ax.set_ylabel('Кількість')
|
187 |
+
plt.xticks(rotation=45)
|
188 |
+
plt.tight_layout()
|
189 |
+
|
190 |
+
return fig
|
191 |
+
|
192 |
+
def plot_created_timeline(self):
|
193 |
+
"""Часова діаграма створення тікетів"""
|
194 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
195 |
+
return None
|
196 |
+
|
197 |
+
# Додаємо колонку з датою створення (без часу)
|
198 |
+
if 'Created_Date' not in self.df.columns:
|
199 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
200 |
+
|
201 |
+
# Кількість створених тікетів за датами
|
202 |
+
created_by_date = self.df['Created_Date'].value_counts().sort_index()
|
203 |
+
|
204 |
+
# Створення діаграми
|
205 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
206 |
+
created_by_date.plot(kind='line', marker='o', ax=ax)
|
207 |
+
|
208 |
+
ax.set_title('Кількість створених тікетів за датами')
|
209 |
+
ax.set_xlabel('Дата')
|
210 |
+
ax.set_ylabel('Кількість')
|
211 |
+
ax.grid(True)
|
212 |
+
plt.tight_layout()
|
213 |
+
|
214 |
+
return fig
|
215 |
+
|
216 |
+
def generate_report(self, inactive_days=14):
|
217 |
+
"""Генерація звіту"""
|
218 |
+
report = []
|
219 |
+
|
220 |
+
# Заголовок звіту
|
221 |
+
report.append("# Звіт аналізу Jira")
|
222 |
+
report.append(f"*Створено: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
223 |
+
|
224 |
+
# Загальна статистика
|
225 |
+
report.append("\n## Загальна статистика")
|
226 |
+
report.append(f"**Загальна кількість тікетів:** {len(self.df)}")
|
227 |
+
|
228 |
+
# Статистика за статусами
|
229 |
+
if 'Status' in self.df.columns:
|
230 |
+
status_counts = self.df['Status'].value_counts()
|
231 |
+
report.append("\n### Статуси тікетів")
|
232 |
+
|
233 |
+
for status, count in status_counts.items():
|
234 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
235 |
+
report.append(f"- **{status}:** {count} ({percentage:.1f}%)")
|
236 |
+
|
237 |
+
# Статистика за типами
|
238 |
+
if 'Issue Type' in self.df.columns:
|
239 |
+
type_counts = self.df['Issue Type'].value_counts()
|
240 |
+
report.append("\n### Типи тікетів")
|
241 |
+
|
242 |
+
for type_name, count in type_counts.items():
|
243 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
244 |
+
report.append(f"- **{type_name}:** {count} ({percentage:.1f}%)")
|
245 |
+
|
246 |
+
# Статистика за пріоритетами
|
247 |
+
if 'Priority' in self.df.columns:
|
248 |
+
priority_counts = self.df['Priority'].value_counts()
|
249 |
+
report.append("\n### Пріоритети тікетів")
|
250 |
+
|
251 |
+
for priority, count in priority_counts.items():
|
252 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
253 |
+
report.append(f"- **{priority}:** {count} ({percentage:.1f}%)")
|
254 |
+
|
255 |
+
# Неактивні тікети
|
256 |
+
self.find_inactive_issues(inactive_days)
|
257 |
+
|
258 |
+
if self.inactive_issues['total_count'] > 0:
|
259 |
+
report.append(f"\n## Неактивні тікети (>{inactive_days} днів)")
|
260 |
+
report.append(f"**Загальна кількість неактивних тікетів:** {self.inactive_issues['total_count']} ({self.inactive_issues['percentage']}%)")
|
261 |
+
|
262 |
+
if self.inactive_issues['by_status']:
|
263 |
+
report.append("\n**Неактивні тікети за статусами:**")
|
264 |
+
for status, count in self.inactive_issues['by_status'].items():
|
265 |
+
report.append(f"- **{status}:** {count}")
|
266 |
+
|
267 |
+
if self.inactive_issues['top_inactive']:
|
268 |
+
report.append("\n**Топ 5 найбільш неактивних тікетів:**")
|
269 |
+
for i, ticket in enumerate(self.inactive_issues['top_inactive']):
|
270 |
+
report.append(f"{i+1}. **{ticket['key']}:** {ticket['summary']}")
|
271 |
+
report.append(f" - Статус: {ticket['status']}")
|
272 |
+
report.append(f" - Днів неактивності: {ticket['days_inactive']}")
|
273 |
+
|
274 |
+
return "\n".join(report)
|
275 |
+
|
276 |
+
# Виправлена функція analyze_csv
|
277 |
+
def analyze_csv(file_obj, inactive_days, include_ai):
|
278 |
+
if file_obj is None:
|
279 |
+
return "Помилка: файл не вибрано", None, None, None, None
|
280 |
+
|
281 |
+
try:
|
282 |
+
logger.info(f"Отримано файл: {file_obj.name}, тип: {type(file_obj)}")
|
283 |
+
|
284 |
+
# Створення тимчасового файлу
|
285 |
+
temp_file_path = os.path.join("temp", f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv")
|
286 |
+
|
287 |
+
# У Gradio 5.19.0 об'єкт файлу має різну структуру
|
288 |
+
# file_obj може бути шляхом до файлу або містити атрибут 'name'
|
289 |
+
if hasattr(file_obj, 'name'):
|
290 |
+
source_path = file_obj.name
|
291 |
+
|
292 |
+
# Копіювання файлу
|
293 |
+
import shutil
|
294 |
+
shutil.copy2(source_path, temp_file_path)
|
295 |
+
else:
|
296 |
+
# Якщо це не шлях до файлу, ймовірно це вже самі дані
|
297 |
+
with open(temp_file_path, "w", encoding="utf-8") as f:
|
298 |
+
f.write(str(file_obj))
|
299 |
+
|
300 |
+
# Аналіз даних
|
301 |
+
analyzer = JiraAnalyzer()
|
302 |
+
analyzer.load_csv(temp_file_path)
|
303 |
+
analyzer.prepare_data()
|
304 |
+
analyzer.generate_stats()
|
305 |
+
|
306 |
+
# Візуалізації
|
307 |
+
status_fig = analyzer.plot_status_counts()
|
308 |
+
priority_fig = analyzer.plot_priority_counts()
|
309 |
+
timeline_fig = analyzer.plot_created_timeline()
|
310 |
+
|
311 |
+
# Генерація звіту
|
312 |
+
report = analyzer.generate_report(inactive_days=inactive_days)
|
313 |
+
|
314 |
+
# AI аналіз (заглушка)
|
315 |
+
ai_analysis = None
|
316 |
+
if include_ai:
|
317 |
+
ai_analysis = "AI аналіз буде доступний у наступних версіях додатку."
|
318 |
+
|
319 |
+
# Видалення тимчасового файлу
|
320 |
+
try:
|
321 |
+
os.remove(temp_file_path)
|
322 |
+
except:
|
323 |
+
pass
|
324 |
+
|
325 |
+
return report, status_fig, priority_fig, timeline_fig, ai_analysis
|
326 |
+
|
327 |
+
except Exception as e:
|
328 |
+
error_msg = f"Помилка аналізу: {str(e)}\n\n{traceback.format_exc()}"
|
329 |
+
logger.error(error_msg)
|
330 |
+
return error_msg, None, None, None, None
|
331 |
+
|
332 |
+
# Функція для збереження звіту
|
333 |
+
def save_report(report_text, format_type, include_visualizations, status_fig, priority_fig, timeline_fig):
|
334 |
+
if not report_text:
|
335 |
+
return "Помилка: спочатку виконайте аналіз даних"
|
336 |
+
|
337 |
+
try:
|
338 |
+
# Створення імені файлу
|
339 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
340 |
+
report_filename = f"jira_report_{timestamp}"
|
341 |
+
reports_dir = Path("reports")
|
342 |
+
|
343 |
+
if format_type == "markdown":
|
344 |
+
filepath = reports_dir / f"{report_filename}.md"
|
345 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
346 |
+
f.write(report_text)
|
347 |
+
|
348 |
+
if include_visualizations:
|
349 |
+
# Збереження візуалізацій
|
350 |
+
charts_dir = reports_dir / f"{report_filename}_charts"
|
351 |
+
charts_dir.mkdir(exist_ok=True)
|
352 |
+
|
353 |
+
if status_fig:
|
354 |
+
status_fig.savefig(charts_dir / "status_counts.png")
|
355 |
+
if priority_fig:
|
356 |
+
priority_fig.savefig(charts_dir / "priority_counts.png")
|
357 |
+
if timeline_fig:
|
358 |
+
timeline_fig.savefig(charts_dir / "timeline.png")
|
359 |
+
|
360 |
+
elif format_type == "html":
|
361 |
+
from markdown import markdown
|
362 |
+
|
363 |
+
filepath = reports_dir / f"{report_filename}.html"
|
364 |
+
|
365 |
+
# Конвертація Markdown в HTML
|
366 |
+
html_content = f"""<!DOCTYPE html>
|
367 |
+
<html>
|
368 |
+
<head>
|
369 |
+
<meta charset="utf-8">
|
370 |
+
<title>Звіт аналізу Jira</title>
|
371 |
+
<style>
|
372 |
+
body {{ font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; }}
|
373 |
+
h1, h2, h3 {{ color: #0052CC; }}
|
374 |
+
table {{ border-collapse: collapse; width: 100%; margin-bottom: 20px; }}
|
375 |
+
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
|
376 |
+
th {{ background-color: #0052CC; color: white; }}
|
377 |
+
img {{ max-width: 100%; margin: 20px 0; }}
|
378 |
+
</style>
|
379 |
+
</head>
|
380 |
+
<body>
|
381 |
+
{markdown(report_text)}
|
382 |
+
"""
|
383 |
+
|
384 |
+
if include_visualizations and (status_fig or priority_fig or timeline_fig):
|
385 |
+
# Збереження візуалізацій
|
386 |
+
charts_dir = reports_dir / f"{report_filename}_charts"
|
387 |
+
charts_dir.mkdir(exist_ok=True)
|
388 |
+
|
389 |
+
html_content += "<h2>Візуалізації</h2>"
|
390 |
+
|
391 |
+
if status_fig:
|
392 |
+
status_path = charts_dir / "status_counts.png"
|
393 |
+
status_fig.savefig(status_path)
|
394 |
+
html_content += f'<div><h3>Статуси тікетів</h3><img src="{status_path.relative_to(reports_dir)}" alt="Статуси тікетів"></div>'
|
395 |
+
|
396 |
+
if priority_fig:
|
397 |
+
priority_path = charts_dir / "priority_counts.png"
|
398 |
+
priority_fig.savefig(priority_path)
|
399 |
+
html_content += f'<div><h3>Пріоритети тікетів</h3><img src="{priority_path.relative_to(reports_dir)}" alt="Пріоритети тікетів"></div>'
|
400 |
+
|
401 |
+
if timeline_fig:
|
402 |
+
timeline_path = charts_dir / "timeline.png"
|
403 |
+
timeline_fig.savefig(timeline_path)
|
404 |
+
html_content += f'<div><h3>Часова шкала</h3><img src="{timeline_path.relative_to(reports_dir)}" alt="Часова шкала"></div>'
|
405 |
+
|
406 |
+
html_content += "</body></html>"
|
407 |
+
|
408 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
409 |
+
f.write(html_content)
|
410 |
+
|
411 |
+
elif format_type == "pdf":
|
412 |
+
try:
|
413 |
+
from weasyprint import HTML
|
414 |
+
|
415 |
+
filepath = reports_dir / f"{report_filename}.pdf"
|
416 |
+
|
417 |
+
# Створюємо тимчасовий HTML-файл
|
418 |
+
temp_html_path = reports_dir / f"{report_filename}_temp.html"
|
419 |
+
|
420 |
+
# Конвертація Markdown в HTML
|
421 |
+
from markdown import markdown
|
422 |
+
html_content = f"""<!DOCTYPE html>
|
423 |
+
<html>
|
424 |
+
<head>
|
425 |
+
<meta charset="utf-8">
|
426 |
+
<title>Звіт аналізу Jira</title>
|
427 |
+
<style>
|
428 |
+
body {{ font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; }}
|
429 |
+
h1, h2, h3 {{ color: #0052CC; }}
|
430 |
+
table {{ border-collapse: collapse; width: 100%; margin-bottom: 20px; }}
|
431 |
+
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
|
432 |
+
th {{ background-color: #0052CC; color: white; }}
|
433 |
+
img {{ max-width: 100%; margin: 20px 0; }}
|
434 |
+
</style>
|
435 |
+
</head>
|
436 |
+
<body>
|
437 |
+
{markdown(report_text)}
|
438 |
+
"""
|
439 |
+
|
440 |
+
if include_visualizations and (status_fig or priority_fig or timeline_fig):
|
441 |
+
# Збереження візуалізацій
|
442 |
+
charts_dir = reports_dir / f"{report_filename}_charts"
|
443 |
+
charts_dir.mkdir(exist_ok=True)
|
444 |
+
|
445 |
+
html_content += "<h2>Візуалізації</h2>"
|
446 |
+
|
447 |
+
if status_fig:
|
448 |
+
status_path = charts_dir / "status_counts.png"
|
449 |
+
status_fig.savefig(status_path)
|
450 |
+
html_content += f'<div><h3>Статуси тікетів</h3><img src="{status_path}" alt="Статуси тікетів"></div>'
|
451 |
+
|
452 |
+
if priority_fig:
|
453 |
+
priority_path = charts_dir / "priority_counts.png"
|
454 |
+
priority_fig.savefig(priority_path)
|
455 |
+
html_content += f'<div><h3>Пріоритети тікетів</h3><img src="{priority_path}" alt="Пріоритети тікетів"></div>'
|
456 |
+
|
457 |
+
if timeline_fig:
|
458 |
+
timeline_path = charts_dir / "timeline.png"
|
459 |
+
timeline_fig.savefig(timeline_path)
|
460 |
+
html_content += f'<div><h3>Часова шкала</h3><img src="{timeline_path}" alt="Часова шкала"></div>'
|
461 |
+
|
462 |
+
html_content += "</body></html>"
|
463 |
+
|
464 |
+
with open(temp_html_path, 'w', encoding='utf-8') as f:
|
465 |
+
f.write(html_content)
|
466 |
+
|
467 |
+
# Конвертація HTML в PDF
|
468 |
+
HTML(filename=str(temp_html_path)).write_pdf(filepath)
|
469 |
+
|
470 |
+
# Видалення тимчасового HTML-файлу
|
471 |
+
try:
|
472 |
+
os.remove(temp_html_path)
|
473 |
+
except:
|
474 |
+
pass
|
475 |
+
|
476 |
+
except ImportError:
|
477 |
+
return "Помилка: для генерації PDF потрібна бібліотека weasyprint"
|
478 |
+
|
479 |
+
return f"Звіт успішно збережено: {filepath}"
|
480 |
+
|
481 |
+
except Exception as e:
|
482 |
+
error_msg = f"Помилка при збереженні звіту: {str(e)}\n\n{traceback.format_exc()}"
|
483 |
+
logger.error(error_msg)
|
484 |
+
return error_msg
|
485 |
+
|
486 |
+
# Інтерфейс Gradio
|
487 |
+
with gr.Blocks(title="Jira AI Assistant") as app:
|
488 |
+
gr.Markdown("# 🔍 Jira AI Assistant")
|
489 |
+
|
490 |
+
with gr.Tabs():
|
491 |
+
with gr.Tab("CSV Аналіз"):
|
492 |
+
with gr.Row():
|
493 |
+
with gr.Column(scale=1):
|
494 |
+
file_input = gr.File(label="Завантажити CSV файл Jira")
|
495 |
+
inactive_days = gr.Slider(minimum=1, maximum=90, value=14, step=1,
|
496 |
+
label="Кількість днів для визначення неактивних тікетів")
|
497 |
+
|
498 |
+
include_ai = gr.Checkbox(label="Включити AI аналіз", value=False)
|
499 |
+
|
500 |
+
analyze_btn = gr.Button("Аналізувати", variant="primary")
|
501 |
+
|
502 |
+
with gr.Accordion("Збереження звіту", open=False):
|
503 |
+
format_type = gr.Dropdown(
|
504 |
+
choices=["markdown", "html", "pdf"],
|
505 |
+
value="markdown",
|
506 |
+
label="Формат звіту"
|
507 |
+
)
|
508 |
+
include_visualizations = gr.Checkbox(
|
509 |
+
label="Включити візуалізації",
|
510 |
+
value=True
|
511 |
+
)
|
512 |
+
save_btn = gr.Button("Зберегти звіт")
|
513 |
+
save_output = gr.Textbox(label="Статус збереження")
|
514 |
+
|
515 |
+
with gr.Column(scale=2):
|
516 |
+
with gr.Tabs():
|
517 |
+
with gr.Tab("Звіт"):
|
518 |
+
report_output = gr.Markdown()
|
519 |
+
|
520 |
+
with gr.Tab("Візуалізації"):
|
521 |
+
with gr.Row():
|
522 |
+
status_plot = gr.Plot(label="Статуси тікетів")
|
523 |
+
priority_plot = gr.Plot(label="Пріоритети тікетів")
|
524 |
+
|
525 |
+
timeline_plot = gr.Plot(label="Часова шкала")
|
526 |
+
|
527 |
+
with gr.Tab("AI Аналіз"):
|
528 |
+
ai_output = gr.Markdown()
|
529 |
+
|
530 |
+
# Встановлюємо обробники подій
|
531 |
+
analyze_btn.click(
|
532 |
+
analyze_csv,
|
533 |
+
inputs=[file_input, inactive_days, include_ai],
|
534 |
+
outputs=[report_output, status_plot, priority_plot, timeline_plot, ai_output]
|
535 |
+
)
|
536 |
+
|
537 |
+
save_btn.click(
|
538 |
+
save_report,
|
539 |
+
inputs=[report_output, format_type, include_visualizations, status_plot, priority_plot, timeline_plot],
|
540 |
+
outputs=[save_output]
|
541 |
+
)
|
542 |
+
|
543 |
+
with gr.Tab("Jira API"):
|
544 |
+
gr.Markdown("## Підключення до Jira API")
|
545 |
+
|
546 |
+
with gr.Row():
|
547 |
+
jira_url = gr.Textbox(
|
548 |
+
label="Jira URL",
|
549 |
+
placeholder="https://your-company.atlassian.net"
|
550 |
+
)
|
551 |
+
jira_username = gr.Textbox(
|
552 |
+
label="Ім'я користувача Jira",
|
553 |
+
placeholder="email@example.com"
|
554 |
+
)
|
555 |
+
jira_api_token = gr.Textbox(
|
556 |
+
label="Jira API Token",
|
557 |
+
type="password"
|
558 |
+
)
|
559 |
+
|
560 |
+
test_connection_btn = gr.Button("Тестувати з'єднання")
|
561 |
+
connection_status = gr.Textbox(label="Статус підключення")
|
562 |
+
|
563 |
+
gr.Markdown("## ⚠️ Ця функція буде доступна у наступних версіях")
|
564 |
+
|
565 |
+
with gr.Tab("AI Асистенти"):
|
566 |
+
gr.Markdown("## AI Асистенти для Jira")
|
567 |
+
gr.Markdown("⚠️ Ця функція буде доступна у наступних версіях")
|
568 |
+
|
569 |
+
with gr.Accordion("Зразок інтерфейсу"):
|
570 |
+
question = gr.Textbox(
|
571 |
+
label="Запитання",
|
572 |
+
placeholder="Наприклад: Які тікети мають найвищий пріоритет?",
|
573 |
+
lines=2
|
574 |
+
)
|
575 |
+
answer = gr.Markdown(label="Відповідь")
|
576 |
+
|
577 |
+
with gr.Tab("Інтеграції"):
|
578 |
+
gr.Markdown("## Інтеграції з зовнішніми системами")
|
579 |
+
gr.Markdown("⚠️ Ця функція буде доступна у наступних версіях")
|
580 |
+
|
581 |
+
with gr.Accordion("Slack інтеграція"):
|
582 |
+
slack_channel = gr.Textbox(
|
583 |
+
label="Slack канал",
|
584 |
+
placeholder="#project-updates"
|
585 |
+
)
|
586 |
+
slack_message = gr.Textbox(
|
587 |
+
label="Повідомлення",
|
588 |
+
placeholder="Тижневий звіт по проекту",
|
589 |
+
lines=3
|
590 |
+
)
|
591 |
+
slack_send_btn = gr.Button("Надіслати у Slack", interactive=False)
|
592 |
+
|
593 |
+
# Запуск додатку
|
594 |
+
if __name__ == "__main__":
|
595 |
+
app.launch()
|
config.json
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"app_name": "Jira AI Assistant",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"data_dir": "data",
|
5 |
+
"reports_dir": "reports",
|
6 |
+
"temp_dir": "temp",
|
7 |
+
"log_dir": "logs",
|
8 |
+
"log_level": "INFO",
|
9 |
+
"default_inactive_days": 14,
|
10 |
+
"openai_model": "gpt-3.5-turbo",
|
11 |
+
"gemini_model": "gemini-pro",
|
12 |
+
"max_results": 500
|
13 |
+
}
|
config.py
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pathlib import Path
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
# Завантаження змінних середовища з .env файлу, якщо він існує
|
6 |
+
load_dotenv()
|
7 |
+
|
8 |
+
# Базові шляхи
|
9 |
+
BASE_DIR = Path(__file__).parent.absolute()
|
10 |
+
DATA_DIR = BASE_DIR / "data"
|
11 |
+
REPORTS_DIR = BASE_DIR / "reports"
|
12 |
+
TEMP_DIR = BASE_DIR / "temp"
|
13 |
+
LOG_DIR = BASE_DIR / "logs"
|
14 |
+
|
15 |
+
# Створення директорій, якщо вони не існують
|
16 |
+
DATA_DIR.mkdir(exist_ok=True, parents=True)
|
17 |
+
REPORTS_DIR.mkdir(exist_ok=True, parents=True)
|
18 |
+
TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
19 |
+
LOG_DIR.mkdir(exist_ok=True, parents=True)
|
20 |
+
|
21 |
+
# Налаштування додатку
|
22 |
+
APP_NAME = "Jira AI Assistant"
|
23 |
+
APP_VERSION = "1.0.0"
|
24 |
+
DEFAULT_INACTIVE_DAYS = 14
|
25 |
+
MAX_RESULTS = 500
|
26 |
+
|
27 |
+
# API ключі та налаштування
|
28 |
+
JIRA_URL = os.getenv("JIRA_URL", "")
|
29 |
+
JIRA_USERNAME = os.getenv("JIRA_USERNAME", "")
|
30 |
+
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN", "")
|
31 |
+
|
32 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
33 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo")
|
34 |
+
|
35 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
36 |
+
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-pro")
|
37 |
+
|
38 |
+
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN", "")
|
39 |
+
|
40 |
+
# Налаштування логування
|
41 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
42 |
+
LOG_FILE = os.getenv("LOG_FILE", LOG_DIR / f"{APP_NAME.lower().replace(' ', '_')}.log")
|
43 |
+
|
44 |
+
# Додаткові налаштування для Gradio
|
45 |
+
GRADIO_THEME = "huggingface" # Доступні теми: default, huggingface, grass, peach
|
46 |
+
GRADIO_AUTH = [] # Список кортежів (username, password) для авторизації
|
47 |
+
GRADIO_SHARE = False # Чи використовувати публічний URL для додатку
|
48 |
+
|
49 |
+
# Функція для збереження налаштувань у конфігураційний файл
|
50 |
+
def save_config():
|
51 |
+
import json
|
52 |
+
|
53 |
+
config = {
|
54 |
+
"app_name": APP_NAME,
|
55 |
+
"version": APP_VERSION,
|
56 |
+
"data_dir": str(DATA_DIR),
|
57 |
+
"reports_dir": str(REPORTS_DIR),
|
58 |
+
"temp_dir": str(TEMP_DIR),
|
59 |
+
"log_dir": str(LOG_DIR),
|
60 |
+
"log_level": LOG_LEVEL,
|
61 |
+
"default_inactive_days": DEFAULT_INACTIVE_DAYS,
|
62 |
+
"openai_model": OPENAI_MODEL,
|
63 |
+
"gemini_model": GEMINI_MODEL,
|
64 |
+
"max_results": MAX_RESULTS,
|
65 |
+
"gradio_theme": GRADIO_THEME,
|
66 |
+
"gradio_share": GRADIO_SHARE
|
67 |
+
}
|
68 |
+
|
69 |
+
with open(BASE_DIR / "config.json", "w", encoding="utf-8") as f:
|
70 |
+
json.dump(config, f, indent=2)
|
71 |
+
|
72 |
+
# Функція для завантаження налаштувань з конфігураційного файлу
|
73 |
+
def load_config():
|
74 |
+
import json
|
75 |
+
|
76 |
+
config_file = BASE_DIR / "config.json"
|
77 |
+
|
78 |
+
if config_file.exists():
|
79 |
+
with open(config_file, "r", encoding="utf-8") as f:
|
80 |
+
config = json.load(f)
|
81 |
+
|
82 |
+
return config
|
83 |
+
|
84 |
+
return None
|
85 |
+
|
86 |
+
# Створення конфігураційного файлу при імпорті, якщо він не існує
|
87 |
+
if not (BASE_DIR / "config.json").exists():
|
88 |
+
save_config()
|
modules/ai_analysis/llm_connector.py
ADDED
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import logging
|
4 |
+
import requests
|
5 |
+
from typing import Dict, List, Any, Optional
|
6 |
+
import openai
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
class LLMConnector:
|
12 |
+
"""
|
13 |
+
Клас для взаємодії з LLM (OpenAI, Google Gemini, тощо)
|
14 |
+
"""
|
15 |
+
def __init__(self, api_key=None, model_type="openai"):
|
16 |
+
"""
|
17 |
+
Ініціалізація з'єднання з LLM.
|
18 |
+
|
19 |
+
Args:
|
20 |
+
api_key (str): API ключ для доступу до LLM.
|
21 |
+
Якщо None, спробує використати змінну середовища.
|
22 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
23 |
+
"""
|
24 |
+
self.model_type = model_type.lower()
|
25 |
+
|
26 |
+
if self.model_type == "openai":
|
27 |
+
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
28 |
+
|
29 |
+
if not self.api_key:
|
30 |
+
logger.warning("API ключ OpenAI не вказано")
|
31 |
+
|
32 |
+
self.model = "gpt-3.5-turbo" # Стандартна модель
|
33 |
+
openai.api_key = self.api_key
|
34 |
+
|
35 |
+
elif self.model_type == "gemini":
|
36 |
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
37 |
+
|
38 |
+
if not self.api_key:
|
39 |
+
logger.warning("API ключ Gemini не вказано")
|
40 |
+
|
41 |
+
self.model = "gemini-pro" # Стандартна модель для Gemini
|
42 |
+
|
43 |
+
else:
|
44 |
+
raise ValueError(f"Непідтримуваний тип моделі: {model_type}")
|
45 |
+
|
46 |
+
def analyze_jira_data(self, stats, inactive_issues, temperature=0.2):
|
47 |
+
"""
|
48 |
+
Аналіз даних Jira за допомогою LLM.
|
49 |
+
|
50 |
+
Args:
|
51 |
+
stats (dict): Базова статистика даних Jira
|
52 |
+
inactive_issues (dict): Дані про неактивні тікети
|
53 |
+
temperature (float): Параметр температури для генерації
|
54 |
+
|
55 |
+
Returns:
|
56 |
+
str: Результат аналізу
|
57 |
+
"""
|
58 |
+
try:
|
59 |
+
# Підготовка даних для аналізу
|
60 |
+
data_summary = self._prepare_data_for_llm(stats, inactive_issues)
|
61 |
+
|
62 |
+
# Відправлення запиту до LLM
|
63 |
+
if self.model_type == "openai":
|
64 |
+
return self._analyze_with_openai(data_summary, temperature)
|
65 |
+
elif self.model_type == "gemini":
|
66 |
+
return self._analyze_with_gemini(data_summary, temperature)
|
67 |
+
|
68 |
+
except Exception as e:
|
69 |
+
logger.error(f"Помилка при аналізі даних за допомогою LLM: {e}")
|
70 |
+
return f"Помилка при аналізі даних: {str(e)}"
|
71 |
+
|
72 |
+
def _prepare_data_for_llm(self, stats, inactive_issues):
|
73 |
+
"""
|
74 |
+
Підготовка даних для аналізу за допомогою LLM.
|
75 |
+
|
76 |
+
Args:
|
77 |
+
stats (dict): Базова статистика
|
78 |
+
inactive_issues (dict): Дані про неактивні тікети
|
79 |
+
|
80 |
+
Returns:
|
81 |
+
str: Дані для аналізу у текстовому форматі
|
82 |
+
"""
|
83 |
+
summary = []
|
84 |
+
|
85 |
+
# Додавання загальної статистики
|
86 |
+
summary.append(f"Загальна кількість тікетів: {stats.get('total_tickets', 'Невідомо')}")
|
87 |
+
|
88 |
+
# Додавання статистики за статусами
|
89 |
+
if 'status_counts' in stats and stats['status_counts']:
|
90 |
+
summary.append("\nТікети за статусами:")
|
91 |
+
for status, count in stats['status_counts'].items():
|
92 |
+
summary.append(f"- {status}: {count}")
|
93 |
+
|
94 |
+
# Додавання статистики за типами
|
95 |
+
if 'type_counts' in stats and stats['type_counts']:
|
96 |
+
summary.append("\nТікети за типами:")
|
97 |
+
for issue_type, count in stats['type_counts'].items():
|
98 |
+
summary.append(f"- {issue_type}: {count}")
|
99 |
+
|
100 |
+
# Додавання статистики за пріоритетами
|
101 |
+
if 'priority_counts' in stats and stats['priority_counts']:
|
102 |
+
summary.append("\nТікети за пріоритетами:")
|
103 |
+
for priority, count in stats['priority_counts'].items():
|
104 |
+
summary.append(f"- {priority}: {count}")
|
105 |
+
|
106 |
+
# Аналіз створених тікетів
|
107 |
+
if 'created_stats' in stats and stats['created_stats']:
|
108 |
+
summary.append("\nСтатистика створених тікетів:")
|
109 |
+
for key, value in stats['created_stats'].items():
|
110 |
+
if key == 'last_7_days':
|
111 |
+
summary.append(f"- Створено за останні 7 днів: {value}")
|
112 |
+
elif key == 'min':
|
113 |
+
summary.append(f"- Найраніший тікет створено: {value}")
|
114 |
+
elif key == 'max':
|
115 |
+
summary.append(f"- Найпізніший тікет створено: {value}")
|
116 |
+
|
117 |
+
# Аналіз неактивних тікетів
|
118 |
+
if inactive_issues:
|
119 |
+
total_inactive = inactive_issues.get('total_count', 0)
|
120 |
+
percentage = inactive_issues.get('percentage', 0)
|
121 |
+
summary.append(f"\nНеактивні тікети: {total_inactive} ({percentage}% від загальної кількості)")
|
122 |
+
|
123 |
+
if 'by_status' in inactive_issues and inactive_issues['by_status']:
|
124 |
+
summary.append("Неактивні тікети за статусами:")
|
125 |
+
for status, count in inactive_issues['by_status'].items():
|
126 |
+
summary.append(f"- {status}: {count}")
|
127 |
+
|
128 |
+
if 'top_inactive' in inactive_issues and inactive_issues['top_inactive']:
|
129 |
+
summary.append("\nНайбільш неактивні тікети:")
|
130 |
+
for i, ticket in enumerate(inactive_issues['top_inactive']):
|
131 |
+
key = ticket.get('key', 'Невідомо')
|
132 |
+
status = ticket.get('status', 'Невідомо')
|
133 |
+
days = ticket.get('days_inactive', 'Невідомо')
|
134 |
+
summary.append(f"- {key} (Статус: {status}, Днів неактивності: {days})")
|
135 |
+
|
136 |
+
return "\n".join(summary)
|
137 |
+
|
138 |
+
def _analyze_with_openai(self, data_summary, temperature=0.2):
|
139 |
+
"""
|
140 |
+
Аналіз даних за допомогою OpenAI.
|
141 |
+
|
142 |
+
Args:
|
143 |
+
data_summary (str): Дані для аналізу
|
144 |
+
temperature (float): Параметр температури
|
145 |
+
|
146 |
+
Returns:
|
147 |
+
str: Результат аналізу
|
148 |
+
"""
|
149 |
+
try:
|
150 |
+
if not self.api_key:
|
151 |
+
return "Не вказано API ключ OpenAI"
|
152 |
+
|
153 |
+
# Створення запиту до LLM
|
154 |
+
response = openai.chat.completions.create(
|
155 |
+
model=self.model,
|
156 |
+
messages=[
|
157 |
+
{"role": "system", "content": """Ви аналітик Jira з досвідом у процесах розробки ПЗ.
|
158 |
+
Проаналізуйте надані дані про тікети та надайте корисні інсайти та рекомендації
|
159 |
+
для покращення процесу. Будьте конкретними та орієнтованими на дії.
|
160 |
+
Виділіть сильні та слабкі сторони, а також потенційні ризики та можливості.
|
161 |
+
Аналіз повинен бути структурованим і легким для сприйняття менеджерами проекту."""},
|
162 |
+
{"role": "user", "content": f"Проаналізуйте наступні дані Jira та надайте рекомендації:\n\n{data_summary}"}
|
163 |
+
],
|
164 |
+
temperature=temperature,
|
165 |
+
)
|
166 |
+
|
167 |
+
# Отримання результату
|
168 |
+
analysis_result = response.choices[0].message.content
|
169 |
+
|
170 |
+
logger.info("Успішно отримано аналіз від OpenAI")
|
171 |
+
return analysis_result
|
172 |
+
|
173 |
+
except Exception as e:
|
174 |
+
logger.error(f"Помилка при взаємодії з OpenAI: {e}")
|
175 |
+
return f"Помилка при взаємодії з OpenAI: {str(e)}"
|
176 |
+
|
177 |
+
def _analyze_with_gemini(self, data_summary, temperature=0.2):
|
178 |
+
"""
|
179 |
+
Аналіз даних за допомогою Google Gemini.
|
180 |
+
|
181 |
+
Args:
|
182 |
+
data_summary (str): Дані для аналізу
|
183 |
+
temperature (float): Параметр температури
|
184 |
+
|
185 |
+
Returns:
|
186 |
+
str: Результат аналізу
|
187 |
+
"""
|
188 |
+
try:
|
189 |
+
if not self.api_key:
|
190 |
+
return "Не вказано API ключ Gemini"
|
191 |
+
|
192 |
+
# API endpoint
|
193 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
|
194 |
+
|
195 |
+
# Формування запиту
|
196 |
+
payload = {
|
197 |
+
"contents": [
|
198 |
+
{
|
199 |
+
"parts": [
|
200 |
+
{
|
201 |
+
"text": """Ви аналітик Jira з досвідом у процесах розробки ПЗ.
|
202 |
+
Проаналізуйте надані дані про тікети та надайте корисні інсайти та рекомендації
|
203 |
+
для покращення процесу. Будьте конкретними та орієнтованими на дії.
|
204 |
+
Виділіть сильні та слабкі сторони, а також потенційні ризики та можливості.
|
205 |
+
Аналіз повинен бути структурованим і легким для сприйняття менеджерами проекту."""
|
206 |
+
}
|
207 |
+
]
|
208 |
+
},
|
209 |
+
{
|
210 |
+
"parts": [
|
211 |
+
{
|
212 |
+
"text": f"Проаналізуйте наступні дані Jira та надайте рекомендації:\n\n{data_summary}"
|
213 |
+
}
|
214 |
+
]
|
215 |
+
}
|
216 |
+
],
|
217 |
+
"generationConfig": {
|
218 |
+
"temperature": temperature,
|
219 |
+
"maxOutputTokens": 2048
|
220 |
+
}
|
221 |
+
}
|
222 |
+
|
223 |
+
# Відправлення запиту
|
224 |
+
headers = {"Content-Type": "application/json"}
|
225 |
+
response = requests.post(url, headers=headers, json=payload)
|
226 |
+
|
227 |
+
# Обробка відповіді
|
228 |
+
if response.status_code == 200:
|
229 |
+
response_data = response.json()
|
230 |
+
|
231 |
+
# Отримання тексту відповіді
|
232 |
+
if 'candidates' in response_data and len(response_data['candidates']) > 0:
|
233 |
+
if 'content' in response_data['candidates'][0]:
|
234 |
+
content = response_data['candidates'][0]['content']
|
235 |
+
if 'parts' in content and len(content['parts']) > 0:
|
236 |
+
result = content['parts'][0].get('text', '')
|
237 |
+
logger.info("Успішно отримано аналіз від Gemini")
|
238 |
+
return result
|
239 |
+
|
240 |
+
logger.error(f"Помилка при взаємодії з Gemini: {response.text}")
|
241 |
+
return f"Помилка при взаємодії з Gemini: статус {response.status_code}"
|
242 |
+
|
243 |
+
except Exception as e:
|
244 |
+
logger.error(f"Помилка при взаємодії з Gemini: {e}")
|
245 |
+
return f"Помилка при взаємодії з Gemini: {str(e)}"
|
246 |
+
|
247 |
+
def ask_question(self, question, context=None, temperature=0.3):
|
248 |
+
"""
|
249 |
+
Задати питання до LLM на основі даних Jira.
|
250 |
+
|
251 |
+
Args:
|
252 |
+
question (str): Питання користувача
|
253 |
+
context (str): Додатковий контекст (може містити JSON дані тікетів)
|
254 |
+
temperature (float): Параметр температури
|
255 |
+
|
256 |
+
Returns:
|
257 |
+
str: Відповідь на питання
|
258 |
+
"""
|
259 |
+
try:
|
260 |
+
# Формування запиту для LLM
|
261 |
+
if self.model_type == "openai":
|
262 |
+
messages = [
|
263 |
+
{"role": "system", "content": """Ви асистент, який допомагає аналізувати дані Jira.
|
264 |
+
Відповідайте на питання користувача на основі наданого контексту.
|
265 |
+
Якщо контекст недостатній для відповіді, чесно визнайте це."""}
|
266 |
+
]
|
267 |
+
|
268 |
+
if context:
|
269 |
+
messages.append({"role": "user", "content": f"Контекст: {context}"})
|
270 |
+
|
271 |
+
messages.append({"role": "user", "content": question})
|
272 |
+
|
273 |
+
# Відправлення запиту
|
274 |
+
response = openai.chat.completions.create(
|
275 |
+
model=self.model,
|
276 |
+
messages=messages,
|
277 |
+
temperature=temperature,
|
278 |
+
)
|
279 |
+
|
280 |
+
# Отримання відповіді
|
281 |
+
answer = response.choices[0].message.content
|
282 |
+
|
283 |
+
logger.info("Успішно отримано відповідь від OpenAI")
|
284 |
+
return answer
|
285 |
+
|
286 |
+
elif self.model_type == "gemini":
|
287 |
+
# TODO: Реалізувати для Gemini
|
288 |
+
return "Gemini API для Q&A ще не реалізовано"
|
289 |
+
|
290 |
+
except Exception as e:
|
291 |
+
logger.error(f"Помилка при отриманні відповіді від LLM: {e}")
|
292 |
+
return f"Помилка при обробці запитання: {str(e)}"
|
293 |
+
|
294 |
+
def generate_summary(self, jira_data, output_format="markdown", temperature=0.3):
|
295 |
+
"""
|
296 |
+
Генерація підсумкового звіту по даним Jira.
|
297 |
+
|
298 |
+
Args:
|
299 |
+
jira_data (str): Дані Jira для аналізу
|
300 |
+
output_format (str): Формат виводу ("markdown", "html", "text")
|
301 |
+
temperature (float): Параметр температури
|
302 |
+
|
303 |
+
Returns:
|
304 |
+
str: Згенерований звіт
|
305 |
+
"""
|
306 |
+
try:
|
307 |
+
# Формування запиту для LLM
|
308 |
+
if self.model_type == "openai":
|
309 |
+
format_instruction = ""
|
310 |
+
if output_format == "markdown":
|
311 |
+
format_instruction = "Використовуйте Markdown для форматування звіту."
|
312 |
+
elif output_format == "html":
|
313 |
+
format_instruction = "Створіть звіт у форматі HTML з використанням відповідних тегів."
|
314 |
+
else:
|
315 |
+
format_instruction = "Створіть звіт у простому текстовому форматі."
|
316 |
+
|
317 |
+
response = openai.chat.completions.create(
|
318 |
+
model=self.model,
|
319 |
+
messages=[
|
320 |
+
{"role": "system", "content": f"""Ви аналітик, який створює професійні звіти на основі даних Jira.
|
321 |
+
Проаналізуйте надані дані та створіть структурований звіт з наступними розділами:
|
322 |
+
1. Короткий огляд проекту
|
323 |
+
2. Аналіз поточного стану
|
324 |
+
3. Ризики та проблеми
|
325 |
+
4. Рекомендації
|
326 |
+
{format_instruction}"""},
|
327 |
+
{"role": "user", "content": f"Дані для аналізу:\n\n{jira_data}"}
|
328 |
+
],
|
329 |
+
temperature=temperature,
|
330 |
+
)
|
331 |
+
|
332 |
+
# Отримання звіту
|
333 |
+
report = response.choices[0].message.content
|
334 |
+
|
335 |
+
logger.info(f"Успішно згенеровано звіт у форматі {output_format}")
|
336 |
+
return report
|
337 |
+
|
338 |
+
elif self.model_type == "gemini":
|
339 |
+
# TODO: Реалізувати для Gemini
|
340 |
+
return "Gemini API для генерації звітів ще не реалізовано"
|
341 |
+
|
342 |
+
except Exception as e:
|
343 |
+
logger.error(f"Помилка при генерації звіту: {e}")
|
344 |
+
return f"Помилка при генерації звіту: {str(e)}"
|
345 |
+
|
346 |
+
|
347 |
+
class AIAgentManager:
|
348 |
+
"""
|
349 |
+
Менеджер AI агентів для аналізу даних Jira
|
350 |
+
"""
|
351 |
+
def __init__(self, api_key=None, model_type="openai"):
|
352 |
+
"""
|
353 |
+
Ініціалізація менеджера AI агентів.
|
354 |
+
|
355 |
+
Args:
|
356 |
+
api_key (str): API ключ для LLM
|
357 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
358 |
+
"""
|
359 |
+
self.llm_connector = LLMConnector(api_key, model_type)
|
360 |
+
self.agents = {
|
361 |
+
"analytics_engineer": self._create_analytics_engineer(),
|
362 |
+
"project_manager": self._create_project_manager(),
|
363 |
+
"communication_specialist": self._create_communication_specialist()
|
364 |
+
}
|
365 |
+
|
366 |
+
def _create_analytics_engineer(self):
|
367 |
+
"""
|
368 |
+
Створення агента "Аналітичний інженер" для обробки даних.
|
369 |
+
|
370 |
+
Returns:
|
371 |
+
dict: Конфігурація агента
|
372 |
+
"""
|
373 |
+
return {
|
374 |
+
"name": "Аналітичний інженер",
|
375 |
+
"role": "Обробка та аналіз даних Jira",
|
376 |
+
"system_prompt": """Ви досвідчений аналітичний інженер, експерт з обробки та аналізу даних Jira.
|
377 |
+
Ваша задача - отримувати, обробляти та аналізувати дані з Jira, виявляти паттерни та готувати
|
378 |
+
інформацію для подальшого аналізу іншими спеціалістами. Фокусуйтеся на виявленні аномалій,
|
379 |
+
трендів та інсайтів у даних."""
|
380 |
+
}
|
381 |
+
|
382 |
+
def _create_project_manager(self):
|
383 |
+
"""
|
384 |
+
Створення агента "Проектний менеджер" для аналізу проекту.
|
385 |
+
|
386 |
+
Returns:
|
387 |
+
dict: Конфігурація агента
|
388 |
+
"""
|
389 |
+
return {
|
390 |
+
"name": "Проектний менеджер",
|
391 |
+
"role": "Аналіз стану проекту та вироблення рекомендацій",
|
392 |
+
"system_prompt": """Ви досвідчений проектний менеджер з глибоким розумінням процесів розробки ПЗ.
|
393 |
+
Ваша задача - аналізувати дані Jira, розуміти поточний стан проекту, виявляти ризики та проблеми,
|
394 |
+
та надавати конкретні дієві рекомендації для покращення процесу. Вас цікавлять дедлайни,
|
395 |
+
блокуючі фактори, неактивні тікети та ефективність процесу."""
|
396 |
+
}
|
397 |
+
|
398 |
+
def _create_communication_specialist(self):
|
399 |
+
"""
|
400 |
+
Створення агента "Комунікаційний спеціаліст" для формування повідомлень.
|
401 |
+
|
402 |
+
Returns:
|
403 |
+
dict: Конфігурація агента
|
404 |
+
"""
|
405 |
+
return {
|
406 |
+
"name": "Комунікаційний спеціаліст",
|
407 |
+
"role": "Формування повідомлень для стейкхолдерів",
|
408 |
+
"system_prompt": """Ви досвідчений комунікаційний спеціаліст, експерт з формування чітких,
|
409 |
+
інформативних та професійних повідомлень для стейкхолдерів проекту. Ваша задача -
|
410 |
+
перетворювати технічну інформацію та аналітику в зрозумілі, структуровані повідомлення,
|
411 |
+
які допоможуть стейкхолдерам приймати рішення. Фокусуйтеся на ключових інсайтах,
|
412 |
+
використовуйте професійний, але дружній тон."""
|
413 |
+
}
|
414 |
+
|
415 |
+
def run_agent(self, agent_name, task, context=None, temperature=0.3):
|
416 |
+
"""
|
417 |
+
Запуск конкретного агента для виконання задачі.
|
418 |
+
|
419 |
+
Args:
|
420 |
+
agent_name (str): Назва агента ("analytics_engineer", "project_manager" або "communication_specialist")
|
421 |
+
task (str): Задача для агента
|
422 |
+
context (str): Контекст для виконання задачі
|
423 |
+
temperature (float): Параметр температури
|
424 |
+
|
425 |
+
Returns:
|
426 |
+
str: Результат виконання задачі
|
427 |
+
"""
|
428 |
+
try:
|
429 |
+
if agent_name not in self.agents:
|
430 |
+
return f"Помилка: агент '{agent_name}' не знайдений"
|
431 |
+
|
432 |
+
agent = self.agents[agent_name]
|
433 |
+
|
434 |
+
# Формування запиту для LLM
|
435 |
+
messages = [
|
436 |
+
{"role": "system", "content": agent["system_prompt"]}
|
437 |
+
]
|
438 |
+
|
439 |
+
if context:
|
440 |
+
messages.append({"role": "user", "content": f"Контекст: {context}"})
|
441 |
+
|
442 |
+
messages.append({"role": "user", "content": task})
|
443 |
+
|
444 |
+
# Відправлення запиту до LLM
|
445 |
+
if self.llm_connector.model_type == "openai":
|
446 |
+
response = openai.chat.completions.create(
|
447 |
+
model=self.llm_connector.model,
|
448 |
+
messages=messages,
|
449 |
+
temperature=temperature,
|
450 |
+
)
|
451 |
+
|
452 |
+
# Отримання результату
|
453 |
+
result = response.choices[0].message.content
|
454 |
+
|
455 |
+
logger.info(f"Успішно отримано результат від агента '{agent_name}'")
|
456 |
+
return result
|
457 |
+
|
458 |
+
elif self.llm_connector.model_type == "gemini":
|
459 |
+
# TODO: Реалізувати для Gemini
|
460 |
+
return f"Gemini API для агента '{agent_name}' ще не реалізовано"
|
461 |
+
|
462 |
+
except Exception as e:
|
463 |
+
logger.error(f"Помилка при виконанні задачі агентом '{agent_name}': {e}")
|
464 |
+
return f"Помилка при виконанні задачі: {str(e)}"
|
modules/core/app_manager.py
ADDED
@@ -0,0 +1,648 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import pandas as pd
|
4 |
+
import json
|
5 |
+
from datetime import datetime
|
6 |
+
from pathlib import Path
|
7 |
+
import importlib
|
8 |
+
import requests
|
9 |
+
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
class AppManager:
|
13 |
+
"""
|
14 |
+
Класс, який керує роботою додатку Jira AI Assistant
|
15 |
+
"""
|
16 |
+
def __init__(self):
|
17 |
+
"""
|
18 |
+
Ініціалізація менеджера додатку
|
19 |
+
"""
|
20 |
+
self.config = self._load_config()
|
21 |
+
self.setup_logging()
|
22 |
+
self.data = None
|
23 |
+
self.analyses = {}
|
24 |
+
self.reports = {}
|
25 |
+
|
26 |
+
# Створення директорій для даних, якщо вони не існують
|
27 |
+
self._create_directories()
|
28 |
+
|
29 |
+
def _load_config(self):
|
30 |
+
"""
|
31 |
+
Завантаження конфігурації додатку
|
32 |
+
|
33 |
+
Returns:
|
34 |
+
dict: Конфігурація додатку
|
35 |
+
"""
|
36 |
+
try:
|
37 |
+
# Спочатку спробуємо завантажити з файлу
|
38 |
+
config_path = Path("config.json")
|
39 |
+
|
40 |
+
if config_path.exists():
|
41 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
42 |
+
config = json.load(f)
|
43 |
+
logger.info("Конфігурація завантажена з файлу")
|
44 |
+
return config
|
45 |
+
|
46 |
+
# Якщо файл не існує, використовуємо стандартну конфігурацію
|
47 |
+
config = {
|
48 |
+
"app_name": "Jira AI Assistant",
|
49 |
+
"version": "1.0.0",
|
50 |
+
"data_dir": "data",
|
51 |
+
"reports_dir": "reports",
|
52 |
+
"temp_dir": "temp",
|
53 |
+
"log_dir": "logs",
|
54 |
+
"log_level": "INFO",
|
55 |
+
"default_inactive_days": 14,
|
56 |
+
"openai_model": "gpt-3.5-turbo",
|
57 |
+
"gemini_model": "gemini-pro",
|
58 |
+
"max_results": 500
|
59 |
+
}
|
60 |
+
|
61 |
+
# Зберігаємо стандартну конфігурацію у файл
|
62 |
+
with open(config_path, 'w', encoding='utf-8') as f:
|
63 |
+
json.dump(config, f, indent=2)
|
64 |
+
|
65 |
+
logger.info("Створено стандартну конфігурацію")
|
66 |
+
return config
|
67 |
+
|
68 |
+
except Exception as e:
|
69 |
+
logger.error(f"Помилка при завантаженні конфігурації: {e}")
|
70 |
+
|
71 |
+
# Аварійна конфігурація
|
72 |
+
return {
|
73 |
+
"app_name": "Jira AI Assistant",
|
74 |
+
"version": "1.0.0",
|
75 |
+
"data_dir": "data",
|
76 |
+
"reports_dir": "reports",
|
77 |
+
"temp_dir": "temp",
|
78 |
+
"log_dir": "logs",
|
79 |
+
"log_level": "INFO",
|
80 |
+
"default_inactive_days": 14,
|
81 |
+
"openai_model": "gpt-3.5-turbo",
|
82 |
+
"gemini_model": "gemini-pro",
|
83 |
+
"max_results": 500
|
84 |
+
}
|
85 |
+
|
86 |
+
def setup_logging(self):
|
87 |
+
"""
|
88 |
+
Налаштування логування
|
89 |
+
"""
|
90 |
+
try:
|
91 |
+
log_dir = Path(self.config.get("log_dir", "logs"))
|
92 |
+
log_dir.mkdir(exist_ok=True, parents=True)
|
93 |
+
|
94 |
+
log_file = log_dir / f"app_{datetime.now().strftime('%Y%m%d')}.log"
|
95 |
+
|
96 |
+
# Рівень логування з конфігурації
|
97 |
+
log_level_str = self.config.get("log_level", "INFO")
|
98 |
+
log_level = getattr(logging, log_level_str, logging.INFO)
|
99 |
+
|
100 |
+
# Налаштування логера
|
101 |
+
logging.basicConfig(
|
102 |
+
level=log_level,
|
103 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
104 |
+
handlers=[
|
105 |
+
logging.FileHandler(log_file),
|
106 |
+
logging.StreamHandler()
|
107 |
+
]
|
108 |
+
)
|
109 |
+
|
110 |
+
logger.info(f"Логування налаштовано. Рівень: {log_level_str}, файл: {log_file}")
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
print(f"Помилка при налаштуванні логування: {e}")
|
114 |
+
|
115 |
+
# Аварійне налаштування логування
|
116 |
+
logging.basicConfig(
|
117 |
+
level=logging.INFO,
|
118 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
119 |
+
)
|
120 |
+
|
121 |
+
def _create_directories(self):
|
122 |
+
"""
|
123 |
+
Створення необхідних директорій
|
124 |
+
"""
|
125 |
+
try:
|
126 |
+
directories = [
|
127 |
+
self.config.get("data_dir", "data"),
|
128 |
+
self.config.get("reports_dir", "reports"),
|
129 |
+
self.config.get("temp_dir", "temp"),
|
130 |
+
self.config.get("log_dir", "logs")
|
131 |
+
]
|
132 |
+
|
133 |
+
for directory in directories:
|
134 |
+
Path(directory).mkdir(exist_ok=True, parents=True)
|
135 |
+
|
136 |
+
logger.info("Створено необхідні директорії")
|
137 |
+
|
138 |
+
except Exception as e:
|
139 |
+
logger.error(f"Помилка при створенні директорій: {e}")
|
140 |
+
|
141 |
+
def load_csv_data(self, file_path):
|
142 |
+
"""
|
143 |
+
Завантаження даних з CSV файлу
|
144 |
+
|
145 |
+
Args:
|
146 |
+
file_path (str): Шлях до CSV файлу
|
147 |
+
|
148 |
+
Returns:
|
149 |
+
pandas.DataFrame: Завантажені дані або None у випадку помилки
|
150 |
+
"""
|
151 |
+
try:
|
152 |
+
logger.info(f"Завантаження даних з CSV файлу: {file_path}")
|
153 |
+
|
154 |
+
# Імпортуємо необхідний модуль
|
155 |
+
from modules.data_import.csv_importer import JiraCsvImporter
|
156 |
+
|
157 |
+
# Створюємо імпортер та завантажуємо дані
|
158 |
+
importer = JiraCsvImporter(file_path)
|
159 |
+
self.data = importer.load_data()
|
160 |
+
|
161 |
+
if self.data is None:
|
162 |
+
logger.error("Не вдалося завантажити дані з CSV файлу")
|
163 |
+
return None
|
164 |
+
|
165 |
+
logger.info(f"Успішно завантажено {len(self.data)} записів")
|
166 |
+
|
167 |
+
# Зберігаємо копію даних
|
168 |
+
self._save_data_copy(file_path)
|
169 |
+
|
170 |
+
return self.data
|
171 |
+
|
172 |
+
except Exception as e:
|
173 |
+
logger.error(f"Помилка при завантаженні даних з CSV: {e}")
|
174 |
+
return None
|
175 |
+
|
176 |
+
def _save_data_copy(self, original_file_path):
|
177 |
+
"""
|
178 |
+
Збереження копії даних
|
179 |
+
|
180 |
+
Args:
|
181 |
+
original_file_path (str): Шлях до оригінального файлу
|
182 |
+
"""
|
183 |
+
try:
|
184 |
+
if self.data is None:
|
185 |
+
return
|
186 |
+
|
187 |
+
# Створюємо ім'я файлу на основі оригінального
|
188 |
+
file_name = os.path.basename(original_file_path)
|
189 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
190 |
+
new_file_name = f"{os.path.splitext(file_name)[0]}_{timestamp}.csv"
|
191 |
+
|
192 |
+
# Шлях для збереження
|
193 |
+
data_dir = Path(self.config.get("data_dir", "data"))
|
194 |
+
save_path = data_dir / new_file_name
|
195 |
+
|
196 |
+
# Зберігаємо дані
|
197 |
+
self.data.to_csv(save_path, index=False, encoding='utf-8')
|
198 |
+
|
199 |
+
logger.info(f"Збережено копію даних у {save_path}")
|
200 |
+
|
201 |
+
except Exception as e:
|
202 |
+
logger.error(f"Помилка при збереженні копії даних: {e}")
|
203 |
+
|
204 |
+
def connect_to_jira(self, jira_url, username, api_token):
|
205 |
+
"""
|
206 |
+
Підключення до Jira API
|
207 |
+
|
208 |
+
Args:
|
209 |
+
jira_url (str): URL Jira сервера
|
210 |
+
username (str): Ім'я користувача
|
211 |
+
api_token (str): API токен
|
212 |
+
|
213 |
+
Returns:
|
214 |
+
bool: True, якщо підключення успішне, False у іншому випадку
|
215 |
+
"""
|
216 |
+
try:
|
217 |
+
logger.info(f"Тестування підключення до Jira: {jira_url}")
|
218 |
+
|
219 |
+
# Спроба прямого HTTP запиту до сервера
|
220 |
+
response = requests.get(
|
221 |
+
f"{jira_url}/rest/api/2/serverInfo",
|
222 |
+
auth=(username, api_token),
|
223 |
+
timeout=10,
|
224 |
+
verify=True
|
225 |
+
)
|
226 |
+
|
227 |
+
if response.status_code == 200:
|
228 |
+
logger.info("Успішне підключення до Jira API")
|
229 |
+
|
230 |
+
# Зберігаємо дані про підключення
|
231 |
+
self.jira_connection = {
|
232 |
+
"url": jira_url,
|
233 |
+
"username": username,
|
234 |
+
"api_token": api_token
|
235 |
+
}
|
236 |
+
|
237 |
+
return True
|
238 |
+
else:
|
239 |
+
logger.error(f"Помилка підключення до Jira: {response.status_code}, {response.text}")
|
240 |
+
return False
|
241 |
+
|
242 |
+
except Exception as e:
|
243 |
+
logger.error(f"Помилка при підключенні до Jira: {e}")
|
244 |
+
return False
|
245 |
+
|
246 |
+
def get_jira_data(self, project_key, board_id=None, max_results=None):
|
247 |
+
"""
|
248 |
+
Отримання даних з Jira API
|
249 |
+
|
250 |
+
Args:
|
251 |
+
project_key (str): Ключ проекту
|
252 |
+
board_id (int): ID дошки (необов'язково)
|
253 |
+
max_results (int): Максимальна кількість результатів
|
254 |
+
|
255 |
+
Returns:
|
256 |
+
pandas.DataFrame: Отримані дані або None у випадку помилки
|
257 |
+
"""
|
258 |
+
try:
|
259 |
+
if not hasattr(self, 'jira_connection'):
|
260 |
+
logger.error("Немає з'єднання з Jira")
|
261 |
+
return None
|
262 |
+
|
263 |
+
logger.info(f"Отримання даних з Jira для проекту {project_key}")
|
264 |
+
|
265 |
+
# Імпортуємо необхідний модуль
|
266 |
+
from modules.data_import.jira_api import JiraConnector
|
267 |
+
|
268 |
+
# Параметри з'єднання
|
269 |
+
jira_url = self.jira_connection["url"]
|
270 |
+
username = self.jira_connection["username"]
|
271 |
+
api_token = self.jira_connection["api_token"]
|
272 |
+
|
273 |
+
# Створюємо коннектор
|
274 |
+
connector = JiraConnector(jira_url, username, api_token)
|
275 |
+
|
276 |
+
# Отримуємо дані
|
277 |
+
if board_id:
|
278 |
+
issues = connector.get_board_issues(
|
279 |
+
board_id,
|
280 |
+
project_key,
|
281 |
+
max_results=max_results or self.config.get("max_results", 500)
|
282 |
+
)
|
283 |
+
else:
|
284 |
+
issues = connector.get_project_issues(
|
285 |
+
project_key,
|
286 |
+
max_results=max_results or self.config.get("max_results", 500)
|
287 |
+
)
|
288 |
+
|
289 |
+
if not issues:
|
290 |
+
logger.error("Не вдалося отримати тікети з Jira")
|
291 |
+
return None
|
292 |
+
|
293 |
+
# Експортуємо у CSV та завантажуємо дані
|
294 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
295 |
+
temp_dir = Path(self.config.get("temp_dir", "temp"))
|
296 |
+
temp_csv_path = temp_dir / f"jira_export_{project_key}_{timestamp}.csv"
|
297 |
+
|
298 |
+
df = connector.export_issues_to_csv(issues, temp_csv_path)
|
299 |
+
self.data = df
|
300 |
+
|
301 |
+
logger.info(f"Успішно отримано {len(df)} тікетів з Jira")
|
302 |
+
return df
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
logger.error(f"Помилка при отриманні даних з Jira: {e}")
|
306 |
+
return None
|
307 |
+
|
308 |
+
def analyze_data(self, inactive_days=None):
|
309 |
+
"""
|
310 |
+
Аналіз завантажених даних
|
311 |
+
|
312 |
+
Args:
|
313 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
314 |
+
|
315 |
+
Returns:
|
316 |
+
dict: Результати аналізу
|
317 |
+
"""
|
318 |
+
try:
|
319 |
+
if self.data is None:
|
320 |
+
logger.error("Немає даних для аналізу")
|
321 |
+
return None
|
322 |
+
|
323 |
+
logger.info("Аналіз даних...")
|
324 |
+
|
325 |
+
# Параметри аналізу
|
326 |
+
if inactive_days is None:
|
327 |
+
inactive_days = self.config.get("default_inactive_days", 14)
|
328 |
+
|
329 |
+
# Імпортуємо необхідний модуль
|
330 |
+
from modules.data_analysis.statistics import JiraDataAnalyzer
|
331 |
+
|
332 |
+
# Створюємо аналізатор та виконуємо аналіз
|
333 |
+
analyzer = JiraDataAnalyzer(self.data)
|
334 |
+
|
335 |
+
# Генеруємо базову статистику
|
336 |
+
stats = analyzer.generate_basic_statistics()
|
337 |
+
|
338 |
+
# Аналізуємо неактивні тікети
|
339 |
+
inactive_issues = analyzer.analyze_inactive_issues(days=inactive_days)
|
340 |
+
|
341 |
+
# Аналізуємо часову шкалу
|
342 |
+
timeline = analyzer.analyze_timeline()
|
343 |
+
|
344 |
+
# Аналізуємо час виконання
|
345 |
+
lead_time = analyzer.analyze_lead_time()
|
346 |
+
|
347 |
+
# Зберігаємо результати аналізу
|
348 |
+
analysis_result = {
|
349 |
+
"stats": stats,
|
350 |
+
"inactive_issues": inactive_issues,
|
351 |
+
"timeline": timeline.to_dict() if isinstance(timeline, pd.DataFrame) else None,
|
352 |
+
"lead_time": lead_time
|
353 |
+
}
|
354 |
+
|
355 |
+
# Зберігаємо в історії аналізів
|
356 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
357 |
+
self.analyses[timestamp] = analysis_result
|
358 |
+
|
359 |
+
logger.info("Аналіз даних успішно завершено")
|
360 |
+
return analysis_result
|
361 |
+
|
362 |
+
except Exception as e:
|
363 |
+
logger.error(f"Помилка при аналізі даних: {e}")
|
364 |
+
return None
|
365 |
+
|
366 |
+
def generate_visualizations(self, analysis_result=None):
|
367 |
+
"""
|
368 |
+
Генерація візуалізацій на основі аналізу
|
369 |
+
|
370 |
+
Args:
|
371 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
372 |
+
|
373 |
+
Returns:
|
374 |
+
dict: Об'єкти Figure для різних візуалізацій
|
375 |
+
"""
|
376 |
+
try:
|
377 |
+
if self.data is None:
|
378 |
+
logger.error("Немає даних для візуалізації")
|
379 |
+
return None
|
380 |
+
|
381 |
+
# Якщо аналіз не вказано, використовуємо останній
|
382 |
+
if analysis_result is None:
|
383 |
+
if not self.analyses:
|
384 |
+
logger.error("Немає результатів аналізу для візуалізації")
|
385 |
+
return None
|
386 |
+
|
387 |
+
# Отримуємо останній аналіз
|
388 |
+
last_timestamp = max(self.analyses.keys())
|
389 |
+
analysis_result = self.analyses[last_timestamp]
|
390 |
+
|
391 |
+
logger.info("Генерація візуалізацій...")
|
392 |
+
|
393 |
+
# Імпортуємо необхідний модуль
|
394 |
+
from modules.data_analysis.visualizations import JiraVisualizer
|
395 |
+
|
396 |
+
# Створюємо візуалізатор
|
397 |
+
visualizer = JiraVisualizer(self.data)
|
398 |
+
|
399 |
+
# Генеруємо візуалізації
|
400 |
+
visualizations = visualizer.plot_all()
|
401 |
+
|
402 |
+
logger.info("Візуалізації успішно згенеровано")
|
403 |
+
return visualizations
|
404 |
+
|
405 |
+
except Exception as e:
|
406 |
+
logger.error(f"Помилка при генерації візуалізацій: {e}")
|
407 |
+
return None
|
408 |
+
|
409 |
+
def analyze_with_ai(self, analysis_result=None, api_key=None, model_type="openai"):
|
410 |
+
"""
|
411 |
+
Аналіз даних за допомогою AI
|
412 |
+
|
413 |
+
Args:
|
414 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
415 |
+
api_key (str): API ключ для LLM
|
416 |
+
model_type (str): Тип моделі ("openai" або "gemini")
|
417 |
+
|
418 |
+
Returns:
|
419 |
+
str: Результат AI аналізу
|
420 |
+
"""
|
421 |
+
try:
|
422 |
+
# Якщо аналіз не вказано, використовуємо останній
|
423 |
+
if analysis_result is None:
|
424 |
+
if not self.analyses:
|
425 |
+
logger.error("Немає результатів аналізу для AI")
|
426 |
+
return None
|
427 |
+
|
428 |
+
# Отримуємо останній аналіз
|
429 |
+
last_timestamp = max(self.analyses.keys())
|
430 |
+
analysis_result = self.analyses[last_timestamp]
|
431 |
+
|
432 |
+
logger.info(f"Аналіз даних за допомогою AI ({model_type})...")
|
433 |
+
|
434 |
+
# Імпортуємо необхідний модуль
|
435 |
+
from modules.ai_analysis.llm_connector import LLMConnector
|
436 |
+
|
437 |
+
# Створюємо коннектор до LLM
|
438 |
+
llm = LLMConnector(api_key=api_key, model_type=model_type)
|
439 |
+
|
440 |
+
# Виконуємо аналіз
|
441 |
+
stats = analysis_result.get("stats", {})
|
442 |
+
inactive_issues = analysis_result.get("inactive_issues", {})
|
443 |
+
|
444 |
+
ai_analysis = llm.analyze_jira_data(stats, inactive_issues)
|
445 |
+
|
446 |
+
logger.info("AI аналіз успішно завершено")
|
447 |
+
return ai_analysis
|
448 |
+
|
449 |
+
except Exception as e:
|
450 |
+
logger.error(f"Помилка при AI аналізі: {e}")
|
451 |
+
return f"Помилка при виконанні AI аналізу: {str(e)}"
|
452 |
+
|
453 |
+
def generate_report(self, analysis_result=None, ai_analysis=None, format="markdown", include_visualizations=True):
|
454 |
+
"""
|
455 |
+
Генерація звіту на основі аналізу
|
456 |
+
|
457 |
+
Args:
|
458 |
+
analysis_result (dict): Результати аналізу або None для використання останнього аналізу
|
459 |
+
ai_analysis (str): Результат AI аналізу
|
460 |
+
format (str): Формат звіту ("markdown", "html", "pdf")
|
461 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
462 |
+
|
463 |
+
Returns:
|
464 |
+
str: Текст звіту
|
465 |
+
"""
|
466 |
+
try:
|
467 |
+
if self.data is None:
|
468 |
+
logger.error("Немає даних для генерації звіту")
|
469 |
+
return None
|
470 |
+
|
471 |
+
# Якщо аналіз не вказано, використовуємо останній
|
472 |
+
if analysis_result is None:
|
473 |
+
if not self.analyses:
|
474 |
+
logger.error("Немає результатів аналізу для звіту")
|
475 |
+
return None
|
476 |
+
|
477 |
+
# Отримуємо останній ана��із
|
478 |
+
last_timestamp = max(self.analyses.keys())
|
479 |
+
analysis_result = self.analyses[last_timestamp]
|
480 |
+
|
481 |
+
logger.info(f"Генерація звіту у форматі {format}...")
|
482 |
+
|
483 |
+
# Імпортуємо необхідний модуль
|
484 |
+
from modules.reporting.report_generator import ReportGenerator
|
485 |
+
|
486 |
+
# Отримуємо дані з аналізу
|
487 |
+
stats = analysis_result.get("stats", {})
|
488 |
+
inactive_issues = analysis_result.get("inactive_issues", {})
|
489 |
+
|
490 |
+
# Генеруємо візуалізації, якщо потрібно
|
491 |
+
visualization_data = None
|
492 |
+
if include_visualizations:
|
493 |
+
visualization_data = self.generate_visualizations(analysis_result)
|
494 |
+
|
495 |
+
# Створюємо генератор звітів
|
496 |
+
report_generator = ReportGenerator(self.data, stats, inactive_issues, ai_analysis)
|
497 |
+
|
498 |
+
# Генеруємо звіт у потрібному форматі
|
499 |
+
if format.lower() == "markdown":
|
500 |
+
report = report_generator.create_markdown_report()
|
501 |
+
elif format.lower() == "html":
|
502 |
+
report = report_generator.create_html_report(include_visualizations=include_visualizations,
|
503 |
+
visualization_data=visualization_data)
|
504 |
+
else:
|
505 |
+
# Для інших форматів спочатку генеруємо HTML
|
506 |
+
temp_html = report_generator.create_html_report(include_visualizations=include_visualizations,
|
507 |
+
visualization_data=visualization_data)
|
508 |
+
report = temp_html
|
509 |
+
|
510 |
+
# Зберігаємо звіт в історії
|
511 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
512 |
+
self.reports[timestamp] = {
|
513 |
+
"format": format,
|
514 |
+
"report": report
|
515 |
+
}
|
516 |
+
|
517 |
+
logger.info(f"Звіт успішно згенеровано у форматі {format}")
|
518 |
+
return report
|
519 |
+
|
520 |
+
except Exception as e:
|
521 |
+
logger.error(f"Помилка при генерації звіту: {e}")
|
522 |
+
return f"Помилка при генерації звіту: {str(e)}"
|
523 |
+
|
524 |
+
def save_report(self, report=None, filepath=None, format="markdown", include_visualizations=True):
|
525 |
+
"""
|
526 |
+
Збереження звіту у файл
|
527 |
+
|
528 |
+
Args:
|
529 |
+
report (str): Текст звіту або None для генерації нового
|
530 |
+
filepath (str): Шлях для збереження файлу
|
531 |
+
format (str): Формат звіту ("markdown", "html", "pdf")
|
532 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
533 |
+
|
534 |
+
Returns:
|
535 |
+
str: Шлях до збереженого файлу
|
536 |
+
"""
|
537 |
+
try:
|
538 |
+
# Якщо звіт не вказано, генеруємо новий
|
539 |
+
if report is None:
|
540 |
+
report = self.generate_report(format=format, include_visualizations=include_visualizations)
|
541 |
+
|
542 |
+
if report is None:
|
543 |
+
logger.error("Не вдалося згенерувати звіт")
|
544 |
+
return None
|
545 |
+
|
546 |
+
# Якщо шлях не вказано, створюємо стандартний
|
547 |
+
if filepath is None:
|
548 |
+
reports_dir = Path(self.config.get("reports_dir", "reports"))
|
549 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
550 |
+
|
551 |
+
if format.lower() == "markdown":
|
552 |
+
filepath = reports_dir / f"jira_report_{timestamp}.md"
|
553 |
+
elif format.lower() == "html":
|
554 |
+
filepath = reports_dir / f"jira_report_{timestamp}.html"
|
555 |
+
else:
|
556 |
+
filepath = reports_dir / f"jira_report_{timestamp}.pdf"
|
557 |
+
|
558 |
+
# Імпортуємо необхідний модуль
|
559 |
+
from modules.reporting.report_generator import ReportGenerator
|
560 |
+
|
561 |
+
# Створюємо генератор звітів (лише для використання методу save_report)
|
562 |
+
report_generator = ReportGenerator(self.data)
|
563 |
+
|
564 |
+
# Генеруємо візуалізації, якщо потрібно
|
565 |
+
visualization_data = None
|
566 |
+
if include_visualizations:
|
567 |
+
visualization_data = self.generate_visualizations()
|
568 |
+
|
569 |
+
# Зберігаємо звіт
|
570 |
+
saved_path = report_generator.save_report(
|
571 |
+
filepath=str(filepath),
|
572 |
+
format=format,
|
573 |
+
include_visualizations=include_visualizations,
|
574 |
+
visualization_data=visualization_data
|
575 |
+
)
|
576 |
+
|
577 |
+
if saved_path:
|
578 |
+
logger.info(f"Звіт успішно збережено у {saved_path}")
|
579 |
+
return saved_path
|
580 |
+
else:
|
581 |
+
logger.error("Не вдалося зберегти звіт")
|
582 |
+
return None
|
583 |
+
|
584 |
+
except Exception as e:
|
585 |
+
logger.error(f"Помилка при збереженні звіту: {e}")
|
586 |
+
return None
|
587 |
+
|
588 |
+
def send_to_slack(self, channel, message, report=None, api_token=None):
|
589 |
+
"""
|
590 |
+
Відправлення повідомлення в Slack
|
591 |
+
|
592 |
+
Args:
|
593 |
+
channel (str): Назва каналу (наприклад, '#general')
|
594 |
+
message (str): Текст повідомлення
|
595 |
+
report (str): URL або шлях до звіту (необов'язково)
|
596 |
+
api_token (str): Slack Bot Token
|
597 |
+
|
598 |
+
Returns:
|
599 |
+
bool: True, якщо повідомлення успішно відправлено, False у іншому випадку
|
600 |
+
"""
|
601 |
+
try:
|
602 |
+
logger.info(f"Відправлення повідомлення в Slack канал {channel}...")
|
603 |
+
|
604 |
+
# Отримуємо токен
|
605 |
+
token = api_token or os.getenv("SLACK_BOT_TOKEN")
|
606 |
+
|
607 |
+
if not token:
|
608 |
+
logger.error("Не вказано Slack Bot Token")
|
609 |
+
return False
|
610 |
+
|
611 |
+
# Формуємо дані для запиту
|
612 |
+
slack_message = {
|
613 |
+
"channel": channel,
|
614 |
+
"text": message
|
615 |
+
}
|
616 |
+
|
617 |
+
# Якщо є звіт, додаємо його як вкладення
|
618 |
+
if report and report.startswith(("http://", "https://")):
|
619 |
+
slack_message["attachments"] = [
|
620 |
+
{
|
621 |
+
"title": "Звіт аналізу Jira",
|
622 |
+
"title_link": report,
|
623 |
+
"text": "Завантажити звіт"
|
624 |
+
}
|
625 |
+
]
|
626 |
+
|
627 |
+
# Відправляємо запит до Slack API
|
628 |
+
headers = {
|
629 |
+
"Authorization": f"Bearer {token}",
|
630 |
+
"Content-Type": "application/json"
|
631 |
+
}
|
632 |
+
|
633 |
+
response = requests.post(
|
634 |
+
"https://slack.com/api/chat.postMessage",
|
635 |
+
headers=headers,
|
636 |
+
json=slack_message
|
637 |
+
)
|
638 |
+
|
639 |
+
if response.status_code == 200 and response.json().get("ok"):
|
640 |
+
logger.info("Повідомлення успішно відправлено в Slack")
|
641 |
+
return True
|
642 |
+
else:
|
643 |
+
logger.error(f"Помилка при відправленні повідомлення в Slack: {response.text}")
|
644 |
+
return False
|
645 |
+
|
646 |
+
except Exception as e:
|
647 |
+
logger.error(f"Помилка при відправленні повідомлення в Slack: {e}")
|
648 |
+
return False
|
modules/data_analysis/statistics.py
ADDED
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import numpy as np
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import logging
|
5 |
+
|
6 |
+
logger = logging.getLogger(__name__)
|
7 |
+
|
8 |
+
class JiraDataAnalyzer:
|
9 |
+
"""
|
10 |
+
Клас для аналізу даних Jira
|
11 |
+
"""
|
12 |
+
def __init__(self, df):
|
13 |
+
"""
|
14 |
+
Ініціалізація аналізатора даних.
|
15 |
+
|
16 |
+
Args:
|
17 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
18 |
+
"""
|
19 |
+
self.df = df
|
20 |
+
|
21 |
+
def generate_basic_statistics(self):
|
22 |
+
"""
|
23 |
+
Генерація базової статистики по даним Jira.
|
24 |
+
|
25 |
+
Returns:
|
26 |
+
dict: Словник з базовою статистикою
|
27 |
+
"""
|
28 |
+
try:
|
29 |
+
stats = {
|
30 |
+
'total_tickets': len(self.df),
|
31 |
+
'status_counts': {},
|
32 |
+
'type_counts': {},
|
33 |
+
'priority_counts': {},
|
34 |
+
'assignee_counts': {},
|
35 |
+
'created_stats': {},
|
36 |
+
'updated_stats': {}
|
37 |
+
}
|
38 |
+
|
39 |
+
# Статистика за статусами
|
40 |
+
if 'Status' in self.df.columns:
|
41 |
+
status_counts = self.df['Status'].value_counts()
|
42 |
+
stats['status_counts'] = status_counts.to_dict()
|
43 |
+
|
44 |
+
# Статистика за типами
|
45 |
+
if 'Issue Type' in self.df.columns:
|
46 |
+
type_counts = self.df['Issue Type'].value_counts()
|
47 |
+
stats['type_counts'] = type_counts.to_dict()
|
48 |
+
|
49 |
+
# Статистика за пріоритетами
|
50 |
+
if 'Priority' in self.df.columns:
|
51 |
+
priority_counts = self.df['Priority'].value_counts()
|
52 |
+
stats['priority_counts'] = priority_counts.to_dict()
|
53 |
+
|
54 |
+
# Статистика за призначеними користувачами
|
55 |
+
if 'Assignee' in self.df.columns:
|
56 |
+
assignee_counts = self.df['Assignee'].value_counts().head(10) # Топ 10
|
57 |
+
stats['assignee_counts'] = assignee_counts.to_dict()
|
58 |
+
|
59 |
+
# Статистика за часом створення
|
60 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
61 |
+
created_min = self.df['Created'].min()
|
62 |
+
created_max = self.df['Created'].max()
|
63 |
+
|
64 |
+
# Групування за місяцями
|
65 |
+
if 'Created_Month' in self.df.columns:
|
66 |
+
created_by_month = self.df['Created_Month'].value_counts().sort_index()
|
67 |
+
stats['created_by_month'] = {str(k): v for k, v in created_by_month.items()}
|
68 |
+
|
69 |
+
stats['created_stats'] = {
|
70 |
+
'min': created_min.strftime('%Y-%m-%d') if created_min else None,
|
71 |
+
'max': created_max.strftime('%Y-%m-%d') if created_max else None,
|
72 |
+
'last_7_days': len(self.df[self.df['Created'] > (datetime.now() - timedelta(days=7))])
|
73 |
+
}
|
74 |
+
|
75 |
+
# Статистика за часом оновлення
|
76 |
+
if 'Updated' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
77 |
+
updated_min = self.df['Updated'].min()
|
78 |
+
updated_max = self.df['Updated'].max()
|
79 |
+
|
80 |
+
stats['updated_stats'] = {
|
81 |
+
'min': updated_min.strftime('%Y-%m-%d') if updated_min else None,
|
82 |
+
'max': updated_max.strftime('%Y-%m-%d') if updated_max else None,
|
83 |
+
'last_7_days': len(self.df[self.df['Updated'] > (datetime.now() - timedelta(days=7))])
|
84 |
+
}
|
85 |
+
|
86 |
+
logger.info("Базова статистика успішно згенерована")
|
87 |
+
return stats
|
88 |
+
|
89 |
+
except Exception as e:
|
90 |
+
logger.error(f"Помилка при генерації базової статистики: {e}")
|
91 |
+
return {'error': str(e)}
|
92 |
+
|
93 |
+
def analyze_inactive_issues(self, days=14):
|
94 |
+
"""
|
95 |
+
Аналіз неактивних тікетів (не оновлювались протягом певної кількості днів).
|
96 |
+
|
97 |
+
Args:
|
98 |
+
days (int): Кількість днів неактивності
|
99 |
+
|
100 |
+
Returns:
|
101 |
+
dict: Інформація про неактивні тікети
|
102 |
+
"""
|
103 |
+
try:
|
104 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
105 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
106 |
+
return {'error': "Неможливо аналізувати неактивні тікети"}
|
107 |
+
|
108 |
+
# Визначення неактивних тікетів
|
109 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
110 |
+
inactive_issues = self.df[self.df['Updated'] < cutoff_date]
|
111 |
+
|
112 |
+
inactive_data = {
|
113 |
+
'total_count': len(inactive_issues),
|
114 |
+
'percentage': round(len(inactive_issues) / len(self.df) * 100, 2) if len(self.df) > 0 else 0,
|
115 |
+
'by_status': {},
|
116 |
+
'by_priority': {},
|
117 |
+
'top_inactive': []
|
118 |
+
}
|
119 |
+
|
120 |
+
# Розподіл за статусами
|
121 |
+
if 'Status' in inactive_issues.columns:
|
122 |
+
inactive_data['by_status'] = inactive_issues['Status'].value_counts().to_dict()
|
123 |
+
|
124 |
+
# Розподіл за пріоритетами
|
125 |
+
if 'Priority' in inactive_issues.columns:
|
126 |
+
inactive_data['by_priority'] = inactive_issues['Priority'].value_counts().to_dict()
|
127 |
+
|
128 |
+
# Топ 5 неактивних тікетів
|
129 |
+
if len(inactive_issues) > 0:
|
130 |
+
top_inactive = inactive_issues.sort_values('Updated', ascending=True).head(5)
|
131 |
+
|
132 |
+
for _, row in top_inactive.iterrows():
|
133 |
+
issue_data = {
|
134 |
+
'key': row.get('Issue key', 'Unknown'),
|
135 |
+
'summary': row.get('Summary', 'Unknown'),
|
136 |
+
'status': row.get('Status', 'Unknown'),
|
137 |
+
'last_updated': row['Updated'].strftime('%Y-%m-%d') if pd.notna(row['Updated']) else 'Unknown',
|
138 |
+
'days_inactive': (datetime.now() - row['Updated']).days if pd.notna(row['Updated']) else 'Unknown'
|
139 |
+
}
|
140 |
+
inactive_data['top_inactive'].append(issue_data)
|
141 |
+
|
142 |
+
logger.info(f"Знайдено {len(inactive_issues)} неактивних тікетів (>{days} днів)")
|
143 |
+
return inactive_data
|
144 |
+
|
145 |
+
except Exception as e:
|
146 |
+
logger.error(f"Помилка при аналізі неактивних тікетів: {e}")
|
147 |
+
return {'error': str(e)}
|
148 |
+
|
149 |
+
def analyze_timeline(self):
|
150 |
+
"""
|
151 |
+
Аналіз часової шкали проекту (зміна стану тікетів з часом).
|
152 |
+
|
153 |
+
Returns:
|
154 |
+
pandas.DataFrame: Дані для візуалізації або None у випадку помилки
|
155 |
+
"""
|
156 |
+
try:
|
157 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
158 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
159 |
+
return None
|
160 |
+
|
161 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
162 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
163 |
+
return None
|
164 |
+
|
165 |
+
# Визначення часового діапазону
|
166 |
+
min_date = self.df['Created'].min().date()
|
167 |
+
max_date = self.df['Updated'].max().date()
|
168 |
+
|
169 |
+
# Створення часового ряду для кожного дня
|
170 |
+
date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
171 |
+
|
172 |
+
# Збір статистики для кожної дати
|
173 |
+
timeline_data = []
|
174 |
+
|
175 |
+
for date in date_range:
|
176 |
+
date_str = date.strftime('%Y-%m-%d')
|
177 |
+
|
178 |
+
# Тікети, створені до цієї дати
|
179 |
+
created_until = self.df[self.df['Created'].dt.date <= date.date()]
|
180 |
+
|
181 |
+
# Статуси тікетів на цю дату
|
182 |
+
status_counts = {}
|
183 |
+
|
184 |
+
# Для кожного тікета визначаємо його статус на цю дату
|
185 |
+
for _, row in created_until.iterrows():
|
186 |
+
# Якщо тікет був оновлений після цієї дати, використовуємо його поточний статус
|
187 |
+
if row['Updated'].date() >= date.date():
|
188 |
+
status = row.get('Status', 'Unknown')
|
189 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
190 |
+
|
191 |
+
# Додаємо запис для цієї дати
|
192 |
+
timeline_data.append({
|
193 |
+
'Date': date_str,
|
194 |
+
'Total': len(created_until),
|
195 |
+
**status_counts
|
196 |
+
})
|
197 |
+
|
198 |
+
# Створення DataFrame
|
199 |
+
timeline_df = pd.DataFrame(timeline_data)
|
200 |
+
|
201 |
+
logger.info("Часова шкала успішно проаналізована")
|
202 |
+
return timeline_df
|
203 |
+
|
204 |
+
except Exception as e:
|
205 |
+
logger.error(f"Помилка при аналізі часової шкали: {e}")
|
206 |
+
return None
|
207 |
+
|
208 |
+
def analyze_lead_time(self):
|
209 |
+
"""
|
210 |
+
Аналіз часу виконання тікетів (Lead Time).
|
211 |
+
|
212 |
+
Returns:
|
213 |
+
dict: Статистика по часу виконання
|
214 |
+
"""
|
215 |
+
try:
|
216 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
217 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
218 |
+
return {'error': "Неможливо аналізувати час виконання"}
|
219 |
+
|
220 |
+
if 'Resolved' not in self.df.columns:
|
221 |
+
logger.warning("Колонка 'Resolved' відсутня")
|
222 |
+
return {'error': "Неможливо аналізувати час виконання"}
|
223 |
+
|
224 |
+
# Конвертація колонки Resolved до datetime, якщо потрібно
|
225 |
+
if not pd.api.types.is_datetime64_dtype(self.df['Resolved']):
|
226 |
+
self.df['Resolved'] = pd.to_datetime(self.df['Resolved'], errors='coerce')
|
227 |
+
|
228 |
+
# Фільтрація завершених тікетів
|
229 |
+
completed_issues = self.df.dropna(subset=['Resolved'])
|
230 |
+
|
231 |
+
if len(completed_issues) == 0:
|
232 |
+
logger.warning("Немає завершених тікетів для аналізу")
|
233 |
+
return {'total_count': 0}
|
234 |
+
|
235 |
+
# Обчислення Lead Time (в днях)
|
236 |
+
completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days
|
237 |
+
|
238 |
+
# Фільтрація некоректних значень
|
239 |
+
valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0]
|
240 |
+
|
241 |
+
lead_time_stats = {
|
242 |
+
'total_count': len(valid_lead_time),
|
243 |
+
'avg_lead_time': round(valid_lead_time['Lead_Time_Days'].mean(), 2),
|
244 |
+
'median_lead_time': round(valid_lead_time['Lead_Time_Days'].median(), 2),
|
245 |
+
'min_lead_time': valid_lead_time['Lead_Time_Days'].min(),
|
246 |
+
'max_lead_time': valid_lead_time['Lead_Time_Days'].max(),
|
247 |
+
'by_type': {},
|
248 |
+
'by_priority': {}
|
249 |
+
}
|
250 |
+
|
251 |
+
# Розподіл за типами
|
252 |
+
if 'Issue Type' in valid_lead_time.columns:
|
253 |
+
lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean().round(2)
|
254 |
+
lead_time_stats['by_type'] = lead_time_by_type.to_dict()
|
255 |
+
|
256 |
+
# Розподіл за пріоритетами
|
257 |
+
if 'Priority' in valid_lead_time.columns:
|
258 |
+
lead_time_by_priority = valid_lead_time.groupby('Priority')['Lead_Time_Days'].mean().round(2)
|
259 |
+
lead_time_stats['by_priority'] = lead_time_by_priority.to_dict()
|
260 |
+
|
261 |
+
logger.info("Час виконання успішно проаналізований")
|
262 |
+
return lead_time_stats
|
263 |
+
|
264 |
+
except Exception as e:
|
265 |
+
logger.error(f"Помилка при аналізі часу виконання: {e}")
|
266 |
+
return {'error': str(e)}
|
modules/data_analysis/visualizations.py
ADDED
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import numpy as np
|
3 |
+
import matplotlib.pyplot as plt
|
4 |
+
import seaborn as sns
|
5 |
+
from datetime import datetime, timedelta
|
6 |
+
import logging
|
7 |
+
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
class JiraVisualizer:
|
11 |
+
"""
|
12 |
+
Клас для створення візуалізацій даних Jira
|
13 |
+
"""
|
14 |
+
def __init__(self, df):
|
15 |
+
"""
|
16 |
+
Ініціалізація візуалізатора.
|
17 |
+
|
18 |
+
Args:
|
19 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
20 |
+
"""
|
21 |
+
self.df = df
|
22 |
+
self._setup_plot_style()
|
23 |
+
|
24 |
+
def _setup_plot_style(self):
|
25 |
+
"""
|
26 |
+
Налаштування стилю візуалізацій.
|
27 |
+
"""
|
28 |
+
plt.style.use('ggplot')
|
29 |
+
sns.set(style="whitegrid")
|
30 |
+
|
31 |
+
# Налаштування для українських символів
|
32 |
+
plt.rcParams['font.family'] = 'DejaVu Sans'
|
33 |
+
|
34 |
+
def plot_status_counts(self):
|
35 |
+
"""
|
36 |
+
Створення діаграми розподілу тікетів за статусами.
|
37 |
+
|
38 |
+
Returns:
|
39 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
40 |
+
"""
|
41 |
+
try:
|
42 |
+
if 'Status' not in self.df.columns:
|
43 |
+
logger.warning("Колонка 'Status' відсутня")
|
44 |
+
return None
|
45 |
+
|
46 |
+
status_counts = self.df['Status'].value_counts()
|
47 |
+
|
48 |
+
# Створення діаграми
|
49 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
50 |
+
|
51 |
+
# Спроба впорядкувати статуси логічно
|
52 |
+
try:
|
53 |
+
status_order = ['To Do', 'In Progress', 'In Review', 'Done', 'Closed']
|
54 |
+
available_statuses = [s for s in status_order if s in status_counts.index]
|
55 |
+
other_statuses = [s for s in status_counts.index if s not in status_order]
|
56 |
+
ordered_statuses = available_statuses + other_statuses
|
57 |
+
status_counts = status_counts.reindex(ordered_statuses)
|
58 |
+
except:
|
59 |
+
pass
|
60 |
+
|
61 |
+
bars = sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax)
|
62 |
+
|
63 |
+
# Додаємо підписи значень над стовпцями
|
64 |
+
for i, v in enumerate(status_counts.values):
|
65 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
66 |
+
|
67 |
+
ax.set_title('Розподіл тікетів за статусами')
|
68 |
+
ax.set_xlabel('Статус')
|
69 |
+
ax.set_ylabel('Кількість')
|
70 |
+
plt.xticks(rotation=45)
|
71 |
+
plt.tight_layout()
|
72 |
+
|
73 |
+
logger.info("Діаграма статусів успішно створена")
|
74 |
+
return fig
|
75 |
+
|
76 |
+
except Exception as e:
|
77 |
+
logger.error(f"Помилка при створенні діаграми статусів: {e}")
|
78 |
+
return None
|
79 |
+
|
80 |
+
def plot_priority_counts(self):
|
81 |
+
"""
|
82 |
+
Створення діаграми розподілу тікетів за пріоритетами.
|
83 |
+
|
84 |
+
Returns:
|
85 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
86 |
+
"""
|
87 |
+
try:
|
88 |
+
if 'Priority' not in self.df.columns:
|
89 |
+
logger.warning("Колонка 'Priority' відсутня")
|
90 |
+
return None
|
91 |
+
|
92 |
+
priority_counts = self.df['Priority'].value_counts()
|
93 |
+
|
94 |
+
# Створення діаграми
|
95 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
96 |
+
|
97 |
+
# Спроба впорядкувати пріоритети логічно
|
98 |
+
try:
|
99 |
+
priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest']
|
100 |
+
available_priorities = [p for p in priority_order if p in priority_counts.index]
|
101 |
+
other_priorities = [p for p in priority_counts.index if p not in priority_order]
|
102 |
+
ordered_priorities = available_priorities + other_priorities
|
103 |
+
priority_counts = priority_counts.reindex(ordered_priorities)
|
104 |
+
except:
|
105 |
+
pass
|
106 |
+
|
107 |
+
# Кольори для різних пріоритетів
|
108 |
+
colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF']
|
109 |
+
if len(priority_counts) <= len(colors):
|
110 |
+
bars = sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax, palette=colors[:len(priority_counts)])
|
111 |
+
else:
|
112 |
+
bars = sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax)
|
113 |
+
|
114 |
+
# Додаємо підписи значень над стовпцями
|
115 |
+
for i, v in enumerate(priority_counts.values):
|
116 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
117 |
+
|
118 |
+
ax.set_title('Розподіл тікетів з�� пріоритетами')
|
119 |
+
ax.set_xlabel('Пріоритет')
|
120 |
+
ax.set_ylabel('Кількість')
|
121 |
+
plt.xticks(rotation=45)
|
122 |
+
plt.tight_layout()
|
123 |
+
|
124 |
+
logger.info("Діаграма пріоритетів успішно створена")
|
125 |
+
return fig
|
126 |
+
|
127 |
+
except Exception as e:
|
128 |
+
logger.error(f"Помилка при створенні діаграми пріоритетів: {e}")
|
129 |
+
return None
|
130 |
+
|
131 |
+
def plot_type_counts(self):
|
132 |
+
"""
|
133 |
+
Створення діаграми розподілу тікетів за типами.
|
134 |
+
|
135 |
+
Returns:
|
136 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
137 |
+
"""
|
138 |
+
try:
|
139 |
+
if 'Issue Type' not in self.df.columns:
|
140 |
+
logger.warning("Колонка 'Issue Type' відсутня")
|
141 |
+
return None
|
142 |
+
|
143 |
+
type_counts = self.df['Issue Type'].value_counts()
|
144 |
+
|
145 |
+
# Створення діаграми
|
146 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
147 |
+
|
148 |
+
bars = sns.barplot(x=type_counts.index, y=type_counts.values, ax=ax)
|
149 |
+
|
150 |
+
# Додаємо підписи значень над стовпцями
|
151 |
+
for i, v in enumerate(type_counts.values):
|
152 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
153 |
+
|
154 |
+
ax.set_title('Розподіл тікетів за типами')
|
155 |
+
ax.set_xlabel('Тип')
|
156 |
+
ax.set_ylabel('Кількість')
|
157 |
+
plt.xticks(rotation=45)
|
158 |
+
plt.tight_layout()
|
159 |
+
|
160 |
+
logger.info("Діаграма типів успішно створена")
|
161 |
+
return fig
|
162 |
+
|
163 |
+
except Exception as e:
|
164 |
+
logger.error(f"Помилка при створенні діаграми типів: {e}")
|
165 |
+
return None
|
166 |
+
|
167 |
+
def plot_created_timeline(self):
|
168 |
+
"""
|
169 |
+
Створення часової діаграми створення тікетів.
|
170 |
+
|
171 |
+
Returns:
|
172 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
173 |
+
"""
|
174 |
+
try:
|
175 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
176 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
177 |
+
return None
|
178 |
+
|
179 |
+
# Додаємо колонку з датою створення (без часу)
|
180 |
+
if 'Created_Date' not in self.df.columns:
|
181 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
182 |
+
|
183 |
+
# Кількість створених тікетів за датами
|
184 |
+
created_by_date = self.df['Created_Date'].value_counts().sort_index()
|
185 |
+
|
186 |
+
# Створення діаграми
|
187 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
188 |
+
|
189 |
+
created_by_date.plot(kind='line', marker='o', ax=ax)
|
190 |
+
|
191 |
+
ax.set_title('Кількість створених тікетів за датами')
|
192 |
+
ax.set_xlabel('Дата')
|
193 |
+
ax.set_ylabel('Кількість')
|
194 |
+
ax.grid(True)
|
195 |
+
plt.tight_layout()
|
196 |
+
|
197 |
+
logger.info("Часова діаграма успішно створена")
|
198 |
+
return fig
|
199 |
+
|
200 |
+
except Exception as e:
|
201 |
+
logger.error(f"Помилка при створенні часової діаграми: {e}")
|
202 |
+
return None
|
203 |
+
|
204 |
+
def plot_inactive_issues(self, days=14):
|
205 |
+
"""
|
206 |
+
Створення діаграми неактивних тікетів.
|
207 |
+
|
208 |
+
Args:
|
209 |
+
days (int): Кількість днів неактивності
|
210 |
+
|
211 |
+
Returns:
|
212 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
213 |
+
"""
|
214 |
+
try:
|
215 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
216 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
217 |
+
return None
|
218 |
+
|
219 |
+
# Визначення неактивних тікетів
|
220 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
221 |
+
inactive_issues = self.df[self.df['Updated'] < cutoff_date]
|
222 |
+
|
223 |
+
if len(inactive_issues) == 0:
|
224 |
+
logger.warning("Немає неактивних тікетів для візуалізації")
|
225 |
+
return None
|
226 |
+
|
227 |
+
# Розподіл неактивних тікетів за статусами
|
228 |
+
if 'Status' in inactive_issues.columns:
|
229 |
+
inactive_by_status = inactive_issues['Status'].value_counts()
|
230 |
+
|
231 |
+
# Створення діаграми
|
232 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
233 |
+
|
234 |
+
bars = sns.barplot(x=inactive_by_status.index, y=inactive_by_status.values, ax=ax)
|
235 |
+
|
236 |
+
# Додаємо підписи значень над стовпцями
|
237 |
+
for i, v in enumerate(inactive_by_status.values):
|
238 |
+
ax.text(i, v + 0.5, str(v), ha='center')
|
239 |
+
|
240 |
+
ax.set_title(f'Розподіл неактивних тікетів за статусами (>{days} днів)')
|
241 |
+
ax.set_xlabel('Статус')
|
242 |
+
ax.set_ylabel('Кількість')
|
243 |
+
plt.xticks(rotation=45)
|
244 |
+
plt.tight_layout()
|
245 |
+
|
246 |
+
logger.info("Діаграма неактивних тікетів успішно створена")
|
247 |
+
return fig
|
248 |
+
else:
|
249 |
+
logger.warning("Колонка 'Status' відсутня для неактивних тікетів")
|
250 |
+
return None
|
251 |
+
|
252 |
+
except Exception as e:
|
253 |
+
logger.error(f"Помилка при створенні діаграми неактивних тікетів: {e}")
|
254 |
+
return None
|
255 |
+
|
256 |
+
def plot_status_timeline(self, timeline_df=None):
|
257 |
+
"""
|
258 |
+
Створення діаграми зміни статусів з часом.
|
259 |
+
|
260 |
+
Args:
|
261 |
+
timeline_df (pandas.DataFrame): DataFrame з часовими даними або None для автоматичного генерування
|
262 |
+
|
263 |
+
Returns:
|
264 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
265 |
+
"""
|
266 |
+
try:
|
267 |
+
if timeline_df is None:
|
268 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
269 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
270 |
+
return None
|
271 |
+
|
272 |
+
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
273 |
+
logger.warning("Колонка 'Updated' відсутня або не містить дат")
|
274 |
+
return None
|
275 |
+
|
276 |
+
# Визначення часового діапазону
|
277 |
+
min_date = self.df['Created'].min().date()
|
278 |
+
max_date = self.df['Updated'].max().date()
|
279 |
+
|
280 |
+
# Створення часового ряду для кожного дня
|
281 |
+
date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
282 |
+
|
283 |
+
# Збір статистики для кожної дати
|
284 |
+
timeline_data = []
|
285 |
+
|
286 |
+
for date in date_range:
|
287 |
+
date_str = date.strftime('%Y-%m-%d')
|
288 |
+
|
289 |
+
# Тікети, створені до цієї дати
|
290 |
+
created_until = self.df[self.df['Created'].dt.date <= date.date()]
|
291 |
+
|
292 |
+
# Статуси тікетів на цю дату
|
293 |
+
status_counts = {}
|
294 |
+
|
295 |
+
# Для кожного тікета визначаємо його статус на цю дату
|
296 |
+
for _, row in created_until.iterrows():
|
297 |
+
# Якщо тікет був оновлений після цієї дати, використовуємо його поточний статус
|
298 |
+
if row['Updated'].date() >= date.date():
|
299 |
+
status = row.get('Status', 'Unknown')
|
300 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
301 |
+
|
302 |
+
# Додаємо запис для цієї дати
|
303 |
+
timeline_data.append({
|
304 |
+
'Date': date_str,
|
305 |
+
'Total': len(created_until),
|
306 |
+
**status_counts
|
307 |
+
})
|
308 |
+
|
309 |
+
# Створення DataFrame
|
310 |
+
timeline_df = pd.DataFrame(timeline_data)
|
311 |
+
|
312 |
+
# Конвертація Date до datetime
|
313 |
+
timeline_df['Date'] = pd.to_datetime(timeline_df['Date'])
|
314 |
+
else:
|
315 |
+
# Конвертація Date до datetime, якщо потрібно
|
316 |
+
if not pd.api.types.is_datetime64_dtype(timeline_df['Date']):
|
317 |
+
timeline_df['Date'] = pd.to_datetime(timeline_df['Date'])
|
318 |
+
|
319 |
+
# Отримання статусів (всі колонки, крім Date і Total)
|
320 |
+
status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']]
|
321 |
+
|
322 |
+
if not status_columns:
|
323 |
+
logger.warning("Немає даних про статуси для візуалізації")
|
324 |
+
return None
|
325 |
+
|
326 |
+
# Створення діаграми
|
327 |
+
fig, ax = plt.subplots(figsize=(14, 8))
|
328 |
+
|
329 |
+
# Створення сетплоту для статусів
|
330 |
+
status_data = timeline_df[['Date'] + status_columns].set_index('Date')
|
331 |
+
status_data.plot.area(ax=ax, stacked=True, alpha=0.7)
|
332 |
+
|
333 |
+
ax.set_title('Зміна статусів тікетів з часом')
|
334 |
+
ax.set_xlabel('Дата')
|
335 |
+
ax.set_ylabel('Кількість тікетів')
|
336 |
+
ax.grid(True)
|
337 |
+
plt.tight_layout()
|
338 |
+
|
339 |
+
logger.info("Часова діаграма статусів успішно створена")
|
340 |
+
return fig
|
341 |
+
|
342 |
+
except Exception as e:
|
343 |
+
logger.error(f"Помилка при створенні часової діаграми статусів: {e}")
|
344 |
+
return None
|
345 |
+
|
346 |
+
def plot_lead_time_by_type(self):
|
347 |
+
"""
|
348 |
+
Створення діаграми часу виконання за типами тікетів.
|
349 |
+
|
350 |
+
Returns:
|
351 |
+
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки
|
352 |
+
"""
|
353 |
+
try:
|
354 |
+
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']):
|
355 |
+
logger.warning("Колонка 'Created' відсутня або не містить дат")
|
356 |
+
return None
|
357 |
+
|
358 |
+
if 'Resolved' not in self.df.columns:
|
359 |
+
logger.warning("Колонка 'Resolved' відсутня")
|
360 |
+
return None
|
361 |
+
|
362 |
+
if 'Issue Type' not in self.df.columns:
|
363 |
+
logger.warning("Колонка 'Issue Type' відсутня")
|
364 |
+
return None
|
365 |
+
|
366 |
+
# Конвертація колонки Resolved до datetime, якщо потрібно
|
367 |
+
if not pd.api.types.is_datetime64_dtype(self.df['Resolved']):
|
368 |
+
self.df['Resolved'] = pd.to_datetime(self.df['Resolved'], errors='coerce')
|
369 |
+
|
370 |
+
# Фільтрація завершених тікетів
|
371 |
+
completed_issues = self.df.dropna(subset=['Resolved'])
|
372 |
+
|
373 |
+
if len(completed_issues) == 0:
|
374 |
+
logger.warning("Немає завершених тікетів для аналізу")
|
375 |
+
return None
|
376 |
+
|
377 |
+
# Обчислення Lead Time (в днях)
|
378 |
+
completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days
|
379 |
+
|
380 |
+
# Фільтрація некоректних значень
|
381 |
+
valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0]
|
382 |
+
|
383 |
+
if len(valid_lead_time) == 0:
|
384 |
+
logger.warning("Немає валідних даних про час виконання")
|
385 |
+
return None
|
386 |
+
|
387 |
+
# Обчислення середнього часу виконання за типами
|
388 |
+
lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean()
|
389 |
+
|
390 |
+
# Створення діаграми
|
391 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
392 |
+
|
393 |
+
bars = sns.barplot(x=lead_time_by_type.index, y=lead_time_by_type.values, ax=ax)
|
394 |
+
|
395 |
+
# Додаємо підписи значень над стовпцями
|
396 |
+
for i, v in enumerate(lead_time_by_type.values):
|
397 |
+
ax.text(i, v + 0.5, f"{v:.1f}", ha='center')
|
398 |
+
|
399 |
+
ax.set_title('Середній час виконання тікетів за типами (дні)')
|
400 |
+
ax.set_xlabel('Тип')
|
401 |
+
ax.set_ylabel('Дні')
|
402 |
+
plt.xticks(rotation=45)
|
403 |
+
plt.tight_layout()
|
404 |
+
|
405 |
+
logger.info("Діаграма часу виконання успішно створена")
|
406 |
+
return fig
|
407 |
+
|
408 |
+
except Exception as e:
|
409 |
+
logger.error(f"Помилка при створенні діаграми часу виконання: {e}")
|
410 |
+
return None
|
411 |
+
|
412 |
+
def plot_all(self, output_dir=None):
|
413 |
+
"""
|
414 |
+
Створення та збереження всіх діаграм.
|
415 |
+
|
416 |
+
Args:
|
417 |
+
output_dir (str): Директорія для збереження діаграм.
|
418 |
+
Якщо None, діаграми не зберігаються.
|
419 |
+
|
420 |
+
Returns:
|
421 |
+
dict: Словник з об'єктами figure для всіх діаграм
|
422 |
+
"""
|
423 |
+
plots = {}
|
424 |
+
|
425 |
+
# Створення діаграм
|
426 |
+
plots['status'] = self.plot_status_counts()
|
427 |
+
plots['priority'] = self.plot_priority_counts()
|
428 |
+
plots['type'] = self.plot_type_counts()
|
429 |
+
plots['created_timeline'] = self.plot_created_timeline()
|
430 |
+
plots['inactive'] = self.plot_inactive_issues()
|
431 |
+
plots['status_timeline'] = self.plot_status_timeline()
|
432 |
+
plots['lead_time'] = self.plot_lead_time_by_type()
|
433 |
+
|
434 |
+
# Збереження діаграм, якщо вказана директорія
|
435 |
+
if output_dir:
|
436 |
+
import os
|
437 |
+
from pathlib import Path
|
438 |
+
|
439 |
+
# Створення директорії, якщо вона не існує
|
440 |
+
output_path = Path(output_dir)
|
441 |
+
output_path.mkdir(exist_ok=True, parents=True)
|
442 |
+
|
443 |
+
# Збереження кожної діаграми
|
444 |
+
for name, fig in plots.items():
|
445 |
+
if fig:
|
446 |
+
fig_path = output_path / f"{name}.png"
|
447 |
+
fig.savefig(fig_path, dpi=300)
|
448 |
+
logger.info(f"Діаграма {name} збережена у {fig_path}")
|
449 |
+
|
450 |
+
return plots
|
modules/data_import/csv_importer.py
ADDED
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
from datetime import datetime
|
3 |
+
import logging
|
4 |
+
import os
|
5 |
+
from pathlib import Path
|
6 |
+
|
7 |
+
logger = logging.getLogger(__name__)
|
8 |
+
|
9 |
+
class JiraCsvImporter:
|
10 |
+
"""
|
11 |
+
Клас для імпорту даних з CSV-файлів Jira
|
12 |
+
"""
|
13 |
+
def __init__(self, file_path):
|
14 |
+
"""
|
15 |
+
Ініціалізація імпортера CSV.
|
16 |
+
|
17 |
+
Args:
|
18 |
+
file_path (str): Шлях до CSV-файлу
|
19 |
+
"""
|
20 |
+
self.file_path = file_path
|
21 |
+
self.df = None
|
22 |
+
|
23 |
+
def load_data(self):
|
24 |
+
"""
|
25 |
+
Завантаження даних з CSV-файлу.
|
26 |
+
|
27 |
+
Returns:
|
28 |
+
pandas.DataFrame: Завантажені дані або None у випадку помилки
|
29 |
+
"""
|
30 |
+
try:
|
31 |
+
logger.info(f"Завантаження CSV-файлу: {self.file_path}")
|
32 |
+
print(f"Завантаження CSV-файлу: {self.file_path}") # Додаткове логування в консоль
|
33 |
+
|
34 |
+
# Перевірка існування файлу
|
35 |
+
if not os.path.exists(self.file_path):
|
36 |
+
logger.error(f"Файл не знайдено: {self.file_path}")
|
37 |
+
print(f"Файл не знайдено: {self.file_path}")
|
38 |
+
return None
|
39 |
+
|
40 |
+
# Спробуємо різні кодування
|
41 |
+
try:
|
42 |
+
self.df = pd.read_csv(self.file_path, encoding='utf-8')
|
43 |
+
print(f"Файл успішно прочитано з кодуванням utf-8")
|
44 |
+
except UnicodeDecodeError:
|
45 |
+
try:
|
46 |
+
logger.warning("Помилка декодування UTF-8, спроба з latin1")
|
47 |
+
print("Помилка декодування UTF-8, спроба з latin1")
|
48 |
+
self.df = pd.read_csv(self.file_path, encoding='latin1')
|
49 |
+
print(f"Файл успішно прочитано з кодуванням latin1")
|
50 |
+
except Exception as e:
|
51 |
+
logger.error(f"Помилка при спробі прочитати з latin1: {e}")
|
52 |
+
print(f"Помилка при спробі прочитати з latin1: {e}")
|
53 |
+
# Спробуємо читати без вказання кодування
|
54 |
+
self.df = pd.read_csv(self.file_path)
|
55 |
+
print(f"Файл успішно прочитано зі стандартним кодуванням")
|
56 |
+
|
57 |
+
# Тимчасово вимкнемо перевірку колонок для діагностики
|
58 |
+
# required_columns = self._check_required_columns()
|
59 |
+
# if not required_columns:
|
60 |
+
# logger.warning("CSV-файл не містить необхідних колонок")
|
61 |
+
# print("CSV-файл не містить необхідних колонок")
|
62 |
+
# return None
|
63 |
+
|
64 |
+
# Відображення наявних колонок для діагностики
|
65 |
+
print(f"Наявні колонки: {self.df.columns.tolist()}")
|
66 |
+
print(f"Кількість рядків: {len(self.df)}")
|
67 |
+
|
68 |
+
# Обробка дат
|
69 |
+
self._process_dates()
|
70 |
+
|
71 |
+
# Очищення та підготовка даних
|
72 |
+
self._clean_data()
|
73 |
+
|
74 |
+
logger.info(f"Успішно завантажено {len(self.df)} записів")
|
75 |
+
print(f"Успішно завантажено {len(self.df)} записів")
|
76 |
+
return self.df
|
77 |
+
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"Помилка при завантаженні CSV-файлу: {e}")
|
80 |
+
import traceback
|
81 |
+
error_details = traceback.format_exc()
|
82 |
+
print(f"Помилка при завантаженні CSV-файлу: {e}")
|
83 |
+
print(f"Деталі помилки: {error_details}")
|
84 |
+
return None
|
85 |
+
|
86 |
+
def _check_required_columns(self):
|
87 |
+
"""
|
88 |
+
Перевірка наявності необхідних колонок у CSV-файлі.
|
89 |
+
|
90 |
+
Returns:
|
91 |
+
bool: True, якщо всі необхідні колонки присутні
|
92 |
+
"""
|
93 |
+
# Основні колонки, які очікуються у файлі Jira
|
94 |
+
basic_columns = ['Summary', 'Issue key', 'Status', 'Issue Type', 'Priority', 'Created', 'Updated']
|
95 |
+
|
96 |
+
# Альтернативні назви колонок
|
97 |
+
alternative_columns = {
|
98 |
+
'Summary': ['Summary', 'Короткий опис'],
|
99 |
+
'Issue key': ['Issue key', 'Key', 'Ключ'],
|
100 |
+
'Status': ['Status', 'Статус'],
|
101 |
+
'Issue Type': ['Issue Type', 'Type', 'Тип'],
|
102 |
+
'Priority': ['Priority', 'Пріоритет'],
|
103 |
+
'Created': ['Created', 'Ств��рено'],
|
104 |
+
'Updated': ['Updated', 'Оновлено']
|
105 |
+
}
|
106 |
+
|
107 |
+
# Перевірка наявності колонок
|
108 |
+
missing_columns = []
|
109 |
+
|
110 |
+
for col in basic_columns:
|
111 |
+
found = False
|
112 |
+
|
113 |
+
# Перевірка основної назви
|
114 |
+
if col in self.df.columns:
|
115 |
+
found = True
|
116 |
+
else:
|
117 |
+
# Перевірка альтернативних назв
|
118 |
+
for alt_col in alternative_columns.get(col, []):
|
119 |
+
if alt_col in self.df.columns:
|
120 |
+
# Перейменування колонки до стандартного імені
|
121 |
+
self.df.rename(columns={alt_col: col}, inplace=True)
|
122 |
+
found = True
|
123 |
+
break
|
124 |
+
|
125 |
+
if not found:
|
126 |
+
missing_columns.append(col)
|
127 |
+
|
128 |
+
if missing_columns:
|
129 |
+
logger.warning(f"Відсутні колонки: {', '.join(missing_columns)}")
|
130 |
+
print(f"Відсутні колонки: {', '.join(missing_columns)}")
|
131 |
+
return False
|
132 |
+
|
133 |
+
return True
|
134 |
+
|
135 |
+
def _process_dates(self):
|
136 |
+
"""
|
137 |
+
Обробка дат у DataFrame.
|
138 |
+
"""
|
139 |
+
try:
|
140 |
+
# Перетворення колонок з датами
|
141 |
+
date_columns = ['Created', 'Updated', 'Resolved', 'Due Date']
|
142 |
+
|
143 |
+
for col in date_columns:
|
144 |
+
if col in self.df.columns:
|
145 |
+
try:
|
146 |
+
self.df[col] = pd.to_datetime(self.df[col], errors='coerce')
|
147 |
+
print(f"Колонку {col} успішно конвертовано до datetime")
|
148 |
+
except Exception as e:
|
149 |
+
logger.warning(f"Не вдалося конвертувати колонку {col} до datetime: {e}")
|
150 |
+
print(f"Не вдалося конвертувати колонку {col} до datetime: {e}")
|
151 |
+
|
152 |
+
except Exception as e:
|
153 |
+
logger.error(f"Помилка при обробці дат: {e}")
|
154 |
+
print(f"Помилка при обробці дат: {e}")
|
155 |
+
|
156 |
+
def _clean_data(self):
|
157 |
+
"""
|
158 |
+
Очищення та підготовка даних.
|
159 |
+
"""
|
160 |
+
try:
|
161 |
+
# Видалення порожніх рядків
|
162 |
+
if 'Issue key' in self.df.columns:
|
163 |
+
self.df.dropna(subset=['Issue key'], inplace=True)
|
164 |
+
print(f"Видалено порожні рядки за колонкою 'Issue key'")
|
165 |
+
|
166 |
+
# Додаткова обробка даних
|
167 |
+
if 'Status' in self.df.columns:
|
168 |
+
self.df['Status'] = self.df['Status'].fillna('Unknown')
|
169 |
+
print(f"Заповнено відсутні значення в колонці 'Status'")
|
170 |
+
|
171 |
+
if 'Priority' in self.df.columns:
|
172 |
+
self.df['Priority'] = self.df['Priority'].fillna('Not set')
|
173 |
+
print(f"Заповнено відсутні значення в колонці 'Priority'")
|
174 |
+
|
175 |
+
# Створення додаткових колонок для аналізу
|
176 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
177 |
+
self.df['Created_Date'] = self.df['Created'].dt.date
|
178 |
+
self.df['Created_Month'] = self.df['Created'].dt.to_period('M')
|
179 |
+
print(f"Створено додаткові колонки для дат створення")
|
180 |
+
|
181 |
+
if 'Updated' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Updated']):
|
182 |
+
self.df['Updated_Date'] = self.df['Updated'].dt.date
|
183 |
+
self.df['Days_Since_Update'] = (datetime.now() - self.df['Updated']).dt.days
|
184 |
+
print(f"Створено додаткові колонки для дат оновлення")
|
185 |
+
|
186 |
+
except Exception as e:
|
187 |
+
logger.error(f"Помилка при очищенні даних: {e}")
|
188 |
+
print(f"Помилка при очищенні даних: {e}")
|
189 |
+
|
190 |
+
def export_to_csv(self, output_path=None):
|
191 |
+
"""
|
192 |
+
Експорт оброблених даних у CSV-файл.
|
193 |
+
|
194 |
+
Args:
|
195 |
+
output_path (str): Шлях для збереження файлу. Якщо None, створюється автоматично.
|
196 |
+
|
197 |
+
Returns:
|
198 |
+
str: Шлях до збереженого файлу або None у випадку помилки
|
199 |
+
"""
|
200 |
+
if self.df is None:
|
201 |
+
logger.error("Немає даних для експорту")
|
202 |
+
return None
|
203 |
+
|
204 |
+
try:
|
205 |
+
if output_path is None:
|
206 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
207 |
+
output_dir = Path("exported_data")
|
208 |
+
output_dir.mkdir(exist_ok=True)
|
209 |
+
output_path = output_dir / f"jira_data_{timestamp}.csv"
|
210 |
+
|
211 |
+
self.df.to_csv(output_path, index=False, encoding='utf-8')
|
212 |
+
logger.info(f"Дані успішно експортовано у {output_path}")
|
213 |
+
return str(output_path)
|
214 |
+
|
215 |
+
except Exception as e:
|
216 |
+
logger.error(f"Помилка при експорті даних: {e}")
|
217 |
+
return None
|
modules/data_import/jira_api.py
ADDED
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import pandas as pd
|
4 |
+
import logging
|
5 |
+
import requests
|
6 |
+
from datetime import datetime, timedelta
|
7 |
+
from pathlib import Path
|
8 |
+
from jira import JIRA
|
9 |
+
import urllib3
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class JiraConnector:
|
14 |
+
"""
|
15 |
+
Клас для взаємодії з API Jira та отримання даних
|
16 |
+
"""
|
17 |
+
def __init__(self, jira_url, jira_username, jira_api_token):
|
18 |
+
"""
|
19 |
+
Ініціалізація з'єднання з Jira.
|
20 |
+
|
21 |
+
Args:
|
22 |
+
jira_url (str): URL Jira сервера
|
23 |
+
jira_username (str): Ім'я користувача (email)
|
24 |
+
jira_api_token (str): API токен
|
25 |
+
"""
|
26 |
+
self.jira_url = jira_url
|
27 |
+
self.jira_username = jira_username
|
28 |
+
self.jira_api_token = jira_api_token
|
29 |
+
self.jira = self._connect()
|
30 |
+
|
31 |
+
def _connect(self):
|
32 |
+
"""
|
33 |
+
Підключення до Jira API.
|
34 |
+
|
35 |
+
Returns:
|
36 |
+
jira.JIRA: Об'єкт для взаємодії з Jira або None у випадку помилки
|
37 |
+
"""
|
38 |
+
try:
|
39 |
+
jira = JIRA(
|
40 |
+
server=self.jira_url,
|
41 |
+
basic_auth=(self.jira_username, self.jira_api_token),
|
42 |
+
options={'timeout': 30}
|
43 |
+
)
|
44 |
+
logger.info("Успішне підключення до Jira")
|
45 |
+
return jira
|
46 |
+
except Exception as e:
|
47 |
+
logger.error(f"Помилка підключення до Jira: {e}")
|
48 |
+
return None
|
49 |
+
|
50 |
+
def get_project_issues(self, project_key, max_results=500):
|
51 |
+
"""
|
52 |
+
Отримання тікетів проекту.
|
53 |
+
|
54 |
+
Args:
|
55 |
+
project_key (str): Ключ проекту Jira
|
56 |
+
max_results (int): Максимальна кількість тікетів для отримання
|
57 |
+
|
58 |
+
Returns:
|
59 |
+
list: Список тікетів або [] у випадку помилки
|
60 |
+
"""
|
61 |
+
try:
|
62 |
+
if self.jira is None:
|
63 |
+
logger.error("Немає з'єднання з Jira")
|
64 |
+
return []
|
65 |
+
|
66 |
+
jql = f'project = {project_key} ORDER BY updated DESC'
|
67 |
+
logger.info(f"Виконання JQL запиту: {jql}")
|
68 |
+
|
69 |
+
issues = self.jira.search_issues(
|
70 |
+
jql,
|
71 |
+
maxResults=max_results,
|
72 |
+
fields="summary,status,issuetype,priority,labels,components,created,updated,assignee,reporter,description,comment"
|
73 |
+
)
|
74 |
+
|
75 |
+
logger.info(f"Отримано {len(issues)} тікетів для проекту {project_key}")
|
76 |
+
return issues
|
77 |
+
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"Помилка отримання тікетів: {e}")
|
80 |
+
return []
|
81 |
+
|
82 |
+
def get_board_issues(self, board_id, project_key, max_results=500):
|
83 |
+
"""
|
84 |
+
Отримання тікетів дошки.
|
85 |
+
|
86 |
+
Args:
|
87 |
+
board_id (int): ID дошки Jira
|
88 |
+
project_key (str): Ключ проекту для фільтрації
|
89 |
+
max_results (int): Максимальна кількість тікетів для отримання
|
90 |
+
|
91 |
+
Returns:
|
92 |
+
list: Список тікетів або [] у випадку помилки
|
93 |
+
"""
|
94 |
+
try:
|
95 |
+
if self.jira is None:
|
96 |
+
logger.error("Немає з'єднання з Jira")
|
97 |
+
return []
|
98 |
+
|
99 |
+
issues = []
|
100 |
+
start_at = 0
|
101 |
+
logger.info(f"Отримання тікетів з дошки ID: {board_id}, проект: {project_key}...")
|
102 |
+
|
103 |
+
while True:
|
104 |
+
logger.info(f" Отримання тікетів (з {start_at}, максимум 100)...")
|
105 |
+
|
106 |
+
batch = self.jira.search_issues(
|
107 |
+
f'project = {project_key} ORDER BY updated DESC',
|
108 |
+
startAt=start_at,
|
109 |
+
maxResults=100,
|
110 |
+
fields="summary,status,issuetype,priority,labels,components,created,updated,assignee,reporter,description,comment"
|
111 |
+
)
|
112 |
+
|
113 |
+
if not batch:
|
114 |
+
break
|
115 |
+
|
116 |
+
issues.extend(batch)
|
117 |
+
start_at += len(batch)
|
118 |
+
logger.info(f" Отримано {len(batch)} тікетів, загалом {len(issues)}")
|
119 |
+
|
120 |
+
if len(batch) < 100 or len(issues) >= max_results:
|
121 |
+
break
|
122 |
+
|
123 |
+
logger.info(f"Загалом отримано {len(issues)} тікетів з дошки {board_id}")
|
124 |
+
return issues
|
125 |
+
|
126 |
+
except Exception as e:
|
127 |
+
logger.error(f"Помилка отримання тікетів дошки: {e}")
|
128 |
+
logger.error(f"Деталі помилки: {str(e)}")
|
129 |
+
return []
|
130 |
+
|
131 |
+
def export_issues_to_csv(self, issues, filepath):
|
132 |
+
"""
|
133 |
+
Експорт тікетів у CSV-файл.
|
134 |
+
|
135 |
+
Args:
|
136 |
+
issues (list): Список тікетів Jira
|
137 |
+
filepath (str): Шлях для збереження CSV-файлу
|
138 |
+
|
139 |
+
Returns:
|
140 |
+
pandas.DataFrame: DataFrame з даними або None у випадку помилки
|
141 |
+
"""
|
142 |
+
if not issues:
|
143 |
+
logger.warning("Немає тікетів для експорту")
|
144 |
+
return None
|
145 |
+
|
146 |
+
try:
|
147 |
+
data = []
|
148 |
+
|
149 |
+
for issue in issues:
|
150 |
+
# Визначення даних тікета з коректною обробкою потенційно відсутніх полів
|
151 |
+
issue_data = {
|
152 |
+
'Issue key': issue.key,
|
153 |
+
'Summary': getattr(issue.fields, 'summary', None),
|
154 |
+
'Status': getattr(issue.fields.status, 'name', None) if hasattr(issue.fields, 'status') else None,
|
155 |
+
'Issue Type': getattr(issue.fields.issuetype, 'name', None) if hasattr(issue.fields, 'issuetype') else None,
|
156 |
+
'Priority': getattr(issue.fields.priority, 'name', None) if hasattr(issue.fields, 'priority') else None,
|
157 |
+
'Components': ','.join([c.name for c in issue.fields.components]) if hasattr(issue.fields, 'components') and issue.fields.components else '',
|
158 |
+
'Labels': ','.join(issue.fields.labels) if hasattr(issue.fields, 'labels') and issue.fields.labels else '',
|
159 |
+
'Created': getattr(issue.fields, 'created', None),
|
160 |
+
'Updated': getattr(issue.fields, 'updated', None),
|
161 |
+
'Assignee': getattr(issue.fields.assignee, 'displayName', None) if hasattr(issue.fields, 'assignee') and issue.fields.assignee else None,
|
162 |
+
'Reporter': getattr(issue.fields.reporter, 'displayName', None) if hasattr(issue.fields, 'reporter') and issue.fields.reporter else None,
|
163 |
+
'Description': getattr(issue.fields, 'description', None),
|
164 |
+
'Comments Count': len(issue.fields.comment.comments) if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments') else 0
|
165 |
+
}
|
166 |
+
|
167 |
+
# Додаємо коментарі, якщо вони є
|
168 |
+
if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments'):
|
169 |
+
for i, comment in enumerate(issue.fields.comment.comments[:3]): # Беремо перші 3 коментарі
|
170 |
+
issue_data[f'Comment {i+1}'] = comment.body
|
171 |
+
|
172 |
+
data.append(issue_data)
|
173 |
+
|
174 |
+
# Створення DataFrame
|
175 |
+
df = pd.DataFrame(data)
|
176 |
+
|
177 |
+
# Збереження в CSV
|
178 |
+
df.to_csv(filepath, index=False, encoding='utf-8')
|
179 |
+
logger.info(f"Дані експортовано у {filepath}")
|
180 |
+
|
181 |
+
return df
|
182 |
+
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Помилка при експорті даних: {e}")
|
185 |
+
return None
|
186 |
+
|
187 |
+
def get_project_info(self, project_key):
|
188 |
+
"""
|
189 |
+
Отримання інформації про проект.
|
190 |
+
|
191 |
+
Args:
|
192 |
+
project_key (str): Ключ проекту Jira
|
193 |
+
|
194 |
+
Returns:
|
195 |
+
dict: Інформація про проект або None у випадку помилки
|
196 |
+
"""
|
197 |
+
try:
|
198 |
+
if self.jira is None:
|
199 |
+
logger.error("Немає з'єднання з Jira")
|
200 |
+
return None
|
201 |
+
|
202 |
+
project = self.jira.project(project_key)
|
203 |
+
|
204 |
+
project_info = {
|
205 |
+
'key': project.key,
|
206 |
+
'name': project.name,
|
207 |
+
'lead': project.lead.displayName,
|
208 |
+
'description': project.description,
|
209 |
+
'url': f"{self.jira_url}/projects/{project.key}"
|
210 |
+
}
|
211 |
+
|
212 |
+
logger.info(f"Отримано інформацію про проект {project_key}")
|
213 |
+
return project_info
|
214 |
+
|
215 |
+
except Exception as e:
|
216 |
+
logger.error(f"Помилка отримання інформації про проект: {e}")
|
217 |
+
return None
|
218 |
+
|
219 |
+
def get_boards_list(self, project_key=None):
|
220 |
+
"""
|
221 |
+
Отримання списку дошок.
|
222 |
+
|
223 |
+
Args:
|
224 |
+
project_key (str): Ключ проекту для фільтрації (необов'язково)
|
225 |
+
|
226 |
+
Returns:
|
227 |
+
list: Список дошок або [] у випадку помилки
|
228 |
+
"""
|
229 |
+
try:
|
230 |
+
if self.jira is None:
|
231 |
+
logger.error("Немає з'єднання з Jira")
|
232 |
+
return []
|
233 |
+
|
234 |
+
# Отримання всіх дошок
|
235 |
+
all_boards = self.jira.boards()
|
236 |
+
|
237 |
+
# Фільтрація за проектом, якщо вказано
|
238 |
+
if project_key:
|
239 |
+
boards = []
|
240 |
+
for board in all_boards:
|
241 |
+
# Перевірка, чи дошка належить до вказаного проекту
|
242 |
+
if hasattr(board, 'location') and hasattr(board.location, 'projectKey') and board.location.projectKey == project_key:
|
243 |
+
boards.append(board)
|
244 |
+
# Або якщо назва дошки містить ключ проекту
|
245 |
+
elif project_key in board.name:
|
246 |
+
boards.append(board)
|
247 |
+
else:
|
248 |
+
boards = all_boards
|
249 |
+
|
250 |
+
# Формування результату
|
251 |
+
result = []
|
252 |
+
for board in boards:
|
253 |
+
board_info = {
|
254 |
+
'id': board.id,
|
255 |
+
'name': board.name,
|
256 |
+
'type': board.type
|
257 |
+
}
|
258 |
+
|
259 |
+
if hasattr(board, 'location'):
|
260 |
+
board_info['project_key'] = getattr(board.location, 'projectKey', None)
|
261 |
+
board_info['project_name'] = getattr(board.location, 'projectName', None)
|
262 |
+
|
263 |
+
result.append(board_info)
|
264 |
+
|
265 |
+
logger.info(f"Отримано {len(result)} дошок")
|
266 |
+
return result
|
267 |
+
|
268 |
+
except Exception as e:
|
269 |
+
logger.error(f"Помилка отримання списку дошок: {e}")
|
270 |
+
return []
|
271 |
+
|
272 |
+
def get_issue_details(self, issue_key):
|
273 |
+
"""
|
274 |
+
Отримання детальної інформації про тікет.
|
275 |
+
|
276 |
+
Args:
|
277 |
+
issue_key (str): Ключ тікета
|
278 |
+
|
279 |
+
Returns:
|
280 |
+
dict: Детальна інформація про тікет або None у випадку помилки
|
281 |
+
"""
|
282 |
+
try:
|
283 |
+
if self.jira is None:
|
284 |
+
logger.error("Немає з'єднання з Jira")
|
285 |
+
return None
|
286 |
+
|
287 |
+
issue = self.jira.issue(issue_key)
|
288 |
+
|
289 |
+
# Базова інформація
|
290 |
+
issue_details = {
|
291 |
+
'key': issue.key,
|
292 |
+
'summary': issue.fields.summary,
|
293 |
+
'status': issue.fields.status.name,
|
294 |
+
'issue_type': issue.fields.issuetype.name,
|
295 |
+
'priority': issue.fields.priority.name if hasattr(issue.fields, 'priority') and issue.fields.priority else None,
|
296 |
+
'created': issue.fields.created,
|
297 |
+
'updated': issue.fields.updated,
|
298 |
+
'description': issue.fields.description,
|
299 |
+
'assignee': issue.fields.assignee.displayName if hasattr(issue.fields, 'assignee') and issue.fields.assignee else None,
|
300 |
+
'reporter': issue.fields.reporter.displayName if hasattr(issue.fields, 'reporter') and issue.fields.reporter else None,
|
301 |
+
'url': f"{self.jira_url}/browse/{issue.key}"
|
302 |
+
}
|
303 |
+
|
304 |
+
# Додаємо коментарі
|
305 |
+
comments = []
|
306 |
+
if hasattr(issue.fields, 'comment') and hasattr(issue.fields.comment, 'comments'):
|
307 |
+
for comment in issue.fields.comment.comments:
|
308 |
+
comments.append({
|
309 |
+
'author': comment.author.displayName,
|
310 |
+
'created': comment.created,
|
311 |
+
'body': comment.body
|
312 |
+
})
|
313 |
+
|
314 |
+
issue_details['comments'] = comments
|
315 |
+
|
316 |
+
# Додаємо історію змін
|
317 |
+
changelog = self.jira.issue(issue_key, expand='changelog').changelog
|
318 |
+
history = []
|
319 |
+
|
320 |
+
for history_item in changelog.histories:
|
321 |
+
item_info = {
|
322 |
+
'author': history_item.author.displayName,
|
323 |
+
'created': history_item.created,
|
324 |
+
'changes': []
|
325 |
+
}
|
326 |
+
|
327 |
+
for item in history_item.items:
|
328 |
+
item_info['changes'].append({
|
329 |
+
'field': item.field,
|
330 |
+
'from_value': item.fromString,
|
331 |
+
'to_value': item.toString
|
332 |
+
})
|
333 |
+
|
334 |
+
history.append(item_info)
|
335 |
+
|
336 |
+
issue_details['history'] = history
|
337 |
+
|
338 |
+
logger.info(f"Отримано детальну інформацію про тікет {issue_key}")
|
339 |
+
return issue_details
|
340 |
+
|
341 |
+
except Exception as e:
|
342 |
+
logger.error(f"Помилка отримання деталей тікета: {e}")
|
343 |
+
return None
|
344 |
+
|
345 |
+
@staticmethod
|
346 |
+
def test_connection(url, username, api_token):
|
347 |
+
"""
|
348 |
+
Тестування підключення до Jira.
|
349 |
+
|
350 |
+
Args:
|
351 |
+
url (str): URL Jira сервера
|
352 |
+
username (str): Ім'я користувача (email)
|
353 |
+
api_token (str): API токен
|
354 |
+
|
355 |
+
Returns:
|
356 |
+
bool: True, якщо підключення успішне, False у іншому випадку
|
357 |
+
"""
|
358 |
+
logger.info(f"Тестування підключення до Jira: {url}")
|
359 |
+
logger.info(f"Користувач: {username}")
|
360 |
+
|
361 |
+
# Спроба прямого HTTP запиту до сервера
|
362 |
+
try:
|
363 |
+
logger.info("Спроба прямого HTTP запиту до сервера...")
|
364 |
+
|
365 |
+
response = requests.get(
|
366 |
+
f"{url}/rest/api/2/serverInfo",
|
367 |
+
auth=(username, api_token),
|
368 |
+
timeout=10,
|
369 |
+
verify=True # Змініть на False, якщо у вас самопідписаний сертифікат
|
370 |
+
)
|
371 |
+
|
372 |
+
logger.info(f"Статус відповіді: {response.status_code}")
|
373 |
+
|
374 |
+
if response.status_code == 200:
|
375 |
+
logger.info(f"Відповідь: {response.text[:200]}...")
|
376 |
+
return True
|
377 |
+
else:
|
378 |
+
logger.error(f"Помилка: {response.text}")
|
379 |
+
return False
|
380 |
+
|
381 |
+
except Exception as e:
|
382 |
+
logger.error(f"Помилка HTTP запиту: {type(e).__name__}: {str(e)}")
|
383 |
+
logger.error(f"Деталі винятку: {repr(e)}")
|
384 |
+
return False
|
modules/reporting/report_generator.py
ADDED
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import logging
|
3 |
+
import pandas as pd
|
4 |
+
import re
|
5 |
+
from datetime import datetime
|
6 |
+
from pathlib import Path
|
7 |
+
import markdown
|
8 |
+
import matplotlib.pyplot as plt
|
9 |
+
import base64
|
10 |
+
from io import BytesIO
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class ReportGenerator:
|
15 |
+
"""
|
16 |
+
Клас для генерації звітів на основі аналізу даних Jira
|
17 |
+
"""
|
18 |
+
def __init__(self, df, stats=None, inactive_issues=None, ai_analysis=None):
|
19 |
+
"""
|
20 |
+
Ініціалізація генератора звітів.
|
21 |
+
|
22 |
+
Args:
|
23 |
+
df (pandas.DataFrame): DataFrame з даними Jira
|
24 |
+
stats (dict): Словник зі статистикою (або None)
|
25 |
+
inactive_issues (dict): Дані про неактивні тікети (або None)
|
26 |
+
ai_analysis (str): Текст AI аналізу (або None)
|
27 |
+
"""
|
28 |
+
self.df = df
|
29 |
+
self.stats = stats
|
30 |
+
self.inactive_issues = inactive_issues
|
31 |
+
self.ai_analysis = ai_analysis
|
32 |
+
|
33 |
+
def create_markdown_report(self, inactive_days=14):
|
34 |
+
"""
|
35 |
+
Створення звіту у форматі Markdown.
|
36 |
+
|
37 |
+
Args:
|
38 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
39 |
+
|
40 |
+
Returns:
|
41 |
+
str: Текст звіту у форматі Markdown
|
42 |
+
"""
|
43 |
+
try:
|
44 |
+
report = []
|
45 |
+
|
46 |
+
# Заголовок звіту
|
47 |
+
report.append("# Звіт аналізу Jira")
|
48 |
+
report.append(f"*Створено: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
49 |
+
|
50 |
+
# Загальна статистика
|
51 |
+
report.append("\n## Загальна статистика")
|
52 |
+
|
53 |
+
if self.stats and 'total_tickets' in self.stats:
|
54 |
+
report.append(f"**Загальна кількість тікетів:** {self.stats['total_tickets']}")
|
55 |
+
else:
|
56 |
+
report.append(f"**Загальна кількість тікетів:** {len(self.df)}")
|
57 |
+
|
58 |
+
# Статистика за статусами
|
59 |
+
if self.stats and 'status_counts' in self.stats and self.stats['status_counts']:
|
60 |
+
report.append("\n### Статуси тікетів")
|
61 |
+
|
62 |
+
for status, count in self.stats['status_counts'].items():
|
63 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
64 |
+
report.append(f"- **{status}:** {count} ({percentage:.1f}%)")
|
65 |
+
elif 'Status' in self.df.columns:
|
66 |
+
status_counts = self.df['Status'].value_counts()
|
67 |
+
report.append("\n### Статуси тікетів")
|
68 |
+
|
69 |
+
for status, count in status_counts.items():
|
70 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
71 |
+
report.append(f"- **{status}:** {count} ({percentage:.1f}%)")
|
72 |
+
|
73 |
+
# Статистика за типами
|
74 |
+
if self.stats and 'type_counts' in self.stats and self.stats['type_counts']:
|
75 |
+
report.append("\n### Типи тікетів")
|
76 |
+
|
77 |
+
for type_name, count in self.stats['type_counts'].items():
|
78 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
79 |
+
report.append(f"- **{type_name}:** {count} ({percentage:.1f}%)")
|
80 |
+
elif 'Issue Type' in self.df.columns:
|
81 |
+
type_counts = self.df['Issue Type'].value_counts()
|
82 |
+
report.append("\n### Типи тікетів")
|
83 |
+
|
84 |
+
for type_name, count in type_counts.items():
|
85 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
86 |
+
report.append(f"- **{type_name}:** {count} ({percentage:.1f}%)")
|
87 |
+
|
88 |
+
# Статистика за пріоритетами
|
89 |
+
if self.stats and 'priority_counts' in self.stats and self.stats['priority_counts']:
|
90 |
+
report.append("\n### Пріоритети тікетів")
|
91 |
+
|
92 |
+
for priority, count in self.stats['priority_counts'].items():
|
93 |
+
percentage = count / self.stats['total_tickets'] * 100 if self.stats['total_tickets'] > 0 else 0
|
94 |
+
report.append(f"- **{priority}:** {count} ({percentage:.1f}%)")
|
95 |
+
elif 'Priority' in self.df.columns:
|
96 |
+
priority_counts = self.df['Priority'].value_counts()
|
97 |
+
report.append("\n### Пріоритети тікетів")
|
98 |
+
|
99 |
+
for priority, count in priority_counts.items():
|
100 |
+
percentage = count / len(self.df) * 100 if len(self.df) > 0 else 0
|
101 |
+
report.append(f"- **{priority}:** {count} ({percentage:.1f}%)")
|
102 |
+
|
103 |
+
# Аналіз часових показників
|
104 |
+
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']):
|
105 |
+
report.append("\n## Часові показники")
|
106 |
+
|
107 |
+
min_date = self.df['Created'].min()
|
108 |
+
max_date = self.df['Created'].max()
|
109 |
+
|
110 |
+
report.append(f"**Період створення тікетів:** з {min_date.strftime('%Y-%m-%d')} по {max_date.strftime('%Y-%m-%d')}")
|
111 |
+
|
112 |
+
# Тікети за останній тиждень
|
113 |
+
last_week = (datetime.now() - pd.Timedelta(days=7))
|
114 |
+
recent_tickets = self.df[self.df['Created'] >= last_week]
|
115 |
+
report.append(f"**Тікети, створені за останній тиждень:** {len(recent_tickets)}")
|
116 |
+
|
117 |
+
# Неактивні тікети
|
118 |
+
if self.inactive_issues:
|
119 |
+
report.append(f"\n## Неактивні тікети (>{inactive_days} днів)")
|
120 |
+
|
121 |
+
total_inactive = self.inactive_issues.get('total_count', 0)
|
122 |
+
percentage = self.inactive_issues.get('percentage', 0)
|
123 |
+
|
124 |
+
report.append(f"**Загальна кількість неактивних тікетів:** {total_inactive} ({percentage:.1f}%)")
|
125 |
+
|
126 |
+
if 'by_status' in self.inactive_issues and self.inactive_issues['by_status']:
|
127 |
+
report.append("\n**Неактивні тікети за статусами:**")
|
128 |
+
|
129 |
+
for status, count in self.inactive_issues['by_status'].items():
|
130 |
+
report.append(f"- **{status}:** {count}")
|
131 |
+
|
132 |
+
if 'top_inactive' in self.inactive_issues and self.inactive_issues['top_inactive']:
|
133 |
+
report.append("\n**Топ 5 найбільш неактивних тікетів:**")
|
134 |
+
|
135 |
+
for i, ticket in enumerate(self.inactive_issues['top_inactive']):
|
136 |
+
key = ticket.get('key', 'Невідомо')
|
137 |
+
summary = ticket.get('summary', 'Невідомо')
|
138 |
+
status = ticket.get('status', 'Невідомо')
|
139 |
+
days = ticket.get('days_inactive', 'Невідомо')
|
140 |
+
|
141 |
+
report.append(f"{i+1}. **{key}:** {summary}")
|
142 |
+
report.append(f" - Статус: {status}")
|
143 |
+
report.append(f" - Днів неактивності: {days}")
|
144 |
+
|
145 |
+
# AI Аналіз
|
146 |
+
if self.ai_analysis:
|
147 |
+
report.append("\n## AI Аналіз")
|
148 |
+
report.append(self.ai_analysis)
|
149 |
+
|
150 |
+
logger.info("Звіт успішно згенеровано у форматі Markdown")
|
151 |
+
return "\n".join(report)
|
152 |
+
|
153 |
+
except Exception as e:
|
154 |
+
logger.error(f"Помилка при створенні звіту: {e}")
|
155 |
+
return f"Помилка при створенні звіту: {str(e)}"
|
156 |
+
|
157 |
+
def create_html_report(self, inactive_days=14, include_visualizations=False, visualization_data=None):
|
158 |
+
"""
|
159 |
+
Створення звіту у форматі HTML.
|
160 |
+
|
161 |
+
Args:
|
162 |
+
inactive_days (int): Кількість днів для визначення неактивних тікетів
|
163 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
164 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
165 |
+
|
166 |
+
Returns:
|
167 |
+
str: Текст звіту у форматі HTML
|
168 |
+
"""
|
169 |
+
try:
|
170 |
+
# Спочатку створюємо звіт у форматі Markdown
|
171 |
+
md_report = self.create_markdown_report(inactive_days)
|
172 |
+
|
173 |
+
# Конвертуємо Markdown у HTML
|
174 |
+
html_report = self.convert_markdown_to_html(md_report)
|
175 |
+
|
176 |
+
# Додаємо візуалізації, якщо потрібно
|
177 |
+
if include_visualizations and visualization_data:
|
178 |
+
html_with_charts = self._add_visualizations_to_html(html_report, visualization_data)
|
179 |
+
return html_with_charts
|
180 |
+
|
181 |
+
return html_report
|
182 |
+
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Помилка при створенні HTML звіту: {e}")
|
185 |
+
return f"<h1>Помилка при створенні звіту</h1><p>{str(e)}</p>"
|
186 |
+
|
187 |
+
def convert_markdown_to_html(self, md_text):
|
188 |
+
"""
|
189 |
+
Конвертація тексту з формату Markdown у HTML.
|
190 |
+
|
191 |
+
Args:
|
192 |
+
md_text (str): Текст у форматі Markdown
|
193 |
+
|
194 |
+
Returns:
|
195 |
+
str: Текст у форматі HTML
|
196 |
+
"""
|
197 |
+
try:
|
198 |
+
# Додаємо CSS стилі
|
199 |
+
css = """
|
200 |
+
<style>
|
201 |
+
body { font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; max-width: 1200px; margin: 0 auto; }
|
202 |
+
h1, h2, h3 { color: #0052CC; }
|
203 |
+
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
|
204 |
+
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
205 |
+
th { background-color: #0052CC; color: white; }
|
206 |
+
tr:hover { background-color: #f5f5f5; }
|
207 |
+
.progress-container { width: 100%; background-color: #f1f1f1; border-radius: 3px; }
|
208 |
+
.progress-bar { height: 20px; border-radius: 3px; }
|
209 |
+
img { max-width: 100%; }
|
210 |
+
</style>
|
211 |
+
"""
|
212 |
+
|
213 |
+
# Конвертація Markdown в HTML
|
214 |
+
html_content = markdown.markdown(md_text, extensions=['tables', 'fenced_code'])
|
215 |
+
|
216 |
+
# Складаємо повний HTML документ
|
217 |
+
html = f"""<!DOCTYPE html>
|
218 |
+
<html lang="uk">
|
219 |
+
<head>
|
220 |
+
<meta charset="UTF-8">
|
221 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
222 |
+
<title>Звіт аналізу Jira</title>
|
223 |
+
{css}
|
224 |
+
</head>
|
225 |
+
<body>
|
226 |
+
{html_content}
|
227 |
+
</body>
|
228 |
+
</html>
|
229 |
+
"""
|
230 |
+
|
231 |
+
return html
|
232 |
+
|
233 |
+
except Exception as e:
|
234 |
+
logger.error(f"Помилка при конвертації Markdown в HTML: {e}")
|
235 |
+
return f"<h1>Помилка при конвертації звіту</h1><p>{str(e)}</p>"
|
236 |
+
|
237 |
+
def _add_visualizations_to_html(self, html_content, visualization_data):
|
238 |
+
"""
|
239 |
+
Додавання візуалізацій до HTML звіту.
|
240 |
+
|
241 |
+
Args:
|
242 |
+
html_content (str): Текст HTML звіту
|
243 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
244 |
+
|
245 |
+
Returns:
|
246 |
+
str: HTML звіт з візуалізаціями
|
247 |
+
"""
|
248 |
+
try:
|
249 |
+
# Додаємо розділ з візуалізаціями перед закриваючим тегом body
|
250 |
+
charts_html = "<h2>Візуалізації</h2>"
|
251 |
+
|
252 |
+
# Конвертуємо кожну візуалізацію у base64 та додаємо до HTML
|
253 |
+
for name, fig in visualization_data.items():
|
254 |
+
if fig:
|
255 |
+
# Зберігаємо фігуру в байтовий потік
|
256 |
+
buf = BytesIO()
|
257 |
+
fig.savefig(buf, format='png', dpi=100)
|
258 |
+
buf.seek(0)
|
259 |
+
|
260 |
+
# Конвертуємо в base64
|
261 |
+
img_str = base64.b64encode(buf.read()).decode('utf-8')
|
262 |
+
|
263 |
+
# Додаємо зображення до HTML
|
264 |
+
title_map = {
|
265 |
+
'status': 'Статуси тікетів',
|
266 |
+
'priority': 'Пріоритети тікетів',
|
267 |
+
'type': 'Типи тікетів',
|
268 |
+
'created_timeline': 'Часова шкала створення тікетів',
|
269 |
+
'inactive': 'Неактивні тікети',
|
270 |
+
'status_timeline': 'Зміна статусів з часом',
|
271 |
+
'lead_time': 'Час виконання тікетів за типами'
|
272 |
+
}
|
273 |
+
|
274 |
+
title = title_map.get(name, name.replace('_', ' ').title())
|
275 |
+
|
276 |
+
charts_html += f"""
|
277 |
+
<div style="text-align: center; margin-bottom: 30px;">
|
278 |
+
<h3>{title}</h3>
|
279 |
+
<img src="data:image/png;base64,{img_str}" alt="{title}" style="max-width: 100%;">
|
280 |
+
</div>
|
281 |
+
"""
|
282 |
+
|
283 |
+
# Вставляємо візуалізації перед закриваючим тегом body
|
284 |
+
html_with_charts = html_content.replace("</body>", f"{charts_html}</body>")
|
285 |
+
|
286 |
+
return html_with_charts
|
287 |
+
|
288 |
+
except Exception as e:
|
289 |
+
logger.error(f"Помилка при додаванні візуалізацій до HTML: {e}")
|
290 |
+
return html_content
|
291 |
+
|
292 |
+
def save_report(self, filepath, format='markdown', include_visualizations=False, visualization_data=None):
|
293 |
+
"""
|
294 |
+
Збереження звіту у файл.
|
295 |
+
|
296 |
+
Args:
|
297 |
+
filepath (str): Шлях до файлу для збереження
|
298 |
+
format (str): Формат звіту ('markdown', 'html', 'pdf')
|
299 |
+
include_visualizations (bool): Чи включати візуалізації у звіт
|
300 |
+
visualization_data (dict): Словник з об'єктами Figure для візуалізацій
|
301 |
+
|
302 |
+
Returns:
|
303 |
+
str: Шлях до збереженого файлу або None у випадку помилки
|
304 |
+
"""
|
305 |
+
try:
|
306 |
+
# Створення директорії для файлу, якщо вона не існує
|
307 |
+
directory = os.path.dirname(filepath)
|
308 |
+
if directory and not os.path.exists(directory):
|
309 |
+
os.makedirs(directory)
|
310 |
+
|
311 |
+
# Вибір формату та створення звіту
|
312 |
+
if format.lower() == 'markdown':
|
313 |
+
report_text = self.create_markdown_report()
|
314 |
+
|
315 |
+
# Перевірка розширення файлу
|
316 |
+
if not filepath.lower().endswith('.md'):
|
317 |
+
filepath += '.md'
|
318 |
+
|
319 |
+
# Збереження у файл
|
320 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
321 |
+
f.write(report_text)
|
322 |
+
|
323 |
+
elif format.lower() == 'html':
|
324 |
+
html_report = self.create_html_report(include_visualizations=include_visualizations,
|
325 |
+
visualization_data=visualization_data)
|
326 |
+
|
327 |
+
# Перевірка розширення файлу
|
328 |
+
if not filepath.lower().endswith('.html'):
|
329 |
+
filepath += '.html'
|
330 |
+
|
331 |
+
# Збереження у файл
|
332 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
333 |
+
f.write(html_report)
|
334 |
+
|
335 |
+
elif format.lower() == 'pdf':
|
336 |
+
# Створення спочатку HTML
|
337 |
+
html_report = self.create_html_report(include_visualizations=include_visualizations,
|
338 |
+
visualization_data=visualization_data)
|
339 |
+
|
340 |
+
# Перевірка розширення файлу
|
341 |
+
if not filepath.lower().endswith('.pdf'):
|
342 |
+
filepath += '.pdf'
|
343 |
+
|
344 |
+
# Створення тимчасового HTML-файлу
|
345 |
+
temp_html_path = filepath + "_temp.html"
|
346 |
+
with open(temp_html_path, 'w', encoding='utf-8') as f:
|
347 |
+
f.write(html_report)
|
348 |
+
|
349 |
+
try:
|
350 |
+
# Конвертація HTML в PDF
|
351 |
+
from weasyprint import HTML
|
352 |
+
HTML(filename=temp_html_path).write_pdf(filepath)
|
353 |
+
|
354 |
+
# Видалення тимчасового HTML-файлу
|
355 |
+
if os.path.exists(temp_html_path):
|
356 |
+
os.remove(temp_html_path)
|
357 |
+
|
358 |
+
except Exception as e:
|
359 |
+
logger.error(f"Помилка при конвертації в PDF: {e}")
|
360 |
+
return None
|
361 |
+
else:
|
362 |
+
logger.error(f"Непідтримуваний формат звіту: {format}")
|
363 |
+
return None
|
364 |
+
|
365 |
+
logger.info(f"Звіт успішно збережено у файл: {filepath}")
|
366 |
+
return filepath
|
367 |
+
|
368 |
+
except Exception as e:
|
369 |
+
logger.error(f"Помилка при збереженні звіту: {e}")
|
370 |
+
return None
|
reports/jira_report_20250228_094906.md
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Звіт аналізу Jira
|
2 |
+
*Створено: 2025-02-28 09:48:16*
|
3 |
+
|
4 |
+
## Загальна статистика
|
5 |
+
**Загальна кількість тікетів:** 33
|
6 |
+
|
7 |
+
### Статуси тікетів
|
8 |
+
- **To Do:** 22 (66.7%)
|
9 |
+
- **Done:** 7 (21.2%)
|
10 |
+
- **Not relevant:** 1 (3.0%)
|
11 |
+
- **In Progress:** 1 (3.0%)
|
12 |
+
- **Committed:** 1 (3.0%)
|
13 |
+
- **READY FOR TEST ON UAT:** 1 (3.0%)
|
14 |
+
|
15 |
+
### Типи тікетів
|
16 |
+
- **Task:** 19 (57.6%)
|
17 |
+
- **Sub-task:** 9 (27.3%)
|
18 |
+
- **Epic:** 3 (9.1%)
|
19 |
+
- **Story:** 1 (3.0%)
|
20 |
+
- **Bug:** 1 (3.0%)
|
21 |
+
|
22 |
+
### Пріоритети тікетів
|
23 |
+
- **Medium:** 28 (84.8%)
|
24 |
+
- **High:** 2 (6.1%)
|
25 |
+
- **Low:** 2 (6.1%)
|
26 |
+
- **Highest:** 1 (3.0%)
|
27 |
+
|
28 |
+
## Неактивні тікети (>14 днів)
|
29 |
+
**Загальна кількість неактивних тікетів:** 26 (78.79%)
|
30 |
+
|
31 |
+
**Неактивні тікети за статусами:**
|
32 |
+
- **To Do:** 15
|
33 |
+
- **Done:** 7
|
34 |
+
- **Not relevant:** 1
|
35 |
+
- **In Progress:** 1
|
36 |
+
- **Committed:** 1
|
37 |
+
- **READY FOR TEST ON UAT:** 1
|
38 |
+
|
39 |
+
**Топ 5 найбільш неактивних тікетів:**
|
40 |
+
1. **IEE-788:** related outstanding keywords
|
41 |
+
- Статус: Done
|
42 |
+
- Днів неактивності: 1592
|
43 |
+
2. **IEE-968:** Left/Right blockers
|
44 |
+
- Статус: Done
|
45 |
+
- Днів неактивності: 1353
|
46 |
+
3. **PAT-287:** CLONE - Extract/Process Location and TS admission from ADT-05
|
47 |
+
- Статус: Done
|
48 |
+
- Днів неактивності: 1324
|
49 |
+
4. **IEE-947:** Priority of specifics TS
|
50 |
+
- Статус: Done
|
51 |
+
- Днів неактивності: 1212
|
52 |
+
5. **IEE-494:** Free Text in structured fields
|
53 |
+
- Статус: Done
|
54 |
+
- Днів неактивності: 1151
|
requirements.txt
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
gradio==5.19.0
|
2 |
+
jira==3.5.2
|
3 |
+
pandas==2.1.0
|
4 |
+
numpy==1.26.0
|
5 |
+
matplotlib==3.7.2
|
6 |
+
seaborn==0.12.2
|
7 |
+
python-dotenv==1.0.0
|
8 |
+
markdown==3.4.4
|
9 |
+
pathlib==1.0.1
|
temp/temp_20250228_093950.csv
ADDED
File without changes
|
temp/temp_20250228_094358.csv
ADDED
File without changes
|