Slice 3: the PR flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 12:37:54 -07:00
parent 33d9d7a482
commit a2bf89e90b
15 changed files with 2928 additions and 141 deletions
+82 -11
View File
@@ -45,7 +45,8 @@ class FakeGitea:
def __init__(self):
# files: (owner, repo, branch, path) -> {"content": str, "sha": str}
self.files: dict[tuple[str, str, str, str], dict] = {}
# branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str}}
# branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str,
# "base_main_files": {path -> str}}}
self.branches: dict[tuple[str, str], dict[str, dict]] = {}
# pulls: (owner, repo) -> list[pull-dict]
self.pulls: dict[tuple[str, str], list[dict]] = {}
@@ -71,6 +72,48 @@ class FakeGitea:
self._commit_counter += 1
return f"sha{self._commit_counter:04d}"
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
"""Return the PR with mergeability fields filled in.
Gitea's PR responses carry `mergeable` and `merge_commit_sha`
plus the head sha; for the per-RFC repo paths in §10 we mirror
that shape.
"""
out = dict(pr)
head_branch = pr["head"]["ref"]
head_sha = (self.branches.get((owner, repo)) or {}).get(head_branch, {}).get("sha")
out["head"] = dict(pr["head"])
if head_sha:
out["head"]["sha"] = head_sha
out["mergeable"] = self._is_mergeable(owner, repo, pr) if pr["state"] == "open" else False
return out
def _is_mergeable(self, owner: str, repo: str, pr: dict) -> bool:
"""A PR is mergeable when the file content under main matches the
branch's snapshot of main at cut-time on every path the branch
either inherited or touched. This collapses to "no path on the
branch has diverged from main since cut" — sufficient for the
single-file RFC.md surface and the §10.9 conflict-replay test
path.
"""
head_branch = pr["head"]["ref"]
branch_data = self.branches.get((owner, repo), {}).get(head_branch, {})
base_snapshot: dict[str, str] = branch_data.get("base_main_files") or {}
# Touch every path the branch tracks plus every path on main, so a
# file deleted on main also surfaces.
paths = set(base_snapshot.keys())
for (o, r, br, p) in self.files.keys():
if (o, r, br) == (owner, repo, head_branch):
paths.add(p)
if (o, r, br) == (owner, repo, "main"):
paths.add(p)
for p in paths:
main_content = (self.files.get((owner, repo, "main", p)) or {}).get("content")
base_content = base_snapshot.get(p)
if main_content != base_content:
return False
return True
def handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.replace("/api/v1", "", 1)
method = request.method
@@ -118,11 +161,18 @@ class FakeGitea:
new = payload["new_branch_name"]
old = payload["old_branch_name"]
old_sha = self.branches[(owner, repo)][old]["sha"]
self.branches[(owner, repo)][new] = {"sha": old_sha}
# Copy main's files into the new branch
# Snapshot the parent branch's files at cut time so we can
# surface §10.5 merge conflicts when main diverges later.
snapshot: dict[str, str] = {}
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, old):
self.files[(owner, repo, new, p)] = dict(data)
snapshot[p] = data["content"]
self.branches[(owner, repo)][new] = {
"sha": old_sha,
"ts": "2026-05-23T00:00:00Z",
"base_main_files": snapshot,
}
return httpx.Response(201, json={"name": new})
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
@@ -163,7 +213,9 @@ class FakeGitea:
content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
br = self.branches[(owner, repo)].setdefault(branch, {})
br["sha"] = sha
br["ts"] = "2026-05-23T00:00:00Z"
return httpx.Response(201, json={"commit": {"sha": sha}})
# PUT /repos/{owner}/{repo}/contents/{path} — update_file
@@ -174,7 +226,9 @@ class FakeGitea:
content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
br = self.branches[(owner, repo)].setdefault(branch, {})
br["sha"] = sha
br["ts"] = "2026-05-23T00:00:00Z"
return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})
# GET /repos/{owner}/{repo}/pulls?state=...
@@ -183,7 +237,7 @@ class FakeGitea:
owner, repo = m.groups()
state = request.url.params.get("state", "open")
items = self.pulls.get((owner, repo), [])
filtered = [p for p in items if (state == "all") or (p["state"] == state)]
filtered = [self._enrich_pr(owner, repo, p) for p in items if (state == "all") or (p["state"] == state)]
return httpx.Response(200, json=filtered)
# POST /repos/{owner}/{repo}/pulls
@@ -205,7 +259,16 @@ class FakeGitea:
"user": {"login": "rfc-bot"},
}
self.pulls[(owner, repo)].append(pr)
return httpx.Response(201, json=pr)
return httpx.Response(201, json=self._enrich_pr(owner, repo, pr))
# GET /repos/{owner}/{repo}/pulls/{number}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)", path)
if method == "GET" and m:
owner, repo, num = m.groups()
for pr in self.pulls.get((owner, repo), []):
if pr["number"] == int(num):
return httpx.Response(200, json=self._enrich_pr(owner, repo, pr))
return httpx.Response(404, json={"message": "not found"})
# POST /repos/{owner}/{repo}/pulls/{number}/merge
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
@@ -213,18 +276,26 @@ class FakeGitea:
owner, repo, num = m.groups()
for pr in self.pulls[(owner, repo)]:
if pr["number"] == int(num):
if pr["state"] != "open":
return httpx.Response(409, json={"message": "PR is not open"})
if not self._is_mergeable(owner, repo, pr):
return httpx.Response(409, json={"message": "merge conflict with main"})
head_branch = pr["head"]["ref"]
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, head_branch):
self.files[(owner, repo, "main", p)] = dict(data)
# Real Gitea: state becomes "closed" with merged=true.
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-23T01:00:00Z"
pr["closed_at"] = "2026-05-23T01:00:00Z"
new_sha = self._next_sha()
self.branches[(owner, repo)]["main"]["sha"] = new_sha
return httpx.Response(200, json={"merged": True})
# Per §10.5: a no-fast-forward merge advances main
# via a new merge commit SHA, not by reusing the
# branch's tip. We mint a fresh sha to model that.
merge_sha = self._next_sha()
pr["merge_commit_sha"] = merge_sha
self.branches[(owner, repo)]["main"]["sha"] = merge_sha
self.branches[(owner, repo)]["main"]["ts"] = "2026-05-23T01:00:00Z"
return httpx.Response(200, json={"merged": True, "merge_commit_sha": merge_sha})
return httpx.Response(404, json={"message": "not found"})
# GET /repos/{owner}/{repo}/hooks