#!/usr/bin/env python3 """A git smart-HTTP server for tests: `git http-backend` behind a plain HTTP server that also takes chunked request bodies (the hosted service streams its pushes). It stands in for git.coline.app and takes any credentials. Under /api it also stands in for the parts of Upstream's API the hosted service uses for review: a repository's rules and its change requests, which merge (as a merge commit) once the test says they're ready. /api/test/rules// sets the rules; /api/test/ready/// makes a change request ready. Usage: git-server.py """ import http.server import json import os import re import subprocess import sys import threading ROOT, PORT = sys.argv[1], int(sys.argv[2]) LOCK = threading.Lock() RULES = {} # "owner/repo" -> {"minimumApprovals", "requiredChecks", "directPushMode"} CHANGES = {} # "owner/repo" -> [change request dicts] def git(repo, *args, env=None): return subprocess.run(["git", "--git-dir", os.path.join(ROOT, repo + ".git"), *args], capture_output=True, text=True, env=env) def merge(repo, cr): """Merges a change request's branch into its target as a merge commit.""" target = git(repo, "rev-parse", "refs/heads/" + cr["target"]).stdout.strip() source = git(repo, "rev-parse", "refs/heads/" + cr["source"]).stdout.strip() tree = git(repo, "merge-tree", "--write-tree", target, source) if tree.returncode != 0: return False env = dict(os.environ, GIT_AUTHOR_NAME="Upstream", GIT_AUTHOR_EMAIL="merge@upstream.test", GIT_COMMITTER_NAME="Upstream", GIT_COMMITTER_EMAIL="merge@upstream.test") commit = git(repo, "commit-tree", tree.stdout.split()[0], "-p", target, "-p", source, "-m", "%s (#%d)" % (cr["title"], cr["number"]), env=env).stdout.strip() if git(repo, "update-ref", "refs/heads/" + cr["target"], commit, target).returncode != 0: return False cr["state"] = "merged" return True def view(cr): blockers = [] if cr["ready"] else [{"checkKey": "review", "reason": "waiting", "detail": "needs an approval"}] return {"number": cr["number"], "state": cr["state"], "sourceBranch": cr["source"], "mergeReadiness": {"canMerge": cr["ready"], "blockers": blockers}} def api(method, path, body): """(status, data) for one call to the stand-in API.""" m = re.match(r"^/api/test/rules/([^/]+)/([^/]+)$", path) if m and method == "POST": RULES[m[1] + "/" + m[2]] = body return 200, {} m = re.match(r"^/api/test/ready/([^/]+)/([^/]+)/(\d+)$", path) if m and method == "POST": repo = m[1] + "/" + m[2] cr = CHANGES.get(repo, [])[int(m[3]) - 1] cr["ready"] = True if cr["auto"]: merge(repo, cr) return 200, view(cr) m = re.match(r"^/api/repos/([^/]+)/([^/]+)/rulesets$", path) if m: r = RULES.get(m[1] + "/" + m[2], {}) return 200, { "mergeTimeRules": {"requiredChecks": r.get("requiredChecks", []), "reviewRules": {"minimumApprovals": r.get("minimumApprovals", 0)}}, "pushTimeRules": {"defaultBranch": {"directPushMode": r.get("directPushMode", "allowed")}}, } m = re.match(r"^/api/repos/([^/]+)/([^/]+)/changes$", path) if m and method == "POST": repo = m[1] + "/" + m[2] crs = CHANGES.setdefault(repo, []) cr = {"number": len(crs) + 1, "title": body["title"], "source": body["sourceBranch"], "target": body["targetBranch"], "state": "open", "auto": False, "ready": False, "versions": 1} crs.append(cr) return 201, view(cr) m = re.match(r"^/api/repos/([^/]+)/([^/]+)/changes/(\d+)(/[a-z-]+)?$", path) if m: repo = m[1] + "/" + m[2] crs = CHANGES.get(repo, []) n = int(m[3]) if n < 1 or n > len(crs): return 404, None cr = crs[n - 1] action = m[4] if action is None and method == "GET": return 200, view(cr) if action == "/versions" and method == "POST": if cr["state"] != "open": return 409, None cr["versions"] += 1 return 201, {"versionNumber": cr["versions"]} if action == "/auto-merge" and method == "PUT": cr["auto"] = True if cr["ready"]: merge(repo, cr) return 202, {"number": n} if action == "/merge" and method == "PUT": if not cr["ready"] or not merge(repo, cr): return 409, None return 200, {"merged": True} return 404, None class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *args): pass def body(self): if self.headers.get("transfer-encoding", "").lower() == "chunked": out = bytearray() while True: size = int(self.rfile.readline().split(b";")[0].strip(), 16) if size == 0: while self.rfile.readline() not in (b"\r\n", b"\n", b""): pass return bytes(out) out += self.rfile.read(size) self.rfile.readline() length = int(self.headers.get("content-length") or 0) return self.rfile.read(length) if length else b"" def answer(self, status, payload): body = json.dumps({"data": payload} if status < 300 else {"error": {"message": "refused"}}).encode() self.send_response(status) self.send_header("content-type", "application/json") self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) def serve(self): path, _, query = self.path.partition("?") data = self.body() if path.startswith("/api/"): with LOCK: status, payload = api(self.command, path, json.loads(data) if data else {}) return self.answer(status, payload) env = dict( os.environ, GIT_PROJECT_ROOT=ROOT, GIT_HTTP_EXPORT_ALL="1", REMOTE_USER="vcs", REQUEST_METHOD=self.command, PATH_INFO=path, QUERY_STRING=query, CONTENT_TYPE=self.headers.get("content-type", ""), CONTENT_LENGTH=str(len(data)), # Partial fetches, as git.coline.app answers them: without files, then files by ID. GIT_CONFIG_COUNT="2", GIT_CONFIG_KEY_0="uploadpack.allowFilter", GIT_CONFIG_VALUE_0="true", GIT_CONFIG_KEY_1="uploadpack.allowAnySHA1InWant", GIT_CONFIG_VALUE_1="true", ) if self.headers.get("content-encoding"): env["HTTP_CONTENT_ENCODING"] = self.headers["content-encoding"] out = subprocess.run(["git", "http-backend"], input=data, env=env, capture_output=True).stdout sep = b"\r\n\r\n" if b"\r\n\r\n" in out else b"\n\n" head, _, body = out.partition(sep) status = 200 headers = [] for line in head.decode().splitlines(): key, _, value = line.partition(":") if key.lower() == "status": status = int(value.strip().split()[0]) elif key: headers.append((key, value.strip())) self.send_response(status) for key, value in headers: self.send_header(key, value) self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) do_GET = serve do_POST = serve do_PUT = serve do_DELETE = serve http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()