feat: close authored pulls with recovery (Closes #1356)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-24 16:18:24 +00:00
parent fde3ba21ca
commit 77f22f3cd5
10 changed files with 442 additions and 6 deletions

View File

@ -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

View File

@ -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; }

View File

@ -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 });

View File

@ -1792,6 +1792,20 @@
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
<summary>Close pull request</summary>
<div>
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
</div>
</details>
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>

View File

@ -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 = '<option value="">Select a teammate</option>';
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;

View File

@ -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:

View File

@ -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)

View File

@ -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"]

View File

@ -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

View File

@ -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 = []