#!/usr/bin/env python3 """Sync upstream NexusCRM docs and agent guides into this pack repo. The copied files are intentionally namespaced. The downstream pack keeps its own root agent docs and skills as authority; upstream docs are imported as reference context only. Upstream `.agents/**` and `.claude/**` files are not copied. """ from __future__ import annotations import argparse import json import os import posixpath import re import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Iterable from urllib.parse import urlparse UPSTREAM_REPO = "https://github.com/pingcap-inc/NexusCRM" UPSTREAM_WEB = "https://github.com/pingcap-inc/NexusCRM" DEST_ROOT = Path("docs/upstream/nexuscrm") ROOT_AGENT_DOCS = ("AGENTS.md", "CLAUDE.md") COPIED_CODE_PATH_PREFIXES = ( "backend/", "frontend/", "shared/", "tests/", "infra/", "scripts/", ".github/", ) LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^)\s]+)((?:\s+['\"][^)]*['\"])?\))") STANDALONE_TARGET_RE = re.compile(r"^(\s*)(\S+)(\s*)$") def run( args: list[str], *, cwd: Path | None = None, input_bytes: bytes | None = None, check: bool = True, ) -> subprocess.CompletedProcess[bytes]: return subprocess.run( args, cwd=cwd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, ) def git(repo: Path, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[bytes]: return run(["git", "-C", str(repo), *args], check=check) def repo_root() -> Path: root = run(["git", "rev-parse", "--show-toplevel"]).stdout.decode().strip() return Path(root) def default_ref(root: Path) -> str: del root return "main" def fetch_ref(repo: Path, ref: str) -> str: rev = git(repo, ["rev-parse", "--verify", f"{ref}^{{commit}}"], check=False) if rev.returncode == 0: return rev.stdout.decode().strip() fetch = git(repo, ["fetch", "--depth=1", "origin", ref], check=False) if fetch.returncode != 0: sys.stderr.write(fetch.stderr.decode()) raise SystemExit(f"Unable to fetch upstream ref {ref!r}") rev = git(repo, ["rev-parse", "--verify", "FETCH_HEAD^{commit}"]) return rev.stdout.decode().strip() def prepare_source_repo(args: argparse.Namespace, temp_root: Path, root: Path) -> tuple[Path, str]: candidates: list[Path] = [] if args.source_dir: candidates.append(Path(args.source_dir)) if os.environ.get("NEXUSCRM_SOURCE_REPO"): candidates.append(Path(os.environ["NEXUSCRM_SOURCE_REPO"])) candidates.append(root.parent / "NexusCRM") for candidate in candidates: if (candidate / ".git").exists(): commit = fetch_ref(candidate, args.ref) return candidate, commit clone = temp_root / "NexusCRM.git" clone.mkdir(parents=True, exist_ok=True) run(["git", "init", str(clone)]) git(clone, ["remote", "add", "origin", args.repo]) fetch = git(clone, ["fetch", "--depth=1", "origin", args.ref], check=False) if fetch.returncode != 0: sys.stderr.write(fetch.stderr.decode()) raise SystemExit(f"Unable to fetch {args.repo}@{args.ref}") commit = git(clone, ["rev-parse", "--verify", "FETCH_HEAD^{commit}"]).stdout.decode().strip() return clone, commit def tree_files(repo: Path, commit: str) -> set[str]: output = git(repo, ["ls-tree", "-r", "--name-only", commit]).stdout.decode() return {line for line in output.splitlines() if line} def tree_dirs(files: Iterable[str]) -> set[str]: dirs: set[str] = set() for file_name in files: parent = posixpath.dirname(file_name) while parent and parent != ".": dirs.add(parent) parent = posixpath.dirname(parent) return dirs def read_blob(repo: Path, commit: str, path: str) -> bytes: return git(repo, ["show", f"{commit}:{path}"]).stdout def copied_destination(path: str) -> Path | None: if path in ROOT_AGENT_DOCS: return DEST_ROOT / path if path.startswith("docs/"): return DEST_ROOT / path return None def github_url(commit: str, path: str, dirs: set[str]) -> str: cleaned = path.strip("/") kind = "tree" if cleaned in dirs else "blob" return f"{UPSTREAM_WEB}/{kind}/{commit}/{cleaned}" def split_local_target(target: str) -> tuple[str, str]: anchor = "" if "#" in target: target, anchor = target.split("#", 1) anchor = "#" + anchor query = "" if "?" in target: target, query = target.split("?", 1) query = "?" + query return target, query + anchor def is_external_target(target: str) -> bool: lowered = target.lower() return ( lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("mailto:") or lowered.startswith("tel:") or lowered.startswith("#") or lowered.startswith("data:") ) def same_repo_github_path(target: str) -> tuple[str, str] | None: parsed = urlparse(target) if parsed.scheme not in {"http", "https"}: return None if parsed.netloc != "github.com": return None parts = [part for part in parsed.path.split("/") if part] if len(parts) < 5: return None owner, repo, kind = parts[0], parts[1], parts[2] if owner != "pingcap-inc" or repo != "NexusCRM" or kind not in {"blob", "tree"}: return None upstream_path = "/".join(parts[4:]) suffix = "" if parsed.query: suffix += "?" + parsed.query if parsed.fragment: suffix += "#" + parsed.fragment return upstream_path, suffix def is_repo_root_path(path: str) -> bool: if path in ROOT_AGENT_DOCS: return True if path in {"README.md", "go.mod", "go.sum", "package.json", "package-lock.json"}: return True return path.startswith(("docs/", ".claude/", ".agents/", *COPIED_CODE_PATH_PREFIXES)) def normalize_target(source_path: str, target_path: str) -> str: if target_path.startswith("/"): return posixpath.normpath(target_path.lstrip("/")) if is_repo_root_path(target_path): return posixpath.normpath(target_path) parent = posixpath.dirname(source_path) return posixpath.normpath(posixpath.join(parent, target_path)) def local_relative_link(from_path: Path, to_path: Path) -> str: rel = os.path.relpath(to_path, start=from_path.parent) return rel.replace(os.sep, "/") def should_pin_to_upstream(path: str, files: set[str], dirs: set[str]) -> bool: if path in files or path in dirs: return True if path in {"README.md", "go.mod", "go.sum", "package.json", "package-lock.json"}: return True return path.startswith(COPIED_CODE_PATH_PREFIXES) def mapped_target( *, normalized: str, rest: str, dest_path: Path, path_map: dict[str, Path], files: set[str], dirs: set[str], commit: str, ) -> str | None: if normalized in path_map: return local_relative_link(dest_path, path_map[normalized]) + rest if should_pin_to_upstream(normalized, files, dirs): return github_url(commit, normalized, dirs) + rest return None def rewrite_markdown_links( text: str, *, source_path: str, dest_path: Path, path_map: dict[str, Path], files: set[str], dirs: set[str], commit: str, ) -> str: def replace(match: re.Match[str]) -> str: prefix, target, suffix = match.groups() if is_external_target(target): same_repo = same_repo_github_path(target) if same_repo is not None: normalized, rest = same_repo rewritten = mapped_target( normalized=normalized, rest=rest, dest_path=dest_path, path_map=path_map, files=files, dirs=dirs, commit=commit, ) if rewritten is not None: return f"{prefix}{rewritten}{suffix}" return match.group(0) target_path, rest = split_local_target(target) if not target_path: return match.group(0) normalized = normalize_target(source_path, target_path) rewritten = mapped_target( normalized=normalized, rest=rest, dest_path=dest_path, path_map=path_map, files=files, dirs=dirs, commit=commit, ) if rewritten is not None: return f"{prefix}{rewritten}{suffix}" return match.group(0) return LINK_RE.sub(replace, text) def rewrite_standalone_path_lines( text: str, *, source_path: str, dest_path: Path, path_map: dict[str, Path], files: set[str], dirs: set[str], commit: str, ) -> str: rewritten_lines: list[str] = [] for line in text.splitlines(keepends=True): newline = "" body = line if body.endswith("\n"): body = body[:-1] newline = "\n" match = STANDALONE_TARGET_RE.match(body) if not match: rewritten_lines.append(line) continue indent, token, trailing = match.groups() normalized: str | None = None rest = "" same_repo = same_repo_github_path(token) if same_repo is not None: normalized, rest = same_repo else: token_path, rest = split_local_target(token) if token_path and (is_repo_root_path(token_path) or token_path.startswith("../") or token_path.startswith("./")): normalized = normalize_target(source_path, token_path) if normalized is None: rewritten_lines.append(line) continue mapped = mapped_target( normalized=normalized, rest=rest, dest_path=dest_path, path_map=path_map, files=files, dirs=dirs, commit=commit, ) if mapped is None: rewritten_lines.append(line) continue rewritten_lines.append(f"{indent}{mapped}{trailing}{newline}") return "".join(rewritten_lines) def normalize_text_eof(text: str) -> str: return text.rstrip() + "\n" def render_readme(args: argparse.Namespace, commit: str, copied_doc_count: int) -> bytes: text = f"""# Synced NexusCRM Upstream Context This directory is generated by `scripts/sync_upstream_context.py`. Source: - repo: `{args.repo}` - requested ref: `{args.ref}` - resolved SHA: `{commit}` - copied docs: `{copied_doc_count}` Do not edit these files directly. Update them by running: ```bash python3 scripts/sync_upstream_context.py ``` ## Path Mapping The sync keeps upstream content readable inside `nexuscrm-pingcap-pack` without replacing downstream-owned files: | Upstream path | Synced path | | --- | --- | | `AGENTS.md` | `docs/upstream/nexuscrm/AGENTS.md` | | `CLAUDE.md` | `docs/upstream/nexuscrm/CLAUDE.md` | | `docs/**` | `docs/upstream/nexuscrm/docs/**` | | upstream `.claude/**` and `.agents/**` paths | not copied; pinned GitHub links when referenced | | upstream source paths such as `backend/**`, `frontend/**`, `shared/**`, `tests/**` | pinned GitHub links at `{commit}` | Markdown links to copied upstream docs are rewritten to local synced paths. Markdown links to upstream files that are not copied are rewritten to pinned GitHub URLs for the resolved SHA. Standalone path-only lines are rewritten when the source path is unambiguous. Other plain text and command examples keep their upstream spelling; interpret them through the table above. ## Excluded Paths Upstream `.claude/**` and `.agents/**` directories are intentionally not copied into this repository. Downstream agent docs and skills remain authoritative under the root `CLAUDE.md`, root `AGENTS.md`, `.claude/`, and `.agents/`. """ return text.encode("utf-8") def render_manifest( args: argparse.Namespace, source_repo: Path, commit: str, copied_paths: dict[str, Path], ) -> bytes: commit_date = git(source_repo, ["show", "-s", "--format=%cI", commit]).stdout.decode().strip() manifest = { "source_repo": args.repo, "requested_ref": args.ref, "resolved_sha": commit, "source_commit_date": commit_date, "sync_script": "scripts/sync_upstream_context.py", "managed_destinations": [ str(DEST_ROOT), ], "path_mapping": { "AGENTS.md": str(DEST_ROOT / "AGENTS.md"), "CLAUDE.md": str(DEST_ROOT / "CLAUDE.md"), "docs/**": str(DEST_ROOT / "docs/**"), ".claude/**": "not copied; upstream GitHub URL when referenced", ".agents/**": "not copied; upstream GitHub URL when referenced", "uncopied upstream source paths": f"{UPSTREAM_WEB}/blob/{commit}/", }, "copied_files": [ {"source": source, "destination": str(destination)} for source, destination in sorted(copied_paths.items()) ], } return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") def collect_expected_files( args: argparse.Namespace, source_repo: Path, commit: str, files: set[str], ) -> dict[Path, bytes]: dirs = tree_dirs(files) upstream_docs = sorted(path for path in files if path.startswith("docs/")) upstream_agent_docs = [path for path in ROOT_AGENT_DOCS if path in files] copied_paths: dict[str, Path] = {} for path in [*upstream_agent_docs, *upstream_docs]: destination = copied_destination(path) if destination is not None: copied_paths[path] = destination expected: dict[Path, bytes] = {} for source_path in [*upstream_agent_docs, *upstream_docs]: dest_path = copied_paths[source_path] blob = read_blob(source_repo, commit, source_path) if source_path.endswith(".md"): text = blob.decode("utf-8") text = rewrite_markdown_links( text, source_path=source_path, dest_path=dest_path, path_map=copied_paths, files=files, dirs=dirs, commit=commit, ) text = rewrite_standalone_path_lines( text, source_path=source_path, dest_path=dest_path, path_map=copied_paths, files=files, dirs=dirs, commit=commit, ) text = normalize_text_eof(text) blob = text.encode("utf-8") expected[dest_path] = blob expected[DEST_ROOT / "README.md"] = render_readme(args, commit, len(upstream_docs)) expected[DEST_ROOT / "SYNCED_FROM.json"] = render_manifest(args, source_repo, commit, copied_paths) return expected def managed_paths(root: Path) -> set[Path]: managed: set[Path] = set() dest = root / DEST_ROOT if dest.exists(): managed.update(path.relative_to(root) for path in dest.rglob("*") if path.is_file()) return managed def write_expected(root: Path, expected: dict[Path, bytes]) -> None: dest = root / DEST_ROOT if dest.exists(): shutil.rmtree(dest) for rel_path, content in expected.items(): full_path = root / rel_path full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_bytes(content) def check_expected(root: Path, expected: dict[Path, bytes]) -> int: actual_paths = managed_paths(root) expected_paths = set(expected) missing = sorted(expected_paths - actual_paths) extra = sorted(actual_paths - expected_paths) changed = sorted( path for path in expected_paths & actual_paths if (root / path).read_bytes() != expected[path] ) if not missing and not extra and not changed: print("Upstream NexusCRM context is current.") return 0 if missing: print("Missing synced files:") for path in missing: print(f" + {path}") if extra: print("Stale synced files:") for path in extra: print(f" - {path}") if changed: print("Outdated synced files:") for path in changed: print(f" * {path}") print("\nRun `python3 scripts/sync_upstream_context.py` and commit the result.") return 1 def parse_args(root: Path) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", default=UPSTREAM_REPO, help="Upstream NexusCRM git URL") parser.add_argument("--ref", default=default_ref(root), help="Upstream ref to sync") parser.add_argument("--source-dir", help="Existing NexusCRM checkout to read from") parser.add_argument("--check", action="store_true", help="Check that synced files are current without writing") return parser.parse_args() def main() -> int: root = repo_root() args = parse_args(root) with tempfile.TemporaryDirectory(prefix="nexuscrm-upstream-sync-") as temp: source_repo, commit = prepare_source_repo(args, Path(temp), root) files = tree_files(source_repo, commit) expected = collect_expected_files(args, source_repo, commit, files) if args.check: return check_expected(root, expected) write_expected(root, expected) print(f"Synced NexusCRM upstream context from {args.repo}@{args.ref} ({commit}).") print(f"Managed files written: {len(expected)}") return 0 if __name__ == "__main__": raise SystemExit(main())