github-talent-mcp 0.1.0__tar.gz → 0.3.0__tar.gz

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 (35) hide show
  1. {github_talent_mcp-0.1.0/src/github_talent_mcp.egg-info → github_talent_mcp-0.3.0}/PKG-INFO +55 -6
  2. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/README.md +50 -1
  3. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/pyproject.toml +5 -5
  4. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/github_client.py +105 -9
  5. github_talent_mcp-0.3.0/src/github_talent_mcp/scoring.py +342 -0
  6. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/server.py +112 -0
  7. github_talent_mcp-0.3.0/src/github_talent_mcp/tools/bulk.py +136 -0
  8. github_talent_mcp-0.3.0/src/github_talent_mcp/tools/compare.py +99 -0
  9. github_talent_mcp-0.3.0/src/github_talent_mcp/tools/outreach.py +137 -0
  10. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/profile.py +16 -2
  11. github_talent_mcp-0.3.0/src/github_talent_mcp/tools/score_jd.py +64 -0
  12. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0/src/github_talent_mcp.egg-info}/PKG-INFO +55 -6
  13. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/SOURCES.txt +9 -0
  14. github_talent_mcp-0.3.0/src/github_talent_mcp.egg-info/requires.txt +4 -0
  15. github_talent_mcp-0.3.0/tests/test_bulk.py +122 -0
  16. github_talent_mcp-0.3.0/tests/test_compare.py +94 -0
  17. github_talent_mcp-0.3.0/tests/test_outreach.py +104 -0
  18. github_talent_mcp-0.3.0/tests/test_rate_limit.py +93 -0
  19. github_talent_mcp-0.3.0/tests/test_score_jd.py +88 -0
  20. github_talent_mcp-0.1.0/src/github_talent_mcp/scoring.py +0 -169
  21. github_talent_mcp-0.1.0/src/github_talent_mcp.egg-info/requires.txt +0 -4
  22. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/LICENSE +0 -0
  23. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/setup.cfg +0 -0
  24. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/__init__.py +0 -0
  25. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/__main__.py +0 -0
  26. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/models.py +0 -0
  27. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/__init__.py +0 -0
  28. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/contributors.py +0 -0
  29. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/rank.py +0 -0
  30. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp/tools/search.py +0 -0
  31. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/dependency_links.txt +0 -0
  32. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/entry_points.txt +0 -0
  33. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/src/github_talent_mcp.egg-info/top_level.txt +0 -0
  34. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/tests/test_scoring.py +0 -0
  35. {github_talent_mcp-0.1.0 → github_talent_mcp-0.3.0}/tests/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-talent-mcp
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: MCP server for searching, scoring, and ranking GitHub developers for technical recruiting
5
5
  Author: Carolina Cherry
6
6
  License: MIT
@@ -21,10 +21,10 @@ Classifier: Topic :: Software Development :: Libraries
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: mcp>=1.0.0
25
- Requires-Dist: httpx>=0.27.0
26
- Requires-Dist: pydantic>=2.0.0
27
- Requires-Dist: python-dotenv>=1.0.0
24
+ Requires-Dist: mcp<2,>=1.0.0
25
+ Requires-Dist: httpx<1,>=0.27.0
26
+ Requires-Dist: pydantic<3,>=2.0.0
27
+ Requires-Dist: python-dotenv<2,>=1.0.0
28
28
  Dynamic: license-file
29
29
 
30
30
  # github-talent-mcp
