feat: paginate mobile My Work queues (#169)
All checks were successful
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy 2026-08-07 05:35:45 +00:00
parent 29a786cea3
commit 625e65e7e4
8 changed files with 588 additions and 84 deletions

View File

@ -69,6 +69,8 @@ textarea { resize: vertical; min-height: 120px; }
.read-update { min-height:44px; width:100%; } .read-update { min-height:44px; width:100%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; } .load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; } .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 { 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-bulk button { min-height:44px; width:100%; }
.my-work[data-stale="true"] { border-color:#fcd34d; } .my-work[data-stale="true"] { border-color:#fcd34d; }
@ -207,6 +209,8 @@ textarea { resize: vertical; min-height: 120px; }
</div> </div>
</div> </div>
<div class="my-work-list" id="my-work-list"></div> <div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
<div class="small" id="notification-page-status" aria-live="polite"></div> <div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button> <button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div> <div class="small" id="my-work-action-status" aria-live="assertive"></div>
@ -522,6 +526,7 @@ textarea { resize: vertical; min-height: 120px; }
let lastNotifications = []; let lastNotifications = [];
let lastContextSnapshot = null; let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false }; let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let hasContextSnapshot = false; let hasContextSnapshot = false;
let selectedReview = null; let selectedReview = null;
let reviewTrigger = null; let reviewTrigger = null;
@ -602,6 +607,15 @@ textarea { resize: vertical; min-height: 120px; }
return payload; 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) { async function postNotificationReply(notificationId, body) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', { const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST', method: 'POST',
@ -648,6 +662,20 @@ textarea { resize: vertical; min-height: 120px; }
}, },
onStatus: message => { qs('#my-work-action-status').textContent = message; }, 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({ const notificationReplier = createNotificationReplier({
post: postNotificationReply, post: postNotificationReply,
storage: localStorage, storage: localStorage,
@ -705,7 +733,25 @@ textarea { resize: vertical; min-height: 120px; }
function renderContextSnapshot(data) { function renderContextSnapshot(data) {
liveMode = true; liveMode = true;
hasContextSnapshot = 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; lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
if (data.error && lastMyWork.length) markMyWorkStale(); if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data); else paintMyWork(data);
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' + qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
@ -759,11 +805,36 @@ textarea { resize: vertical; min-height: 120px; }
qs('#my-work').removeAttribute('data-stale'); qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ? qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.'; summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork(); 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() { function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter); const visible = filterMyWork(lastMyWork, selectedWorkFilter);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => { qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind); const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const contents = const contents =
@ -786,7 +857,9 @@ textarea { resize: vertical; min-height: 120px; }
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>'; return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
} }
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>'; return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>'; }).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
document.querySelectorAll('[data-review-index]').forEach(button => { document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], 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', () => qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications) 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 () => { qs('#bulk-mark-read').addEventListener('click', async () => {
const allIds = notificationIds(filterMyWork(lastMyWork, 'update')); const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
const ids = allIds.slice(0, 50); const ids = allIds.slice(0, 50);
@ -1701,6 +1789,7 @@ textarea { resize: vertical; min-height: 120px; }
item.setAttribute('aria-pressed', String(item === button)) item.setAttribute('aria-pressed', String(item === button))
); );
renderMyWork(); renderMyWork();
updateWorkPaginationControls();
}); });
}); });
contextPoller.start(); contextPoller.start();

View File

@ -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 }) { function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
let selected = null; let selected = null;
let loadVersion = 0; let loadVersion = 0;
@ -295,6 +355,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.notificationIds = notificationIds; buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger; buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager; buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader; buildMyWork.createNotificationReader = createNotificationReader;
buildMyWork.createNotificationReplier = createNotificationReplier; buildMyWork.createNotificationReplier = createNotificationReplier;
module.exports = buildMyWork; module.exports = buildMyWork;

View File

@ -14,6 +14,14 @@ REVIEW_DIFF_MAX_LINES = 400
_client: httpx.AsyncClient | None = None _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): class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review.""" """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") return await fetch("user/repos?limit=50")
async def issues() -> list[dict]: WORK_SEARCHES = {
return await fetch( "issue": ("assigned=true", "issues", None),
"repos/issues/search?state=open&assigned=true&type=issues&limit=50" "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: 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: async def is_assigned_issue(repository: str, number: int) -> bool:
assigned = await issues() return await _work_contains("issue", repository, number)
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 [])
)
async def pull_requests() -> list[dict]: async def pull_requests() -> WorkItems:
assigned, review_requested = await asyncio.gather( assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"), work_page("pull"),
fetch( work_page("review"),
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
),
) )
merged: dict[int, dict] = {} merged: dict[int, dict] = {}
for reason, pulls in ( for result in (
("assigned_to_me", assigned or []), assigned,
("review_requested", review_requested or []), review_requested,
): ):
for pull in pulls: for pull in result["items"]:
identity = pull.get("id") identity = pull.get("id")
if identity not in merged: if identity not in merged:
merged[identity] = {**pull, "work_reasons": []} merged[identity] = {**pull, "work_reasons": []}
merged[identity]["work_reasons"].append(reason) for reason in pull.get("work_reasons", []):
return list(merged.values()) 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: async def is_requested_review(repository: str, number: int) -> bool:
pulls = await fetch( return await _work_contains("review", repository, number)
"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 [])
)
async def is_assigned_pull(repository: str, number: int) -> bool: async def is_assigned_pull(repository: str, number: int) -> bool:
pulls = await fetch( return await _work_contains("pull", repository, number)
"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 [])
)
async def pull_completion_detail(repository: str, number: int) -> dict: async def pull_completion_detail(repository: str, number: int) -> dict:

