Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

145 lines
4.3 KiB
Python

"""Infer GitHub repository scope (owner/repo) for repo-scoped tool calls."""
from __future__ import annotations
import os
import re
import subprocess
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from pydantic import BaseModel
_GITHUB_HOST_RE = re.compile(
r"(?:https?://)?(?:www\.)?github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s#?]+)",
re.IGNORECASE,
)
_REPO_QUALIFIER_RE = re.compile(
r"\brepo:(?P<owner>[^/\s]+)/(?P<repo>[^/\s#?]+)",
re.IGNORECASE,
)
_BARE_REPO_RE = re.compile(
r"(?<![/\w.-])(?P<owner>[A-Za-z0-9][\w.-]*)/(?P<repo>[A-Za-z0-9][\w.-]*)(?![/\w.-])",
)
def split_repo_full_name(value: str) -> tuple[str, str]:
"""Split ``owner/repo`` (optionally with trailing ``.git``) into its parts."""
cleaned = value.strip().strip("/")
if cleaned.count("/") < 1:
return "", ""
owner, repo = cleaned.split("/", 1)
return owner.strip(), repo.strip().removesuffix(".git")
def parse_github_repository_reference(text: str) -> tuple[str, str] | None:
"""Return the last owner/repo pair found in *text*, or ``None``."""
if not text.strip():
return None
matches: list[tuple[str, str]] = []
for pattern in (_GITHUB_HOST_RE, _REPO_QUALIFIER_RE, _BARE_REPO_RE):
for match in pattern.finditer(text):
owner = match.group("owner").strip()
repo = match.group("repo").strip().removesuffix(".git")
if owner and repo:
matches.append((owner, repo))
return matches[-1] if matches else None
def _parse_git_remote_url(url: str) -> tuple[str, str] | None:
cleaned = url.strip()
if not cleaned:
return None
if cleaned.startswith("git@"):
_, _, path = cleaned.partition(":")
if path:
owner, repo = split_repo_full_name(path)
return (owner, repo) if owner and repo else None
return parse_github_repository_reference(cleaned)
def detect_git_remote_repo_scope(cwd: str | Path | None = None) -> tuple[str, str] | None:
"""Best-effort ``owner/repo`` from ``git remote get-url origin`` in *cwd*."""
work_dir = Path(cwd or os.getcwd())
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=work_dir,
capture_output=True,
text=True,
timeout=2,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
return _parse_git_remote_url(result.stdout)
def infer_github_repo_scope(
*,
message: str,
conversation_messages: Sequence[tuple[str, str]] | None = None,
env: Mapping[str, str] | None = None,
cwd: str | Path | None = None,
cached: tuple[str, str] | None = None,
) -> tuple[str, str] | None:
"""Resolve GitHub owner/repo using message, history, session cache, env, and git."""
from_message = parse_github_repository_reference(message)
if from_message:
return from_message
if conversation_messages:
for _role, content in reversed(conversation_messages):
from_history = parse_github_repository_reference(content)
if from_history:
return from_history
if cached:
return cached
env_map = env if env is not None else os.environ
env_repo = str(env_map.get("GITHUB_REPOSITORY", "")).strip()
if env_repo:
owner, repo = split_repo_full_name(env_repo)
if owner and repo:
return owner, repo
return detect_git_remote_repo_scope(cwd)
def apply_github_repo_scope(
resolved: dict[str, Any],
owner: str,
repo: str,
) -> dict[str, Any]:
"""Return a copy of *resolved* with ``github.owner`` and ``github.repo`` set."""
gh = resolved.get("github")
if not gh:
return dict(resolved)
if isinstance(gh, BaseModel):
gh_dict = gh.model_dump(exclude_none=True)
elif isinstance(gh, dict):
gh_dict = dict(gh)
else:
return dict(resolved)
merged = dict(resolved)
gh_dict["owner"] = owner
gh_dict["repo"] = repo
merged["github"] = gh_dict
return merged
__all__ = [
"apply_github_repo_scope",
"detect_git_remote_repo_scope",
"infer_github_repo_scope",
"parse_github_repository_reference",
"split_repo_full_name",
]