返回 last30days-skill
test_github.py
根目录 / tests / test_github.py
1 """Tests for GitHub source module."""
2
3 import json
4 import unittest
5 import urllib.parse
6 from unittest.mock import patch, MagicMock
7
8 from lib import github
9
10
11 class TestResolveToken(unittest.TestCase):
12 def test_explicit_token(self):
13 self.assertEqual(github._resolve_token("my-token"), "my-token")
14
15 @patch.dict("os.environ", {"GITHUB_TOKEN": "env-token"})
16 def test_env_token(self):
17 self.assertEqual(github._resolve_token(), "env-token")
18
19 @patch.dict("os.environ", {}, clear=True)
20 @patch("subprocess.run")
21 def test_gh_cli_fallback(self, mock_run):
22 mock_run.return_value = MagicMock(returncode=0, stdout="gh-token\n")
23 # Clear GITHUB_TOKEN from env for this test
24 result = github._resolve_token()
25 self.assertEqual(result, "gh-token")
26
27 @patch.dict("os.environ", {}, clear=True)
28 @patch("subprocess.run", side_effect=FileNotFoundError)
29 def test_no_token_available(self, mock_run):
30 result = github._resolve_token()
31 self.assertIsNone(result)
32
33
34 class TestParseRepoFromUrl(unittest.TestCase):
35 def test_issue_url(self):
36 url = "https://github.com/facebook/react/issues/123"
37 self.assertEqual(github._parse_repo_from_url(url), "facebook/react")
38
39 def test_pr_url(self):
40 url = "https://github.com/vercel/next.js/pull/456"
41 self.assertEqual(github._parse_repo_from_url(url), "vercel/next.js")
42
43 def test_empty(self):
44 self.assertEqual(github._parse_repo_from_url(""), "")
45
46
47 class TestParseDate(unittest.TestCase):
48 def test_iso_date(self):
49 self.assertEqual(github._parse_date("2026-03-15T12:00:00Z"), "2026-03-15")
50
51 def test_none(self):
52 self.assertIsNone(github._parse_date(None))
53
54 def test_empty(self):
55 self.assertIsNone(github._parse_date(""))
56
57 def test_rejects_garbage(self):
58 """The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
59 self.assertIsNone(github._parse_date("hello world"))
60 self.assertIsNone(github._parse_date("not-a-date"))
61 self.assertIsNone(github._parse_date("abcdefghij"))
62
63 def test_rejects_invalid_date_values(self):
64 """An out-of-range date like 2026-99-99 is not a real date."""
65 self.assertIsNone(github._parse_date("2026-99-99"))
66
67 def test_iso_with_offset(self):
68 self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
69
70 def test_iso_with_no_colon_offset(self):
71 self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
72
73
74 class TestSearchGithub(unittest.TestCase):
75 @patch.dict("os.environ", {}, clear=True)
76 @patch("subprocess.run", side_effect=FileNotFoundError)
77 @patch("lib.github._fetch_json", return_value=None)
78 def test_no_token_unauth_rate_limited_sets_error(self, mock_fetch, mock_run):
79 # No token -> unauthenticated request; on failure (likely anon rate
80 # limit) the envelope carries a clear error instead of being silent.
81 result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
82 self.assertEqual(result.get("items", []), [])
83 self.assertIn("error", result)
84 self.assertIn("unauthenticated", result["error"].lower())
85 self.assertIn("context", result)
86 self.assertEqual(result["context"]["from_date"], "2026-03-01")
87 # Unauth requests are capped to the low-rate tier.
88 self.assertLessEqual(result["context"]["count"], github.UNAUTH_COUNT_CAP)
89 # The request was actually attempted without a token (no early return).
90 mock_fetch.assert_called_once()
91 self.assertIsNone(mock_fetch.call_args.kwargs.get("token"))
92
93 @patch.dict("os.environ", {}, clear=True)
94 @patch("subprocess.run", side_effect=FileNotFoundError)
95 @patch("lib.github._fetch_json", return_value={"items": [{"id": 1, "title": "x"}]})
96 def test_no_token_unauth_success_returns_items(self, mock_fetch, mock_run):
97 result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
98 self.assertEqual(len(result["items"]), 1)
99 self.assertNotIn("error", result)
100
101 def test_resolve_token_public_alias(self):
102 """resolve_token is the public entry point pipeline uses; _resolve_token stays
103 private. Both should return the same value for the same input."""
104 self.assertEqual(
105 github.resolve_token("explicit-token"),
106 github._resolve_token("explicit-token"),
107 )
108 self.assertEqual(github.resolve_token("explicit-token"), "explicit-token")
109
110 @patch.object(github, "_fetch_json")
111 @patch.object(github, "_resolve_token", return_value="test-token")
112 def test_search_returns_raw_envelope(self, mock_token, mock_fetch):
113 mock_fetch.return_value = {
114 "total_count": 1,
115 "items": [
116 {
117 "html_url": "https://github.com/facebook/react/issues/42",
118 "title": "React Server Components bug",
119 "body": "There is a bug when using RSC with streaming...",
120 "created_at": "2026-03-15T10:00:00Z",
121 "state": "open",
122 "comments": 12,
123 "reactions": {"total_count": 8},
124 "labels": [{"name": "bug"}, {"name": "rsc"}],
125 "user": {"login": "testuser"},
126 },
127 ],
128 }
129 # Search returns raw envelope; parse normalizes.
130 response = github.search_github("react", "2026-03-01", "2026-03-31")
131 self.assertEqual(len(response["items"]), 1)
132 self.assertEqual(response["items"][0]["title"], "React Server Components bug")
133 self.assertEqual(response["context"]["from_date"], "2026-03-01")
134
135 items = github.parse_github_response(response)
136 self.assertEqual(len(items), 1)
137 item = items[0]
138 self.assertEqual(item["source"], "github")
139 self.assertEqual(item["container"], "facebook/react")
140 self.assertEqual(item["title"], "React Server Components bug")
141 self.assertEqual(item["date"], "2026-03-15")
142 self.assertEqual(item["author"], "testuser")
143 self.assertIn("bug", item["metadata"]["labels"])
144 self.assertEqual(item["metadata"]["state"], "open")
145 self.assertEqual(item["metadata"]["comment_count"], 12)
146 self.assertEqual(item["metadata"]["reactions"], 8)
147 self.assertEqual(item["engagement"]["reactions"], 8)
148 self.assertEqual(item["engagement"]["comments"], 12)
149 self.assertFalse(item["metadata"]["is_pr"])
150
151 @patch.object(github, "_fetch_json", return_value=None)
152 @patch.object(github, "_resolve_token", return_value="test-token")
153 def test_rate_limit_returns_empty_envelope(self, mock_token, mock_fetch):
154 """403 rate limit returns envelope with empty items list."""
155 response = github.search_github("react", "2026-03-01", "2026-03-31")
156 self.assertEqual(response["items"], [])
157 self.assertEqual(github.parse_github_response(response), [])
158
159 @patch.object(github, "_fetch_json")
160 @patch.object(github, "_resolve_token", return_value="test-token")
161 def test_pr_detected(self, mock_token, mock_fetch):
162 mock_fetch.return_value = {
163 "total_count": 1,
164 "items": [
165 {
166 "html_url": "https://github.com/vercel/next.js/pull/99",
167 "title": "Add streaming support",
168 "body": "This PR adds...",
169 "created_at": "2026-03-20T10:00:00Z",
170 "state": "open",
171 "comments": 5,
172 "reactions": {"total_count": 3},
173 "labels": [],
174 "user": {"login": "dev"},
175 "pull_request": {"url": "..."},
176 },
177 ],
178 }
179 response = github.search_github("next.js", "2026-03-01", "2026-03-31")
180 items = github.parse_github_response(response)
181 self.assertEqual(len(items), 1)
182 self.assertTrue(items[0]["metadata"]["is_pr"])
183
184
185 class TestParseGithubResponse(unittest.TestCase):
186 """Fixture-driven parse tests: feed a synthetic search_github envelope to
187 parse_github_response and assert normalized output.
188
189 This contract (search returns dict envelope, parse turns it into a list)
190 matches every other source adapter. Before this refactor, search_github
191 returned a bare list and there was no parse step, blocking fixture tests.
192 """
193
194 _RAW_ENVELOPE = {
195 "items": [
196 {
197 "html_url": "https://github.com/facebook/react/issues/42",
198 "title": "React Server Components bug",
199 "body": "There is a bug when using RSC with streaming...",
200 "created_at": "2026-03-15T10:00:00Z",
201 "state": "open",
202 "comments": 12,
203 "reactions": {"total_count": 8},
204 "labels": [{"name": "bug"}, {"name": "rsc"}],
205 "user": {"login": "testuser"},
206 },
207 {
208 "html_url": "https://github.com/vercel/next.js/pull/99",
209 "title": "Add streaming support",
210 "body": "This PR adds...",
211 "created_at": "2026-03-20T10:00:00Z",
212 "state": "open",
213 "comments": 5,
214 "reactions": {"total_count": 3},
215 "labels": [],
216 "user": {"login": "dev"},
217 "pull_request": {"url": "..."},
218 },
219 ],
220 "context": {
221 "core": "react",
222 "from_date": "2026-03-01",
223 "to_date": "2026-03-31",
224 "count": 25,
225 },
226 }
227
228 def test_normalizes_items(self):
229 items = github.parse_github_response(self._RAW_ENVELOPE)
230 self.assertEqual(len(items), 2)
231 by_url = {i["url"]: i for i in items}
232 issue = by_url["https://github.com/facebook/react/issues/42"]
233 self.assertEqual(issue["source"], "github")
234 self.assertEqual(issue["container"], "facebook/react")
235 self.assertEqual(issue["title"], "React Server Components bug")
236 self.assertEqual(issue["date"], "2026-03-15")
237 self.assertEqual(issue["author"], "testuser")
238 self.assertEqual(issue["engagement"]["reactions"], 8)
239 self.assertEqual(issue["engagement"]["comments"], 12)
240 self.assertFalse(issue["metadata"]["is_pr"])
241
242 def test_detects_pr(self):
243 items = github.parse_github_response(self._RAW_ENVELOPE)
244 pr = next(i for i in items if "/pull/" in i["url"])
245 self.assertTrue(pr["metadata"]["is_pr"])
246
247 def test_date_filter_drops_outside_window(self):
248 envelope = {
249 "items": [
250 {
251 "html_url": "https://github.com/foo/bar/issues/1",
252 "title": "Too old",
253 "created_at": "2026-01-15T10:00:00Z",
254 "comments": 0, "reactions": {"total_count": 0},
255 "labels": [], "user": {"login": "x"},
256 },
257 {
258 "html_url": "https://github.com/foo/bar/issues/2",
259 "title": "In window",
260 "created_at": "2026-03-15T10:00:00Z",
261 "comments": 0, "reactions": {"total_count": 0},
262 "labels": [], "user": {"login": "x"},
263 },
264 ],
265 "context": {"core": "foo", "from_date": "2026-03-01",
266 "to_date": "2026-03-31", "count": 25},
267 }
268 items = github.parse_github_response(envelope)
269 self.assertEqual(len(items), 1)
270 self.assertEqual(items[0]["title"], "In window")
271
272 def test_sorts_by_relevance(self):
273 items = github.parse_github_response(self._RAW_ENVELOPE)
274 scores = [i.get("relevance", 0) for i in items]
275 self.assertEqual(scores, sorted(scores, reverse=True))
276
277 def test_empty_envelope(self):
278 self.assertEqual(github.parse_github_response({"items": []}), [])
279 self.assertEqual(github.parse_github_response({}), [])
280
281
282 class TestComputeRelevance(unittest.TestCase):
283 def test_basic_relevance(self):
284 score = github._compute_relevance("react hooks", "React Hooks Tutorial", 0, 10, 5)
285 self.assertGreater(score, 0.5)
286 self.assertLessEqual(score, 1.0)
287
288 def test_lower_rank_lower_score(self):
289 high = github._compute_relevance("react", "React", 0, 0, 0)
290 low = github._compute_relevance("react", "React", 20, 0, 0)
291 self.assertGreater(high, low)
292
293 class TestPersonPushEventsLane(unittest.TestCase):
294 """Person mode must not go dark when PR search returns nothing."""
295
296 @staticmethod
297 def _event(
298 event_id,
299 *,
300 actor="kurt",
301 repo="kurt/power-bi-agentic-development",
302 created_at="2026-07-22T20:28:18Z",
303 event_type="PushEvent",
304 ):
305 return {
306 "id": str(event_id),
307 "type": event_type,
308 "actor": {"login": actor},
309 "repo": {"name": repo},
310 "created_at": created_at,
311 }
312
313 def _run(self):
314 with patch.object(github, "_resolve_token", return_value="t"), \
315 patch.object(github, "_enrich_own_repo", return_value={}), \
316 patch.object(github, "_fetch_repo_info", return_value={
317 "stars": 811,
318 "forks": 119,
319 "description": "Claude Code plugin marketplace for Power BI",
320 "language": "Python",
321 "open_issues": 4,
322 }):
323 return github.search_github_person(
324 "kurt", "2026-06-25", "2026-07-25", token="t",
325 )
326
327 def test_unsearchable_account_falls_back_to_actor_push_events(self):
328 def fetch(url, **kwargs):
329 if "search/issues" in url:
330 return None
331 return [self._event(1)]
332
333 with patch.object(github, "_fetch_json", side_effect=fetch):
334 items = self._run()
335
336 self.assertEqual(len(items), 1)
337 self.assertEqual(items[0]["container"], "kurt/power-bi-agentic-development")
338 self.assertEqual(items[0]["date"], "2026-07-22")
339 self.assertIn("@kurt pushed", items[0]["title"])
340 self.assertIn("recent-push", items[0]["metadata"]["labels"])
341 self.assertEqual(items[0]["metadata"]["event_type"], "PushEvent")
342
343 def test_empty_pr_search_falls_back_to_actor_push_events(self):
344 def fetch(url, **kwargs):
345 if "search/issues" in url:
346 return {"total_count": 0, "items": []}
347 return [self._event(1, actor="KURT")]
348
349 with patch.object(github, "_fetch_json", side_effect=fetch):
350 items = self._run()
351
352 self.assertEqual(len(items), 1)
353 self.assertEqual(items[0]["author"], "KURT")
354
355 def test_other_actor_push_is_rejected(self):
356 def fetch(url, **kwargs):
357 if "search/issues" in url:
358 return {"total_count": 0, "items": []}
359 return [self._event(1, actor="collaborator")]
360
361 with patch.object(github, "_fetch_json", side_effect=fetch):
362 items = self._run()
363
364 self.assertEqual(items, [])
365
366 def test_discovers_push_on_second_events_page(self):
367 first_page = [
368 self._event(
369 i,
370 event_type="WatchEvent",
371 created_at=f"2026-07-{24 - (i // 25):02d}T12:00:00Z",
372 )
373 for i in range(github.PERSON_EVENTS_PER_PAGE)
374 ]
375 requested_urls = []
376
377 def fetch(url, **kwargs):
378 requested_urls.append(url)
379 if "search/issues" in url:
380 return {"total_count": 0, "items": []}
381 if "&page=1" in url:
382 return first_page
383 if "&page=2" in url:
384 return [self._event(101, repo="kurt/page-two")]
385 self.fail(f"Unexpected URL: {url}")
386
387 with patch.object(github, "_fetch_json", side_effect=fetch):
388 items = self._run()
389
390 self.assertEqual([item["container"] for item in items], ["kurt/page-two"])
391 self.assertTrue(any("&page=2" in url for url in requested_urls))
392
393 def test_requests_page_after_three_full_event_pages(self):
394 full_page = [
395 self._event(
396 i,
397 event_type="WatchEvent",
398 created_at="2026-07-24T12:00:00Z",
399 )
400 for i in range(github.PERSON_EVENTS_PER_PAGE)
401 ]
402 requested_pages = []
403
404 def fetch(url, **kwargs):
405 if "search/issues" in url:
406 return {"total_count": 0, "items": []}
407 page = int(url.rsplit("&page=", 1)[1])
408 requested_pages.append(page)
409 return full_page if page <= 3 else []
410
411 with patch.object(github, "_fetch_json", side_effect=fetch):
412 items = self._run()
413
414 self.assertEqual(items, [])
415 self.assertEqual(requested_pages, [1, 2, 3, 4])
416
417 def test_stops_paging_at_event_older_than_window(self):
418 requested_urls = []
419
420 def fetch(url, **kwargs):
421 requested_urls.append(url)
422 if "search/issues" in url:
423 return {"total_count": 0, "items": []}
424 if "&page=1" in url:
425 return [
426 self._event(1, event_type="WatchEvent"),
427 self._event(2, created_at="2026-06-24T23:59:59Z"),
428 ]
429 self.fail("Events paging continued after reaching an old event")
430
431 with patch.object(github, "_fetch_json", side_effect=fetch):
432 items = self._run()
433
434 self.assertEqual(items, [])
435 event_urls = [url for url in requested_urls if "/events/public" in url]
436 self.assertEqual(len(event_urls), 1)
437
438 def test_ranks_all_event_repos_before_applying_depth_cap(self):
439 events = [
440 self._event(1, repo="kurt/newest", created_at="2026-07-24T12:00:00Z"),
441 self._event(2, repo="kurt/recent", created_at="2026-07-23T12:00:00Z"),
442 self._event(3, repo="kurt/third", created_at="2026-07-22T12:00:00Z"),
443 self._event(4, repo="kurt/high-star", created_at="2026-07-21T12:00:00Z"),
444 ]
445 stars = {
446 "kurt/newest": 3,
447 "kurt/recent": 2,
448 "kurt/third": 1,
449 "kurt/high-star": 10_000,
450 }
451
452 def repo_info(repo, token):
453 return {
454 "stars": stars[repo],
455 "forks": 0,
456 "description": "",
457 "language": "Python",
458 "open_issues": 0,
459 }
460
461 with patch.object(github, "_fetch_json", return_value=events), \
462 patch.object(github, "_fetch_repo_info", side_effect=repo_info), \
463 patch.object(github, "_enrich_own_repo", return_value={}) as enrich:
464 items = github._person_recent_pushes(
465 "kurt",
466 "2026-06-25",
467 "2026-07-25",
468 {"own_repos": 3},
469 "t",
470 )
471
472 containers = [item["container"] for item in items]
473 self.assertIn("kurt/high-star", containers)
474 self.assertNotIn("kurt/third", containers)
475 self.assertEqual(enrich.call_count, 3)
476
477 def test_aggregates_each_repo_at_its_latest_matching_push(self):
478 events = [
479 self._event(2, created_at="2026-07-24T12:00:00Z"),
480 self._event(1, created_at="2026-07-20T12:00:00Z"),
481 ]
482
483 with patch.object(github, "_fetch_json", return_value=events), \
484 patch.object(github, "_fetch_repo_info", return_value={}), \
485 patch.object(github, "_enrich_own_repo", return_value={}):
486 items = github._person_recent_pushes(
487 "kurt",
488 "2026-06-25",
489 "2026-07-25",
490 {"own_repos": 5},
491 "t",
492 )
493
494 self.assertEqual(len(items), 1)
495 self.assertEqual(items[0]["date"], "2026-07-24")
496 self.assertEqual(items[0]["metadata"]["event_id"], "2")
497
498
499 class TestStripSearchQualifiers(unittest.TestCase):
500 """Planner-injected GitHub search qualifiers must never reach the query
501 builder: search_github appends its own created:>{from_date}, and two
502 created: qualifiers collide (GitHub honors the first), which then makes
503 the local date filter drop everything (issue #949)."""
504
505 def test_strips_qualifiers_keeps_words(self):
506 self.assertEqual(
507 github.strip_search_qualifiers(
508 "open source ai stars:>1000 created:>2025-03-20"
509 ),
510 "open source ai",
511 )
512
513 def test_plain_word_is_not_a_qualifier_without_colon(self):
514 self.assertEqual(
515 github.strip_search_qualifiers("ai in healthcare"),
516 "ai in healthcare",
517 )
518
519 def test_qualifiers_removed_from_mixed_topic(self):
520 self.assertEqual(
521 github.strip_search_qualifiers("langchain is:issue created:>2026-01-01"),
522 "langchain",
523 )
524
525 def test_paren_wrapped_qualifier_stripped(self):
526 # Wrapper shapes bypassed the strip until the boundary accepted them
527 # (issue #952); a surviving created: would collide with the adapter's
528 # own window and silently zero out the source (issue #949 class).
529 self.assertEqual(
530 github.strip_search_qualifiers("(created:>2025-03-20)"),
531 "",
532 )
533
534 def test_double_quote_wrapped_qualifier_stripped(self):
535 self.assertEqual(
536 github.strip_search_qualifiers('"created:>2025-03-20"'),
537 "",
538 )
539
540 def test_single_quote_wrapped_qualifier_stripped(self):
541 self.assertEqual(
542 github.strip_search_qualifiers("'is:issue'"),
543 "",
544 )
545
546 def test_bracket_wrapped_qualifier_stripped(self):
547 self.assertEqual(
548 github.strip_search_qualifiers("[stars:>1000]"),
549 "",
550 )
551
552 def test_wrapped_qualifier_among_words_leaves_no_empty_pair(self):
553 self.assertEqual(
554 github.strip_search_qualifiers("ai (created:>2025-03-20) model"),
555 "ai model",
556 )
557
558 def test_quoted_value_and_wrapped_qualifier_both_stripped(self):
559 self.assertEqual(
560 github.strip_search_qualifiers('label:"bug fix" (created:>2025-03-20)'),
561 "",
562 )
563
564 def test_wrapped_and_plain_duplicate_qualifiers_both_stripped(self):
565 self.assertEqual(
566 github.strip_search_qualifiers("(created:>2025-03-20) created:>2026-01-01"),
567 "",
568 )
569
570 def test_nested_wrapper_collapses_to_fixpoint(self):
571 self.assertEqual(
572 github.strip_search_qualifiers("((created:>2025-03-20))"),
573 "",
574 )
575
576 def test_missing_closer_qualifier_still_stripped(self):
577 # An opener without its closer ("(created:>2025-03-20") must not leak
578 # the qualifier into the query: only the stray opener survives, and the
579 # collision class (#949) stays dead.
580 result = github.strip_search_qualifiers("(created:>2025-03-20")
581 self.assertNotIn("created:", result)
582
583 def test_quote_wrapped_with_space_inside_does_not_leak_qualifier(self):
584 # '"created:>2025-03-20 abc"' has a space in the quoted value, so it is
585 # not a single wrapper pair; the qualifier itself must still not reach
586 # the query.
587 result = github.strip_search_qualifiers('"created:>2025-03-20 abc"')
588 self.assertNotIn("created:", result)
589
590 def test_wrapped_qualifier_with_glued_term_preserves_term(self):
591 # Mirrors the plain glued-term case: "(created:>2025-03-20,robotics)"
592 # must strip the qualifier and keep the term, with no created: leak.
593 result = github.strip_search_qualifiers("(created:>2025-03-20,robotics)")
594 self.assertIn("robotics", result)
595 self.assertNotIn("created:", result)
596
597 def test_case_insensitive_qualifier_only_topic(self):
598 self.assertEqual(github.strip_search_qualifiers("Stars:>1000"), "")
599
600 def test_slash_containing_value_consumed(self):
601 self.assertEqual(
602 github.strip_search_qualifiers("repo:facebook/react bug"),
603 "bug",
604 )
605
606 def test_qualifier_glued_after_comma_is_stripped(self):
607 # A comma-glued qualifier (planner output like "ai,created:>2025-03-20")
608 # must not survive to collide with the adapter's own created: window.
609 self.assertEqual(
610 github.strip_search_qualifiers("ai,created:>2025-03-20"),
611 "ai,",
612 )
613
614 def test_qualifier_glued_after_semicolon_is_stripped(self):
615 self.assertEqual(
616 github.strip_search_qualifiers("ai;created:>2025-03-20"),
617 "ai;",
618 )
619
620 def test_quoted_qualifier_value_fully_consumed(self):
621 # label:"bug fix" spans a space; the whole quoted value must be
622 # consumed so no stray fragment (e.g. `fix"`) reaches the query.
623 self.assertEqual(
624 github.strip_search_qualifiers('label:"bug fix" open source'),
625 "open source",
626 )
627
628 def test_topic_term_glued_after_qualifier_value_is_preserved(self):
629 # A topic term glued after a qualifier value ("created:>2025-03-20,
630 # robotics") must survive stripping - the value class must stop at
631 # the separator instead of greedily eating the following term.
632 result = github.strip_search_qualifiers("created:>2025-03-20,robotics")
633 self.assertIn("robotics", result)
634 self.assertNotIn("created:", result)
635
636 def test_mid_topic_qualifier_with_glued_term_preserves_subject(self):
637 result = github.strip_search_qualifiers("ai created:>2025-03-20,robotics")
638 self.assertIn("robotics", result)
639 self.assertIn("ai", result)
640 self.assertNotIn("created:", result)
641
642
643 class TestSearchGithubQualifiers(unittest.TestCase):
644 """End-to-end behavior of search_github on qualifier-bearing topics."""
645
646 def _capturing_fetch(self, captured):
647 def fake_fetch(url, *args, **kwargs):
648 captured["url"] = url
649 captured.setdefault("urls", []).append(url)
650 return {"total_count": 0, "items": []}
651 return fake_fetch
652
653 def _query(self, captured_url):
654 return urllib.parse.parse_qs(
655 urllib.parse.urlparse(captured_url).query
656 )["q"][0]
657
658 @patch.object(github, "_resolve_token", return_value="test-token")
659 def test_planner_qualifiers_stripped_before_query_build(self, mock_token):
660 captured = {}
661 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
662 github.search_github(
663 "open source ai stars:>1000 created:>2025-03-20",
664 "2026-07-01", "2026-07-31",
665 )
666 queries = [self._query(u) for u in captured["urls"]]
667 # Authenticated searches must carry `is:issue` or `is:pull-request`
668 # (GitHub 422s without one), so the subject is asserted per sub-query
669 # rather than against a single exact string.
670 self.assertEqual(len(queries), 2)
671 for q in queries:
672 self.assertTrue(q.startswith("open source ai created:>2026-07-01"))
673 self.assertEqual(q.count("created:"), 1)
674 self.assertNotIn("stars:", q)
675 self.assertEqual(
676 {q.rsplit(" ", 1)[-1] for q in queries},
677 {"is:issue", "is:pull-request"},
678 )
679
680 @patch.object(github, "_resolve_token", return_value="test-token")
681 def test_authenticated_search_merges_issues_and_pull_requests(self, mock_token):
682 """Both qualifier queries run, and their results are deduped and
683 re-sorted by reactions — a plain concatenation would let the second
684 query's tail outrank the first query's head."""
685 issue = {"id": 1, "reactions": {"total_count": 5}}
686 pull = {"id": 2, "reactions": {"total_count": 9}}
687 also_issue = {"id": 1, "reactions": {"total_count": 5}} # cross-query dupe
688
689 def fake_fetch(url, *args, **kwargs):
690 q = self._query(url)
691 if "is:issue" in q:
692 return {"items": [issue]}
693 return {"items": [pull, also_issue]}
694
695 with patch.object(github, "_fetch_json", side_effect=fake_fetch):
696 envelope = github.search_github("topic", "2026-07-01", "2026-07-31")
697
698 ids = [item["id"] for item in envelope["items"]]
699 self.assertEqual(ids, [2, 1], "expected reaction-sorted, deduped merge")
700
701 @patch.object(github, "_resolve_token", return_value="test-token")
702 def test_authenticated_search_one_partition_fails_keeps_items_and_reports_error(self, mock_token):
703 """If one authenticated partition fails (returns None) and the other
704 returns items, the surviving items are kept but the envelope carries
705 an error so the source is not marked as a clean success."""
706 issue = {"id": 1, "reactions": {"total_count": 5}}
707
708 def fake_fetch(url, *args, **kwargs):
709 q = self._query(url)
710 if "is:issue" in q:
711 return {"items": [issue]}
712 return None # PR partition failed
713
714 with patch.object(github, "_fetch_json", side_effect=fake_fetch):
715 envelope = github.search_github("topic", "2026-07-01", "2026-07-31")
716
717 self.assertEqual(len(envelope["items"]), 1)
718 self.assertEqual(envelope["items"][0]["id"], 1)
719 self.assertIn("error", envelope)
720 self.assertIn("is:pull-request", envelope["error"])
721 self.assertIn("partition", envelope["error"].lower())
722
723 @patch.object(github, "_resolve_token", return_value="test-token")
724 def test_authenticated_search_both_partitions_fail_is_full_failure(self, mock_token):
725 """If both authenticated partitions fail (return None), the envelope
726 has empty items and carries an error indicating complete failure."""
727
728 with patch.object(github, "_fetch_json", return_value=None):
729 envelope = github.search_github("topic", "2026-07-01", "2026-07-31")
730
731 self.assertEqual(envelope["items"], [])
732 self.assertIn("error", envelope)
733 self.assertIn("GitHub", envelope["error"])
734
735 @patch.object(github, "_resolve_token", return_value=None)
736 def test_unauthenticated_search_omits_qualifier(self, mock_token):
737 """Anonymous /search/issues is still grandfathered without a
738 qualifier, so the single-query path must stay qualifier-free."""
739 captured = {}
740 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
741 github.search_github("open source ai", "2026-07-01", "2026-07-31")
742 self.assertEqual(len(captured["urls"]), 1)
743 q = self._query(captured["urls"][0])
744 self.assertNotIn("is:issue", q)
745 self.assertNotIn("is:pull-request", q)
746
747 @patch.object(github, "_resolve_token", return_value="test-token")
748 def test_qualifier_only_topic_skips_network(self, mock_token):
749 with patch.object(github, "_fetch_json") as mock_fetch:
750 result = github.search_github(
751 "created:>2025-03-20", "2026-07-01", "2026-07-31",
752 )
753 mock_fetch.assert_not_called()
754 self.assertEqual(result["items"], [])
755 self.assertNotIn("error", result)
756 self.assertEqual(result["context"]["from_date"], "2026-07-01")
757
758 @patch.object(github, "_resolve_token", return_value="test-token")
759 def test_qualifier_key_word_without_colon_survives(self, mock_token):
760 captured = {}
761 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
762 github.search_github(
763 "state of the art ai", "2026-07-01", "2026-07-31",
764 )
765 q = self._query(captured["url"])
766 # `state` is a GitHub qualifier key but appears here without a colon;
767 # it must survive extract_core_subject + the qualifier strip.
768 self.assertIn("state", q)
769 self.assertEqual(q.count("created:"), 1)
770
771
772
773 @patch.object(github, "_resolve_token", return_value="test-token")
774 def test_comma_glued_qualifier_builds_single_created_query(self, mock_token):
775 captured = {}
776 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
777 github.search_github(
778 "ai,created:>2025-03-20", "2026-07-01", "2026-07-31",
779 )
780 q = self._query(captured["url"])
781 self.assertEqual(q.count("created:"), 1)
782 self.assertIn("created:>2026-07-01", q)
783 self.assertNotIn("created:>2025-03-20", q)
784
785 @patch.object(github, "_resolve_token", return_value="test-token")
786 def test_empty_topic_skips_network(self, mock_token):
787 with patch.object(github, "_fetch_json") as mock_fetch:
788 result = github.search_github("", "2026-07-01", "2026-07-31")
789 mock_fetch.assert_not_called()
790 self.assertEqual(result["items"], [])
791 self.assertNotIn("error", result)
792
793 @patch.object(github, "_resolve_token", return_value="test-token")
794 def test_glued_term_after_qualifier_value_reaches_query(self, mock_token):
795 captured = {}
796 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
797 github.search_github(
798 "ai created:>2025-03-20,robotics", "2026-07-01", "2026-07-31",
799 )
800 q = self._query(captured["url"])
801 self.assertIn("robotics", q)
802 self.assertIn("ai", q)
803 self.assertEqual(q.count("created:"), 1)
804
805 @patch.object(github, "_resolve_token", return_value="test-token")
806 def test_paren_wrapped_qualifier_builds_single_created_query(self, mock_token):
807 # Wrapped qualifier (issue #952) must not survive into the query to
808 # collide with the adapter's own created: window (issue #949 class).
809 # Authenticated search emits is:issue / is:pull-request partitions
810 # (GitHub 422s without one); assert the subject per sub-query.
811 captured = {}
812 with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
813 github.search_github(
814 "open source ai (created:>2025-03-20)", "2026-07-01", "2026-07-31",
815 )
816 queries = [self._query(u) for u in captured["urls"]]
817 self.assertEqual(len(queries), 2)
818 for q in queries:
819 self.assertTrue(q.startswith("open source ai created:>2026-07-01"))
820 self.assertEqual(q.count("created:"), 1)
821 self.assertIn("created:>2026-07-01", q)
822 self.assertNotIn("created:>2025-03-20", q)
823 self.assertIn("open source", q)
824 self.assertIn("ai", q)
825 self.assertEqual(
826 {q.rsplit(" ", 1)[-1] for q in queries},
827 {"is:issue", "is:pull-request"},
828 )
829
830 @patch.object(github, "_resolve_token", return_value="test-token")
831 def test_quote_wrapped_qualifier_only_topic_skips_network(self, mock_token):
832 # A quote-wrapped qualifier-only topic strips to nothing, so the
833 # adapter must skip the network (#949/#952) and return a clean
834 # no-results envelope rather than ERROR (#953).
835 with patch.object(github, "_fetch_json") as mock_fetch:
836 result = github.search_github(
837 '"created:>2025-03-20"', "2026-07-01", "2026-07-31",
838 )
839 mock_fetch.assert_not_called()
840 self.assertEqual(result["items"], [])
841 self.assertNotIn("error", result)
842
843
844 if __name__ == "__main__":
845 unittest.main()
846
846 lines PYTHON