mirror of
https://github.com/gmh5225/awesome-game-security
synced 2026-06-21 13:56:22 +00:00
489 lines
20 KiB
Python
489 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Clone every GitHub repository listed in README.md, run code2prompt on each,
|
|
and save the output as archive/{owner}/{repo}.txt.
|
|
|
|
Prerequisites:
|
|
cargo install code2prompt
|
|
|
|
Usage:
|
|
python scripts/archive-repos.py # archive all new repos
|
|
python scripts/archive-repos.py --commit-every 5 # commit to git every 5 archives
|
|
python scripts/archive-repos.py --workers 4 # control parallelism
|
|
python scripts/archive-repos.py --no-skip-existing # re-archive everything
|
|
python scripts/archive-repos.py --owner-filter gmh5225 # only one owner
|
|
python scripts/archive-repos.py --limit 10 --dry-run # preview first 10
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import shutil
|
|
import argparse
|
|
import tempfile
|
|
import threading
|
|
import subprocess
|
|
import urllib.request
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
README_PATH = "README.md"
|
|
ARCHIVE_DIR = "archive"
|
|
GITHUB_REPO_PATTERN = re.compile(
|
|
r"https://github\.com/([^/\s\)\]>\"']+)/([^/\s\)\]>\"'#]+)"
|
|
)
|
|
MAX_WORKERS = 3
|
|
CLONE_TIMEOUT = 180 # seconds
|
|
CODE2PROMPT_TIMEOUT = 60 # seconds — abandon large repos quickly
|
|
MAX_FILE_MB = 95 # GitHub hard-rejects files > 100 MB; keep safely below
|
|
|
|
# Binary / large-asset extensions excluded from code2prompt output.
|
|
# Keeps archive files focused on source code and avoids TOOLARGE rejections
|
|
# on game repos that embed audio, textures, models, datasets, or compiled binaries.
|
|
CODE2PROMPT_EXCLUDE = ",".join([
|
|
# Audio / video
|
|
"*.wav", "*.mp3", "*.ogg", "*.flac", "*.aac", "*.wma", "*.m4a",
|
|
"*.mp4", "*.avi", "*.mov", "*.mkv", "*.webm",
|
|
# Images / textures
|
|
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.tga", "*.tiff",
|
|
"*.webp", "*.ico", "*.psd", "*.hdr", "*.exr", "*.dds", "*.ktx",
|
|
# 3-D assets
|
|
"*.fbx", "*.obj", "*.3ds", "*.blend", "*.dae", "*.glb", "*.gltf",
|
|
"*.uasset", "*.umap", "*.pak",
|
|
# Compiled / binary
|
|
"*.lib", "*.dll", "*.exe", "*.so", "*.dylib", "*.a", "*.o",
|
|
"*.pdb", "*.ilk", "*.exp",
|
|
# Archives & disk images
|
|
"*.zip", "*.tar", "*.gz", "*.7z", "*.rar", "*.xz", "*.txz",
|
|
"*.iso", "*.img", "*.bin",
|
|
# ML model weights / datasets
|
|
"*.pth", "*.ckpt", "*.pt", "*.model", "*.weights",
|
|
"*.h5", "*.pb", "*.onnx", "*.npy", "*.npz", "*.parquet", "*.arrow",
|
|
# Documents
|
|
"*.pdf", "*.doc", "*.docx", "*.xls", "*.xlsx", "*.ppt", "*.pptx",
|
|
# Fonts
|
|
"*.ttf", "*.otf", "*.woff", "*.woff2",
|
|
# Misc large data
|
|
"*.db", "*.sqlite", "*.sqlite3", "*.csv",
|
|
])
|
|
|
|
SCAN_START_MARKER = "## Game Engine"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fallback: fetch repo content via content extraction API when clone/code2prompt fails.
|
|
# API endpoint is configurable via REPO_SNAPSHOT_HOST (set as a GitHub Actions secret).
|
|
# ---------------------------------------------------------------------------
|
|
import json as _json
|
|
|
|
_SNAPSHOT_API = os.environ.get("REPO_SNAPSHOT_HOST", "").strip()
|
|
|
|
# Binary/asset patterns to exclude when calling the extraction API
|
|
_SNAPSHOT_EXCLUDE = ",".join([
|
|
"*.wav", "*.mp3", "*.ogg", "*.flac", "*.aac",
|
|
"*.mp4", "*.avi", "*.mov", "*.mkv", "*.webm",
|
|
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.tga", "*.tiff",
|
|
"*.webp", "*.ico", "*.psd", "*.hdr", "*.exr", "*.dds", "*.ktx",
|
|
"*.fbx", "*.obj", "*.3ds", "*.blend", "*.dae", "*.glb", "*.gltf",
|
|
"*.uasset", "*.umap", "*.pak",
|
|
"*.lib", "*.dll", "*.exe", "*.so", "*.dylib", "*.a", "*.o",
|
|
"*.zip", "*.tar", "*.gz", "*.7z", "*.rar", "*.xz", "*.txz",
|
|
"*.iso", "*.img", "*.bin",
|
|
"*.pth", "*.ckpt", "*.pt", "*.model", "*.weights",
|
|
"*.h5", "*.pb", "*.onnx", "*.npy", "*.npz",
|
|
"*.pdf", "*.doc", "*.docx", "*.xls", "*.xlsx",
|
|
"*.ttf", "*.otf", "*.woff", "*.woff2",
|
|
"*.db", "*.sqlite", "*.sqlite3",
|
|
])
|
|
|
|
|
|
def _fetch_via_snapshot(owner: str, repo: str) -> tuple[bool, str]:
|
|
"""Fetch repo content via the content extraction API.
|
|
|
|
Returns (success, content_or_reason).
|
|
"""
|
|
if not _SNAPSHOT_API:
|
|
return False, "REPO_SNAPSHOT_HOST not configured"
|
|
|
|
repo_url = f"https://github.com/{owner}/{repo}"
|
|
payload = _json.dumps({
|
|
"input_text": repo_url,
|
|
"max_file_size": "243", # KB — keeps output manageable
|
|
"pattern_type": "exclude",
|
|
"pattern": _SNAPSHOT_EXCLUDE,
|
|
}).encode()
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
_SNAPSHOT_API,
|
|
data=payload,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Mozilla/5.0 (compatible; archive-bot/1.0)",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
result = _json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
tree = result.get("tree", "")
|
|
content = result.get("content", "")
|
|
if not tree and not content:
|
|
return False, "extraction service returned empty tree and content"
|
|
return True, f"{tree}\n\n{content}".strip()
|
|
except Exception as exc:
|
|
return False, f"extraction service failed: {exc}"
|
|
|
|
|
|
def extract_github_repos(text: str) -> list[tuple[str, str]]:
|
|
"""Return unique (owner, repo) pairs from README, starting at SCAN_START_MARKER."""
|
|
marker_pos = text.find(SCAN_START_MARKER)
|
|
if marker_pos == -1:
|
|
print(f"[WARN] Marker '{SCAN_START_MARKER}' not found — scanning full README.")
|
|
else:
|
|
text = text[marker_pos:]
|
|
|
|
matches = GITHUB_REPO_PATTERN.findall(text)
|
|
seen: set[tuple[str, str]] = set()
|
|
result = []
|
|
for owner, repo in matches:
|
|
repo = repo.rstrip(".,;:")
|
|
if owner == "gmh5225" and repo == "awesome-game-security":
|
|
continue
|
|
key = (owner.lower(), repo.lower())
|
|
if key not in seen:
|
|
seen.add(key)
|
|
result.append((owner, repo))
|
|
return result
|
|
|
|
|
|
def check_code2prompt() -> bool:
|
|
return shutil.which("code2prompt") is not None
|
|
|
|
|
|
def install_code2prompt() -> bool:
|
|
print("code2prompt not found — installing via cargo ...")
|
|
try:
|
|
subprocess.run(["cargo", "install", "code2prompt"], check=True, timeout=300)
|
|
return True
|
|
except Exception as e:
|
|
print(f" [ERROR] Install failed: {e}")
|
|
return False
|
|
|
|
|
|
def git_commit_and_push(archive_dir: Path, count: int, push_retries: int = 5) -> None:
|
|
"""Stage archive dir and push a commit. Called with the commit lock held.
|
|
|
|
If the push is rejected because the remote moved ahead (concurrent pushes),
|
|
pull --rebase and retry up to push_retries times.
|
|
"""
|
|
try:
|
|
subprocess.run(["git", "add", str(archive_dir)], check=True, capture_output=True)
|
|
result = subprocess.run(
|
|
["git", "diff", "--cached", "--quiet"],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode == 0:
|
|
return # nothing staged
|
|
|
|
subprocess.run(
|
|
["git", "commit", "-m",
|
|
f"archive: add {count} repo prompt(s) [skip ci]"],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f" [GIT ERROR] commit: {e.stderr.decode().strip()[:200] if e.stderr else e}")
|
|
return
|
|
|
|
for attempt in range(1, push_retries + 1):
|
|
try:
|
|
subprocess.run(["git", "push"], check=True, capture_output=True)
|
|
print(f" [GIT] Committed and pushed {count} archive(s)")
|
|
return
|
|
except subprocess.CalledProcessError as e:
|
|
err = e.stderr.decode().strip() if e.stderr else str(e)
|
|
|
|
# File too large for GitHub (LFS rejection) — undo this commit
|
|
# so it doesn't block all subsequent pushes.
|
|
if any(k in err for k in ("gh.io/lfs", "large files",
|
|
"exceeds GitHub's file size limit")):
|
|
print(f" [GIT] Push blocked by large file — undoing commit")
|
|
subprocess.run(["git", "reset", "HEAD~1"], capture_output=True)
|
|
return
|
|
|
|
if attempt >= push_retries:
|
|
print(f" [GIT ERROR] push failed after {push_retries} retries: {err[:200]}")
|
|
subprocess.run(["git", "reset", "HEAD~1"], capture_output=True)
|
|
return
|
|
|
|
# Remote moved ahead (conflict) — rebase then retry
|
|
if any(k in err for k in ("rejected", "fetch first", "non-fast-forward")):
|
|
print(f" [GIT] Push rejected (attempt {attempt}/{push_retries}), rebasing ...")
|
|
try:
|
|
subprocess.run(
|
|
["git", "pull", "--rebase", "origin", "main"],
|
|
check=True, capture_output=True,
|
|
)
|
|
except subprocess.CalledProcessError as re_err:
|
|
re_msg = re_err.stderr.decode().strip()[:200] if re_err.stderr else str(re_err)
|
|
print(f" [GIT ERROR] rebase failed: {re_msg}")
|
|
subprocess.run(["git", "rebase", "--abort"], capture_output=True)
|
|
subprocess.run(["git", "reset", "HEAD~1"], capture_output=True)
|
|
return
|
|
|
|
# Network / timeout error (408, RPC failure, disconnect) — just retry
|
|
elif any(k in err for k in ("408", "RPC failed", "unexpected disconnect",
|
|
"remote end hung up", "timed out")):
|
|
wait = 5 * attempt
|
|
print(f" [GIT] Push timeout (attempt {attempt}/{push_retries}), "
|
|
f"retrying in {wait}s ...")
|
|
time.sleep(wait)
|
|
|
|
else:
|
|
print(f" [GIT ERROR] push: {err[:200]}")
|
|
subprocess.run(["git", "reset", "HEAD~1"], capture_output=True)
|
|
return
|
|
|
|
print(f" [GIT ERROR] push failed after {push_retries} retries — giving up")
|
|
subprocess.run(["git", "reset", "HEAD~1"], capture_output=True)
|
|
|
|
|
|
def archive_repo(
|
|
owner: str,
|
|
repo: str,
|
|
archive_dir: Path,
|
|
skip_existing: bool,
|
|
) -> tuple[str, str, str]:
|
|
"""
|
|
Clone owner/repo, run code2prompt, write to archive.
|
|
Returns (slug, status, message).
|
|
status: OK | SKIP | FAIL | TIMEOUT | TOOLARGE
|
|
"""
|
|
slug = f"{owner}/{repo}"
|
|
out_dir = archive_dir / owner
|
|
out_file = out_dir / f"{repo}.txt"
|
|
|
|
if skip_existing and out_file.exists():
|
|
return (slug, "SKIP", "")
|
|
|
|
clone_url = f"https://github.com/{owner}/{repo}.git"
|
|
tmp_dir = tempfile.mkdtemp(prefix=f"arc_{owner}_{repo}_")
|
|
|
|
# Skip LFS pointer downloads entirely — we only need source text.
|
|
clone_env = {"GIT_LFS_SKIP_SMUDGE": "1", **__import__("os").environ}
|
|
|
|
try:
|
|
# First attempt: with blob size filter (faster, skips large files)
|
|
r = subprocess.run(
|
|
["git", "clone", "--depth", "1", "--single-branch", "--quiet",
|
|
"--filter=blob:limit:20m",
|
|
clone_url, tmp_dir],
|
|
capture_output=True, text=True, timeout=CLONE_TIMEOUT,
|
|
env=clone_env,
|
|
)
|
|
# Some servers reject the filter-spec — retry without it
|
|
if r.returncode != 0 and "invalid filter-spec" in r.stderr:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
tmp_dir = tempfile.mkdtemp(prefix=f"arc_{owner}_{repo}_")
|
|
r = subprocess.run(
|
|
["git", "clone", "--depth", "1", "--single-branch", "--quiet",
|
|
clone_url, tmp_dir],
|
|
capture_output=True, text=True, timeout=CLONE_TIMEOUT,
|
|
env=clone_env,
|
|
)
|
|
if r.returncode != 0:
|
|
# Both clone attempts failed — try doc service fallback
|
|
clone_err = r.stderr.strip()[:200]
|
|
ok, content = _fetch_via_snapshot(owner, repo)
|
|
if ok:
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
out_file.write_text(content, encoding="utf-8")
|
|
size_kb = len(content.encode()) / 1024
|
|
return (slug, "OK", f"{size_kb:.1f} KB [snapshot]")
|
|
return (slug, "FAIL", f"clone: {clone_err}; snapshot: {content}")
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
cp = subprocess.run(
|
|
["code2prompt",
|
|
"--output-file", str(out_file),
|
|
"--exclude", CODE2PROMPT_EXCLUDE,
|
|
tmp_dir],
|
|
capture_output=True, text=True, timeout=CODE2PROMPT_TIMEOUT,
|
|
)
|
|
if cp.returncode != 0:
|
|
# code2prompt failed — try doc service fallback
|
|
cp_err = cp.stderr.strip()[:200]
|
|
ok, content = _fetch_via_snapshot(owner, repo)
|
|
if ok:
|
|
out_file.write_text(content, encoding="utf-8")
|
|
size_kb = len(content.encode()) / 1024
|
|
return (slug, "OK", f"{size_kb:.1f} KB [snapshot]")
|
|
return (slug, "FAIL", f"code2prompt: {cp_err}; snapshot: {content}")
|
|
|
|
size_bytes = out_file.stat().st_size
|
|
size_mb = size_bytes / 1024 / 1024
|
|
if size_mb > MAX_FILE_MB:
|
|
out_file.unlink(missing_ok=True)
|
|
# Oversized — try doc service which may return a lighter snapshot
|
|
ok, content = _fetch_via_snapshot(owner, repo)
|
|
if ok:
|
|
out_file.write_text(content, encoding="utf-8")
|
|
size_kb = len(content.encode()) / 1024
|
|
return (slug, "OK", f"{size_kb:.1f} KB [snapshot, was {size_mb:.1f} MB]")
|
|
return (slug, "TOOLARGE", f"{size_mb:.1f} MB > limit {MAX_FILE_MB} MB")
|
|
|
|
return (slug, "OK", f"{size_bytes / 1024:.1f} KB")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
# Timeout — try doc service fallback
|
|
ok, content = _fetch_via_snapshot(owner, repo)
|
|
if ok:
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
out_file.write_text(content, encoding="utf-8")
|
|
size_kb = len(content.encode()) / 1024
|
|
return (slug, "OK", f"{size_kb:.1f} KB [snapshot, after timeout]")
|
|
return (slug, "TIMEOUT", "exceeded timeout")
|
|
except Exception as e:
|
|
return (slug, "ERROR", str(e))
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Archive GitHub repos from README.md as code2prompt text files."
|
|
)
|
|
parser.add_argument("--readme", default=README_PATH)
|
|
parser.add_argument("--archive-dir", default=ARCHIVE_DIR)
|
|
parser.add_argument("--workers", type=int, default=MAX_WORKERS)
|
|
parser.add_argument("--skip-existing", action="store_true", default=True)
|
|
parser.add_argument("--no-skip-existing", dest="skip_existing", action="store_false")
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--owner-filter", default="")
|
|
parser.add_argument("--limit", type=int, default=0)
|
|
parser.add_argument(
|
|
"--commit-every",
|
|
type=int,
|
|
default=0,
|
|
metavar="N",
|
|
help="Commit and push to git after every N successful archives (0 = disabled)",
|
|
)
|
|
parser.add_argument(
|
|
"--repos",
|
|
nargs="+",
|
|
metavar="OWNER/REPO",
|
|
help="Archive only these specific repos (e.g. --repos torvalds/linux foo/bar). "
|
|
"Skips README scanning entirely.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# ── preflight ─────────────────────────────────────────────────────────────
|
|
if not args.dry_run:
|
|
if not check_code2prompt():
|
|
if not install_code2prompt():
|
|
raise SystemExit("Abort: code2prompt is required.")
|
|
print(f"code2prompt: {shutil.which('code2prompt')}")
|
|
|
|
# ── resolve repo list ─────────────────────────────────────────────────────
|
|
if args.repos:
|
|
# Explicit list supplied — skip README scanning
|
|
repos = []
|
|
for slug in args.repos:
|
|
parts = slug.strip().split("/")
|
|
if len(parts) == 2:
|
|
repos.append((parts[0], parts[1]))
|
|
else:
|
|
print(f"[WARN] Ignoring invalid repo slug: {slug!r}")
|
|
else:
|
|
# Default: scan README
|
|
with open(args.readme, encoding="utf-8") as f:
|
|
repos = extract_github_repos(f.read())
|
|
|
|
if args.owner_filter:
|
|
repos = [(o, r) for o, r in repos if o.lower() == args.owner_filter.lower()]
|
|
|
|
archive_dir = Path(args.archive_dir)
|
|
archive_dir.mkdir(exist_ok=True)
|
|
|
|
# Filter out already-archived repos BEFORE applying --limit so that
|
|
# "--limit N" means "process N repos that still need archiving".
|
|
if args.skip_existing:
|
|
pending = [(o, r) for o, r in repos
|
|
if not (archive_dir / o / f"{r}.txt").exists()]
|
|
skipped_upfront = len(repos) - len(pending)
|
|
if skipped_upfront:
|
|
print(f"Already archived : {skipped_upfront} (skipped)")
|
|
repos = pending
|
|
|
|
if args.limit:
|
|
repos = repos[: args.limit]
|
|
|
|
print(f"Repos to process: {len(repos)} → {archive_dir}/")
|
|
|
|
if args.dry_run:
|
|
for owner, repo in repos:
|
|
# After pre-filtering, everything here is either TODO or re-archive
|
|
flag = "TODO" if args.skip_existing else (
|
|
"SKIP" if (archive_dir / owner / f"{repo}.txt").exists() else "TODO"
|
|
)
|
|
print(f" [{flag}] {owner}/{repo}")
|
|
return
|
|
|
|
# ── archive with optional periodic git commits ────────────────────────────
|
|
counters = {"ok": 0, "fail": 0, "skip": 0, "timeout": 0, "toolarge": 0}
|
|
since_last_commit = 0 # OK archives since last commit
|
|
commit_lock = threading.Lock() # serialise git operations
|
|
|
|
def on_ok(slug: str, msg: str, done: int, total: int) -> None:
|
|
nonlocal since_last_commit
|
|
counters["ok"] += 1
|
|
since_last_commit += 1
|
|
print(f" [OK {done:4d}/{total}] {slug} ({msg})")
|
|
if args.commit_every and since_last_commit >= args.commit_every:
|
|
with commit_lock:
|
|
batch = since_last_commit
|
|
since_last_commit = 0
|
|
git_commit_and_push(archive_dir, batch)
|
|
|
|
total = len(repos)
|
|
done = 0
|
|
|
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
|
futures = {
|
|
executor.submit(
|
|
archive_repo, owner, repo, archive_dir, args.skip_existing
|
|
): (owner, repo)
|
|
for owner, repo in repos
|
|
}
|
|
for future in as_completed(futures):
|
|
slug, status, msg = future.result()
|
|
done += 1
|
|
if status == "OK":
|
|
on_ok(slug, msg, done, total)
|
|
elif status == "SKIP":
|
|
counters["skip"] += 1
|
|
elif status == "TIMEOUT":
|
|
counters["timeout"] += 1
|
|
print(f" [TIME {done:4d}/{total}] {slug}")
|
|
elif status == "TOOLARGE":
|
|
counters["toolarge"] += 1
|
|
print(f" [BIG {done:4d}/{total}] {slug} {msg}")
|
|
else:
|
|
counters["fail"] += 1
|
|
print(f" [FAIL {done:4d}/{total}] {slug} {msg}")
|
|
|
|
# ── final commit for any remaining uncommitted archives ───────────────────
|
|
if args.commit_every and since_last_commit > 0:
|
|
with commit_lock:
|
|
git_commit_and_push(archive_dir, since_last_commit)
|
|
|
|
print(
|
|
f"\nDone. OK={counters['ok']} SKIP={counters['skip']} "
|
|
f"TIMEOUT={counters['timeout']} TOOLARGE={counters['toolarge']} "
|
|
f"FAIL={counters['fail']} (total={total})"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|