Merge pull request #302 from nidhi-singh02/fix/github-repo-canonicalization
fix: Canonicalize ambiguous GitHub repo resolution for product comparisons
This commit is contained in:
@@ -644,6 +644,7 @@ def main() -> int:
|
|||||||
# Auto-resolve: use web search to discover subreddits/handles before planning.
|
# 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
|
# This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms
|
||||||
# without WebSearch (OpenClaw, Codex, raw CLI).
|
# without WebSearch (OpenClaw, Codex, raw CLI).
|
||||||
|
repos_from_auto_resolve = False
|
||||||
if args.auto_resolve and not external_plan:
|
if args.auto_resolve and not external_plan:
|
||||||
from lib import resolve
|
from lib import resolve
|
||||||
resolution = resolve.auto_resolve(topic, config)
|
resolution = resolve.auto_resolve(topic, config)
|
||||||
@@ -658,6 +659,9 @@ def main() -> int:
|
|||||||
sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
|
sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
|
||||||
if resolution.get("github_repos") and not args.github_repo:
|
if resolution.get("github_repos") and not args.github_repo:
|
||||||
args.github_repo = ",".join(resolution["github_repos"])
|
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")
|
sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n")
|
||||||
if resolution.get("context"):
|
if resolution.get("context"):
|
||||||
# Inject context into external_plan metadata for the planner to use
|
# Inject context into external_plan metadata for the planner to use
|
||||||
@@ -670,6 +674,20 @@ def main() -> int:
|
|||||||
github_user = args.github_user.lstrip("@").lower() if args.github_user else None
|
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
|
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
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
|
# --deep-research: auto-enable perplexity source and set deep flag
|
||||||
if args.deep_research:
|
if args.deep_research:
|
||||||
if not config.get("OPENROUTER_API_KEY"):
|
if not config.get("OPENROUTER_API_KEY"):
|
||||||
|
|||||||
@@ -160,6 +160,93 @@ def _extract_github_repos(items: list[dict]) -> list[str]:
|
|||||||
return repos[:5] # cap at 5 repos
|
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:
|
def _build_context_summary(items: list[dict]) -> str:
|
||||||
"""Build a 1-2 sentence current events summary from news search results."""
|
"""Build a 1-2 sentence current events summary from news search results."""
|
||||||
snippets: list[str] = []
|
snippets: list[str] = []
|
||||||
@@ -240,7 +327,7 @@ def auto_resolve(topic: str, config: dict) -> dict:
|
|||||||
subreddits = _extract_subreddits(results.get("subreddit", []))
|
subreddits = _extract_subreddits(results.get("subreddit", []))
|
||||||
x_handle = _extract_x_handle(results.get("x_handle", []))
|
x_handle = _extract_x_handle(results.get("x_handle", []))
|
||||||
github_user = _extract_github_user(results.get("github", []))
|
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", []))
|
context = _build_context_summary(results.get("news", []))
|
||||||
|
|
||||||
subreddits, category = _merge_category_peers(topic, subreddits)
|
subreddits, category = _merge_category_peers(topic, subreddits)
|
||||||
|
|||||||
@@ -260,6 +260,51 @@ class CliV3Tests(unittest.TestCase):
|
|||||||
fake_progress.show_promo.assert_called_once_with("both", diag=diag)
|
fake_progress.show_promo.assert_called_once_with("both", diag=diag)
|
||||||
self.assertIn("# rendered", stdout.getvalue())
|
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 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())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ class TestBuildContextSummary(unittest.TestCase):
|
|||||||
self.assertEqual(resolve._build_context_summary(items), "")
|
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):
|
class TestAutoResolve(unittest.TestCase):
|
||||||
def test_no_backend_returns_empty(self):
|
def test_no_backend_returns_empty(self):
|
||||||
result = resolve.auto_resolve("test topic", {})
|
result = resolve.auto_resolve("test topic", {})
|
||||||
|
|||||||
Reference in New Issue
Block a user