From d0b990e211198dbacbdf3aa79a18463b735ad91f Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Wed, 22 Apr 2026 18:14:14 +0530 Subject: [PATCH] Canonicalize GitHub repo resolution for ambiguous product repos --- skills/last30days/scripts/last30days.py | 10 +++ skills/last30days/scripts/lib/resolve.py | 89 +++++++++++++++++++++++- tests/test_cli_v3.py | 38 ++++++++++ tests/test_resolve.py | 18 +++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/skills/last30days/scripts/last30days.py b/skills/last30days/scripts/last30days.py index b22d28f..bdc37f3 100644 --- a/skills/last30days/scripts/last30days.py +++ b/skills/last30days/scripts/last30days.py @@ -651,6 +651,16 @@ def main() -> int: github_user = args.github_user.lstrip("@").lower() if args.github_user else None github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None + if github_repos: + from lib import resolve as resolve_lib + original_github_repos = github_repos[:] + github_repos = resolve_lib.canonicalize_github_repos(topic, github_repos, cap=None) + if github_repos != original_github_repos: + sys.stderr.write( + "[GitHub] Canonicalized repos: " + f"{','.join(original_github_repos)} -> {','.join(github_repos)}\n" + ) + # --deep-research: auto-enable perplexity source and set deep flag if args.deep_research: if not config.get("OPENROUTER_API_KEY"): diff --git a/skills/last30days/scripts/lib/resolve.py b/skills/last30days/scripts/lib/resolve.py index 6bc7748..4eef6fd 100644 --- a/skills/last30days/scripts/lib/resolve.py +++ b/skills/last30days/scripts/lib/resolve.py @@ -160,6 +160,93 @@ def _extract_github_repos(items: list[dict]) -> list[str]: return repos[:5] # cap at 5 repos +_INTEGRATION_SUFFIX_KEYWORDS: dict[str, set[str]] = { + "-action": {"action", "actions", "workflow", "workflows"}, + "-sdk": {"sdk", "client", "library"}, + "-plugin": {"plugin", "plugins", "extension", "extensions"}, + "-plugins": {"plugin", "plugins", "extension", "extensions"}, + "-docs": {"docs", "documentation"}, + "-examples": {"example", "examples", "sample", "samples"}, + "-template": {"template", "templates", "starter", "boilerplate"}, +} + + +def _topic_tokens(topic: str) -> set[str]: + return set(re.findall(r"[a-z0-9]+", (topic or "").lower())) + + +def _topic_entity_slugs(topic: str) -> list[str]: + entities = re.split(r"\b(?:vs|versus)\b", (topic or "").lower()) + slugs: list[str] = [] + for entity in entities: + tokens = re.findall(r"[a-z0-9]+", entity) + if tokens: + slugs.append("-".join(tokens)) + return slugs + + +def _repo_slug(repo: str) -> str: + parts = repo.split("/", 1) + if len(parts) != 2: + return "" + return parts[1].lower() + + +def _canonicalize_integration_repo(topic: str, repo: str) -> str: + """Map integration repos back to canonical product repos when intent allows. + + Example: + anthropics/claude-code-action -> anthropics/claude-code + unless topic explicitly asks for "action"/"workflow". + """ + parts = repo.split("/", 1) + if len(parts) != 2: + return repo + owner, name = parts[0], parts[1] + lower_name = name.lower() + topic_words = _topic_tokens(topic) + for suffix, intent_words in _INTEGRATION_SUFFIX_KEYWORDS.items(): + if not lower_name.endswith(suffix): + continue + if topic_words.intersection(intent_words): + return repo + base = name[: -len(suffix)] + if base: + return f"{owner}/{base}" + return repo + + +def canonicalize_github_repos(topic: str, repos: list[str], *, cap: int | None = 5) -> list[str]: + """Normalize/priority-sort GitHub repos for the current topic. + + - Rewrites common integration suffixes to canonical product repos when + topic intent does not mention those integrations. + - Promotes exact topic slug matches (e.g., `claude-code`) over partials. + """ + canonicalized: list[str] = [] + seen: set[str] = set() + for repo in repos: + candidate = _canonicalize_integration_repo(topic, repo.strip()) + if "/" not in candidate: + continue + key = candidate.lower() + if key in seen: + continue + seen.add(key) + canonicalized.append(candidate) + + topic_slugs = set(_topic_entity_slugs(topic)) + if topic_slugs: + exact = [r for r in canonicalized if _repo_slug(r) in topic_slugs] + prefixed = [r for r in canonicalized if any(_repo_slug(r).startswith(f"{slug}-") for slug in topic_slugs) and r not in exact] + rest = [r for r in canonicalized if r not in exact and r not in prefixed] + canonicalized = exact + prefixed + rest + + if cap is not None: + return canonicalized[:cap] + return canonicalized + + def _build_context_summary(items: list[dict]) -> str: """Build a 1-2 sentence current events summary from news search results.""" snippets: list[str] = [] @@ -240,7 +327,7 @@ def auto_resolve(topic: str, config: dict) -> dict: subreddits = _extract_subreddits(results.get("subreddit", [])) x_handle = _extract_x_handle(results.get("x_handle", [])) github_user = _extract_github_user(results.get("github", [])) - github_repos = _extract_github_repos(results.get("github", [])) + github_repos = canonicalize_github_repos(topic, _extract_github_repos(results.get("github", []))) context = _build_context_summary(results.get("news", [])) subreddits, category = _merge_category_peers(topic, subreddits) diff --git a/tests/test_cli_v3.py b/tests/test_cli_v3.py index 7ec19d3..cac250d 100644 --- a/tests/test_cli_v3.py +++ b/tests/test_cli_v3.py @@ -260,6 +260,44 @@ class CliV3Tests(unittest.TestCase): fake_progress.show_promo.assert_called_once_with("both", diag=diag) self.assertIn("# rendered", stdout.getvalue()) + def test_main_canonicalizes_explicit_github_repo_flags(self): + report = self.make_report() + diag = { + "available_sources": ["grounding"], + "providers": {"google": True, "openai": False, "xai": False}, + "x_backend": None, + "bird_installed": True, + "bird_authenticated": False, + "bird_username": None, + "native_web_backend": "brave", + } + with mock.patch.object(cli.env, "get_config", return_value={}), \ + mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \ + mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \ + mock.patch.object(cli, "emit_output", return_value="# rendered"), \ + mock.patch.object(sys, "argv", [ + "last30days.py", + "claude", + "code", + "vs", + "codex", + "--github-repo", + "openai/codex,anthropics/claude-code-action", + ]): + stdout = io.StringIO() + stderr = io.StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + rc = cli.main() + self.assertEqual(0, rc) + # In vs-mode the main_runner is the first pipeline.run call; sub-runs + # for competitor entities follow with their own per-entity github_repos. + kwargs = run_mock.call_args_list[0].kwargs + self.assertEqual( + ["openai/codex", "anthropics/claude-code"], + kwargs["github_repos"], + ) + self.assertIn("[GitHub] Canonicalized repos:", stderr.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 742f657..eab6ec4 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -109,6 +109,24 @@ class TestBuildContextSummary(unittest.TestCase): self.assertEqual(resolve._build_context_summary(items), "") +class TestCanonicalizeGithubRepos(unittest.TestCase): + def test_rewrites_integration_repo_to_canonical_product(self): + repos = ["openai/codex", "anthropics/claude-code-action"] + result = resolve.canonicalize_github_repos("claude code vs codex", repos, cap=None) + self.assertEqual(result, ["openai/codex", "anthropics/claude-code"]) + + def test_preserves_action_repo_when_topic_intends_action(self): + repos = ["anthropics/claude-code-action", "openai/codex"] + result = resolve.canonicalize_github_repos("claude code action setup", repos, cap=None) + self.assertIn("anthropics/claude-code-action", result) + self.assertNotIn("anthropics/claude-code", result) + + def test_dedupes_case_insensitive_after_canonicalization(self): + repos = ["Anthropics/Claude-Code-Action", "anthropics/claude-code"] + result = resolve.canonicalize_github_repos("claude code", repos, cap=None) + self.assertEqual(result, ["Anthropics/Claude-Code"]) + + class TestAutoResolve(unittest.TestCase): def test_no_backend_returns_empty(self): result = resolve.auto_resolve("test topic", {})