Compare commits
16 Commits
v0.1.0-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d602f85f | |||
| 2a06cf1daa | |||
| b0dbcb5a25 | |||
| 2766a75f24 | |||
| 5f9b5b3a7f | |||
| cec5b93665 | |||
| 5af34508b4 | |||
| 9b1abf37d0 | |||
| b0b28a936c | |||
| f23be99ab4 | |||
| 1484c579f9 | |||
| afec9b11d1 | |||
| ca37d8a1bb | |||
| 94bd2cafba | |||
| 2b630d3fa8 | |||
| 19732ea5a6 |
13
README.md
13
README.md
|
|
@ -131,6 +131,17 @@ losing either order. Account changes discard stale responses and retry work. Res
|
|||
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
||||
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
||||
|
||||
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
|
||||
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
||||
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
||||
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
||||
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
|
||||
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
|
||||
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
|
||||
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
|
||||
private-state key; stale responses from a prior account are discarded. Set
|
||||
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
|
|
@ -310,7 +321,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
|||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
|
|
|
|||
|
|
@ -888,8 +888,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.current-work-pin { display:none; }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
|
||||
@media (max-width:600px) {
|
||||
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
|
||||
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
|
||||
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
|
|
@ -1551,7 +1554,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-recent-work-route] { min-height:56px; overflow-wrap:anywhere; }
|
||||
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
|
||||
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
|
||||
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
|
||||
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
|
||||
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
|
|
|
|||
|
|
@ -435,9 +435,15 @@
|
|||
mobileRecentWork = createMobileRecentWork({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
fetchJson:fetchReviewJson,
|
||||
document,
|
||||
section:qs('#mobile-recent-work'),
|
||||
list:qs('#mobile-recent-work-list'),
|
||||
pinnedSection:qs('#mobile-pinned-work'),
|
||||
pinnedList:qs('#mobile-pinned-work-list'),
|
||||
pinnedToggle:qs('#mobile-pinned-work-toggle'),
|
||||
detailPins:qsa('[data-current-work-pin]'),
|
||||
status:qs('#mobile-recent-work-status'),
|
||||
openRoute:fragment => {
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
if (sheet.open) sheet.close();
|
||||
|
|
@ -445,6 +451,7 @@
|
|||
workRoute.sync();
|
||||
},
|
||||
});
|
||||
mobileRecentWork.startLifecycle({window, document});
|
||||
mobileQueuePriority = createMobileQueuePriority({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
|
|
@ -2076,6 +2083,7 @@
|
|||
closeOpenWorkSheets();
|
||||
await openRoutedWorkSection(item);
|
||||
mobileRecentWork.record(item);
|
||||
mobileRecentWork.setCurrent(item);
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||
},
|
||||
|
|
@ -2106,6 +2114,7 @@
|
|||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
mobileRecentWork.setCurrent(null);
|
||||
issueVoiceReply.cancel();
|
||||
pullVoiceReply.cancel();
|
||||
updateVoiceReply.cancel();
|
||||
|
|
@ -5665,6 +5674,7 @@
|
|||
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||||
mobileQueuePriority.render();
|
||||
renderMobileQueuePresentation();
|
||||
void mobileRecentWork.load();
|
||||
void mobileQueuePriority.load();
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,7 @@
|
|||
<div class="small" id="issue-sheet-key"></div>
|
||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||
|
|
@ -1553,6 +1554,7 @@
|
|||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
||||
|
|
@ -1676,6 +1678,7 @@
|
|||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button id="close-pull-sheet" type="button">Close</button>
|
||||
</div>
|
||||
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
|
||||
|
|
@ -1912,6 +1915,7 @@
|
|||
<div class="small" id="review-sheet-key"></div>
|
||||
<h3 id="review-sheet-title">Pull request review</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button class="review-action" id="close-review-sheet">Close</button>
|
||||
</div>
|
||||
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
||||
|
|
@ -2183,6 +2187,12 @@
|
|||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||
</section>
|
||||
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
||||
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
|
||||
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
|
||||
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
|
||||
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
|
||||
</section>
|
||||
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
||||
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
||||
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,66 @@
|
|||
|
||||
const storage = options.storage;
|
||||
const getLogin = options.getLogin;
|
||||
const fetchJson = options.fetchJson;
|
||||
const limit = Math.max(1, Number(options.limit) || 5);
|
||||
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
|
||||
const prefix = 'stackchain.mobile-recent-work.v1.';
|
||||
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
||||
const setTimer = options.setTimeout || setTimeout;
|
||||
const clearTimer = options.clearTimeout || clearTimeout;
|
||||
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
|
||||
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
|
||||
let syncFlight = null;
|
||||
let syncAccount = '';
|
||||
let debounceTimer = null;
|
||||
let retryTimer = null;
|
||||
let retryAccount = '';
|
||||
let retryAttempt = 0;
|
||||
let operationSequence = 0;
|
||||
let pinsExpanded = false;
|
||||
let currentItem = null;
|
||||
|
||||
const detailPins = Array.from(options.detailPins || []);
|
||||
detailPins.forEach(button => button.addEventListener?.('click', () => {
|
||||
if (!currentItem) return;
|
||||
const isPinned = pinned().some(item => item.route === currentItem.route);
|
||||
if (isPinned) unpin(currentItem.route);
|
||||
else pin(currentItem);
|
||||
}));
|
||||
|
||||
options.pinnedToggle?.addEventListener?.('click', () => {
|
||||
pinsExpanded = !pinsExpanded;
|
||||
render();
|
||||
});
|
||||
|
||||
function operationId() {
|
||||
operationSequence += 1;
|
||||
return Date.now().toString(36) + '-' + operationSequence.toString(36);
|
||||
}
|
||||
|
||||
function clearRetry(resetAttempt = false) {
|
||||
if (retryTimer) clearTimer(retryTimer);
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
if (resetAttempt) retryAttempt = 0;
|
||||
}
|
||||
|
||||
function scheduleRetry(accountKey) {
|
||||
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
|
||||
retryAccount = accountKey;
|
||||
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
retryTimer = setTimer(() => {
|
||||
const timer = retryTimer;
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
if (timer) clearTimer(timer);
|
||||
if (key() === accountKey && hasPending(read())) void sync();
|
||||
}, delay);
|
||||
return true;
|
||||
}
|
||||
|
||||
function login() {
|
||||
return String(getLogin?.() || '').trim().toLowerCase();
|
||||
|
|
@ -35,68 +91,365 @@
|
|||
}
|
||||
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
||||
if (!title) return null;
|
||||
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
||||
}
|
||||
|
||||
function normalizeList(value, maximum = limit) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const item = normalize(candidate);
|
||||
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
||||
if (unique.length === maximum) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizePinOps(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const action = candidate?.action;
|
||||
const item = action === 'pin' ? normalize(candidate.item) : null;
|
||||
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
|
||||
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
|
||||
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
|
||||
const normalized = action === 'pin' ? {action, item} : {action, route};
|
||||
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
|
||||
unique.push(normalized);
|
||||
}
|
||||
if (unique.length === pinnedLimit) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizePending(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const item = normalize(candidate);
|
||||
if (!item || unique.some(existing => existing.route === item.route)) continue;
|
||||
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
|
||||
unique.push(item);
|
||||
if (unique.length === limit) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function empty() {
|
||||
return {items:[], pinned:[], pending:[], pinOps:[]};
|
||||
}
|
||||
|
||||
function read() {
|
||||
const storageKey = key();
|
||||
if (!storageKey) return empty();
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
|
||||
return {
|
||||
kind,
|
||||
...(repository ? { repository } : {}),
|
||||
number,
|
||||
title,
|
||||
route,
|
||||
items:normalizeList(parsed?.items),
|
||||
pinned:normalizeList(parsed?.pinned, pinnedLimit),
|
||||
pending:normalizePending(parsed?.pending),
|
||||
pinOps:normalizePinOps(parsed?.pinOps),
|
||||
};
|
||||
}
|
||||
|
||||
function items() {
|
||||
const storageKey = key();
|
||||
if (!storageKey) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(normalize).filter(Boolean).slice(0, limit);
|
||||
} catch (_) {
|
||||
return [];
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
|
||||
function record(item) {
|
||||
const storageKey = key();
|
||||
const normalized = normalize(item);
|
||||
if (!storageKey || !normalized) return false;
|
||||
const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||
function persist(value, accountKey = key()) {
|
||||
if (!accountKey || accountKey !== key()) return false;
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(next));
|
||||
storage.setItem(accountKey, JSON.stringify({
|
||||
items:normalizeList(value.items),
|
||||
pinned:normalizeList(value.pinned, pinnedLimit),
|
||||
pending:normalizePending(value.pending),
|
||||
pinOps:normalizePinOps(value.pinOps),
|
||||
}));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const recent = items();
|
||||
const list = options.list;
|
||||
const section = options.section;
|
||||
if (!list || !section || !options.document) return recent.length;
|
||||
const rows = recent.map(item => {
|
||||
const detail = item.kind === 'update'
|
||||
function hasPending(value) {
|
||||
return value.pending.length > 0 || value.pinOps.length > 0;
|
||||
}
|
||||
|
||||
function announce(value = read(), status = null) {
|
||||
if (!options.status) return;
|
||||
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
|
||||
}
|
||||
|
||||
function items() {
|
||||
return read().items;
|
||||
}
|
||||
|
||||
function pinned() {
|
||||
return read().pinned;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const value = read();
|
||||
const pendingCount = value.pending.length + value.pinOps.length;
|
||||
return {pending:pendingCount > 0, pendingCount};
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (!fetchJson || !key()) return false;
|
||||
if (debounceTimer) clearTimer(debounceTimer);
|
||||
debounceTimer = setTimer(() => {
|
||||
debounceTimer = null;
|
||||
void sync();
|
||||
}, debounceMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
function record(item) {
|
||||
const accountKey = key();
|
||||
const normalized = normalize(item);
|
||||
if (!accountKey || !normalized) return false;
|
||||
const current = read();
|
||||
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
|
||||
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
|
||||
: current.pinned;
|
||||
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function queuePinOp(current, operation) {
|
||||
const route = operation.item?.route || operation.route;
|
||||
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
|
||||
}
|
||||
|
||||
function pin(item) {
|
||||
const accountKey = key();
|
||||
const normalized = normalize(item);
|
||||
if (!accountKey || !normalized) return false;
|
||||
const current = read();
|
||||
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
|
||||
queuePinOp(current, {action:'pin', item:normalized});
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function unpin(route) {
|
||||
const accountKey = key();
|
||||
route = String(route || '');
|
||||
if (!accountKey || !route) return false;
|
||||
const current = read();
|
||||
if (!current.pinned.some(item => item.route === route)) return false;
|
||||
current.pinned = current.pinned.filter(item => item.route !== route);
|
||||
queuePinOp(current, {action:'unpin', route});
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPinOps(remote, operations) {
|
||||
let result = normalizeList(remote, pinnedLimit);
|
||||
for (const operation of [...normalizePinOps(operations)].reverse()) {
|
||||
const route = operation.item?.route || operation.route;
|
||||
result = operation.action === 'pin'
|
||||
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
|
||||
: result.filter(item => item.route !== route);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
|
||||
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
||||
const remote = normalizeList(snapshot.items);
|
||||
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
|
||||
const unsent = normalizePending(pending);
|
||||
const unsentPinOps = normalizePinOps(pinOps);
|
||||
const value = {
|
||||
items:normalizeList([...unsent, ...remote]),
|
||||
pinned:applyPinOps(remotePinned, unsentPinOps),
|
||||
pending:unsent,
|
||||
pinOps:unsentPinOps,
|
||||
};
|
||||
persist(value, accountKey);
|
||||
announce(value);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function drain(accountKey) {
|
||||
while (key() === accountKey) {
|
||||
const current = read();
|
||||
if (!hasPending(current)) return current;
|
||||
const sending = current.pending[current.pending.length - 1];
|
||||
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
|
||||
announce(current, 'Syncing recent work…');
|
||||
try {
|
||||
let snapshot;
|
||||
if (sending) {
|
||||
snapshot = await fetchJson('api/v1/recent-work', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
|
||||
});
|
||||
} else {
|
||||
const isPin = pinOperation.action === 'pin';
|
||||
snapshot = await fetchJson('api/v1/recent-work/pin', {
|
||||
method:isPin ? 'PUT' : 'DELETE',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
|
||||
});
|
||||
}
|
||||
if (key() !== accountKey) return read();
|
||||
const latest = read();
|
||||
const pending = sending
|
||||
? latest.pending.filter(item => item.operationId !== sending.operationId)
|
||||
: latest.pending;
|
||||
const pinOps = pinOperation
|
||||
? latest.pinOps.filter(operation => {
|
||||
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
|
||||
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
|
||||
})
|
||||
: latest.pinOps;
|
||||
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
|
||||
retryAttempt = 0;
|
||||
} catch (_error) {
|
||||
if (key() === accountKey) {
|
||||
announce(read());
|
||||
scheduleRetry(accountKey);
|
||||
}
|
||||
return read();
|
||||
}
|
||||
}
|
||||
return read();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
||||
const accountKey = key();
|
||||
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
|
||||
else if (retryTimer) clearRetry(false);
|
||||
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
|
||||
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
||||
syncAccount = accountKey;
|
||||
syncFlight = drain(accountKey).finally(() => {
|
||||
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
|
||||
});
|
||||
return syncFlight;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const accountKey = key();
|
||||
if (!fetchJson || !accountKey) return read();
|
||||
try {
|
||||
const snapshot = await fetchJson('api/v1/recent-work');
|
||||
if (key() !== accountKey) return read();
|
||||
const current = read();
|
||||
adopt(snapshot, accountKey, current.pending, current.pinOps);
|
||||
return hasPending(current) ? sync() : read();
|
||||
} catch (_error) {
|
||||
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
function startLifecycle(lifecycle = {}) {
|
||||
const reconcile = () => hasPending(read()) ? sync() : load();
|
||||
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
||||
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
||||
if (!lifecycle.document.hidden) void reconcile();
|
||||
});
|
||||
return reconcile;
|
||||
}
|
||||
|
||||
function detail(item) {
|
||||
return item.kind === 'update'
|
||||
? 'Update · #' + item.number
|
||||
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
||||
}
|
||||
|
||||
function row(item, isPinned) {
|
||||
const itemDetail = detail(item);
|
||||
const wrapper = options.document.createElement('div');
|
||||
const button = options.document.createElement('button');
|
||||
const action = options.document.createElement('button');
|
||||
const copy = options.document.createElement('span');
|
||||
const primary = options.document.createElement('strong');
|
||||
const secondary = options.document.createElement('small');
|
||||
wrapper.setAttribute('class', 'mobile-recent-work-row');
|
||||
primary.textContent = item.title;
|
||||
secondary.textContent = detail;
|
||||
secondary.textContent = itemDetail;
|
||||
copy.appendChild(primary);
|
||||
copy.appendChild(secondary);
|
||||
button.appendChild(copy);
|
||||
button.setAttribute('type', 'button');
|
||||
button.setAttribute('data-recent-work-route', item.route);
|
||||
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
|
||||
button.addEventListener('click', () => options.openRoute?.(item.route));
|
||||
return button;
|
||||
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
|
||||
button.addEventListener('click', () => {
|
||||
if (isPinned) record(item);
|
||||
options.openRoute?.(item.route);
|
||||
});
|
||||
list.replaceChildren(...rows);
|
||||
section.hidden = rows.length === 0;
|
||||
return rows.length;
|
||||
action.textContent = isPinned ? 'Unpin' : 'Pin';
|
||||
action.setAttribute('type', 'button');
|
||||
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
|
||||
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
|
||||
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(action);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
return { items, record, render };
|
||||
function renderCurrent() {
|
||||
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
|
||||
detailPins.forEach(button => {
|
||||
button.hidden = !currentItem;
|
||||
if (!currentItem) return;
|
||||
button.textContent = isPinned ? 'Pinned' : 'Pin';
|
||||
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
|
||||
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
|
||||
});
|
||||
}
|
||||
|
||||
function setCurrent(item) {
|
||||
currentItem = normalize(item);
|
||||
renderCurrent();
|
||||
return Boolean(currentItem);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const recent = items();
|
||||
const fixed = pinned();
|
||||
const fixedRoutes = new Set(fixed.map(item => item.route));
|
||||
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
|
||||
const list = options.list;
|
||||
const section = options.section;
|
||||
if (list && section && options.document) {
|
||||
const rows = visibleRecent.map(item => row(item, false));
|
||||
list.replaceChildren(...rows);
|
||||
section.hidden = rows.length === 0;
|
||||
}
|
||||
if (options.pinnedList && options.pinnedSection && options.document) {
|
||||
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
|
||||
const rows = visiblePins.map(item => row(item, true));
|
||||
options.pinnedList.replaceChildren(...rows);
|
||||
options.pinnedSection.hidden = rows.length === 0;
|
||||
}
|
||||
if (options.pinnedToggle) {
|
||||
options.pinnedToggle.hidden = fixed.length <= 3;
|
||||
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
|
||||
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
|
||||
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
|
||||
}
|
||||
renderCurrent();
|
||||
return visibleRecent.length + fixed.length;
|
||||
}
|
||||
|
||||
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@
|
|||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
source_branch_deleted: 'Source branch deleted',
|
||||
release_rollback_prepared: 'Release rollback prepared',
|
||||
ci_job_retried: 'CI job retried',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
|
|
|
|||
|
|
@ -338,8 +338,18 @@ function mountTodayWeekReschedule({
|
|||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
||||
let result;
|
||||
try{
|
||||
result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
}catch(error){
|
||||
if(!dialog.open)dialog.showModal();
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
|
|
@ -347,11 +357,7 @@ function mountTodayWeekReschedule({
|
|||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
if(!dialog.open)dialog.showModal();
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ STORES = (
|
|||
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
||||
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
||||
)),
|
||||
Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
|
||||
Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
|
|
|
|||
35
src/disk_capacity.py
Normal file
35
src/disk_capacity.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Disk-capacity incident assessment shared by operations checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from os import PathLike
|
||||
|
||||
|
||||
def assess_disk_capacity(
|
||||
*,
|
||||
total_bytes: int,
|
||||
available_bytes: int,
|
||||
threshold_percent: float = 85.0,
|
||||
) -> dict[str, float | bool]:
|
||||
"""Return the capacity status using the runbook's inclusive threshold."""
|
||||
usage_percent = round((total_bytes - available_bytes) / total_bytes * 100, 1)
|
||||
return {
|
||||
"usage_percent": usage_percent,
|
||||
"threshold_percent": threshold_percent,
|
||||
"incident": usage_percent >= threshold_percent,
|
||||
}
|
||||
|
||||
|
||||
def read_disk_capacity(
|
||||
path: str | PathLike[str] = "/",
|
||||
*,
|
||||
threshold_percent: float = 85.0,
|
||||
) -> dict[str, float | bool]:
|
||||
"""Assess capacity for a real filesystem path."""
|
||||
usage = shutil.disk_usage(path)
|
||||
return assess_disk_capacity(
|
||||
total_bytes=usage.total,
|
||||
available_bytes=usage.free,
|
||||
threshold_percent=threshold_percent,
|
||||
)
|
||||
166
src/main.py
166
src/main.py
|
|
@ -72,6 +72,7 @@ from src.push_subscription_store import build_push_subscription_store
|
|||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
|
||||
from src.recent_work_store import RecentWorkStore
|
||||
from src.following_store import FollowingStore
|
||||
from src.unfiled_draft_store import (
|
||||
UnfiledDraftConflict,
|
||||
|
|
@ -605,6 +606,7 @@ StepUpAction = Literal[
|
|||
"merge_pull",
|
||||
"delete_source_branch",
|
||||
"prepare_release_rollback",
|
||||
"retry_ci_job",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"delete_comment",
|
||||
|
|
@ -971,6 +973,18 @@ class QueuePriorityCollection(BaseModel):
|
|||
order: list[str] = Field(min_length=9, max_length=9)
|
||||
|
||||
|
||||
class RecentWorkItem(BaseModel):
|
||||
kind: Literal["issue", "filed", "pull", "review", "update"]
|
||||
repository: str = Field(default="", max_length=200)
|
||||
number: PositiveInt
|
||||
title: str = Field(min_length=1, max_length=180)
|
||||
route: str = Field(min_length=1, max_length=300)
|
||||
|
||||
|
||||
class RecentWorkRoute(BaseModel):
|
||||
route: str = Field(min_length=1, max_length=300)
|
||||
|
||||
|
||||
class CompletedFiledReviewReceipt(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
|
|
@ -1790,7 +1804,7 @@ async def require_operator_session(request: Request, call_next):
|
|||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
|
|
@ -3114,6 +3128,12 @@ def _queue_priority_store() -> QueuePriorityStore:
|
|||
)
|
||||
|
||||
|
||||
def _recent_work_store() -> RecentWorkStore:
|
||||
return RecentWorkStore(
|
||||
os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
def _following_store() -> FollowingStore:
|
||||
return FollowingStore(
|
||||
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||
|
|
@ -3421,6 +3441,72 @@ async def replace_queue_priority(payload: QueuePriorityCollection):
|
|||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/recent-work")
|
||||
async def get_recent_work(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_recent_work_store().get, login)
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Recent work synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.post("/api/v1/recent-work")
|
||||
async def record_recent_work(payload: RecentWorkItem):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_recent_work_store().record, login, payload.model_dump()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Recent work synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/v1/recent-work/pin")
|
||||
async def pin_recent_work(payload: RecentWorkItem):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_recent_work_store().pin, login, payload.model_dump()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Recent work synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/v1/recent-work/pin")
|
||||
async def unpin_recent_work(payload: RecentWorkRoute):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_recent_work_store().unpin, login, payload.route
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Recent work synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/unfiled-drafts")
|
||||
async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
|
|
@ -7778,13 +7864,38 @@ async def pull_action_failure(
|
|||
)
|
||||
async def retry_pull_action_job(
|
||||
retry: PullReadyRequest,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
target = (
|
||||
f"{repository}#{number}@{retry.expected_head_sha}:"
|
||||
f"actions/{run_id}/jobs/{job_index}"
|
||||
)
|
||||
await _require_step_up(
|
||||
request, step_up_grant, action="retry_ci_job", target=target
|
||||
)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"ci_job_retried",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. No job was retried."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
|
||||
async def retry_job():
|
||||
if not _has_pull_workspace_access(
|
||||
|
|
@ -7800,14 +7911,26 @@ async def retry_pull_action_job(
|
|||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "New commits arrived. Reload checks before retrying this job."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
|
|
@ -7819,6 +7942,10 @@ async def retry_pull_action_job(
|
|||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
@ -8274,14 +8401,39 @@ async def release_action_failure(
|
|||
"/checks/{run_id}/jobs/{job_index}/retry"
|
||||
)
|
||||
async def retry_release_action_job(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
target = (
|
||||
f"{repository}#{number}@{commit_sha}:"
|
||||
f"actions/{run_id}/jobs/{job_index}"
|
||||
)
|
||||
await _require_step_up(
|
||||
request, step_up_grant, action="retry_ci_job", target=target
|
||||
)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"ci_job_retried",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. No job was retried."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
|
||||
async def retry_job():
|
||||
if not await gitea_proxy.can_recover_merged_release(
|
||||
|
|
@ -8297,8 +8449,16 @@ async def retry_release_action_job(
|
|||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
except ValueError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This release check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
|
|
@ -8310,6 +8470,10 @@ async def retry_release_action_job(
|
|||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
|
|||
178
src/recent_work_store.py
Normal file
178
src/recent_work_store.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Encrypted, account-scoped recent work shared by signed-in devices."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
|
||||
|
||||
|
||||
class RecentWorkStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
limit: int = 5,
|
||||
pinned_limit: int = 20,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self.limit = max(1, int(limit))
|
||||
self.pinned_limit = max(1, int(pinned_limit))
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="recent-work",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS recent_work ("
|
||||
"login TEXT PRIMARY KEY, items TEXT NOT NULL)"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize(item: dict) -> dict:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("recent work item is invalid")
|
||||
kind = item.get("kind")
|
||||
number = item.get("number")
|
||||
title = item.get("title")
|
||||
repository = item.get("repository", "")
|
||||
if (
|
||||
kind not in KINDS
|
||||
or not isinstance(number, int)
|
||||
or isinstance(number, bool)
|
||||
or number < 1
|
||||
or not isinstance(title, str)
|
||||
or not title.strip()
|
||||
):
|
||||
raise ValueError("recent work item is invalid")
|
||||
title = title.strip()[:180]
|
||||
if kind == "update":
|
||||
if repository:
|
||||
raise ValueError("recent work item is invalid")
|
||||
route = f"#/my-work/update/{number}"
|
||||
normalized = {"kind": kind, "number": number, "title": title, "route": route}
|
||||
else:
|
||||
if (
|
||||
not isinstance(repository, str)
|
||||
or repository.count("/") != 1
|
||||
or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
|
||||
for part in repository.split("/"))
|
||||
):
|
||||
raise ValueError("recent work item is invalid")
|
||||
route = f"#/my-work/{kind}/{repository}/{number}"
|
||||
normalized = {
|
||||
"kind": kind,
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"route": route,
|
||||
}
|
||||
if item.get("route", route) != route:
|
||||
raise ValueError("recent work item is invalid")
|
||||
return normalized
|
||||
|
||||
def _state(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"items": [], "pinned": []}, False
|
||||
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
||||
if isinstance(payload, list):
|
||||
payload = {"items": payload, "pinned": []}
|
||||
legacy = True
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
try:
|
||||
items = [self._normalize(item) for item in payload["items"]][: self.limit]
|
||||
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
|
||||
if len({item["route"] for item in pinned}) != len(pinned):
|
||||
raise ValueError("recent work item is invalid")
|
||||
return {"items": items, "pinned": pinned}, legacy
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
|
||||
def _seal(self, state: dict, login: str) -> str:
|
||||
return self._cipher.seal(state, binding=f"items:{login}")
|
||||
|
||||
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
||||
(login, self._seal(state, login)),
|
||||
)
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, legacy = self._state(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
||||
(self._seal(state, login), login, row[0]),
|
||||
)
|
||||
return state
|
||||
|
||||
def record(self, login: str, item: dict) -> dict:
|
||||
login = self._login(login)
|
||||
normalized = self._normalize(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
|
||||
state["pinned"] = [
|
||||
normalized,
|
||||
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||
] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
|
||||
def pin(self, login: str, item: dict) -> dict:
|
||||
login = self._login(login)
|
||||
normalized = self._normalize(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["pinned"] = [
|
||||
normalized,
|
||||
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||
][: self.pinned_limit]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
|
||||
def unpin(self, login: str, route: str) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(route, str) or not route:
|
||||
raise ValueError("recent work route is invalid")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
107
tests/e2e/test_mobile_pinned_work_release.py
Normal file
107
tests/e2e/test_mobile_pinned_work_release.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
};
|
||||
window.opened = [];
|
||||
window.recentWork = createMobileRecentWork({
|
||||
storage, getLogin:() => 'timmy', document,
|
||||
section:document.querySelector('#mobile-recent-work'),
|
||||
list:document.querySelector('#mobile-recent-work-list'),
|
||||
pinnedSection:document.querySelector('#mobile-pinned-work'),
|
||||
pinnedList:document.querySelector('#mobile-pinned-work-list'),
|
||||
pinnedToggle:document.querySelector('#mobile-pinned-work-toggle'),
|
||||
detailPins:document.querySelectorAll('[data-current-work-pin]'),
|
||||
status:document.querySelector('#mobile-recent-work-status'),
|
||||
openRoute:route => window.opened.push(route),
|
||||
});
|
||||
for (let number=1; number<=20; number += 1) {
|
||||
const item = {
|
||||
kind:'issue', repository:'stackchain/stackchain-dashboard', number,
|
||||
title:'Pinned mobile work ' + number,
|
||||
};
|
||||
window.recentWork.record(item);
|
||||
window.recentWork.pin(item);
|
||||
}
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#mobile-pinned-work")).to_be_visible()
|
||||
expect(page.locator("#mobile-recent-work")).to_be_hidden()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
|
||||
toggle = page.locator("#mobile-pinned-work-toggle")
|
||||
open_button = page.get_by_role(
|
||||
"button", name="Open Pinned mobile work 20, issue stackchain/stackchain-dashboard #20"
|
||||
).first
|
||||
pin_button = page.get_by_role(
|
||||
"button", name="Unpin Pinned mobile work 20"
|
||||
)
|
||||
for control in (open_button, pin_button, toggle):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
toggle.click()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(20)
|
||||
expect(toggle).to_have_text("Show fewer")
|
||||
assert toggle.get_attribute("aria-expanded") == "true"
|
||||
toggle.click()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||
|
||||
open_button.focus()
|
||||
open_button.press("Enter")
|
||||
assert page.evaluate("window.opened") == [
|
||||
"#/my-work/issue/stackchain/stackchain-dashboard/20"
|
||||
]
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#mobile-queue-sheet').close();
|
||||
document.querySelector('#issue-sheet').classList.add('open');
|
||||
window.recentWork.setCurrent({
|
||||
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1489,
|
||||
title:'Pin the current item from mobile detail',
|
||||
});
|
||||
}""")
|
||||
detail_pin = page.get_by_role(
|
||||
"button", name="Pin Pin the current item from mobile detail"
|
||||
)
|
||||
expect(detail_pin).to_be_visible()
|
||||
bounds = detail_pin.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
detail_pin.focus()
|
||||
detail_pin.press("Enter")
|
||||
expect(detail_pin).to_have_text("Pinned")
|
||||
expect(detail_pin).to_have_attribute("aria-pressed", "true")
|
||||
assert page.evaluate("window.recentWork.pinned()[0].number") == 1489
|
||||
browser.close()
|
||||
|
|
@ -18,6 +18,10 @@ def test_release_rollback_is_a_supported_step_up_action():
|
|||
assert "prepare_release_rollback" in get_args(main.StepUpAction)
|
||||
|
||||
|
||||
def test_ci_job_retry_is_a_supported_step_up_action():
|
||||
assert "retry_ci_job" in get_args(main.StepUpAction)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
|
@ -125,6 +129,158 @@ async def fresh_grant(client, action: str, target: str) -> str:
|
|||
return response.json()["grant"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ci_job_reruns_require_exact_one_time_authorization_and_audit_both_flows(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def record(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
assert principal_id == 42
|
||||
operation_id = f"operation-{len(lifecycle)}"
|
||||
lifecycle.append(("reserve", kind, target, operation_id))
|
||||
return operation_id
|
||||
|
||||
def finalize(self, operation_id):
|
||||
lifecycle.append(("finalize", operation_id))
|
||||
|
||||
def discard(self, operation_id):
|
||||
lifecycle.append(("discard", operation_id))
|
||||
|
||||
async def capabilities(repository, number):
|
||||
return {"authored": True, "assigned": False}
|
||||
|
||||
async def retry_pull(repository, number, head_sha, run_id, job_index):
|
||||
lifecycle.append(("retry-pull", repository, number, head_sha, run_id, job_index))
|
||||
return {"status": "queued"}
|
||||
|
||||
async def release_access(repository, number, commit_sha):
|
||||
return True
|
||||
|
||||
async def retry_release(repository, commit_sha, run_id, job_index):
|
||||
lifecycle.append(("retry-release", repository, commit_sha, run_id, job_index))
|
||||
return {"status": "queued"}
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
|
||||
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
|
||||
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry_pull)
|
||||
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry_release)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
pull_path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
|
||||
release_path = (
|
||||
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234"
|
||||
"/checks/91/jobs/3/retry"
|
||||
)
|
||||
pull_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||
release_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
signed_in = await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
assert signed_in.status_code == 200
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
|
||||
missing = await client.post(
|
||||
pull_path, json={"expected_head_sha": "abc1234"}, headers=headers
|
||||
)
|
||||
wrong_grant = await fresh_grant(
|
||||
client, "retry_ci_job", "stackchain/api#7@different:actions/91/jobs/3"
|
||||
)
|
||||
mismatched = await client.post(
|
||||
pull_path,
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
headers={**headers, "X-Step-Up-Grant": wrong_grant},
|
||||
)
|
||||
pull_grant = await fresh_grant(client, "retry_ci_job", pull_target)
|
||||
retried_pull = await client.post(
|
||||
pull_path,
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
headers={**headers, "X-Step-Up-Grant": pull_grant},
|
||||
)
|
||||
replayed = await client.post(
|
||||
pull_path,
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
headers={**headers, "X-Step-Up-Grant": pull_grant},
|
||||
)
|
||||
release_grant = await fresh_grant(client, "retry_ci_job", release_target)
|
||||
retried_release = await client.post(
|
||||
release_path,
|
||||
headers={**headers, "X-Step-Up-Grant": release_grant},
|
||||
)
|
||||
|
||||
assert missing.status_code == 428
|
||||
assert missing.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "retry_ci_job",
|
||||
"target": pull_target,
|
||||
}
|
||||
assert mismatched.status_code == 428
|
||||
assert retried_pull.status_code == 202
|
||||
assert replayed.status_code == 428
|
||||
assert retried_release.status_code == 202
|
||||
assert lifecycle == [
|
||||
("reserve", "ci_job_retried", pull_target, "operation-0"),
|
||||
("retry-pull", "stackchain/api", 7, "abc1234", 91, 3),
|
||||
("finalize", "operation-0"),
|
||||
("reserve", "ci_job_retried", release_target, "operation-3"),
|
||||
("retry-release", "stackchain/api", "abc1234", 91, 3),
|
||||
("finalize", "operation-3"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ci_job_retry_fails_closed_when_security_activity_is_unavailable(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
retry_calls = []
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
async def retry(*args):
|
||||
retry_calls.append(args)
|
||||
return {"status": "queued"}
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
|
||||
target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
grant = await fresh_grant(client, "retry_ci_job", target)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry)
|
||||
response = await client.post(
|
||||
path,
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "Security activity is temporarily unavailable. No job was retried."
|
||||
}
|
||||
assert retry_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
|
|
|
|||
19
tests/test_disk_capacity.py
Normal file
19
tests/test_disk_capacity.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from src.disk_capacity import assess_disk_capacity, read_disk_capacity
|
||||
|
||||
|
||||
def test_usage_at_incident_threshold_requires_action():
|
||||
status = assess_disk_capacity(total_bytes=100, available_bytes=15)
|
||||
|
||||
assert status == {
|
||||
"usage_percent": 85.0,
|
||||
"threshold_percent": 85.0,
|
||||
"incident": True,
|
||||
}
|
||||
|
||||
|
||||
def test_read_disk_capacity_assesses_a_real_filesystem(tmp_path):
|
||||
status = read_disk_capacity(tmp_path)
|
||||
|
||||
assert 0 <= status["usage_percent"] <= 100
|
||||
assert status["threshold_percent"] == 85.0
|
||||
assert status["incident"] is (status["usage_percent"] >= 85.0)
|
||||
|
|
@ -93,13 +93,14 @@ const recent=createRecentWork({{
|
|||
openRoute:route=>opened.push(route),
|
||||
}});
|
||||
const rendered=recent.render();
|
||||
list.children[1].click();
|
||||
list.children[1].children[0].click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
rendered,hidden:section.hidden,rows:list.children.map(button=>({{
|
||||
rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
|
||||
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
|
||||
primary:button.children[0].children[0].textContent,
|
||||
secondary:button.children[0].children[1].textContent,
|
||||
}})),opened,
|
||||
pin:row.children[1].attributes['data-recent-work-pin'],
|
||||
}});}}),opened,
|
||||
}}));
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
|
@ -113,18 +114,348 @@ process.stdout.write(JSON.stringify({{
|
|||
"route": "#/my-work/issue/stackchain/dashboard/7",
|
||||
"primary": "Fix mobile queue",
|
||||
"secondary": "Issue · stackchain/dashboard #7",
|
||||
"pin": "pin",
|
||||
},
|
||||
{
|
||||
"label": "Open Review release status, update #42",
|
||||
"route": "#/my-work/update/42",
|
||||
"primary": "Review release status",
|
||||
"secondary": "Update · #42",
|
||||
"pin": "pin",
|
||||
},
|
||||
],
|
||||
"opened": ["#/my-work/update/42"],
|
||||
}
|
||||
|
||||
|
||||
def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const values = new Map(); const calls=[]; const status={{textContent:''}};
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
|
||||
const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
|
||||
const recent=createRecentWork({{
|
||||
storage,getLogin:()=>'alice',status,debounceMs:99999,
|
||||
fetchJson:async (url, options={{}})=>{{
|
||||
calls.push([url,options.method||'GET']);
|
||||
return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
|
||||
}},
|
||||
}});
|
||||
recent.record(local);
|
||||
const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
|
||||
await recent.sync();
|
||||
process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["immediate"]["items"][0]["number"] == 7
|
||||
assert payload["immediate"]["status"] == "Sync pending."
|
||||
assert payload["immediate"]["state"]["pending"] is True
|
||||
assert [item["number"] for item in payload["settled"]] == [7, 9]
|
||||
assert payload["status"] == ""
|
||||
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
||||
|
||||
|
||||
def test_recent_work_drains_a_newer_same_route_generation_after_an_inflight_response():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const values = new Map(); const calls=[];
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old title'}};
|
||||
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New title'}};
|
||||
let releaseFirst;
|
||||
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
|
||||
const recent=createRecentWork({{
|
||||
storage,getLogin:()=>'alice',debounceMs:99999,
|
||||
fetchJson:async (_url, options)=>{{
|
||||
const sent=JSON.parse(options.body);
|
||||
calls.push(sent.title);
|
||||
if (calls.length === 1) return firstResponse;
|
||||
return {{items:[{{...newItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}};
|
||||
}},
|
||||
}});
|
||||
recent.record(oldItem);
|
||||
const syncing=recent.sync();
|
||||
await Promise.resolve();
|
||||
recent.record(newItem);
|
||||
releaseFirst({{items:[{{...oldItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}});
|
||||
await syncing;
|
||||
process.stdout.write(JSON.stringify({{calls,items:recent.items(),state:recent.state()}}));
|
||||
process.exit(0);
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["calls"] == ["Old title", "New title"]
|
||||
assert payload["items"][0]["title"] == "New title"
|
||||
assert payload["state"] == {"pending": False, "pendingCount": 0}
|
||||
|
||||
|
||||
def test_recent_work_retries_a_transient_sync_failure_without_a_lifecycle_event():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const values=new Map(); const timers=[]; let calls=0;
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',debounceMs:99999,retryMs:25,
|
||||
setTimeout:(callback,delay)=>{{const timer={{callback,delay,cleared:false}};timers.push(timer);return timer;}},
|
||||
clearTimeout:timer=>{{timer.cleared=true;}},
|
||||
fetchJson:async (_url,options)=>{{
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('temporary outage');
|
||||
return {{items:[JSON.parse(options.body)],pinned:[]}};
|
||||
}},
|
||||
}});
|
||||
recent.record({{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Keep me'}});
|
||||
await recent.sync();
|
||||
const afterFailure={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).map(timer=>timer.delay)}};
|
||||
const retryTimer=timers.find(timer=>!timer.cleared);
|
||||
retryTimer?.callback();
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const settled={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).length}};
|
||||
process.stdout.write(JSON.stringify({{afterFailure,settled}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["afterFailure"] == {
|
||||
"calls": 1,
|
||||
"state": {"pending": True, "pendingCount": 1},
|
||||
"active": [25],
|
||||
}
|
||||
assert payload["settled"] == {
|
||||
"calls": 2,
|
||||
"state": {"pending": False, "pendingCount": 0},
|
||||
"active": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_recent_work_drains_a_newer_pin_generation_after_an_inflight_response():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const values=new Map(); const calls=[];
|
||||
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old pin'}};
|
||||
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New pin'}};
|
||||
const route='#/my-work/issue/stackchain/dashboard/7';
|
||||
let releaseFirst;
|
||||
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',debounceMs:99999,
|
||||
fetchJson:async (_url,options)=>{{
|
||||
const sent=JSON.parse(options.body); calls.push(sent.title);
|
||||
if (calls.length === 1) return firstResponse;
|
||||
return {{items:[],pinned:[{{...newItem,route}}]}};
|
||||
}},
|
||||
}});
|
||||
recent.pin(oldItem);
|
||||
const syncing=recent.sync();
|
||||
await Promise.resolve();
|
||||
recent.pin(newItem);
|
||||
releaseFirst({{items:[],pinned:[{{...oldItem,route}}]}});
|
||||
await syncing;
|
||||
process.stdout.write(JSON.stringify({{calls,pinned:recent.pinned(),state:recent.state()}}));
|
||||
process.exit(0);
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["calls"] == ["Old pin", "New pin"]
|
||||
assert payload["pinned"][0]["title"] == "New pin"
|
||||
assert payload["state"] == {"pending": False, "pendingCount": 0}
|
||||
|
||||
|
||||
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
|
||||
const storageKey='stackchain.mobile-recent-work.v1.alice';
|
||||
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
|
||||
const status={{textContent:''}}; const calls=[]; const opened=[];
|
||||
function node(tag) {{
|
||||
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||
appendChild(child){{this.children.push(child);return child;}},
|
||||
replaceChildren(...children){{this.children=children;}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||
}};
|
||||
}}
|
||||
const recentList=node('div'); const recentSection=node('section');
|
||||
const pinnedList=node('div'); const pinnedSection=node('section');
|
||||
let remote={{items:[item],pinned:[]}};
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
|
||||
list:recentList,section:recentSection,pinnedList,pinnedSection,
|
||||
openRoute:route=>opened.push(route),
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
|
||||
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
|
||||
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
|
||||
return remote;
|
||||
}},
|
||||
}});
|
||||
const pinnedImmediately=recent.pin(item);
|
||||
recent.render();
|
||||
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
|
||||
recentRows:recentList.children.length,
|
||||
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
|
||||
await recent.sync();
|
||||
pinnedList.children[0].children[0].click();
|
||||
const unpinnedImmediately=recent.unpin(item.route);
|
||||
await recent.sync();
|
||||
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["pinnedImmediately"] is True
|
||||
assert payload["immediate"]["pinned"][0]["number"] == 1477
|
||||
assert payload["immediate"]["status"] == "Sync pending."
|
||||
assert payload["immediate"]["recentHidden"] is True
|
||||
assert payload["immediate"]["pinnedHidden"] is False
|
||||
assert payload["immediate"]["recentRows"] == 0
|
||||
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
|
||||
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
|
||||
assert payload["unpinnedImmediately"] is True
|
||||
assert payload["settled"] == []
|
||||
assert payload["calls"] == [
|
||||
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
|
||||
["api/v1/recent-work", "POST", payload["immediate"]["pinned"][0]],
|
||||
[
|
||||
"api/v1/recent-work/pin",
|
||||
"DELETE",
|
||||
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_current_detail_pin_follows_route_and_toggles_offline_first():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
const values=new Map();
|
||||
function button() {{ return {{hidden:true,textContent:'',attributes:{{}},listeners:{{}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||
click(){{this.listeners.click?.();}},
|
||||
}}; }}
|
||||
const detailPins=[button(),button(),button(),button()];
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',detailPins,debounceMs:99999,
|
||||
}});
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:1489,title:'Pin current detail'}};
|
||||
const pull={{kind:'pull',repository:'stackchain/api',number:20,title:'Ship API'}};
|
||||
recent.setCurrent(issue);
|
||||
const issueReady=detailPins.map(pin=>({{hidden:pin.hidden,text:pin.textContent,
|
||||
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}));
|
||||
detailPins[0].click();
|
||||
const pinned={{items:recent.pinned(),state:recent.state(),buttons:detailPins.map(pin=>({{text:pin.textContent,
|
||||
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}))}};
|
||||
recent.setCurrent(pull);
|
||||
const pullReady={{text:detailPins[1].textContent,label:detailPins[1].attributes['aria-label'],pressed:detailPins[1].attributes['aria-pressed']}};
|
||||
recent.setCurrent(issue);
|
||||
detailPins[2].click();
|
||||
const unpinned={{items:recent.pinned(),recent:recent.items(),state:recent.state(),text:detailPins[0].textContent}};
|
||||
recent.setCurrent(null);
|
||||
process.stdout.write(JSON.stringify({{issueReady,pinned,pullReady,unpinned,hidden:detailPins.map(pin=>pin.hidden)}}));
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["issueReady"] == [
|
||||
{
|
||||
"hidden": False,
|
||||
"text": "Pin",
|
||||
"pressed": "false",
|
||||
"label": "Pin Pin current detail",
|
||||
"action": "pin",
|
||||
}
|
||||
] * 4
|
||||
assert payload["pinned"]["items"][0]["route"] == "#/my-work/issue/stackchain/dashboard/1489"
|
||||
assert payload["pinned"]["state"] == {"pending": True, "pendingCount": 1}
|
||||
assert payload["pinned"]["buttons"] == [
|
||||
{
|
||||
"text": "Pinned",
|
||||
"pressed": "true",
|
||||
"label": "Unpin Pin current detail",
|
||||
"action": "unpin",
|
||||
}
|
||||
] * 4
|
||||
assert payload["pullReady"] == {
|
||||
"text": "Pin",
|
||||
"label": "Pin Ship API",
|
||||
"pressed": "false",
|
||||
}
|
||||
assert payload["unpinned"]["items"] == []
|
||||
assert payload["unpinned"]["recent"] == []
|
||||
assert payload["unpinned"]["state"] == {"pending": True, "pendingCount": 1}
|
||||
assert payload["unpinned"]["text"] == "Pin"
|
||||
assert payload["hidden"] == [True] * 4
|
||||
|
||||
|
||||
def test_pinned_work_is_compact_deduplicated_and_promotes_on_open():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
const makeItem=number=>({{kind:'issue',repository:'stackchain/dashboard',number,title:'Issue '+number}});
|
||||
const values=new Map([['stackchain.mobile-recent-work.v1.alice',JSON.stringify({{
|
||||
items:[1,2,3,4,5].map(makeItem),pinned:[1,2,3,4,5].map(makeItem),pending:[],pinOps:[]
|
||||
}})]]);
|
||||
function node(tag) {{ return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||
appendChild(child){{this.children.push(child);return child;}},
|
||||
replaceChildren(...children){{this.children=children;}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||
}}; }}
|
||||
const recentList=node('div'), recentSection=node('section');
|
||||
const pinnedList=node('div'), pinnedSection=node('section'), pinnedToggle=node('button');
|
||||
const opened=[];
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',document:{{createElement:node}},debounceMs:99999,
|
||||
list:recentList,section:recentSection,pinnedList,pinnedSection,pinnedToggle,
|
||||
openRoute:route=>opened.push(route),
|
||||
}});
|
||||
recent.render();
|
||||
const collapsed={{pinnedRows:pinnedList.children.length,recentRows:recentList.children.length,
|
||||
recentHidden:recentSection.hidden,toggleHidden:pinnedToggle.hidden,toggleText:pinnedToggle.textContent,
|
||||
expanded:pinnedToggle.attributes['aria-expanded'],controls:pinnedToggle.attributes['aria-controls']}};
|
||||
pinnedList.children[2].children[0].click();
|
||||
const promoted=recent.pinned().map(item=>item.number);
|
||||
pinnedToggle.click();
|
||||
const expanded={{pinnedRows:pinnedList.children.length,toggleText:pinnedToggle.textContent,
|
||||
ariaExpanded:pinnedToggle.attributes['aria-expanded']}};
|
||||
process.stdout.write(JSON.stringify({{collapsed,promoted,expanded,opened,state:recent.state()}}));
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["collapsed"] == {
|
||||
"pinnedRows": 3,
|
||||
"recentRows": 0,
|
||||
"recentHidden": True,
|
||||
"toggleHidden": False,
|
||||
"toggleText": "Show all 5",
|
||||
"expanded": "false",
|
||||
"controls": "mobile-pinned-work-list",
|
||||
}
|
||||
assert payload["promoted"] == [3, 1, 2, 4, 5]
|
||||
assert payload["expanded"] == {
|
||||
"pinnedRows": 5,
|
||||
"toggleText": "Show fewer",
|
||||
"ariaExpanded": "true",
|
||||
}
|
||||
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/3"]
|
||||
assert payload["state"] == {"pending": True, "pendingCount": 1}
|
||||
|
||||
|
||||
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
|
@ -132,10 +463,29 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
|||
|
||||
assert 'id="mobile-recent-work"' in html
|
||||
assert 'id="mobile-recent-work-list"' in html
|
||||
assert 'id="mobile-pinned-work"' in html
|
||||
assert 'id="mobile-pinned-work-list"' in html
|
||||
assert 'id="mobile-pinned-work-toggle"' in html
|
||||
assert 'aria-controls="mobile-pinned-work-list"' in html
|
||||
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
||||
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
||||
assert "createMobileRecentWork({" in dashboard
|
||||
assert "fetchJson:fetchReviewJson" in dashboard
|
||||
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
||||
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
|
||||
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
|
||||
assert "pinnedToggle:qs('#mobile-pinned-work-toggle')" in dashboard
|
||||
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
||||
assert "void mobileRecentWork.load();" in dashboard
|
||||
assert "mobileRecentWork.record(item)" in dashboard
|
||||
assert "mobileRecentWork.render()" in dashboard
|
||||
assert "workRoute.sync()" in dashboard
|
||||
assert html.count('data-current-work-pin') == 4
|
||||
assert "detailPins:qsa('[data-current-work-pin]')" in dashboard
|
||||
assert "mobileRecentWork.setCurrent(item)" in dashboard
|
||||
assert "mobileRecentWork.setCurrent(null)" in dashboard
|
||||
assert "[data-recent-work-route]" in css
|
||||
assert "min-height:56px" in css
|
||||
assert "[data-recent-work-pin]" in css
|
||||
assert "[data-current-work-pin]" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "min-width:0" in css
|
||||
|
|
|
|||
106
tests/test_recent_work_api.py
Normal file
106
tests/test_recent_work_api.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||
active_login = "Timmy"
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": active_login}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
entry = {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 1475,
|
||||
"title": "Sync recent work",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/1475",
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
forbidden = await client.post("/api/v1/recent-work", json=entry)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
saved = await client.post("/api/v1/recent-work", json=entry, headers=headers)
|
||||
fetched = await client.get("/api/v1/recent-work")
|
||||
active_login = "Alexander"
|
||||
other_account = await client.get("/api/v1/recent-work")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"items": [entry], "pinned": []}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
assert other_account.json() == {"items": [], "pinned": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||
active_login = "Timmy"
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": active_login}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
entry = {
|
||||
"kind": "pull",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 1476,
|
||||
"title": "Sync recent work",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/1476",
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
|
||||
active_login = "Alexander"
|
||||
isolated = await client.get("/api/v1/recent-work")
|
||||
active_login = "Timmy"
|
||||
unpinned = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/recent-work/pin",
|
||||
json={"route": entry["route"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert pinned.status_code == 200
|
||||
assert pinned.json() == {"items": [], "pinned": [entry]}
|
||||
assert isolated.json() == {"items": [], "pinned": []}
|
||||
assert unpinned.status_code == 200
|
||||
assert unpinned.json() == {"items": [], "pinned": []}
|
||||
89
tests/test_recent_work_store.py
Normal file
89
tests/test_recent_work_store.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import sqlite3
|
||||
|
||||
from src.recent_work_store import RecentWorkStore
|
||||
|
||||
|
||||
def item(number: int, *, title: str | None = None) -> dict:
|
||||
return {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": number,
|
||||
"title": title or f"Issue {number}",
|
||||
"route": f"#/my-work/issue/stackchain/dashboard/{number}",
|
||||
}
|
||||
|
||||
|
||||
def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path):
|
||||
database = tmp_path / "recent-work.sqlite3"
|
||||
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||
|
||||
for number in range(1, 7):
|
||||
store.record(" Timmy ", item(number))
|
||||
expected = store.record("timmy", item(3, title="Issue 3 updated"))
|
||||
|
||||
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
||||
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
||||
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert "Issue 3 updated" not in payload
|
||||
assert "#/my-work/issue" not in payload
|
||||
|
||||
|
||||
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
|
||||
database = tmp_path / "recent-work.sqlite3"
|
||||
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||
|
||||
store.record("timmy", item(1))
|
||||
pinned = store.pin("timmy", item(1))
|
||||
for number in range(2, 8):
|
||||
store.record("timmy", item(number))
|
||||
|
||||
assert pinned["pinned"] == [item(1)]
|
||||
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
|
||||
assert store.get("timmy")["pinned"] == [item(1)]
|
||||
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||
|
||||
store.record("timmy", item(1, title="Issue 1 current"))
|
||||
unpinned = store.unpin("timmy", item(1)["route"])
|
||||
|
||||
assert unpinned["pinned"] == []
|
||||
assert unpinned["items"][0] == item(1, title="Issue 1 current")
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert "Issue 1" not in payload
|
||||
assert "#/my-work/issue" not in payload
|
||||
|
||||
|
||||
def test_reopening_pinned_work_promotes_it_for_every_device(tmp_path):
|
||||
store = RecentWorkStore(
|
||||
tmp_path / "recent-work.sqlite3",
|
||||
encryption_key=b"r" * 32,
|
||||
)
|
||||
for number in range(1, 5):
|
||||
store.pin("timmy", item(number))
|
||||
|
||||
reopened = store.record("timmy", item(1, title="Issue 1 current"))
|
||||
|
||||
assert [entry["number"] for entry in reopened["pinned"]] == [1, 4, 3, 2]
|
||||
assert reopened["pinned"][0]["title"] == "Issue 1 current"
|
||||
assert store.get("timmy")["pinned"] == reopened["pinned"]
|
||||
|
||||
|
||||
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
||||
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
||||
|
||||
invalid = item(1)
|
||||
invalid["route"] = "https://attacker.example/"
|
||||
|
||||
try:
|
||||
store.record("timmy", invalid)
|
||||
except ValueError as error:
|
||||
assert str(error) == "recent work item is invalid"
|
||||
else:
|
||||
raise AssertionError("invalid route was accepted")
|
||||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
|||
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||
from src.recent_work_store import RecentWorkStore
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
|
||||
|
||||
|
|
@ -138,3 +139,33 @@ def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
|
|||
assert QueuePriorityStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == expected
|
||||
|
||||
|
||||
def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
path = state / "recent-work.sqlite3"
|
||||
private_item = {
|
||||
"kind": "issue",
|
||||
"repository": "private/canary",
|
||||
"number": 1475,
|
||||
"title": "Secret release investigation",
|
||||
"route": "#/my-work/issue/private/canary/1475",
|
||||
}
|
||||
expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item)
|
||||
|
||||
completed = run_rotation(state)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout)["recent-work"] == {
|
||||
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||
}
|
||||
assert private_item["title"] not in completed.stdout
|
||||
assert "timmy" not in completed.stdout
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v2:next:")
|
||||
assert RecentWorkStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == expected
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ def test_release_rollback_has_a_specific_security_activity_label():
|
|||
assert "release_rollback_prepared: 'Release rollback prepared'" in source
|
||||
|
||||
|
||||
def test_ci_job_retry_has_a_specific_security_activity_label():
|
||||
source = SECURITY_CENTER.read_text()
|
||||
|
||||
assert "ci_job_retried: 'CI job retried'" in source
|
||||
|
||||
|
||||
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
|
|
|
|||
|
|
@ -326,8 +326,9 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
|
|||
assert result["remaining"] == 1
|
||||
|
||||
|
||||
def run_mounted_confirmation(*, fail_move: bool = False) -> dict:
|
||||
def run_mounted_confirmation(*, fail_move: bool = False, fail_refresh: bool = False) -> dict:
|
||||
failure = "true" if fail_move else "false"
|
||||
refresh_failure = "true" if fail_refresh else "false"
|
||||
scenario = """
|
||||
const mount=require('./frontend/today-week-reschedule.js').mount;
|
||||
class Element {
|
||||
|
|
@ -361,7 +362,9 @@ const week={load:async()=>weekState,review:()=>({days:[{
|
|||
}]}),adopt(){}};
|
||||
const api=async(url)=>url==='api/v1/today' ? today : move;
|
||||
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
|
||||
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{},warm(){},
|
||||
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{
|
||||
if(__FAIL_REFRESH__)throw new Error('Refresh unavailable.');
|
||||
},warm(){},
|
||||
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
|
||||
await selectors['[data-work-session-reschedule-week]'].emit('click');
|
||||
await selectors['#today-week-reschedule-days'].children[0].emit('click');
|
||||
|
|
@ -376,7 +379,11 @@ if(__FAIL_MOVE__){
|
|||
await confirming;
|
||||
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
|
||||
"""
|
||||
return run_controller(scenario.replace("__FAIL_MOVE__", failure))
|
||||
return run_controller(
|
||||
scenario.replace("__FAIL_MOVE__", failure).replace(
|
||||
"__FAIL_REFRESH__", refresh_failure
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
|
||||
|
|
@ -393,6 +400,13 @@ def test_reschedule_dialog_reopens_with_retry_state_when_confirm_fails():
|
|||
}
|
||||
|
||||
|
||||
def test_reschedule_dialog_stays_closed_when_refresh_fails_after_confirm_succeeds():
|
||||
assert run_mounted_confirmation(fail_refresh=True) == {
|
||||
"openWhileSaving": False,
|
||||
"openAfterSettled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user