/ 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