GitHub source returns zero results on every topic: search query omits is:issue/is:pull-request (HTTP 422)

Author: DataTechWizardCreated Jul 31, 2026Updated Sep 12, 2026

Summary

search_github() builds its query as f"{core} created:>{from_date}", with no is:issue / is:pull-request qualifier. GitHub's Search API now rejects that outright:

{"message":"Query must include 'is:issue' or 'is:pull-request'",
 "documentation_url":"https://docs.github.com/rest/search/search#search-issues-and-pull-requests",
 "status":"422"}

So the GitHub source returns zero items on every topic, authenticated or not. It fails quietly — doctor reports github tier=ok status=ok, and the failure only surfaces as run_outcome.state=error in the last-run evidence. A normal run just shows GitHub contributing nothing, which reads as "no GitHub discussion about this topic" rather than "the source is broken".

Version: 3.18.4. Auth via gh CLI (also reproduces with GITHUB_TOKEN).

Reproduce

gh api -X GET search/issues -f q="claude code created:>2026-07-01" -f sort=reactions -f order=desc -f per_page=5
# gh: Query must include 'is:issue' or 'is:pull-request' (HTTP 422)

advanced_search=true does not help — same 422.

The non-obvious part

The natural one-line fix (append both qualifiers to the single existing query) silently drops all issues:

query total_count
claude code created:>2026-07-01 is:issue 236,777
claude code created:>2026-07-01 is:pull-request 3,262,183
claude code created:>2026-07-01 is:issue is:pull-request 3,262,183

The two qualifiers don't union — is:pull-request wins. Combining them looks like it works (results come back, no error) while quietly returning PRs only.

Fix

Run the two lanes as separate requests and merge. One extra search call per run; the authenticated search limit is 30/min, so there's headroom.

One ordering detail: parse_github_response() truncates with raw_items[:count] and derives relevance from list position, so the merged list has to be re-sorted by reactions before returning — otherwise the truncation keeps the tail of the issue lane instead of the best of both.

Patch against skills/last30days/scripts/lib/github.py attached below. After it, the same query returns 30 raw items (15 issues + 15 PRs) and 9 items survive into a real --search=github,hackernews run, versus 0 before.

I'm happy to open this as a PR if the approach looks right to you.

@@ -202,19 +202,36 @@
         _log("No GitHub token; using the unauthenticated REST tier (low rate limit)")
     _log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
 
-    # Build search query with date filter
-    q = f"{core} created:>{from_date}"
-    params = {
-        "q": q,
-        "sort": "reactions",
-        "order": "desc",
-        "per_page": str(min(count, 100)),
-    }
-    url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
+    # Build search query with date filter.
+    #
+    # GitHub's issue search API rejects any query that carries neither
+    # ``is:issue`` nor ``is:pull-request`` -- it answers HTTP 422 "Query must
+    # include 'is:issue' or 'is:pull-request'". The two qualifiers also do NOT
+    # union inside one query: ``is:pull-request`` wins and issues are silently
+    # dropped. So run each lane separately and merge. Verified against the live
+    # API on 2026-07-31.
+    base_q = f"{core} created:>{from_date}"
+    per_page = str(min(count, 100))
 
     fetch_failures: List[str] = []
-    data = _fetch_json(url, token=resolved_token, timeout=30, failure_out=fetch_failures)
-    if not data:
+    raw_items: List[Dict[str, Any]] = []
+    any_lane_ok = False
+    for qualifier in ("is:issue", "is:pull-request"):
+        params = {
+            "q": f"{base_q} {qualifier}",
+            "sort": "reactions",
+            "order": "desc",
+            "per_page": per_page,
+        }
+        url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
+        lane = _fetch_json(url, token=resolved_token, timeout=30,
+                           failure_out=fetch_failures)
+        if lane is None:
+            continue
+        any_lane_ok = True
+        raw_items.extend(lane.get("items") or [])
+
+    if not any_lane_ok:
         envelope = {"items": [], "context": {"core": core, "from_date": from_date,
                                              "to_date": to_date, "count": count}}
         if authed and fetch_failures:
@@ -231,7 +248,25 @@
             )
         return envelope
 
-    raw_items = data.get("items", [])
+    # Each lane is reaction-sorted on its own, so the merged list is not.
+    # Re-sort before returning: parse_github_response() truncates with
+    # ``raw_items[:count]`` and ranks by position, so an unsorted merge would
+    # hand it the tail of the issue lane instead of the best of both.
+    def _reactions(item: Dict[str, Any]) -> int:
+        reactions = item.get("reactions")
+        return reactions.get("total_count", 0) if isinstance(reactions, dict) else 0
+
+    seen: set = set()
+    deduped: List[Dict[str, Any]] = []
+    for item in raw_items:
+        key = item.get("id") or item.get("html_url")
+        if key in seen:
+            continue
+        seen.add(key)
+        deduped.append(item)
+    deduped.sort(key=_reactions, reverse=True)
+    raw_items = deduped
+
     _log(f"Found {len(raw_items)} issues/PRs")
 
     return {

Source: mvanhorn/last30days-skill