Compare commits

..

15 Commits

Author SHA1 Message Date
f9d602f85f Merge pull request 'Pin the current item from mobile detail' (#1490) from timmy/1489-pin-current-mobile-detail into main
Some checks failed
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 1h14m26s
CI / release-candidate (push) Has been cancelled
2026-08-28 02:11:46 +00:00
2a06cf1daa feat: pin current mobile detail item (Closes #1489)
Some checks failed
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / release-candidate (pull_request) Has been cancelled
2026-08-27 23:30:06 +00:00
b0dbcb5a25 Merge pull request 'Compact and rank pinned mobile work by recent use' (#1488) from timmy/1487-compact-rank-pinned-mobile-work into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 6s
2026-08-27 22:46:49 +00:00
2766a75f24 Compact and rank pinned mobile work
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 22:34:37 +00:00
5f9b5b3a7f Merge pull request 'Make mobile Recent Work sync generation-safe and self-healing' (#1486) from timmy/1485-mobile-recent-work-sync into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m49s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:55:39 +00:00
cec5b93665 fix: make mobile recent work sync generation-safe (Closes #1485)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 8m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 21:42:47 +00:00
5af34508b4 Merge pull request 'feat: Stabilize Today-to-Week dialog closure in packaged browser CI' (#1484) from timmy/1483-stabilize-today-to-week-dialog-closure-in-packag into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:15:15 +00:00
9b1abf37d0 fix: keep reschedule dialog closed after successful move
All checks were successful
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:48:02 +00:00
b0b28a936c Merge pull request 'Require fresh authorization and audit CI job reruns' (#1482)
Some checks failed
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 7m52s
CI / release-candidate (push) Has been skipped
2026-08-27 20:20:50 +00:00
f23be99ab4 security: fresh-authorize CI job reruns (Closes #1481)
All checks were successful
CI / lint (pull_request) Successful in 4m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:08:15 +00:00
1484c579f9 Merge pull request 'feat: Disk usage 86%' (#1480) from timmy/1479-disk-usage-86 into main
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m47s
CI / release-candidate (push) Successful in 8s
2026-08-27 19:42:11 +00:00
afec9b11d1 ops: add disk capacity incident assessment
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 19:14:25 +00:00
ca37d8a1bb Merge pull request 'Pin frequent work across signed-in mobile devices' (#1478) from timmy/1477-pin-mobile-frequent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m23s
CI / release-candidate (push) Successful in 8s
2026-08-27 18:46:56 +00:00
94bd2cafba feat: pin frequent mobile work (Closes #1477)
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 18:34:01 +00:00
2b630d3fa8 Merge pull request 'Sync recent work across signed-in mobile devices' (#1476) from timmy/1475-sync-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 17:45:02 +00:00
18 changed files with 1258 additions and 87 deletions

View File

@ -135,7 +135,10 @@ Mobile **Recent work** is also portable across signed-in devices. Opening an iss
review, Filed item, or update records its canonical detail route locally before navigation and marks 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 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 merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. Titles, repositories, and routes are encrypted at rest with the shared 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 private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`. `STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.

View File

@ -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-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 { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; } .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; } .mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
@media (max-width:600px) { @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)); } .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 { .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; 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 { 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 button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); } .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-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-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; } .mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }

View File

@ -439,6 +439,10 @@
document, document,
section:qs('#mobile-recent-work'), section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'), 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'), status:qs('#mobile-recent-work-status'),
openRoute:fragment => { openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet'); const sheet = qs('#mobile-queue-sheet');
@ -2079,6 +2083,7 @@
closeOpenWorkSheets(); closeOpenWorkSheets();
await openRoutedWorkSection(item); await openRoutedWorkSection(item);
mobileRecentWork.record(item); mobileRecentWork.record(item);
mobileRecentWork.setCurrent(item);
const route = createWorkRoute.parse(window.location.hash); const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section); if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
}, },
@ -2109,6 +2114,7 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync()); qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() { function closeOpenWorkSheets() {
mobileRecentWork.setCurrent(null);
issueVoiceReply.cancel(); issueVoiceReply.cancel();
pullVoiceReply.cancel(); pullVoiceReply.cancel();
updateVoiceReply.cancel(); updateVoiceReply.cancel();

View File

@ -1053,6 +1053,7 @@
<div class="small" id="issue-sheet-key"></div> <div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3> <h3 id="issue-sheet-title">Assigned issue</h3>
</div> </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> <button id="close-issue-sheet" type="button">Close sheet</button>
</div> </div>
<nav class="mobile-issue-detail-nav" aria-label="Issue sections"> <nav class="mobile-issue-detail-nav" aria-label="Issue sections">
@ -1553,6 +1554,7 @@
<div class="small" id="update-sheet-key"></div> <div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3> <h3 id="update-sheet-title">Unread update</h3>
</div> </div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
</div> </div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></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"> <nav class="mobile-update-detail-nav" aria-label="Update sections">
@ -1676,6 +1678,7 @@
<section class="pull-sheet-panel"> <section class="pull-sheet-panel">
<div class="pull-sheet-header"> <div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div> <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> <button id="close-pull-sheet" type="button">Close</button>
</div> </div>
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections"> <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> <div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3> <h3 id="review-sheet-title">Pull request review</h3>
</div> </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> <button class="review-action" id="close-review-sheet">Close</button>
</div> </div>
<nav class="mobile-review-detail-nav" aria-label="Review sections"> <nav class="mobile-review-detail-nav" aria-label="Review sections">
@ -2183,9 +2187,14 @@
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p> <p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button> <button id="mobile-queue-next-action" type="button">Find Work</button>
</section> </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> <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> <h3 id="mobile-recent-work-heading">Recent work</h3>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div> <div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section> </section>
<details class="mobile-queue-priority" id="mobile-queue-priority"> <details class="mobile-queue-priority" id="mobile-queue-priority">

View File

@ -8,15 +8,64 @@
const getLogin = options.getLogin; const getLogin = options.getLogin;
const fetchJson = options.fetchJson; const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5); 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 prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']); const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
const setTimer = options.setTimeout || setTimeout; const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout; const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150; 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 syncFlight = null;
let syncAccount = ''; let syncAccount = '';
let debounceTimer = null; 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() { function login() {
return String(getLogin?.() || '').trim().toLowerCase(); return String(getLogin?.() || '').trim().toLowerCase();
@ -45,29 +94,66 @@
return {kind, ...(repository ? {repository} : {}), number, title, route}; return {kind, ...(repository ? {repository} : {}), number, title, route};
} }
function normalizeList(value) { function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
const unique = []; const unique = [];
for (const candidate of value) { for (const candidate of value) {
const item = normalize(candidate); const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item); 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; if (unique.length === limit) break;
} }
return unique; return unique;
} }
function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]};
}
function read() { function read() {
const storageKey = key(); const storageKey = key();
if (!storageKey) return {items:[], pending:[]}; if (!storageKey) return empty();
try { try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]}; if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
return { return {
items:normalizeList(parsed?.items), items:normalizeList(parsed?.items),
pending:normalizeList(parsed?.pending), pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizePending(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps),
}; };
} catch (_) { } catch (_) {
return {items:[], pending:[]}; return empty();
} }
} }
@ -75,7 +161,10 @@
if (!accountKey || accountKey !== key()) return false; if (!accountKey || accountKey !== key()) return false;
try { try {
storage.setItem(accountKey, JSON.stringify({ storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items), pending:normalizeList(value.pending), items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizePending(value.pending),
pinOps:normalizePinOps(value.pinOps),
})); }));
return true; return true;
} catch (_) { } catch (_) {
@ -83,18 +172,27 @@
} }
} }
function hasPending(value) {
return value.pending.length > 0 || value.pinOps.length > 0;
}
function announce(value = read(), status = null) { function announce(value = read(), status = null) {
if (!options.status) return; if (!options.status) return;
options.status.textContent = status || (value.pending.length ? 'Sync pending.' : ''); options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
} }
function items() { function items() {
return read().items; return read().items;
} }
function pinned() {
return read().pinned;
}
function state() { function state() {
const value = read(); const value = read();
return {pending:value.pending.length > 0, pendingCount:value.pending.length}; const pendingCount = value.pending.length + value.pinOps.length;
return {pending:pendingCount > 0, pendingCount};
} }
function scheduleSync() { function scheduleSync() {
@ -113,7 +211,10 @@
if (!accountKey || !normalized) return false; if (!accountKey || !normalized) return false;
const current = read(); const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit); current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pending = [normalized, ...current.pending.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; if (!persist(current, accountKey)) return false;
announce(current); announce(current);
render(); render();
@ -121,13 +222,62 @@
return true; return true;
} }
function adopt(snapshot, accountKey, pending = []) { 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; if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items); const remote = normalizeList(snapshot.items);
const unsent = normalizeList(pending); const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizePending(pending);
const unsentPinOps = normalizePinOps(pinOps);
const value = { const value = {
items:normalizeList([...unsent, ...remote]), items:normalizeList([...unsent, ...remote]),
pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent, pending:unsent,
pinOps:unsentPinOps,
}; };
persist(value, accountKey); persist(value, accountKey);
announce(value); announce(value);
@ -138,19 +288,42 @@
async function drain(accountKey) { async function drain(accountKey) {
while (key() === accountKey) { while (key() === accountKey) {
const current = read(); const current = read();
if (!current.pending.length) return current; if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1]; const sending = current.pending[current.pending.length - 1];
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…'); announce(current, 'Syncing recent work…');
try { try {
const snapshot = await fetchJson('api/v1/recent-work', { let snapshot;
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending), 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(); if (key() !== accountKey) return read();
const latest = read(); const latest = read();
const pending = latest.pending.filter(item => item.route !== sending.route); const pending = sending
if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.'); ? 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) { } catch (_error) {
if (key() === accountKey) announce(read()); if (key() === accountKey) {
announce(read());
scheduleRetry(accountKey);
}
return read(); return read();
} }
} }
@ -160,7 +333,9 @@
function sync() { function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; } if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key(); const accountKey = key();
if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read()); 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; if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey; syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => { syncFlight = drain(accountKey).finally(() => {
@ -176,16 +351,16 @@
const snapshot = await fetchJson('api/v1/recent-work'); const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read(); if (key() !== accountKey) return read();
const current = read(); const current = read();
adopt(snapshot, accountKey, current.pending); adopt(snapshot, accountKey, current.pending, current.pinOps);
return current.pending.length ? sync() : read(); return hasPending(current) ? sync() : read();
} catch (_error) { } catch (_error) {
if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.'); if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
return read(); return read();
} }
} }
function startLifecycle(lifecycle = {}) { function startLifecycle(lifecycle = {}) {
const reconcile = () => read().pending.length ? sync() : load(); const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); }); lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => { lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile(); if (!lifecycle.document.hidden) void reconcile();
@ -193,34 +368,88 @@
return reconcile; return reconcile;
} }
function render() { function detail(item) {
const recent = items(); return item.kind === 'update'
const list = options.list; ? 'Update · #' + item.number
const section = options.section; : item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
if (!list || !section || !options.document) return recent.length;
const rows = recent.map(item => {
const detail = item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
const button = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
primary.textContent = item.title;
secondary.textContent = detail;
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;
});
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
return rows.length;
} }
return {items, record, render, load, sync, startLifecycle, state}; 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 = 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 + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => {
if (isPinned) record(item);
options.openRoute?.(item.route);
});
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;
}
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};
}); });

