Slice 3: the PR flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user