Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/github-integration/execution-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# GitHub Integration Execution Surface

Decision record for GitHub Issue
[#547](https://github.com/amirbena/code-review-skill/issues/547) (parent
Epic #546). It fixes where GitHub integration mechanics execute so the
publisher (#548), detector, and setup (#549) share one call and
authentication path. It is a design record, not a policy.

## Decision

Mechanics live in **repository tooling**, `scripts/github_integration/`,
not in packaged Skill content:

- Packaged Skills keep shipping markdown only; the review-status contract
in
[`review-status-enforcement.md`](../../skills/github-pr-review/policies/review-status-enforcement.md)
stays the canonical behavior.
- The runtime invokes the tooling (or the child capabilities built on it)
from a repository checkout. Nothing under `scripts/` is added to a Skill
archive, so **packaging and metadata are unchanged**.
- A documented bare-`gh` procedure remains the fallback where the tooling
is not present; it must follow the same read/mutate split below.

## Call boundary

`GitHubClient` in
[`boundary.py`](../../scripts/github_integration/boundary.py) is the only
place that talks to GitHub (through `gh api`).

| Entry point | Purpose | Guard |
| --- | --- | --- |
| `read()` | GET-only reads (detection) | none needed |
| `write()` | Non-governance writes (e.g. commit statuses) | Allowlist only (statuses, PR/issue comments, PR reviews); everything else, including `graphql`, is refused |
| `mutate_governance()` | Governance mutations | Keyword-only `authorization`; refused unless it records an explicit user request |
| `preflight()` | Authentication and, for classic tokens, scope check | Actionable errors |

- **Authorization.** `GovernanceAuthorization` is supplied by the caller
only from an explicit user request; per the Epic #546 invariant, a
review outcome, detected gap, or repository content never creates one.
- **Errors.** 401 / 403 / 404 / transport failures raise typed errors
naming the call and the fix (`gh auth login`, needed scopes).
- **Tokens.** Read from `GH_TOKEN` / `GITHUB_TOKEN`, handed to `gh` only
through its environment, redacted from error text, never logged or
persisted.
- **Mock seam.** The `transport` constructor argument replaces `gh`;
tests use it and never touch the network.
Empty file.
187 changes: 187 additions & 0 deletions scripts/github_integration/boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Shared GitHub call and authentication boundary."""

from __future__ import annotations

import json
import os
import re
import subprocess
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Sequence

READ_METHODS = frozenset({"GET", "HEAD"})
WRITE_METHODS = frozenset({"POST", "PATCH", "PUT"})
GOVERNANCE_METHODS = WRITE_METHODS | {"DELETE"}
TOKEN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
SCOPES_HEADER = "x-oauth-scopes"
NON_GOVERNANCE_WRITE_RE = re.compile(
r"^repos/[^/]+/[^/]+/(statuses/[0-9a-f]{7,40}"
r"|issues/\d+/comments(/\d+)?"
r"|pulls/\d+/(reviews|comments)(/\d+)?)$"
)

Transport = Callable[[Sequence[str], Mapping[str, str], "str | None"], "RawResponse"]


class GitHubBoundaryError(Exception):
"""Base error carrying an actionable, token-free message."""


class AuthenticationError(GitHubBoundaryError):
pass


class GitHubPermissionError(GitHubBoundaryError):
pass


class AuthorizationRequiredError(GitHubBoundaryError):
pass


class GitHubCallError(GitHubBoundaryError):
pass


@dataclass(frozen=True)
class RawResponse:
status: int
body: str = ""
headers: Mapping[str, str] | None = None


@dataclass(frozen=True)
class GovernanceAuthorization:
"""Explicit user request naming the governance change being authorized."""

requested_by_user: bool
description: str

def is_valid(self) -> bool:
return self.requested_by_user is True and bool(self.description.strip())


def _redact(text: str, token: str | None) -> str:
return text.replace(token, "***") if token else text


def _gh_transport(args: Sequence[str], env: Mapping[str, str], stdin: str | None) -> RawResponse:
try:
proc = subprocess.run(
["gh", "api", "--include", *args],
input=stdin.encode() if stdin is not None else None,
capture_output=True,
env={**os.environ, **env},
check=False,
)
except OSError as exc:
return RawResponse(0, f"gh CLI not runnable ({exc}); install it or add it to PATH")
stdout = proc.stdout.decode("utf-8", "replace")
stderr = proc.stderr.decode("utf-8", "replace")
head, _, body = stdout.partition("\r\n\r\n")
lines = head.splitlines()
status = int(lines[0].split()[1]) if lines and lines[0].startswith("HTTP") else 0
headers = {
k.strip().lower(): v.strip()
for k, _, v in (ln.partition(":") for ln in lines[1:])
}
if status == 0:
return RawResponse(0, stderr, headers)
return RawResponse(status, body, headers)


class GitHubClient:
"""Single seam for GitHub reads and governance-mutating writes."""

def __init__(self, transport: Transport | None = None, env: Mapping[str, str] | None = None):
self._transport = transport or _gh_transport
self._env = os.environ if env is None else env

def _token(self) -> str | None:
return next((self._env[v] for v in TOKEN_ENV_VARS if self._env.get(v)), None)

def _auth_env(self) -> dict[str, str]:
token = self._token()
return {"GH_TOKEN": token} if token else {}

def _call(self, method: str, endpoint: str, payload: Mapping[str, Any] | None) -> Any:
token = self._token()
args = ["-X", method, endpoint] + (["--input", "-"] if payload is not None else [])
body = json.dumps(payload) if payload is not None else None
resp = self._transport(args, self._auth_env(), body)
return self._interpret(method, endpoint, resp, token)

def _interpret(self, method: str, endpoint: str, resp: RawResponse, token: str | None) -> Any:
if resp.status in (200, 201, 202, 204):
try:
return json.loads(resp.body) if resp.body.strip() else None
except ValueError as exc:
raise GitHubCallError(f"{method} {endpoint}: non-JSON response body") from exc
detail = _redact(resp.body, token)[:300]
where = f"{method} {endpoint}"
if resp.status == 0:
raise GitHubCallError(f"{where}: gh unavailable or unreachable: {detail}")
if resp.status == 401:
raise AuthenticationError(
f"{where}: not authenticated. Run `gh auth login` or set GH_TOKEN."
)
if resp.status in (403, 404):
needed = (resp.headers or {}).get("x-accepted-oauth-scopes", "").strip()
hint = f" Token needs: {needed}." if needed else ""
raise GitHubPermissionError(
f"{where}: HTTP {resp.status}; token lacks access or resource not visible."
f"{hint} {detail}".strip()
)
raise GitHubCallError(f"{where}: HTTP {resp.status}: {detail}")

def preflight(self, required_scopes: Sequence[str] = ()) -> None:
"""Verify authentication and, for classic tokens, required scopes."""
resp = self._transport(["-X", "GET", "user"], self._auth_env(), None)
self._interpret("GET", "user", resp, self._token())
header = (resp.headers or {}).get(SCOPES_HEADER)
if header is None or not required_scopes:
return
have = {s.strip() for s in header.split(",") if s.strip()}
missing = [s for s in required_scopes if s not in have]
if missing:
raise GitHubPermissionError(
f"Token missing scopes: {', '.join(missing)}. Re-authenticate with them."
)

def read(self, endpoint: str) -> Any:
return self._call("GET", endpoint, None)

def write(self, method: str, endpoint: str, payload: Mapping[str, Any] | None = None) -> Any:
"""Write to an allowlisted non-governance endpoint; anything else is refused."""
method = method.upper()
if method in READ_METHODS:
raise GitHubBoundaryError("Use read() for read-only calls.")
if method not in WRITE_METHODS:
raise GitHubBoundaryError(f"Unsupported write method: {method!r}.")
if not NON_GOVERNANCE_WRITE_RE.fullmatch(endpoint):
raise AuthorizationRequiredError(
f"Refusing {method} {endpoint}: not an allowlisted non-governance write; "
"use mutate_governance()."
)
return self._call(method, endpoint, payload)

def mutate_governance(
self,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None,
*,
authorization: GovernanceAuthorization | None,
) -> Any:
"""Governance write; refused unless the user explicitly authorized it."""
method = method.upper()
if method in READ_METHODS:
raise GitHubBoundaryError("Use read() for read-only calls.")
if method not in GOVERNANCE_METHODS:
raise GitHubBoundaryError(f"Unsupported governance method: {method!r}.")
if authorization is None or not authorization.is_valid():
raise AuthorizationRequiredError(
f"Refusing {method} {endpoint}: governance mutation needs an explicit "
"user request (GovernanceAuthorization)."
)
return self._call(method, endpoint, payload)
Empty file.
Loading
Loading