View File

@ -233,6 +233,7 @@
issue_closed: 'Issue closed', pull_merged: 'Pull request merged', issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
source_branch_deleted: 'Source branch deleted', source_branch_deleted: 'Source branch deleted',
release_rollback_prepared: 'Release rollback prepared', release_rollback_prepared: 'Release rollback prepared',
ci_job_retried: 'CI job retried',
comment_deleted: 'Comment deleted', comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved', pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged', pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',

View File

@ -338,8 +338,18 @@ function mountTodayWeekReschedule({
confirm.addEventListener('click',async()=>{ confirm.addEventListener('click',async()=>{
if(!selectedDate)return; if(!selectedDate)return;
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close(); 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{ try{
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
if(result.sync_pending){ if(result.sync_pending){
announce('Moved locally. Saved on this device · sync pending.');warm(); announce('Moved locally. Saved on this device · sync pending.');warm();
}else{ }else{
@ -347,11 +357,7 @@ function mountTodayWeekReschedule({
} }
await continueToday(); await continueToday();
}catch(error){ }catch(error){
if(!dialog.open)dialog.showModal(); announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
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 {controller,close,flushPending,resumePending}; return {controller,close,flushPending,resumePending};

35
src/disk_capacity.py Normal file
View 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,
)

View File

@ -606,6 +606,7 @@ StepUpAction = Literal[
"merge_pull", "merge_pull",
"delete_source_branch", "delete_source_branch",
"prepare_release_rollback", "prepare_release_rollback",
"retry_ci_job",
"submit_pull_review", "submit_pull_review",
"close_issue", "close_issue",
"delete_comment", "delete_comment",
@ -980,6 +981,10 @@ class RecentWorkItem(BaseModel):
route: str = Field(min_length=1, max_length=300) route: str = Field(min_length=1, max_length=300)
class RecentWorkRoute(BaseModel):
route: str = Field(min_length=1, max_length=300)
class CompletedFiledReviewReceipt(BaseModel): class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field( repository: str = Field(
min_length=3, min_length=3,
@ -3468,6 +3473,40 @@ async def record_recent_work(payload: RecentWorkItem):
) )
@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") @app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response): async def get_unfiled_drafts(response: Response):
login = await _confirmed_login() login = await _confirmed_login()
@ -7825,13 +7864,38 @@ async def pull_action_failure(
) )
async def retry_pull_action_job( async def retry_pull_action_job(
retry: PullReadyRequest, retry: PullReadyRequest,
request: Request,
owner: str, owner: str,
repo: str, repo: str,
number: int = PathParam(gt=0), number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0), run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0), job_index: int = PathParam(ge=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
) -> JSONResponse: ) -> JSONResponse:
repository = f"{owner}/{repo}" 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(): async def retry_job():
if not _has_pull_workspace_access( if not _has_pull_workspace_access(
@ -7847,14 +7911,26 @@ async def retry_pull_action_job(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
) )
except HTTPException: except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise raise
except gitea_proxy.StalePullError: except gitea_proxy.StalePullError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse( return JSONResponse(
{"error": "New commits arrived. Reload checks before retrying this job."}, {"error": "New commits arrived. Reload checks before retrying this job."},
status_code=409, status_code=409,
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
except ValueError: except ValueError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse( return JSONResponse(
{"error": "This check is no longer failed or cannot be retried."}, {"error": "This check is no longer failed or cannot be retried."},
status_code=409, status_code=409,
@ -7866,6 +7942,10 @@ async def retry_pull_action_job(
status_code=503, status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"}, headers={"Cache-Control": "no-store", "Retry-After": "1"},
) )
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse( return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"} result, status_code=202, headers={"Cache-Control": "no-store"}
) )
@ -8321,14 +8401,39 @@ async def release_action_failure(
"/checks/{run_id}/jobs/{job_index}/retry" "/checks/{run_id}/jobs/{job_index}/retry"
) )
async def retry_release_action_job( async def retry_release_action_job(
request: Request,
owner: str, owner: str,
repo: str, repo: str,
number: int = PathParam(gt=0), number: int = PathParam(gt=0),
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"), commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
run_id: int = PathParam(gt=0), run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0), job_index: int = PathParam(ge=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
) -> JSONResponse: ) -> JSONResponse:
repository = f"{owner}/{repo}" 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(): async def retry_job():
if not await gitea_proxy.can_recover_merged_release( if not await gitea_proxy.can_recover_merged_release(
@ -8344,8 +8449,16 @@ async def retry_release_action_job(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
) )
except HTTPException: except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise raise
except ValueError: except ValueError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse( return JSONResponse(
{"error": "This release check is no longer failed or cannot be retried."}, {"error": "This release check is no longer failed or cannot be retried."},
status_code=409, status_code=409,
@ -8357,6 +8470,10 @@ async def retry_release_action_job(
status_code=503, status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"}, headers={"Cache-Control": "no-store", "Retry-After": "1"},
) )
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse( return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"} result, status_code=202, headers={"Cache-Control": "no-store"}
) )

View File

@ -18,10 +18,12 @@ class RecentWorkStore:
timeout: float = 1.0, timeout: float = 1.0,
encryption_key: bytes | None = None, encryption_key: bytes | None = None,
limit: int = 5, limit: int = 5,
pinned_limit: int = 20,
): ):
self.path = Path(path) self.path = Path(path)
self.timeout = timeout self.timeout = timeout
self.limit = max(1, int(limit)) self.limit = max(1, int(limit))
self.pinned_limit = max(1, int(pinned_limit))
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="recent-work", store="recent-work",
@ -86,30 +88,47 @@ class RecentWorkStore:
raise ValueError("recent work item is invalid") raise ValueError("recent work item is invalid")
return normalized return normalized
def _items(self, row, login: str) -> tuple[list[dict], bool]: def _state(self, row, login: str) -> tuple[dict, bool]:
if row is None: if row is None:
return [], False return {"items": [], "pinned": []}, False
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}") payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
if not isinstance(payload, list): 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") raise PrivateStateEncryptionError("private state could not be decrypted")
try: try:
return [self._normalize(item) for item in payload][: self.limit], legacy 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: except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from 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: def get(self, login: str) -> dict:
login = self._login(login) login = self._login(login)
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,) "SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone() ).fetchone()
items, legacy = self._items(row, login) state, legacy = self._state(row, login)
if row is not None and legacy: if row is not None and legacy:
connection.execute( connection.execute(
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?", "UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
(self._cipher.seal(items, binding=f"items:{login}"), login, row[0]), (self._seal(state, login), login, row[0]),
) )
return {"items": items} return state
def record(self, login: str, item: dict) -> dict: def record(self, login: str, item: dict) -> dict:
login = self._login(login) login = self._login(login)
@ -119,13 +138,41 @@ class RecentWorkStore:
row = connection.execute( row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,) "SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone() ).fetchone()
current, _legacy = self._items(row, login) state, _legacy = self._state(row, login)
items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])] state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
items = items[: self.limit] state["pinned"] = [
sealed = self._cipher.seal(items, binding=f"items:{login}") normalized,
connection.execute( *(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
"INSERT INTO recent_work(login, items) VALUES (?, ?) " ] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
"ON CONFLICT(login) DO UPDATE SET items=excluded.items", self._write(connection, login, state)
(login, sealed), return state
)
return {"items": items} 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

View 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()

View File

@ -18,6 +18,10 @@ def test_release_rollback_is_a_supported_step_up_action():
assert "prepare_release_rollback" in get_args(main.StepUpAction) 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 @pytest.fixture
def access_control(monkeypatch, tmp_path): def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") 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"] 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 @pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization( async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch access_control, monkeypatch

View 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)

View File

@ -93,13 +93,14 @@ const recent=createRecentWork({{
openRoute:route=>opened.push(route), openRoute:route=>opened.push(route),
}}); }});
const rendered=recent.render(); const rendered=recent.render();
list.children[1].click(); list.children[1].children[0].click();
process.stdout.write(JSON.stringify({{ 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'], label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
primary:button.children[0].children[0].textContent, primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent, secondary:button.children[0].children[1].textContent,
}})),opened, pin:row.children[1].attributes['data-recent-work-pin'],
}});}}),opened,
}})); }}));
""" """
payload = run_node(script) payload = run_node(script)
@ -113,12 +114,14 @@ process.stdout.write(JSON.stringify({{
"route": "#/my-work/issue/stackchain/dashboard/7", "route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue", "primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7", "secondary": "Issue · stackchain/dashboard #7",
"pin": "pin",
}, },
{ {
"label": "Open Review release status, update #42", "label": "Open Review release status, update #42",
"route": "#/my-work/update/42", "route": "#/my-work/update/42",
"primary": "Review release status", "primary": "Review release status",
"secondary": "Update · #42", "secondary": "Update · #42",
"pin": "pin",
}, },
], ],
"opened": ["#/my-work/update/42"], "opened": ["#/my-work/update/42"],
@ -156,6 +159,303 @@ process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:st
assert payload["calls"] == [["api/v1/recent-work", "POST"]] 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(): def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text() html = INDEX.read_text()
dashboard = DASHBOARD.read_text() dashboard = DASHBOARD.read_text()
@ -163,15 +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"' in html
assert 'id="mobile-recent-work-list"' 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 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard assert "createMobileRecentWork({" in dashboard
assert "fetchJson:fetchReviewJson" in dashboard assert "fetchJson:fetchReviewJson" in dashboard
assert "status:qs('#mobile-recent-work-status')" 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 "mobileRecentWork.startLifecycle({window, document})" in dashboard
assert "void mobileRecentWork.load();" in dashboard assert "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" 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 "[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

