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%; }
|
.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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
68
src/main.py
68
src/main.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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