diff --git a/README.md b/README.md index fa55e5c..900829c 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ python3 -m pip install -r requirements.txt Point the dashboard at the Gitea server root (without `/api/v1`) and provide a token that can read dashboard data, update the authenticated user's notification -threads, and create issue comments. Pull-request replies use Gitea's issue-comment -API. Serve the dashboard only to trusted users on its own origin; cross-origin API +threads, create issue comments, and submit pull-request reviews. Pull-request +replies use Gitea's issue-comment API; native Comment, Approve, and Request changes +reviews require repository write permission. Serve the dashboard only to trusted +users on its own origin; cross-origin API access is intentionally disabled. Then start the API and bundled frontend: ```bash diff --git a/frontend/index.html b/frontend/index.html index d71235e..cc5a5b7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -88,7 +88,7 @@ textarea { resize: vertical; min-height: 120px; } .review-feedback { display:grid; gap:8px; } .review-feedback label { display:grid; gap:6px; } .review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } -.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.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; } .review-handoff-link[hidden] { display:none; } @@ -318,6 +318,8 @@ textarea { resize: vertical; min-height: 120px; }
+ + @@ -383,6 +385,7 @@ textarea { resize: vertical; min-height: 120px; } let progress = null; let draft = null; let reviewFiles = []; + let selectedReviewHead = ''; let bulkConfirmationPending = false; let bulkMarkPending = false; let reviewHandoffPending = false; @@ -717,8 +720,11 @@ textarea { resize: vertical; min-height: 120px; } qs('#review-handoff-link').hidden = true; qs('#review-handoff-link').href = item.url; qs('#review-handoff-status').textContent = ''; + qs('#review-submit-status').textContent = ''; + qs('#submit-review').disabled = true; draft = null; reviewFiles = []; + selectedReviewHead = ''; qs('#close-review-sheet').focus(); try { const detail = await reviewController.load(selectedReview); @@ -742,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; } files: detail.files || [], }); reviewFiles = detail.files || []; + selectedReviewHead = detail.head_sha || ''; draft = createReviewController.createDraft({ storage: localStorage, repository: item.repository, @@ -771,6 +778,7 @@ textarea { resize: vertical; min-height: 120px; } escapeHtml(review.state || 'commented') + (review.body ? '
' + escapeHtml(review.body) + '
' : '') + '
' ).join('') : '
No prior reviews.
'; qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author'); + qs('#submit-review').disabled = false; } catch (error) { if (selectedReview !== item) return; qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.'; @@ -785,6 +793,7 @@ textarea { resize: vertical; min-height: 120px; } progress = null; draft = null; reviewFiles = []; + selectedReviewHead = ''; if (reviewTrigger?.isConnected) reviewTrigger.focus(); } @@ -978,6 +987,37 @@ textarea { resize: vertical; min-height: 120px; } }); qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value)); qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value)); + qs('#submit-review').addEventListener('click', async () => { + if (!draft || !selectedReview || !selectedReviewHead) return; + const snapshot = draft.snapshot(); + const labels = { approve: 'Approve', request_changes: 'Request changes' }; + if (labels[snapshot.decision] && !window.confirm( + labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?' + )) return; + const button = qs('#submit-review'); + button.disabled = true; + qs('#review-submit-status').textContent = 'Submitting review…'; + try { + const result = await reviewController.submit(selectedReview, { + decision: snapshot.decision, + body: createReviewController.formatFeedback(snapshot, reviewFiles), + expected_head_sha: selectedReviewHead, + }); + draft.clear(); + progress?.clear(); + qs('#review-decision').value = 'comment'; + qs('#review-summary').value = ''; + document.querySelectorAll('.review-note').forEach(note => { note.value = ''; }); + if (progress) showReviewProgress(progress.snapshot()); + qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.'; + await load(); + button.focus(); + } catch (error) { + qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.'; + button.disabled = false; + button.focus(); + } + }); qs('#copy-review-feedback').addEventListener('click', async () => { if (!draft || !selectedReview || reviewHandoffPending) return; reviewHandoffPending = true; diff --git a/frontend/review-sheet.js b/frontend/review-sheet.js index ed56a65..f31c8e4 100644 --- a/frontend/review-sheet.js +++ b/frontend/review-sheet.js @@ -1,4 +1,6 @@ function createReviewController({ fetchJson }) { + let pendingSubmission = null; + function endpoint(item) { const [owner, repo] = String(item.repository || '').split('/'); if (!owner || !repo || !Number.isInteger(Number(item.number))) { @@ -12,7 +14,17 @@ function createReviewController({ fetchJson }) { return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } }); } - return { load }; + function submit(item, payload) { + if (pendingSubmission) return pendingSubmission; + pendingSubmission = fetchJson(endpoint(item), { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).finally(() => { pendingSubmission = null; }); + return pendingSubmission; + } + + return { load, submit }; } function diffLineClass(line) { @@ -90,7 +102,14 @@ function createProgress({ storage, repository, number, headSha, files }) { return snapshot(); } - return { snapshot, markReviewed }; + function clear() { + reviewed = []; + newHead = false; + try { storage.removeItem(key); } catch (error) { /* cleared in memory */ } + return snapshot(); + } + + return { snapshot, markReviewed, clear }; } function createDraft({ storage, repository, number, headSha, files }) { @@ -140,7 +159,13 @@ function createDraft({ storage, repository, number, headSha, files }) { return snapshot(); } - return { snapshot, setNote, setSummary, setDecision }; + function clear() { + draft = { notes: {}, summary: '', decision: 'comment' }; + try { storage.removeItem(key); } catch (error) { /* cleared in memory */ } + return snapshot(); + } + + return { snapshot, setNote, setSummary, setDecision, clear }; } function formatFeedback(draft, files) { diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 7d1f2e7..aa985c1 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -14,6 +14,10 @@ REVIEW_DIFF_MAX_LINES = 400 _client: httpx.AsyncClient | None = None +class StaleReviewError(ValueError): + """Raised before mutation when a pull request head changed during review.""" + + def _auth() -> dict[str, str]: headers: dict[str, str] = {"Accept": "application/json"} if GITEA_TOKEN: @@ -416,6 +420,43 @@ async def pull_review_detail(repository: str, number: int) -> dict: } +async def submit_pull_review( + repository: str, + number: int, + expected_head_sha: str, + decision: str, + body: str, +) -> dict: + base = f"repos/{repository}/pulls/{number}" + pull = await fetch(base) + head = pull.get("head") if isinstance(pull, dict) else None + current_sha = head.get("sha") if isinstance(head, dict) else None + if current_sha != expected_head_sha: + raise StaleReviewError("Pull request changed while it was being reviewed") + response = await _get_client().post( + f"/api/v1/{base}/reviews", + headers=_auth(), + json={ + "body": body, + "event": { + "comment": "COMMENT", + "approve": "APPROVE", + "request_changes": "REQUEST_CHANGES", + }[decision], + "commit_id": current_sha, + }, + ) + response.raise_for_status() + review = response.json() + if not isinstance(review, dict): + raise ValueError("Gitea review response was not an object") + return { + "id": review.get("id"), + "state": review.get("state") or "COMMENT", + "url": _safe_web_url(review.get("html_url")), + } + + async def activity_events(user: dict | None = None) -> list[dict]: if user is None: user = await current_user() diff --git a/src/main.py b/src/main.py index f1ae60c..9629c63 100644 --- a/src/main.py +++ b/src/main.py @@ -93,6 +93,19 @@ class NotificationReply(BaseModel): return value +class PullReviewSubmission(BaseModel): + decision: str + body: str = Field(max_length=10_000) + expected_head_sha: str + + @field_validator("decision") + @classmethod + def validate_decision(cls, value: str) -> str: + if value not in {"comment", "approve", "request_changes"}: + raise ValueError("unsupported review decision") + return value + + def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: user_model = User( id=user_data["id"], @@ -680,3 +693,40 @@ async def review_detail(owner: str, repo: str, number: int): {"error": "Pull request review details are temporarily unavailable"}, status_code=503, ) + + +@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) +async def submit_review( + submission: PullReviewSubmission, owner: str, repo: str, number: int +): + repository = f"{owner}/{repo}" + + async def submit_requested_review(): + if not await is_requested_review(repository, number): + raise HTTPException(status_code=404, detail="Review request not found") + return await gitea_proxy.submit_pull_review( + repository, + number, + submission.expected_head_sha, + submission.decision, + submission.body, + ) + + try: + result = await asyncio.wait_for( + submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS + ) + except HTTPException: + raise + except gitea_proxy.StaleReviewError: + return JSONResponse( + {"error": "New commits were pushed. Refresh the review before submitting."}, + status_code=409, + ) + except Exception: + return JSONResponse( + {"error": "The review could not be submitted. Your draft is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result, status_code=201) diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 3d3f9e2..51ecc22 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -974,7 +974,12 @@ async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff(): assert "review-files" in html assert "review-history" in html assert "open-review-gitea" in html - assert "reviewController.submit" not in html + assert 'id="submit-review"' in html + assert 'id="review-submit-status"' in html and 'aria-live="assertive"' in html + assert "reviewController.submit(selectedReview" in html + assert "window.confirm" in html + assert "expected_head_sha: selectedReviewHead" in html + assert "await load()" in html @pytest.mark.anyio @@ -1003,6 +1008,86 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback(): assert "handoffWindow.opener = null" in html +def test_review_controller_submits_once_while_request_is_in_flight(): + script = f""" +const createReviewController = require({json.dumps(str(REVIEW_SHEET))}); +let resolveRequest; +const calls = []; +const controller = createReviewController({{ fetchJson: (url, options) => {{ + calls.push({{url, options}}); + return new Promise(resolve => {{ resolveRequest = resolve; }}); +}} }}); +const item = {{repository:'stackchain/api', number:7}}; +const payload = {{decision:'approve', body:'Looks good.', expected_head_sha:'abc123'}}; +const first = controller.submit(item, payload); +const second = controller.submit(item, payload); +resolveRequest({{id:91, state:'APPROVED'}}); +Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{ + calls, results +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + payload = json.loads(result.stdout) + assert len(payload["calls"]) == 1 + assert payload["calls"][0] == { + "url": "api/v1/repos/stackchain/api/pulls/7/review", + "options": { + "method": "POST", + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + }, + "body": json.dumps( + { + "decision": "approve", + "body": "Looks good.", + "expected_head_sha": "abc123", + }, + separators=(",", ":"), + ), + }, + } + assert payload["results"] == [ + {"id": 91, "state": "APPROVED"}, + {"id": 91, "state": "APPROVED"}, + ] + + +def test_successful_review_can_clear_head_scoped_draft_and_progress(): + script = f""" +const reviewSheet = require({json.dumps(str(REVIEW_SHEET))}); +const values = new Map(); +const removed = []; +const storage = {{ + getItem: key => values.get(key) || null, + setItem: (key, value) => values.set(key, value), + removeItem: key => {{ removed.push(key); values.delete(key); }}, +}}; +const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'a.py'}}]}}; +const draft = reviewSheet.createDraft(options); +const progress = reviewSheet.createProgress(options); +draft.setSummary('Looks good.'); +progress.markReviewed('a.py'); +draft.clear(); +progress.clear(); +process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:progress.snapshot()}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + payload = json.loads(result.stdout) + assert payload["removed"] == [ + "stackchain.review-draft.v1:stackchain/api#7@abc123", + "stackchain.review-progress.v1:stackchain/api#7@abc123", + ] + assert payload["draft"] == {"notes": {}, "summary": "", "decision": "comment"} + assert payload["progress"]["reviewedCount"] == 0 + + @pytest.mark.anyio async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item(): html = await dashboard() diff --git a/tests/test_review_api.py b/tests/test_review_api.py index 8fc14d3..fd005f2 100644 --- a/tests/test_review_api.py +++ b/tests/test_review_api.py @@ -3,7 +3,7 @@ import asyncio import httpx import pytest -from src import main +from src import gitea_proxy, main @pytest.mark.anyio @@ -76,13 +76,202 @@ async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work @pytest.mark.anyio -async def test_public_review_endpoint_does_not_expose_service_token_mutations(): +async def test_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch): + calls = [] + + async def requested(repository, number): + return (repository, number) == ("stackchain/api", 7) + + async def submit(repository, number, expected_head_sha, decision, body): + calls.append((repository, number, expected_head_sha, decision, body)) + return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"} + + monkeypatch.setattr(main, "is_requested_review", requested) + monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, 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/review", - json={"action": "approve"}, + json={ + "decision": "approve", + "body": "Looks good on mobile.", + "expected_head_sha": "abc123", + }, ) - assert response.status_code == 405 + assert response.status_code == 201 + assert response.headers["cache-control"] == "no-store" + assert response.json() == { + "id": 91, + "state": "APPROVED", + "url": "https://forge.example/reviews/91", + } + assert calls == [ + ("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.") + ] + + +@pytest.mark.anyio +async def test_gitea_review_submission_checks_head_then_maps_decision_upstream(): + requests = [] + + async def handler(request): + requests.append(request) + if request.method == "GET": + return httpx.Response(200, json={"head": {"sha": "abc123"}}) + return httpx.Response( + 200, + json={ + "id": 91, + "state": "APPROVED", + "html_url": "https://forge.example/reviews/91", + }, + ) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.submit_pull_review( + "stackchain/api", 7, "abc123", "approve", "Looks good." + ) + finally: + await gitea_proxy.stop_client() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/repos/stackchain/api/pulls/7"), + ("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"), + ] + assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}' + assert result == { + "id": 91, + "state": "APPROVED", + "url": "https://forge.example/reviews/91", + } + + +@pytest.mark.anyio +async def test_gitea_review_submission_rejects_changed_head_without_posting(): + methods = [] + + async def handler(request): + methods.append(request.method) + return httpx.Response(200, json={"head": {"sha": "new456"}}) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(gitea_proxy.StaleReviewError): + await gitea_proxy.submit_pull_review( + "stackchain/api", 7, "abc123", "request_changes", "Please revise." + ) + finally: + await gitea_proxy.stop_client() + + assert methods == ["GET"] + + +@pytest.mark.anyio +async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch): + async def requested(repository, number): + return True + + async def submit(*args): + raise gitea_proxy.StaleReviewError("changed") + + monkeypatch.setattr(main, "is_requested_review", requested) + monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit) + 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/review", + json={ + "decision": "request_changes", + "body": "Please revise.", + "expected_head_sha": "abc123", + }, + ) + + assert response.status_code == 409 + assert response.json() == { + "error": "New commits were pushed. Refresh the review before submitting." + } + + +@pytest.mark.anyio +async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch): + called = False + + async def requested(repository, number): + nonlocal called + called = True + return True + + monkeypatch.setattr(main, "is_requested_review", requested) + 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/review", + json={ + "decision": "merge", + "body": "Ship it.", + "expected_head_sha": "abc123", + }, + ) + + assert response.status_code == 422 + assert called is False + + +@pytest.mark.anyio +async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch): + called = False + + async def requested(repository, number): + nonlocal called + called = True + return True + + monkeypatch.setattr(main, "is_requested_review", requested) + 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/review", + json={ + "decision": "comment", + "body": "x" * 10_001, + "expected_head_sha": "abc123", + }, + ) + + assert response.status_code == 422 + assert called is False + + +@pytest.mark.anyio +async def test_review_submission_deadline_cancels_request_check(monkeypatch): + cancelled = asyncio.Event() + + async def requested(repository, number): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + monkeypatch.setattr(main, "is_requested_review", requested) + monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await asyncio.wait_for( + client.post( + "/api/v1/repos/stackchain/api/pulls/7/review", + json={ + "decision": "comment", + "body": "Review note.", + "expected_head_sha": "abc123", + }, + ), + timeout=0.2, + ) + + assert response.status_code == 503 + assert response.headers["retry-after"] == "1" + assert cancelled.is_set()