Files
mu/scripts/tests/fake-github.py
T
8d401929b6 The repo scripts make repos on an operator's own account ("owner": "user") (#81)
* The repo scripts make repos on an operator's own account ("owner": "user")

Any operator can now make private or public repos on their own GitHub account with the same setup as
ouroboric-dev's. A config with "owner": "user" gets it, with the differences GitHub forces:

- create-repo.sh creates through /user/repos, after checking that the token belongs to the config's
  user, so a repo never lands on the wrong account.
- setup-repo.sh writes each user repo's own Actions allowlist (lint.yml's pinned actions, nothing
  else), since a user has no org to hold it. On a user's private repo it skips secret scanning, push
  protection and private vulnerability reporting, which GitHub doesn't offer there, and says so.
- If GitHub refuses the ruleset (a user's private repo without GitHub Pro), setup-repo.sh fails and
  says main is not protected, after every other setting is in place; new-repo.sh then reports the repo
  as unprotected. The ruleset itself is unchanged.

An operator's config is theirs, not the company's: it lives in scripts/orgs/local/, which git ignores
except for its README, and --org reads it from there (a name in both folders is refused). Only
generic examples are committed, in scripts/orgs/examples/: a user config and a proprietary licence
text that names no one. Invariant 9 in AGENTS.md says never to commit an operator's config, and a test
fails if any committed config is neither an org's nor an example. The fakes learn GET /user,
POST /user/repos and refused writes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* check-repos.sh --check operator: no tracked file names the operator (invariant 9)

The check reads each repo's local clone and fails when a tracked file names the operator who runs it: their
login, their home folder's name, or a user account in scripts/orgs/local/, as whole words in any case. The
names come from the machine, so the check serves any operator and the script names no one;
CHECK_REPOS_OPERATOR replaces them. It prints the files, never the names, and fails closed on a missing
clone, an empty name list or a git error. The records of past work keep their paths as written: RECORDS
lists those files one by one, so a new plan beside them is still checked. It is a default check.

The local-qwen design, plan and how-to now give paths relative to the clones, and the design no longer
names the founder's folders. Invariant 9 also covers docs, plans and fixtures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Bruno Guilhermo de Barros Valério <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 18:35:25 +02:00

73 lines
3.1 KiB
Python

# Fake GitHub API for the script tests. Logs each request to LOG as one JSON line.
# python3 -I fake-github.py LOG PORTFILE
# FAKE_PEM: the key the manifest conversion returns. FAKE_GIT_ROOT: where a created repo gets its
# bare git repo, so a test can push to file://$FAKE_GIT_ROOT/<org>/<repo>.git. FAKE_USER: the login
# that GET /user returns, and the account that POST /user/repos creates in (default acme).
import json
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
log, portfile = sys.argv[1:3]
repos = set() # (org, name) of the repos created; "taken" exists in every org
def repo_routes(method, path, body):
if method == "GET" and (m := re.fullmatch(r"/repos/([^/]+)/([^/]+)", path)):
org, name = m.groups()
return (200, {"name": name}) if name == "taken" or (org, name) in repos else None
if method == "GET" and path == "/user":
return 200, {"login": os.environ.get("FAKE_USER", "acme")}
if method == "POST" and (m := re.fullmatch(r"/orgs/([^/]+)/repos|/user/repos", path)):
org = m.group(1) or os.environ.get("FAKE_USER", "acme")
name = json.loads(body)["name"]
if name == "taken" or (org, name) in repos:
return 422, {"message": "Repository creation failed."}
repos.add((org, name))
subprocess.run(["git", "init", "-q", "--bare", "-b", "main",
os.path.join(os.environ["FAKE_GIT_ROOT"], org, name + ".git")], check=True)
return 201, {"name": name}
return None
def routes():
return {
("GET", "/orgs/ouroboric-dev/installation"): (200, {"id": 42}),
("POST", "/app/installations/42/access_tokens"):
(201, {"token": "ghs_FAKE", "expires_at": "2099-01-01T00:00:00Z"}),
("POST", "/app-manifests/good-code/conversions"):
(201, {"id": 7, "slug": "ouroboric-pr-bot", "client_id": "Iv1.test",
"client_secret": "SECRET_CS", "webhook_secret": None,
"pem": open(os.environ["FAKE_PEM"]).read() if "FAKE_PEM" in os.environ else ""}),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def handle_any(self):
n = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(n).decode() if n else ""
with open(log, "a") as f:
f.write(json.dumps({"method": self.command, "path": self.path,
"auth": self.headers.get("Authorization", ""), "body": body}) + "\n")
code, obj = repo_routes(self.command, self.path, body) \
or routes().get((self.command, self.path), (404, {"message": "Not Found"}))
data = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = handle_any
server = HTTPServer(("127.0.0.1", 0), Handler)
with open(portfile, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()