View File

@ -4,7 +4,7 @@ import time
from collections.abc import Awaitable from collections.abc import Awaitable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Path as PathParam, Query from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -57,6 +57,7 @@ REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0 ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0 NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0 NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5 BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0 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) if isinstance(p, dict)
and all(field in p for field in ("id", "number", "title", "state", "html_url")) 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.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router) app.include_router(frontend_router)
@ -227,7 +265,7 @@ app.include_router(frontend_router)
@app.middleware("http") @app.middleware("http")
async def prevent_live_api_caching(request, call_next): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) 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/") request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review") and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or ( ) 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)) 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: async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather( repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests() repos(), issues(), pull_requests()

View File

@ -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/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=", "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/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
} }

View File

@ -2,27 +2,86 @@ import asyncio
import json import json
import pytest import pytest
import httpx
from src import gitea_proxy from src import gitea_proxy
from src import main 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 @pytest.mark.anyio
async def test_work_collections_include_supported_review_request_search(monkeypatch): async def test_work_collections_include_supported_review_request_search(monkeypatch):
requested_paths = [] requested_streams = []
async def fake_fetch(path): async def fake_page(stream, page=1, limit=50):
requested_paths.append(path) requested_streams.append((stream, page, limit))
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)
assert await gitea_proxy.issues() == [] assert await gitea_proxy.issues() == []
assert await gitea_proxy.pull_requests() == [] assert await gitea_proxy.pull_requests() == []
assert requested_paths == [ assert requested_streams == [
"repos/issues/search?state=open&assigned=true&type=issues&limit=50", ("issue", 1, 50),
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50", ("pull", 1, 50),
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", ("review", 1, 50),
] ]
@ -41,12 +100,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
"repository": {"full_name": "stackchain/mobile"}, "repository": {"full_name": "stackchain/mobile"},
} }
async def fake_fetch(path): async def fake_page(stream, page=1, limit=50):
if "assigned=true" in path: items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
return [assigned] reason = "assigned_to_me" if stream == "pull" else "review_requested"
return [assigned.copy(), review_only] 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() pulls = await gitea_proxy.pull_requests()
@ -60,13 +123,13 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
started = [asyncio.Event(), asyncio.Event()] started = [asyncio.Event(), asyncio.Event()]
release = asyncio.Event() release = asyncio.Event()
async def fake_fetch(path): async def fake_page(stream, page=1, limit=50):
index = 0 if "assigned=true" in path else 1 index = 0 if stream == "pull" else 1
started[index].set() started[index].set()
await release.wait() 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()) task = asyncio.create_task(gitea_proxy.pull_requests())
try: try:
await asyncio.wait_for( await asyncio.wait_for(
@ -80,30 +143,51 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
@pytest.mark.anyio @pytest.mark.anyio
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch): async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
requested_paths = [] requested = []
async def fake_fetch(path): async def fake_page(stream, page=1, limit=50):
requested_paths.append(path) requested.append((stream, page))
return [ items = [] if page == 1 else [{
{ "number": 77,
"number": 7, "repository": {"full_name": "stackchain/api"},
"repository": {"full_name": "stackchain/api"}, }]
}, return {
{ "stream": stream, "items": items, "page": page,
"number": 8, "total": 51, "has_more": page == 1,
"repository": {"full_name": "stackchain/api"}, }
},
]
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/api", 9) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False assert requested == [
assert requested_paths == [ ("review", 1), ("review", 2),
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", ("review", 1), ("review", 2),
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50", ]
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
@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),
] ]

View File

@ -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} 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(): def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f""" script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});

60
tests/test_work_pages.py Normal file
View File

@ -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