Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 |
| Under /api it also stands in for the parts of Upstream's API the hosted |
| 7 | service uses for review: a repository's rules and its change requests, |
| 8 | which merge (as a merge commit) once the test says they're ready. |
| 9 | /api/test/rules/<owner>/<repo> sets the rules; /api/test/ready/<owner>/<repo>/<n> |
| 10 | makes a change request ready. |
| 11 |
| 12 | Usage: git-server.py <root> <port> |
| 13 | """ |
| 14 | import http.server |
| 15 | import json |
| 16 | import os |
| 17 | import re |
| 18 | import subprocess |
| 19 | import sys |
| 20 | import threading |
| 21 |
| 22 | ROOT, PORT = sys.argv[1], int(sys.argv[2]) |
| 23 | LOCK = threading.Lock() |
| 24 | RULES = {} # "owner/repo" -> {"minimumApprovals", "requiredChecks", "directPushMode"} |
| 25 | CHANGES = {} # "owner/repo" -> [change request dicts] |
| 26 |
| 27 |
| 28 | def git(repo, *args, env=None): |
| 29 | return subprocess.run(["git", "--git-dir", os.path.join(ROOT, repo + ".git"), *args], capture_output=True, text=True, env=env) |
| 30 |
| 31 |
| 32 | def merge(repo, cr): |
| 33 | """Merges a change request's branch into its target as a merge commit.""" |
| 34 | target = git(repo, "rev-parse", "refs/heads/" + cr["target"]).stdout.strip() |
| 35 | source = git(repo, "rev-parse", "refs/heads/" + cr["source"]).stdout.strip() |
| 36 | tree = git(repo, "merge-tree", "--write-tree", target, source) |
| 37 | if tree.returncode != 0: |
| 38 | return False |
| 39 | env = dict(os.environ, GIT_AUTHOR_NAME="Upstream", GIT_AUTHOR_EMAIL="merge@upstream.test", GIT_COMMITTER_NAME="Upstream", GIT_COMMITTER_EMAIL="merge@upstream.test") |
| 40 | commit = git(repo, "commit-tree", tree.stdout.split()[0], "-p", target, "-p", source, "-m", "%s (#%d)" % (cr["title"], cr["number"]), env=env).stdout.strip() |
| 41 | if git(repo, "update-ref", "refs/heads/" + cr["target"], commit, target).returncode != 0: |
| 42 | return False |
| 43 | cr["state"] = "merged" |
| 44 | return True |
| 45 |
| 46 |
| 47 | def view(cr): |
| 48 | blockers = [] if cr["ready"] else [{"checkKey": "review", "reason": "waiting", "detail": "needs an approval"}] |
| 49 | return {"number": cr["number"], "state": cr["state"], "sourceBranch": cr["source"], "mergeReadiness": {"canMerge": cr["ready"], "blockers": blockers}} |
| 50 |
| 51 |
| 52 | def api(method, path, body): |
| 53 | """(status, data) for one call to the stand-in API.""" |
| 54 | m = re.match(r"^/api/test/rules/([^/]+)/([^/]+)$", path) |
| 55 | if m and method == "POST": |
| 56 | RULES[m[1] + "/" + m[2]] = body |
| 57 | return 200, {} |
| 58 | m = re.match(r"^/api/test/ready/([^/]+)/([^/]+)/(\d+)$", path) |
| 59 | if m and method == "POST": |
| 60 | repo = m[1] + "/" + m[2] |
| 61 | cr = CHANGES.get(repo, [])[int(m[3]) - 1] |
| 62 | cr["ready"] = True |
| 63 | if cr["auto"]: |
| 64 | merge(repo, cr) |
| 65 | return 200, view(cr) |
| 66 | m = re.match(r"^/api/repos/([^/]+)/([^/]+)/rulesets$", path) |
| 67 | if m: |
| 68 | r = RULES.get(m[1] + "/" + m[2], {}) |
| 69 | return 200, { |
| 70 | "mergeTimeRules": {"requiredChecks": r.get("requiredChecks", []), "reviewRules": {"minimumApprovals": r.get("minimumApprovals", 0)}}, |
| 71 | "pushTimeRules": {"defaultBranch": {"directPushMode": r.get("directPushMode", "allowed")}}, |
| 72 | } |
| 73 | m = re.match(r"^/api/repos/([^/]+)/([^/]+)/changes$", path) |
| 74 | if m and method == "POST": |
| 75 | repo = m[1] + "/" + m[2] |
| 76 | crs = CHANGES.setdefault(repo, []) |
| 77 | cr = {"number": len(crs) + 1, "title": body["title"], "source": body["sourceBranch"], "target": body["targetBranch"], "state": "open", "auto": False, "ready": False, "versions": 1} |
| 78 | crs.append(cr) |
| 79 | return 201, view(cr) |
| 80 | m = re.match(r"^/api/repos/([^/]+)/([^/]+)/changes/(\d+)(/[a-z-]+)?$", path) |
| 81 | if m: |
| 82 | repo = m[1] + "/" + m[2] |
| 83 | crs = CHANGES.get(repo, []) |
| 84 | n = int(m[3]) |
| 85 | if n < 1 or n > len(crs): |
| 86 | return 404, None |
| 87 | cr = crs[n - 1] |
| 88 | action = m[4] |
| 89 | if action is None and method == "GET": |
| 90 | return 200, view(cr) |
| 91 | if action == "/versions" and method == "POST": |
| 92 | if cr["state"] != "open": |
| 93 | return 409, None |
| 94 | cr["versions"] += 1 |
| 95 | return 201, {"versionNumber": cr["versions"]} |
| 96 | if action == "/auto-merge" and method == "PUT": |
| 97 | cr["auto"] = True |
| 98 | if cr["ready"]: |
| 99 | merge(repo, cr) |
| 100 | return 202, {"number": n} |
| 101 | if action == "/merge" and method == "PUT": |
| 102 | if not cr["ready"] or not merge(repo, cr): |
| 103 | return 409, None |
| 104 | return 200, {"merged": True} |
| 105 | return 404, None |
| 106 |
| 107 |
| 108 | class Handler(http.server.BaseHTTPRequestHandler): |
| 109 | protocol_version = "HTTP/1.1" |
| 110 |
| 111 | def log_message(self, *args): |
| 112 | pass |
| 113 |
| 114 | def body(self): |
| 115 | if self.headers.get("transfer-encoding", "").lower() == "chunked": |
| 116 | out = bytearray() |
| 117 | while True: |
| 118 | size = int(self.rfile.readline().split(b";")[0].strip(), 16) |
| 119 | if size == 0: |
| 120 | while self.rfile.readline() not in (b"\r\n", b"\n", b""): |
| 121 | pass |
| 122 | return bytes(out) |
| 123 | out += self.rfile.read(size) |
| 124 | self.rfile.readline() |
| 125 | length = int(self.headers.get("content-length") or 0) |
| 126 | return self.rfile.read(length) if length else b"" |
| 127 |
| 128 | def answer(self, status, payload): |
| 129 | body = json.dumps({"data": payload} if status < 300 else {"error": {"message": "refused"}}).encode() |
| 130 | self.send_response(status) |
| 131 | self.send_header("content-type", "application/json") |
| 132 | self.send_header("content-length", str(len(body))) |
| 133 | self.end_headers() |
| 134 | self.wfile.write(body) |
| 135 |
| 136 | def serve(self): |
| 137 | path, _, query = self.path.partition("?") |
| 138 | data = self.body() |
| 139 | if path.startswith("/api/"): |
| 140 | with LOCK: |
| 141 | status, payload = api(self.command, path, json.loads(data) if data else {}) |
| 142 | return self.answer(status, payload) |
| 143 | env = dict( |
| 144 | os.environ, |
| 145 | GIT_PROJECT_ROOT=ROOT, |
| 146 | GIT_HTTP_EXPORT_ALL="1", |
| 147 | REMOTE_USER="vcs", |
| 148 | REQUEST_METHOD=self.command, |
| 149 | PATH_INFO=path, |
| 150 | QUERY_STRING=query, |
| 151 | CONTENT_TYPE=self.headers.get("content-type", ""), |
| 152 | CONTENT_LENGTH=str(len(data)), |
| 153 | ) |
| 154 | if self.headers.get("content-encoding"): |
| 155 | env["HTTP_CONTENT_ENCODING"] = self.headers["content-encoding"] |
| 156 | out = subprocess.run(["git", "http-backend"], input=data, env=env, capture_output=True).stdout |
| 157 | sep = b"\r\n\r\n" if b"\r\n\r\n" in out else b"\n\n" |
| 158 | head, _, body = out.partition(sep) |
| 159 | status = 200 |
| 160 | headers = [] |
| 161 | for line in head.decode().splitlines(): |
| 162 | key, _, value = line.partition(":") |
| 163 | if key.lower() == "status": |
| 164 | status = int(value.strip().split()[0]) |
| 165 | elif key: |
| 166 | headers.append((key, value.strip())) |
| 167 | self.send_response(status) |
| 168 | for key, value in headers: |
| 169 | self.send_header(key, value) |
| 170 | self.send_header("content-length", str(len(body))) |
| 171 | self.end_headers() |
| 172 | self.wfile.write(body) |
| 173 |
| 174 | do_GET = serve |
| 175 | do_POST = serve |
| 176 | do_PUT = serve |
| 177 | do_DELETE = serve |
| 178 |
| 179 |
| 180 | http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() |
| 181 |