@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.
Files changed (40) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +43 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +33 -0
  4. package/.github/PULL_REQUEST_TEMPLATE.md +18 -0
  5. package/.github/dependabot.yml +16 -0
  6. package/.github/workflows/ci.yml +33 -0
  7. package/CODE_OF_CONDUCT.md +27 -0
  8. package/Dockerfile +8 -0
  9. package/LICENSE +21 -21
  10. package/README.md +135 -39
  11. package/SECURITY.md +22 -0
  12. package/devpulse/__init__.py +4 -4
  13. package/devpulse/api/__init__.py +1 -1
  14. package/devpulse/api/app.py +371 -371
  15. package/devpulse/cli/__init__.py +1 -1
  16. package/devpulse/cli/dashboard.py +131 -131
  17. package/devpulse/cli/main.py +678 -678
  18. package/devpulse/cli/render.py +175 -175
  19. package/devpulse/core/__init__.py +34 -34
  20. package/devpulse/core/analytics.py +487 -487
  21. package/devpulse/core/config.py +77 -77
  22. package/devpulse/core/database.py +612 -612
  23. package/devpulse/core/github_client.py +281 -281
  24. package/devpulse/core/models.py +142 -142
  25. package/devpulse/core/report_generator.py +454 -454
  26. package/devpulse/static/.gitkeep +1 -1
  27. package/devpulse/templates/report.html +64 -64
  28. package/package.json +35 -35
  29. package/pyproject.toml +80 -80
  30. package/requirements.txt +14 -14
  31. package/tests/__init__.py +1 -1
  32. package/tests/conftest.py +208 -208
  33. package/tests/test_analytics.py +284 -284
  34. package/tests/test_api.py +313 -313
  35. package/tests/test_cli.py +204 -204
  36. package/tests/test_config.py +47 -47
  37. package/tests/test_database.py +255 -255
  38. package/tests/test_models.py +107 -107
  39. package/tests/test_report_generator.py +173 -173
  40. package/jest.config.js +0 -7
@@ -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