diff --git a/frontend/index.html b/frontend/index.html index 71c63be..dfd8579 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -69,6 +69,8 @@ textarea { resize: vertical; min-height: 120px; } .read-update { min-height:44px; width:100%; } .load-more-notifications { min-height:44px; width:100%; margin-top:10px; } .load-more-notifications[hidden] { display:none; } +.load-more-work { min-height:44px; width:100%; margin-top:10px; } +.load-more-work[hidden] { display:none; } .my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .my-work-bulk button { min-height:44px; width:100%; } .my-work[data-stale="true"] { border-color:#fcd34d; } @@ -207,6 +209,8 @@ textarea { resize: vertical; min-height: 120px; }
+
+
@@ -522,6 +526,7 @@ textarea { resize: vertical; min-height: 120px; } let lastNotifications = []; let lastContextSnapshot = null; let notificationPagination = { page: 1, total: 0, has_more: false }; + let workPagination = {}; let hasContextSnapshot = false; let selectedReview = null; let reviewTrigger = null; @@ -602,6 +607,15 @@ textarea { resize: vertical; min-height: 120px; } return payload; } + async function fetchWorkPage(stream, page) { + const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading older work failed.'); + return payload; + } + async function postNotificationReply(notificationId, body) { const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', { method: 'POST', @@ -648,6 +662,20 @@ textarea { resize: vertical; min-height: 120px; } }, onStatus: message => { qs('#my-work-action-status').textContent = message; }, }); + const workPager = createWorkPager({ + load: fetchWorkPage, + onItems: (stream, items) => { + if (!lastContextSnapshot) return; + if (stream === 'issue') lastContextSnapshot.issues = items; + else lastContextSnapshot.pull_requests = items; + paintMyWork(lastContextSnapshot); + }, + onPagination: pagination => { + workPagination = pagination; + updateWorkPaginationControls(); + }, + onStatus: message => { qs('#my-work-action-status').textContent = message; }, + }); const notificationReplier = createNotificationReplier({ post: postNotificationReply, storage: localStorage, @@ -705,7 +733,25 @@ textarea { resize: vertical; min-height: 120px; } function renderContextSnapshot(data) { liveMode = true; hasContextSnapshot = true; + if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) { + const merge = (older, latest) => { + const byId = new Map((older || []).map(item => [item.id, item])); + (latest || []).forEach(item => { + const current = byId.get(item.id) || {}; + const reasons = Array.from(new Set( + (current.work_reasons || []).concat(item.work_reasons || []) + )); + byId.set(item.id, { + ...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}), + }); + }); + return Array.from(byId.values()); + }; + data.issues = merge(lastContextSnapshot.issues, data.issues); + data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests); + } lastContextSnapshot = data; + if (data.work_pagination) workPager.reset(data.work_pagination); if (data.error && lastMyWork.length) markMyWorkStale(); else paintMyWork(data); qs('#context').innerHTML = '
User
' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '
' + @@ -759,11 +805,36 @@ textarea { resize: vertical; min-height: 120px; } qs('#my-work').removeAttribute('data-stale'); qs('#my-work-status').textContent = lastMyWork.length ? summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.'; + updateWorkPaginationControls(); renderMyWork(); } + function activeWorkStreams() { + if (selectedWorkFilter === 'issue') return ['issue']; + if (selectedWorkFilter === 'pull') return ['pull']; + if (selectedWorkFilter === 'review') return ['review']; + if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review']; + return []; + } + + function updateWorkPaginationControls() { + const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' }; + const streams = activeWorkStreams(); + const incomplete = streams.filter(stream => workPagination[stream]?.has_more); + const summaries = streams.flatMap(stream => { + const page = workPagination[stream]; + return page && page.total ? + [Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : []; + }); + qs('#work-page-status').textContent = summaries.join(' · '); + qs('#load-more-work').hidden = incomplete.length === 0; + qs('#load-more-work').textContent = incomplete.length ? + 'Load older ' + labels[incomplete[0]] : 'Load older work'; + } + function renderMyWork() { const visible = filterMyWork(lastMyWork, selectedWorkFilter); + const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); qs('#my-work-list').innerHTML = visible.length ? visible.map(item => { const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind); const contents = @@ -786,7 +857,9 @@ textarea { resize: vertical; min-height: 120px; } return '
' + readUpdate + markRead + '
'; } return '
' + contents + '' + readUpdate + markRead + '
'; - }).join('') : '
No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.
'; + }).join('') : '
' + (incomplete ? + 'More work is available. Load the next page.' : + 'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '
'; document.querySelectorAll('[data-review-index]').forEach(button => { button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button)); }); @@ -1666,6 +1739,21 @@ textarea { resize: vertical; min-height: 120px; } qs('#load-more-notifications').addEventListener('click', () => notificationPager.loadMore(lastNotifications) ); + qs('#load-more-work').addEventListener('click', async () => { + const stream = activeWorkStreams().find(item => workPagination[item]?.has_more); + if (!stream || !lastContextSnapshot) return; + const button = qs('#load-more-work'); + button.disabled = true; + const existing = stream === 'issue' ? + (lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []); + try { + const loaded = await workPager.loadMore(stream, existing); + if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus(); + else button.focus(); + } finally { + button.disabled = false; + } + }); qs('#bulk-mark-read').addEventListener('click', async () => { const allIds = notificationIds(filterMyWork(lastMyWork, 'update')); const ids = allIds.slice(0, 50); @@ -1701,6 +1789,7 @@ textarea { resize: vertical; min-height: 120px; } item.setAttribute('aria-pressed', String(item === button)) ); renderMyWork(); + updateWorkPaginationControls(); }); }); contextPoller.start(); diff --git a/frontend/my-work.js b/frontend/my-work.js index 28d9010..ebfe436 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -169,6 +169,66 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus }; } +function createWorkPager({ load, onItems, onPagination, onStatus }) { + let pagination = {}; + const pending = new Set(); + const labels = { + issue: 'issues', + pull: 'pull requests', + review: 'review requests', + }; + return { + reset(next) { + Object.entries(next || {}).forEach(([stream, value]) => { + const current = pagination[stream]; + pagination[stream] = current && current.page > 1 ? + { ...value, page: current.page, has_more: current.page * 50 < value.total } : + { ...value }; + }); + onPagination({ ...pagination }); + }, + async loadMore(stream, existing) { + const page = pagination[stream]; + if (pending.has(stream) || !page?.has_more) return false; + pending.add(stream); + const label = labels[stream] || 'work'; + onStatus('Loading older ' + label + '…'); + try { + const result = await load(stream, page.page + 1); + const merged = new Map((existing || []) + .filter(item => item && Number.isInteger(item.id)) + .map(item => [item.id, { ...item }])); + (result.items || []).forEach(item => { + if (!item || !Number.isInteger(item.id)) return; + const current = merged.get(item.id); + if (!current) { + merged.set(item.id, { ...item }); + return; + } + const reasons = Array.from(new Set( + (current.work_reasons || []).concat(item.work_reasons || []) + )); + merged.set(item.id, { + ...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}), + }); + }); + pagination[stream] = { + page: result.page, total: result.total, has_more: result.has_more === true, + }; + onItems(stream, Array.from(merged.values())); + onPagination({ ...pagination }); + onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.'); + return true; + } catch (_error) { + onStatus('Could not load older ' + label + '. Retry.'); + return false; + } finally { + pending.delete(stream); + } + }, + }; +} + function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) { let selected = null; let loadVersion = 0; @@ -295,6 +355,7 @@ if (typeof module !== 'undefined' && module.exports) { buildMyWork.notificationIds = notificationIds; buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger; buildMyWork.createNotificationPager = createNotificationPager; + buildMyWork.createWorkPager = createWorkPager; buildMyWork.createNotificationReader = createNotificationReader; buildMyWork.createNotificationReplier = createNotificationReplier; module.exports = buildMyWork; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index bb93159..8f86b9b 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -14,6 +14,14 @@ REVIEW_DIFF_MAX_LINES = 400 _client: httpx.AsyncClient | None = None +class WorkItems(list[dict]): + """A list-compatible first page carrying truthful per-stream totals.""" + + def __init__(self, items: list[dict], pagination: dict[str, dict]): + super().__init__(items) + self.pagination = pagination + + class StaleReviewError(ValueError): """Raised before mutation when a pull request head changed during review.""" @@ -129,10 +137,55 @@ async def repos() -> list[dict]: return await fetch("user/repos?limit=50") -async def issues() -> list[dict]: - return await fetch( - "repos/issues/search?state=open&assigned=true&type=issues&limit=50" +WORK_SEARCHES = { + "issue": ("assigned=true", "issues", None), + "pull": ("assigned=true", "pulls", "assigned_to_me"), + "review": ("review_requested=true", "pulls", "review_requested"), +} + + +async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict: + """Load exactly one bounded My Work stream page with upstream totals.""" + selector, item_type, reason = WORK_SEARCHES[stream] + response = await _get_client().get( + "/api/v1/repos/issues/search", + headers=_auth(), + params={ + "state": "open", selector.split("=", 1)[0]: "true", + "type": item_type, "limit": limit, "page": page, + }, ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("Gitea work search response was not a list") + items = [item for item in payload if isinstance(item, dict)] + if reason: + items = [{**item, "work_reasons": [reason]} for item in items] + try: + total = max(len(items), int(response.headers.get("X-Total-Count", len(items)))) + except (TypeError, ValueError): + total = len(items) + return { + "stream": stream, + "items": items, + "page": page, + "total": total, + "has_more": page * limit < total, + } + + +def _page_metadata(result: dict) -> dict: + return { + "page": result["page"], + "total": result["total"], + "has_more": result["has_more"], + } + + +async def issues() -> WorkItems: + result = await work_page("issue") + return WorkItems(result["items"], {"issue": _page_metadata(result)}) def _safe_web_url(value: Any) -> str: @@ -491,61 +544,59 @@ async def issue_detail(repository: str, number: int) -> dict: } +async def _work_contains(stream: str, repository: str, number: int) -> bool: + page = 1 + while page <= 100: + result = await work_page(stream, page) + if any( + item.get("number") == number + and isinstance(item.get("repository"), dict) + and item["repository"].get("full_name") == repository + for item in result["items"] + ): + return True + if not result["has_more"]: + return False + page += 1 + return False + + async def is_assigned_issue(repository: str, number: int) -> bool: - assigned = await issues() - return any( - isinstance(issue, dict) - and issue.get("number") == number - and isinstance(issue.get("repository"), dict) - and issue["repository"].get("full_name") == repository - for issue in (assigned or []) - ) + return await _work_contains("issue", repository, number) -async def pull_requests() -> list[dict]: +async def pull_requests() -> WorkItems: assigned, review_requested = await asyncio.gather( - fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"), - fetch( - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50" - ), + work_page("pull"), + work_page("review"), ) merged: dict[int, dict] = {} - for reason, pulls in ( - ("assigned_to_me", assigned or []), - ("review_requested", review_requested or []), + for result in ( + assigned, + review_requested, ): - for pull in pulls: + for pull in result["items"]: identity = pull.get("id") if identity not in merged: merged[identity] = {**pull, "work_reasons": []} - merged[identity]["work_reasons"].append(reason) - return list(merged.values()) + for reason in pull.get("work_reasons", []): + if reason not in merged[identity]["work_reasons"]: + merged[identity]["work_reasons"].append(reason) + return WorkItems( + list(merged.values()), + { + "pull": _page_metadata(assigned), + "review": _page_metadata(review_requested), + }, + ) async def is_requested_review(repository: str, number: int) -> bool: - pulls = await fetch( - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50" - ) - return any( - isinstance(pull, dict) - and pull.get("number") == number - and isinstance(pull.get("repository"), dict) - and pull["repository"].get("full_name") == repository - for pull in (pulls or []) - ) + return await _work_contains("review", repository, number) async def is_assigned_pull(repository: str, number: int) -> bool: - pulls = await fetch( - "repos/issues/search?state=open&assigned=true&type=pulls&limit=50" - ) - return any( - isinstance(pull, dict) - and pull.get("number") == number - and isinstance(pull.get("repository"), dict) - and pull["repository"].get("full_name") == repository - for pull in (pulls or []) - ) + return await _work_contains("pull", repository, number) async def pull_completion_detail(repository: str, number: int) -> dict: diff --git a/src/main.py b/src/main.py index 1501acf..61f96bf 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ import time from collections.abc import Awaitable from contextlib import asynccontextmanager from pathlib import Path -from typing import Any +from typing import Any, Literal from fastapi import FastAPI, HTTPException, Path as PathParam, Query from fastapi.responses import JSONResponse @@ -57,6 +57,7 @@ REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0 ISSUE_ACTION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0 +WORK_PAGE_TIMEOUT_SECONDS = 5.0 NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0 BULK_NOTIFICATION_CONCURRENCY = 5 BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0 @@ -218,7 +219,44 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: if isinstance(p, dict) and all(field in p for field in ("id", "number", "title", "state", "html_url")) ] - return compute(user_model, repo_models, issue_models, pr_models).model_dump() + payload = compute(user_model, repo_models, issue_models, pr_models).model_dump() + pagination = {} + pagination.update(getattr(issues_data, "pagination", {})) + pagination.update(getattr(prs_data, "pagination", {})) + if pagination: + payload["work_pagination"] = pagination + return payload + + +def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]: + if stream == "issue": + return [ + Issue( + id=item["id"], number=item["number"], title=item["title"], + state=item["state"], + labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)], + assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)], + repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "", + updated_at=item.get("updated_at") or "", url=item["html_url"], + ).model_dump() + for item in items + if all(field in item for field in ("id", "number", "title", "state", "html_url")) + ] + reason = "assigned_to_me" if stream == "pull" else "review_requested" + return [ + PullRequest( + id=item["id"], number=item["number"], title=item["title"], + state=item["state"], + user=item["user"].get("login", "") if isinstance(item.get("user"), dict) else "", + labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)], + assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)], + work_reasons=[reason], + repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "", + updated_at=item.get("updated_at") or "", url=item["html_url"], + ).model_dump() + for item in items + if all(field in item for field in ("id", "number", "title", "state", "html_url")) + ] app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") app.include_router(frontend_router) @@ -227,7 +265,7 @@ app.include_router(frontend_router) @app.middleware("http") async def prevent_live_api_caching(request, call_next): response = await call_next(request) - if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or ( + if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or request.url.path.startswith("/api/v1/work/") or ( request.url.path.startswith("/api/v1/repos/") and request.url.path.endswith("/review") ) or request.url.path.startswith("/api/v1/notifications") or ( @@ -334,6 +372,30 @@ async def context() -> JSONResponse: return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data)) +@app.get("/api/v1/work/{stream}") +async def paged_work( + stream: Literal["issue", "pull", "review"], + page: int = Query(ge=2, le=100), +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.work_page(stream, page), timeout=WORK_PAGE_TIMEOUT_SECONDS + ) + except Exception: + return JSONResponse( + {"error": "Work page is temporarily unavailable"}, + status_code=503, + headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))}, + ) + return JSONResponse({ + "stream": stream, + "page": result["page"], + "total": result["total"], + "has_more": result["has_more"], + "items": _normalize_work_items(stream, result["items"]), + }) + + async def _load_context_for_user(user_data: dict) -> dict: repo_data, issues_data, prs_data = await asyncio.gather( repos(), issues(), pull_requests() diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 9106895..567e23c 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -19,4 +19,5 @@ def test_api_requests_resolve_inside_dashboard_subpath(): "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/", "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=", "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read", + "https://forge.alexanderwhitestone.com/dashboard/api/v1/work/", } diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index 83fcb12..86a0989 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -2,27 +2,86 @@ import asyncio import json import pytest +import httpx from src import gitea_proxy from src import main +@pytest.mark.anyio +async def test_work_page_preserves_total_and_reason_without_loading_other_pages(): + requests = [] + + def upstream(request): + requests.append(str(request.url)) + return httpx.Response( + 200, + headers={"X-Total-Count": "84"}, + json=[{ + "id": 51, + "number": 51, + "title": "Older review", + "state": "open", + "repository": {"full_name": "stackchain/api"}, + "html_url": "https://forge.example/stackchain/api/pulls/51", + }], + ) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.work_page("review", page=2) + finally: + await gitea_proxy.stop_client() + + assert requests == [ + "http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2" + ] + assert result["page"] == 2 + assert result["total"] == 84 + assert result["has_more"] is False + assert result["items"][0]["work_reasons"] == ["review_requested"] + + +@pytest.mark.anyio +async def test_initial_work_collections_expose_independent_pagination(monkeypatch): + async def fake_page(stream, page=1, limit=50): + assert page == 1 + totals = {"issue": 84, "pull": 61, "review": 73} + return { + "items": [], "page": 1, "total": totals[stream], + "has_more": True, "stream": stream, + } + + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) + + assigned_issues = await gitea_proxy.issues() + pulls = await gitea_proxy.pull_requests() + + assert assigned_issues.pagination == { + "issue": {"page": 1, "total": 84, "has_more": True} + } + assert pulls.pagination == { + "pull": {"page": 1, "total": 61, "has_more": True}, + "review": {"page": 1, "total": 73, "has_more": True}, + } + + @pytest.mark.anyio async def test_work_collections_include_supported_review_request_search(monkeypatch): - requested_paths = [] + requested_streams = [] - async def fake_fetch(path): - requested_paths.append(path) - return [] + async def fake_page(stream, page=1, limit=50): + requested_streams.append((stream, page, limit)) + return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False} - monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) assert await gitea_proxy.issues() == [] assert await gitea_proxy.pull_requests() == [] - assert requested_paths == [ - "repos/issues/search?state=open&assigned=true&type=issues&limit=50", - "repos/issues/search?state=open&assigned=true&type=pulls&limit=50", - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", + assert requested_streams == [ + ("issue", 1, 50), + ("pull", 1, 50), + ("review", 1, 50), ] @@ -41,12 +100,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey "repository": {"full_name": "stackchain/mobile"}, } - async def fake_fetch(path): - if "assigned=true" in path: - return [assigned] - return [assigned.copy(), review_only] + async def fake_page(stream, page=1, limit=50): + items = [assigned] if stream == "pull" else [assigned.copy(), review_only] + reason = "assigned_to_me" if stream == "pull" else "review_requested" + return { + "stream": stream, + "items": [{**item, "work_reasons": [reason]} for item in items], + "page": page, "total": len(items), "has_more": False, + } - monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) pulls = await gitea_proxy.pull_requests() @@ -60,13 +123,13 @@ async def test_pull_request_searches_start_concurrently(monkeypatch): started = [asyncio.Event(), asyncio.Event()] release = asyncio.Event() - async def fake_fetch(path): - index = 0 if "assigned=true" in path else 1 + async def fake_page(stream, page=1, limit=50): + index = 0 if stream == "pull" else 1 started[index].set() await release.wait() - return [] + return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False} - monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) task = asyncio.create_task(gitea_proxy.pull_requests()) try: await asyncio.wait_for( @@ -80,30 +143,51 @@ async def test_pull_request_searches_start_concurrently(monkeypatch): @pytest.mark.anyio async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch): - requested_paths = [] + requested = [] - async def fake_fetch(path): - requested_paths.append(path) - return [ - { - "number": 7, - "repository": {"full_name": "stackchain/api"}, - }, - { - "number": 8, - "repository": {"full_name": "stackchain/api"}, - }, - ] + async def fake_page(stream, page=1, limit=50): + requested.append((stream, page)) + items = [] if page == 1 else [{ + "number": 77, + "repository": {"full_name": "stackchain/api"}, + }] + return { + "stream": stream, "items": items, "page": page, + "total": 51, "has_more": page == 1, + } - monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) - assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True + assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False - assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False - assert requested_paths == [ - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", - "repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", + assert requested == [ + ("review", 1), ("review", 2), + ("review", 1), ("review", 2), + ] + + +@pytest.mark.anyio +async def test_assignment_guards_find_eligible_items_beyond_first_page(monkeypatch): + requested = [] + + async def fake_page(stream, page=1, limit=50): + requested.append((stream, page)) + return { + "stream": stream, + "items": [] if page == 1 else [{ + "number": 77, + "repository": {"full_name": "stackchain/api"}, + }], + "page": page, "total": 51, "has_more": page == 1, + } + + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) + + assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True + assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True + assert requested == [ + ("issue", 1), ("issue", 2), + ("pull", 1), ("pull", 2), ] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 76d9919..0c14400 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -119,6 +119,102 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)}) assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0} +def test_work_pager_is_single_flight_and_unions_pull_responsibilities(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +let calls = 0; +let release; +const pages = []; +const states = []; +const statuses = []; +const pager = buildMyWork.createWorkPager({{ + load: (stream, page) => {{ + calls += 1; + return new Promise(resolve => {{ release = () => resolve({{ + stream, page, total: 51, has_more: false, + items: [ + {{id:1, title:'shared', work_reasons:['review_requested']}}, + {{id:51, title:'older', work_reasons:['review_requested']}} + ], + }}); }}); + }}, + onItems: (stream, items) => states.push({{stream, items}}), + onPagination: pagination => pages.push(pagination), + onStatus: status => statuses.push(status), +}}); +pager.reset({{review:{{page:1,total:51,has_more:true}}}}); +const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}]; +const first = pager.loadMore('review', existing); +const duplicate = pager.loadMore('review', existing); +release(); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls, pages, states, statuses, results +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["calls"] == 1 + assert output["states"] == [{ + "stream": "review", + "items": [ + {"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]}, + {"id": 51, "title": "older", "work_reasons": ["review_requested"]}, + ], + }] + assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False} + assert output["statuses"] == [ + "Loading older review requests…", "51 of 51 review requests loaded." + ] + assert output["results"] == [True, False] + + +def test_work_pager_keeps_items_and_retries_same_page_after_failure(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const requested = []; +const states = []; +const statuses = []; +const pager = buildMyWork.createWorkPager({{ + load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }}, + onItems: (stream, items) => states.push(items), + onPagination: () => {{}}, + onStatus: status => statuses.push(status), +}}); +pager.reset({{issue:{{page:2,total:125,has_more:true}}}}); +pager.loadMore('issue', [{{id:1}}]).then(result => + pager.loadMore('issue', [{{id:1}}]).then(retry => + process.stdout.write(JSON.stringify({{requested,states,statuses,result,retry}})) + ) +); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == { + "requested": [["issue", 3], ["issue", 3]], + "states": [], + "statuses": [ + "Loading older issues…", "Could not load older issues. Retry.", + "Loading older issues…", "Could not load older issues. Retry.", + ], + "result": False, + "retry": False, + } + + +@pytest.mark.anyio +async def test_mobile_my_work_exposes_truthful_work_pagination_control(): + html = await dashboard() + + assert 'id="work-page-status"' in html + assert 'id="load-more-work"' in html + assert '>Load older work<' in html + assert '.load-more-work { min-height:44px;' in html + + def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success(): script = f""" const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py new file mode 100644 index 0000000..5928cb0 --- /dev/null +++ b/tests/test_work_pages.py @@ -0,0 +1,60 @@ +import httpx +import pytest + +from src import main + + +@pytest.mark.anyio +async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable(monkeypatch): + requested = [] + + async def page_loader(stream, page): + requested.append((stream, page)) + return { + "stream": stream, + "page": page, + "total": 84, + "has_more": False, + "items": [{ + "id": 51, "number": 51, "title": "Older issue", "state": "open", + "labels": [], "assignees": [{"login": "timmy"}], + "repository": {"full_name": "stackchain/api"}, + "updated_at": "2026-08-07T10:00:00Z", + "html_url": "https://forge.example/stackchain/api/issues/51", + }], + } + + monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/work/issue?page=2") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert requested == [("issue", 2)] + assert response.json() == { + "stream": "issue", "page": 2, "total": 84, "has_more": False, + "items": [{ + "id": 51, "number": 51, "title": "Older issue", "state": "open", + "labels": [], "assignees": ["timmy"], "repository": "stackchain/api", + "updated_at": "2026-08-07T10:00:00Z", + "url": "https://forge.example/stackchain/api/issues/51", + }], + } + + +@pytest.mark.anyio +async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monkeypatch): + called = False + + async def page_loader(stream, page): + nonlocal called + called = True + + monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/work/unknown?page=2") + + assert response.status_code == 422 + assert called is False \ No newline at end of file