@@ -33,12 +33,19 @@ Dynamic: license-file
33
33
  [![Python 3.14+](https://img.shields.io/badge/Python-3.14+-blue.svg)](https://www.python.org)
34
34
  [![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-8A2BE2)](https://modelcontextprotocol.io)
35
35
  [![Claude](https://img.shields.io/badge/Built_for-Claude_by_Anthropic-d4a373)](https://claude.ai)
36
+ [![GitHub Copilot](https://img.shields.io/badge/Works_with-GitHub_Copilot-8957E5?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot)
36
37
  [![GitHub API](https://img.shields.io/badge/GitHub-REST_API_v3-181717?logo=github)](https://docs.github.com/en/rest)
37
38
 
38
39
  MCP server that searches, scores, and ranks GitHub developers for technical recruiting.
39
40
 
41
+ Works with **Claude** (Code & Desktop) and **GitHub Copilot** (CLI & desktop app) — any MCP client that speaks stdio.
42
+
40
43
  ## Demo
41
44
 
45
+ https://github.com/user-attachments/assets/b2dbe9e0-26ee-4849-861a-4b5cb268facc
46
+
47
+ Sourcing candidates for a real Anthropic JD, live in Claude Cowork.
48
+
42
49
  https://github.com/user-attachments/assets/2dfd82b4-3eb5-4f2b-bc0a-2580b95043e4
43
50
 
44
51
  ### Profile deep dive
@@ -118,7 +125,7 @@ Then set the token as an environment variable. Either:
118
125
  - Export it in your shell: `export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`
119
126
  - Or keep it in the `.env` file — the server reads it via `python-dotenv` on startup
120
127
 
121
- Restart Claude Code to pick up the new server. Verify with `/mcp` — you should see 4 tools under `github-talent`.
128
+ Restart Claude Code to pick up the new server. Verify with `/mcp` — you should see 8 tools under `github-talent`.
122
129
 
123
130
  #### Claude Desktop
124
131
 
@@ -141,6 +148,32 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
141
148
 
142
149
  Restart Claude Desktop. The tools will appear in the toolbox icon.
143
150
 
151
+ #### GitHub Copilot (CLI & desktop app)
152
+
153
+ The [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) and the [GitHub Copilot app](https://github.com/features/ai/github-app) share one MCP config, so a single setup covers both.
154
+
155
+ Add it to `~/.copilot/mcp-config.json` (global), or commit a `.copilot/mcp-config.json` in your repo — the Copilot app picks that up automatically:
156
+
157
+ ```json
158
+ {
159
+ "mcpServers": {
160
+ "github-talent": {
161
+ "type": "local",
162
+ "command": "uvx",
163
+ "args": ["github-talent-mcp"],
164
+ "env": {
165
+ "GITHUB_TOKEN": "${GITHUB_TOKEN}"
166
+ },
167
+ "tools": ["*"]
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ Export your token first (`export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`) — Copilot only inherits `PATH`, so the `${GITHUB_TOKEN}` reference reads it from your shell. If `uvx` isn't on your `PATH`, use its absolute path as `command`.
174
+
175
+ The **Copilot app** reads this same config and also lets you add servers under Settings → MCP. In a Copilot CLI session, run `/mcp add` to register interactively or `/mcp show` to verify — you should see 8 tools under `github-talent`.
176
+
144
177
  ## Try It
145
178
 
146
179
  Once installed, paste these prompts to verify everything works:
@@ -157,6 +190,18 @@ Once installed, paste these prompts to verify everything works:
157
190
  **Repo contributors:**
158
191
  > Get the top contributors to huggingface/transformers and rank them for a founding ML engineer role at an AI startup
159
192
 
193
+ **JD scoring:**
194
+ > Score these candidates against this job description: [paste JD]. Candidates: tiangolo, karpathy, hwchase17
195
+
196
+ **Compare candidates:**
197
+ > Compare tiangolo and hwchase17 for a Senior Python AI Engineer role
198
+
199
+ **Bulk scoring:**
200
+ > Score these 10 GitHub usernames and give me a ranked table: [paste list]
201
+
202
+ **Outreach:**
203
+ > Generate a casual recruiter message for tiangolo about a Senior Python role at Acme. My name is Daniel.
204
+
160
205
  ## Tools
161
206
 
162
207
  | Tool | Description |
@@ -164,6 +209,10 @@ Once installed, paste these prompts to verify everything works:
164
209
  | `search_developers` | Search GitHub users by language, location, activity, followers. For topic-based sourcing, use `get_repo_contributors` on relevant repos instead. |
165
210
  | `get_developer_profile` | Deep profile enrichment: languages, stars, commits + PRs, OSS contributions, license breakdown, profile README, and activity score with breakdown. |
166
211
  | `rank_candidates` | Rank usernames against a job description. Returns sorted candidates with combined score, strengths, gaps, and reasoning. |
212
+ | `score_against_jd` | Score candidates against a JD with per-dimension breakdown (tech stack, experience level, OSS signal, leadership). Returns gaps and personalized interview questions. |
213
+ | `compare_candidates` | Side-by-side comparison of 2-5 candidates. Shows dimension winners and a recommendation. Optionally scored against a JD. |
214
+ | `bulk_score` | Score up to 100 GitHub usernames in one call. Returns a ranked markdown table or CSV. Supports optional JD matching. |
215
+ | `generate_outreach` | Generate personalized recruiter messages (short/medium/detailed) that reference the candidate's actual repos and contributions. Requires your company name and sender name. Casual or formal tone. |
167
216
  | `get_repo_contributors` | Top contributors for any repo. Accepts `owner/repo` or full URL. The fastest way to source for a specific domain. |
168
217
 
169
218
  ## Scoring
@@ -4,12 +4,19 @@
4
4
  [![Python 3.14+](https://img.shields.io/badge/Python-3.14+-blue.svg)](https://www.python.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Model_Context_Protocol-8A2BE2)](https://modelcontextprotocol.io)
6
6
  [![Claude](https://img.shields.io/badge/Built_for-Claude_by_Anthropic-d4a373)](https://claude.ai)
7
+ [![GitHub Copilot](https://img.shields.io/badge/Works_with-GitHub_Copilot-8957E5?logo=githubcopilot&logoColor=white)](https://github.com/features/copilot)
7
8
  [![GitHub API](https://img.shields.io/badge/GitHub-REST_API_v3-181717?logo=github)](https://docs.github.com/en/rest)
8
9
 
9
10
  MCP server that searches, scores, and ranks GitHub developers for technical recruiting.
10
11
 
12
+ Works with **Claude** (Code & Desktop) and **GitHub Copilot** (CLI & desktop app) — any MCP client that speaks stdio.
13
+
11
14
  ## Demo
12
15
 
16
+ https://github.com/user-attachments/assets/b2dbe9e0-26ee-4849-861a-4b5cb268facc
17
+
18
+ Sourcing candidates for a real Anthropic JD, live in Claude Cowork.
19
+
13
20
  https://github.com/user-attachments/assets/2dfd82b4-3eb5-4f2b-bc0a-2580b95043e4
14
21
 
15
22
  ### Profile deep dive
@@ -89,7 +96,7 @@ Then set the token as an environment variable. Either:
89
96
  - Export it in your shell: `export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`
90
97
  - Or keep it in the `.env` file — the server reads it via `python-dotenv` on startup
91
98
 
92
- Restart Claude Code to pick up the new server. Verify with `/mcp` — you should see 4 tools under `github-talent`.
99
+ Restart Claude Code to pick up the new server. Verify with `/mcp` — you should see 8 tools under `github-talent`.
93
100
 
94
101
  #### Claude Desktop
95
102
 
@@ -112,6 +119,32 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
112
119
 
113
120
  Restart Claude Desktop. The tools will appear in the toolbox icon.
114
121
 
122
+ #### GitHub Copilot (CLI & desktop app)
123
+
124
+ The [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) and the [GitHub Copilot app](https://github.com/features/ai/github-app) share one MCP config, so a single setup covers both.
125
+
126
+ Add it to `~/.copilot/mcp-config.json` (global), or commit a `.copilot/mcp-config.json` in your repo — the Copilot app picks that up automatically:
127
+
128
+ ```json
129
+ {
130
+ "mcpServers": {
131
+ "github-talent": {
132
+ "type": "local",
133
+ "command": "uvx",
134
+ "args": ["github-talent-mcp"],
135
+ "env": {
136
+ "GITHUB_TOKEN": "${GITHUB_TOKEN}"
137
+ },
138
+ "tools": ["*"]
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ Export your token first (`export GITHUB_TOKEN=ghp_xxxxxxxxxxxx`) — Copilot only inherits `PATH`, so the `${GITHUB_TOKEN}` reference reads it from your shell. If `uvx` isn't on your `PATH`, use its absolute path as `command`.
145
+
146
+ The **Copilot app** reads this same config and also lets you add servers under Settings → MCP. In a Copilot CLI session, run `/mcp add` to register interactively or `/mcp show` to verify — you should see 8 tools under `github-talent`.
147
+
115
148
  ## Try It
116
149
 
117
150
  Once installed, paste these prompts to verify everything works:
@@ -128,6 +161,18 @@ Once installed, paste these prompts to verify everything works:
128
161
  **Repo contributors:**
129
162
  > Get the top contributors to huggingface/transformers and rank them for a founding ML engineer role at an AI startup
130
163
 
164
+ **JD scoring:**
165
+ > Score these candidates against this job description: [paste JD]. Candidates: tiangolo, karpathy, hwchase17
166
+
167
+ **Compare candidates:**
168
+ > Compare tiangolo and hwchase17 for a Senior Python AI Engineer role
169
+
170
+ **Bulk scoring:**
171
+ > Score these 10 GitHub usernames and give me a ranked table: [paste list]
172
+
173
+ **Outreach:**
174
+ > Generate a casual recruiter message for tiangolo about a Senior Python role at Acme. My name is Daniel.
175
+
131
176
  ## Tools
132
177
 
133
178
  | Tool | Description |
@@ -135,6 +180,10 @@ Once installed, paste these prompts to verify everything works:
135
180
  | `search_developers` | Search GitHub users by language, location, activity, followers. For topic-based sourcing, use `get_repo_contributors` on relevant repos instead. |
136
181
  | `get_developer_profile` | Deep profile enrichment: languages, stars, commits + PRs, OSS contributions, license breakdown, profile README, and activity score with breakdown. |
137
182
  | `rank_candidates` | Rank usernames against a job description. Returns sorted candidates with combined score, strengths, gaps, and reasoning. |
183
+ | `score_against_jd` | Score candidates against a JD with per-dimension breakdown (tech stack, experience level, OSS signal, leadership). Returns gaps and personalized interview questions. |
184
+ | `compare_candidates` | Side-by-side comparison of 2-5 candidates. Shows dimension winners and a recommendation. Optionally scored against a JD. |
185
+ | `bulk_score` | Score up to 100 GitHub usernames in one call. Returns a ranked markdown table or CSV. Supports optional JD matching. |
186
+ | `generate_outreach` | Generate personalized recruiter messages (short/medium/detailed) that reference the candidate's actual repos and contributions. Requires your company name and sender name. Casual or formal tone. |
138
187
  | `get_repo_contributors` | Top contributors for any repo. Accepts `owner/repo` or full URL. The fastest way to source for a specific domain. |
139
188
 
140
189
  ## Scoring
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "github-talent-mcp"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "MCP server for searching, scoring, and ranking GitHub developers for technical recruiting"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -26,10 +26,10 @@ classifiers = [
26
26
  "Topic :: Software Development :: Libraries",
27
27
  ]
28
28
  dependencies = [
29
- "mcp>=1.0.0",
30
- "httpx>=0.27.0",
31
- "pydantic>=2.0.0",
32
- "python-dotenv>=1.0.0",
29
+ "mcp>=1.0.0,<2",
30
+ "httpx>=0.27.0,<1",
31
+ "pydantic>=2.0.0,<3",
32
+ "python-dotenv>=1.0.0,<2",
33
33
  ]
34
34
 
35
35
  [project.urls]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  import os
5
6
  import time
@@ -54,6 +55,59 @@ class GitHubClient:
54
55
  reset = resp.headers.get("X-RateLimit-Reset", "unknown")
55
56
  log.warning(f"Rate limit low: {remaining} remaining, resets at {reset}")
56
57
 
58
+ # -- Request wrapper with rate-limit backoff --
59
+
60
+ async def _get(
61
+ self,
62
+ url: str,
63
+ *,
64
+ params: dict | None = None,
65
+ headers: dict | None = None,
66
+ max_retries: int = 3,
67
+ ) -> httpx.Response:
68
+ """GET with backoff on GitHub rate limits (primary + secondary) and 5xx.
69
+
70
+ Respects Retry-After and X-RateLimit-Reset so a broad sourcing run paces
71
+ itself instead of erroring out mid-job. Each wait is capped (60s) so an
72
+ exhausted hourly quota fails fast rather than hanging the session.
73
+ """
74
+ resp = await self._client.get(url, params=params, headers=headers)
75
+ for attempt in range(max_retries):
76
+ if not self._should_retry(resp):
77
+ return resp
78
+ delay = self._retry_after_seconds(resp, attempt)
79
+ log.warning(
80
+ f"GitHub {resp.status_code} on {url} — backing off {delay:.0f}s "
81
+ f"(retry {attempt + 1}/{max_retries})"
82
+ )
83
+ await asyncio.sleep(delay)
84
+ resp = await self._client.get(url, params=params, headers=headers)
85
+ return resp
86
+
87
+ @staticmethod
88
+ def _should_retry(resp: httpx.Response) -> bool:
89
+ if resp.status_code >= 500:
90
+ return True
91
+ if resp.status_code in (403, 429):
92
+ # Secondary limit sends Retry-After; primary limit zeroes Remaining.
93
+ if resp.headers.get("Retry-After"):
94
+ return True
95
+ if resp.headers.get("X-RateLimit-Remaining") == "0":
96
+ return True
97
+ return False
98
+
99
+ @staticmethod
100
+ def _retry_after_seconds(resp: httpx.Response, attempt: int) -> float:
101
+ retry_after = resp.headers.get("Retry-After", "")
102
+ if retry_after.isdigit():
103
+ return min(float(retry_after), 60.0)
104
+ if resp.headers.get("X-RateLimit-Remaining") == "0":
105
+ reset = resp.headers.get("X-RateLimit-Reset", "")
106
+ if reset.isdigit():
107
+ wait = float(reset) - time.time()
108
+ return min(max(wait, 0.0), 60.0) + 1.0
109
+ return min(2.0 ** attempt, 30.0)
110
+
57
111
  # -- API methods --
58
112
 
59
113
  async def search_users(
@@ -71,7 +125,7 @@ class GitHubClient:
71
125
  for lang in languages:
72
126
  parts.append(f"language:{lang}")
73
127
  if location:
74
- parts.append(f"location:{location}")
128
+ parts.append(f'location:"{location}"')
75
129
  if min_followers is not None:
76
130
  parts.append(f"followers:>={min_followers}")
77
131
  if min_repos is not None:
@@ -81,7 +135,7 @@ class GitHubClient:
81
135
  # Recent activity should be verified via get_developer_profile.
82
136
 
83
137
  q = " ".join(parts)
84
- resp = await self._client.get(
138
+ resp = await self._get(
85
139
  "/search/users",
86
140
  params={"q": q, "per_page": per_page, "page": page, "sort": "followers", "order": "desc"},
87
141
  )
@@ -94,7 +148,7 @@ class GitHubClient:
94
148
  cached = self._cache_get(cache_key)
95
149
  if cached is not None:
96
150
  return cached
97
- resp = await self._client.get(f"/repos/{owner}/{repo}")
151
+ resp = await self._get(f"/repos/{owner}/{repo}")
98
152
  self._check_rate_limit(resp)
99
153
  resp.raise_for_status()
100
154
  data = resp.json()
@@ -106,7 +160,7 @@ class GitHubClient:
106
160
  cached = self._cache_get(cache_key)
107
161
  if cached is not None:
108
162
  return cached
109
- resp = await self._client.get(f"/users/{username}")
163
+ resp = await self._get(f"/users/{username}")
110
164
  self._check_rate_limit(resp)
111
165
  resp.raise_for_status()
112
166
  data = resp.json()
@@ -118,7 +172,7 @@ class GitHubClient:
118
172
  cached = self._cache_get(cache_key)
119
173
  if cached is not None:
120
174
  return cached
121
- resp = await self._client.get(
175
+ resp = await self._get(
122
176
  f"/users/{username}/repos",
123
177
  params={"per_page": per_page, "sort": "pushed", "direction": "desc", "type": "owner"},
124
178
  )
@@ -133,7 +187,7 @@ class GitHubClient:
133
187
  cached = self._cache_get(cache_key)
134
188
  if cached is not None:
135
189
  return cached
136
- resp = await self._client.get(f"/repos/{owner}/{repo}/languages")
190
+ resp = await self._get(f"/repos/{owner}/{repo}/languages")
137
191
  self._check_rate_limit(resp)
138
192
  resp.raise_for_status()
139
193
  data = resp.json()
@@ -147,7 +201,7 @@ class GitHubClient:
147
201
  return cached
148
202
  all_events: list[dict] = []
149
203
  for page in range(1, max_pages + 1):
150
- resp = await self._client.get(
204
+ resp = await self._get(
151
205
  f"/users/{username}/events/public",
152
206
  params={"per_page": 100, "page": page},
153
207
  )
@@ -160,13 +214,55 @@ class GitHubClient:
160
214
  self._cache_set(cache_key, all_events)
161
215
  return all_events
162
216
 
217
+ async def search_commit_count(self, username: str, since_date: str) -> int:
218
+ """Count commits by username since a date using the Search API.
219
+
220
+ More accurate than Events API for users whose commits don't
221
+ surface as PushEvents (e.g. Torvalds' kernel merges).
222
+ """
223
+ cache_key = f"commit_count:{username}:{since_date}"
224
+ cached = self._cache_get(cache_key)
225
+ if cached is not None:
226
+ return cached
227
+ resp = await self._get(
228
+ "/search/commits",
229
+ params={
230
+ "q": f"author:{username} user:{username} committer-date:>={since_date}",
231
+ "per_page": 1,
232
+ },
233
+ )
234
+ self._check_rate_limit(resp)
235
+ resp.raise_for_status()
236
+ count = resp.json().get("total_count", 0)
237
+ self._cache_set(cache_key, count)
238
+ return count
239
+
240
+ async def search_pr_count(self, username: str, since_date: str) -> int:
241
+ """Count PRs opened by username since a date using the Search API."""
242
+ cache_key = f"pr_count:{username}:{since_date}"
243
+ cached = self._cache_get(cache_key)
244
+ if cached is not None:
245
+ return cached
246
+ resp = await self._get(
247
+ "/search/issues",
248
+ params={
249
+ "q": f"author:{username} type:pr created:>={since_date}",
250
+ "per_page": 1,
251
+ },
252
+ )
253
+ self._check_rate_limit(resp)
254
+ resp.raise_for_status()
255
+ count = resp.json().get("total_count", 0)
256
+ self._cache_set(cache_key, count)
257
+ return count
258
+
163
259
  async def get_profile_readme(self, username: str) -> str | None:
164
260
  cache_key = f"readme:{username}"
165
261
  cached = self._cache_get(cache_key)
166
262
  if cached is not None:
167
263
  return cached
168
264
  try:
169
- resp = await self._client.get(
265
+ resp = await self._get(
170
266
  f"/repos/{username}/{username}/readme",
171
267
  headers={"Accept": "application/vnd.github.raw+json"},
172
268
  )
@@ -182,7 +278,7 @@ class GitHubClient:
182
278
  async def get_repo_contributors(
183
279
  self, owner: str, repo: str, per_page: int = 30,
184
280
  ) -> list[dict]:
185
- resp = await self._client.get(
281
+ resp = await self._get(
186
282
  f"/repos/{owner}/{repo}/contributors",
187
283
  params={"per_page": per_page},
188
284
  )