diff --git a/README.md b/README.md
index 42e6c9c..0bf7265 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,13 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
-requests, merge assigned pull requests, and submit pull-request reviews.
+requests, merge assigned pull requests, and submit pull-request reviews. For authored
+pulls, several non-overlapping one-line reviewer suggestions in the same bounded UTF-8
+file can be staged during the mobile feedback pass, reviewed together, and committed as
+one race-guarded branch update. The batch preserves the original blob identity, applies
+replacements from the bottom up, rejects overlapping or stale lines, and verifies the
+advanced pull head and final file before reporting success; suggestions in another file
+wait for the next batch so Gitea's single-file contents API cannot produce partial commits.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 73a6c09..acd74fb 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -774,6 +774,12 @@ textarea { resize: vertical; min-height: 120px; }
.pull-feedback-suggestion-change > div { min-width:0; }
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
+.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
+.pull-feedback-batch-review[hidden] { display:none; }
+.pull-feedback-batch-review h5 { margin:0; }
+.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
+.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
+#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
diff --git a/frontend/index.html b/frontend/index.html
index 0ee2928..c903f29 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1755,9 +1755,21 @@
Cancel suggestion
+ Add to fix batch
Commit suggested change
+
+ Review fixes (0)
Edit
File content
diff --git a/frontend/pull-sheet.js b/frontend/pull-sheet.js
index c6f5435..270172a 100644
--- a/frontend/pull-sheet.js
+++ b/frontend/pull-sheet.js
@@ -416,6 +416,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
+ qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
@@ -430,6 +431,25 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
+ const renderBatchButton = () => {
+ const count = active?.batch ? Object.values(active.batch)
+ .reduce((total, file) => total + file.suggestions.length, 0) : 0;
+ const button = qs('#review-pull-feedback-batch');
+ button.hidden = count < 2;
+ button.textContent = 'Review fixes (' + count + ')';
+ };
+ const rebuildBatchFile = entry => {
+ const trailingNewline = entry.file.content.endsWith('\n');
+ const lines = entry.file.content.split('\n');
+ if (trailingNewline) lines.pop();
+ const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
+ if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
+ for (const suggestion of ordered) {
+ if (suggestion.line < 1 || suggestion.line > lines.length) return null;
+ lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
+ }
+ return lines.join('\n') + (trailingNewline ? '\n' : '');
+ };
const render = () => {
if (!active) return;
const items = comments();
@@ -500,15 +520,17 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
- active = { item, detail, reviewer, login,
+ active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
+ qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
+ renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
@@ -534,6 +556,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
+ qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
@@ -548,6 +571,90 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
+ qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
+ if (!active?.suggestion) return;
+ const pending = active.suggestion;
+ const path = pending.file.path;
+ const stagedPath = Object.keys(active.batch)[0];
+ if (stagedPath && stagedPath !== path) {
+ qs('#pull-feedback-status').textContent = 'Commit the staged file before batching suggestions from another file.';
+ return;
+ }
+ const entry = active.batch[path] || {file:pending.file, suggestions:[]};
+ if (entry.file.blob_sha !== pending.file.blob_sha ||
+ entry.suggestions.some(item => item.commentId === pending.commentId)) return;
+ const comment = current();
+ entry.suggestions.push({
+ line:Number(comment.suggestion.line), content:comment.suggestion.content,
+ commentId:pending.commentId,
+ });
+ entry.content = rebuildBatchFile(entry);
+ if (entry.content === null) {
+ entry.suggestions.pop();
+ qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
+ return;
+ }
+ active.batch[path] = entry;
+ clearSuggestion();
+ renderBatchButton();
+ qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
+ qs('#next-pull-feedback').focus();
+ });
+ qs('#review-pull-feedback-batch').addEventListener('click', () => {
+ if (!active?.batch) return;
+ const list = qs('#pull-feedback-batch-items');
+ list.replaceChildren(...Object.values(active.batch).map(entry => {
+ const item = doc.createElement('li');
+ item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
+ (entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
+ return item;
+ }));
+ qs('#pull-feedback-batch-review').hidden = false;
+ qs('#cancel-pull-feedback-batch').hidden = false;
+ qs('#commit-pull-feedback-batch').hidden = false;
+ qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
+ qs('#commit-pull-feedback-batch').focus();
+ });
+ qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
+ qs('#pull-feedback-batch-review').hidden = true;
+ qs('#cancel-pull-feedback-batch').hidden = true;
+ qs('#commit-pull-feedback-batch').hidden = true;
+ qs('#review-pull-feedback-batch').focus();
+ });
+ qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
+ if (!active?.batch) return;
+ const entries = Object.values(active.batch);
+ const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
+ const message = qs('#pull-feedback-batch-message').value.trim();
+ if (count < 2 || !message) return;
+ if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
+ const button = qs('#commit-pull-feedback-batch');
+ button.disabled = true;
+ qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
+ try {
+ const result = await controller.commitFeedbackBatch(active.item, {
+ file:{
+ path:entries[0].file.path, content:entries[0].content,
+ expectedBlobSha:entries[0].file.blob_sha,
+ },
+ suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
+ });
+ active.detail.head_sha = result.head_sha;
+ active.state.headSha = result.head_sha;
+ active.batch = {};
+ save();
+ qs('#pull-feedback-batch-review').hidden = true;
+ qs('#cancel-pull-feedback-batch').hidden = true;
+ qs('#commit-pull-feedback-batch').hidden = true;
+ renderBatchButton();
+ qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
+ render();
+ } catch (error) {
+ qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
+ button.disabled = false;
+ button.focus();
+ }
+ });
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
@@ -951,6 +1058,28 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
+ commitFeedbackBatch(item, draft) {
+ if (feedbackFixRequest) return feedbackFixRequest;
+ feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
+ method:'POST',
+ headers:{ Accept:'application/json', 'Content-Type':'application/json' },
+ body:JSON.stringify({
+ file:{
+ path:draft.file.path, content:draft.file.content,
+ expected_blob_sha:draft.file.expectedBlobSha,
+ },
+ suggestion_count:draft.suggestionCount,
+ message:draft.message, expected_head_sha:draft.expectedHeadSha,
+ }),
+ }).then(result => {
+ if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
+ result.path !== draft.file.path || result.suggestion_count !== draft.suggestionCount) {
+ throw new Error('The suggestion batch was not confirmed.');
+ }
+ return result;
+ }).finally(() => { feedbackFixRequest = null; });
+ return feedbackFixRequest;
+ },
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 6f63648..e409c86 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -2419,6 +2419,24 @@ async def commit_authored_pull_feedback_fix(
}
+async def commit_authored_pull_feedback_batch(
+ repository: str,
+ number: int,
+ file: dict,
+ suggestion_count: int,
+ message: str,
+ expected_head_sha: str,
+) -> dict:
+ """Commit multiple reviewed replacements in one file as one verified commit."""
+ if not 2 <= suggestion_count <= 8:
+ raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
+ result = await commit_authored_pull_feedback_fix(
+ repository, number, file.get("path", ""), file.get("content", ""), message,
+ expected_head_sha, file.get("expected_blob_sha", ""),
+ )
+ return {**result, "suggestion_count": suggestion_count}
+
+
async def update_authored_pull_branch(
repository: str, number: int, expected_head_sha: str
) -> dict:
diff --git a/src/main.py b/src/main.py
index b9c812b..37c7b3c 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1166,6 +1166,21 @@ class PullFeedbackFileUpdate(BaseModel):
expected_blob_sha: str = Field(min_length=1, max_length=128)
+class PullFeedbackBatchFile(BaseModel):
+ path: str = Field(min_length=1, max_length=1024)
+ content: str = Field(max_length=131_072)
+ expected_blob_sha: str = Field(min_length=1, max_length=128)
+
+
+class PullFeedbackBatchUpdate(BaseModel):
+ file: PullFeedbackBatchFile
+ suggestion_count: int = Field(ge=2, le=8)
+ message: str = Field(min_length=1, max_length=120)
+ expected_head_sha: str = Field(
+ min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
+ )
+
+
class PullContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@@ -7159,6 +7174,34 @@ async def commit_authored_pull_feedback_fix(
return JSONResponse(result)
+@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-batch")
+async def commit_authored_pull_feedback_batch(
+ update: PullFeedbackBatchUpdate,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+):
+ try:
+ result = await asyncio.wait_for(
+ gitea_proxy.commit_authored_pull_feedback_batch(
+ f"{owner}/{repo}", number, update.file.model_dump(),
+ update.suggestion_count, update.message, update.expected_head_sha,
+ ),
+ timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
+ )
+ except gitea_proxy.IssueNotAvailableError:
+ return JSONResponse(
+ {"error": "The pull request or a suggested file changed. No fixes were committed."},
+ status_code=409,
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "The suggestion batch could not be confirmed. Keep the batch and retry."},
+ status_code=503, headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
diff --git a/tests/e2e/test_mobile_apply_review_suggestion_batch_release.py b/tests/e2e/test_mobile_apply_review_suggestion_batch_release.py
new file mode 100644
index 0000000..c1695d2
--- /dev/null
+++ b/tests/e2e/test_mobile_apply_review_suggestion_batch_release.py
@@ -0,0 +1,108 @@
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).parents[2]
+
+
+@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
+def test_mobile_author_stages_and_commits_two_same_file_suggestions_atomically(viewport):
+ playwright = pytest.importorskip("playwright.sync_api")
+ html = (ROOT / "frontend" / "index.html").read_text()
+ detail = {
+ "state": "open", "merged": False, "head_sha": "abc1234",
+ "capabilities": {"authored": True},
+ "reviewers": [{
+ "review_id": 42, "login": "sam", "status": "changes_requested",
+ "head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
+ "comments": [
+ {"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
+ "suggestion": {"line": 2, "content": " return empty_result()"}},
+ {"id": 100, "path": "src/api.py", "line": 3, "body": "Keep the result typed.",
+ "suggestion": {"line": 3, "content": " return typed_result()"}},
+ ],
+ }],
+ "files": [
+ {"filename": "src/api.py", "status": "modified", "diff_available": True,
+ "diff_lines": ["@@ -2 +2 @@", "+ return None"]},
+
+ ],
+ }
+
+ with playwright.sync_playwright() as runtime:
+ try:
+ browser = runtime.chromium.launch(headless=True)
+ except Exception as error:
+ pytest.skip(f"Chromium is not installed: {error}")
+ page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
+ page.set_content(html, wait_until="domcontentloaded")
+ page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
+ page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
+ page.evaluate(
+ """detail => {
+ globalThis.confirm = () => true;
+ globalThis.batchCalls = [];
+ globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
+ globalThis.batchDetail = detail;
+ globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
+ batchCalls.push({path, options});
+ if ((options.method || 'GET') === 'GET') {
+ return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
+ content:'def lookup():\\n return None\\n return result\\n'};
+ }
+ return {path:'src/api.py', suggestion_count:2, previous_head_sha:'abc1234', head_sha:'def5678'};
+ }});
+ document.querySelector('#pull-sheet').classList.add('open');
+ document.querySelector('#pull-review').open = true;
+ document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
+ createPullSheet.renderFile(file, index, false, value => String(value))
+ ).join('');
+ createPullSheet.bindFeedbackControls(document, batchController,
+ () => batchItem, () => batchDetail, () => 'alex');
+ createPullSheet.review(detail, null, document);
+ }""",
+ detail,
+ )
+
+ page.locator(".pull-review-feedback summary").click()
+ page.locator('[data-address-review-feedback="42"]').click()
+ page.locator("#review-pull-feedback-suggestion").click()
+ page.wait_for_function("batchCalls.length === 1")
+ page.locator("#stage-pull-feedback-suggestion").click()
+ assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
+ page.locator("#next-pull-feedback").click()
+ page.locator("#review-pull-feedback-suggestion").click()
+ page.wait_for_function("batchCalls.length === 2")
+ page.locator("#stage-pull-feedback-suggestion").click()
+ assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
+ page.locator("#review-pull-feedback-batch").click()
+ assert page.locator("#pull-feedback-batch-review").is_visible()
+ assert page.locator("#pull-feedback-batch-items li").count() == 1
+ page.locator("#commit-pull-feedback-batch").click()
+ page.wait_for_function("batchCalls.length === 3")
+ result = page.evaluate("""() => ({
+ calls:batchCalls, head:batchDetail.head_sha,
+ reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
+ status:document.querySelector('#pull-feedback-status').textContent,
+ scrollWidth:document.documentElement.scrollWidth,
+ clientWidth:document.documentElement.clientWidth,
+ heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
+ .filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
+ })""")
+ browser.close()
+
+ assert result["scrollWidth"] <= result["clientWidth"]
+ assert min(result["heights"]) >= 44
+ assert result["head"] == "def5678"
+ assert result["reviewHidden"] is True
+ assert "2 suggested changes committed" in result["status"].lower()
+ request = result["calls"][2]
+ assert request["path"].endswith("/feedback-batch")
+ assert request["options"]["method"] == "POST"
+ body = request["options"]["body"]
+ assert '"expected_head_sha":"abc1234"' in body
+ assert '"file":{"path":"src/api.py"' in body
+ assert '"suggestion_count":2' in body
+ assert 'return empty_result()' in body
+ assert 'return typed_result()' in body
diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py
index 4e28098..39ee13d 100644
--- a/tests/test_pull_api.py
+++ b/tests/test_pull_api.py
@@ -108,6 +108,74 @@ async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
]
+@pytest.mark.anyio
+async def test_gitea_commits_multiple_same_file_suggestions_as_one_commit():
+ requests = []
+ pull = {
+ "number": 7, "state": "open", "merged": False,
+ "user": {"login": "alex"},
+ "head": {"sha": "abc1234", "ref": "alex/review-fix", "repo": {"full_name": "stackchain/api"}},
+ "base": {"repo": {"full_name": "stackchain/api"}},
+ }
+
+ async def handler(request):
+ requests.append((request.method, request.url.path))
+ if request.url.path == "/api/v1/user":
+ return httpx.Response(200, json={"login": "alex"})
+ if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
+ return httpx.Response(200, json=pull)
+ if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "GET":
+ updated = request.url.params["ref"] == "def5678"
+ return httpx.Response(200, json=_content_payload(
+ "first fixed\nsecond fixed\n" if updated else "first\nsecond\n",
+ "blob2" if updated else "blob1",
+ ))
+ if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "PUT":
+ body = json.loads(request.content)
+ assert body["branch"] == "alex/review-fix"
+ assert base64.b64decode(body["content"]).decode() == "first fixed\nsecond fixed\n"
+ pull["head"]["sha"] = "def5678"
+ return httpx.Response(200, json={"commit": {"sha": "def5678"}})
+ raise AssertionError(f"unexpected request: {request.method} {request.url}")
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.commit_authored_pull_feedback_batch(
+ "stackchain/api", 7,
+ {"path": "src/api.py", "content": "first fixed\nsecond fixed\n", "expected_blob_sha": "blob1"},
+ 2, "fix: apply review suggestions", "abc1234",
+ )
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result["previous_head_sha"] == "abc1234"
+ assert result["head_sha"] == "def5678"
+ assert result["path"] == "src/api.py"
+ assert result["suggestion_count"] == 2
+ assert len([request for request in requests if request[0] == "PUT"]) == 1
+
+
+@pytest.mark.anyio
+async def test_feedback_batch_api_requires_at_least_two_suggestions(monkeypatch):
+ called = False
+
+ async def commit(*args):
+ nonlocal called
+ called = True
+
+ monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.post(
+ "/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
+ json={"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 1,
+ "file": {"path": "src/api.py", "content": "one\n", "expected_blob_sha": "blob1"}},
+ )
+
+ assert response.status_code == 422
+ assert called is False
+
+
@pytest.mark.anyio
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
calls = []