diff --git a/README.md b/README.md index d6162cb..4703f1a 100644 --- a/README.md +++ b/README.md @@ -302,12 +302,14 @@ successful response is JSON containing to search commands plus issues and pull requests across every repository visible to the configured Gitea token. Remote search starts after two characters, is debounced, and keeps local commands usable if Gitea search is unavailable. Selecting a remote -result opens a mobile-safe, read-only preview without discarding the search query; -open unassigned issues can be claimed in place and handed into My Work after Gitea -confirms the assignment. Closed work and pull requests remain read-only with a safe -canonical Gitea link. The bounded APIs are available at -`GET /api/v1/search?q=&limit=<1-25>` and -`GET /api/v1/repos///issues//preview?kind=issue|pull`. Never commit the token +result opens a mobile-safe preview without discarding the search query. Open +unassigned issues can be claimed in place and handed into My Work after Gitea +confirms the assignment. A closed issue can be reopened, self-assigned, added to +Today, and resumed through the same capacity-guarded flow; pull requests remain +read-only with a safe canonical Gitea link. The bounded APIs are available at +`GET /api/v1/search?q=&limit=<1-25>`, +`GET /api/v1/repos///issues//preview?kind=issue|pull`, and +`PATCH /api/v1/repos///issues//reopen`. Never commit the token or place it in a tracked configuration file. For service monitoring, GET `/healthz` is a liveness check that confirms the diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 0f1261e..c94a21a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3551,6 +3551,7 @@ const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); const startButton = qs('#start-search-result'); + qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' ? 'Back to blockers' : 'Back to search'; if (state.status === 'closed') { @@ -3562,6 +3563,7 @@ claimButton.disabled = false; startButton.hidden = true; startButton.disabled = false; + if (state.status === 'loading') { searchPreviewDetail = null; qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number; @@ -3591,44 +3593,51 @@ claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.disabled = state.status === 'claiming'; - startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' && - (detail.claimable || detail.assigned_to_me)); - startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'; - startButton.disabled = state.status === 'claiming'; - status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' : + startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable || + (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))); + startButton.textContent = detail.reopenable ? 'Reopen & resume' : + (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); + startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; + status.textContent = state.status === 'reopening' ? 'Reopening…' : + (state.status === 'claiming' ? 'Assigning this issue to you…' : (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : (detail.claimable ? 'This issue is open and unassigned.' : - (detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.'))); + (detail.assigned_to_me ? 'This issue is already in My Work.' : + (detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.'))))); } const searchPreview = createSearchPreview({ fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), - claim: detail => fetchReviewJson( + mutate: (detail, action) => fetchReviewJson( 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + - '/issues/' + encodeURIComponent(detail.number) + '/claim', + '/issues/' + encodeURIComponent(detail.number) + '/' + action, { method:'PATCH', headers:{ Accept:'application/json' } } ), onState: renderSearchPreview, }); - const searchAssignAndStart = createAssignAndStart({ - available: createAndStart.available, - claim: detail => searchPreview.claim(detail), - start: confirmed => { - const claimed = acceptClaimedIssue(confirmed); - taskOverlayHistory.leave(); - refreshMyWorkView(); - return createAndStart.complete(claimed); - }, - recover: confirmed => { - const claimed = acceptClaimedIssue(confirmed); - taskOverlayHistory.leave(); - refreshMyWorkView(); - openRoutedWork(claimed, qs('#open-palette')); - }, - announce: message => { - qs('#search-preview-status').textContent = message; - qs('#my-work-action-status').textContent = message; - }, - }); + function createSearchStart(claim) { + return createAssignAndStart({ + available: createAndStart.available, + claim, + start: confirmed => { + const item = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + return createAndStart.complete(item); + }, + recover: confirmed => { + const item = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + openRoutedWork(item, qs('#open-palette')); + }, + announce: message => { + qs('#search-preview-status').textContent = message; + qs('#my-work-action-status').textContent = message; + }, + }); + } + const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail)); + const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail)); const mobileSearchViewport = createMobileSearchViewport({ palette: qs('#cmd-palette'), results: qs('#cmd-results'), @@ -3848,12 +3857,14 @@ }); qs('#start-search-result').addEventListener('click', async () => { const detail = searchPreviewDetail; - if (!detail || detail.kind !== 'issue' || detail.state !== 'open' || - (!detail.claimable && !detail.assigned_to_me)) return; + if (!detail || detail.kind !== 'issue') return; try { - await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me }); + if (detail.reopenable) await searchReopenAndStart.run(detail); + else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) { + await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me }); + } } catch (error) { - qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.'; + qs('#search-preview-status').textContent = error.message + ' Retry.'; } }); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); diff --git a/frontend/index.html b/frontend/index.html index 89c10ef..c3fd1d3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -272,6 +272,7 @@
+
Open in Gitea diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 1402295..b362dab 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -3,10 +3,23 @@ if (typeof module === 'object' && module.exports) module.exports = createSearchPreview; if (root) root.createSearchPreview = createSearchPreview; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, claim, onState }) { + return function createSearchPreview({ fetchJson, mutate, onState }) { let generation = 0; let current = null; - let claimRequest = null; + let mutationRequest = null; + + function run(action, pending, success, detail) { + if (mutationRequest) return mutationRequest; + onState({ status: pending, item: current, detail }); + mutationRequest = mutate(detail, action).then(result => { + onState({ status: success, item: current, detail, result }); + return result; + }).catch(error => { + onState({ status: 'ready', item: current, detail, error }); + throw error; + }).finally(() => { mutationRequest = null; }); + return mutationRequest; + } return { open(item) { @@ -32,16 +45,10 @@ onState({ status: 'closed' }); }, claim(detail) { - if (claimRequest) return claimRequest; - onState({ status: 'claiming', item: current, detail }); - claimRequest = claim(detail).then(result => { - onState({ status: 'claimed', item: current, detail, result }); - return result; - }).catch(error => { - onState({ status: 'ready', item: current, detail, error }); - throw error; - }).finally(() => { claimRequest = null; }); - return claimRequest; + return run('claim', 'claiming', 'claimed', detail); + }, + reopen(detail) { + return run('reopen', 'reopening', 'reopened', detail); }, }; }; diff --git a/frontend/widgets.js b/frontend/widgets.js index 097f450..66c057a 100644 --- a/frontend/widgets.js +++ b/frontend/widgets.js @@ -5,19 +5,6 @@ function renderRepoMix(element, data) { element.innerHTML = '
Repos
' + repos.length + '
Open issues
' + issues.length + '
Open PRs
' + pullRequests.length + '
'; } -async function updateRepoMix(element, fetchContext = fetch) { - try { - const response = await fetchContext('api/v1/context', { - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error('HTTP ' + response.status); - renderRepoMix(element, await response.json()); - } catch (error) { - element.innerHTML = '
Widget unavailable.
'; - } -} - if (typeof module !== 'undefined' && module.exports) { - updateRepoMix.renderRepoMix = renderRepoMix; - module.exports = updateRepoMix; + module.exports = renderRepoMix; } diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 849b58b..10276a4 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -12,6 +12,9 @@ import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) +COMMONJS_EXPORT_LINE = re.compile( + rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE +) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { "issue-capture": ("static/create-issue-sheet.js",), @@ -49,6 +52,9 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: path = frontend_dir / source.removeprefix("static/") chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") source = b"".join(chunks) + # Node-only export shims support source-level unit tests but are unreachable + # in the browser. Strip the simple one-line form from shipped bundles. + source = COMMONJS_EXPORT_LINE.sub(b"", source) revision = hashlib.sha256(source).hexdigest() minified = rjsmin.jsmin(source.decode()).encode() return minified + f';"source-sha256:{revision}";'.encode() diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 7b1b8ed..8c3bab3 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -472,6 +472,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict: "assignees": assignee_names, "url": _safe_web_url(issue.get("html_url")), "claimable": actual_kind == "issue" and state == "open" and not assignee_names, + "reopenable": actual_kind == "issue" and state == "closed", "assigned_to_me": bool(login and login in assignee_names), } @@ -1272,6 +1273,66 @@ async def claim_available_issue(repository: str, number: int) -> dict: } +async def reopen_issue(repository: str, number: int) -> dict: + """Reopen a closed issue, assign it to the current user, and confirm both.""" + issue, user = await asyncio.gather( + fetch(f"repos/{repository}/issues/{number}"), current_user() + ) + login = user.get("login") if isinstance(user, dict) else None + if ( + not isinstance(issue, dict) + or issue.get("number") != number + or issue.get("pull_request") is not None + or not isinstance(login, str) + or not login + ): + raise IssueNotAvailableError("Issue cannot be resumed") + + if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")): + confirmed = issue + elif issue.get("state") == "closed": + response = await _get_client().patch( + f"/api/v1/repos/{repository}/issues/{number}", + headers=_auth(), + json={"state": "open", "assignee": login}, + ) + response.raise_for_status() + confirmed = response.json() + else: + raise IssueNotAvailableError("Issue is no longer available to resume") + assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None + assignees = assignees_value if isinstance(assignees_value, list) else [] + logins = [ + assignee["login"] for assignee in assignees + if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) + ] + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or confirmed.get("state") != "open" + or login not in logins + ): + raise ValueError("Gitea did not confirm issue reopening and assignment") + labels_value = confirmed.get("labels") + labels = labels_value if isinstance(labels_value, list) else [] + return { + "id": confirmed.get("id"), + "number": number, + "title": confirmed.get("title", "") + if isinstance(confirmed.get("title"), str) else "", + "state": "open", + "repository": repository, + "labels": [ + label["name"] for label in labels + if isinstance(label, dict) and isinstance(label.get("name"), str) + ], + "assignees": logins, + "updated_at": confirmed.get("updated_at", "") + if isinstance(confirmed.get("updated_at"), str) else "", + "url": _safe_web_url(confirmed.get("html_url")), + } + + async def release_assigned_issue(repository: str, number: int) -> dict: login, issue = await _current_login_and_target( f"repos/{repository}/issues/{number}" diff --git a/src/main.py b/src/main.py index e157242..823f001 100644 --- a/src/main.py +++ b/src/main.py @@ -3224,6 +3224,30 @@ async def claim_available_issue( return JSONResponse(result) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reopen") +async def reopen_closed_issue( + owner: str, repo: str, number: int = PathParam(gt=0) +) -> JSONResponse: + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.reopen_issue(repository, number), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + return JSONResponse( + {"error": "This issue is no longer closed or cannot be resumed."}, + status_code=409, + ) + except Exception: + return JSONResponse( + {"error": "The issue could not be reopened. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release") async def release_assigned_issue( owner: str, repo: str, number: int = PathParam(gt=0) diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 0f496eb..6fd34ec 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -178,7 +178,7 @@ const pending = new Map(); const states = []; const preview = createSearchPreview({{ fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)), - claim: () => Promise.resolve(), + mutate: () => Promise.resolve(), onState: state => states.push(state), }}); preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }}); @@ -206,7 +206,7 @@ let resolveClaim; const states = []; const preview = createSearchPreview({{ fetchJson: item => Promise.resolve(item), - claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }}, + mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }}, onState: state => states.push(state), }}); await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }}); @@ -223,6 +223,32 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_search_preview_reopen_is_single_flight_and_reports_confirmation(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +let reopens = 0; +let resolveReopen; +const states = []; +const preview = createSearchPreview({{ + fetchJson: item => Promise.resolve(item), + mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }}, + onState: state => states.push(state), +}}); +const detail = {{ repository:'stackchain/api', number:42, reopenable:true }}; +await preview.open(detail); +const first = preview.reopen(detail); +const second = preview.reopen(detail); +if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight'); +resolveReopen({{ state:'open', assignees:['timmy'] }}); +await first; +if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing'); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_remote_search_selection_opens_native_preview_without_navigation(): html = dashboard_bundle_text() @@ -248,13 +274,31 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues(): css = (FRONTEND / "dashboard.css").read_text() assert 'id="start-search-result"' in html - assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html - assert "const searchAssignAndStart = createAssignAndStart({" in html + assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html + 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 assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css +def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard(): + html = dashboard_bundle_text() + + assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html + assert "mutate: (detail, action) => fetchReviewJson(" in html + handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split( + "qs('#close-whiteboard')", 1 + )[0] + assert "searchReopenAndStart.run(detail)" in handler + orchestrator = html.split("function createSearchStart(claim)", 1)[1].split( + "const searchAssignAndStart", 1 + )[0] + assert "available: createAndStart.available" in orchestrator + assert "claim," in orchestrator + assert "acceptClaimedIssue(confirmed)" in orchestrator + assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html + + def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners(): script = f""" const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))}); diff --git a/tests/test_global_search.py b/tests/test_global_search.py index a0a5c71..7b69015 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -229,6 +229,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i "assignees": [], "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, + "reopenable": False, "assigned_to_me": False, } @@ -255,3 +256,26 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim(): assert preview["kind"] == "pull" assert preview["claimable"] is False + + +@pytest.mark.anyio +async def test_work_preview_offers_reopen_only_for_closed_issues(): + async def handler(request): + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "number": 42, + "title": "Resume work", + "state": "closed", + "html_url": "https://forge.example/stackchain/api/issues/42", + "assignees": [], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42) + finally: + await gitea_proxy.stop_client() + + assert preview["reopenable"] is True + assert preview["claimable"] is False diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 85c69d3..6a848ec 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1094,6 +1094,71 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_ assert result["assignees"] == ["timmy"] +@pytest.mark.anyio +async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assignment(): + requests = [] + + async def handler(request): + requests.append(request) + if request.method == "GET" and request.url.path.endswith("/issues/17"): + return httpx.Response(200, json={ + "id": 81, "number": 17, "title": "Resume work", "state": "closed", + "assignees": [], "pull_request": None, "labels": [{"name": "P1"}], + "html_url": "https://forge.example/stackchain/api/issues/17", + }) + if request.method == "GET" and request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "id": 81, "number": 17, "title": "Resume work", "state": "open", + "assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}], + "html_url": "https://forge.example/stackchain/api/issues/17", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.reopen_issue("stackchain/api", 17) + finally: + await gitea_proxy.stop_client() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/repos/stackchain/api/issues/17"), + ("GET", "/api/v1/user"), + ("PATCH", "/api/v1/repos/stackchain/api/issues/17"), + ] + assert requests[2].content == b'{"state":"open","assignee":"timmy"}' + assert result == { + "id": 81, "number": 17, "title": "Resume work", "state": "open", + "repository": "stackchain/api", "labels": ["P1"], + "assignees": ["timmy"], "updated_at": "", + "url": "https://forge.example/stackchain/api/issues/17", + } + + +@pytest.mark.anyio +async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue(): + requests = [] + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "id": 81, "number": 17, "title": "Resume work", "state": "open", + "assignees": [{"login": "timmy"}], "pull_request": None, "labels": [], + "html_url": "https://forge.example/stackchain/api/issues/17", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.reopen_issue("stackchain/api", 17) + finally: + await gitea_proxy.stop_client() + + assert result["state"] == "open" + assert result["assignees"] == ["timmy"] + assert all(request.method == "GET" for request in requests) + + @pytest.mark.anyio async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch): calls = [] @@ -1134,6 +1199,30 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic } +@pytest.mark.anyio +async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monkeypatch): + calls = [] + + async def reopen(repository, number): + calls.append((repository, number)) + return { + "id": 81, "number": number, "title": "Resume work", "state": "open", + "repository": repository, "labels": ["P1"], "assignees": ["timmy"], + "url": "https://forge.example/stackchain/api/issues/17", + } + + monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False) + 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/reopen") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["state"] == "open" + assert response.json()["assignees"] == ["timmy"] + assert calls == [("stackchain/api", 17)] + + @pytest.mark.anyio async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers(): requests = [] diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 8934172..f2b4f61 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -36,21 +36,20 @@ async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails assert "Agent UI delta binding" not in html -def test_repo_mix_reports_unavailable_for_failed_context_response(): +def test_repo_mix_renders_counts_from_shared_context_snapshot(): script = f""" -const updateRepoMix = require({json.dumps(str(WIDGETS))}); -const element = {{ innerHTML: 'stale counts' }}; -const failedFetch = async () => ({{ - ok: false, - status: 503, - json: async () => ({{ error: 'upstream unavailable' }}), -}}); - -updateRepoMix(element, failedFetch).then(() => {{ - if (!element.innerHTML.includes('Widget unavailable.')) {{ - throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`); - }} +const renderRepoMix = require({json.dumps(str(WIDGETS))}); +const element = {{ innerHTML: '' }}; +renderRepoMix(element, {{ + repos:[{{}},{{}}], + issues:[{{state:'open'}},{{state:'closed'}}], + pull_requests:[{{}},{{}},{{}}], }}); +if (!element.innerHTML.includes('
2
') || + !element.innerHTML.includes('
1
') || + !element.innerHTML.includes('
3
')) {{ + throw new Error('shared snapshot counts were not rendered: ' + element.innerHTML); +}} """ subprocess.run(