From 77f22f3cd536c140aaf11cc117fabd64fb7f42a1 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 24 Aug 2026 16:18:24 +0000 Subject: [PATCH] feat: close authored pulls with recovery (Closes #1356) --- .gitea/workflows/ci.yml | 2 +- frontend/dashboard.css | 9 +- frontend/dashboard.js | 3 +- frontend/index.html | 14 ++ frontend/pull-sheet.js | 85 +++++++++++- src/gitea_proxy.py | 66 ++++++++++ src/main.py | 52 ++++++++ ...test_mobile_close_authored_pull_release.py | 87 +++++++++++++ tests/test_ci_workflow.py | 7 + tests/test_pull_api.py | 123 ++++++++++++++++++ 10 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/test_mobile_close_authored_pull_release.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2b69086..8c8151e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py tests/e2e/test_mobile_close_authored_pull_release.py release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 15fb7b9..b820ffa 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -966,8 +966,13 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; } .detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; } .issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } -.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; } -.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; } +.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; } +.pull-lifecycle { border-color:#b45353; } +.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; } +.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; } +.pull-close-receipt h3 { margin-top:0; } +.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; } .issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); } .issue-handoff select, .issue-handoff button { min-height:44px; } .pull-ownership select, .pull-ownership button { min-height:44px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4b10202..923c3fb 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1280,7 +1280,7 @@ trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.', }, () => { if (!pullController) { - pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView }); createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => { const continuing = workSession.checkpointed(item); lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item); @@ -1289,7 +1289,6 @@ paintMyWork(lastContextSnapshot); return false; }, ()=>selectedPullDetail, ()=>confirmedOwnerLogin); - } attachReleaseReceipt(); if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); diff --git a/frontend/index.html b/frontend/index.html index bd60580..6a75504 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1792,6 +1792,20 @@
Load teammates to transfer ownership.
+ +
diff --git a/frontend/pull-sheet.js b/frontend/pull-sheet.js index e2ecae1..eddff29 100644 --- a/frontend/pull-sheet.js +++ b/frontend/pull-sheet.js @@ -198,6 +198,7 @@ function applyOwnershipCapabilities(doc, capabilities = null) { function resetOwnershipControls(doc, item, checkpointed, capabilities = null) { const qs = selector => doc.querySelector(selector); const assigned = applyOwnershipCapabilities(doc, capabilities); + resetLifecycleControls(doc, null); qs('#pull-ownership').open = false; qs('#pull-handoff-recipient').innerHTML = ''; qs('#pull-handoff-recipient').disabled = true; @@ -238,6 +239,7 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL controller.setReviewDetail = detail => { resetReviewRequestControls(doc, detail); applyOwnershipCapabilities(doc, detail?.capabilities); + resetLifecycleControls(doc, detail?.saved_at ? null : detail); controller.edit?.setDetail(detail, Boolean(detail?.saved_at)); const publishable = detail?.state === 'open' && detail?.draft === true && detail?.author === getLogin?.() && detail?.head_sha; @@ -577,6 +579,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin); bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin); bindCheckRecovery(doc, controller, getSelected, getDetail); + bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result)); controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin); const qs = selector => doc.querySelector(selector); const load = qs('#load-pull-handoff'); @@ -642,7 +645,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, }); } -function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { +function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let mergeRequest = null; let checkRequest = null; @@ -652,6 +655,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT let reviewRequestMutation = null; let reviewCancelMutation = null; let readyMutation = null; + let lifecycleMutation = null; let editRequest = null; let feedbackRequest = null; const reviewRequests = new Map(); @@ -680,6 +684,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT } return { + onState, load(item) { return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } }); }, @@ -791,6 +796,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT }).finally(() => { readyMutation = null; }); return readyMutation; }, + transition(item, expectedHeadSha, action) { + if (lifecycleMutation) return lifecycleMutation; + lifecycleMutation = fetchJson(pathFor(item) + '/' + action, { + method:'PATCH', + headers:{ Accept:'application/json', 'Content-Type':'application/json' }, + body:JSON.stringify({ expected_head_sha:expectedHeadSha }), + }).then(result => { + const expectedState = action === 'close' ? 'closed' : 'open'; + if (result?.number !== item.number || result?.head_sha !== expectedHeadSha || + result?.state !== expectedState || result?.merged === true) { + throw new Error('Pull request state change was not confirmed.'); + } + return result; + }).finally(() => { lifecycleMutation = null; }); + return lifecycleMutation; + }, loadEditDraft(item) { try { const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null'); @@ -957,6 +978,66 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT }; } +function resetLifecycleControls(doc, detail) { + const authored = detail?.capabilities?.authored === true; + const lifecycle = doc.querySelector('#pull-lifecycle'); + if (!lifecycle) return; + lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true; + doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true; + doc.querySelector('#pull-close-receipt').hidden = true; + doc.querySelector('#pull-lifecycle-status').textContent = ''; + doc.querySelector('#pull-reopen-status').textContent = ''; +} + +function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) { + const close = doc.querySelector('#close-authored-pull'); + const reopen = doc.querySelector('#reopen-authored-pull'); + close.addEventListener('click', async () => { + const item = selectedPull(); + const detail = selectedDetail(); + if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true || + !globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return; + close.disabled = true; + doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…'; + try { + const result = await controller.transition(item, detail.head_sha, 'close'); + Object.assign(detail, result); + applyResult(result); + doc.querySelector('#pull-lifecycle').hidden = true; + doc.querySelector('#pull-close-receipt').hidden = false; + doc.querySelector('#pull-lifecycle-status').textContent = ''; + doc.querySelector('#pull-close-receipt-title').focus(); + } catch (error) { + doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.'; + close.disabled = false; + close.focus(); + } + }); + reopen.addEventListener('click', async () => { + const item = selectedPull(); + const detail = selectedDetail(); + if (!item || !detail?.head_sha) return; + reopen.disabled = true; + doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…'; + try { + const result = await controller.transition(item, detail.head_sha, 'reopen'); + Object.assign(detail, result); + applyResult(result); + doc.querySelector('#pull-close-receipt').hidden = true; + doc.querySelector('#pull-lifecycle').hidden = false; + close.hidden = false; + close.disabled = false; + doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.'; + doc.querySelector('#pull-reopen-status').textContent = ''; + close.focus(); + } catch (error) { + doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.'; + reopen.disabled = false; + reopen.focus(); + } + }); +} + function bindCheckRecovery( doc, controller, selectedPull, selectedDetail, confirmRetry = message => globalThis.confirm(message) @@ -1066,6 +1147,8 @@ createPullSheet.renderFile = renderFile; createPullSheet.focusNextUnreviewed = focusNextUnreviewed; createPullSheet.removeFromSnapshot = removeFromSnapshot; createPullSheet.sameTarget = sameTarget; +createPullSheet.bindLifecycleControls = bindLifecycleControls; +createPullSheet.resetLifecycleControls = resetLifecycleControls; createPullSheet.ownershipSelectors = ownershipSelectors; createPullSheet.renderHandoffCandidates = renderHandoffCandidates; createPullSheet.ownershipExitMessage = ownershipExitMessage; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 090fa5d..0574d42 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2004,6 +2004,72 @@ def _login_can_manage_pull(login: str, pull: dict) -> bool: ) +async def _transition_authored_pull( + repository: str, + number: int, + expected_head_sha: str, + *, + from_state: str, + to_state: str, +) -> dict: + login, pull = await _current_login_and_target( + f"repos/{repository}/pulls/{number}" + ) + author = pull.get("user") if isinstance(pull.get("user"), dict) else {} + head = pull.get("head") if isinstance(pull.get("head"), dict) else {} + if ( + pull.get("state") != from_state + or pull.get("merged") is True + or author.get("login", "").casefold() != login.casefold() + or head.get("sha") != expected_head_sha + ): + raise IssueNotAvailableError("Pull request state changed") + response = await _get_client().patch( + f"/api/v1/repos/{repository}/pulls/{number}", + headers=_auth(), + json={"state": to_state}, + ) + 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() + confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {} + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or confirmed.get("state") != to_state + or confirmed.get("merged") is True + or confirmed_head.get("sha") != expected_head_sha + ): + raise ValueError("Gitea did not confirm the pull request state") + return { + "repository": repository, + "number": number, + "title": confirmed.get("title", ""), + "head_sha": expected_head_sha, + "state": to_state, + "merged": False, + } + + +async def close_authored_pull( + repository: str, number: int, expected_head_sha: str +) -> dict: + return await _transition_authored_pull( + repository, number, expected_head_sha, from_state="open", to_state="closed" + ) + + +async def reopen_authored_pull( + repository: str, number: int, expected_head_sha: str +) -> dict: + return await _transition_authored_pull( + repository, number, expected_head_sha, from_state="closed", to_state="open" + ) + + async def request_assigned_pull_review( repository: str, number: int, reviewer: str, expected_head_sha: str ) -> dict: diff --git a/src/main.py b/src/main.py index 5cbc12f..41bbbdf 100644 --- a/src/main.py +++ b/src/main.py @@ -6669,6 +6669,58 @@ async def publish_authored_assigned_pull( return JSONResponse(result) +async def _transition_authored_pull( + update: PullReadyRequest, + owner: str, + repo: str, + number: int, + transition, + action: str, +): + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + transition(repository, number, update.expected_head_sha), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + return JSONResponse( + {"error": f"The pull request changed. Reload before {action}."}, + status_code=409, + ) + except Exception: + return JSONResponse( + {"error": f"The pull request could not be {action}. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + +@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/close") +async def close_authored_pull( + update: PullReadyRequest, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + return await _transition_authored_pull( + update, owner, repo, number, gitea_proxy.close_authored_pull, "closed" + ) + + +@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/reopen") +async def reopen_authored_pull( + update: PullReadyRequest, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + return await _transition_authored_pull( + update, owner, repo, number, gitea_proxy.reopen_authored_pull, "reopened" + ) + + @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_close_authored_pull_release.py b/tests/e2e/test_mobile_close_authored_pull_release.py new file mode 100644 index 0000000..9301ad5 --- /dev/null +++ b/tests/e2e/test_mobile_close_authored_pull_release.py @@ -0,0 +1,87 @@ +import os +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[2] +pytestmark = pytest.mark.skipif( + os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1", + reason="release browser journey is opt-in", +) + + +@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)]) +def test_mobile_author_closes_obsolete_pull_then_recovers_it(viewport): + playwright = pytest.importorskip("playwright.sync_api") + html = (ROOT / "frontend" / "index.html").read_text() + + 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.on("dialog", lambda dialog: dialog.accept()) + 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( + """() => { + window.lifecycleCalls = []; + window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'}; + window.lifecycleDetail = { + number:7, title:'Obsolete experiment', author:'timmy', state:'open', + merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false}, + }; + const controller = createPullSheet({ + storage:null, + fetchJson:async (path, options) => { + lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)}); + const state = path.endsWith('/close') ? 'closed' : 'open'; + return {repository:'stackchain/api', number:7, title:'Obsolete experiment', + head_sha:'abc1234', state, merged:false}; + }, + }); + createPullSheet.bindLifecycleControls( + document, controller, () => lifecycleItem, () => lifecycleDetail, + result => { lifecycleDetail = {...lifecycleDetail, ...result}; } + ); + createPullSheet.resetLifecycleControls(document, lifecycleDetail); + document.querySelector('#pull-sheet').classList.add('open'); + }""" + ) + + page.locator("#pull-lifecycle summary").click() + close = page.get_by_role("button", name="Close pull request") + before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})") + close.click() + page.get_by_text("Pull request closed.").wait_for() + reopen = page.get_by_role("button", name="Reopen pull request") + receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})") + reopen.click() + page.get_by_text("Pull request reopened.").wait_for() + metrics = page.evaluate( + """() => ({ + calls:lifecycleCalls, + state:lifecycleDetail.state, + closeHidden:document.querySelector('#close-authored-pull').hidden, + receiptHidden:document.querySelector('#pull-close-receipt').hidden, + focused:document.activeElement === document.querySelector('#close-authored-pull'), + scrollWidth:document.documentElement.scrollWidth, + clientWidth:document.documentElement.clientWidth, + })""" + ) + browser.close() + + assert before["hidden"] is False + assert before["height"] >= 44 + assert receipt["height"] >= 44 + assert receipt["hidden"] is False + assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"] + assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"]) + assert metrics["state"] == "open" + assert metrics["closeHidden"] is False + assert metrics["receiptHidden"] is True + assert metrics["focused"] is True + assert metrics["scrollWidth"] <= metrics["clientWidth"] diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 732e637..6bb11d8 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -100,3 +100,10 @@ def test_browser_job_gates_authored_pull_queue_journey(): browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")] assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser + + +def test_browser_job_gates_authored_pull_close_recovery_journey(): + text = WORKFLOW.read_text() + browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")] + + assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py index 38b2f69..743f012 100644 --- a/tests/test_pull_api.py +++ b/tests/test_pull_api.py @@ -8,6 +8,129 @@ from src import gitea_proxy, main from src.security_event_store import SecurityEventStoreError +@pytest.mark.anyio +async def test_gitea_closes_authored_pull_at_expected_head_and_verifies_state(): + requests = [] + pull = { + "number": 7, + "title": "Obsolete experiment", + "state": "open", + "merged": False, + "user": {"login": "alex"}, + "head": {"sha": "abc1234"}, + } + + async def handler(request): + requests.append((request.method, request.url.path, request.content)) + 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" and request.method == "GET": + return httpx.Response(200, json=pull) + if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH": + assert json.loads(request.content) == {"state": "closed"} + pull["state"] = "closed" + return httpx.Response(200, json=pull) + raise AssertionError(f"unexpected request: {request.method} {request.url.path}") + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.close_authored_pull("stackchain/api", 7, "abc1234") + finally: + await gitea_proxy.stop_client() + + assert result == { + "repository": "stackchain/api", + "number": 7, + "title": "Obsolete experiment", + "head_sha": "abc1234", + "state": "closed", + "merged": False, + } + assert [item[:2] for item in requests] == [ + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/pulls/7"), + ("PATCH", "/api/v1/repos/stackchain/api/pulls/7"), + ("GET", "/api/v1/repos/stackchain/api/pulls/7"), + ] + + +@pytest.mark.anyio +async def test_gitea_reopens_authored_unmerged_pull_at_expected_head(): + pull = { + "number": 7, + "title": "Obsolete experiment", + "state": "closed", + "merged": False, + "user": {"login": "alex"}, + "head": {"sha": "abc1234"}, + } + + async def handler(request): + 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" and request.method == "GET": + return httpx.Response(200, json=pull) + if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH": + assert json.loads(request.content) == {"state": "open"} + pull["state"] = "open" + return httpx.Response(200, json=pull) + raise AssertionError(f"unexpected request: {request.method} {request.url.path}") + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.reopen_authored_pull("stackchain/api", 7, "abc1234") + finally: + await gitea_proxy.stop_client() + + assert result["state"] == "open" + assert result["head_sha"] == "abc1234" + + +@pytest.mark.anyio +async def test_author_can_close_then_reopen_pull_through_api(monkeypatch): + calls = [] + + async def transition(repository, number, expected_head_sha, state): + calls.append((state, repository, number, expected_head_sha)) + return { + "repository": repository, + "number": number, + "head_sha": expected_head_sha, + "state": state, + "merged": False, + } + + monkeypatch.setattr( + main.gitea_proxy, + "close_authored_pull", + lambda repository, number, head: transition(repository, number, head, "closed"), + ) + monkeypatch.setattr( + main.gitea_proxy, + "reopen_authored_pull", + lambda repository, number, head: transition(repository, number, head, "open"), + ) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + closed = await client.patch( + "/api/v1/repos/stackchain/api/pulls/7/close", + json={"expected_head_sha": "abc1234"}, + ) + reopened = await client.patch( + "/api/v1/repos/stackchain/api/pulls/7/reopen", + json={"expected_head_sha": "abc1234"}, + ) + + assert closed.status_code == 200 + assert closed.json()["state"] == "closed" + assert reopened.status_code == 200 + assert reopened.json()["state"] == "open" + assert calls == [ + ("closed", "stackchain/api", 7, "abc1234"), + ("open", "stackchain/api", 7, "abc1234"), + ] + + @pytest.mark.anyio async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch): calls = [] -- 2.43.0