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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user