fix(github): repos lane when person-mode PR search is empty (#883)

Co-authored-by: bekonyn <noyanjeanbean@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
This commit is contained in:
Beko
2026-07-30 21:58:15 +03:00
committed by GitHub
parent a940c98965
commit b0fdfb0d51
5 changed files with 478 additions and 2 deletions
@@ -0,0 +1 @@
`--github-user` no longer returns unrelated repos for people whose PR search comes back empty or is unavailable. Person mode now falls back to the selected user's public GitHub events and returns only in-window `PushEvent` activity attributed to that actor, instead of treating repository-level `pushed_at` as proof that the selected user pushed. A pinned `--github-user` that still yields nothing is recorded as `no-results` instead of passing silently.
+174
View File
@@ -427,6 +427,8 @@ PERSON_DEPTH_LIMITS = {
"deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
}
PERSON_EVENTS_PER_PAGE = 100
def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
"""Fetch README content for a repo, truncated to first ~max_chars."""
@@ -623,6 +625,17 @@ def search_github_person(
_log(f"Found {total_prs} total PRs, {merged_count} merged")
if total_prs == 0 and merged_count == 0:
# An empty PR search can mean no PRs in the window or an account that
# GitHub's issue index cannot search. Public PushEvents provide an
# actor-attributed fallback for either case.
search_unavailable = total_data is None or merged_data is None
recent = _person_recent_pushes(
username, from_date, to_date, limits, resolved_token,
)
if recent:
reason = "account not searchable" if search_unavailable else "no PRs in window"
_log(f"PR search empty ({reason}); public events returned {len(recent)} items")
return recent
_log("No PRs found, falling back to keyword search")
return []
@@ -822,6 +835,167 @@ def search_github_person(
return items
def _person_recent_pushes(
username: str,
from_date: str,
to_date: str,
limits: Dict[str, int],
token: str,
) -> List[Dict[str, Any]]:
"""Return repos the selected actor publicly pushed inside the window."""
latest_by_repo: Dict[str, Dict[str, str]] = {}
encoded_username = urllib.parse.quote(username, safe="")
page = 1
while True:
url = (
f"https://api.github.com/users/{encoded_username}/events/public"
f"?per_page={PERSON_EVENTS_PER_PAGE}&page={page}"
)
data = _fetch_json(url, token=token, timeout=15)
if not data or not isinstance(data, list):
break
reached_before_window = False
for event in data:
created_at = event.get("created_at")
pushed = _parse_date(created_at)
if not pushed:
continue
if pushed < from_date:
reached_before_window = True
break
if pushed > to_date or event.get("type") != "PushEvent":
continue
actor = event.get("actor")
actor_login = actor.get("login", "") if isinstance(actor, dict) else ""
if actor_login.casefold() != username.casefold():
continue
repo = event.get("repo")
full_name = repo.get("name", "") if isinstance(repo, dict) else ""
if not re.fullmatch(r"[^/\s]+/[^/\s]+", full_name):
continue
previous = latest_by_repo.get(full_name)
if previous is None or created_at > previous["created_at"]:
latest_by_repo[full_name] = {
"full_name": full_name,
"pushed": pushed,
"created_at": created_at,
"actor": actor_login,
"event_id": str(event.get("id") or ""),
}
if reached_before_window or len(data) < PERSON_EVENTS_PER_PAGE:
break
page += 1
if not latest_by_repo:
return []
recent = sorted(
latest_by_repo.values(),
key=lambda r: r["created_at"],
reverse=True,
)
_log(
f"Public events: {len(recent)} actor-attributed repos pushed in window, "
"loading repository metadata for ranking"
)
repo_info: Dict[str, Dict[str, Any]] = {}
with ThreadPoolExecutor(max_workers=8) as executor:
info_futures = {
executor.submit(_fetch_repo_info, r["full_name"], token): r["full_name"]
for r in recent
}
for future in as_completed(info_futures):
name = info_futures[future]
try:
repo_info[name] = future.result(timeout=20) or {}
except Exception as exc:
_log(f"Push-event repo metadata failed for {name}: {exc}")
repo_info[name] = {}
recent.sort(
key=lambda r: (
repo_info.get(r["full_name"], {}).get("stars", 0),
r["created_at"],
),
reverse=True,
)
selected = recent[:limits["own_repos"]]
enrichments: Dict[str, Dict[str, Any]] = {}
_log(f"Public events: enriching {len(selected)} top-ranked repositories")
with ThreadPoolExecutor(max_workers=8) as executor:
enrichment_futures = {
executor.submit(_enrich_own_repo, r["full_name"], token): r["full_name"]
for r in selected
}
for future in as_completed(enrichment_futures):
name = enrichment_futures[future]
try:
enrichments[name] = future.result(timeout=25)
except Exception as exc:
_log(f"Push-event enrichment failed for {name}: {exc}")
enrichments[name] = {}
items: List[Dict[str, Any]] = []
for idx, repo in enumerate(selected, start=1):
name = repo["full_name"]
info = repo_info.get(name, {})
stars = info.get("stars", 0)
stars_str = _format_stars(stars)
open_issues = info.get("open_issues", 0)
enrichment = enrichments.get(name, {})
readme = enrichment.get("readme")
releases = enrichment.get("releases", [])
snippet_parts = [
f"@{repo['actor']} pushed {name} on {repo['pushed']} "
f"({stars_str} stars, {open_issues} open issues)"
]
if info.get("description"):
snippet_parts.append(f" {info['description']}")
if readme:
snippet_parts.append(f" README: {readme[:300]}")
for rel in releases[:2]:
body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
snippet_parts.append(f" Release: {rel['name']} ({rel['date']}){body_preview}")
items.append({
"id": f"GH{idx}",
"title": f"@{repo['actor']} pushed {name} on {repo['pushed']}",
"url": f"https://github.com/{name}",
"date": repo["pushed"],
"author": repo["actor"],
"source": "github",
"score": stars,
"container": name,
"snippet": "\n".join(snippet_parts),
"relevance": min(0.9, 0.6 + math.log1p(stars) / 30),
"why_relevant": (
f"GitHub activity: @{repo['actor']} pushed {name} on {repo['pushed']} "
f"({stars_str} stars)"
),
"engagement": {"stars": stars, "comments": open_issues},
"metadata": {
"labels": ["person-profile", "recent-push"],
"state": "open",
"comment_count": open_issues,
"reactions": stars,
"is_pr": False,
"event_type": "PushEvent",
"event_id": repo["event_id"],
},
})
return items
def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
"""Fetch star count + releases for an external repo."""
info = _fetch_repo_info(repo, token)
+10 -1
View File
@@ -2109,6 +2109,7 @@ def run(
_github_person_done = False
if github_user and "github" in available and not _github_custom_done:
bundle.mark_attempted("github")
_github_person_done = True
try:
person_items = github.search_github_person(
github_user, from_date, to_date,
@@ -2123,7 +2124,15 @@ def run(
# Use the first subquery's label so RRF can look up the weight
primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
bundle.add_items(primary_label, "github", normalized)
_github_person_done = True
else:
# A pinned --github-user that yields nothing must not be
# silently backfilled by generic keyword search: the report
# would then present unrelated repos as this person's work.
bundle.record_failure(
"github",
"no-results",
f"Person mode found no activity for @{github_user} in the window",
)
except Exception as exc:
bundle.errors_by_source["github"] = f"Person-mode failed: {exc}"
state, attempted = _classify_source_failure(exc)
+206
View File
@@ -289,5 +289,211 @@ class TestComputeRelevance(unittest.TestCase):
low = github._compute_relevance("react", "React", 20, 0, 0)
self.assertGreater(high, low)
class TestPersonPushEventsLane(unittest.TestCase):
"""Person mode must not go dark when PR search returns nothing."""
@staticmethod
def _event(
event_id,
*,
actor="kurt",
repo="kurt/power-bi-agentic-development",
created_at="2026-07-22T20:28:18Z",
event_type="PushEvent",
):
return {
"id": str(event_id),
"type": event_type,
"actor": {"login": actor},
"repo": {"name": repo},
"created_at": created_at,
}
def _run(self):
with patch.object(github, "_resolve_token", return_value="t"), \
patch.object(github, "_enrich_own_repo", return_value={}), \
patch.object(github, "_fetch_repo_info", return_value={
"stars": 811,
"forks": 119,
"description": "Claude Code plugin marketplace for Power BI",
"language": "Python",
"open_issues": 4,
}):
return github.search_github_person(
"kurt", "2026-06-25", "2026-07-25", token="t",
)
def test_unsearchable_account_falls_back_to_actor_push_events(self):
def fetch(url, **kwargs):
if "search/issues" in url:
return None
return [self._event(1)]
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["container"], "kurt/power-bi-agentic-development")
self.assertEqual(items[0]["date"], "2026-07-22")
self.assertIn("@kurt pushed", items[0]["title"])
self.assertIn("recent-push", items[0]["metadata"]["labels"])
self.assertEqual(items[0]["metadata"]["event_type"], "PushEvent")
def test_empty_pr_search_falls_back_to_actor_push_events(self):
def fetch(url, **kwargs):
if "search/issues" in url:
return {"total_count": 0, "items": []}
return [self._event(1, actor="KURT")]
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["author"], "KURT")
def test_other_actor_push_is_rejected(self):
def fetch(url, **kwargs):
if "search/issues" in url:
return {"total_count": 0, "items": []}
return [self._event(1, actor="collaborator")]
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual(items, [])
def test_discovers_push_on_second_events_page(self):
first_page = [
self._event(
i,
event_type="WatchEvent",
created_at=f"2026-07-{24 - (i // 25):02d}T12:00:00Z",
)
for i in range(github.PERSON_EVENTS_PER_PAGE)
]
requested_urls = []
def fetch(url, **kwargs):
requested_urls.append(url)
if "search/issues" in url:
return {"total_count": 0, "items": []}
if "&page=1" in url:
return first_page
if "&page=2" in url:
return [self._event(101, repo="kurt/page-two")]
self.fail(f"Unexpected URL: {url}")
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual([item["container"] for item in items], ["kurt/page-two"])
self.assertTrue(any("&page=2" in url for url in requested_urls))
def test_requests_page_after_three_full_event_pages(self):
full_page = [
self._event(
i,
event_type="WatchEvent",
created_at="2026-07-24T12:00:00Z",
)
for i in range(github.PERSON_EVENTS_PER_PAGE)
]
requested_pages = []
def fetch(url, **kwargs):
if "search/issues" in url:
return {"total_count": 0, "items": []}
page = int(url.rsplit("&page=", 1)[1])
requested_pages.append(page)
return full_page if page <= 3 else []
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual(items, [])
self.assertEqual(requested_pages, [1, 2, 3, 4])
def test_stops_paging_at_event_older_than_window(self):
requested_urls = []
def fetch(url, **kwargs):
requested_urls.append(url)
if "search/issues" in url:
return {"total_count": 0, "items": []}
if "&page=1" in url:
return [
self._event(1, event_type="WatchEvent"),
self._event(2, created_at="2026-06-24T23:59:59Z"),
]
self.fail("Events paging continued after reaching an old event")
with patch.object(github, "_fetch_json", side_effect=fetch):
items = self._run()
self.assertEqual(items, [])
event_urls = [url for url in requested_urls if "/events/public" in url]
self.assertEqual(len(event_urls), 1)
def test_ranks_all_event_repos_before_applying_depth_cap(self):
events = [
self._event(1, repo="kurt/newest", created_at="2026-07-24T12:00:00Z"),
self._event(2, repo="kurt/recent", created_at="2026-07-23T12:00:00Z"),
self._event(3, repo="kurt/third", created_at="2026-07-22T12:00:00Z"),
self._event(4, repo="kurt/high-star", created_at="2026-07-21T12:00:00Z"),
]
stars = {
"kurt/newest": 3,
"kurt/recent": 2,
"kurt/third": 1,
"kurt/high-star": 10_000,
}
def repo_info(repo, token):
return {
"stars": stars[repo],
"forks": 0,
"description": "",
"language": "Python",
"open_issues": 0,
}
with patch.object(github, "_fetch_json", return_value=events), \
patch.object(github, "_fetch_repo_info", side_effect=repo_info), \
patch.object(github, "_enrich_own_repo", return_value={}) as enrich:
items = github._person_recent_pushes(
"kurt",
"2026-06-25",
"2026-07-25",
{"own_repos": 3},
"t",
)
containers = [item["container"] for item in items]
self.assertIn("kurt/high-star", containers)
self.assertNotIn("kurt/third", containers)
self.assertEqual(enrich.call_count, 3)
def test_aggregates_each_repo_at_its_latest_matching_push(self):
events = [
self._event(2, created_at="2026-07-24T12:00:00Z"),
self._event(1, created_at="2026-07-20T12:00:00Z"),
]
with patch.object(github, "_fetch_json", return_value=events), \
patch.object(github, "_fetch_repo_info", return_value={}), \
patch.object(github, "_enrich_own_repo", return_value={}):
items = github._person_recent_pushes(
"kurt",
"2026-06-25",
"2026-07-25",
{"own_repos": 5},
"t",
)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["date"], "2026-07-24")
self.assertEqual(items[0]["metadata"]["event_id"], "2")
if __name__ == "__main__":
unittest.main()
+87 -1
View File
@@ -2,8 +2,9 @@ import threading
import unittest
from unittest.mock import patch
from lib import pipeline
from lib import health
from lib import http
from lib import pipeline
from lib import schema
@@ -448,6 +449,91 @@ class TestThinSourceRetryPlannedSource(unittest.TestCase):
self.assertEqual("https://x.com/example/status/100", bundle.items_by_source["x"][0].url)
class TestPinnedGithubPersonAuthority(unittest.TestCase):
@patch("lib.pipeline._retrieve_stream")
@patch("lib.pipeline.github.search_github_person", return_value=[])
def test_empty_person_result_suppresses_generic_fanout_and_retry(
self, mock_person_search, mock_retrieve
):
plan = {
"intent": "person",
"freshness_mode": "balanced_recent",
"cluster_mode": "topic",
"subqueries": [
{
"label": "primary",
"search_query": "octocat recent activity",
"ranking_query": "What has @octocat done on GitHub recently?",
"sources": ["github"],
}
],
"source_weights": {"github": 1.0},
}
report = pipeline.run(
topic="octocat",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="default",
requested_sources=["github"],
mock=True,
external_plan=plan,
github_user="octocat",
)
mock_person_search.assert_called_once()
mock_retrieve.assert_not_called()
self.assertEqual(schema.NO_RESULTS, report.source_status["github"].state)
self.assertIn(
"Person mode found no activity for @octocat",
report.source_status["github"].detail,
)
@patch("lib.pipeline._retrieve_stream")
@patch(
"lib.pipeline.github.search_github_person",
side_effect=RuntimeError("GitHub API unavailable"),
)
def test_person_failure_suppresses_generic_fanout_and_retry(
self, mock_person_search, mock_retrieve
):
plan = {
"intent": "person",
"freshness_mode": "balanced_recent",
"cluster_mode": "topic",
"subqueries": [
{
"label": "primary",
"search_query": "octocat recent activity",
"ranking_query": "What has @octocat done on GitHub recently?",
"sources": ["github"],
}
],
"source_weights": {"github": 1.0},
}
report = pipeline.run(
topic="octocat",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="default",
requested_sources=["github"],
mock=True,
external_plan=plan,
github_user="octocat",
)
mock_person_search.assert_called_once()
mock_retrieve.assert_not_called()
self.assertEqual(health.ERROR, report.source_status["github"].state)
self.assertEqual(
"GitHub API unavailable",
report.source_status["github"].detail,
)
self.assertEqual(
"Person-mode failed: GitHub API unavailable",
report.errors_by_source["github"],
)
class TestTrustpilotNeverRetriedAsThin(unittest.TestCase):
@patch("lib.pipeline._retrieve_stream")