View File

@ -47,7 +47,60 @@ async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_acco
assert forbidden.status_code == 403 assert forbidden.status_code == 403
assert saved.status_code == 200 assert saved.status_code == 200
assert saved.json() == {"items": [entry]} assert saved.json() == {"items": [entry], "pinned": []}
assert fetched.json() == saved.json() assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store" assert fetched.headers["cache-control"] == "no-store"
assert other_account.json() == {"items": []} 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": []}

View File

@ -23,7 +23,7 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2] 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 RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
assert store.get("alexander") == {"items": []} assert store.get("alexander") == {"items": [], "pinned": []}
with sqlite3.connect(database) as connection: with sqlite3.connect(database) as connection:
payload = connection.execute( payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'" "SELECT items FROM recent_work WHERE login = 'timmy'"
@ -33,6 +33,48 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
assert "#/my-work/issue" 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): def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32) store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)

View File

@ -18,6 +18,12 @@ def test_release_rollback_has_a_specific_security_activity_label():
assert "release_rollback_prepared: 'Release rollback prepared'" in source 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(): def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
harness = f""" harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});

View File

@ -326,8 +326,9 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
assert result["remaining"] == 1 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" failure = "true" if fail_move else "false"
refresh_failure = "true" if fail_refresh else "false"
scenario = """ scenario = """
const mount=require('./frontend/today-week-reschedule.js').mount; const mount=require('./frontend/today-week-reschedule.js').mount;
class Element { class Element {
@ -361,7 +362,9 @@ const week={load:async()=>weekState,review:()=>({days:[{
}]}),adopt(){}}; }]}),adopt(){}};
const api=async(url)=>url==='api/v1/today' ? today : move; const api=async(url)=>url==='api/v1/today' ? today : move;
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today, 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()}); continueToday:async()=>{},announce(){},schedule:callback=>callback()});
await selectors['[data-work-session-reschedule-week]'].emit('click'); await selectors['[data-work-session-reschedule-week]'].emit('click');
await selectors['#today-week-reschedule-days'].children[0].emit('click'); await selectors['#today-week-reschedule-days'].children[0].emit('click');
@ -376,7 +379,11 @@ if(__FAIL_MOVE__){
await confirming; await confirming;
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open})); 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(): 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(): def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
index = INDEX.read_text() index = INDEX.read_text()
css = CSS.read_text() css = CSS.read_text()