From d0b990e211198dbacbdf3aa79a18463b735ad91f Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Wed, 22 Apr 2026 18:14:14 +0530 Subject: [PATCH 1/2] 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", {}) From 8ccd778366b1e30411db6c1999ed989d52066d2f Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sun, 17 May 2026 00:51:21 -0700 Subject: [PATCH 2/2] fix(canonicalization): predicate-based call lookup + skip double-canon on auto-resolve Two findings from Greptile review on PR #302: 1. tests/test_cli_v3.py:302 - The test asserted run_mock.call_args_list[0] was the main runner's invocation, but fanout.run_competitor_fanout submits main + competitors to a ThreadPoolExecutor and iterates with as_completed. With zero-latency mocks, thread scheduling determines which pipeline.run call lands first, so the competitor's call could take index [0] and flake CI. Replace [0] indexing with a predicate match on the canonicalized github_repos kwargs. 2. skills/last30days/scripts/last30days.py:662 - When auto_resolve returns github_repos, it has already run canonicalize_github_repos(cap=5) and ranked by relevance. The downstream block then re-canonicalized with cap=None, which can re-sort by topic-slug match and clobber the auto_resolve relevance order. Guard the second canonicalization with a repos_from_auto_resolve flag so it only fires for user-supplied --github-repo input. --- skills/last30days/scripts/last30days.py | 10 +++++++++- tests/test_cli_v3.py | 19 +++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/skills/last30days/scripts/last30days.py b/skills/last30days/scripts/last30days.py index bdc37f3..37bab35 100644 --- a/skills/last30days/scripts/last30days.py +++ b/skills/last30days/scripts/last30days.py @@ -625,6 +625,7 @@ def main() -> int: # Auto-resolve: use web search to discover subreddits/handles before planning. # This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms # without WebSearch (OpenClaw, Codex, raw CLI). + repos_from_auto_resolve = False if args.auto_resolve and not external_plan: from lib import resolve resolution = resolve.auto_resolve(topic, config) @@ -639,6 +640,9 @@ def main() -> int: sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n") if resolution.get("github_repos") and not args.github_repo: args.github_repo = ",".join(resolution["github_repos"]) + # auto_resolve already canonicalized via canonicalize_github_repos(cap=5); + # mark so we don't re-canonicalize below and clobber its relevance order. + repos_from_auto_resolve = True sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n") if resolution.get("context"): # Inject context into external_plan metadata for the planner to use @@ -651,7 +655,11 @@ 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: + # Only canonicalize when repos came from a user-supplied --github-repo flag. + # When repos_from_auto_resolve is True, auto_resolve already ran + # canonicalize_github_repos(cap=5) and ranked by relevance; re-running here + # with cap=None can re-sort by topic-slug match and lose that ordering. + if github_repos and not repos_from_auto_resolve: from lib import resolve as resolve_lib original_github_repos = github_repos[:] github_repos = resolve_lib.canonicalize_github_repos(topic, github_repos, cap=None) diff --git a/tests/test_cli_v3.py b/tests/test_cli_v3.py index cac250d..153d6cc 100644 --- a/tests/test_cli_v3.py +++ b/tests/test_cli_v3.py @@ -289,12 +289,19 @@ class CliV3Tests(unittest.TestCase): 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"], + # In vs-mode main + competitors run in parallel via ThreadPoolExecutor, + # so the order of pipeline.run invocations is non-deterministic. Find + # the main runner's call by predicate on the canonicalized github_repos + # rather than by index. + expected_repos = ["openai/codex", "anthropics/claude-code"] + main_call = next( + (c for c in run_mock.call_args_list if c.kwargs.get("github_repos") == expected_repos), + None, + ) + self.assertIsNotNone( + main_call, + f"No pipeline.run call had github_repos={expected_repos}; " + f"saw {[c.kwargs.get('github_repos') for c in run_mock.call_args_list]}", ) self.assertIn("[GitHub] Canonicalized repos:", stderr.getvalue())