diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 64f0dc3..64c6ca8 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp let duplicateRequest = 0; let duplicateState = {status: 'idle', key: '', candidates: []}; let acknowledgedDuplicateKey = ''; + const repositoryPageRequests = new Map(); const safeLabelIds = value => Array.from(new Set( (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) )).slice(0, 20); @@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp ); } + function loadRepositoryPage(page) { + const safePage = Math.max(1, Math.floor(Number(page) || 1)); + if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage); + const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50') + .then(payload => ({ + items: Array.isArray(payload?.items) ? payload.items : [], + page: Number(payload?.page) || safePage, + total: Math.max(0, Number(payload?.total) || 0), + has_more: payload?.has_more === true, + })) + .finally(() => repositoryPageRequests.delete(safePage)); + repositoryPageRequests.set(safePage, request); + return request; + } + function duplicateKey(draft) { const repository = String(draft?.repository || '').trim(); const title = String(draft?.title || '').replace(/\s+/g, ' ').trim(); @@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, }; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 2662d42..b245c22 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -289,6 +289,7 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .create-issue-header button, .create-issue-actions button { min-height:44px; } .create-issue-form { display:grid; gap:12px; } +.create-issue-repository-more { min-height:44px; width:100%; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } .create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c1b8407..4bf8e31 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1868,6 +1868,27 @@ }; } + let issueCaptureRepositories = []; + let nextIssueRepositoryPage = 2; + let moreIssueRepositoriesAvailable = false; + + function appendIssueRepositories(items) { + const select = qs('#create-issue-repository'); + const selected = select.value; + const known = new Set(issueCaptureRepositories); + (Array.isArray(items) ? items : []).forEach(item => { + const repository = String(item?.full_name || '').trim(); + if (!repository || known.has(repository)) return; + known.add(repository); + issueCaptureRepositories.push(repository); + const option = document.createElement('option'); + option.value = repository; + option.textContent = repository; + select.appendChild(option); + }); + if (selected) select.value = selected; + } + let duplicateCheckTimer = null; function renderIssueDuplicates(state) { if (state.status === 'stale') return; @@ -1990,13 +2011,18 @@ return; } const captureDraft = issueCapture.loadDraft(); - const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); - if (captureDraft.repository && !repositories.includes(captureDraft.repository)) { - repositories.unshift(captureDraft.repository); + const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); + if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice(); + if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) { + issueCaptureRepositories.unshift(captureDraft.repository); } - qs('#create-issue-repository').innerHTML = repositories.map(repository => + qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository => '' ).join(''); + if (nextIssueRepositoryPage === 2) { + moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true; + } + qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable; if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-body').value = captureDraft.body; @@ -2004,9 +2030,9 @@ loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); scheduleIssueDuplicateCheck(); - qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; - qs('#submit-new-issue').disabled = !repositories.length; - qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available(); + qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.'; + qs('#submit-new-issue').disabled = !issueCaptureRepositories.length; + qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; qs('#create-issue-title').focus(); @@ -2799,6 +2825,26 @@ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); }); + qs('#load-more-issue-repositories').addEventListener('click', async event => { + const button = event.currentTarget; + const status = qs('#create-issue-repository-status'); + saveIssueCaptureDraft(); + button.disabled = true; + status.textContent = 'Loading more repositories…'; + try { + const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage); + appendIssueRepositories(payload.items); + nextIssueRepositoryPage = payload.page + 1; + moreIssueRepositoriesAvailable = payload.has_more; + button.hidden = !moreIssueRepositoriesAvailable; + status.textContent = payload.items.length ? + 'More repositories are available in the selector.' : 'All accessible repositories are loaded.'; + } catch (_error) { + status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.'; + } finally { + button.disabled = false; + } + }); qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-anyway').addEventListener('click', () => { diff --git a/frontend/index.html b/frontend/index.html index 6cd5efe..d6018c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -361,6 +361,8 @@ + +
diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f1ebd5a..4ccf556 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-v63'; +const CACHE = 'stackchain-dashboard-shell-v64'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 6f392f8..57f47b7 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -133,6 +133,14 @@ class WorkItems(list[dict]): self.pagination = pagination +class RepositoryItems(list[dict]): + """A list-compatible repository page carrying truthful pagination metadata.""" + + def __init__(self, items: list[dict], pagination: dict): + super().__init__(items) + self.pagination = pagination + + class WorkRouteUnavailableError(ValueError): """Raised when a shared route no longer belongs in the current user's queue.""" @@ -261,8 +269,48 @@ async def current_user() -> dict: return await fetch("user") -async def repos() -> list[dict]: - return await fetch("user/repos?limit=50") +async def repo_page(page: int = 1, limit: int = 50) -> dict: + """Load one bounded page of repositories available to the current user.""" + response = await _get_client().get( + "/api/v1/user/repos", + headers=_auth(), + params={"page": page, "limit": limit}, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea repository response was not a list") + items = [item for item in payload if isinstance(item, dict)] + try: + total = max(len(items), int(response.headers.get("X-Total-Count", len(items)))) + except (TypeError, ValueError): + total = len(items) + return { + "items": items, + "page": page, + "total": total, + "has_more": page * limit < total, + } + + +async def repos() -> RepositoryItems: + result = await repo_page() + return RepositoryItems( + result["items"], + {key: result[key] for key in ("page", "total", "has_more")}, + ) + + +async def repository_access(repository: str) -> dict | None: + """Return a repository only when the authenticated user can access it.""" + response = await _get_client().get( + f"/api/v1/repos/{repository}", headers=_auth() + ) + if response.status_code == 404: + return None + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else None WORK_SEARCHES = { diff --git a/src/main.py b/src/main.py index 90e3694..1217d8f 100644 --- a/src/main.py +++ b/src/main.py @@ -502,6 +502,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: and all(field in p for field in ("id", "number", "title", "state", "html_url")) ] payload = compute(user_model, repo_models, issue_models, pr_models).model_dump() + repository_pagination = getattr(repo_data, "pagination", None) + if isinstance(repository_pagination, dict): + payload["repository_pagination"] = repository_pagination pagination = {} pagination.update(getattr(issues_data, "pagination", {})) pagination.update(getattr(prs_data, "pagination", {})) @@ -1213,6 +1216,36 @@ async def context() -> JSONResponse: return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data)) +@app.get("/api/v1/repositories") +async def repository_page( + page: int = Query(default=1, ge=1, le=1000), + limit: int = Query(default=50, ge=1, le=50), +) -> JSONResponse: + """Return one bounded repository page for lazy issue-capture selection.""" + try: + result = await asyncio.wait_for( + gitea_proxy.repo_page(page=page, limit=limit), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + items = [ + Repo( + id=item["id"], name=item["name"], full_name=item["full_name"], + description=item.get("description") or "", url=item["html_url"], + updated_at=item.get("updated_at", ""), + ).model_dump() + for item in result.get("items", []) + if isinstance(item, dict) + and all(field in item for field in ("id", "name", "full_name", "html_url")) + ] + return JSONResponse({**result, "items": items}) + except Exception: + return JSONResponse( + {"error": "Repositories could not be loaded. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/background-identity") async def background_identity() -> JSONResponse: """Return only the account key required to safely drain a browser outbox.""" @@ -2191,13 +2224,7 @@ async def repository_milestones(owner: str, repo: str): repository = f"{owner}/{repo}" async def load_milestones(): - available = await gitea_proxy.repos() - accessible = { - item.get("full_name") - for item in (available if isinstance(available, list) else []) - if isinstance(item, dict) - } - if repository not in accessible: + if await gitea_proxy.repository_access(repository) is None: raise HTTPException(status_code=404, detail="Repository not found") return await gitea_proxy.repo_milestones(repository) @@ -2220,13 +2247,7 @@ async def repository_labels(owner: str, repo: str): repository = f"{owner}/{repo}" async def load_labels(): - available = await gitea_proxy.repos() - accessible = { - item.get("full_name") - for item in (available if isinstance(available, list) else []) - if isinstance(item, dict) - } - if repository not in accessible: + if await gitea_proxy.repository_access(repository) is None: raise HTTPException(status_code=404, detail="Repository not found") return await gitea_proxy.repo_labels(repository) @@ -2252,16 +2273,11 @@ async def create_assigned_issue( repository = f"{owner}/{repo}" async def create_issue(): - user, available = await asyncio.gather( - gitea_proxy.current_user(), gitea_proxy.repos() + user, accessible = await asyncio.gather( + gitea_proxy.current_user(), gitea_proxy.repository_access(repository) ) login = user.get("login") if isinstance(user, dict) else None - accessible = { - item.get("full_name") - for item in (available if isinstance(available, list) else []) - if isinstance(item, dict) - } - if not login or repository not in accessible: + if not login or accessible is None: raise HTTPException(status_code=404, detail="Repository not found") if creation.label_ids: available_labels = await gitea_proxy.repo_labels(repository) diff --git a/tests/test_drafts.py b/tests/test_drafts.py index d552e3b..d7ee837 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -195,7 +195,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert 'Discard this unfinished draft?' in html assert '.draft-actions button { min-height:44px;' in html assert 'createDraftInbox({ storage: localStorage' in html - assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html + assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html assert "Verified not posted — retry" in html assert "payload.detail?.message" in html assert "error.code = payload.detail?.code" in html diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index c20c4c5..03a8126 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -8,6 +8,13 @@ from src import gitea_proxy, main from src.idempotency import IdempotencyLedger +async def repository_access_from(loader, repository): + repositories = await loader() + return next( + (item for item in repositories if item.get("full_name") == repository), None + ) + + @pytest.mark.anyio async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone(): requests = [] @@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch) } monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False) monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False) transport = httpx.ASGITransport(app=main.app) @@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False) monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False) transport = httpx.ASGITransport(app=main.app) @@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])] +@pytest.mark.anyio +async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route(): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append((request.url.path, dict(request.url.params))) + if request.url.path.endswith("/user/repos"): + return httpx.Response( + 200, + json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}], + headers={"X-Total-Count": "51"}, + ) + if request.url.path.endswith("/repos/stackchain/later"): + return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"}) + return httpx.Response(404) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + page = await gitea_proxy.repo_page(page=2, limit=50) + first_page = await gitea_proxy.repos() + accessible = await gitea_proxy.repository_access("stackchain/later") + missing = await gitea_proxy.repository_access("stackchain/missing") + finally: + await gitea_proxy.stop_client() + + assert page == { + "items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}], + "page": 2, + "total": 51, + "has_more": False, + } + assert accessible["full_name"] == "stackchain/later" + assert missing is None + assert first_page.pagination == {"page": 1, "total": 51, "has_more": True} + assert calls == [ + ("/api/v1/user/repos", {"page": "2", "limit": "50"}), + ("/api/v1/user/repos", {"page": "1", "limit": "50"}), + ("/api/v1/repos/stackchain/later", {}), + ("/api/v1/repos/stackchain/missing", {}), + ] + + +@pytest.mark.anyio +async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch): + access_calls = [] + create_calls = [] + + async def page(page, limit): + assert (page, limit) == (2, 50) + return { + "items": [{ + "id": 51, "name": "later", "full_name": "stackchain/later", + "description": "", "html_url": "https://forge.example/stackchain/later", + "updated_at": "2026-08-09T12:00:00Z", + }], + "page": 2, "total": 51, "has_more": False, + } + + async def access(repository): + access_calls.append(repository) + return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None + + async def user(): + return {"login": "timmy"} + + async def create(repository, title, body, assignee, label_ids): + create_calls.append((repository, title, assignee)) + return { + "id": 403, "number": 403, "title": title, "state": "open", + "repository": repository, "labels": [], "assignees": [assignee], + "updated_at": "2026-08-09T12:00:00Z", + "url": "https://forge.example/stackchain/later/issues/403", + } + + monkeypatch.setattr(main.gitea_proxy, "repo_page", page) + monkeypatch.setattr(main.gitea_proxy, "repository_access", access) + monkeypatch.setattr(main.gitea_proxy, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "create_issue", create) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + listed = await client.get("/api/v1/repositories?page=2&limit=50") + created = await client.post( + "/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"} + ) + + assert listed.status_code == 200 + assert listed.json()["items"][0]["full_name"] == "stackchain/later" + assert listed.json()["has_more"] is False + assert created.status_code == 201 + assert access_calls == ["stackchain/later"] + assert create_calls == [("stackchain/later", "Later-page work", "timmy")] + + +def test_context_exposes_truthful_repository_pagination(): + repositories = gitea_proxy.RepositoryItems( + [{ + "id": 1, "name": "api", "full_name": "stackchain/api", + "description": "", "html_url": "https://forge.example/stackchain/api", + "updated_at": "2026-08-09T12:00:00Z", + }], + {"page": 1, "total": 51, "has_more": True}, + ) + + payload = main._context_payload( + {"id": 2, "login": "timmy"}, repositories, [], [] + ) + + assert payload["repository_pagination"] == { + "page": 1, "total": 51, "has_more": True + } + + @pytest.mark.anyio async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch): calls = [] @@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) @@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) headers = {"Idempotency-Key": "capture-177-concurrent"} @@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk ) monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) headers = {"Idempotency-Key": "restart-create-201"} @@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_ monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) headers = {"Idempotency-Key": "capture-177-payload-conflict"} @@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_ monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01) monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) headers = {"Idempotency-Key": "capture-177-timeout-recovery"} @@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit( monkeypatch.setattr(main, "_idempotency_ledger", ledger) monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: @@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository( return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}] monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: @@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch): monkeypatch.setattr(main.gitea_proxy, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "repos", available_repos) + monkeypatch.setattr( + main.gitea_proxy, "repository_access", + lambda repository: repository_access_from(available_repos, repository), + ) monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels) monkeypatch.setattr(main.gitea_proxy, "create_issue", create) transport = httpx.ASGITransport(app=main.app) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index e29e9af..d64093b 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,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-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 8fe8c25..3bb265f 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,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-v63" in worker + assert "stackchain-dashboard-shell-v64" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index cb493a8..dd05e1e 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ 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-v63" in worker + assert "stackchain-dashboard-shell-v64" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a3c2d06..2fb6a6f 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1938,6 +1938,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control(): assert '.load-more-work { min-height:44px;' in html +@pytest.mark.anyio +async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form(): + html = await dashboard() + + assert 'id="load-more-issue-repositories"' in html + assert '>Load more repositories<' in html + assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html + assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html + assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html + assert 'appendIssueRepositories(payload.items)' in html + assert '.create-issue-repository-more { min-height:44px;' in html + + @pytest.mark.anyio async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor(): html = await dashboard() @@ -2040,6 +2053,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string assert output["results"][1]["number"] == 17 +def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values = new Map(); +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let calls = 0; +let release; +const capture = createIssueCapture({{ + storage, + fetchJson: url => {{ + calls += 1; + if (calls === 1) return Promise.reject(new Error('temporary')); + return new Promise(resolve => {{ release = () => resolve({{ + items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false + }}); }}); + }}, +}}); +capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}}); +capture.loadRepositoryPage(2).catch(() => {{ + const first = capture.loadRepositoryPage(2); + const duplicate = capture.loadRepositoryPage(2); + release(); + Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, results, draft:capture.loadDraft() + }}))); +}}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["calls"] == 2 + assert output["results"] == [ + {"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False}, + {"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False}, + ] + assert output["draft"] == { + "repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3] + } + + def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload(): script = f""" const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index f32b4f1..bab4edd 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -113,5 +113,5 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/plan-today.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 6060586..90bca3f 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -138,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v63" in source + assert "stackchain-dashboard-shell-v64" 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-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -154,21 +154,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-v63" in source + assert "stackchain-dashboard-shell-v64" 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-v63" in source + assert "stackchain-dashboard-shell-v64" 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-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 09635ef..50809f8 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v63" in source + assert "stackchain-dashboard-shell-v64" in source assert "BASE + 'static/today-sync.js'" in source