Recover closed authored pull requests from mobile Search #1359

Merged
rockachopa merged 1 commits from timmy/1358-reopen-closed-authored-pull-search into main 2026-08-24 17:22:36 +00:00
8 changed files with 410 additions and 15 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 tests/e2e/test_mobile_close_authored_pull_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 tests/e2e/test_mobile_search_authored_pull_recovery_release.py
release-candidate:
runs-on: ubuntu-latest

View File

@ -5746,11 +5746,7 @@
planButton.hidden = !searchWeekPlan.eligible(detail);
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
planButton.disabled = searchWeekPlan.pending();
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewStart(detail, state, startButton);
renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = {
@ -5772,9 +5768,7 @@
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
fetchReview:searchSubscription.review,
mutate:(detail,action)=>fetchReviewJson(
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
),
mutate:searchPreviewMutation(fetchReviewJson),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
@ -5868,6 +5862,14 @@
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const recoverSearchPull = createSearchAuthoredPullRecovery({
confirm,
reopen:detail => searchPreview.reopenPull(detail),
refresh:load,
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
open:item => openRoutedWork(item, qs('#start-search-result')),
unavailable:message => qs('#search-preview-status').textContent = message,
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@ -6216,6 +6218,14 @@
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.authored_pull_reopenable) {
try {
await recoverSearchPull(detail);
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
return;
}
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';

View File

@ -36,6 +36,16 @@
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewMutation = fetchJson => (detail, action) => {
if (action === 'reopen-pull') {
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
return fetchJson(path, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({expected_head_sha:detail.head_sha}),
});
}
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
};
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => {
@ -102,6 +112,30 @@
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.renderSearchPreviewStart = (detail, state, button) => {
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
detail.authored_pull_reopenable === true;
const issue = detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
button.hidden = !(detail.reviewable || pull || issue);
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
button.disabled = state.status === 'claiming' || state.status === 'reopening';
};
root.createSearchAuthoredPullRecovery = o =>
async d => {
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
await o.reopen(d);
await o.refresh();
const item = o.find(d);
if (!item) {
o.unavailable('Reopened. Refresh My Work.');
return 'unavailable';
}
o.open(item);
return 'opened';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
@ -548,6 +582,9 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
reopenPull(detail) {
return run('reopen-pull', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {

View File

@ -590,11 +590,13 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
reviewable = False
if actual_kind == "pull" and state == "open" and login:
pull = None
if actual_kind == "pull" and login:
try:
pull = await fetch(f"repos/{repository}/pulls/{number}")
except Exception:
pull = None
if actual_kind == "pull" and state == "open" and login:
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
if not isinstance(requested_reviewers, list):
requested_reviewers = []
@ -602,6 +604,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
isinstance(reviewer, dict) and reviewer.get("login") == login
for reviewer in requested_reviewers
)
pull_author = pull.get("user") if isinstance(pull, dict) and isinstance(pull.get("user"), dict) else {}
pull_head = pull.get("head") if isinstance(pull, dict) and isinstance(pull.get("head"), dict) else {}
head_sha = pull_head.get("sha") if isinstance(pull_head.get("sha"), str) else ""
authored_pull_reopenable = bool(
actual_kind == "pull"
and state == "closed"
and isinstance(pull, dict)
and pull.get("state") == "closed"
and pull.get("merged") is not True
and login
and pull_author.get("login", "").casefold() == login.casefold()
and head_sha
)
return {
"kind": actual_kind,
"repository": repository,
@ -623,6 +638,10 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"assigned_to_me": bool(login and login in assignee_names),
"reviewable": reviewable,
"commentable": bool(login),
**({
"authored_pull_reopenable": authored_pull_reopenable,
"head_sha": head_sha,
} if actual_kind == "pull" else {}),
}

View File

@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("rendered authored-pull recovery checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_mobile_search_reopens_closed_authored_pull_into_my_work(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
page.evaluate("""() => {
const detail = {
repository:'stackchain/web', number:9, kind:'pull', state:'closed',
authored_pull_reopenable:true, head_sha:'abc1234',
};
const button = document.querySelector('#start-search-result');
globalThis.renderSearchPreviewStart(detail, {status:'ready'}, button);
globalThis.recoveryMetrics = {calls:[], history:['search']};
const mutate = globalThis.searchPreviewMutation((path, options) => {
globalThis.recoveryMetrics.calls.push({path, body:JSON.parse(options.body)});
return Promise.resolve({...detail, state:'open'});
});
const preview = globalThis.createSearchPreview({
fetchJson:async () => detail,
mutate,
onState:() => {},
});
preview.open(detail);
const recover = globalThis.createSearchAuthoredPullRecovery({
confirm:message => window.confirm(message),
reopen:target => preview.reopenPull(target),
refresh:async () => { globalThis.recoveryMetrics.refreshed = true; },
find:() => ({repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}),
open:item => globalThis.recoveryMetrics.history.push(item.kind),
unavailable:message => { globalThis.recoveryMetrics.unavailable = message; },
});
button.addEventListener('click', () => recover(detail));
}""")
action = page.locator("#start-search-result")
expect(action).to_be_visible()
expect(action).to_have_text("Reopen in My Work")
bounds = action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.once("dialog", lambda dialog: dialog.accept())
action.click()
page.wait_for_function("globalThis.recoveryMetrics.history.length === 2")
metrics = page.evaluate("globalThis.recoveryMetrics")
assert metrics["calls"] == [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"body": {"expected_head_sha": "abc1234"},
}]
assert metrics["refreshed"] is True
assert metrics["history"] == ["search", "pull"]
assert "unavailable" not in metrics
browser.close()

View File

@ -107,3 +107,10 @@ def test_browser_job_gates_authored_pull_close_recovery_journey():
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser
def test_browser_job_gates_search_authored_pull_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_search_authored_pull_recovery_release.py" in browser

View File

@ -827,6 +827,143 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_closed_authored_pull_reopen_sends_head_guard_to_pull_api():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls = [];
const mutate = globalThis.searchPreviewMutation(async (path, options) => {{
calls.push({{path, options}});
return {{repository:'stackchain/web',number:9,state:'open',head_sha:'abc1234'}};
}});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const result = await mutate(detail, 'reopen-pull');
process.stdout.write(JSON.stringify({{calls,result}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"options": {
"method": "PATCH",
"headers": {"Content-Type": "application/json"},
"body": '{"expected_head_sha":"abc1234"}',
},
}],
"result": {
"repository": "stackchain/web",
"number": 9,
"state": "open",
"head_sha": "abc1234",
},
}
def test_search_preview_closed_authored_pull_exposes_reopen_in_my_work_action():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const ready = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'ready'}}, ready);
const pending = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'reopening'}}, pending);
process.stdout.write(JSON.stringify({{ready,pending}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"ready": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": False,
},
"pending": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": True,
},
}
def test_search_preview_closed_authored_pull_reopen_is_single_flight():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
let resolveMutation;
const states = [];
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const preview = createSearchPreview({{
fetchJson:async () => detail,
mutate:(_detail, action) => {{
if (action !== 'reopen-pull') throw new Error('wrong action');
calls += 1;
return new Promise(resolve => {{ resolveMutation = resolve; }});
}},
onState:state => states.push(state),
}});
await preview.open(detail);
const first = preview.reopenPull(detail);
const second = preview.reopenPull(detail);
if (first !== second || calls !== 1) throw new Error('pull recovery was not single-flight');
resolveMutation({{...detail,state:'open'}});
await first;
process.stdout.write(JSON.stringify({{calls,status:states.at(-1).status}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"calls": 1, "status": "reopened"}
def test_authored_pull_recovery_confirms_refreshes_and_opens_above_search_history():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const events = [];
const history = ['search'];
const detail = {{repository:'stackchain/web',number:9,kind:'pull',head_sha:'abc1234'}};
const item = {{repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}};
const recover = globalThis.createSearchAuthoredPullRecovery({{
confirm:message => {{ events.push(['confirm',message]); return true; }},
reopen:target => {{ events.push(['reopen',target.head_sha]); return Promise.resolve({{...target,state:'open'}}); }},
refresh:() => {{ events.push(['refresh']); return Promise.resolve(); }},
find:target => target.number === 9 ? item : null,
open:opened => {{ events.push(['open',opened.key]); history.push('pull'); }},
unavailable:message => events.push(['unavailable',message]),
}});
const outcome = await recover(detail);
process.stdout.write(JSON.stringify({{events,history,outcome}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["events"] == [
["confirm", "Reopen stackchain/web #9?"],
["reopen", "abc1234"],
["refresh"],
["open", "stackchain/web#9"],
]
assert payload["history"] == ["search", "pull"]
assert payload["outcome"] == "opened"
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@ -1159,9 +1296,11 @@ def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claim
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert "startButton.hidden = !(detail.reviewable" in html
assert "detail.reviewable ? 'Review now'" in html
assert "renderSearchPreviewStart(detail, state, startButton)" in html
assert "button.hidden = !(detail.reviewable" in preview
assert "detail.reviewable ? 'Review now'" in preview
handler = html.split(
"qs('#start-search-result').addEventListener('click'", 1
)[1].split("qs('#close-whiteboard')", 1)[0]
@ -1176,9 +1315,10 @@ def test_requested_pull_preview_opens_native_review_without_assignment_mutation(
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert 'id="start-search-result"' in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in preview
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
@ -1332,9 +1472,10 @@ process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
preview = SEARCH_PREVIEW.read_text()
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate:(detail,action)=>fetchReviewJson(" in html
assert "detail.reopenable ? 'Reopen & resume'" in preview
assert "mutate:searchPreviewMutation(fetchReviewJson)" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]

View File

@ -875,6 +875,108 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_offers_closed_authored_unmerged_pull_recovery_with_head_guard():
requested_paths = []
async def handler(request):
requested_paths.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
"requested_reviewers": [],
})
return httpx.Response(200, json={
"number": 9,
"title": "Recover mobile flow",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is True
assert preview["head_sha"] == "abc1234"
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_does_not_offer_closed_pull_recovery_to_non_author():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "alexander"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Foreign pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "alexander"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_does_not_offer_recovery_for_merged_pull():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": True,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Merged pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": True},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):