DocUA commited on
Commit
a7174ff
·
0 Parent(s):

Initial commit

Browse files
.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