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.
+
+ Close pull request
+
+
Close an obsolete pull request without deleting its branch or discussion.
+
+
+
+
+
+ Pull request closed.
+ This pull request was removed from active work. Reopen it if the close was accidental.
+
+
+
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 = []