@theihtisham/dev-pulse 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +12 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +43 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +33 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +18 -0
- package/.github/dependabot.yml +16 -0
- package/.github/workflows/ci.yml +33 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/Dockerfile +8 -0
- package/LICENSE +21 -21
- package/README.md +135 -39
- package/SECURITY.md +22 -0
- package/devpulse/__init__.py +4 -4
- package/devpulse/api/__init__.py +1 -1
- package/devpulse/api/app.py +371 -371
- package/devpulse/cli/__init__.py +1 -1
- package/devpulse/cli/dashboard.py +131 -131
- package/devpulse/cli/main.py +678 -678
- package/devpulse/cli/render.py +175 -175
- package/devpulse/core/__init__.py +34 -34
- package/devpulse/core/analytics.py +487 -487
- package/devpulse/core/config.py +77 -77
- package/devpulse/core/database.py +612 -612
- package/devpulse/core/github_client.py +281 -281
- package/devpulse/core/models.py +142 -142
- package/devpulse/core/report_generator.py +454 -454
- package/devpulse/static/.gitkeep +1 -1
- package/devpulse/templates/report.html +64 -64
- package/package.json +35 -35
- package/pyproject.toml +80 -80
- package/requirements.txt +14 -14
- package/tests/__init__.py +1 -1
- package/tests/conftest.py +208 -208
- package/tests/test_analytics.py +284 -284
- package/tests/test_api.py +313 -313
- package/tests/test_cli.py +204 -204
- package/tests/test_config.py +47 -47
- package/tests/test_database.py +255 -255
- package/tests/test_models.py +107 -107
- package/tests/test_report_generator.py +173 -173
- package/jest.config.js +0 -7
package/tests/test_analytics.py
CHANGED
|
@@ -1,284 +1,284 @@
|
|
|
1
|
-
"""Tests for the AnalyticsEngine."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from devpulse.core.analytics import AnalyticsEngine, _parse_date, _days_between
|
|
6
|
-
from devpulse.core.models import DeveloperMetrics, TeamHealth
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class TestParseDate:
|
|
10
|
-
"""Test date parsing utility."""
|
|
11
|
-
|
|
12
|
-
def test_iso_with_tz(self):
|
|
13
|
-
dt = _parse_date("2026-04-01T10:00:00Z")
|
|
14
|
-
assert dt is not None
|
|
15
|
-
assert dt.year == 2026
|
|
16
|
-
assert dt.month == 4
|
|
17
|
-
assert dt.day == 1
|
|
18
|
-
|
|
19
|
-
def test_iso_without_tz(self):
|
|
20
|
-
dt = _parse_date("2026-04-01T10:00:00")
|
|
21
|
-
assert dt is not None
|
|
22
|
-
|
|
23
|
-
def test_date_only(self):
|
|
24
|
-
dt = _parse_date("2026-04-01")
|
|
25
|
-
assert dt is not None
|
|
26
|
-
|
|
27
|
-
def test_empty_string(self):
|
|
28
|
-
assert _parse_date("") is None
|
|
29
|
-
|
|
30
|
-
def test_none(self):
|
|
31
|
-
assert _parse_date(None) is None
|
|
32
|
-
|
|
33
|
-
def test_invalid_format(self):
|
|
34
|
-
assert _parse_date("not-a-date") is None
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class TestDaysBetween:
|
|
38
|
-
"""Test time difference computation."""
|
|
39
|
-
|
|
40
|
-
def test_same_day(self):
|
|
41
|
-
hours = _days_between("2026-04-01T10:00:00Z", "2026-04-01T14:00:00Z")
|
|
42
|
-
assert hours == 4.0
|
|
43
|
-
|
|
44
|
-
def test_24_hours(self):
|
|
45
|
-
hours = _days_between("2026-04-01T00:00:00Z", "2026-04-02T00:00:00Z")
|
|
46
|
-
assert hours == 24.0
|
|
47
|
-
|
|
48
|
-
def test_empty_start(self):
|
|
49
|
-
hours = _days_between("", "2026-04-01T10:00:00Z")
|
|
50
|
-
assert hours == 0.0
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class TestDeveloperMetrics:
|
|
54
|
-
"""Test developer metrics computation."""
|
|
55
|
-
|
|
56
|
-
def test_basic_metrics(self, populated_db):
|
|
57
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
58
|
-
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
59
|
-
|
|
60
|
-
assert isinstance(metrics, DeveloperMetrics)
|
|
61
|
-
assert metrics.author == "Alice"
|
|
62
|
-
assert metrics.commits_count == 3
|
|
63
|
-
assert metrics.prs_created == 2
|
|
64
|
-
assert metrics.prs_merged == 1 # Only PR #1 is merged and authored by Alice
|
|
65
|
-
|
|
66
|
-
def test_bob_metrics(self, populated_db):
|
|
67
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
68
|
-
metrics = engine.developer_metrics(author="Bob", days=30)
|
|
69
|
-
|
|
70
|
-
assert metrics.author == "Bob"
|
|
71
|
-
assert metrics.commits_count == 1
|
|
72
|
-
assert metrics.prs_created == 1
|
|
73
|
-
|
|
74
|
-
def test_unknown_developer(self, populated_db):
|
|
75
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
76
|
-
metrics = engine.developer_metrics(author="Nobody", days=30)
|
|
77
|
-
|
|
78
|
-
assert metrics.commits_count == 0
|
|
79
|
-
assert metrics.author == "Nobody"
|
|
80
|
-
|
|
81
|
-
def test_active_days(self, populated_db):
|
|
82
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
83
|
-
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
84
|
-
|
|
85
|
-
assert metrics.active_days == 2 # April 1 and April 3
|
|
86
|
-
|
|
87
|
-
def test_commits_per_day(self, populated_db):
|
|
88
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
89
|
-
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
90
|
-
|
|
91
|
-
assert metrics.commits_per_day > 0
|
|
92
|
-
assert metrics.period_days == 30
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
class TestTeamMetrics:
|
|
96
|
-
"""Test team-wide metrics."""
|
|
97
|
-
|
|
98
|
-
def test_team_metrics_list(self, populated_db):
|
|
99
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
100
|
-
team = engine.team_metrics(days=30)
|
|
101
|
-
|
|
102
|
-
assert len(team) >= 2 # At least Alice and Bob
|
|
103
|
-
authors = {m.author for m in team}
|
|
104
|
-
assert "Alice" in authors
|
|
105
|
-
assert "Bob" in authors
|
|
106
|
-
|
|
107
|
-
def test_empty_team(self, db):
|
|
108
|
-
engine = AnalyticsEngine(db=db)
|
|
109
|
-
team = engine.team_metrics(days=30)
|
|
110
|
-
assert team == []
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
class TestTeamHealth:
|
|
114
|
-
"""Test team health analysis."""
|
|
115
|
-
|
|
116
|
-
def test_team_health_with_data(self, populated_db):
|
|
117
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
118
|
-
health = engine.team_health(days=30)
|
|
119
|
-
|
|
120
|
-
assert isinstance(health, TeamHealth)
|
|
121
|
-
assert 0 <= health.overall_score <= 100
|
|
122
|
-
assert 0 <= health.workload_balance <= 1
|
|
123
|
-
assert 0 <= health.collaboration_score <= 1
|
|
124
|
-
assert health.velocity_trend in ("growing", "stable", "declining")
|
|
125
|
-
assert len(health.recommendations) > 0
|
|
126
|
-
|
|
127
|
-
def test_team_health_empty(self, db):
|
|
128
|
-
engine = AnalyticsEngine(db=db)
|
|
129
|
-
health = engine.team_health(days=30)
|
|
130
|
-
|
|
131
|
-
assert health.overall_score == 0
|
|
132
|
-
assert len(health.recommendations) > 0 # Should have "no data" recommendation
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class TestSprintAnalytics:
|
|
136
|
-
"""Test sprint analytics."""
|
|
137
|
-
|
|
138
|
-
def test_sprint_burndown(self, db):
|
|
139
|
-
# Record multiple snapshots
|
|
140
|
-
for i, completed in enumerate([0, 5, 10, 15, 18]):
|
|
141
|
-
db.save_sprint_snapshot({
|
|
142
|
-
"sprint_name": "Sprint 1",
|
|
143
|
-
"total_points": 20,
|
|
144
|
-
"completed_points": completed,
|
|
145
|
-
"remaining_points": 20 - completed,
|
|
146
|
-
"added_points": 0,
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
engine = AnalyticsEngine(db=db)
|
|
150
|
-
data = engine.sprint_burndown("Sprint 1")
|
|
151
|
-
assert len(data) == 5
|
|
152
|
-
assert data[0]["remaining"] == 20
|
|
153
|
-
assert data[-1]["remaining"] == 2
|
|
154
|
-
|
|
155
|
-
def test_sprint_burndown_nonexistent(self, populated_db):
|
|
156
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
157
|
-
data = engine.sprint_burndown("Nonexistent Sprint")
|
|
158
|
-
assert data == []
|
|
159
|
-
|
|
160
|
-
def test_predict_sprint_completion(self, db):
|
|
161
|
-
for i, completed in enumerate([0, 5, 10, 15]):
|
|
162
|
-
db.save_sprint_snapshot({
|
|
163
|
-
"sprint_name": "Sprint X",
|
|
164
|
-
"total_points": 20,
|
|
165
|
-
"completed_points": completed,
|
|
166
|
-
"remaining_points": 20 - completed,
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
engine = AnalyticsEngine(db=db)
|
|
170
|
-
result = engine.predict_sprint_completion("Sprint X", total_points=20, days_elapsed=4, total_days=10)
|
|
171
|
-
assert result["prediction"] in ("on_track", "at_risk", "unknown")
|
|
172
|
-
assert "message" in result
|
|
173
|
-
|
|
174
|
-
def test_predict_nonexistent_sprint(self, populated_db):
|
|
175
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
176
|
-
result = engine.predict_sprint_completion("Ghost Sprint", 20, 5, 10)
|
|
177
|
-
assert result["prediction"] == "unknown"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
class TestCodeQuality:
|
|
181
|
-
"""Test code quality analytics."""
|
|
182
|
-
|
|
183
|
-
def test_quality_trend(self, db):
|
|
184
|
-
db.save_quality_snapshot({
|
|
185
|
-
"repo": "testorg/testrepo",
|
|
186
|
-
"test_coverage": 0.75,
|
|
187
|
-
"open_bugs": 5,
|
|
188
|
-
"tech_debt_score": 3.5,
|
|
189
|
-
})
|
|
190
|
-
db.save_quality_snapshot({
|
|
191
|
-
"repo": "testorg/testrepo",
|
|
192
|
-
"test_coverage": 0.80,
|
|
193
|
-
"open_bugs": 3,
|
|
194
|
-
"tech_debt_score": 3.0,
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
engine = AnalyticsEngine(db=db)
|
|
198
|
-
trends = engine.code_quality_trend(repo="testorg/testrepo")
|
|
199
|
-
assert len(trends) == 2
|
|
200
|
-
assert trends[0].test_coverage == 0.75
|
|
201
|
-
assert trends[1].test_coverage == 0.80
|
|
202
|
-
|
|
203
|
-
def test_compute_quality_score(self, populated_db):
|
|
204
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
205
|
-
score = engine.compute_quality_score(repo="testorg/testrepo", days=30)
|
|
206
|
-
|
|
207
|
-
assert score["repo"] == "testorg/testrepo"
|
|
208
|
-
assert score["total_commits"] == 4
|
|
209
|
-
assert score["total_prs"] == 3
|
|
210
|
-
assert isinstance(score["tech_debt_score"], float)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
class TestInsights:
|
|
214
|
-
"""Test AI insight generation."""
|
|
215
|
-
|
|
216
|
-
def test_generate_insights_with_data(self, populated_db):
|
|
217
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
218
|
-
insights = engine.generate_insights(days=30)
|
|
219
|
-
|
|
220
|
-
assert isinstance(insights, list)
|
|
221
|
-
assert len(insights) > 0
|
|
222
|
-
for insight in insights:
|
|
223
|
-
assert "type" in insight
|
|
224
|
-
assert "severity" in insight
|
|
225
|
-
assert "message" in insight
|
|
226
|
-
assert "recommendation" in insight
|
|
227
|
-
|
|
228
|
-
def test_generate_insights_empty(self, db):
|
|
229
|
-
engine = AnalyticsEngine(db=db)
|
|
230
|
-
insights = engine.generate_insights(days=30)
|
|
231
|
-
|
|
232
|
-
assert len(insights) > 0
|
|
233
|
-
# Should have a "no data" or default insight
|
|
234
|
-
|
|
235
|
-
def test_insights_include_merge_time(self, populated_db):
|
|
236
|
-
engine = AnalyticsEngine(db=populated_db)
|
|
237
|
-
insights = engine.generate_insights(days=30, repo="testorg/testrepo")
|
|
238
|
-
|
|
239
|
-
messages = " ".join(i["message"] for i in insights)
|
|
240
|
-
assert "merge time" in messages.lower() or "healthy" in messages.lower()
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
class TestGoalCoaching:
|
|
244
|
-
"""Test goal coaching."""
|
|
245
|
-
|
|
246
|
-
def test_coaching_active_goals(self, db):
|
|
247
|
-
db.upsert_goal({
|
|
248
|
-
"title": "Commits per day",
|
|
249
|
-
"target_value": 10,
|
|
250
|
-
"current_value": 7,
|
|
251
|
-
"metric": "commits_per_day",
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
engine = AnalyticsEngine(db=db)
|
|
255
|
-
coaching = engine.goal_coaching()
|
|
256
|
-
|
|
257
|
-
assert len(coaching) == 1
|
|
258
|
-
assert coaching[0]["progress_pct"] == 70.0
|
|
259
|
-
assert coaching[0]["status"] in ("on_track", "at_risk", "achieved")
|
|
260
|
-
|
|
261
|
-
def test_coaching_achieved_goal(self, db):
|
|
262
|
-
db.upsert_goal({
|
|
263
|
-
"title": "Write code",
|
|
264
|
-
"target_value": 1,
|
|
265
|
-
"current_value": 5,
|
|
266
|
-
"metric": "commits",
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
engine = AnalyticsEngine(db=db)
|
|
270
|
-
coaching = engine.goal_coaching()
|
|
271
|
-
|
|
272
|
-
assert coaching[0]["status"] == "achieved"
|
|
273
|
-
|
|
274
|
-
def test_coaching_specific_goal(self, db):
|
|
275
|
-
gid = db.upsert_goal({
|
|
276
|
-
"title": "Reviews",
|
|
277
|
-
"target_value": 5,
|
|
278
|
-
"current_value": 1,
|
|
279
|
-
"metric": "reviews",
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
engine = AnalyticsEngine(db=db)
|
|
283
|
-
coaching = engine.goal_coaching(goal_id=gid)
|
|
284
|
-
assert len(coaching) == 1
|
|
1
|
+
"""Tests for the AnalyticsEngine."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from devpulse.core.analytics import AnalyticsEngine, _parse_date, _days_between
|
|
6
|
+
from devpulse.core.models import DeveloperMetrics, TeamHealth
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestParseDate:
|
|
10
|
+
"""Test date parsing utility."""
|
|
11
|
+
|
|
12
|
+
def test_iso_with_tz(self):
|
|
13
|
+
dt = _parse_date("2026-04-01T10:00:00Z")
|
|
14
|
+
assert dt is not None
|
|
15
|
+
assert dt.year == 2026
|
|
16
|
+
assert dt.month == 4
|
|
17
|
+
assert dt.day == 1
|
|
18
|
+
|
|
19
|
+
def test_iso_without_tz(self):
|
|
20
|
+
dt = _parse_date("2026-04-01T10:00:00")
|
|
21
|
+
assert dt is not None
|
|
22
|
+
|
|
23
|
+
def test_date_only(self):
|
|
24
|
+
dt = _parse_date("2026-04-01")
|
|
25
|
+
assert dt is not None
|
|
26
|
+
|
|
27
|
+
def test_empty_string(self):
|
|
28
|
+
assert _parse_date("") is None
|
|
29
|
+
|
|
30
|
+
def test_none(self):
|
|
31
|
+
assert _parse_date(None) is None
|
|
32
|
+
|
|
33
|
+
def test_invalid_format(self):
|
|
34
|
+
assert _parse_date("not-a-date") is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestDaysBetween:
|
|
38
|
+
"""Test time difference computation."""
|
|
39
|
+
|
|
40
|
+
def test_same_day(self):
|
|
41
|
+
hours = _days_between("2026-04-01T10:00:00Z", "2026-04-01T14:00:00Z")
|
|
42
|
+
assert hours == 4.0
|
|
43
|
+
|
|
44
|
+
def test_24_hours(self):
|
|
45
|
+
hours = _days_between("2026-04-01T00:00:00Z", "2026-04-02T00:00:00Z")
|
|
46
|
+
assert hours == 24.0
|
|
47
|
+
|
|
48
|
+
def test_empty_start(self):
|
|
49
|
+
hours = _days_between("", "2026-04-01T10:00:00Z")
|
|
50
|
+
assert hours == 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestDeveloperMetrics:
|
|
54
|
+
"""Test developer metrics computation."""
|
|
55
|
+
|
|
56
|
+
def test_basic_metrics(self, populated_db):
|
|
57
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
58
|
+
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
59
|
+
|
|
60
|
+
assert isinstance(metrics, DeveloperMetrics)
|
|
61
|
+
assert metrics.author == "Alice"
|
|
62
|
+
assert metrics.commits_count == 3
|
|
63
|
+
assert metrics.prs_created == 2
|
|
64
|
+
assert metrics.prs_merged == 1 # Only PR #1 is merged and authored by Alice
|
|
65
|
+
|
|
66
|
+
def test_bob_metrics(self, populated_db):
|
|
67
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
68
|
+
metrics = engine.developer_metrics(author="Bob", days=30)
|
|
69
|
+
|
|
70
|
+
assert metrics.author == "Bob"
|
|
71
|
+
assert metrics.commits_count == 1
|
|
72
|
+
assert metrics.prs_created == 1
|
|
73
|
+
|
|
74
|
+
def test_unknown_developer(self, populated_db):
|
|
75
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
76
|
+
metrics = engine.developer_metrics(author="Nobody", days=30)
|
|
77
|
+
|
|
78
|
+
assert metrics.commits_count == 0
|
|
79
|
+
assert metrics.author == "Nobody"
|
|
80
|
+
|
|
81
|
+
def test_active_days(self, populated_db):
|
|
82
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
83
|
+
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
84
|
+
|
|
85
|
+
assert metrics.active_days == 2 # April 1 and April 3
|
|
86
|
+
|
|
87
|
+
def test_commits_per_day(self, populated_db):
|
|
88
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
89
|
+
metrics = engine.developer_metrics(author="Alice", days=30)
|
|
90
|
+
|
|
91
|
+
assert metrics.commits_per_day > 0
|
|
92
|
+
assert metrics.period_days == 30
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestTeamMetrics:
|
|
96
|
+
"""Test team-wide metrics."""
|
|
97
|
+
|
|
98
|
+
def test_team_metrics_list(self, populated_db):
|
|
99
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
100
|
+
team = engine.team_metrics(days=30)
|
|
101
|
+
|
|
102
|
+
assert len(team) >= 2 # At least Alice and Bob
|
|
103
|
+
authors = {m.author for m in team}
|
|
104
|
+
assert "Alice" in authors
|
|
105
|
+
assert "Bob" in authors
|
|
106
|
+
|
|
107
|
+
def test_empty_team(self, db):
|
|
108
|
+
engine = AnalyticsEngine(db=db)
|
|
109
|
+
team = engine.team_metrics(days=30)
|
|
110
|
+
assert team == []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestTeamHealth:
|
|
114
|
+
"""Test team health analysis."""
|
|
115
|
+
|
|
116
|
+
def test_team_health_with_data(self, populated_db):
|
|
117
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
118
|
+
health = engine.team_health(days=30)
|
|
119
|
+
|
|
120
|
+
assert isinstance(health, TeamHealth)
|
|
121
|
+
assert 0 <= health.overall_score <= 100
|
|
122
|
+
assert 0 <= health.workload_balance <= 1
|
|
123
|
+
assert 0 <= health.collaboration_score <= 1
|
|
124
|
+
assert health.velocity_trend in ("growing", "stable", "declining")
|
|
125
|
+
assert len(health.recommendations) > 0
|
|
126
|
+
|
|
127
|
+
def test_team_health_empty(self, db):
|
|
128
|
+
engine = AnalyticsEngine(db=db)
|
|
129
|
+
health = engine.team_health(days=30)
|
|
130
|
+
|
|
131
|
+
assert health.overall_score == 0
|
|
132
|
+
assert len(health.recommendations) > 0 # Should have "no data" recommendation
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestSprintAnalytics:
|
|
136
|
+
"""Test sprint analytics."""
|
|
137
|
+
|
|
138
|
+
def test_sprint_burndown(self, db):
|
|
139
|
+
# Record multiple snapshots
|
|
140
|
+
for i, completed in enumerate([0, 5, 10, 15, 18]):
|
|
141
|
+
db.save_sprint_snapshot({
|
|
142
|
+
"sprint_name": "Sprint 1",
|
|
143
|
+
"total_points": 20,
|
|
144
|
+
"completed_points": completed,
|
|
145
|
+
"remaining_points": 20 - completed,
|
|
146
|
+
"added_points": 0,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
engine = AnalyticsEngine(db=db)
|
|
150
|
+
data = engine.sprint_burndown("Sprint 1")
|
|
151
|
+
assert len(data) == 5
|
|
152
|
+
assert data[0]["remaining"] == 20
|
|
153
|
+
assert data[-1]["remaining"] == 2
|
|
154
|
+
|
|
155
|
+
def test_sprint_burndown_nonexistent(self, populated_db):
|
|
156
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
157
|
+
data = engine.sprint_burndown("Nonexistent Sprint")
|
|
158
|
+
assert data == []
|
|
159
|
+
|
|
160
|
+
def test_predict_sprint_completion(self, db):
|
|
161
|
+
for i, completed in enumerate([0, 5, 10, 15]):
|
|
162
|
+
db.save_sprint_snapshot({
|
|
163
|
+
"sprint_name": "Sprint X",
|
|
164
|
+
"total_points": 20,
|
|
165
|
+
"completed_points": completed,
|
|
166
|
+
"remaining_points": 20 - completed,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
engine = AnalyticsEngine(db=db)
|
|
170
|
+
result = engine.predict_sprint_completion("Sprint X", total_points=20, days_elapsed=4, total_days=10)
|
|
171
|
+
assert result["prediction"] in ("on_track", "at_risk", "unknown")
|
|
172
|
+
assert "message" in result
|
|
173
|
+
|
|
174
|
+
def test_predict_nonexistent_sprint(self, populated_db):
|
|
175
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
176
|
+
result = engine.predict_sprint_completion("Ghost Sprint", 20, 5, 10)
|
|
177
|
+
assert result["prediction"] == "unknown"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestCodeQuality:
|
|
181
|
+
"""Test code quality analytics."""
|
|
182
|
+
|
|
183
|
+
def test_quality_trend(self, db):
|
|
184
|
+
db.save_quality_snapshot({
|
|
185
|
+
"repo": "testorg/testrepo",
|
|
186
|
+
"test_coverage": 0.75,
|
|
187
|
+
"open_bugs": 5,
|
|
188
|
+
"tech_debt_score": 3.5,
|
|
189
|
+
})
|
|
190
|
+
db.save_quality_snapshot({
|
|
191
|
+
"repo": "testorg/testrepo",
|
|
192
|
+
"test_coverage": 0.80,
|
|
193
|
+
"open_bugs": 3,
|
|
194
|
+
"tech_debt_score": 3.0,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
engine = AnalyticsEngine(db=db)
|
|
198
|
+
trends = engine.code_quality_trend(repo="testorg/testrepo")
|
|
199
|
+
assert len(trends) == 2
|
|
200
|
+
assert trends[0].test_coverage == 0.75
|
|
201
|
+
assert trends[1].test_coverage == 0.80
|
|
202
|
+
|
|
203
|
+
def test_compute_quality_score(self, populated_db):
|
|
204
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
205
|
+
score = engine.compute_quality_score(repo="testorg/testrepo", days=30)
|
|
206
|
+
|
|
207
|
+
assert score["repo"] == "testorg/testrepo"
|
|
208
|
+
assert score["total_commits"] == 4
|
|
209
|
+
assert score["total_prs"] == 3
|
|
210
|
+
assert isinstance(score["tech_debt_score"], float)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestInsights:
|
|
214
|
+
"""Test AI insight generation."""
|
|
215
|
+
|
|
216
|
+
def test_generate_insights_with_data(self, populated_db):
|
|
217
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
218
|
+
insights = engine.generate_insights(days=30)
|
|
219
|
+
|
|
220
|
+
assert isinstance(insights, list)
|
|
221
|
+
assert len(insights) > 0
|
|
222
|
+
for insight in insights:
|
|
223
|
+
assert "type" in insight
|
|
224
|
+
assert "severity" in insight
|
|
225
|
+
assert "message" in insight
|
|
226
|
+
assert "recommendation" in insight
|
|
227
|
+
|
|
228
|
+
def test_generate_insights_empty(self, db):
|
|
229
|
+
engine = AnalyticsEngine(db=db)
|
|
230
|
+
insights = engine.generate_insights(days=30)
|
|
231
|
+
|
|
232
|
+
assert len(insights) > 0
|
|
233
|
+
# Should have a "no data" or default insight
|
|
234
|
+
|
|
235
|
+
def test_insights_include_merge_time(self, populated_db):
|
|
236
|
+
engine = AnalyticsEngine(db=populated_db)
|
|
237
|
+
insights = engine.generate_insights(days=30, repo="testorg/testrepo")
|
|
238
|
+
|
|
239
|
+
messages = " ".join(i["message"] for i in insights)
|
|
240
|
+
assert "merge time" in messages.lower() or "healthy" in messages.lower()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestGoalCoaching:
|
|
244
|
+
"""Test goal coaching."""
|
|
245
|
+
|
|
246
|
+
def test_coaching_active_goals(self, db):
|
|
247
|
+
db.upsert_goal({
|
|
248
|
+
"title": "Commits per day",
|
|
249
|
+
"target_value": 10,
|
|
250
|
+
"current_value": 7,
|
|
251
|
+
"metric": "commits_per_day",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
engine = AnalyticsEngine(db=db)
|
|
255
|
+
coaching = engine.goal_coaching()
|
|
256
|
+
|
|
257
|
+
assert len(coaching) == 1
|
|
258
|
+
assert coaching[0]["progress_pct"] == 70.0
|
|
259
|
+
assert coaching[0]["status"] in ("on_track", "at_risk", "achieved")
|
|
260
|
+
|
|
261
|
+
def test_coaching_achieved_goal(self, db):
|
|
262
|
+
db.upsert_goal({
|
|
263
|
+
"title": "Write code",
|
|
264
|
+
"target_value": 1,
|
|
265
|
+
"current_value": 5,
|
|
266
|
+
"metric": "commits",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
engine = AnalyticsEngine(db=db)
|
|
270
|
+
coaching = engine.goal_coaching()
|
|
271
|
+
|
|
272
|
+
assert coaching[0]["status"] == "achieved"
|
|
273
|
+
|
|
274
|
+
def test_coaching_specific_goal(self, db):
|
|
275
|
+
gid = db.upsert_goal({
|
|
276
|
+
"title": "Reviews",
|
|
277
|
+
"target_value": 5,
|
|
278
|
+
"current_value": 1,
|
|
279
|
+
"metric": "reviews",
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
engine = AnalyticsEngine(db=db)
|
|
283
|
+
coaching = engine.goal_coaching(goal_id=gid)
|
|
284
|
+
assert len(coaching) == 1
|