feat: retire closed issues during Following review (Closes #1305)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 12:12:55 +00:00
1 parent fdf38a72ac
commit c4bb7a9be1
20 files changed
+278 -46

No files matched your search

+97 -2
View File
@@ -901,7 +901,7 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action():
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_watch_is_available_for_every_open_issue_but_not_pulls_or_closed_issues():
def test_search_preview_watch_is_available_for_open_issues_and_closed_following_retirement():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
@@ -925,7 +925,10 @@ for (const detail of [
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
excluded.push(candidate.hidden);
}}
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded}}));
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
const retire = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
@@ -942,6 +945,98 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
"disabled": False,
}
assert payload["excluded"] == [True, True]
assert payload["closedFollowing"]["watching"] is True
assert payload["retire"] == {
"hidden": False,
"textContent": "Stop watching & next",
"disabled": False,
}
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let finish;
const states=[]; const retired=[]; const opened=[]; let calls=0;
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
const preview=createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
afterUnwatch:item => {{ retired.push(item.number); return next; }},
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
const first=preview.setWatching(false); const second=preview.setWatching(false);
await new Promise(resolve => setImmediate(resolve));
const before={{calls,retired:[...retired],opened:[...opened]}};
finish({{watching:false,following_synced:true}});
await Promise.all([first,second]);
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
}})().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["before"] == {"calls": 1, "retired": [], "opened": []}
assert payload["same"] is True
assert payload["retired"] == [42]
assert payload["opened"] == [9]
assert payload["last"]["detail"]["number"] == 9
def test_search_subscription_preview_hydrates_detail_before_watch_state():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
if (path.includes('/subscription')) return {{watching:true}};
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().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": [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
],
"detail": {
"repository": "stackchain/api", "number": 42, "kind": "issue",
"state": "open", "title": "Hydrated", "watching": True,
},
}
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().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["calls"] == [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
]
assert payload["detail"]["following"] is True
assert payload["detail"]["watching"] is True
def test_search_preview_shares_canonical_url_without_closing_the_preview():
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
+46
View File
@@ -249,3 +249,49 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
assert response.status_code == 200
assert response.json()["items"][0]["has_unseen_change"] is False
@pytest.mark.anyio
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
closed = {
"repository": "stackchain/api", "number": 42, "title": "Finished work",
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
store.set_watching("timmy", closed, True)
mutations = []
async def preview(repository, kind, number):
return {**closed, "kind": "issue"}
async def set_subscription(repository, number, watching):
mutations.append((repository, number, watching))
return {"watching": watching}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
watch = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
unwatch = await client.delete(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert watch.status_code == 404
assert unwatch.status_code == 200
assert unwatch.json() == {
"watching": False,
"following_synced": True,
"following_revision": 2,
"following_count": 0,
}
assert mutations == [("stackchain/api", 42, False)]
assert store.get("timmy")["items"] == []
+44 -1
View File
@@ -46,6 +46,7 @@ process.stdout.write(JSON.stringify(state));
"url": "https://forge.example/issue/42",
"has_unseen_change": False,
"kind": "issue",
"following": True,
}]
@@ -133,6 +134,7 @@ const feature = createFollowing({{
assert result["started"] is True
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
assert all(item["kind"] == "issue" for item in result["session"]["items"])
assert all(item["following"] is True for item in result["session"]["items"])
assert result["session"]["more"] is False
assert result["state"] == {"opened": [42], "acknowledged": [42]}
@@ -196,6 +198,46 @@ const feature = createFollowing({{
).stdout) == {"beforeFinish": [], "completed": ["following"]}
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{renders:[], counts:[], completed:[]}};
const items=[
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
onOpen:async () => {{}}, onAcknowledge:async () => {{}},
onReviewComplete:() => state.completed.push('following'),
}});
(async () => {{
await feature.load();
await feature.startReview();
const first=feature.session().items[0];
const next=feature.retire(first);
await feature.previewLoaded(next);
const final=feature.retire(next);
const handoff=feature.finishReview();
process.stdout.write(JSON.stringify({{
next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["next"] == 9
assert result["final"] is None
assert result["handoff"] is True
assert result["items"] == []
assert result["session"] is None
assert result["state"]["counts"][-1] == 0
assert result["state"]["completed"] == ["following"]
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
@@ -250,11 +292,12 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
assert "query('#review-following').addEventListener('click'" in following
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
assert "followingQueue.returnToFollowing()" in dashboard
assert "'Back to Following'" in dashboard
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v133" in service_worker
assert "stackchain-dashboard-shell-v134" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
+1 -1
View File
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+1 -1
View File
@@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v133" in service_worker
assert "stackchain-dashboard-shell-v134" in service_worker
@pytest.mark.anyio
+1 -1
View File
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+16 -16
View File
@@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1302,7 +1302,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/queue-today.js'" in source
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v133';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v134';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/today-sync.js'" in source