feat: paginate mobile My Work queues (#169)
This commit is contained in:
parent
29a786cea3
commit
625e65e7e4
|
|
@ -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; }
|
|||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -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 = '<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-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 '<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>';
|
||||
}).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 => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
68
src/main.py
68
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()
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
60
tests/test_work_pages.py
Normal file
60
tests/test_work_pages.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user