/ github_client.py
github_client.py
1 """GitHub API client — authentication and HTTP helpers.""" 2 3 import os 4 import re 5 import sys 6 import keyring 7 import requests 8 9 SERVICE = "pr-review-mcp" 10 11 GITHUB_TOKEN = None 12 REPO = None 13 14 # Regex: owner/repo, only ASCII letters, digits, hyphens, underscores, dots 15 _REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") 16 17 18 # ── Validation ─────────────────────────────────────────── 19 20 def _validate_token(value: str) -> str: 21 """Validate and clean GitHub token.""" 22 value = value.strip() 23 if not value: 24 raise ValueError("GITHUB_TOKEN is empty") 25 if not value.isascii(): 26 raise ValueError( 27 "GITHUB_TOKEN contains non-ASCII characters. " 28 "Make sure you copied the real token, not a placeholder." 29 ) 30 return value 31 32 33 def _validate_repo(value: str) -> str: 34 """Validate and clean GitHub repo (owner/repo format).""" 35 value = value.strip() 36 if not value: 37 raise ValueError("GITHUB_REPO is empty") 38 if not _REPO_RE.match(value): 39 raise ValueError( 40 f"GITHUB_REPO '{value}' is invalid. " 41 "Expected format: owner/repo (e.g. octocat/Hello-World)" 42 ) 43 return value 44 45 46 _VALIDATORS = { 47 "GITHUB_TOKEN": _validate_token, 48 "GITHUB_REPO": _validate_repo, 49 } 50 51 52 # ── Secrets ────────────────────────────────────────────── 53 54 def get_secret(key: str) -> str: 55 """Read a secret from env, then keychain, then interactive prompt.""" 56 value = os.environ.get(key) or keyring.get_password(SERVICE, key) 57 if not value: 58 # Interactive input — only works in terminal, not in Claude Desktop 59 if not sys.stdin.isatty(): 60 raise ValueError( 61 f"{key} not found. Set it via environment variable or " 62 "run 'python server.py' manually first to save it to keychain." 63 ) 64 print(f"\n[pr-review-mcp] {key} not found in keychain.") 65 value = input(f"Enter {key}: ") 66 if not value: 67 raise ValueError(f"{key} cannot be empty") 68 keyring.set_password(SERVICE, key, value) 69 print(f"[pr-review-mcp] {key} saved to keychain.") 70 71 validate = _VALIDATORS.get(key) 72 if validate: 73 value = validate(value) 74 return value 75 76 77 def init_secrets(): 78 """Load token and repo from keychain (prompt if missing).""" 79 global GITHUB_TOKEN, REPO 80 GITHUB_TOKEN = get_secret("GITHUB_TOKEN") 81 REPO = get_secret("GITHUB_REPO") 82 print(f"[pr-review-mcp] Ready. Repo: {REPO}") 83 84 85 def reset_secrets(): 86 """Delete all stored secrets from the keychain.""" 87 for key in ("GITHUB_TOKEN", "GITHUB_REPO"): 88 try: 89 keyring.delete_password(SERVICE, key) 90 print(f"[pr-review-mcp] Deleted {key}") 91 except keyring.errors.PasswordDeleteError: 92 print(f"[pr-review-mcp] {key} not found") 93 94 95 # ── GitHub HTTP helpers ────────────────────────────────── 96 97 def get_headers() -> dict: 98 return { 99 "Authorization": f"token {GITHUB_TOKEN}", 100 "Accept": "application/vnd.github.v3+json", 101 } 102 103 104 def github_get(path: str, **params) -> requests.Response: 105 """GET request to the GitHub API. `path` is appended to the repo URL.""" 106 url = f"https://api.github.com/repos/{REPO}/{path}" 107 resp = requests.get(url, headers=get_headers(), params=params) 108 resp.encoding = "utf-8" 109 return resp