@theihtisham/dev-pulse 1.0.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.
@@ -0,0 +1,281 @@
1
+ """GitHub API client with rate limiting and caching."""
2
+
3
+ import time
4
+ import logging
5
+ from datetime import datetime, timedelta
6
+ from typing import Any, Optional
7
+
8
+ import requests
9
+
10
+ from devpulse.core.config import get_settings
11
+
12
+ logger = logging.getLogger("devpulse.github")
13
+
14
+ # Suppress requests logging of URLs (may contain tokens)
15
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
16
+
17
+
18
+ class GitHubClient:
19
+ """Authenticated GitHub API client with rate-limit awareness."""
20
+
21
+ BASE = "https://api.github.com"
22
+
23
+ def __init__(
24
+ self,
25
+ token: Optional[str] = None,
26
+ username: Optional[str] = None,
27
+ org: Optional[str] = None,
28
+ ) -> None:
29
+ settings = get_settings()
30
+ self.token = token or settings.github_token
31
+ self.username = username or settings.github_username
32
+ self.org = org or settings.github_org
33
+ self.base_url = settings.github_api_url
34
+ self.cache_ttl = settings.github_cache_ttl_seconds
35
+ self.rate_limit_rpm = settings.github_rate_limit_rpm
36
+
37
+ self._session = requests.Session()
38
+ if self.token:
39
+ self._session.headers.update(
40
+ {
41
+ "Authorization": f"token {self.token}",
42
+ "Accept": "application/vnd.github+json",
43
+ }
44
+ )
45
+ self._session.headers.update({"User-Agent": "DevPulse/1.0"})
46
+
47
+ self._last_request_time: float = 0.0
48
+ self._min_interval = 60.0 / max(self.rate_limit_rpm, 1)
49
+ self._cache: dict[str, tuple[float, Any]] = {}
50
+
51
+ # ── Rate limiting ────────────────────────────────────────────────
52
+
53
+ def _throttle(self) -> None:
54
+ elapsed = time.time() - self._last_request_time
55
+ if elapsed < self._min_interval:
56
+ time.sleep(self._min_interval - elapsed)
57
+ self._last_request_time = time.time()
58
+
59
+ def _get(self, url: str, params: Optional[dict[str, Any]] = None) -> Any:
60
+ """GET with rate limiting and caching."""
61
+ cache_key = f"{url}:{sorted((params or {}).items())}"
62
+ now = time.time()
63
+ if cache_key in self._cache:
64
+ ts, data = self._cache[cache_key]
65
+ if now - ts < self.cache_ttl:
66
+ return data
67
+
68
+ self._throttle()
69
+ resp = self._session.get(url, params=params, timeout=30)
70
+ resp.raise_for_status()
71
+
72
+ data = resp.json()
73
+ self._cache[cache_key] = (now, data)
74
+
75
+ # Check remaining rate limit
76
+ remaining = resp.headers.get("X-RateLimit-Remaining")
77
+ if remaining and int(remaining) < 5:
78
+ reset_time = int(resp.headers.get("X-RateLimit-Reset", 0))
79
+ wait = max(reset_time - int(time.time()), 1)
80
+ logger.warning("Rate limit low (%s remaining), waiting %ds", remaining, wait)
81
+ time.sleep(wait)
82
+
83
+ return data
84
+
85
+ def _paginated(self, url: str, params: Optional[dict[str, Any]] = None, max_pages: int = 10) -> list[dict[str, Any]]:
86
+ """Fetch all pages of a paginated endpoint."""
87
+ results: list[dict[str, Any]] = []
88
+ params = dict(params or {})
89
+ params.setdefault("per_page", 100)
90
+ params["page"] = 1
91
+
92
+ for _ in range(max_pages):
93
+ data = self._get(url, params)
94
+ if not isinstance(data, list) or not data:
95
+ break
96
+ results.extend(data)
97
+ if len(data) < params["per_page"]:
98
+ break
99
+ params["page"] += 1
100
+
101
+ return results
102
+
103
+ # ── Repository listing ───────────────────────────────────────────
104
+
105
+ def get_repos(self) -> list[str]:
106
+ """Get list of repository full names."""
107
+ if self.org:
108
+ repos = self._paginated(f"{self.base_url}/orgs/{self.org}/repos", {"type": "all"})
109
+ elif self.username:
110
+ repos = self._paginated(f"{self.base_url}/users/{self.username}/repos", {"type": "all"})
111
+ else:
112
+ return []
113
+ return [r["full_name"] for r in repos if isinstance(r, dict)]
114
+
115
+ # ── Commits ──────────────────────────────────────────────────────
116
+
117
+ def get_commits(
118
+ self, repo: str, since: Optional[str] = None, until: Optional[str] = None, author: Optional[str] = None
119
+ ) -> list[dict[str, Any]]:
120
+ """Fetch commits for a repository."""
121
+ params: dict[str, Any] = {"per_page": 100}
122
+ if since:
123
+ params["since"] = since
124
+ if until:
125
+ params["until"] = until
126
+ if author:
127
+ params["author"] = author
128
+
129
+ raw = self._paginated(f"{self.base_url}/repos/{repo}/commits", params, max_pages=5)
130
+ commits: list[dict[str, Any]] = []
131
+ for c in raw:
132
+ commit_data = c.get("commit", c)
133
+ author_info = commit_data.get("author", {})
134
+ commits.append(
135
+ {
136
+ "sha": c.get("sha", ""),
137
+ "repo": repo,
138
+ "author": author_info.get("name", ""),
139
+ "author_date": author_info.get("date", ""),
140
+ "message": commit_data.get("message", "").split("\n")[0][:200],
141
+ "additions": 0,
142
+ "deletions": 0,
143
+ "url": c.get("html_url", ""),
144
+ }
145
+ )
146
+ return commits
147
+
148
+ def get_commit_detail(self, repo: str, sha: str) -> dict[str, Any]:
149
+ """Get detailed stats for a single commit."""
150
+ data = self._get(f"{self.base_url}/repos/{repo}/commits/{sha}")
151
+ stats = data.get("stats", {})
152
+ return {"additions": stats.get("additions", 0), "deletions": stats.get("deletions", 0)}
153
+
154
+ # ── Pull Requests ────────────────────────────────────────────────
155
+
156
+ def get_pull_requests(
157
+ self, repo: str, state: str = "all", since: Optional[str] = None
158
+ ) -> list[dict[str, Any]]:
159
+ """Fetch pull requests for a repository."""
160
+ params: dict[str, Any] = {"state": state, "per_page": 100}
161
+ raw = self._paginated(f"{self.base_url}/repos/{repo}/pulls", params, max_pages=5)
162
+
163
+ prs: list[dict[str, Any]] = []
164
+ for p in raw:
165
+ if since and p.get("created_at", "") < since:
166
+ continue
167
+ prs.append(
168
+ {
169
+ "number": p["number"],
170
+ "repo": repo,
171
+ "title": p.get("title", ""),
172
+ "author": p.get("user", {}).get("login", ""),
173
+ "state": "merged" if p.get("merged_at") else p.get("state", "open"),
174
+ "created_at": p.get("created_at", ""),
175
+ "merged_at": p.get("merged_at"),
176
+ "closed_at": p.get("closed_at"),
177
+ "additions": p.get("additions", 0),
178
+ "deletions": p.get("deletions", 0),
179
+ "changed_files": p.get("changed_files", 0),
180
+ "review_comments": p.get("comments", 0) + p.get("review_comments", 0),
181
+ "url": p.get("html_url", ""),
182
+ }
183
+ )
184
+ return prs
185
+
186
+ # ── Issues ───────────────────────────────────────────────────────
187
+
188
+ def get_issues(
189
+ self, repo: str, state: str = "all", since: Optional[str] = None
190
+ ) -> list[dict[str, Any]]:
191
+ """Fetch issues (excluding PRs) for a repository."""
192
+ params: dict[str, Any] = {"state": state, "per_page": 100}
193
+ if since:
194
+ params["since"] = since
195
+ raw = self._paginated(f"{self.base_url}/repos/{repo}/issues", params, max_pages=5)
196
+
197
+ issues: list[dict[str, Any]] = []
198
+ for i in raw:
199
+ if "pull_request" in i:
200
+ continue # skip PRs returned by issues endpoint
201
+ issues.append(
202
+ {
203
+ "number": i["number"],
204
+ "repo": repo,
205
+ "title": i.get("title", ""),
206
+ "author": i.get("user", {}).get("login", ""),
207
+ "state": i.get("state", "open"),
208
+ "labels": [lbl.get("name", "") for lbl in i.get("labels", [])],
209
+ "created_at": i.get("created_at", ""),
210
+ "closed_at": i.get("closed_at"),
211
+ "url": i.get("html_url", ""),
212
+ }
213
+ )
214
+ return issues
215
+
216
+ # ── Reviews ──────────────────────────────────────────────────────
217
+
218
+ def get_reviews(self, repo: str, pr_number: int) -> list[dict[str, Any]]:
219
+ """Fetch reviews for a pull request."""
220
+ raw = self._paginated(
221
+ f"{self.base_url}/repos/{repo}/pulls/{pr_number}/reviews", {"per_page": 100}
222
+ )
223
+ reviews: list[dict[str, Any]] = []
224
+ for r in raw:
225
+ reviews.append(
226
+ {
227
+ "id": r["id"],
228
+ "repo": repo,
229
+ "pr_number": pr_number,
230
+ "author": r.get("user", {}).get("login", ""),
231
+ "state": r.get("state", ""),
232
+ "submitted_at": r.get("submitted_at", ""),
233
+ "body": r.get("body", ""),
234
+ }
235
+ )
236
+ return reviews
237
+
238
+ # ── Sync ─────────────────────────────────────────────────────────
239
+
240
+ def sync_all(
241
+ self,
242
+ repos: Optional[list[str]] = None,
243
+ since: Optional[str] = None,
244
+ db: Optional[Any] = None,
245
+ ) -> dict[str, int]:
246
+ """Sync commits, PRs, issues, and reviews from GitHub."""
247
+ if db is None:
248
+ from devpulse.core.database import Database
249
+ db = Database()
250
+
251
+ if repos is None:
252
+ repos = self.get_repos()
253
+
254
+ if not since:
255
+ since = (datetime.utcnow() - timedelta(days=30)).isoformat()
256
+
257
+ counts: dict[str, int] = {"commits": 0, "prs": 0, "issues": 0, "reviews": 0}
258
+
259
+ for repo in repos:
260
+ logger.info("Syncing %s ...", repo)
261
+ try:
262
+ commits = self.get_commits(repo, since=since)
263
+ counts["commits"] += db.upsert_commits(commits)
264
+
265
+ prs = self.get_pull_requests(repo, since=since)
266
+ counts["prs"] += db.upsert_pull_requests(prs)
267
+
268
+ # Fetch reviews for each PR
269
+ for pr in prs:
270
+ try:
271
+ reviews = self.get_reviews(repo, pr["number"])
272
+ counts["reviews"] += db.upsert_reviews(reviews)
273
+ except Exception:
274
+ pass
275
+
276
+ issues = self.get_issues(repo, since=since)
277
+ counts["issues"] += db.upsert_issues(issues)
278
+ except Exception as exc:
279
+ logger.error("Error syncing %s: %s", repo, exc)
280
+
281
+ return counts
@@ -0,0 +1,142 @@
1
+ """Pydantic models for DevPulse data structures."""
2
+
3
+ from datetime import datetime, date
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Commit(BaseModel):
10
+ sha: str
11
+ repo: str
12
+ author: str
13
+ author_date: str
14
+ message: str = ""
15
+ additions: int = 0
16
+ deletions: int = 0
17
+ url: str = ""
18
+
19
+
20
+ class PullRequest(BaseModel):
21
+ number: int
22
+ repo: str
23
+ title: str = ""
24
+ author: str = ""
25
+ state: str = "open"
26
+ created_at: str = ""
27
+ merged_at: Optional[str] = None
28
+ closed_at: Optional[str] = None
29
+ additions: int = 0
30
+ deletions: int = 0
31
+ changed_files: int = 0
32
+ review_comments: int = 0
33
+ url: str = ""
34
+
35
+
36
+ class Issue(BaseModel):
37
+ number: int
38
+ repo: str
39
+ title: str = ""
40
+ author: str = ""
41
+ state: str = "open"
42
+ labels: list[str] = Field(default_factory=list)
43
+ created_at: str = ""
44
+ closed_at: Optional[str] = None
45
+ url: str = ""
46
+
47
+
48
+ class Review(BaseModel):
49
+ id: int
50
+ repo: str
51
+ pr_number: int
52
+ author: str = ""
53
+ state: str = ""
54
+ submitted_at: str = ""
55
+ body: str = ""
56
+
57
+
58
+ class DeveloperMetrics(BaseModel):
59
+ author: str
60
+ commits_count: int = 0
61
+ prs_created: int = 0
62
+ prs_merged: int = 0
63
+ issues_opened: int = 0
64
+ issues_closed: int = 0
65
+ reviews_given: int = 0
66
+ avg_pr_merge_time_hours: float = 0.0
67
+ avg_review_turnaround_hours: float = 0.0
68
+ lines_added: int = 0
69
+ lines_removed: int = 0
70
+ commits_per_day: float = 0.0
71
+ active_days: int = 0
72
+ period_days: int = 30
73
+
74
+
75
+ class SprintData(BaseModel):
76
+ name: str
77
+ total_points: float = 0
78
+ completed_points: float = 0
79
+ remaining_points: float = 0
80
+ added_points: float = 0
81
+ start_date: str = ""
82
+ end_date: str = ""
83
+ velocity: float = 0
84
+ scope_creep_pct: float = 0
85
+
86
+
87
+ class TeamHealth(BaseModel):
88
+ team_name: str = "team"
89
+ overall_score: float = 0.0
90
+ workload_balance: float = 0.0
91
+ burnout_risk: dict[str, float] = Field(default_factory=dict)
92
+ collaboration_score: float = 0.0
93
+ velocity_trend: str = "stable"
94
+ recommendations: list[str] = Field(default_factory=list)
95
+
96
+
97
+ class CodeQuality(BaseModel):
98
+ repo: str
99
+ date: str = ""
100
+ test_coverage: float = 0.0
101
+ open_bugs: int = 0
102
+ tech_debt_score: float = 0.0
103
+ lines_added: int = 0
104
+ lines_removed: int = 0
105
+ files_changed: int = 0
106
+
107
+
108
+ class Goal(BaseModel):
109
+ id: Optional[int] = None
110
+ title: str
111
+ description: str = ""
112
+ target_value: float
113
+ current_value: float = 0
114
+ metric: str
115
+ deadline: Optional[str] = None
116
+ status: str = "active"
117
+
118
+
119
+ class DailyReport(BaseModel):
120
+ date: str
121
+ author: str
122
+ commits: int = 0
123
+ prs_opened: int = 0
124
+ prs_merged: int = 0
125
+ issues_opened: int = 0
126
+ issues_closed: int = 0
127
+ reviews_given: int = 0
128
+ lines_changed: int = 0
129
+ summary: str = ""
130
+
131
+
132
+ class WeeklyReport(BaseModel):
133
+ week_start: str
134
+ week_end: str
135
+ authors: list[str] = Field(default_factory=list)
136
+ total_commits: int = 0
137
+ total_prs: int = 0
138
+ total_issues_closed: int = 0
139
+ avg_merge_time_hours: float = 0.0
140
+ top_contributors: list[dict[str, int]] = Field(default_factory=list)
141
+ insights: list[str] = Field(default_factory=list)
142
+ recommendations: list[str] = Field(default_factory=list)