diff --git a/frontend/index.html b/frontend/index.html
index d27dc61..c7863e7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -189,6 +189,10 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
+.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
+.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
+.issue-handoff 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; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
@@ -519,6 +523,16 @@ textarea { resize: vertical; min-height: 120px; }
Expand planning controls to load milestones.
+
+ Hand off to teammate
+
+
+
+
+
+
Load teammates to transfer ownership.
+
+
@@ -1803,6 +1817,12 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
+ qs('#issue-handoff').open = false;
+ qs('#issue-handoff-recipient').innerHTML = '';
+ qs('#issue-handoff-recipient').disabled = true;
+ qs('#confirm-issue-handoff').disabled = true;
+ qs('#load-issue-handoff').disabled = false;
+ qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
@@ -3130,6 +3150,60 @@ textarea { resize: vertical; min-height: 120px; }
button.focus();
}
});
+ qs('#load-issue-handoff').addEventListener('click', async () => {
+ if (!selectedIssue) return;
+ const button = qs('#load-issue-handoff');
+ const select = qs('#issue-handoff-recipient');
+ button.disabled = true;
+ qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
+ try {
+ const candidates = await issueController.loadHandoffCandidates(selectedIssue);
+ select.textContent = '';
+ const placeholder = document.createElement('option');
+ placeholder.value = '';
+ placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
+ select.appendChild(placeholder);
+ candidates.forEach(candidate => {
+ const option = document.createElement('option');
+ option.value = candidate.login;
+ option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
+ select.appendChild(option);
+ });
+ select.disabled = !candidates.length;
+ qs('#confirm-issue-handoff').disabled = true;
+ qs('#issue-handoff-status').textContent = candidates.length ?
+ 'Choose who should own this issue next.' : 'No other eligible assignees were found.';
+ if (candidates.length) select.focus();
+ } catch (error) {
+ qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
+ button.disabled = false;
+ button.focus();
+ }
+ });
+ qs('#issue-handoff-recipient').addEventListener('change', event => {
+ qs('#confirm-issue-handoff').disabled = !event.target.value;
+ });
+ qs('#confirm-issue-handoff').addEventListener('click', async () => {
+ const recipient = qs('#issue-handoff-recipient').value;
+ if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
+ const handingOff = selectedIssue;
+ const button = qs('#confirm-issue-handoff');
+ button.disabled = true;
+ qs('#issue-handoff-status').textContent = 'Confirming handoff…';
+ try {
+ await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
+ lastContextSnapshot = buildMyWork.removeIssue(
+ lastContextSnapshot, handingOff.repository, handingOff.number
+ );
+ closeIssueSheet();
+ paintMyWork(lastContextSnapshot);
+ qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
+ } catch (error) {
+ qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
+ button.disabled = false;
+ button.focus();
+ }
+ });
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index cf1285c..b742e05 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
+ let handoffRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
+ loadHandoffCandidates(item) {
+ return fetchJson(issuePath(item) + '/handoff-candidates', {
+ headers: { Accept: 'application/json' },
+ });
+ },
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
+ handoff(item, recipient, currentLogin) {
+ if (handoffRequest) return handoffRequest;
+ handoffRequest = fetchJson(issuePath(item) + '/handoff', {
+ method: 'PATCH',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ recipient }),
+ }).then(result => {
+ if (
+ result?.number !== item.number ||
+ result?.recipient !== recipient ||
+ !Array.isArray(result.assignees) ||
+ !result.assignees.includes(recipient) ||
+ result.assignees.includes(currentLogin)
+ ) {
+ throw new Error('Issue handoff was not confirmed.');
+ }
+ return result;
+ }).finally(() => { handoffRequest = null; });
+ return handoffRequest;
+ },
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 28ef82a..349ff8a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v20';
+const CACHE = 'stackchain-dashboard-shell-v21';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index ff0a1ab..fbd3592 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
}
+async def issue_handoff_candidates(repository: str) -> list[dict]:
+ user, response = await asyncio.gather(
+ current_user(),
+ _get_client().get(
+ f"/api/v1/repos/{repository}/assignees", headers=_auth()
+ ),
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ raise ValueError("Gitea assignees response was not a list")
+ current_login = user.get("login") if isinstance(user, dict) else None
+ candidates = []
+ for item in payload:
+ if not isinstance(item, dict):
+ continue
+ login = item.get("login")
+ if not isinstance(login, str) or not login or login == current_login:
+ continue
+ full_name = item.get("full_name")
+ candidates.append({
+ "login": login,
+ "name": full_name if isinstance(full_name, str) and full_name else login,
+ })
+ return candidates
+
+
+async def handoff_assigned_issue(
+ repository: str, number: int, recipient: str
+) -> dict:
+ login, issue = await _current_login_and_target(
+ f"repos/{repository}/issues/{number}"
+ )
+ if (
+ issue.get("state") != "open"
+ or issue.get("pull_request") is not None
+ or not _login_in_users(login, issue.get("assignees"))
+ ):
+ raise IssueNotAvailableError("Issue is not assigned to the current user")
+
+ eligible = {
+ item["login"] for item in await issue_handoff_candidates(repository)
+ }
+ if recipient not in eligible:
+ raise IssueNotAvailableError("Handoff recipient is not eligible")
+ assignees_value = issue.get("assignees")
+ assignees = assignees_value if isinstance(assignees_value, list) else []
+ desired = [
+ item["login"] for item in assignees
+ if isinstance(item, dict)
+ and isinstance(item.get("login"), str)
+ and item["login"] != login
+ ]
+ if recipient not in desired:
+ desired.append(recipient)
+ response = await _get_client().patch(
+ f"/api/v1/repos/{repository}/issues/{number}",
+ headers=_auth(),
+ json={"assignees": desired},
+ )
+ response.raise_for_status()
+ confirmed = response.json()
+ confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
+ confirmed_assignees = [
+ item["login"] for item in confirmed_value
+ if isinstance(item, dict) and isinstance(item.get("login"), str)
+ ] if isinstance(confirmed_value, list) else []
+ if (
+ not isinstance(confirmed, dict)
+ or confirmed.get("number") != number
+ or login in confirmed_assignees
+ or recipient not in confirmed_assignees
+ or set(confirmed_assignees) != set(desired)
+ ):
+ raise ValueError("Gitea did not confirm issue handoff")
+ return {
+ "repository": repository,
+ "number": number,
+ "state": confirmed.get("state", "open"),
+ "assignees": confirmed_assignees,
+ "recipient": recipient,
+ }
+
+
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
diff --git a/src/main.py b/src/main.py
index 16acabd..500de7c 100644
--- a/src/main.py
+++ b/src/main.py
@@ -239,6 +239,12 @@ class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
+class IssueHandoff(BaseModel):
+ recipient: str = Field(
+ min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
+ )
+
+
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -1478,6 +1484,61 @@ async def release_assigned_issue(
return JSONResponse(result)
+@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
+async def issue_handoff_candidates(
+ owner: str, repo: str, number: int = PathParam(gt=0)
+) -> JSONResponse:
+ repository = f"{owner}/{repo}"
+
+ async def load_candidates():
+ if not await gitea_proxy.is_assigned_issue(repository, number):
+ raise HTTPException(status_code=404, detail="Assigned issue not found")
+ return await gitea_proxy.issue_handoff_candidates(repository)
+
+ try:
+ result = await asyncio.wait_for(
+ load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ return JSONResponse(
+ {"error": "Teammates could not be loaded. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
+@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
+async def handoff_assigned_issue(
+ handoff: IssueHandoff,
+ owner: str,
+ repo: str,
+ number: int = PathParam(gt=0),
+) -> JSONResponse:
+ repository = f"{owner}/{repo}"
+ try:
+ result = await asyncio.wait_for(
+ gitea_proxy.handoff_assigned_issue(
+ repository, number, handoff.recipient
+ ),
+ timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
+ )
+ except gitea_proxy.IssueNotAvailableError:
+ return JSONResponse(
+ {"error": "The issue or recipient changed. Reload before handing off."},
+ status_code=409,
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result)
+
+
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py
index 484ea51..4d97f85 100644
--- a/tests/test_issue_api.py
+++ b/tests/test_issue_api.py
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
assert result["available"] is False
+@pytest.mark.anyio
+async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
+ async def handler(request):
+ if request.url.path == "/api/v1/user":
+ return httpx.Response(200, json={"login": "timmy"})
+ assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
+ return httpx.Response(200, json=[
+ {"login": "timmy", "full_name": "Timmy"},
+ {"login": "alex", "full_name": "Alexander"},
+ {"login": "casey", "full_name": ""},
+ {"login": ""},
+ {"full_name": "Missing login"},
+ "malformed",
+ ])
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == [
+ {"login": "alex", "name": "Alexander"},
+ {"login": "casey", "name": "casey"},
+ ]
+
+
+@pytest.mark.anyio
+async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
+ requests = []
+
+ async def handler(request):
+ requests.append(request)
+ if request.url.path == "/api/v1/user":
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.url.path.endswith("/assignees"):
+ return httpx.Response(200, json=[
+ {"login": "alex", "full_name": "Alexander"},
+ {"login": "casey", "full_name": "Casey"},
+ ])
+ if request.method == "GET":
+ return httpx.Response(200, json={
+ "number": 17, "state": "open", "pull_request": None,
+ "assignees": [{"login": "timmy"}, {"login": "casey"}],
+ })
+ assert request.content == b'{"assignees":["casey","alex"]}'
+ return httpx.Response(200, json={
+ "number": 17, "state": "open",
+ "assignees": [{"login": "casey"}, {"login": "alex"}],
+ })
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == {
+ "repository": "stackchain/api", "number": 17, "state": "open",
+ "assignees": ["casey", "alex"], "recipient": "alex",
+ }
+ assert [(request.method, request.url.path) for request in requests] == [
+ ("GET", "/api/v1/user"),
+ ("GET", "/api/v1/repos/stackchain/api/issues/17"),
+ ("GET", "/api/v1/user"),
+ ("GET", "/api/v1/repos/stackchain/api/assignees"),
+ ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
+ ]
+
+
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
assert main._available_issue_snapshot_created_at is None
+@pytest.mark.anyio
+async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
+ calls = []
+
+ async def assigned(repository, number):
+ calls.append(("assigned", repository, number))
+ return True
+
+ async def candidates(repository):
+ calls.append(("candidates", repository))
+ return [{"login": "alex", "name": "Alexander"}]
+
+ async def handoff(repository, number, recipient):
+ calls.append(("handoff", repository, number, recipient))
+ return {
+ "repository": repository, "number": number, "state": "open",
+ "assignees": [recipient], "recipient": recipient,
+ }
+
+ monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
+ monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
+ monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ listed = await client.get(
+ "/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
+ )
+ transferred = await client.patch(
+ "/api/v1/repos/stackchain/api/issues/17/handoff",
+ json={"recipient": "alex"},
+ )
+
+ assert listed.status_code == 200
+ assert listed.json() == [{"login": "alex", "name": "Alexander"}]
+ assert transferred.status_code == 200
+ assert transferred.json()["recipient"] == "alex"
+ assert listed.headers["cache-control"] == "no-store"
+ assert transferred.headers["cache-control"] == "no-store"
+ assert calls == [
+ ("assigned", "stackchain/api", 17),
+ ("candidates", "stackchain/api"),
+ ("handoff", "stackchain/api", 17, "alex"),
+ ]
+
+
+@pytest.mark.anyio
+async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
+ async def handoff(_repository, _number, _recipient):
+ raise gitea_proxy.IssueNotAvailableError("stale")
+
+ monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.patch(
+ "/api/v1/repos/stackchain/api/issues/17/handoff",
+ json={"recipient": "alex"},
+ )
+
+ assert response.status_code == 409
+ assert response.json() == {
+ "error": "The issue or recipient changed. Reload before handing off."
+ }
+
+
+@pytest.mark.anyio
+async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
+ called = False
+
+ async def assigned(_repository, _number):
+ return False
+
+ async def candidates(_repository):
+ nonlocal called
+ called = True
+ return []
+
+ monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
+ monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get(
+ "/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
+ )
+
+ assert response.status_code == 404
+ assert called is False
+
+
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 16e0887..e1e69ae 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -470,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
assert result.stdout == "Issue release was not confirmed."
+def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+let calls = [];
+let finish;
+const controller = createIssueSheet({{
+ fetchJson: (url, options) => {{
+ calls.push({{url, options}});
+ return new Promise(resolve => {{ finish = resolve; }});
+ }},
+ storage: null,
+}});
+const item = {{repository:'stackchain/api', number:17}};
+const first = controller.handoff(item, 'alex', 'timmy');
+const duplicate = controller.handoff(item, 'alex', 'timmy');
+finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
+Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
+ calls, same: first === duplicate, results
+}})));
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output["same"] is True
+ assert len(output["calls"]) == 1
+ assert output["calls"][0]["url"].endswith("/issues/17/handoff")
+ assert output["calls"][0]["options"]["method"] == "PATCH"
+ assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
+ assert output["results"][0]["assignees"] == ["alex"]
+
+
+def test_issue_handoff_candidates_use_the_assigned_issue_route():
+ script = f"""
+const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+let call;
+const controller = createIssueSheet({{
+ fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
+ storage: null,
+}});
+controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
+ .then(result => process.stdout.write(JSON.stringify({{call, result}})));
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
+ assert output["call"]["options"]["headers"]["Accept"] == "application/json"
+ assert output["result"] == [{"login": "alex", "name": "Alexander"}]
+
+
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -576,6 +628,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
+@pytest.mark.anyio
+async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
+ html = await dashboard()
+
+ assert 'id="issue-handoff-recipient"' in html
+ assert 'id="load-issue-handoff"' in html
+ assert 'id="confirm-issue-handoff"' in html
+ assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
+ assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
+ assert "issueController.loadHandoffCandidates(selectedIssue)" in html
+ assert "issueController.handoff(selectedIssue, recipient" in html
+
+
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 8633001..b26037e 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
-def test_today_queue_ships_in_a_new_shell_cache():
+def test_issue_handoff_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v20" in source
+ assert "stackchain-dashboard-shell-v21" in source
assert "BASE + 'static/today-work.js'" in source