Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0304f5ab2c | ||
|
|
50d51ed159 | ||
|
|
8e74f022eb | ||
|
|
92b0fd56ca | ||
|
|
7ce352553d | ||
|
|
f4a4ed84c3 | ||
|
|
b21888fd7a | ||
|
|
320d6348ca | ||
|
|
7f8ee771dc | ||
|
|
f446e10670 | ||
|
|
79309196b8 | ||
|
|
cdf6ecdcb8 | ||
|
|
797fd0d544 | ||
|
|
a13e25f6c9 | ||
|
|
91e5b1e0c9 | ||
|
|
1f05521094 | ||
|
|
59c263fc59 | ||
|
|
4c64cc4c3e | ||
|
|
6e90e3fa9e | ||
|
|
83702e75b0 | ||
|
|
446281674b | ||
|
|
9c5b100d6a |
No files matched your search
@@ -187,6 +187,22 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||
# The feature stays disabled unless all three values are present. Privacy-safe update
|
||||
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
||||
# 09:00 in the device's local timezone without opening the dashboard.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
export STACKCHAIN_PUSH_LEASE_SECONDS=60
|
||||
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
@@ -302,12 +318,14 @@ successful response is JSON containing
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
result opens a mobile-safe preview without discarding the search query. Open
|
||||
unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. A closed issue can be reopened, self-assigned, added to
|
||||
Today, and resumed through the same capacity-guarded flow; pull requests remain
|
||||
read-only with a safe canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>`,
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
|
||||
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
|
||||
+24
-3
@@ -18,6 +18,21 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.device-setup-header h2, .device-setup-header p { margin-top:0; }
|
||||
.device-setup-header button, .device-setup-action { min-height:44px; }
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.device-readiness-actions button { min-height:44px; min-width:0; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
@@ -81,6 +96,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -340,10 +356,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
@@ -462,6 +479,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
|
||||
+138
-83
@@ -417,6 +417,8 @@
|
||||
document,
|
||||
urls: {
|
||||
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||||
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
|
||||
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
|
||||
},
|
||||
});
|
||||
let sharedLaunchState = null;
|
||||
@@ -446,7 +448,17 @@
|
||||
return await pullWorkflowFeatures.run('pull-workflow', {
|
||||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||||
}, () => {
|
||||
if (!pullController) pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!pullController) {
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
|
||||
const continuing = workSession.checkpointed(item);
|
||||
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
|
||||
closePullSheet();
|
||||
if (continuing) return await completeOwnershipExitToday(item);
|
||||
paintMyWork(lastContextSnapshot);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1698,9 +1710,9 @@
|
||||
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
||||
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
||||
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
||||
] : [
|
||||
] : createPullSheet.ownershipSelectors().concat([
|
||||
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
|
||||
];
|
||||
]);
|
||||
selectors.forEach(selector => {
|
||||
const control = qs(selector);
|
||||
if (control) control.disabled = true;
|
||||
@@ -1710,6 +1722,7 @@
|
||||
qs('#issue-handoff').inert = true;
|
||||
} else {
|
||||
qs('#pull-review').inert = true;
|
||||
qs('#pull-ownership').inert = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2592,21 +2605,6 @@
|
||||
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
|
||||
}
|
||||
|
||||
function focusNextUnreviewedPullFile() {
|
||||
if (!selectedPullDetail || !pullReviewState) return;
|
||||
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
|
||||
const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (toggle && panel) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPullReview({ refresh = false } = {}) {
|
||||
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
||||
const item = selectedPull;
|
||||
@@ -2631,16 +2629,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sameWorkTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
selectedPull = item;
|
||||
pullMentions.dismiss();
|
||||
pullTrigger = trigger;
|
||||
@@ -2670,6 +2664,7 @@
|
||||
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
||||
qs('#merge-pull').disabled = true;
|
||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||
createPullSheet.resetOwnershipControls(document, item, candidate => workSession.checkpointed(candidate));
|
||||
qs('#retry-pull-load').hidden = true;
|
||||
qs('#open-pull-gitea').href = item.url || '#';
|
||||
setCommentNextVisibility('pull');
|
||||
@@ -3551,6 +3546,7 @@
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
|
||||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||||
? 'Back to blockers' : 'Back to search';
|
||||
if (state.status === 'closed') {
|
||||
@@ -3562,6 +3558,7 @@
|
||||
claimButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
|
||||
if (state.status === 'loading') {
|
||||
searchPreviewDetail = null;
|
||||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||||
@@ -3591,44 +3588,51 @@
|
||||
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
claimButton.disabled = state.status === 'claiming';
|
||||
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
|
||||
startButton.disabled = state.status === 'claiming';
|
||||
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||||
startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
|
||||
(detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
|
||||
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
|
||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||
status.textContent = state.status === 'reopening' ? 'Reopening…' :
|
||||
(state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
||||
(detail.claimable ? 'This issue is open and unassigned.' :
|
||||
(detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.')));
|
||||
(detail.assigned_to_me ? 'This issue is already in My Work.' :
|
||||
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
|
||||
}
|
||||
const searchPreview = createSearchPreview({
|
||||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
||||
claim: detail => fetchReviewJson(
|
||||
mutate: (detail, action) => fetchReviewJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchAssignAndStart = createAssignAndStart({
|
||||
available: createAndStart.available,
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
start: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(claimed);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
openRoutedWork(claimed, qs('#open-palette'));
|
||||
},
|
||||
announce: message => {
|
||||
qs('#search-preview-status').textContent = message;
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
});
|
||||
function createSearchStart(claim) {
|
||||
return createAssignAndStart({
|
||||
available: createAndStart.available,
|
||||
claim,
|
||||
start: confirmed => {
|
||||
const item = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(item);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const item = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
openRoutedWork(item, qs('#open-palette'));
|
||||
},
|
||||
announce: message => {
|
||||
qs('#search-preview-status').textContent = message;
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
});
|
||||
}
|
||||
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
|
||||
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
|
||||
const mobileSearchViewport = createMobileSearchViewport({
|
||||
palette: qs('#cmd-palette'),
|
||||
results: qs('#cmd-results'),
|
||||
@@ -3848,12 +3852,14 @@
|
||||
});
|
||||
qs('#start-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
(!detail.claimable && !detail.assigned_to_me)) return;
|
||||
if (!detail || detail.kind !== 'issue') return;
|
||||
try {
|
||||
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
|
||||
if (detail.reopenable) await searchReopenAndStart.run(detail);
|
||||
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
|
||||
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.';
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
@@ -4558,7 +4564,9 @@
|
||||
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
|
||||
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
|
||||
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
|
||||
});
|
||||
qs('#load-older-pull-comments').addEventListener('click', async () => {
|
||||
if (!pullConversation) return;
|
||||
const button = qs('#load-older-pull-comments');
|
||||
@@ -5054,20 +5062,25 @@
|
||||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
});
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', async () => {
|
||||
offlineWorkStore.setEnabled(keepWorkOffline.checked);
|
||||
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
|
||||
async function setOfflineWorkEnabled(enabled) {
|
||||
keepWorkOffline.checked = enabled;
|
||||
offlineWorkStore.setEnabled(enabled);
|
||||
if (enabled && liveMode && lastContextSnapshot) {
|
||||
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
notification_pagination:notificationPagination });
|
||||
if (admitted === false) {
|
||||
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!keepWorkOffline.checked) await offlineWorkStore.clear();
|
||||
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (keepWorkOffline.checked) warmTodayOffline();
|
||||
if (!enabled) await offlineWorkStore.clear();
|
||||
await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (enabled) warmTodayOffline();
|
||||
else offlineToday.cancel();
|
||||
return offlineWorkStore.enabled();
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', async () => {
|
||||
await setOfflineWorkEnabled(keepWorkOffline.checked);
|
||||
});
|
||||
deliveryReceipts.addEventListener('change', async () => {
|
||||
let enabled = deliveryReceipts.checked;
|
||||
@@ -5258,30 +5271,72 @@
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
let pushControllerReady = Promise.resolve(null);
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('service-worker.js').catch(error =>
|
||||
console.warn('Stackchain install support unavailable', error)
|
||||
);
|
||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||
await issueCaptureFeatures.load('push-notifications');
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
status:qs('#push-update-status'),
|
||||
notification:window.Notification,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
fetchJson:fetchReviewJson,
|
||||
});
|
||||
await controller.init();
|
||||
return controller;
|
||||
}).catch(error => {
|
||||
qs('#push-updates').disabled = true;
|
||||
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||||
console.warn('Push notifications unavailable', error);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
qs('#push-updates').disabled = true;
|
||||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||||
}
|
||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) &&
|
||||
!/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent);
|
||||
createInstallApp({
|
||||
window,
|
||||
card: qs('#install-app-card'),
|
||||
installButton: qs('#install-app'),
|
||||
dismissButton: qs('#dismiss-install-app'),
|
||||
guidance: qs('#install-app-guidance'),
|
||||
status: qs('#install-app-status'),
|
||||
storage: localStorage,
|
||||
isStandalone: () => window.matchMedia('(display-mode: standalone)').matches ||
|
||||
navigator.standalone === true,
|
||||
isIosSafari: () => isIosSafari,
|
||||
}).start();
|
||||
let deferredInstallPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault();
|
||||
deferredInstallPrompt = event;
|
||||
});
|
||||
let deviceSetup = null;
|
||||
async function ensureDeviceSetup() {
|
||||
if (deviceSetup) return deviceSetup;
|
||||
await issueCaptureFeatures.load('device-setup');
|
||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const installApp = createInstallApp({
|
||||
window, initialPrompt:deferredInstallPrompt,
|
||||
card:qs('#install-app-card'), installButton:qs('#install-app'),
|
||||
dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
|
||||
status:qs('#install-app-status'), storage:localStorage,
|
||||
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
|
||||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||||
});
|
||||
installApp.start();
|
||||
deviceSetup = createMobileDeviceSetup.mount({
|
||||
document, installApp, promptStorage:localStorage,
|
||||
offlineAvailable:() => offlineStorageReady,
|
||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
qs('#push-updates').checked = true;
|
||||
await controller.change();
|
||||
},
|
||||
});
|
||||
await deviceSetup.start();
|
||||
return deviceSetup;
|
||||
}
|
||||
qs('#open-device-setup').addEventListener('click', async event => {
|
||||
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
|
||||
});
|
||||
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
|
||||
contextPoller.start();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
if (!document.hidden) deviceSetup?.render();
|
||||
});
|
||||
|
||||
/* Widgets */
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="app-menu-panel">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
<button id="active-devices" type="button">Active devices</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="sign-out-all" type="button">Sign out all devices</button>
|
||||
@@ -59,6 +60,33 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
|
||||
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
|
||||
<div class="device-setup-header">
|
||||
<div>
|
||||
<h2 id="device-setup-heading">Make this device work-ready</h2>
|
||||
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
|
||||
</div>
|
||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||
</div>
|
||||
<ol class="device-setup-list">
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
@@ -97,6 +125,8 @@
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -116,6 +146,17 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
|
||||
<div>
|
||||
<strong id="device-readiness-heading">Make this phone work-ready</strong>
|
||||
<p class="small muted">Install, save work offline, and enable update alerts.</p>
|
||||
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="device-readiness-actions">
|
||||
<button id="finish-device-setup" type="button">Finish setup</button>
|
||||
<button id="dismiss-device-readiness" type="button">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
@@ -272,6 +313,7 @@
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
@@ -613,8 +655,19 @@
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
<button id="load-pull-handoff" type="button">Choose teammate</button>
|
||||
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
|
||||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
@@ -773,9 +826,11 @@
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+19
-2
@@ -3,7 +3,8 @@
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
let deferredPrompt = options.initialPrompt || null;
|
||||
let installed = false;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
@@ -25,6 +26,7 @@
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
installed = true;
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
@@ -44,6 +46,7 @@
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (deferredPrompt) showNativePrompt(deferredPrompt);
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
@@ -51,6 +54,7 @@
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
@@ -58,5 +62,18 @@
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
function state() {
|
||||
if (installed || options.isStandalone()) {
|
||||
return {state:'complete', detail:'Stackchain is installed on this device.'};
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
|
||||
}
|
||||
if (deferredPrompt) {
|
||||
return {state:'incomplete', detail:'Stackchain is ready to install.'};
|
||||
}
|
||||
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
|
||||
}
|
||||
|
||||
return {start, install, state};
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeviceSetup = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
|
||||
return {available, complete};
|
||||
}
|
||||
|
||||
function renderPrompt(readiness) {
|
||||
if (!options.promptCard) return;
|
||||
const {available, complete} = readinessCounts(readiness);
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
|
||||
} catch (_error) {}
|
||||
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
|
||||
options.promptCard.hidden = dismissed || available === 0 || complete === available;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
if (step.state === 'complete') complete += 1;
|
||||
});
|
||||
options.readyStatus.textContent = complete === available
|
||||
? 'This device is ready.'
|
||||
: `${complete} of ${available} available steps ready.`;
|
||||
renderPrompt(readiness);
|
||||
return readiness;
|
||||
}
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
options.promptDismiss?.addEventListener('click', () => {
|
||||
try {
|
||||
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
|
||||
} catch (_error) {}
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await action(); }
|
||||
finally { await render(); }
|
||||
});
|
||||
});
|
||||
renderPrompt(await options.getReadiness());
|
||||
}
|
||||
|
||||
return {start, open, close, render};
|
||||
});
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports.mount = mountMobileDeviceSetup;
|
||||
} else {
|
||||
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
|
||||
}
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
escapeTarget:options.document,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
});
|
||||
}
|
||||
@@ -41,10 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
return { ...data, pulls:(data.pulls || []).filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number) };
|
||||
}
|
||||
|
||||
function sameTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
function ownershipSelectors() {
|
||||
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
|
||||
}
|
||||
|
||||
function renderHandoffCandidates(select, candidates, doc) {
|
||||
select.textContent = '';
|
||||
const placeholder = doc.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
function ownershipExitMessage(item, action, transitionResult) {
|
||||
return item.key + ' ' + action + (transitionResult === 'opened' ?
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
load.dataset.ownershipBound = 'true';
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-handoff-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#pull-handoff-status').textContent = count ?
|
||||
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
|
||||
if (count) select.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-handoff-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-handoff').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-handoff').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const recipient = qs('#pull-handoff-recipient').value;
|
||||
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
|
||||
const button = qs('#confirm-pull-handoff');
|
||||
button.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
await controller.handoff(selected, recipient);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' handed off to @' + recipient + '.' :
|
||||
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#release-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
|
||||
const button = qs('#release-pull');
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
await controller.release(selected);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
@@ -90,6 +217,29 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
release(item) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/release', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
@@ -165,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -0,0 +1,79 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(padded, 'base64'));
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
const registration = await serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey(configuration.public_key),
|
||||
});
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
}
|
||||
|
||||
async function change() {
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (control.checked) await enable();
|
||||
else await disable();
|
||||
} catch (error) {
|
||||
control.checked = !control.checked;
|
||||
status.textContent = 'Could not change update notifications. Check your connection and try again.';
|
||||
} finally {
|
||||
control.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change};
|
||||
});
|
||||
+19
-12
@@ -3,10 +3,23 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
let mutationRequest = null;
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
@@ -32,16 +45,10 @@
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+97
-12
@@ -1,9 +1,10 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v87';
|
||||
const CACHE = 'stackchain-dashboard-shell-v88';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -60,9 +61,11 @@ const SHELL = [
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
@@ -268,18 +271,100 @@ self.addEventListener('message', event => {
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
self.addEventListener('push', event => {
|
||||
let payload;
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
const options = {
|
||||
body: 'Tap to review it in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
};
|
||||
if (
|
||||
Number.isSafeInteger(notificationId) && notificationId > 0
|
||||
&& route === '#/my-work/update/' + notificationId
|
||||
&& tag === 'stackchain-update-' + notificationId
|
||||
) {
|
||||
options.actions = [
|
||||
{ action: 'mark-read', title: 'Mark read' },
|
||||
{ action: 'tomorrow', title: 'Tomorrow' },
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'mark-read') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.notification.close();
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
|
||||
+1
-14
@@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
module.exports = renderRepoMix;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
webauthn==3.0.0
|
||||
@@ -205,6 +205,15 @@ async def session_management_id(session: Session) -> str:
|
||||
return await asyncio.to_thread(_session_store().management_id, session.session_id)
|
||||
|
||||
|
||||
async def managed_session_active(management_id: str) -> bool:
|
||||
status = await asyncio.to_thread(
|
||||
_session_store().managed_status,
|
||||
management_id,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
|
||||
@@ -12,10 +12,15 @@ import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
COMMONJS_EXPORT_LINE = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
@@ -49,6 +54,9 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
source = b"".join(chunks)
|
||||
# Node-only export shims support source-level unit tests but are unreachable
|
||||
# in the browser. Strip the simple one-line form from shipped bundles.
|
||||
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
@@ -472,6 +472,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"reopenable": actual_kind == "issue" and state == "closed",
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
}
|
||||
|
||||
@@ -1272,6 +1273,66 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def reopen_issue(repository: str, number: int) -> dict:
|
||||
"""Reopen a closed issue, assign it to the current user, and confirm both."""
|
||||
issue, user = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/issues/{number}"), current_user()
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if (
|
||||
not isinstance(issue, dict)
|
||||
or issue.get("number") != number
|
||||
or issue.get("pull_request") is not None
|
||||
or not isinstance(login, str)
|
||||
or not login
|
||||
):
|
||||
raise IssueNotAvailableError("Issue cannot be resumed")
|
||||
|
||||
if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")):
|
||||
confirmed = issue
|
||||
elif issue.get("state") == "closed":
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": "open", "assignee": login},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
else:
|
||||
raise IssueNotAvailableError("Issue is no longer available to resume")
|
||||
assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
logins = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
]
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or login not in logins
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue reopening and assignment")
|
||||
labels_value = confirmed.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
return {
|
||||
"id": confirmed.get("id"),
|
||||
"number": number,
|
||||
"title": confirmed.get("title", "")
|
||||
if isinstance(confirmed.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"repository": repository,
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": logins,
|
||||
"updated_at": confirmed.get("updated_at", "")
|
||||
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
@@ -1437,6 +1498,92 @@ async def handoff_assigned_issue(
|
||||
}
|
||||
|
||||
|
||||
async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
candidates = await issue_handoff_candidates(repository)
|
||||
return [
|
||||
item for item in candidates
|
||||
if re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
|
||||
][:25]
|
||||
|
||||
|
||||
async def _change_assigned_pull_owners(
|
||||
repository: str, number: int, recipient: str | None
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
assignees_value = pull.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
login_folded = login.casefold()
|
||||
assigned_to_login = any(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() == login_folded
|
||||
for item in assignees
|
||||
)
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not assigned_to_login
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request is not assigned to the current user")
|
||||
|
||||
if recipient is not None:
|
||||
eligible = {
|
||||
item["login"] for item in await pull_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() != login_folded
|
||||
]
|
||||
if recipient is not None and recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login_folded in {item.casefold() for item in confirmed_assignees}
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
or (recipient is not None and recipient not in confirmed_assignees)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm pull request ownership change")
|
||||
result = {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
}
|
||||
if recipient is not None:
|
||||
result["recipient"] = recipient
|
||||
return result
|
||||
|
||||
|
||||
async def handoff_assigned_pull(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, recipient)
|
||||
|
||||
|
||||
async def release_assigned_pull(repository: str, number: int) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, None)
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
|
||||
+291
-3
@@ -47,6 +47,8 @@ from src.live_snapshot_store import (
|
||||
)
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
@@ -76,16 +78,51 @@ async def _drain_authored_action_operations() -> None:
|
||||
_authored_action_operations.pop(key, None)
|
||||
|
||||
|
||||
async def _push_poll_loop() -> None:
|
||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||
send_timeout = max(
|
||||
1.0, float(os.getenv("STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS", "10"))
|
||||
)
|
||||
lease_seconds = max(
|
||||
send_timeout + 5.0,
|
||||
float(os.getenv("STACKCHAIN_PUSH_LEASE_SECONDS", "60")),
|
||||
)
|
||||
max_concurrency = max(
|
||||
1, int(os.getenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "8"))
|
||||
)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await dispatch_unread_updates(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
notifications,
|
||||
session_active=dashboard_auth.managed_session_active,
|
||||
lease_seconds=lease_seconds,
|
||||
send_timeout_seconds=send_timeout,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# Gitea and push endpoints are external; one failed poll must not
|
||||
# stop later delivery attempts.
|
||||
continue
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
global _live_snapshot_task, _available_issue_snapshot_task
|
||||
global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task
|
||||
gitea_proxy.start_client()
|
||||
if _push_configuration().enabled:
|
||||
_push_poll_task = asyncio.create_task(_push_poll_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
live_task = _live_snapshot_task
|
||||
available_task = _available_issue_snapshot_task
|
||||
for task in (live_task, available_task):
|
||||
push_task = _push_poll_task
|
||||
for task in (live_task, available_task, push_task):
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
@@ -100,6 +137,8 @@ async def lifespan(_app: FastAPI):
|
||||
_live_snapshot_task = None
|
||||
if _available_issue_snapshot_task is available_task:
|
||||
_available_issue_snapshot_task = None
|
||||
if _push_poll_task is push_task:
|
||||
_push_poll_task = None
|
||||
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
@@ -135,6 +174,7 @@ AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
|
||||
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
_live_snapshot_task: asyncio.Task | None = None
|
||||
_push_poll_task: asyncio.Task | None = None
|
||||
_live_snapshot_value: dict | None = None
|
||||
_live_snapshot_created_at: float | None = None
|
||||
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
|
||||
@@ -182,6 +222,17 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
|
||||
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
||||
)
|
||||
)
|
||||
_push_subscription_store = PushSubscriptionStore(
|
||||
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
def _push_configuration() -> PushConfiguration:
|
||||
return PushConfiguration(
|
||||
os.getenv("STACKCHAIN_VAPID_PUBLIC_KEY", "").strip(),
|
||||
os.getenv("STACKCHAIN_VAPID_PRIVATE_KEY", "").strip(),
|
||||
os.getenv("STACKCHAIN_VAPID_SUBJECT", "").strip(),
|
||||
)
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
@@ -205,6 +256,20 @@ class DashboardSignIn(BaseModel):
|
||||
return normalized
|
||||
|
||||
|
||||
class PushSubscriptionPayload(BaseModel):
|
||||
endpoint: str = Field(min_length=12, max_length=2_048, pattern=r"^https://")
|
||||
keys: dict[str, str]
|
||||
|
||||
@field_validator("keys")
|
||||
@classmethod
|
||||
def validate_push_keys(cls, value: dict[str, str]) -> dict[str, str]:
|
||||
if set(value) != {"p256dh", "auth"} or any(
|
||||
not item or len(item) > 1_024 for item in value.values()
|
||||
):
|
||||
raise ValueError("Valid Web Push keys are required")
|
||||
return value
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
@@ -361,6 +426,10 @@ class LaterOperationBatch(BaseModel):
|
||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class NotificationLaterRequest(BaseModel):
|
||||
wake_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class NotificationReply(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
@@ -859,7 +928,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/later", "/api/v1/security-events"} 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/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -1631,6 +1700,67 @@ async def session_status(request: Request):
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/v1/push-subscription")
|
||||
async def push_status(request: Request):
|
||||
configuration = _push_configuration()
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
subscribed = await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed,
|
||||
device_id,
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
"subscribed": subscribed,
|
||||
"public_key": configuration.public_key if configuration.enabled else "",
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription")
|
||||
async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
|
||||
if not _push_configuration().enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.upsert,
|
||||
device_id,
|
||||
payload.model_dump(),
|
||||
)
|
||||
try:
|
||||
current = await asyncio.wait_for(notifications(), NOTIFICATION_PAGE_TIMEOUT_SECONDS)
|
||||
except Exception as error:
|
||||
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Unread updates are temporarily unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
) from error
|
||||
existing_ids = {
|
||||
int(item["id"])
|
||||
for item in current.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivered, device_id, existing_ids
|
||||
)
|
||||
return {"subscribed": True}
|
||||
|
||||
|
||||
@app.delete("/api/v1/push-subscription")
|
||||
async def unsubscribe_push(request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.delete_session,
|
||||
device_id,
|
||||
)
|
||||
return {"subscribed": False}
|
||||
|
||||
|
||||
@app.post("/api/v1/session/activity")
|
||||
async def record_session_activity(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
@@ -1776,6 +1906,10 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
@app.delete("/api/v1/session")
|
||||
async def sign_out(request: Request, response: Response):
|
||||
session = request.state.dashboard_session
|
||||
try:
|
||||
push_device_id = await dashboard_auth.session_management_id(session)
|
||||
except (dashboard_auth.SessionStoreError, AttributeError):
|
||||
push_device_id = None
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
@@ -1808,6 +1942,10 @@ async def sign_out(request: Request, response: Response):
|
||||
# successful revocation as failed merely because completion could not
|
||||
# be marked yet.
|
||||
pass
|
||||
if push_device_id is not None:
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.delete_session, push_device_id
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
@@ -1908,6 +2046,9 @@ async def revoke_active_device(
|
||||
)
|
||||
if not revoked:
|
||||
raise HTTPException(status_code=404, detail="Active device not found")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.delete_session, management_id
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
@@ -1950,6 +2091,7 @@ async def sign_out_all_devices(
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
await asyncio.to_thread(_push_subscription_store.delete_all)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
@@ -3078,6 +3220,57 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||
async def defer_notification(
|
||||
payload: NotificationLaterRequest,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
item = await asyncio.wait_for(
|
||||
gitea_proxy.resolve_work_route("update", None, None, thread_id),
|
||||
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
|
||||
)
|
||||
repository = item.get("repository")
|
||||
if not isinstance(repository, str) or not repository:
|
||||
raise ValueError("The update has no repository identity")
|
||||
item_id = f"update:{repository}::{thread_id}"
|
||||
result = await asyncio.to_thread(
|
||||
_later_store().apply,
|
||||
login,
|
||||
f"push-tomorrow:{thread_id}:{payload.wake_at}",
|
||||
"defer",
|
||||
item_id,
|
||||
wake_at=payload.wake_at,
|
||||
)
|
||||
except gitea_proxy.WorkRouteUnavailableError:
|
||||
return JSONResponse(
|
||||
{"error": "This update is no longer available to defer."},
|
||||
status_code=409,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Deferring the update timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
return JSONResponse(
|
||||
{"error": "Later synchronization is unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse({
|
||||
"id": thread_id,
|
||||
"status": "deferred",
|
||||
"item_id": item_id,
|
||||
"wake_at": payload.wake_at,
|
||||
"revision": result["revision"],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
|
||||
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
@@ -3224,6 +3417,30 @@ async def claim_available_issue(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reopen")
|
||||
async def reopen_closed_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.reopen_issue(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "This issue is no longer closed or cannot be resumed."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be reopened. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release")
|
||||
async def release_assigned_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
@@ -4100,6 +4317,77 @@ async def assigned_pull_checks(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
|
||||
async def pull_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.pull_handoff_candidates(repository),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Teammates could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff")
|
||||
async def handoff_assigned_pull(
|
||||
handoff: IssueHandoff,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.handoff_assigned_pull(
|
||||
f"{owner}/{repo}", number, handoff.recipient
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request or recipient changed. Reload before handing off."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
|
||||
async def release_assigned_pull(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.release_assigned_pull(f"{owner}/{repo}", number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The assignment could not be released. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
|
||||
async def assigned_pull_conversation(
|
||||
owner: str,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushConfiguration:
|
||||
public_key: str
|
||||
private_key: str
|
||||
subject: str
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.public_key and self.private_key and self.subject)
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
from pywebpush import webpush
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
data=payload,
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
page = await unread()
|
||||
thread_ids = {
|
||||
int(item["id"])
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_ids)
|
||||
if session_active is not None:
|
||||
try:
|
||||
authorized = [await session_active(item.session_id) for item in deliveries]
|
||||
except Exception:
|
||||
# Authorization state is mandatory for delivery. Preserve subscriptions
|
||||
# so a temporary registry failure can be retried safely.
|
||||
return 0
|
||||
for delivery, active in zip(deliveries, authorized):
|
||||
if not active:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery, active in zip(deliveries, authorized)
|
||||
if active
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
|
||||
async def dispatch_device(delivery) -> int:
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
count = 0
|
||||
for thread_id in delivery.thread_ids:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not still_owner:
|
||||
ownership_lost.set()
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
if send is None:
|
||||
operation = send_web_push(
|
||||
delivery.subscription, payload, configuration
|
||||
)
|
||||
else:
|
||||
operation = send(delivery.subscription, payload)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
break
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered, delivery.session_id, (thread_id,)
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
thread_ids: tuple[int, ...]
|
||||
|
||||
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deliveries (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, now: float, lease_seconds: float
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if current is not None and current[0] != owner and current[1] > now:
|
||||
return False
|
||||
connection.execute(
|
||||
"""INSERT INTO push_dispatch_lease(singleton, owner, expires_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||
endpoint = subscription["endpoint"]
|
||||
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
connection.execute(
|
||||
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
||||
(session_id, endpoint, encoded),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def claim_unseen(self, thread_ids: Iterable[int]) -> list[PushDelivery]:
|
||||
candidates = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
||||
if not candidates:
|
||||
return []
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
||||
).fetchall()
|
||||
deliveries = []
|
||||
for session_id, encoded in rows:
|
||||
delivered = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id FROM push_deliveries WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
unseen = tuple(value for value in candidates if value not in delivered)
|
||||
if unseen:
|
||||
deliveries.append(PushDelivery(session_id, json.loads(encoded), unseen))
|
||||
return deliveries
|
||||
|
||||
def mark_delivered(self, session_id: str, thread_ids: Iterable[int]) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO push_deliveries(session_id, thread_id) VALUES (?, ?)",
|
||||
((session_id, int(thread_id)) for thread_id in thread_ids),
|
||||
)
|
||||
@@ -271,6 +271,24 @@ class SessionStore:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
return row[0]
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
@@ -178,7 +178,7 @@ const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
@@ -206,7 +206,7 @@ let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
@@ -223,6 +223,32 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_reopen_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let reopens = 0;
|
||||
let resolveReopen;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, reopenable:true }};
|
||||
await preview.open(detail);
|
||||
const first = preview.reopen(detail);
|
||||
const second = preview.reopen(detail);
|
||||
if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight');
|
||||
resolveReopen({{ state:'open', assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -248,13 +274,31 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
|
||||
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
)[0]
|
||||
assert "searchReopenAndStart.run(detail)" in handler
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "available: createAndStart.available" in orchestrator
|
||||
assert "claim," in orchestrator
|
||||
assert "acceptClaimedIssue(confirmed)" in orchestrator
|
||||
assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
@@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications", "device-setup"}
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -141,7 +141,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v88';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -229,6 +229,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
@@ -255,3 +256,26 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Resume work",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["reopenable"] is True
|
||||
assert preview["claimable"] is False
|
||||
@@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_setup_api_reports_install_state_without_prompting():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
const before = app.state();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const after = app.state();
|
||||
process.stdout.write(JSON.stringify({before, after}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
|
||||
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
}
|
||||
|
||||
|
||||
def test_captured_install_prompt_remains_available_until_setup_opens():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
let prevented = 0;
|
||||
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:new FakeTarget(), initialPrompt:prompt, card,
|
||||
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
|
||||
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
"shown": True,
|
||||
"prevented": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
|
||||
@@ -1094,6 +1094,71 @@ async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_
|
||||
assert result["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assignment():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "closed",
|
||||
"assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"state":"open","assignee":"timmy"}'
|
||||
assert result == {
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"repository": "stackchain/api", "labels": ["P1"],
|
||||
"assignees": ["timmy"], "updated_at": "",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 81, "number": 17, "title": "Resume work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reopen_issue("stackchain/api", 17)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["state"] == "open"
|
||||
assert result["assignees"] == ["timmy"]
|
||||
assert all(request.method == "GET" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
|
||||
calls = []
|
||||
@@ -1134,6 +1199,30 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def reopen(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {
|
||||
"id": 81, "number": number, "title": "Resume work", "state": "open",
|
||||
"repository": repository, "labels": ["P1"], "assignees": ["timmy"],
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/reopen")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["state"] == "open"
|
||||
assert response.json()["assignees"] == ["timmy"]
|
||||
assert calls == [("stackchain/api", 17)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
|
||||
requests = []
|
||||
|
||||
@@ -494,7 +494,7 @@ def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_i
|
||||
"\n function closePullSheet", 1
|
||||
)[0]
|
||||
|
||||
assert "if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
@@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createMobileDeviceSetup = require(__MODULE__);
|
||||
class FakeTarget {
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
}
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
|
||||
const launcher = new FakeTarget();
|
||||
const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
const installButton = new FakeTarget();
|
||||
const offlineButton = new FakeTarget();
|
||||
const pushButton = new FakeTarget();
|
||||
const installStatus = new FakeTarget();
|
||||
const offlineStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
const escapeTarget = new FakeTarget();
|
||||
const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
getItem(key) { return this.values[key] ?? null; },
|
||||
setItem(key, value) { this.values[key] = String(value); },
|
||||
};
|
||||
let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||
};
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
|
||||
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
});
|
||||
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:sheet.hidden,
|
||||
install:installStatus.textContent,
|
||||
offline:offlineStatus.textContent,
|
||||
push:pushStatus.textContent,
|
||||
summary:readyStatus.textContent,
|
||||
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"install": "Stackchain is installed.",
|
||||
"offline": "Offline work is off.",
|
||||
"push": "Notifications are unavailable.",
|
||||
"summary": "1 of 2 available steps ready.",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
||||
result = run_scenario("""
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:promptCard.hidden,
|
||||
summary:promptSummary.textContent,
|
||||
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hidden": False,
|
||||
"summary": "1 of 2 steps complete",
|
||||
"calls": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
|
||||
result = run_scenario("""
|
||||
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
|
||||
const opened = !sheet.hidden && state.focused === closeButton;
|
||||
await closeButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
opened,
|
||||
closed:sheet.hidden,
|
||||
promptFocused:state.focused === promptLauncher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {"opened": True, "closed": True, "promptFocused": True}
|
||||
|
||||
|
||||
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
|
||||
result = run_scenario("""
|
||||
await promptDismiss.dispatch('click');
|
||||
const hiddenAfterDismiss = promptCard.hidden;
|
||||
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
|
||||
now = dismissedUntil + 1;
|
||||
await setup.render();
|
||||
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"hiddenAfterDismiss": True,
|
||||
"delay": 7 * 24 * 60 * 60 * 1000,
|
||||
"hiddenAfterExpiry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
|
||||
await offlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:state.offlineCalls,
|
||||
offline:offlineStatus.textContent,
|
||||
hidden:offlineButton.hidden,
|
||||
promptHidden:promptCard.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"offline": "Offline work is saved.",
|
||||
"hidden": True,
|
||||
"promptHidden": True,
|
||||
"summary": "This device is ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_escape_closes_setup_and_restores_launcher_focus():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
await escapeTarget.dispatch('keydown', {key:'Escape'});
|
||||
process.stdout.write(JSON.stringify({
|
||||
hidden:sheet.hidden,
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-device-setup"' in html
|
||||
assert 'id="device-readiness-card"' in html
|
||||
assert 'id="finish-device-setup"' in html
|
||||
assert 'id="dismiss-device-readiness"' in html
|
||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v88" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".device-setup-action { min-height:44px;" in css
|
||||
assert ".device-readiness-actions button { min-height:44px;" in css
|
||||
+134
-1
@@ -4129,6 +4129,121 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||
|
||||
|
||||
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const data = {{
|
||||
issues:[{{repository:'stackchain/api',number:7}}],
|
||||
pulls:[
|
||||
{{repository:'stackchain/api',number:7,title:'Transfer me'}},
|
||||
{{repository:'stackchain/web',number:7,title:'Keep me'}}
|
||||
]
|
||||
}};
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
snapshot:createPullSheet.removeFromSnapshot(data, item),
|
||||
same:createPullSheet.sameTarget(item, {{repository:'stackchain/api',number:7}}),
|
||||
selectors:createPullSheet.ownershipSelectors()
|
||||
}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output["snapshot"]["issues"] == [{"repository": "stackchain/api", "number": 7}]
|
||||
assert output["snapshot"]["pulls"] == [{"repository": "stackchain/web", "number": 7, "title": "Keep me"}]
|
||||
assert output["same"] is True
|
||||
assert output["selectors"] == [
|
||||
"#release-pull", "#load-pull-handoff", "#pull-handoff-recipient", "#confirm-pull-handoff"
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_ownership_presenters_render_candidates_and_exit_messages():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const children = [];
|
||||
const elements = new Map([
|
||||
['#pull-ownership', {{open:true}}],
|
||||
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
|
||||
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
|
||||
['#load-pull-handoff', {{disabled:true}}],
|
||||
['#release-pull', {{disabled:true,textContent:''}}],
|
||||
['#pull-handoff-status', {{textContent:''}}],
|
||||
]);
|
||||
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
||||
const doc = {{
|
||||
createElement:() => ({{value:'',textContent:''}}),
|
||||
querySelector:selector => elements.get(selector)
|
||||
}};
|
||||
const count = createPullSheet.renderHandoffCandidates(select, [
|
||||
{{login:'alex',name:'Alexander'}}, {{login:'sam',name:'sam'}}
|
||||
], doc);
|
||||
createPullSheet.resetOwnershipControls(doc, {{key:'stackchain/api#7'}}, () => true);
|
||||
const item = {{key:'stackchain/api#7'}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
count, children,
|
||||
reset:Object.fromEntries(elements),
|
||||
handed:createPullSheet.ownershipExitMessage(item, 'handed off to @alex', 'opened'),
|
||||
released:createPullSheet.ownershipExitMessage(item, 'released', 'gated')
|
||||
}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output["count"] == 2
|
||||
assert output["children"] == [
|
||||
{"value": "", "textContent": "Select a teammate"},
|
||||
{"value": "alex", "textContent": "Alexander (@alex)"},
|
||||
{"value": "sam", "textContent": "sam"},
|
||||
]
|
||||
assert output["reset"]["#pull-ownership"]["open"] is False
|
||||
assert output["reset"]["#confirm-pull-handoff"]["textContent"] == "Hand off & next"
|
||||
assert output["reset"]["#release-pull"]["textContent"] == "Release & next"
|
||||
assert output["reset"]["#pull-handoff-status"]["textContent"] == "Load teammates to transfer ownership."
|
||||
assert output["handed"] == "stackchain/api#7 handed off to @alex. Next work item opened."
|
||||
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
|
||||
|
||||
|
||||
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let finish;
|
||||
const controller = createPullSheet({{
|
||||
storage: null,
|
||||
fetchJson: (url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/handoff')) return new Promise(resolve => {{ finish = resolve; }});
|
||||
return Promise.resolve(url.endsWith('/handoff-candidates') ? [{{login:'alex',name:'Alexander'}}] : {{assignees:[]}});
|
||||
}}
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
(async () => {{
|
||||
const candidates = await controller.loadHandoffCandidates(item);
|
||||
const first = controller.handoff(item, 'alex');
|
||||
const second = controller.handoff(item, 'alex');
|
||||
finish({{assignees:['alex'],recipient:'alex'}});
|
||||
const handoffs = await Promise.all([first, second]);
|
||||
const released = await controller.release(item);
|
||||
process.stdout.write(JSON.stringify({{calls,candidates,handoffs,released}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/handoff-candidates", "method": "GET", "body": None},
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/handoff", "method": "PATCH", "body": {"recipient": "alex"}},
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/release", "method": "PATCH", "body": None},
|
||||
]
|
||||
assert output["candidates"] == [{"login": "alex", "name": "Alexander"}]
|
||||
assert output["handoffs"] == [
|
||||
{"assignees": ["alex"], "recipient": "alex"},
|
||||
{"assignees": ["alex"], "recipient": "alex"},
|
||||
]
|
||||
assert output["released"] == {"assignees": []}
|
||||
|
||||
|
||||
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -4282,6 +4397,24 @@ sheet.loadReview(item, 'abc123').then(first => {{
|
||||
assert output["progress"] == {"reviewed": ["src/api.py"], "total": 1, "complete": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_sheet_exposes_mobile_ownership_exit_and_today_continuation():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="pull-ownership"' in html
|
||||
assert 'id="pull-handoff-recipient"' in html
|
||||
assert 'id="load-pull-handoff"' in html
|
||||
assert 'id="confirm-pull-handoff"' in html
|
||||
assert 'id="release-pull"' in html
|
||||
assert 'id="pull-handoff-status" class="small" aria-live="assertive"' in html
|
||||
assert '.pull-ownership select, .pull-ownership button { min-height:44px;' in html
|
||||
assert "createPullSheet.bindOwnershipControls(" in html
|
||||
assert "document, pullController, () => selectedPull" in html
|
||||
assert "createPullSheet.removeFromSnapshot(lastContextSnapshot" in html
|
||||
assert "if (continuing) return await completeOwnershipExitToday(item)" in html
|
||||
assert "createPullSheet.resetOwnershipControls(document, item" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
|
||||
html = await dashboard()
|
||||
@@ -4348,7 +4481,7 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
|
||||
assert "pullController.load(item)" in html
|
||||
assert "createPullSheet.renderFile" in html
|
||||
assert "pullController.toggleReviewed" in html
|
||||
assert "pullController.nextUnreviewed" in html
|
||||
assert "createPullSheet.focusNextUnreviewed(document" in html
|
||||
assert ".pull-diff { overflow-x:auto;" in html
|
||||
assert ".pull-file-toggle, .pull-review-file { min-height:44px;" in html
|
||||
assert "window.confirm('Merge ' + selectedPull.key" in html
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_tomorrow_resolves_unread_update_and_persists_canonical_later_item(
|
||||
monkeypatch,
|
||||
):
|
||||
resolved = []
|
||||
applied = []
|
||||
|
||||
async def confirmed_login():
|
||||
return "timmy"
|
||||
|
||||
async def resolve(kind, repository, number, notification_id):
|
||||
resolved.append((kind, repository, number, notification_id))
|
||||
return {
|
||||
"kind": "update",
|
||||
"notification_id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
}
|
||||
|
||||
class Store:
|
||||
def apply(
|
||||
self,
|
||||
login,
|
||||
operation_id,
|
||||
action,
|
||||
item_id,
|
||||
*,
|
||||
wake_at=None,
|
||||
base_revision=None,
|
||||
):
|
||||
applied.append(
|
||||
(login, operation_id, action, item_id, wake_at, base_revision)
|
||||
)
|
||||
return {"revision": 3, "records": {item_id: wake_at}}
|
||||
|
||||
monkeypatch.setattr(main, "_confirmed_login", confirmed_login)
|
||||
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
|
||||
monkeypatch.setattr(main, "_later_store", lambda: Store())
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/42/later",
|
||||
json={"wake_at": "2026-08-12T09:00:00.000Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"id": 42,
|
||||
"status": "deferred",
|
||||
"item_id": "update:stackchain/api::42",
|
||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
||||
"revision": 3,
|
||||
}
|
||||
assert resolved == [("update", None, None, 42)]
|
||||
assert applied == [(
|
||||
"timmy",
|
||||
"push-tomorrow:42:2026-08-12T09:00:00.000Z",
|
||||
"defer",
|
||||
"update:stackchain/api::42",
|
||||
"2026-08-12T09:00:00.000Z",
|
||||
None,
|
||||
)]
|
||||
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -459,3 +459,145 @@ async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository):
|
||||
calls.append(("candidates", repository))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
async def handoff(repository, number, recipient):
|
||||
calls.append(("handoff", repository, number, recipient))
|
||||
return {"repository": repository, "number": number, "assignees": [recipient], "recipient": recipient}
|
||||
|
||||
async def release(repository, number):
|
||||
calls.append(("release", repository, number))
|
||||
return {"repository": repository, "number": number, "assignees": []}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_handoff_candidates", candidates, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_pull", handoff, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "release_assigned_pull", release, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repos/stackchain/api/pulls/7/handoff-candidates")
|
||||
transferred = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/handoff", json={"recipient": "alex"}
|
||||
)
|
||||
released = await client.patch("/api/v1/repos/stackchain/api/pulls/7/release")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert transferred.status_code == 200
|
||||
assert transferred.json()["recipient"] == "alex"
|
||||
assert released.status_code == 200
|
||||
assert released.json()["assignees"] == []
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("candidates", "stackchain/api"),
|
||||
("handoff", "stackchain/api", 7, "alex"),
|
||||
("release", "stackchain/api", 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"assignees": [{"login": "timmy"}, {"login": "sam"}],
|
||||
})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy"}, {"login": "sam"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
])
|
||||
if request.method == "PATCH" and request.url.path.endswith("/issues/7"):
|
||||
assert request.read()
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open",
|
||||
"assignees": [{"login": "sam"}, {"login": "alex"}],
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.handoff_assigned_pull("stackchain/api", 7, "alex")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 7, "state": "open",
|
||||
"assignees": ["sam", "alex"], "recipient": "alex",
|
||||
}
|
||||
assert [(method, path) for method, path, _body in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/assignees"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/7"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_current_logins():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=(
|
||||
[{"login": "timmy"}, {"login": "bad login"}, {"login": ""}]
|
||||
+ [{"login": f"user-{index:02d}"} for index in range(30)]
|
||||
))
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
candidates = await gitea_proxy.pull_handoff_candidates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(candidates) == 25
|
||||
assert [candidate["login"] for candidate in candidates] == [
|
||||
f"user-{index:02d}" for index in range(25)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_release_removes_current_login_case_insensitively():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"assignees": [{"login": "Timmy"}, {"login": "sam"}],
|
||||
})
|
||||
if request.method == "PATCH" and request.url.path.endswith("/issues/7"):
|
||||
assert request.content == b'{"assignees":["sam"]}'
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "assignees": [{"login": "sam"}],
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
released = await gitea_proxy.release_assigned_pull("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert released["assignees"] == ["sam"]
|
||||
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createPushNotifications = require(__MODULE__);
|
||||
const state = { prompts:0, requests:[], subscriptions:[], text:'' };
|
||||
const control = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.change = callback,
|
||||
};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
const registration = {pushManager:{
|
||||
getSubscription: async () => state.current || null,
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status,
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
});
|
||||
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_initialization_reports_availability_without_prompting_for_permission():
|
||||
result = run_scenario("await feature.init(); process.stdout.write(JSON.stringify(state));")
|
||||
|
||||
assert result["prompts"] == 0
|
||||
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
|
||||
assert result["text"] == "New update notifications are off for this device."
|
||||
|
||||
|
||||
def test_user_gesture_subscribes_device_and_reports_enabled_state():
|
||||
result = run_scenario("""
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"][0]["userVisibleOnly"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription", "PUT"]
|
||||
assert json.loads(result["requests"][-1][2])["endpoint"] == "https://push.example/device"
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
requirements = (root / "requirements.txt").read_text()
|
||||
readme = (root / "README.md").read_text()
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
assert "BASE + 'static/push-notifications.js'" in worker
|
||||
assert ".push-update-control" in css and "min-height:44px" in css
|
||||
assert "pywebpush==" in requirements
|
||||
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
|
||||
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
|
||||
@@ -0,0 +1,523 @@
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import dashboard_auth, gitea_proxy, main
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
|
||||
assert first.acquire_dispatch_lease("worker-a", now=100, lease_seconds=30) is True
|
||||
assert second.acquire_dispatch_lease("worker-b", now=100, lease_seconds=30) is False
|
||||
assert second.acquire_dispatch_lease("worker-b", now=131, lease_seconds=30) is True
|
||||
|
||||
assert first.release_dispatch_lease("worker-a") is False
|
||||
assert second.release_dispatch_lease("worker-b") is True
|
||||
|
||||
|
||||
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
state_dir = tmp_path / "push-state"
|
||||
previous_umask = os.umask(0)
|
||||
try:
|
||||
database = state_dir / "push.sqlite3"
|
||||
PushSubscriptionStore(database)
|
||||
finally:
|
||||
os.umask(previous_umask)
|
||||
|
||||
assert state_dir.stat().st_mode & 0o777 == 0o700
|
||||
assert database.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
|
||||
store.upsert("session-a", subscription)
|
||||
|
||||
first = store.claim_unseen({41, 42})
|
||||
store.mark_delivered("session-a", first[0].thread_ids)
|
||||
second = store.claim_unseen({41, 42})
|
||||
|
||||
assert [(item.session_id, item.thread_ids) for item in first] == [
|
||||
("session-a", (41, 42))
|
||||
]
|
||||
assert second == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_managed_session_active_applies_configured_idle_deadline(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Store:
|
||||
def managed_status(self, management_id, *, idle_timeout_seconds):
|
||||
calls.append((management_id, idle_timeout_seconds))
|
||||
return "active" if management_id == "active-device" else "idle"
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
|
||||
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
|
||||
|
||||
assert await dashboard_auth.managed_session_active("active-device") is True
|
||||
assert await dashboard_auth.managed_session_active("idle-device") is False
|
||||
assert calls == [("active-device", 321), ("idle-device", 321)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def no_wait(_seconds):
|
||||
return None
|
||||
|
||||
async def stop_after_capture(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert captured["session_active"] is dashboard_auth.managed_session_active
|
||||
assert captured["max_concurrency"] == 3
|
||||
|
||||
|
||||
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
original = {
|
||||
"endpoint": "https://push.example/original",
|
||||
"keys": {"p256dh": "old-key", "auth": "old-auth"},
|
||||
}
|
||||
replacement = {
|
||||
"endpoint": "https://push.example/replacement",
|
||||
"keys": {"p256dh": "new-key", "auth": "new-auth"},
|
||||
}
|
||||
store.upsert("session-a", original)
|
||||
store.claim_unseen({9})
|
||||
|
||||
store.upsert("session-a", replacement)
|
||||
delivery = store.claim_unseen({9})
|
||||
store.delete_session("session-a")
|
||||
|
||||
assert delivery[0].subscription == replacement
|
||||
assert delivery[0].thread_ids == (9,)
|
||||
assert store.claim_unseen({10}) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [
|
||||
{"id": 42, "repository": "private/repo", "title": "Secret title"},
|
||||
]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
sent.append((subscription, json.loads(payload)))
|
||||
|
||||
config = PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
assert sent[0][1] == {
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
}
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_production_notification_page_dispatches_one_unread_push(tmp_path):
|
||||
def upstream(_request):
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"repository": {"full_name": "private/repo"},
|
||||
"subject": {
|
||||
"title": "Secret title",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/private/repo/issues/7",
|
||||
},
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
page = await gitea_proxy.notification_page(1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return page
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert sent == [{
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
}]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sending = asyncio.Event()
|
||||
finish = asyncio.Event()
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 42}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload)["tag"])
|
||||
sending.set()
|
||||
await finish.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
active = asyncio.create_task(dispatch_unread_updates(first, config, unread, send))
|
||||
await sending.wait()
|
||||
competing = await asyncio.wait_for(
|
||||
dispatch_unread_updates(second, config, unread, send), timeout=0.1
|
||||
)
|
||||
finish.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert sent == ["stackchain-update-42"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/expired",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
class Expired(Exception):
|
||||
response = SimpleNamespace(status_code=410)
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8}]}
|
||||
|
||||
async def send(_subscription, _payload):
|
||||
raise Expired()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
assert store.is_subscribed("session-a") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
if subscription["endpoint"].endswith("session-a"):
|
||||
raise RuntimeError("provider unavailable")
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert sent == ["https://push.example/session-b"]
|
||||
assert store.claim_unseen({8})[0].session_id == "session-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("failing-device", "healthy-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
attempts = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [
|
||||
{"id": 8},
|
||||
{"id": 9},
|
||||
]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
endpoint = subscription["endpoint"]
|
||||
thread_id = int(json.loads(payload)["route"].rsplit("/", 1)[1])
|
||||
attempts.append((endpoint, thread_id))
|
||||
if endpoint.endswith("failing-device"):
|
||||
raise RuntimeError("provider unavailable")
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 2
|
||||
assert [item for item in attempts if item[0].endswith("failing-device")] == [
|
||||
("https://push.example/failing-device", 8)
|
||||
]
|
||||
remaining = {item.session_id: item.thread_ids for item in store.claim_unseen({8, 9})}
|
||||
assert remaining == {"failing-device": (8, 9)}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 13}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
if subscription["endpoint"].endswith("session-a"):
|
||||
await asyncio.Event().wait()
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
delivered = await asyncio.wait_for(
|
||||
dispatch_unread_updates(
|
||||
store, config, unread, send, send_timeout_seconds=0.01
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert sent == ["https://push.example/session-b"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatches_devices_concurrently_with_a_strict_bound(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for index in range(4):
|
||||
store.upsert(f"session-{index}", {
|
||||
"endpoint": f"https://push.example/session-{index}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
active = 0
|
||||
peak = 0
|
||||
bound_reached = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 13}]}
|
||||
|
||||
async def send(_subscription, _payload):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
bound_reached.set()
|
||||
await release.wait()
|
||||
active -= 1
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
dispatch = asyncio.create_task(dispatch_unread_updates(
|
||||
store, config, unread, send, max_concurrency=2
|
||||
))
|
||||
await asyncio.wait_for(bound_reached.wait(), timeout=0.2)
|
||||
|
||||
assert peak == 2
|
||||
assert dispatch.done() is False
|
||||
|
||||
release.set()
|
||||
assert await dispatch == 4
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_inactive_sessions_without_blocking_active_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("expired-device", "active-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 21}]}
|
||||
|
||||
async def session_active(management_id):
|
||||
return management_id == "active-device"
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, session_active=session_active
|
||||
) == 1
|
||||
assert store.is_subscribed("expired-device") is False
|
||||
assert store.is_subscribed("active-device") is True
|
||||
assert sent == ["https://push.example/active-device"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_registry_fails(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("active-device", {
|
||||
"endpoint": "https://push.example/active-device",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 22}]}
|
||||
|
||||
async def registry_unavailable(_management_id):
|
||||
raise RuntimeError("session registry unavailable")
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, session_active=registry_unavailable
|
||||
) == 0
|
||||
assert store.is_subscribed("active-device") is True
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_push_configuration",
|
||||
lambda: PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com"),
|
||||
)
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
|
||||
async def no_unread():
|
||||
return {"items": []}
|
||||
|
||||
monkeypatch.setattr(main, "notifications", no_unread)
|
||||
request = SimpleNamespace(state=SimpleNamespace(
|
||||
dashboard_session=SimpleNamespace(session_id="session-a")
|
||||
))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
assert await main.push_status(request) == {
|
||||
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
assert await main.unsubscribe_push(request) == {"subscribed": False}
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_baselines_production_notification_ids(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_push_configuration",
|
||||
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
)
|
||||
|
||||
async def management_id(_session):
|
||||
return "device-a"
|
||||
|
||||
async def current_unread():
|
||||
return {"items": [{"id": 42}]}
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "notifications", current_unread)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert store.claim_unseen({42}) == []
|
||||
assert store.claim_unseen({42, 43})[0].thread_ids == (43,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main, "_push_configuration",
|
||||
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
)
|
||||
|
||||
async def management_id(_session):
|
||||
return "device-a"
|
||||
|
||||
async def unavailable():
|
||||
raise RuntimeError("Gitea unavailable")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "notifications", unavailable)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.subscribe_push(payload, request)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert store.is_subscribed("device-a") is False
|
||||
+229
-12
@@ -41,6 +41,7 @@ const context = {{
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -104,10 +105,19 @@ async function dispatchMessage(data, ports = []) {{
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route) {{
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
action,
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchPush(payload) {{
|
||||
let pending;
|
||||
listeners.push({{
|
||||
data: {{json: () => payload}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
@@ -125,7 +135,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -134,14 +144,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -149,7 +159,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -157,14 +167,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -173,21 +183,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -373,6 +383,211 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'New work update', body:'Tap to review it in Stackchain.',
|
||||
tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42,
|
||||
repository:'must-not-render',
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/update/42');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "New work update",
|
||||
"options": {
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"tag": "stackchain-update-42",
|
||||
"actions": [
|
||||
{"action": "mark-read", "title": "Mark read"},
|
||||
{"action": "tomorrow", "title": "Tomorrow"},
|
||||
],
|
||||
"data": {
|
||||
"route": "#/my-work/update/42",
|
||||
"notificationId": 42,
|
||||
},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/update/42"
|
||||
]
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const calls = [];
|
||||
context.fetch = async (request, options = {}) => {
|
||||
const url = String(request.url || request);
|
||||
const headers = new Headers(options.headers || {});
|
||||
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ok:true}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
};
|
||||
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
||||
process.stdout.write(JSON.stringify({state,calls}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["calls"] == [
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/session",
|
||||
"method": "GET",
|
||||
"csrf": None,
|
||||
},
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/notifications/42/read",
|
||||
"method": "PATCH",
|
||||
"csrf": "session-proof",
|
||||
},
|
||||
]
|
||||
assert result["state"]["notificationClosed"] is True
|
||||
assert result["state"]["opened"] == []
|
||||
assert result["state"]["focused"] == []
|
||||
|
||||
|
||||
def test_push_tomorrow_action_defers_privately_without_opening_or_marking_read():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const calls = [];
|
||||
context.self.__STACKCHAIN_NOW = () => new Date('2026-08-11T18:30:00.000Z');
|
||||
context.fetch = async (request, options = {}) => {
|
||||
const url = String(request.url || request);
|
||||
const headers = new Headers(options.headers || {});
|
||||
calls.push({
|
||||
url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token'),
|
||||
body:options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({id:42,status:'deferred'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
};
|
||||
await dispatchNotificationClick('#/my-work/update/42', 'tomorrow', 42, 'stackchain-update-42');
|
||||
process.stdout.write(JSON.stringify({state,calls}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["calls"] == [
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/session",
|
||||
"method": "GET",
|
||||
"csrf": None,
|
||||
"body": None,
|
||||
},
|
||||
{
|
||||
"url": "https://forge.example/dashboard/api/v1/notifications/42/later",
|
||||
"method": "PATCH",
|
||||
"csrf": "session-proof",
|
||||
"body": {"wake_at": "2026-08-12T09:00:00.000Z"},
|
||||
},
|
||||
]
|
||||
assert result["state"]["notificationClosed"] is True
|
||||
assert result["state"]["opened"] == []
|
||||
assert all(not call["url"].endswith("/read") for call in result["calls"])
|
||||
|
||||
|
||||
def test_push_mark_read_failure_opens_existing_update_reader():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientList = [{
|
||||
url:'https://forge.example/dashboard/',
|
||||
navigate:async function(url){ this.url=url; },
|
||||
focus:async function(){ state.focused.push(this.url); },
|
||||
}];
|
||||
context.fetch = async (request, options = {}) => {
|
||||
const url = String(request.url || request);
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({error:'unavailable'}), {
|
||||
status:503, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
};
|
||||
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/update/42"
|
||||
]
|
||||
assert result["opened"] == []
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_push_mark_read_rejects_mismatched_notification_identity():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const calls = [];
|
||||
context.fetch = async (request, options = {}) => {
|
||||
calls.push({url:String(request.url || request), method:String(options.method || 'GET')});
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
};
|
||||
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 43);
|
||||
process.stdout.write(JSON.stringify({state,calls}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["calls"] == []
|
||||
assert result["state"].get("notificationClosed") is not True
|
||||
assert result["state"]["opened"] == []
|
||||
|
||||
|
||||
def test_push_mark_read_timeout_aborts_request_and_opens_update_reader():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientList = [{
|
||||
url:'https://forge.example/dashboard/',
|
||||
navigate:async function(url){ this.url=url; },
|
||||
focus:async function(){ state.focused.push(this.url); },
|
||||
}];
|
||||
context.fetch = async (request, options = {}) => {
|
||||
const url = String(request.url || request);
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||
status:200, headers:{'Content-Type':'application/json'},
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
state.actionFetchAborted = true;
|
||||
reject(new Error('aborted'));
|
||||
}, {once:true});
|
||||
});
|
||||
};
|
||||
dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
process.exit(0);
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["actionFetchAborted"] is True
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/update/42"
|
||||
]
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
@@ -397,7 +612,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -467,9 +682,11 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/push-notifications.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,20 @@ def test_session_status_reports_idle_without_background_validation_extending_act
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
|
||||
|
||||
def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000, management_id="phone-device")
|
||||
store.activate("short-session", 1_200, management_id="short-device")
|
||||
|
||||
assert store.managed_status("phone-device", idle_timeout_seconds=900) == "active"
|
||||
now[0] = 1_200.0
|
||||
assert store.managed_status("short-device", idle_timeout_seconds=900) == "revoked"
|
||||
now[0] = 1_900.0
|
||||
assert store.managed_status("phone-device", idle_timeout_seconds=900) == "idle"
|
||||
assert store.managed_status("missing-device", idle_timeout_seconds=900) == "revoked"
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v88';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "stackchain-dashboard-shell-v88" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
+12
-13
@@ -36,21 +36,20 @@ async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails
|
||||
assert "Agent UI delta binding" not in html
|
||||
|
||||
|
||||
def test_repo_mix_reports_unavailable_for_failed_context_response():
|
||||
def test_repo_mix_renders_counts_from_shared_context_snapshot():
|
||||
script = f"""
|
||||
const updateRepoMix = require({json.dumps(str(WIDGETS))});
|
||||
const element = {{ innerHTML: 'stale counts' }};
|
||||
const failedFetch = async () => ({{
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({{ error: 'upstream unavailable' }}),
|
||||
}});
|
||||
|
||||
updateRepoMix(element, failedFetch).then(() => {{
|
||||
if (!element.innerHTML.includes('Widget unavailable.')) {{
|
||||
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
|
||||
}}
|
||||
const renderRepoMix = require({json.dumps(str(WIDGETS))});
|
||||
const element = {{ innerHTML: '' }};
|
||||
renderRepoMix(element, {{
|
||||
repos:[{{}},{{}}],
|
||||
issues:[{{state:'open'}},{{state:'closed'}}],
|
||||
pull_requests:[{{}},{{}},{{}}],
|
||||
}});
|
||||
if (!element.innerHTML.includes('<div class="value">2</div>') ||
|
||||
!element.innerHTML.includes('<div class="value">1</div>') ||
|
||||
!element.innerHTML.includes('<div class="value">3</div>')) {{
|
||||
throw new Error('shared snapshot counts were not rendered: ' + element.innerHTML);
|
||||
}}
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
|
||||
Reference in new issue
Block a user