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; }
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