返回 last30days-skill
test_github.py
根目录 / tests / test_github.py
1 """Tests for GitHub source module."""
2
3 import json
4 import unittest
5 from unittest.mock import patch, MagicMock
6
7 from lib import github
8
9
10 class TestResolveToken(unittest.TestCase):
11 def test_explicit_token(self):
12 self.assertEqual(github._resolve_token("my-token"), "my-token")
13
14 @patch.dict("os.environ", {"GITHUB_TOKEN": "env-token"})
15 def test_env_token(self):
16 self.assertEqual(github._resolve_token(), "env-token")
17
18 @patch.dict("os.environ", {}, clear=True)
19 @patch("subprocess.run")
20 def test_gh_cli_fallback(self, mock_run):
21 mock_run.return_value = MagicMock(returncode=0, stdout="gh-token\n")
22 # Clear GITHUB_TOKEN from env for this test
23 result = github._resolve_token()
24 self.assertEqual(result, "gh-token")
25
26 @patch.dict("os.environ", {}, clear=True)
27 @patch("subprocess.run", side_effect=FileNotFoundError)
28 def test_no_token_available(self, mock_run):
29 result = github._resolve_token()
30 self.assertIsNone(result)
31
32
33 class TestParseRepoFromUrl(unittest.TestCase):
34 def test_issue_url(self):
35 url = "https://github.com/facebook/react/issues/123"
36 self.assertEqual(github._parse_repo_from_url(url), "facebook/react")
37
38 def test_pr_url(self):
39 url = "https://github.com/vercel/next.js/pull/456"
40 self.assertEqual(github._parse_repo_from_url(url), "vercel/next.js")
41
42 def test_empty(self):
43 self.assertEqual(github._parse_repo_from_url(""), "")
44
45
46 class TestParseDate(unittest.TestCase):
47 def test_iso_date(self):
48 self.assertEqual(github._parse_date("2026-03-15T12:00:00Z"), "2026-03-15")
49
50 def test_none(self):
51 self.assertIsNone(github._parse_date(None))
52
53 def test_empty(self):
54 self.assertIsNone(github._parse_date(""))
55
56 def test_rejects_garbage(self):
57 """The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
58 self.assertIsNone(github._parse_date("hello world"))
59 self.assertIsNone(github._parse_date("not-a-date"))
60 self.assertIsNone(github._parse_date("abcdefghij"))
61
62 def test_rejects_invalid_date_values(self):
63 """An out-of-range date like 2026-99-99 is not a real date."""
64 self.assertIsNone(github._parse_date("2026-99-99"))
65
66 def test_iso_with_offset(self):
67 self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
68
69 def test_iso_with_no_colon_offset(self):
70 self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
71
72
73 class TestSearchGithub(unittest.TestCase):
74 @patch.dict("os.environ", {}, clear=True)
75 @patch("subprocess.run", side_effect=FileNotFoundError)
76 @patch("lib.github._fetch_json", return_value=None)
77 def test_no_token_unauth_rate_limited_sets_error(self, mock_fetch, mock_run):
78 # No token -> unauthenticated request; on failure (likely anon rate
79 # limit) the envelope carries a clear error instead of being silent.
80 result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
81 self.assertEqual(result.get("items", []), [])
82 self.assertIn("error", result)
83 self.assertIn("unauthenticated", result["error"].lower())
84 self.assertIn("context", result)
85 self.assertEqual(result["context"]["from_date"], "2026-03-01")
86 # Unauth requests are capped to the low-rate tier.
87 self.assertLessEqual(result["context"]["count"], github.UNAUTH_COUNT_CAP)
88 # The request was actually attempted without a token (no early return).
89 mock_fetch.assert_called_once()
90 self.assertIsNone(mock_fetch.call_args.kwargs.get("token"))
91
92 @patch.dict("os.environ", {}, clear=True)
93 @patch("subprocess.run", side_effect=FileNotFoundError)
94 @patch("lib.github._fetch_json", return_value={"items": [{"id": 1, "title": "x"}]})
95 def test_no_token_unauth_success_returns_items(self, mock_fetch, mock_run):
96 result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
97 self.assertEqual(len(result["items"]), 1)
98 self.assertNotIn("error", result)
99
100 def test_resolve_token_public_alias(self):
101 """resolve_token is the public entry point pipeline uses; _resolve_token stays
102 private. Both should return the same value for the same input."""
103 self.assertEqual(
104 github.resolve_token("explicit-token"),
105 github._resolve_token("explicit-token"),
106 )
107 self.assertEqual(github.resolve_token("explicit-token"), "explicit-token")
108
109 @patch.object(github, "_fetch_json")
110 @patch.object(github, "_resolve_token", return_value="test-token")
111 def test_search_returns_raw_envelope(self, mock_token, mock_fetch):
112 mock_fetch.return_value = {
113 "total_count": 1,
114 "items": [
115 {
116 "html_url": "https://github.com/facebook/react/issues/42",
117 "title": "React Server Components bug",
118 "body": "There is a bug when using RSC with streaming...",
119 "created_at": "2026-03-15T10:00:00Z",
120 "state": "open",
121 "comments": 12,
122 "reactions": {"total_count": 8},
123 "labels": [{"name": "bug"}, {"name": "rsc"}],
124 "user": {"login": "testuser"},
125 },
126 ],
127 }
128 # Search returns raw envelope; parse normalizes.
129 response = github.search_github("react", "2026-03-01", "2026-03-31")
130 self.assertEqual(len(response["items"]), 1)
131 self.assertEqual(response["items"][0]["title"], "React Server Components bug")
132 self.assertEqual(response["context"]["from_date"], "2026-03-01")
133
134 items = github.parse_github_response(response)
135 self.assertEqual(len(items), 1)
136 item = items[0]
137 self.assertEqual(item["source"], "github")
138 self.assertEqual(item["container"], "facebook/react")
139 self.assertEqual(item["title"], "React Server Components bug")
140 self.assertEqual(item["date"], "2026-03-15")
141 self.assertEqual(item["author"], "testuser")
142 self.assertIn("bug", item["metadata"]["labels"])
143 self.assertEqual(item["metadata"]["state"], "open")
144 self.assertEqual(item["metadata"]["comment_count"], 12)
145 self.assertEqual(item["metadata"]["reactions"], 8)
146 self.assertEqual(item["engagement"]["reactions"], 8)
147 self.assertEqual(item["engagement"]["comments"], 12)
148 self.assertFalse(item["metadata"]["is_pr"])
149
150 @patch.object(github, "_fetch_json", return_value=None)
151 @patch.object(github, "_resolve_token", return_value="test-token")
152 def test_rate_limit_returns_empty_envelope(self, mock_token, mock_fetch):
153 """403 rate limit returns envelope with empty items list."""
154 response = github.search_github("react", "2026-03-01", "2026-03-31")
155 self.assertEqual(response["items"], [])
156 self.assertEqual(github.parse_github_response(response), [])
157
158 @patch.object(github, "_fetch_json")
159 @patch.object(github, "_resolve_token", return_value="test-token")
160 def test_pr_detected(self, mock_token, mock_fetch):
161 mock_fetch.return_value = {
162 "total_count": 1,
163 "items": [
164 {
165 "html_url": "https://github.com/vercel/next.js/pull/99",
166 "title": "Add streaming support",
167 "body": "This PR adds...",
168 "created_at": "2026-03-20T10:00:00Z",
169 "state": "open",
170 "comments": 5,
171 "reactions": {"total_count": 3},
172 "labels": [],
173 "user": {"login": "dev"},
174 "pull_request": {"url": "..."},
175 },
176 ],
177 }
178 response = github.search_github("next.js", "2026-03-01", "2026-03-31")
179 items = github.parse_github_response(response)
180 self.assertEqual(len(items), 1)
181 self.assertTrue(items[0]["metadata"]["is_pr"])
182
183
184 class TestParseGithubResponse(unittest.TestCase):
185 """Fixture-driven parse tests: feed a synthetic search_github envelope to
186 parse_github_response and assert normalized output.
187
188 This contract (search returns dict envelope, parse turns it into a list)
189 matches every other source adapter. Before this refactor, search_github
190 returned a bare list and there was no parse step, blocking fixture tests.
191 """
192
193 _RAW_ENVELOPE = {
194 "items": [
195 {
196 "html_url": "https://github.com/facebook/react/issues/42",
197 "title": "React Server Components bug",
198 "body": "There is a bug when using RSC with streaming...",
199 "created_at": "2026-03-15T10:00:00Z",
200 "state": "open",
201 "comments": 12,
202 "reactions": {"total_count": 8},
203 "labels": [{"name": "bug"}, {"name": "rsc"}],
204 "user": {"login": "testuser"},
205 },
206 {
207 "html_url": "https://github.com/vercel/next.js/pull/99",
208 "title": "Add streaming support",
209 "body": "This PR adds...",
210 "created_at": "2026-03-20T10:00:00Z",
211 "state": "open",
212 "comments": 5,
213 "reactions": {"total_count": 3},
214 "labels": [],
215 "user": {"login": "dev"},
216 "pull_request": {"url": "..."},
217 },
218 ],
219 "context": {
220 "core": "react",
221 "from_date": "2026-03-01",
222 "to_date": "2026-03-31",
223 "count": 25,
224 },
225 }
226
227 def test_normalizes_items(self):
228 items = github.parse_github_response(self._RAW_ENVELOPE)
229 self.assertEqual(len(items), 2)
230 by_url = {i["url"]: i for i in items}
231 issue = by_url["https://github.com/facebook/react/issues/42"]
232 self.assertEqual(issue["source"], "github")
233 self.assertEqual(issue["container"], "facebook/react")
234 self.assertEqual(issue["title"], "React Server Components bug")
235 self.assertEqual(issue["date"], "2026-03-15")
236 self.assertEqual(issue["author"], "testuser")
237 self.assertEqual(issue["engagement"]["reactions"], 8)
238 self.assertEqual(issue["engagement"]["comments"], 12)
239 self.assertFalse(issue["metadata"]["is_pr"])
240
241 def test_detects_pr(self):
242 items = github.parse_github_response(self._RAW_ENVELOPE)
243 pr = next(i for i in items if "/pull/" in i["url"])
244 self.assertTrue(pr["metadata"]["is_pr"])
245
246 def test_date_filter_drops_outside_window(self):
247 envelope = {
248 "items": [
249 {
250 "html_url": "https://github.com/foo/bar/issues/1",
251 "title": "Too old",
252 "created_at": "2026-01-15T10:00:00Z",
253 "comments": 0, "reactions": {"total_count": 0},
254 "labels": [], "user": {"login": "x"},
255 },
256 {
257 "html_url": "https://github.com/foo/bar/issues/2",
258 "title": "In window",
259 "created_at": "2026-03-15T10:00:00Z",
260 "comments": 0, "reactions": {"total_count": 0},
261 "labels": [], "user": {"login": "x"},
262 },
263 ],
264 "context": {"core": "foo", "from_date": "2026-03-01",
265 "to_date": "2026-03-31", "count": 25},
266 }
267 items = github.parse_github_response(envelope)
268 self.assertEqual(len(items), 1)
269 self.assertEqual(items[0]["title"], "In window")
270
271 def test_sorts_by_relevance(self):
272 items = github.parse_github_response(self._RAW_ENVELOPE)
273 scores = [i.get("relevance", 0) for i in items]
274 self.assertEqual(scores, sorted(scores, reverse=True))
275
276 def test_empty_envelope(self):
277 self.assertEqual(github.parse_github_response({"items": []}), [])
278 self.assertEqual(github.parse_github_response({}), [])
279
280
281 class TestComputeRelevance(unittest.TestCase):
282 def test_basic_relevance(self):
283 score = github._compute_relevance("react hooks", "React Hooks Tutorial", 0, 10, 5)
284 self.assertGreater(score, 0.5)
285 self.assertLessEqual(score, 1.0)
286
287 def test_lower_rank_lower_score(self):
288 high = github._compute_relevance("react", "React", 0, 0, 0)
289 low = github._compute_relevance("react", "React", 20, 0, 0)
290 self.assertGreater(high, low)
291
292 class TestPersonPushEventsLane(unittest.TestCase):
293 """Person mode must not go dark when PR search returns nothing."""
294
295 @staticmethod
296 def _event(
297 event_id,
298 *,
299 actor="kurt",
300 repo="kurt/power-bi-agentic-development",
301 created_at="2026-07-22T20:28:18Z",
302 event_type="PushEvent",
303 ):
304 return {
305 "id": str(event_id),
306 "type": event_type,
307 "actor": {"login": actor},
308 "repo": {"name": repo},
309 "created_at": created_at,
310 }
311
312 def _run(self):
313 with patch.object(github, "_resolve_token", return_value="t"), \
314 patch.object(github, "_enrich_own_repo", return_value={}), \
315 patch.object(github, "_fetch_repo_info", return_value={
316 "stars": 811,
317 "forks": 119,
318 "description": "Claude Code plugin marketplace for Power BI",
319 "language": "Python",
320 "open_issues": 4,
321 }):
322 return github.search_github_person(
323 "kurt", "2026-06-25", "2026-07-25", token="t",
324 )
325
326 def test_unsearchable_account_falls_back_to_actor_push_events(self):
327 def fetch(url, **kwargs):
328 if "search/issues" in url:
329 return None
330 return [self._event(1)]
331
332 with patch.object(github, "_fetch_json", side_effect=fetch):
333 items = self._run()
334
335 self.assertEqual(len(items), 1)
336 self.assertEqual(items[0]["container"], "kurt/power-bi-agentic-development")
337 self.assertEqual(items[0]["date"], "2026-07-22")
338 self.assertIn("@kurt pushed", items[0]["title"])
339 self.assertIn("recent-push", items[0]["metadata"]["labels"])
340 self.assertEqual(items[0]["metadata"]["event_type"], "PushEvent")
341
342 def test_empty_pr_search_falls_back_to_actor_push_events(self):
343 def fetch(url, **kwargs):
344 if "search/issues" in url:
345 return {"total_count": 0, "items": []}
346 return [self._event(1, actor="KURT")]
347
348 with patch.object(github, "_fetch_json", side_effect=fetch):
349 items = self._run()
350
351 self.assertEqual(len(items), 1)
352 self.assertEqual(items[0]["author"], "KURT")
353
354 def test_other_actor_push_is_rejected(self):
355 def fetch(url, **kwargs):
356 if "search/issues" in url:
357 return {"total_count": 0, "items": []}
358 return [self._event(1, actor="collaborator")]
359
360 with patch.object(github, "_fetch_json", side_effect=fetch):
361 items = self._run()
362
363 self.assertEqual(items, [])
364
365 def test_discovers_push_on_second_events_page(self):
366 first_page = [
367 self._event(
368 i,
369 event_type="WatchEvent",
370 created_at=f"2026-07-{24 - (i // 25):02d}T12:00:00Z",
371 )
372 for i in range(github.PERSON_EVENTS_PER_PAGE)
373 ]
374 requested_urls = []
375
376 def fetch(url, **kwargs):
377 requested_urls.append(url)
378 if "search/issues" in url:
379 return {"total_count": 0, "items": []}
380 if "&page=1" in url:
381 return first_page
382 if "&page=2" in url:
383 return [self._event(101, repo="kurt/page-two")]
384 self.fail(f"Unexpected URL: {url}")
385
386 with patch.object(github, "_fetch_json", side_effect=fetch):
387 items = self._run()
388
389 self.assertEqual([item["container"] for item in items], ["kurt/page-two"])
390 self.assertTrue(any("&page=2" in url for url in requested_urls))
391
392 def test_requests_page_after_three_full_event_pages(self):
393 full_page = [
394 self._event(
395 i,
396 event_type="WatchEvent",
397 created_at="2026-07-24T12:00:00Z",
398 )
399 for i in range(github.PERSON_EVENTS_PER_PAGE)
400 ]
401 requested_pages = []
402
403 def fetch(url, **kwargs):
404 if "search/issues" in url:
405 return {"total_count": 0, "items": []}
406 page = int(url.rsplit("&page=", 1)[1])
407 requested_pages.append(page)
408 return full_page if page <= 3 else []
409
410 with patch.object(github, "_fetch_json", side_effect=fetch):
411 items = self._run()
412
413 self.assertEqual(items, [])
414 self.assertEqual(requested_pages, [1, 2, 3, 4])
415
416 def test_stops_paging_at_event_older_than_window(self):
417 requested_urls = []
418
419 def fetch(url, **kwargs):
420 requested_urls.append(url)
421 if "search/issues" in url:
422 return {"total_count": 0, "items": []}
423 if "&page=1" in url:
424 return [
425 self._event(1, event_type="WatchEvent"),
426 self._event(2, created_at="2026-06-24T23:59:59Z"),
427 ]
428 self.fail("Events paging continued after reaching an old event")
429
430 with patch.object(github, "_fetch_json", side_effect=fetch):
431 items = self._run()
432
433 self.assertEqual(items, [])
434 event_urls = [url for url in requested_urls if "/events/public" in url]
435 self.assertEqual(len(event_urls), 1)
436
437 def test_ranks_all_event_repos_before_applying_depth_cap(self):
438 events = [
439 self._event(1, repo="kurt/newest", created_at="2026-07-24T12:00:00Z"),
440 self._event(2, repo="kurt/recent", created_at="2026-07-23T12:00:00Z"),
441 self._event(3, repo="kurt/third", created_at="2026-07-22T12:00:00Z"),
442 self._event(4, repo="kurt/high-star", created_at="2026-07-21T12:00:00Z"),
443 ]
444 stars = {
445 "kurt/newest": 3,
446 "kurt/recent": 2,
447 "kurt/third": 1,
448 "kurt/high-star": 10_000,
449 }
450
451 def repo_info(repo, token):
452 return {
453 "stars": stars[repo],
454 "forks": 0,
455 "description": "",
456 "language": "Python",
457 "open_issues": 0,
458 }
459
460 with patch.object(github, "_fetch_json", return_value=events), \
461 patch.object(github, "_fetch_repo_info", side_effect=repo_info), \
462 patch.object(github, "_enrich_own_repo", return_value={}) as enrich:
463 items = github._person_recent_pushes(
464 "kurt",
465 "2026-06-25",
466 "2026-07-25",
467 {"own_repos": 3},
468 "t",
469 )
470
471 containers = [item["container"] for item in items]
472 self.assertIn("kurt/high-star", containers)
473 self.assertNotIn("kurt/third", containers)
474 self.assertEqual(enrich.call_count, 3)
475
476 def test_aggregates_each_repo_at_its_latest_matching_push(self):
477 events = [
478 self._event(2, created_at="2026-07-24T12:00:00Z"),
479 self._event(1, created_at="2026-07-20T12:00:00Z"),
480 ]
481
482 with patch.object(github, "_fetch_json", return_value=events), \
483 patch.object(github, "_fetch_repo_info", return_value={}), \
484 patch.object(github, "_enrich_own_repo", return_value={}):
485 items = github._person_recent_pushes(
486 "kurt",
487 "2026-06-25",
488 "2026-07-25",
489 {"own_repos": 5},
490 "t",
491 )
492
493 self.assertEqual(len(items), 1)
494 self.assertEqual(items[0]["date"], "2026-07-24")
495 self.assertEqual(items[0]["metadata"]["event_id"], "2")
496
497
498 if __name__ == "__main__":
499 unittest.main()
500
500 lines PYTHON