@' +
escapeHtml(reviewer?.login || 'unknown') + '' + escapeHtml(status) + '
' +
- renderReviewerFeedback(reviewer, escapeHtml) + '';
+ renderReviewerFeedback(reviewer, escapeHtml) + cancel + '';
}).join('');
}
@@ -223,6 +226,37 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail) {
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review';
qs('#merge-pull').disabled = true;
};
+ qs('#pull-reviewer-statuses').addEventListener('click', async event => {
+ const button = event.target.closest?.('[data-cancel-review-request]');
+ if (!button || button.disabled) return;
+ const selected = getSelected?.();
+ const detail = getDetail?.();
+ const reviewer = button.dataset.cancelReviewRequest;
+ if (!selected || !detail?.head_sha || !reviewer ||
+ !globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return;
+ button.disabled = true;
+ qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…';
+ try {
+ const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
+ if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
+ throw new Error('Cancellation was not confirmed.');
+ }
+ detail.reviewers = (detail.reviewers || []).filter(candidate =>
+ candidate.login !== reviewer || candidate.status !== 'waiting');
+ renderReviewerPanel(doc, detail);
+ const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
+ qs('#pull-merge-state').textContent = eligibility.reason;
+ qs('#merge-pull').disabled = !eligibility.allowed;
+ qs('#pull-review-request').open = true;
+ qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
+ load.disabled = false;
+ load.focus();
+ } catch (error) {
+ qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
+ button.disabled = false;
+ button.focus();
+ }
+ });
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
@@ -531,6 +565,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
let candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
+ let reviewCancelMutation = null;
let editRequest = null;
let feedbackRequest = null;
const reviewRequests = new Map();
@@ -607,6 +642,18 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { reviewRequestMutation = null; });
return reviewRequestMutation;
},
+ cancelReview(item, reviewer, expectedHeadSha) {
+ if (reviewCancelMutation) return reviewCancelMutation;
+ reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
+ method: 'DELETE',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
+ }).then(result => {
+ this.onReviewCancelled?.(result);
+ return result;
+ }).finally(() => { reviewCancelMutation = null; });
+ return reviewCancelMutation;
+ },
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index b4d24a3..04249fe 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -2028,6 +2028,53 @@ async def request_assigned_pull_review(
}
+async def cancel_assigned_pull_review(
+ repository: str, number: int, reviewer: str, expected_head_sha: str
+) -> dict:
+ login, pull = await _current_login_and_target(
+ f"repos/{repository}/pulls/{number}"
+ )
+ head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
+ requested = [
+ item["login"] for item in (pull.get("requested_reviewers") or [])
+ if isinstance(item, dict) and isinstance(item.get("login"), str)
+ ]
+ if (
+ pull.get("state") != "open"
+ or pull.get("merged") is True
+ or not _login_in_users(login, pull.get("assignees"))
+ or head.get("sha") != expected_head_sha
+ or reviewer not in requested
+ ):
+ raise IssueNotAvailableError("Pending review request changed")
+ response = await _get_client().request(
+ "DELETE",
+ f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
+ headers=_auth(),
+ json={"reviewers": [reviewer]},
+ )
+ response.raise_for_status()
+ confirmed_response = await _get_client().get(
+ f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
+ )
+ confirmed_response.raise_for_status()
+ confirmed = confirmed_response.json()
+ remaining = [
+ item["login"] for item in (confirmed.get("requested_reviewers") or [])
+ if isinstance(item, dict) and isinstance(item.get("login"), str)
+ ] if isinstance(confirmed, dict) else []
+ confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
+ if reviewer in remaining or confirmed_head.get("sha") != expected_head_sha:
+ raise ValueError("Gitea did not confirm reviewer cancellation")
+ return {
+ "repository": repository,
+ "number": number,
+ "head_sha": expected_head_sha,
+ "requested_reviewers": remaining,
+ "reviewer": reviewer,
+ }
+
+
async def _change_assigned_pull_owners(
repository: str, number: int, recipient: str | None
) -> dict:
diff --git a/src/main.py b/src/main.py
index 05ab136..e2c9d0b 100644
--- a/src/main.py
+++ b/src/main.py
@@ -6773,6 +6773,34 @@ async def request_assigned_pull_review(
return JSONResponse(result)
+@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/request-review")
+async def cancel_assigned_pull_review(
+ request: PullReviewRequest,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+) -> JSONResponse:
+ try:
+ result = await asyncio.wait_for(
+ gitea_proxy.cancel_assigned_pull_review(
+ f"{owner}/{repo}", number, request.reviewer, request.expected_head_sha
+ ),
+ timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
+ )
+ except gitea_proxy.IssueNotAvailableError:
+ return JSONResponse(
+ {"error": "The pull request or reviewer changed. Reload before cancelling the request."},
+ status_code=409,
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "The cancellation could not be confirmed. Refresh review data before retrying."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
async def release_assigned_pull(
owner: str, repo: str, number: int = PathParam(gt=0)
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index adda748..80e5390 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -7662,6 +7662,45 @@ const item = {{repository:'stackchain/api',number:7}};
}
+def test_pull_sheet_single_flights_pending_review_cancellation():
+ script = f"""
+const createPullSheet = require({json.dumps(str(PULL_SHEET))});
+const calls = [];
+let finish;
+const controller = createPullSheet({{
+ storage:null,
+ fetchJson:(url, options={{}}) => {{
+ calls.push({{url, method:options.method, body:JSON.parse(options.body)}});
+ return new Promise(resolve => {{ finish = resolve; }});
+ }}
+}});
+const notified = [];
+controller.onReviewCancelled = result => notified.push(result);
+const item = {{repository:'stackchain/api',number:7}};
+(async () => {{
+ const first = controller.cancelReview(item, 'sam', 'abc1234');
+ const duplicate = controller.cancelReview(item, 'sam', 'abc1234');
+ finish({{reviewer:'sam',head_sha:'abc1234',requested_reviewers:['casey']}});
+ const results = await Promise.all([first, duplicate]);
+ process.stdout.write(JSON.stringify({{calls,results,same:first===duplicate,notified}}));
+}})();
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output == {
+ "calls": [{
+ "url": "api/v1/repos/stackchain/api/pulls/7/request-review",
+ "method": "DELETE",
+ "body": {"reviewer": "sam", "expected_head_sha": "abc1234"},
+ }],
+ "results": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}] * 2,
+ "same": True,
+ "notified": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}],
+ }
+
+
def test_pull_sheet_exposes_touch_safe_review_request_flow():
root = PULL_SHEET.parents[1]
html = (root / "frontend" / "index.html").read_text()
@@ -7772,6 +7811,36 @@ process.stdout.write(JSON.stringify({{
assert "Approved current head" in output["html"]
+def test_pull_sheet_offers_cancel_only_for_pending_review_requests():
+ script = f"""
+const createPullSheet = require({json.dumps(str(PULL_SHEET))});
+const html = createPullSheet.renderReviewerStatuses([
+ {{login:'sam',status:'waiting'}},
+ {{login:'casey',status:'approved'}},
+ {{login:'lee',status:'commented'}},
+], value => String(value));
+process.stdout.write(html);
+"""
+ html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
+
+ assert html.count('data-cancel-review-request=') == 1
+ assert 'data-cancel-review-request="sam"' in html
+ assert "Cancel request" in html
+ assert 'data-cancel-review-request="casey"' not in html
+ assert 'data-cancel-review-request="lee"' not in html
+
+
+def test_pull_sheet_pending_review_cancellation_is_touch_safe_and_opens_replacement_picker():
+ root = PULL_SHEET.parents[1]
+ css = (root / "frontend" / "dashboard.css").read_text()
+ script = PULL_SHEET.read_text()
+
+ assert ".cancel-review-request { min-height:44px;" in css
+ assert "controller.cancelReview(selected, reviewer, detail.head_sha)" in script
+ assert "Choose a replacement reviewer." in script
+ assert "qs('#pull-review-request').open = true" in script
+
+
def test_pull_sheet_renders_bounded_feedback_grouped_by_file():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py
index 86d4667..de93e01 100644
--- a/tests/test_pull_api.py
+++ b/tests/test_pull_api.py
@@ -970,6 +970,30 @@ async def test_assigned_pull_review_request_endpoints_list_and_submit(monkeypatc
]
+@pytest.mark.anyio
+async def test_assigned_pull_cancel_review_endpoint_forwards_head_scoped_request(monkeypatch):
+ calls = []
+
+ async def cancel(repository, number, reviewer, expected_head_sha):
+ calls.append((repository, number, reviewer, expected_head_sha))
+ return {
+ "repository": repository, "number": number, "reviewer": reviewer,
+ "head_sha": expected_head_sha, "requested_reviewers": ["casey"],
+ }
+
+ monkeypatch.setattr(main.gitea_proxy, "cancel_assigned_pull_review", cancel, raising=False)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.request(
+ "DELETE", "/api/v1/repos/stackchain/api/pulls/7/request-review",
+ json={"reviewer": "sam", "expected_head_sha": "abc1234"},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["requested_reviewers"] == ["casey"]
+ assert calls == [("stackchain/api", 7, "sam", "abc1234")]
+
+
@pytest.mark.anyio
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
requests = []
@@ -1090,6 +1114,73 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
assert mutation[2] == b'{"reviewers":["casey"]}'
+@pytest.mark.anyio
+async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
+ requests = []
+ cancelled = False
+
+ async def handler(request):
+ nonlocal cancelled
+ requests.append((request.method, request.url.path, request.content))
+ if request.url.path.endswith("/user"):
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.url.path.endswith("/pulls/7"):
+ return httpx.Response(200, json={
+ "number": 7, "state": "open", "merged": False,
+ "head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
+ "requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
+ })
+ if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
+ assert request.content == b'{"reviewers":["sam"]}'
+ cancelled = True
+ return httpx.Response(204)
+ raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.cancel_assigned_pull_review(
+ "stackchain/api", 7, "sam", "abc123"
+ )
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == {
+ "repository": "stackchain/api", "number": 7, "head_sha": "abc123",
+ "requested_reviewers": ["casey"], "reviewer": "sam",
+ }
+ assert [method for method, _path, _body in requests] == ["GET", "GET", "DELETE", "GET"]
+
+
+@pytest.mark.anyio
+async def test_gitea_cancel_pending_pull_review_rejects_head_drift_before_mutation():
+ requests = []
+
+ async def handler(request):
+ requests.append(request.method)
+ if request.url.path.endswith("/user"):
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.url.path.endswith("/pulls/7"):
+ return httpx.Response(200, json={
+ "number": 7, "state": "open", "merged": False,
+ "head": {"sha": "new-head"}, "assignees": [{"login": "timmy"}],
+ "requested_reviewers": [{"login": "sam"}],
+ })
+ if request.method == "DELETE":
+ return httpx.Response(204)
+ raise AssertionError(request.url.path)
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ with pytest.raises(gitea_proxy.IssueNotAvailableError):
+ await gitea_proxy.cancel_assigned_pull_review(
+ "stackchain/api", 7, "sam", "old-head"
+ )
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert requests == ["GET", "GET"]
+
+
@pytest.mark.anyio
async def test_gitea_pull_release_removes_current_login_case_insensitively():
async def handler(request):