Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
798790ce1a | ||
|
|
9629643a63 | ||
|
|
9f3855d779 | ||
|
|
c99dd3e3be | ||
|
|
7e173640ed | ||
|
|
87f63370f6 | ||
|
|
614ffae188 | ||
|
|
aaa9aef78b | ||
|
|
10a8b2738b | ||
|
|
379f2b8203 | ||
|
|
53bf89bd47 | ||
|
|
697f72da09 | ||
|
|
c170240057 | ||
|
|
3043ccc846 | ||
|
|
c5ddb1275b | ||
|
|
1fb26e0a86 | ||
|
|
be7bfcaa5a | ||
|
|
6a3c6c7a9e | ||
|
|
cab230e3a2 | ||
|
|
80ea81ad73 | ||
|
|
8cd2459468 | ||
|
|
7ee10619a4 | ||
|
|
a8da5ce949 | ||
|
|
8e357a097b | ||
|
|
43f5833f7f | ||
|
|
27fff15da4 | ||
|
|
a2b78710f2 | ||
|
|
e9271f5aab | ||
|
|
257bc0110e | ||
|
|
5e0683cd4a | ||
|
|
c94fc5308f | ||
|
|
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
@@ -67,7 +67,8 @@ and an active Today session shows the current estimate plus estimated remaining
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
@@ -187,6 +188,27 @@ 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. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
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
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
|
||||
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 +324,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
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
function pathFor(context, commentId) {
|
||||
const item = context?.item || {};
|
||||
if (context?.kind === 'update') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
|
||||
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
|
||||
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
return Boolean(login && comment && comment.author === login);
|
||||
},
|
||||
async edit(context, pager, commentId, body) {
|
||||
const draft = String(body || '').trim();
|
||||
if (!draft) throw new Error('Comment must not be blank.');
|
||||
const comment = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: draft }),
|
||||
});
|
||||
pager.replace(comment);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async remove(context, pager, commentId) {
|
||||
if (!confirmDelete('Delete this comment permanently?')) return null;
|
||||
const result = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'DELETE', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
|
||||
throw new Error('Comment deletion was not confirmed.');
|
||||
}
|
||||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
actionHtml(comment) {
|
||||
return controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
|
||||
if (!comment || !controller.isOwned(comment)) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to edit or delete this comment.';
|
||||
return;
|
||||
}
|
||||
if (button.dataset.commentAction === 'delete') {
|
||||
try {
|
||||
const state = await controller.remove(surface.context, surface.pager, commentId);
|
||||
if (state) {
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment deleted.';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
card.innerHTML = '<div class="small">Editing your comment</div>' +
|
||||
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
|
||||
escapeHtml(comment.body || '') + '</textarea>' +
|
||||
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
|
||||
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
|
||||
const textarea = card.querySelector('.comment-edit-textarea');
|
||||
textarea.focus();
|
||||
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
|
||||
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
|
||||
const save = saveEvent.currentTarget;
|
||||
save.disabled = true;
|
||||
surface.status.textContent = 'Saving comment…';
|
||||
try {
|
||||
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment updated.';
|
||||
} catch (error) {
|
||||
save.disabled = false;
|
||||
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
|
||||
@@ -63,6 +63,21 @@ function createConversationPager({ loadPage }) {
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
|
||||
};
|
||||
return snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
const comments = state.comments.filter(comment => comment.id !== commentId);
|
||||
if (comments.length !== state.comments.length) {
|
||||
state = { ...state, comments, total: Math.max(0, state.total - 1) };
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+67
-4
@@ -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; }
|
||||
@@ -35,6 +50,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
@@ -81,6 +100,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; }
|
||||
@@ -107,6 +127,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
@@ -117,6 +138,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-interruption-sheet[hidden] { display:none; }
|
||||
.today-interruption-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 #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-recap-header h2 { margin:.2rem 0; }
|
||||
.today-recap-header button { min-height:44px; min-width:44px; }
|
||||
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
|
||||
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
|
||||
.today-recap-row strong { overflow-wrap:anywhere; }
|
||||
.today-recap-row input { width:6rem; min-height:44px; }
|
||||
.today-recap-context { display:block; overflow-wrap:anywhere; }
|
||||
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
|
||||
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
|
||||
.today-recap-log input { width:24px; min-height:24px; }
|
||||
.today-recap-log-success { color:#86efac; font-weight:700; }
|
||||
.today-recap-log-verify { color:#fde68a; font-weight:700; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -340,19 +393,21 @@ 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; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
@@ -451,6 +506,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
@@ -462,7 +519,12 @@ 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; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
@@ -507,6 +569,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
||||
+284
-109
@@ -44,10 +44,22 @@
|
||||
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||||
);
|
||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
const attentionInterruption = qs('#attention-interruption');
|
||||
function renderAttentionInterruption() {
|
||||
const pending = timer.attentionInterruption();
|
||||
attentionInterruption.hidden = !pending;
|
||||
return pending;
|
||||
}
|
||||
function openMobileWorkFallback() {
|
||||
const counts = countMyWork(activeMyWork);
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('[data-work-filter="all"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
function openMobileAttention() {
|
||||
if (workSession.checkpointed()) timer.beginAttention();
|
||||
renderAttentionInterruption();
|
||||
timerView.render();
|
||||
qs('[data-work-filter="attention"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
@@ -68,6 +80,7 @@
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
attention: openMobileAttention,
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
@@ -219,6 +232,8 @@
|
||||
});
|
||||
laterSync.startLifecycle({ window, document });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) interruptionPrompt.background();
|
||||
else interruptionPrompt.foreground();
|
||||
if (!document.hidden) refreshMyWorkView();
|
||||
});
|
||||
|
||||
@@ -236,6 +251,8 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
let commentActions = { isOwned: () => false, wire: () => {} };
|
||||
|
||||
function loadMentionCandidates(repository, query) {
|
||||
return fetchReviewJson(
|
||||
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
|
||||
@@ -413,10 +430,27 @@
|
||||
text: shareParams.get('text') || '',
|
||||
url: shareParams.get('url') || '',
|
||||
};
|
||||
const commentActionFeatures = createFeatureLoader({
|
||||
document,
|
||||
urls: {
|
||||
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
|
||||
},
|
||||
});
|
||||
await commentActionFeatures.run('comment-actions', {
|
||||
status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.',
|
||||
}, () => {
|
||||
commentActions = createCommentActions({
|
||||
fetchJson: fetchReviewJson,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
confirmDelete: message => window.confirm(message),
|
||||
});
|
||||
});
|
||||
const issueCaptureFeatures = createFeatureLoader({
|
||||
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 +480,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({
|
||||
@@ -884,6 +928,7 @@
|
||||
}
|
||||
|
||||
function openWorkSessionItem(item) {
|
||||
timerView.open(workIdentity(item), workSession.checkpointed(item));
|
||||
openRoutedWork(item, null, { replace:true });
|
||||
}
|
||||
|
||||
@@ -895,6 +940,50 @@
|
||||
'Session recovery could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
const timer = createTodayTimer({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const timerView = createTodayTimerView({
|
||||
timer,
|
||||
isActive: () => workSession.checkpointed(),
|
||||
queryAll: s => document.querySelectorAll(s),
|
||||
formatEstimate: formatPlanMinutes,
|
||||
});
|
||||
renderAttentionInterruption();
|
||||
qs('#return-to-today').addEventListener('click', () => {
|
||||
const returned = timer.returnFromAttention();
|
||||
renderAttentionInterruption();
|
||||
if (!returned) return;
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === returned.identity);
|
||||
if (item) workSession.reopen(item);
|
||||
else continueTodaySession();
|
||||
timerView.render();
|
||||
});
|
||||
const interruptionPrompt = createTodayInterruptionPrompt({
|
||||
timer,
|
||||
sheet: qs('#today-interruption-sheet'),
|
||||
description: qs('#today-interruption-description'),
|
||||
getItemLabel: identity => {
|
||||
const item = [...todayMyWork, ...activeMyWork].find(entry => todayWork.identity(entry) === identity);
|
||||
return item?.title || '';
|
||||
},
|
||||
onResolved: () => timerView.render(),
|
||||
});
|
||||
window.addEventListener('pagehide', () => interruptionPrompt.background());
|
||||
interruptionPrompt.restore();
|
||||
document.querySelectorAll('[data-today-interruption]').forEach(button => {
|
||||
button.addEventListener('click', () =>
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
button.textContent = active ? 'Later today & next' : 'Later today';
|
||||
@@ -912,7 +1001,8 @@
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
updateDetailDeferLabels(active);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
@@ -926,10 +1016,7 @@
|
||||
updateWorkSessionActions();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
|
||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total + (runway?.current_minutes ?
|
||||
' · ' + formatPlanMinutes(runway.current_minutes) + ' · ' + formatPlanMinutes(runway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
timerView.update(state, runway);
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
|
||||
button.disabled = !state.can_previous;
|
||||
});
|
||||
@@ -940,15 +1027,8 @@
|
||||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
},
|
||||
onFinish: () => {
|
||||
closeOpenWorkSheets();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
updateWorkSessionActions();
|
||||
qs('#start-work-session').focus();
|
||||
},
|
||||
onFinish: () => todayRecapView.finish(selectedWorkFilter),
|
||||
});
|
||||
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
@@ -1246,13 +1326,16 @@
|
||||
const title = escapeHtml(item.title || 'Untitled work');
|
||||
const state = planToday.snapshot();
|
||||
const estimate = state.estimates?.[id] || '';
|
||||
const recommendation = state.recommendations?.[id];
|
||||
const estimateControl = selected ? '<label class="small plan-today-estimate-wrap">Estimate <input class="plan-today-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" value="' + escAttr(estimate) + '" data-plan-estimate="' + escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || key) + ' in minutes" /> min</label>' : '';
|
||||
const recommendationControl = selected && recommendation ? '<button class="plan-today-recommendation" type="button" data-plan-recommendation="' +
|
||||
escAttr(id) + '">Use ' + formatPlanMinutes(recommendation) + ' as new estimate</button>' : '';
|
||||
const controls = selected ?
|
||||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === state.count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||||
(item.kind === 'issue' ?
|
||||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
|
||||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + '</div>' + controls + '</article>';
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + recommendationControl + '</div>' + controls + '</article>';
|
||||
}
|
||||
|
||||
function resetPlanTodayConfirmation() {
|
||||
@@ -1309,6 +1392,11 @@
|
||||
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
|
||||
renderPlanToday();
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
|
||||
planToday.applyRecommendation(button.dataset.planRecommendation);
|
||||
renderPlanToday();
|
||||
document.querySelector('[data-plan-estimate="' + CSS.escape(button.dataset.planRecommendation) + '"]')?.focus();
|
||||
}));
|
||||
}
|
||||
|
||||
function closePlanToday(navigate = true) {
|
||||
@@ -1331,6 +1419,7 @@
|
||||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
||||
if (!todayWork.replace(ids)) return false;
|
||||
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
||||
todayRecapView.completeReplan();
|
||||
refreshMyWorkView();
|
||||
todaySync.flush();
|
||||
warmTodayOffline();
|
||||
@@ -1584,17 +1673,22 @@
|
||||
taskOverlayHistory.close();
|
||||
});
|
||||
|
||||
function openPlanToday(trigger, navigate = true) {
|
||||
let pendingPlanActualMinutes = null;
|
||||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||
if (!planningOwnerLogin) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
return;
|
||||
}
|
||||
if (trigger) planTodayTrigger = trigger;
|
||||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('plan-today');
|
||||
return;
|
||||
}
|
||||
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
|
||||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||||
pendingPlanActualMinutes = null;
|
||||
planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations);
|
||||
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
|
||||
qs('#plan-today-error').textContent = '';
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
@@ -1602,6 +1696,14 @@
|
||||
qs('#cancel-plan-today').focus();
|
||||
}
|
||||
|
||||
qs('#discard-recap-replan').addEventListener('click', () => {
|
||||
todayRecapView.discardReplan();
|
||||
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
|
||||
qs('#discard-recap-replan').hidden = true;
|
||||
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
|
||||
renderPlanToday();
|
||||
});
|
||||
|
||||
const detailDefer = createDetailDefer({
|
||||
laterWork,
|
||||
session: workSession,
|
||||
@@ -1698,9 +1800,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 +1812,7 @@
|
||||
qs('#issue-handoff').inert = true;
|
||||
} else {
|
||||
qs('#pull-review').inert = true;
|
||||
qs('#pull-ownership').inert = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1842,7 +1945,8 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
|
||||
' (' + (counts[selectedWorkFilter] || 0) + ')';
|
||||
@@ -2333,10 +2437,11 @@
|
||||
}
|
||||
|
||||
function renderIssueComment(comment) {
|
||||
const actions = commentActions.actionHtml?.(comment) || '';
|
||||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||||
'</div><div class="issue-sheet-content markdown-content">' +
|
||||
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
|
||||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
@@ -2368,6 +2473,32 @@
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
|
||||
function commentSurface(selector) {
|
||||
if (selector === '#issue-comments') return {
|
||||
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
|
||||
render:renderIssueConversation, status:qs('#issue-sheet-status'),
|
||||
};
|
||||
if (selector === '#pull-comments') return {
|
||||
context:{kind:'pull',item:selectedPull}, pager:pullConversation,
|
||||
render:renderPullConversation, status:qs('#pull-sheet-status'),
|
||||
};
|
||||
return {
|
||||
context:{kind:'update',item:selectedUpdate}, pager:notificationReader.commentPager(),
|
||||
render:renderUpdateConversation, status:qs('#update-sheet-status'),
|
||||
};
|
||||
}
|
||||
|
||||
function wireCommentActions(selector) {
|
||||
commentActions.wire({
|
||||
root:qs(selector), getSurface:()=>commentSurface(selector),
|
||||
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
|
||||
});
|
||||
}
|
||||
|
||||
wireCommentActions('#issue-comments');
|
||||
wireCommentActions('#pull-comments');
|
||||
wireCommentActions('#update-comments');
|
||||
|
||||
function renderIssueLabelEditor(item, confirmedNames, labels) {
|
||||
const list = qs('#issue-label-list');
|
||||
const status = qs('#issue-label-status');
|
||||
@@ -2592,21 +2723,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 +2747,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 +2782,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');
|
||||
@@ -3413,6 +3526,7 @@
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
}
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
@@ -3551,6 +3665,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 +3677,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 +3707,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 +3971,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 +4683,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');
|
||||
@@ -5016,6 +5143,7 @@
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
interruptionPrompt.restore();
|
||||
updatePlanningAvailability();
|
||||
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
||||
lastNotifications = saved.notifications;
|
||||
@@ -5054,20 +5182,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;
|
||||
@@ -5155,17 +5288,17 @@
|
||||
else workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||
qs('#end-today-session').addEventListener('click', () => {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
qs('#start-work-session').focus();
|
||||
});
|
||||
|
||||
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.previous())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => timerView.toggle())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
|
||||
button.addEventListener('click', () =>
|
||||
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
|
||||
@@ -5258,30 +5391,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 */
|
||||
|
||||
+105
-1
@@ -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">
|
||||
@@ -68,6 +96,7 @@
|
||||
</div>
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
@@ -97,6 +126,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 +147,21 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<aside class="attention-interruption" id="attention-interruption" role="status" aria-live="polite" hidden>
|
||||
<span><strong>Today paused</strong> while handling Attention</span>
|
||||
<button id="return-to-today" type="button">Return to Today</button>
|
||||
</aside>
|
||||
<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>
|
||||
@@ -209,6 +255,7 @@
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
@@ -224,6 +271,41 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
|
||||
<section class="today-interruption-panel">
|
||||
<div class="small">Today timer</div>
|
||||
<h2 id="today-interruption-title">Were you working while away?</h2>
|
||||
<p id="today-interruption-description" class="small"></p>
|
||||
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
|
||||
<div class="today-interruption-actions">
|
||||
<button type="button" data-today-interruption="count">Count this time</button>
|
||||
<button type="button" data-today-interruption="exclude">Exclude this time</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
|
||||
<section class="today-recap-panel">
|
||||
<div class="today-recap-header">
|
||||
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
|
||||
<button id="close-today-recap" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</p>
|
||||
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-recap-items" class="today-recap-items"></div>
|
||||
<div id="today-recap-totals" class="today-recap-totals"></div>
|
||||
<section aria-labelledby="today-recap-history-title">
|
||||
<h3 id="today-recap-history-title">Recent recaps</h3>
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -272,6 +354,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>
|
||||
@@ -408,6 +491,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -561,6 +645,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -613,14 +698,26 @@
|
||||
<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>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -695,6 +792,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -714,7 +812,8 @@
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
@@ -743,6 +842,8 @@
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
@@ -758,6 +859,7 @@
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
@@ -773,9 +875,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,
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
@@ -35,31 +34,33 @@
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateWork(mode, count) {
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const visible = total > 0;
|
||||
if (options.attentionBadge) {
|
||||
options.attentionBadge.textContent = String(total);
|
||||
options.attentionBadge.hidden = !visible;
|
||||
}
|
||||
if (buttons.attention) {
|
||||
buttons.attention.hidden = !visible;
|
||||
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
|
||||
}
|
||||
if (visible) nav.setAttribute('data-attention', 'true');
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
|
||||
@@ -395,6 +395,9 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
|
||||
+25
-2
@@ -4,6 +4,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
|
||||
function cleanItems(items) {
|
||||
@@ -15,7 +17,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates, planning = null) {
|
||||
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
@@ -29,6 +31,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||
}
|
||||
if (Object.keys(recommendations).length) capacityAware = true;
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
@@ -62,6 +71,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
}
|
||||
|
||||
@@ -84,6 +95,15 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyRecommendation(id) {
|
||||
const minutes = recommendations[id];
|
||||
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
|
||||
estimates[id] = minutes;
|
||||
delete recommendations[id];
|
||||
capacityAware = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
@@ -111,6 +131,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
...(recommendationAware ? { recommendations:Object.fromEntries(
|
||||
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
||||
) } : {}),
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
@@ -126,7 +149,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+115
-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-v91';
|
||||
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',
|
||||
@@ -30,6 +31,8 @@ const SHELL = [
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
@@ -45,6 +48,7 @@ const SHELL = [
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
@@ -60,9 +64,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 +274,115 @@ 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);
|
||||
const updateCount = Number(payload.update_count);
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
&& Number.isSafeInteger(updateCount)
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
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 => {
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
function todayRecapFeedbackRows(draft, describe = () => null) {
|
||||
return (draft?.items || []).map(item => {
|
||||
const work = describe(item.identity) || {};
|
||||
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
|
||||
return {
|
||||
identity:item.identity,
|
||||
label:String(work.title || item.identity).slice(0, 180),
|
||||
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
|
||||
estimate_minutes:estimate,
|
||||
actual_minutes:item.actual_minutes,
|
||||
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
|
||||
const totals = items => {
|
||||
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
|
||||
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
|
||||
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
|
||||
};
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
|
||||
};
|
||||
const handoffKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
|
||||
};
|
||||
const validStoredDraft = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
|
||||
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
|
||||
item.identity.length <= 500 && (item.estimate_minutes === null ||
|
||||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
|
||||
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
|
||||
if (!itemsValid) return false;
|
||||
const identities = new Set(saved.items.map(item => item.identity));
|
||||
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
|
||||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
|
||||
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
|
||||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
|
||||
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
|
||||
return true;
|
||||
};
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validStoredDraft(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { ...saved, ...totals(saved.items) };
|
||||
}
|
||||
catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const validHandoff = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
|
||||
const entries = Object.entries(saved.actual_minutes);
|
||||
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
|
||||
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
|
||||
);
|
||||
};
|
||||
const pendingReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validHandoff(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const completeReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
};
|
||||
let draftKey = storageKey();
|
||||
let draft = load();
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key || !draft) return;
|
||||
const saved = { session_id:draft.session_id, items:draft.items };
|
||||
if (draft.log_identities) saved.log_identities = draft.log_identities;
|
||||
if (draft.time_logs) saved.time_logs = draft.time_logs;
|
||||
storage.setItem(key, JSON.stringify(saved));
|
||||
};
|
||||
const restore = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
if (!draft) draft = load();
|
||||
return Boolean(draft);
|
||||
};
|
||||
const snapshot = () => {
|
||||
restore();
|
||||
return draft ? {
|
||||
...draft, items:draft.items.map(item => ({...item})),
|
||||
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
|
||||
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
|
||||
} : null;
|
||||
};
|
||||
const saveConfirmed = async includeActuals => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
|
||||
const actualMinutes = includeActuals ? Object.fromEntries(
|
||||
payload.items.map(item => [item.identity, item.actual_minutes])
|
||||
) : null;
|
||||
const result = await save(payload);
|
||||
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
|
||||
session_id:payload.session_id, actual_minutes:actualMinutes,
|
||||
}));
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
|
||||
};
|
||||
const saveWithTime = async identities => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
|
||||
const requested = new Set(Array.isArray(identities) ? identities : []);
|
||||
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
|
||||
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
|
||||
.filter(identity => eligible.has(identity));
|
||||
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
|
||||
draft.log_identities = intended;
|
||||
draft.time_logs = draft.time_logs || {};
|
||||
persist();
|
||||
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
|
||||
const payload = {
|
||||
session_id:draft.session_id,
|
||||
items:draft.items.map(item => ({...item})),
|
||||
log_identities:logIdentities,
|
||||
};
|
||||
const result = await saveAndLog(payload);
|
||||
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
|
||||
persist();
|
||||
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
|
||||
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
|
||||
const retry = intended.length - logged - verify;
|
||||
if (retry || verify) {
|
||||
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
|
||||
' verification in Gitea; it will not be posted again automatically.');
|
||||
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
|
||||
}
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
restore,
|
||||
begin(entries, estimates = {}) {
|
||||
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
|
||||
identity:String(entry.identity),
|
||||
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
|
||||
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
|
||||
}));
|
||||
draftKey = storageKey();
|
||||
draft = { session_id:makeId(), items, ...totals(items) };
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
correct(identity, minutes) {
|
||||
restore();
|
||||
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
|
||||
const item = draft.items.find(candidate => candidate.identity === identity);
|
||||
if (!item) return false;
|
||||
item.actual_minutes = minutes;
|
||||
Object.assign(draft, totals(draft.items));
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
snapshot,
|
||||
save:() => saveConfirmed(false),
|
||||
saveForReplan:() => saveConfirmed(true),
|
||||
saveAndLog:saveWithTime,
|
||||
pendingReplan,
|
||||
completeReplan,
|
||||
discardReplan:completeReplan,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTodayRecap(payload) {
|
||||
const response = await fetch('api/v1/today/recaps', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function saveTodayRecapTime(payload) {
|
||||
const response = await fetch('api/v1/today/recaps/log-time', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
|
||||
return result;
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
const container = qs('#today-recap-items');
|
||||
const rows = todayRecapFeedbackRows(draft, describeWork);
|
||||
container.innerHTML = rows.map(item => {
|
||||
const variance = item.variance_minutes === null ? 'No estimate comparison' :
|
||||
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
|
||||
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
|
||||
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
|
||||
const logStatus = draft?.time_logs?.[item.identity];
|
||||
const logControl = logStatus === 'logged' ?
|
||||
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
|
||||
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
|
||||
item.actual_minutes > 0 && canonical ?
|
||||
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
|
||||
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
|
||||
minutes(item.actual_minutes) + ' to Gitea</label>' :
|
||||
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
|
||||
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
|
||||
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
|
||||
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
|
||||
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
|
||||
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
|
||||
}).join('') || '';
|
||||
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
||||
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
||||
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
||||
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
|
||||
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
|
||||
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
|
||||
}
|
||||
render();
|
||||
}));
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
|
||||
try {
|
||||
const result = await api('api/v1/today/recaps');
|
||||
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
|
||||
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
|
||||
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
|
||||
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
|
||||
).join('') || '<div class="small">No saved recaps yet.</div>';
|
||||
} catch (_error) {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
|
||||
}
|
||||
};
|
||||
const open = ({ begin = false } = {}) => {
|
||||
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
|
||||
else recap.restore();
|
||||
render();
|
||||
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#today-recap-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
loadHistory();
|
||||
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
|
||||
};
|
||||
const close = () => {
|
||||
qs('#today-recap-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
const handoff = await recap.saveForReplan();
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const saveTimeDraft = async button => {
|
||||
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
|
||||
.map(input => input.dataset.recapLogIdentity);
|
||||
if (!selected.length) {
|
||||
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
await recap.saveAndLog(selected);
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const bind = () => {
|
||||
qs('#open-today-recaps').addEventListener('click', () => open());
|
||||
qs('#close-today-recap').addEventListener('click', close);
|
||||
qs('#discard-today-recap').addEventListener('click', close);
|
||||
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
|
||||
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
|
||||
};
|
||||
return {
|
||||
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
|
||||
pendingReplan:recap.pendingReplan,
|
||||
completeReplan:recap.completeReplan,
|
||||
discardReplan:recap.discardReplan,
|
||||
};
|
||||
}
|
||||
|
||||
function endTodaySession(workSession, qs) {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
}
|
||||
|
||||
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
timerView.finish();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
|
||||
else qs('#start-work-session').focus();
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
});
|
||||
const view = createTodayRecapView({ ...options, recap });
|
||||
view.bind();
|
||||
setInterval(timerView.render, 1000);
|
||||
view.finish = workFilter => {
|
||||
closeSheets();
|
||||
updateActions();
|
||||
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayRecap.createView = createTodayRecapView;
|
||||
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
||||
module.exports = createTodayRecap;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
const key = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return empty();
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
|
||||
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
|
||||
!value.entries || typeof value.entries !== 'object') return empty();
|
||||
return value;
|
||||
} catch (_error) {
|
||||
return empty();
|
||||
}
|
||||
};
|
||||
const write = state => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validPending = state => {
|
||||
const pending = state.pending_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
|
||||
{ identity:pending.identity, away_ms:pending.away_ms } : null;
|
||||
};
|
||||
const validAttention = state => {
|
||||
const pending = state.attention_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
|
||||
Math.max(0, at - Number(entry.started_at ?? at));
|
||||
entry.started_at = null;
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const state = read();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||
};
|
||||
return {
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.entries[identity] = entry;
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
resume() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry) return false;
|
||||
if (!entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
const state = read();
|
||||
const existing = validAttention(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry) return null;
|
||||
const resume = Boolean(entry.running);
|
||||
if (resume) settle(state);
|
||||
state.away_at = null;
|
||||
state.attention_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.attention_interruption } : null;
|
||||
},
|
||||
attentionInterruption() {
|
||||
return validAttention(read());
|
||||
},
|
||||
returnFromAttention() {
|
||||
const state = read();
|
||||
const pending = validAttention(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
state.active_identity = pending.identity;
|
||||
if (pending.resume && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.attention_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running || validPending(state)) return false;
|
||||
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
|
||||
state.away_at = now();
|
||||
return write(state);
|
||||
},
|
||||
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
|
||||
const state = read();
|
||||
const existing = validPending(state);
|
||||
if (existing) return existing;
|
||||
state.pending_interruption = null;
|
||||
const entry = state.entries[state.active_identity];
|
||||
const awayAt = Number(state.away_at);
|
||||
const detectedAt = now();
|
||||
state.away_at = null;
|
||||
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
|
||||
detectedAt - awayAt < thresholdMs) {
|
||||
write(state);
|
||||
return null;
|
||||
}
|
||||
const awayMs = detectedAt - awayAt;
|
||||
settle(state, detectedAt);
|
||||
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
|
||||
write(state);
|
||||
return { ...state.pending_interruption };
|
||||
},
|
||||
pendingInterruption() {
|
||||
return validPending(read());
|
||||
},
|
||||
resolveInterruption(decision) {
|
||||
if (decision !== 'count' && decision !== 'exclude') return false;
|
||||
const state = read();
|
||||
const pending = state.pending_interruption;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
|
||||
if (decision === 'exclude') {
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
|
||||
}
|
||||
state.active_identity = pending.identity;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.pending_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
recapEntries() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
write(state);
|
||||
return Object.entries(state.entries).map(([identity, entry]) => ({
|
||||
identity,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
|
||||
})).filter(entry => entry.elapsed_ms > 0);
|
||||
},
|
||||
clearRecap() {
|
||||
return write(empty());
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
const elapsed = milliseconds => {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainder = String(seconds % 60).padStart(2, '0');
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
|
||||
};
|
||||
const render = () => {
|
||||
if (!progress) return;
|
||||
const snapshot = timer.snapshot();
|
||||
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
|
||||
(runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : '';
|
||||
queryAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing +
|
||||
(runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
queryAll('[data-work-session-timer-toggle]').forEach(button => {
|
||||
button.hidden = !isActive();
|
||||
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
open(identity, active) { active ? timer.activate(identity) : timer.stop(); render(); },
|
||||
finish() { timer.stop(); progress = null; runway = null; },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
|
||||
const render = pending => {
|
||||
if (!pending) {
|
||||
sheet.hidden = true;
|
||||
return false;
|
||||
}
|
||||
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
|
||||
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
|
||||
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
|
||||
sheet.hidden = false;
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
background() { return timer.markAway(); },
|
||||
foreground() { return render(timer.reconcileInterruption()); },
|
||||
restore() { return render(timer.pendingInterruption()); },
|
||||
resolve(decision) {
|
||||
if (!timer.resolveInterruption(decision)) return false;
|
||||
sheet.hidden = true;
|
||||
onResolved?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
+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,
|
||||
|
||||
+18
-1
@@ -12,10 +12,21 @@ 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*$(?!\n\s*else)", re.MULTILINE
|
||||
)
|
||||
COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"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"),
|
||||
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
@@ -49,6 +60,10 @@ 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_BROWSER_BRANCH.sub(b"{", source)
|
||||
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()
|
||||
@@ -86,7 +101,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
|
||||
@@ -20,6 +20,10 @@ class GiteaOverloadedError(RuntimeError):
|
||||
"""Raised when bounded transport admission expires before capacity is available."""
|
||||
|
||||
|
||||
class CommentMutationForbiddenError(RuntimeError):
|
||||
"""Raised when a comment is not owned by the operator or enclosing thread."""
|
||||
|
||||
|
||||
class GiteaTransport:
|
||||
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
|
||||
|
||||
@@ -210,6 +214,35 @@ async def fetch(path: str) -> Any:
|
||||
return r.json()
|
||||
|
||||
|
||||
def issue_time_target(identity: str) -> tuple[str, str]:
|
||||
"""Return the canonical repository and issue number encoded by an identity."""
|
||||
match = re.fullmatch(
|
||||
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
|
||||
identity,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError("time log target is invalid")
|
||||
return match.groups()
|
||||
|
||||
|
||||
async def log_issue_time(identity: str, seconds: int) -> None:
|
||||
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
|
||||
repository, number = issue_time_target(identity)
|
||||
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
|
||||
raise ValueError("time log duration is invalid")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/times",
|
||||
headers={**_auth(), "Content-Type": "application/json"},
|
||||
json={"time": seconds},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def time_log_failure_is_retryable(error: Exception) -> bool:
|
||||
"""Return true only when failure proves no successful response was lost."""
|
||||
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
|
||||
|
||||
|
||||
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
@@ -472,6 +505,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),
|
||||
}
|
||||
|
||||
@@ -708,6 +742,41 @@ async def notifications() -> dict:
|
||||
return await notification_page(1)
|
||||
|
||||
|
||||
async def unread_notification_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load one complete, bounded snapshot of unread notification threads."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await notification_page(1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Unread notification snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await notification_page(page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page["total"] != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Unread notification pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
thread_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(thread_ids)) != first["total"]:
|
||||
raise ValueError("Unread notification snapshot has an incomplete thread set")
|
||||
return {"items": items, "total": first["total"], "complete": True}
|
||||
|
||||
|
||||
async def notification_page(page: int, limit: int = 50) -> dict:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
|
||||
@@ -927,6 +996,25 @@ async def notification_conversation_page(
|
||||
)
|
||||
|
||||
|
||||
async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject_value = thread.get("subject")
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
repository_name = repository.get("full_name") if isinstance(repository, dict) else None
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1041,6 +1129,51 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def _owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
user, comment = await asyncio.gather(
|
||||
current_user(),
|
||||
fetch(f"repos/{repository}/issues/comments/{comment_id}"),
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
author = comment.get("user") if isinstance(comment, dict) else None
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not login
|
||||
or not isinstance(comment, dict)
|
||||
or not isinstance(author, dict)
|
||||
or author.get("login") != login
|
||||
or _gitea_api_path(comment.get("issue_url")) != expected_issue_path
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not editable in this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def edit_owned_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
|
||||
async def upload_assigned_issue_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -1272,6 +1405,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 +1630,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}",
|
||||
|
||||
+592
-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,56 @@ 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"))
|
||||
)
|
||||
max_individual_notifications = max(
|
||||
0,
|
||||
int(os.getenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "3")),
|
||||
)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await dispatch_unread_updates(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
gitea_proxy.unread_notification_snapshot,
|
||||
session_active=dashboard_auth.managed_session_active,
|
||||
lease_seconds=lease_seconds,
|
||||
send_timeout_seconds=send_timeout,
|
||||
max_concurrency=max_concurrency,
|
||||
max_individual_notifications=max_individual_notifications,
|
||||
)
|
||||
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 +142,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 +179,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 +227,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,10 +261,25 @@ 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",
|
||||
"close_issue",
|
||||
"log_recap_time",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
@@ -345,6 +416,34 @@ class TodayOperation(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class TodayRecapItem(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
|
||||
actual_minutes: int = Field(ge=0, le=1440)
|
||||
|
||||
|
||||
class TodayRecap(BaseModel):
|
||||
session_id: str = Field(min_length=1, max_length=100)
|
||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TodayRecapTimeLog(TodayRecap):
|
||||
log_identities: list[str] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
def _recap_time_log_target(payload: TodayRecapTimeLog) -> str:
|
||||
selected_minutes = {
|
||||
item.identity: item.actual_minutes
|
||||
for item in payload.items
|
||||
if item.identity in payload.log_identities
|
||||
}
|
||||
material = "\0".join(
|
||||
[payload.session_id]
|
||||
+ [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)]
|
||||
)
|
||||
return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}"
|
||||
|
||||
|
||||
class LaterOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["defer", "restore"]
|
||||
@@ -361,6 +460,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 +962,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 +1734,69 @@ 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 gitea_proxy.unread_notification_snapshot(
|
||||
deadline_seconds=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_revisions = {
|
||||
int(item["id"]): str(item.get("updated_at") or "")
|
||||
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_revisions
|
||||
)
|
||||
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
|
||||
@@ -1731,6 +1897,146 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today/recaps")
|
||||
async def get_today_recaps(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap history is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {"recaps": recaps}
|
||||
|
||||
|
||||
@app.post("/api/v1/today/recaps")
|
||||
async def save_today_recap(payload: TodayRecap, response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
recap = await asyncio.to_thread(
|
||||
_today_store().save_recap,
|
||||
login,
|
||||
payload.session_id,
|
||||
[item.model_dump() for item in payload.items],
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today recap could not be saved",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return recap
|
||||
|
||||
|
||||
@app.post("/api/v1/today/recaps/log-time")
|
||||
async def save_today_recap_and_log_time(
|
||||
payload: TodayRecapTimeLog,
|
||||
request: Request,
|
||||
response: Response,
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
login = await _confirmed_login()
|
||||
if len(set(payload.log_identities)) != len(payload.log_identities):
|
||||
raise HTTPException(status_code=422, detail="time log targets must be unique")
|
||||
requested_items = {item.identity: item for item in payload.items}
|
||||
selected = []
|
||||
try:
|
||||
for identity in payload.log_identities:
|
||||
gitea_proxy.issue_time_target(identity)
|
||||
requested = requested_items.get(identity)
|
||||
if requested is None:
|
||||
raise ValueError("time log target must match the recap")
|
||||
if requested.actual_minutes <= 0:
|
||||
raise ValueError("only non-zero recap time can be logged")
|
||||
selected.append(requested)
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="log_recap_time",
|
||||
target=_recap_time_log_target(payload),
|
||||
)
|
||||
recap = await asyncio.to_thread(
|
||||
_today_store().save_recap,
|
||||
login,
|
||||
payload.session_id,
|
||||
[item.model_dump() for item in payload.items],
|
||||
)
|
||||
saved_items = {item["identity"]: item for item in recap["items"]}
|
||||
for requested in selected:
|
||||
saved = saved_items.get(requested.identity)
|
||||
if saved is None or saved["actual_minutes"] != requested.actual_minutes:
|
||||
raise ValueError("time log target must match the saved recap")
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
|
||||
|
||||
results = []
|
||||
for item in selected:
|
||||
try:
|
||||
state = await asyncio.to_thread(
|
||||
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
|
||||
)
|
||||
if state == "claimed":
|
||||
repository, number = gitea_proxy.issue_time_target(item.identity)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=False,
|
||||
)
|
||||
results.append({"identity": item.identity, "status": "retry"})
|
||||
continue
|
||||
try:
|
||||
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
|
||||
except Exception as error:
|
||||
if gitea_proxy.time_log_failure_is_retryable(error):
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=False,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
status = "retry"
|
||||
else:
|
||||
status = "verify"
|
||||
results.append({"identity": item.identity, "status": status})
|
||||
continue
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
state = "logged"
|
||||
results.append({
|
||||
"identity": item.identity,
|
||||
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
|
||||
})
|
||||
except (ValueError, OSError, sqlite3.Error):
|
||||
results.append({"identity": item.identity, "status": "retry"})
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {**recap, "time_logs": results}
|
||||
|
||||
|
||||
@app.get("/api/v1/later")
|
||||
async def get_later_plan():
|
||||
login = await _confirmed_login()
|
||||
@@ -1776,6 +2082,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 +2118,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 +2222,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 +2267,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 +3396,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 +3593,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)
|
||||
@@ -3694,6 +4087,131 @@ async def comment_on_assigned_issue(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
async def _edit_conversation_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.edit_owned_comment(repository, number, comment_id, body),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only change your own comments"
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The comment could not be updated. Your edit is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _delete_conversation_comment(
|
||||
repository: str, number: int, comment_id: int
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.delete_owned_comment(repository, number, comment_id),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only change your own comments"
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The comment deletion could not be confirmed. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
|
||||
async def edit_assigned_issue_comment(
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_issue_comment(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
async def edit_assigned_pull_comment(
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_pull_comment(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
async def edit_notification_comment(
|
||||
comment: IssueComment,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
async def delete_notification_comment(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_issue(
|
||||
request: Request,
|
||||
@@ -4100,6 +4618,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,210 @@
|
||||
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,
|
||||
max_individual_notifications: int = 3,
|
||||
) -> 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()
|
||||
if page.get("complete") is False:
|
||||
return 0
|
||||
thread_revisions = {
|
||||
int(item["id"]): str(item.get("updated_at") or "")
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
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
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
thread_revision
|
||||
for thread_revision in delivery.thread_revisions
|
||||
if thread_revision not in digest_pending
|
||||
)
|
||||
individual_revisions = new_revisions[
|
||||
:max(0, max_individual_notifications)
|
||||
]
|
||||
overflow_revisions = (
|
||||
delivery.digest_revisions
|
||||
+ new_revisions[len(individual_revisions):]
|
||||
)
|
||||
can_send_digest = True
|
||||
for thread_id, revision in individual_revisions:
|
||||
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
|
||||
)
|
||||
can_send_digest = False
|
||||
# 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, revision),),
|
||||
)
|
||||
count += 1
|
||||
if overflow_revisions and can_send_digest:
|
||||
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": f"{len(overflow_revisions)} new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
},
|
||||
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
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
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,285 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
thread_revisions: tuple[tuple[int, str], ...]
|
||||
digest_revisions: tuple[tuple[int, str], ...] = ()
|
||||
|
||||
@property
|
||||
def thread_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
|
||||
|
||||
@property
|
||||
def digest_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
if isinstance(values, Mapping):
|
||||
items = list(values.items())
|
||||
else:
|
||||
items = []
|
||||
for value in values:
|
||||
if isinstance(value, tuple):
|
||||
items.append(value)
|
||||
else:
|
||||
items.append((value, ""))
|
||||
normalized = {}
|
||||
for thread_id, revision in items:
|
||||
thread_id = int(thread_id)
|
||||
if thread_id > 0:
|
||||
normalized[thread_id] = str(revision or "")
|
||||
return tuple(sorted(normalized.items()))
|
||||
|
||||
|
||||
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,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_digest_pending (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
|
||||
}
|
||||
if "revision" not in delivery_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
digest_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
|
||||
}
|
||||
if "revision" not in digest_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
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_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
candidates = _revisions(thread_revisions)
|
||||
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]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
for thread_id, revision in candidates:
|
||||
if delivered.get(thread_id) == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_deliveries SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
delivered[thread_id] = revision
|
||||
unseen = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if (
|
||||
candidate[0] not in delivered
|
||||
or (
|
||||
bool(candidate[1])
|
||||
and (
|
||||
not delivered[candidate[0]]
|
||||
or candidate[1] > delivered[candidate[0]]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if unseen:
|
||||
pending = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
digest_revisions = []
|
||||
for thread_id, revision in unseen:
|
||||
if pending.get(thread_id) in {revision, "*"}:
|
||||
digest_revisions.append((thread_id, revision))
|
||||
if pending[thread_id] == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_digest_pending SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
deliveries.append(
|
||||
PushDelivery(
|
||||
session_id,
|
||||
json.loads(encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
|
||||
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
|
||||
"""Prune per-device checkpoints that are absent from a complete snapshot."""
|
||||
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO current_unread(thread_id) VALUES (?)",
|
||||
((thread_id,) for thread_id in unread_ids),
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_deliveries
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_deliveries.thread_id
|
||||
)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_digest_pending
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_digest_pending.thread_id
|
||||
)"""
|
||||
)
|
||||
|
||||
def mark_digest_pending(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
|
||||
def mark_delivered(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
connection.executemany(
|
||||
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
||||
((session_id, thread_id) for thread_id, _revision in values),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,7 @@ class TodayStore:
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -26,6 +27,7 @@ class TodayStore:
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -65,6 +67,33 @@ class TodayStore:
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_recaps (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
|
||||
"ON today_recaps(login, created_at DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_time_logs (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
identity TEXT NOT NULL,
|
||||
actual_minutes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id, identity)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
@@ -113,6 +142,126 @@ class TodayStore:
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("recap requires at least one item")
|
||||
if len(items) > 20:
|
||||
raise ValueError("recap is limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("recap item identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("recap item identities must be unique")
|
||||
actual = item.get("actual_minutes")
|
||||
estimate = item.get("estimate_minutes")
|
||||
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
|
||||
raise ValueError("actual minutes must be between 0 and 1440")
|
||||
if estimate is not None and (
|
||||
not isinstance(estimate, int) or isinstance(estimate, bool)
|
||||
or estimate < 5 or estimate > 1440
|
||||
):
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
seen.add(identity)
|
||||
normalized.append({
|
||||
"identity": identity,
|
||||
"estimate_minutes": estimate,
|
||||
"actual_minutes": actual,
|
||||
})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
||||
items = json.loads(serialized)
|
||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
||||
actual = sum(item["actual_minutes"] for item in items)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"created_at": created_at,
|
||||
"items": items,
|
||||
"estimated_minutes": estimated,
|
||||
"actual_minutes": actual,
|
||||
"variance_minutes": actual - estimated,
|
||||
}
|
||||
|
||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
|
||||
raise ValueError("session_id is required and bounded")
|
||||
session_id = session_id.strip()
|
||||
normalized = self._normalize_recap_items(items)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
||||
(login, session_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
||||
created_at = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
||||
(login, session_id, created_at, serialized),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.recap_limit),
|
||||
)
|
||||
return self._recap_snapshot(session_id, created_at, serialized)
|
||||
|
||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(self._normalize_login(login), bounded_limit),
|
||||
).fetchall()
|
||||
return [self._recap_snapshot(*row) for row in rows]
|
||||
|
||||
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
||||
"""Claim one recap item for upstream logging, returning its current state."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT actual_minutes, status FROM today_time_logs "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
||||
"VALUES (?, ?, ?, ?, 'pending')",
|
||||
(login, session_id, identity, actual_minutes),
|
||||
)
|
||||
return "claimed"
|
||||
if row[0] != actual_minutes:
|
||||
raise ValueError("logged recap time cannot be changed")
|
||||
if row[1] == "failed":
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = 'pending' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
)
|
||||
return "claimed"
|
||||
return row[1]
|
||||
|
||||
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = ? "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
||||
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
|
||||
@@ -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-v91" in worker
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -1617,13 +1618,18 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
recording_started = threading.Event()
|
||||
recording_finished = threading.Event()
|
||||
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def record_failure(self, source):
|
||||
recording_started.set()
|
||||
time.sleep(0.15)
|
||||
recording_finished.set()
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
|
||||
@@ -1631,27 +1637,30 @@ async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
assert await asyncio.to_thread(recording_started.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
assert not recording_finished.is_set()
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 401
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
clear_started = threading.Event()
|
||||
clear_finished = threading.Event()
|
||||
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
clear_started.set()
|
||||
time.sleep(0.15)
|
||||
clear_finished.set()
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
|
||||
@@ -1662,14 +1671,12 @@ async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(clear_started.wait, 1)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
assert not clear_finished.is_set()
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -815,3 +815,9 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||
@@ -1,6 +1,7 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -25,7 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -45,7 +47,14 @@ 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) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -54,7 +63,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -76,6 +86,17 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
bundles = {"core.js": build.runtime_bytes}
|
||||
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
|
||||
for name, source in bundles.items():
|
||||
path = tmp_path / name
|
||||
path.write_bytes(source)
|
||||
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
build = build_frontend(FRONTEND)
|
||||
@@ -141,7 +162,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-v91';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -40,6 +40,123 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_loads_every_page_with_bounded_concurrency():
|
||||
active = 0
|
||||
peak = 0
|
||||
requested_pages = []
|
||||
|
||||
async def upstream(request):
|
||||
nonlocal active, peak
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await __import__("asyncio").sleep(0)
|
||||
active -= 1
|
||||
start = (page - 1) * 50 + 1
|
||||
end = min(start + 50, 126)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"updated_at": f"2026-08-11T12:{thread_id % 60:02d}:00Z",
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, end)],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=3, max_concurrency=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages[0] == 1
|
||||
assert sorted(requested_pages) == [1, 2, 3]
|
||||
assert peak == 2
|
||||
assert result["complete"] is True
|
||||
assert result["total"] == 125
|
||||
assert [item["id"] for item in result["items"]] == list(range(1, 126))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_pagination_that_changes_mid_scan():
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
total = 51 if page == 1 else 50
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": str(total)},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, min(start + 50, total + 1))],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="changed during the scan"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_missing_or_duplicate_threads():
|
||||
def upstream(_request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "3"},
|
||||
json=[
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
"malformed",
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="incomplete thread set"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_fails_before_exceeding_the_scan_limit():
|
||||
requested_pages = []
|
||||
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "101"},
|
||||
json=[
|
||||
{"id": thread_id, "subject": {}, "repository": {}}
|
||||
for thread_id in range(start, min(start + 50, 102))
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="scan limit"):
|
||||
await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
|
||||
@@ -7,6 +7,26 @@ from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"id": 1})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
|
||||
assert requests[0].read() == b'{"time":2520}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,163 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/comments/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"body": "Original",
|
||||
"user": {"login": "timmy"},
|
||||
"issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/17",
|
||||
"html_url": f"{gitea_proxy.GITEA_URL}/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
if request.method == "PATCH":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42, "body": "Corrected", "user": {"login": "timmy"},
|
||||
"html_url": "https://gitea.example/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
return httpx.Response(204)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
edited = await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Corrected")
|
||||
deleted = await gitea_proxy.delete_owned_comment("stackchain/api", 17, 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert edited["body"] == "Corrected"
|
||||
assert deleted == {"id": 42, "deleted": True}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
]
|
||||
assert json.loads(requests[2].content) == {"body": "Corrected"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"comment",
|
||||
[
|
||||
{"id": 42, "user": {"login": "alex"}, "issue_url": "https://gitea.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/99"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": "https://evil.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
],
|
||||
)
|
||||
async def test_owned_comment_mutations_reject_other_author_or_thread_without_writing(comment):
|
||||
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=comment)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.CommentMutationForbiddenError):
|
||||
await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Nope")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [request.method for request in requests] == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("base_path", "availability_call"),
|
||||
[
|
||||
("/api/v1/repos/stackchain/api/issues/17", "issue"),
|
||||
("/api/v1/repos/stackchain/api/pulls/17", "pull"),
|
||||
("/api/v1/notifications/91", "notification"),
|
||||
],
|
||||
)
|
||||
async def test_comment_mutation_routes_cover_issue_pull_and_unread_update(
|
||||
monkeypatch, base_path, availability_call
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def available_issue(repository, number):
|
||||
calls.append(("issue", repository, number))
|
||||
return True
|
||||
|
||||
async def available_pull(repository, number):
|
||||
calls.append(("pull", repository, number))
|
||||
return True
|
||||
|
||||
async def notification_target(thread_id):
|
||||
calls.append(("notification", thread_id))
|
||||
return "stackchain/api", 17
|
||||
|
||||
async def edit(repository, number, comment_id, body):
|
||||
calls.append(("edit", repository, number, comment_id, body))
|
||||
return {"id": comment_id, "author": "timmy", "body": body}
|
||||
|
||||
async def delete(repository, number, comment_id):
|
||||
calls.append(("delete", repository, number, comment_id))
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available_issue)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", available_pull)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "notification_conversation_target", notification_target,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", edit)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
edited = await client.patch(base_path + "/comments/42", json={"body": " Corrected "})
|
||||
deleted = await client.delete(base_path + "/comments/42")
|
||||
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["body"] == "Corrected"
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"id": 42, "deleted": True}
|
||||
if availability_call == "notification":
|
||||
assert calls == [
|
||||
("notification", 91), ("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
("notification", 91), ("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
else:
|
||||
assert calls == [
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_mutation_route_returns_forbidden_without_hiding_saved_edit(monkeypatch):
|
||||
async def available(repository, number):
|
||||
return True
|
||||
|
||||
async def forbidden(*args):
|
||||
raise gitea_proxy.CommentMutationForbiddenError("not owned")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", forbidden)
|
||||
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/comments/42",
|
||||
json={"body": "Keep this draft"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "You can only change your own comments"
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
@@ -1094,6 +1251,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 +1356,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-v91" 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-v91" 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-v91" 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-v91" 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
|
||||
@@ -61,7 +61,8 @@ class FakeElement {{
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
buttons.attention.hidden = true;
|
||||
const overlay = new FakeElement();
|
||||
const calls = [];
|
||||
let observerCallback;
|
||||
@@ -79,6 +80,7 @@ overlay.classList.values.delete('open'); observerCallback();
|
||||
buttons.drafts.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||
attentionHidden:buttons.attention.hidden,
|
||||
findFocuses:buttons.find.focuses,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||
}}));
|
||||
@@ -92,9 +94,11 @@ process.stdout.write(JSON.stringify({{
|
||||
"calls": ["work", "find", "drafts"],
|
||||
"hiddenWhileOpen": True,
|
||||
"hiddenAfterClose": False,
|
||||
"attentionHidden": True,
|
||||
"findFocuses": 1,
|
||||
"current": {
|
||||
"work": None,
|
||||
"attention": None,
|
||||
"find": None,
|
||||
"new": None,
|
||||
"search": None,
|
||||
@@ -103,17 +107,16 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
|
||||
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const workLabel = {{ textContent:'' }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel }});
|
||||
dock.updateWork('continue', 3);
|
||||
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const continuing = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const resuming = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
@@ -130,28 +133,27 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"continuing": {
|
||||
"text": "Continue",
|
||||
"count": "3",
|
||||
"hidden": False,
|
||||
"label": "Continue Today, 3 items need attention",
|
||||
"label": "Continue Today",
|
||||
},
|
||||
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
|
||||
"resuming": {"text": "Resume", "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
def test_mobile_task_dock_exposes_independent_attention_action_and_six_column_state():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const nav = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}, removeAttribute(name) {{ delete this.attributes[name]; }} }};
|
||||
const attention = {{ attributes: {{}}, hidden:true, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||
const dock = createDock({{ nav, buttons:{{attention}}, attentionBadge:badge }});
|
||||
dock.updateAttention(3);
|
||||
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
const pending = {{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] }};
|
||||
dock.updateAttention(0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending,
|
||||
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
|
||||
empty:{{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] || null }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -160,8 +162,8 @@ process.stdout.write(JSON.stringify({{
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||
"pending": {"count": "3", "badgeHidden": False, "actionHidden": False, "label": "Attention, 3 items", "layout": "true"},
|
||||
"empty": {"count": "0", "badgeHidden": True, "actionHidden": True, "label": "Attention, 0 items", "layout": None},
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +181,8 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "runTodayTransition('continue')" in html
|
||||
|
||||
@@ -189,24 +192,41 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "drafts"):
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
|
||||
for task in ("work", "attention", "find", "new", "search", "drafts"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "const filter = counts.attention ? 'attention' : 'all';" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "attention: openMobileAttention" in html
|
||||
assert "qs('[data-work-filter=\"attention\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="attention-interruption" id="attention-interruption"' in html
|
||||
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
||||
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
|
||||
assert 'timer.returnFromAttention()' in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "attentionInterruption.hidden = !pending" in html
|
||||
assert '.attention-interruption:not([hidden]) {' in html
|
||||
assert '.attention-interruption button { min-height:44px;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
+592
-3
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
@@ -18,6 +19,7 @@ ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
@@ -1930,6 +1932,352 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_today_timer_restores_wall_clock_time_once_and_excludes_pauses():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let now = 1000;
|
||||
const create = () => createTodayTimer({{
|
||||
storage, getLogin:() => 'Timmy', now:() => now,
|
||||
}});
|
||||
const first = create();
|
||||
first.activate('issue:stackchain/dashboard:577:');
|
||||
now += 10 * 60 * 1000;
|
||||
const beforeReload = first.snapshot();
|
||||
const restored = create();
|
||||
const afterReload = restored.snapshot();
|
||||
restored.pause();
|
||||
now += 5 * 60 * 1000;
|
||||
const whilePaused = restored.snapshot();
|
||||
restored.resume();
|
||||
now += 2 * 60 * 1000;
|
||||
const afterResume = restored.snapshot();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
api:typeof createTodayTimer,
|
||||
beforeReload, afterReload, whilePaused, afterResume,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"api": "function",
|
||||
"beforeReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
||||
"afterReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
||||
"whilePaused": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": False},
|
||||
"afterResume": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 720000, "running": True},
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_reconciles_long_away_time_once_and_keeps_short_switches_seamless():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||
timer.activate('issue:repo:591:');
|
||||
now = 60000;
|
||||
timer.markAway();
|
||||
now = 4 * 60000;
|
||||
const short = timer.reconcileInterruption();
|
||||
now = 5 * 60000;
|
||||
timer.markAway();
|
||||
now = 11 * 60000;
|
||||
const pending = timer.reconcileInterruption();
|
||||
now = 12 * 60000;
|
||||
const frozen = timer.snapshot();
|
||||
const restored = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).pendingInterruption();
|
||||
const counted = timer.resolveInterruption('count');
|
||||
const afterCount = timer.snapshot();
|
||||
const duplicate = timer.resolveInterruption('count');
|
||||
now = 13 * 60000;
|
||||
timer.markAway();
|
||||
now = 19 * 60000;
|
||||
timer.reconcileInterruption();
|
||||
const excluded = timer.resolveInterruption('exclude');
|
||||
const afterExclude = timer.snapshot();
|
||||
login = 'alexander';
|
||||
const isolated = timer.pendingInterruption();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
short,pending,frozen,restored,counted,afterCount,duplicate,excluded,afterExclude,isolated,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"short": None,
|
||||
"pending": {
|
||||
"identity": "issue:repo:591:",
|
||||
"away_ms": 360000,
|
||||
},
|
||||
"frozen": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 660000,
|
||||
"running": False,
|
||||
},
|
||||
"restored": {
|
||||
"identity": "issue:repo:591:",
|
||||
"away_ms": 360000,
|
||||
},
|
||||
"counted": True,
|
||||
"afterCount": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 660000,
|
||||
"running": True,
|
||||
},
|
||||
"duplicate": False,
|
||||
"excluded": True,
|
||||
"afterExclude": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 720000,
|
||||
"running": True,
|
||||
},
|
||||
"isolated": None,
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_pauses_for_attention_and_restores_only_automatic_running_state():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let login = 'timmy';
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
|
||||
timer.activate('issue:r:595:');
|
||||
now = 60000;
|
||||
const entered = timer.beginAttention();
|
||||
now = 5 * 60000;
|
||||
const duplicate = timer.beginAttention();
|
||||
const frozen = timer.snapshot();
|
||||
const restored = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}}).attentionInterruption();
|
||||
now = 11 * 60000;
|
||||
const returned = timer.returnFromAttention();
|
||||
now = 12 * 60000;
|
||||
const resumed = timer.snapshot();
|
||||
timer.pause();
|
||||
now = 13 * 60000;
|
||||
const enteredPaused = timer.beginAttention();
|
||||
now = 20 * 60000;
|
||||
const returnedPaused = timer.returnFromAttention();
|
||||
const stillPaused = timer.snapshot();
|
||||
login = 'alexander';
|
||||
const isolated = timer.attentionInterruption();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
entered,duplicate,frozen,restored,returned,resumed,
|
||||
enteredPaused,returnedPaused,stillPaused,isolated,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"entered": {"identity": "issue:r:595:", "resume": True},
|
||||
"duplicate": {"identity": "issue:r:595:", "resume": True},
|
||||
"frozen": {"identity": "issue:r:595:", "elapsed_ms": 60000, "running": False},
|
||||
"restored": {"identity": "issue:r:595:", "resume": True},
|
||||
"returned": {"identity": "issue:r:595:", "resumed": True},
|
||||
"resumed": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": True},
|
||||
"enteredPaused": {"identity": "issue:r:595:", "resume": False},
|
||||
"returnedPaused": {"identity": "issue:r:595:", "resumed": False},
|
||||
"stillPaused": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": False},
|
||||
"isolated": None,
|
||||
}
|
||||
|
||||
|
||||
def test_today_interruption_prompt_restores_and_resolves_the_pending_mobile_decision():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||
const sheet = {{hidden:true}};
|
||||
const description = {{textContent:''}};
|
||||
let resolved = 0;
|
||||
const prompt = createTodayTimer.createInterruptionPrompt({{
|
||||
timer, sheet, description,
|
||||
getItemLabel: identity => identity === 'issue:r:7:' ? 'Fix mobile timer' : '',
|
||||
onResolved: () => resolved++,
|
||||
}});
|
||||
timer.activate('issue:r:7:');
|
||||
now = 60000;
|
||||
prompt.background();
|
||||
now = 7 * 60000;
|
||||
const opened = prompt.foreground();
|
||||
const shown = {{hidden:sheet.hidden, text:description.textContent}};
|
||||
const restored = prompt.restore();
|
||||
const excluded = prompt.resolve('exclude');
|
||||
const closed = sheet.hidden;
|
||||
process.stdout.write(JSON.stringify({{opened,shown,restored,excluded,closed,resolved}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"shown": {
|
||||
"hidden": False,
|
||||
"text": "Fix mobile timer · away for 6 minutes",
|
||||
},
|
||||
"restored": True,
|
||||
"excluded": True,
|
||||
"closed": True,
|
||||
"resolved": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_today_interruption_detection_is_idempotent_and_fails_closed_for_invalid_state():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||
timer.activate('issue:r:1:');
|
||||
now = 60000;
|
||||
timer.markAway();
|
||||
now = 120000;
|
||||
timer.markAway();
|
||||
now = 6 * 60000;
|
||||
const originalCheckpoint = timer.reconcileInterruption();
|
||||
timer.resolveInterruption('exclude');
|
||||
timer.pause();
|
||||
now = 20 * 60000;
|
||||
const paused = timer.markAway();
|
||||
const key = 'stackchain.today-timer.v1.timmy';
|
||||
const invalid = JSON.parse(values.get(key));
|
||||
invalid.pending_interruption = {{identity:7,away_ms:'bad'}};
|
||||
invalid.away_at = now + 1000;
|
||||
values.set(key, JSON.stringify(invalid));
|
||||
const malformed = timer.reconcileInterruption();
|
||||
process.stdout.write(JSON.stringify({{originalCheckpoint,paused,malformed,pending:timer.pendingInterruption()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"originalCheckpoint": {"identity": "issue:r:1:", "away_ms": 300000},
|
||||
"paused": False,
|
||||
"malformed": None,
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||
timer.activate('issue:repo:1:');
|
||||
now = 90000;
|
||||
timer.activate('pull:repo:2:');
|
||||
now = 120000;
|
||||
const first = timer.snapshot('issue:repo:1:');
|
||||
const second = timer.snapshot();
|
||||
timer.stop();
|
||||
now = 300000;
|
||||
const stopped = timer.snapshot();
|
||||
login = 'alexander';
|
||||
const other = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).snapshot();
|
||||
process.stdout.write(JSON.stringify({{first,second,stopped,other}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"identity": "issue:repo:1:", "elapsed_ms": 90000, "running": False},
|
||||
"second": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": True},
|
||||
"stopped": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": False},
|
||||
"other": {"identity": "", "elapsed_ms": 0, "running": False},
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_can_be_initialized_before_operator_identity_is_restored():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = '';
|
||||
let now = 100;
|
||||
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||
login = 'timmy';
|
||||
const activated = timer.activate('issue:repo:1:');
|
||||
now = 1100;
|
||||
process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"activated": True,
|
||||
"snapshot": {"identity": "issue:repo:1:", "elapsed_ms": 1000, "running": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
html = await dashboard()
|
||||
timer_source = TODAY_TIMER.read_text()
|
||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
||||
|
||||
assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
|
||||
assert 'createTodayTimer({' in html
|
||||
assert 'createTodayTimerView({' in html
|
||||
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
||||
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
||||
assert "setInterval(timerView.render, 1000)" in recap_source
|
||||
assert 'timer.pause()' in timer_source
|
||||
assert 'timer.resume()' in timer_source
|
||||
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
||||
assert "' / ' + formatEstimate(runway.current_minutes)" in timer_source
|
||||
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_reconciles_long_today_interruptions_in_a_touch_safe_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="today-interruption-sheet"' in html
|
||||
assert 'id="today-interruption-description"' in html
|
||||
assert 'data-today-interruption="count"' in html
|
||||
assert 'data-today-interruption="exclude"' in html
|
||||
assert "const interruptionPrompt = createTodayInterruptionPrompt({" in html
|
||||
assert "if (document.hidden) interruptionPrompt.background();" in html
|
||||
assert "else interruptionPrompt.foreground();" in html
|
||||
assert "window.addEventListener('pagehide', () => interruptionPrompt.background());" in html
|
||||
assert "interruptionPrompt.restore();" in html
|
||||
assert "interruptionPrompt.resolve(button.dataset.todayInterruption)" in html
|
||||
assert ".today-interruption-panel { box-sizing:border-box; width:min(620px,100%);" in html
|
||||
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in html
|
||||
assert ".today-interruption-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -2141,6 +2489,7 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
@@ -2152,7 +2501,7 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume(item)" in html
|
||||
assert "runTodayTransition('resume')" in html
|
||||
assert "workSession.end()" in html
|
||||
assert "workSession.end()" in recap_source
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
@@ -2251,6 +2600,7 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||
html = await dashboard()
|
||||
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
@@ -2259,7 +2609,7 @@ async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_an
|
||||
assert "merging.key + ' merged.'" in merge_handler
|
||||
assert "else if (continuingSession" not in merge_handler
|
||||
assert "onFinish: () =>" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -3862,6 +4212,112 @@ Promise.all([first, duplicate]).then(() => {{
|
||||
assert output["state"]["total"] == 4
|
||||
|
||||
|
||||
def test_owned_comment_actions_edit_delete_all_conversation_routes_and_preserve_failed_edit():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const createCommentActions = require({json.dumps(str(COMMENT_ACTIONS))});
|
||||
const requests = [];
|
||||
let shouldFail = false;
|
||||
const controller = createCommentActions({{
|
||||
getLogin: () => 'timmy',
|
||||
confirmDelete: () => true,
|
||||
fetchJson: async (url, options) => {{
|
||||
requests.push([url, options.method, options.body || null]);
|
||||
if (shouldFail) throw new Error('offline');
|
||||
if (options.method === 'DELETE') return {{id:42,deleted:true}};
|
||||
return {{id:42,author:'timmy',body:JSON.parse(options.body).body,created_at:'2026-08-11T10:00:00Z'}};
|
||||
}},
|
||||
}});
|
||||
const cancelController = createCommentActions({{
|
||||
getLogin: () => 'timmy', confirmDelete: () => false,
|
||||
fetchJson: async () => {{ throw new Error('delete should not run'); }},
|
||||
}});
|
||||
const contexts = [
|
||||
{{kind:'issue',item:{{repository:'stackchain/api',number:17}}}},
|
||||
{{kind:'pull',item:{{repository:'stackchain/api',number:17}}}},
|
||||
{{kind:'update',item:{{notification_id:91}}}},
|
||||
];
|
||||
(async () => {{
|
||||
const results = [];
|
||||
for (const context of contexts) {{
|
||||
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
||||
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}},{{id:43,author:'alex',body:'Other'}}],total:2}});
|
||||
results.push(controller.isOwned(pager.snapshot().comments[0]));
|
||||
results.push(controller.isOwned(pager.snapshot().comments[1]));
|
||||
await controller.edit(context, pager, 42, 'Corrected');
|
||||
results.push(pager.snapshot().comments[0].body);
|
||||
await controller.remove(context, pager, 42);
|
||||
results.push([pager.snapshot().comments.map(item=>item.id), pager.snapshot().total]);
|
||||
}}
|
||||
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
||||
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}}],total:1}});
|
||||
shouldFail = true;
|
||||
let failure = '';
|
||||
try {{ await controller.edit(contexts[0], pager, 42, 'Unsaved correction'); }} catch (error) {{ failure=error.message; }}
|
||||
const cancelled = await cancelController.remove(contexts[0], pager, 42);
|
||||
process.stdout.write(JSON.stringify({{requests,results,failure,cancelled,state:pager.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
assert [request[:2] for request in output["requests"][:6]] == [
|
||||
["api/v1/repos/stackchain/api/issues/17/comments/42", "PATCH"],
|
||||
["api/v1/repos/stackchain/api/issues/17/comments/42", "DELETE"],
|
||||
["api/v1/repos/stackchain/api/pulls/17/comments/42", "PATCH"],
|
||||
["api/v1/repos/stackchain/api/pulls/17/comments/42", "DELETE"],
|
||||
["api/v1/notifications/91/comments/42", "PATCH"],
|
||||
["api/v1/notifications/91/comments/42", "DELETE"],
|
||||
]
|
||||
assert output["results"] == [True, False, "Corrected", [[43], 1]] * 3
|
||||
assert output["failure"] == "offline"
|
||||
assert output["cancelled"] is None
|
||||
assert output["state"]["comments"][0]["body"] == "Original"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_actions_render_inline_for_mobile_in_all_conversations():
|
||||
html = await dashboard()
|
||||
dashboard_javascript = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
action_javascript = COMMENT_ACTIONS.read_text()
|
||||
javascript = dashboard_javascript + action_javascript
|
||||
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/comment-actions.js"></script>' in html
|
||||
assert html.index('static/comment-actions.js') < html.index('static/dashboard.js')
|
||||
assert "BASE + 'static/comment-actions.js'" in service_worker
|
||||
assert 'data-comment-action="edit"' in javascript
|
||||
assert 'data-comment-action="delete"' in javascript
|
||||
assert 'class="comment-edit-textarea"' in javascript
|
||||
assert "wireCommentActions('#issue-comments')" in javascript
|
||||
assert "wireCommentActions('#pull-comments')" in javascript
|
||||
assert "wireCommentActions('#update-comments')" in javascript
|
||||
assert ".comment-owned-actions button" in css and "min-height:44px" in css
|
||||
assert ".comment-edit-textarea" in css and "max-width:100%" in css
|
||||
assert ".issue-comment" in css and "overflow-wrap:anywhere" in css
|
||||
|
||||
|
||||
def test_notification_reader_exposes_current_comment_pager_only_while_open():
|
||||
script = f"""
|
||||
const {{createNotificationReader}} = require({json.dumps(str(MY_WORK))});
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const reader = createNotificationReader({{
|
||||
load: async () => ({{conversation:{{comments:[{{id:42,body:'Before'}}],total:1}}}}),
|
||||
markRead: async()=>{{}}, onOpen:()=>{{}}, onDetail:()=>{{}}, onItems:()=>{{}}, onStatus:()=>{{}}, onClose:()=>{{}},
|
||||
loadConversation: async()=>({{}}), createPager:createConversationPager,
|
||||
}});
|
||||
(async()=>{{
|
||||
const before = reader.commentPager();
|
||||
await reader.open({{notification_id:91}});
|
||||
const pager = reader.commentPager();
|
||||
pager.replace({{id:42,body:'After'}});
|
||||
process.stdout.write(JSON.stringify({{before:before===null,body:pager.snapshot().comments[0].body}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
assert json.loads(result.stdout) == {"before": True, "body": "After"}
|
||||
|
||||
|
||||
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
@@ -4129,6 +4585,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 +4853,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 +4937,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,
|
||||
)]
|
||||
@@ -347,14 +347,14 @@ async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypa
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
started_at = time.monotonic()
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
response = await asyncio.wait_for(
|
||||
client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
),
|
||||
timeout=0.5,
|
||||
)
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert elapsed < 0.06
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
|
||||
@@ -127,6 +127,43 @@ process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, sav
|
||||
}]
|
||||
|
||||
|
||||
def test_plan_today_applies_recap_actual_only_to_the_draft_after_explicit_action():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: plan => {{ saved.push(plan); return true; }},
|
||||
}});
|
||||
const first='issue:stackchain/dashboard:1:';
|
||||
const second='issue:stackchain/dashboard:2:';
|
||||
planner.open([item(1), item(2)], [item(1), item(2)], {{
|
||||
capacity_minutes:120, estimates:{{[first]:30,[second]:45}}
|
||||
}}, {{[first]:52,[second]:45}});
|
||||
const before=planner.snapshot();
|
||||
const ignored=planner.applyRecommendation(second);
|
||||
const applied=planner.applyRecommendation(first);
|
||||
const after=planner.snapshot();
|
||||
const result=planner.commit();
|
||||
process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["before"]["estimates"] == {
|
||||
"issue:stackchain/dashboard:1:": 30,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
}
|
||||
assert result["before"]["recommendations"] == {"issue:stackchain/dashboard:1:": 52}
|
||||
assert result["ignored"] is False
|
||||
assert result["applied"] is True
|
||||
assert result["after"]["estimates"]["issue:stackchain/dashboard:1:"] == 52
|
||||
assert result["after"]["recommendations"] == {}
|
||||
assert result["saved"][0]["estimates"] == {
|
||||
"issue:stackchain/dashboard:1:": 52,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
@@ -247,6 +284,9 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
assert "formatPlanMinutes(state.planned_minutes)" in html
|
||||
assert "planToday.setCapacity" in html
|
||||
assert "planToday.setEstimate" in html
|
||||
assert 'data-plan-recommendation="' in html
|
||||
assert "planToday.applyRecommendation" in html
|
||||
assert "Use " in html and " as new estimate" in html
|
||||
assert "todaySync.enqueueConfiguration" in html
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
||||
assert "resetPlanTodayConfirmation()" in html
|
||||
@@ -292,6 +332,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-v91" 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,825 @@
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
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 == []
|
||||
|
||||
|
||||
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(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"},
|
||||
})
|
||||
|
||||
first = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
|
||||
store.mark_delivered("session-a", first[0].thread_revisions)
|
||||
unchanged = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
|
||||
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
|
||||
|
||||
assert unchanged == []
|
||||
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
|
||||
|
||||
|
||||
def test_subscription_store_does_not_realert_for_an_older_snapshot_revision(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"},
|
||||
})
|
||||
store.mark_delivered("session-a", {42: "2026-08-11T12:05:00Z"})
|
||||
|
||||
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
|
||||
|
||||
|
||||
def test_complete_unread_snapshot_reconciles_delivery_and_digest_checkpoints(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"},
|
||||
})
|
||||
revisions = {1: "r1", 2: "r2", 3: "r3"}
|
||||
store.mark_delivered("session-a", revisions)
|
||||
store.mark_digest_pending("session-a", {3: "r3"})
|
||||
|
||||
store.reconcile_unread({2})
|
||||
delivery = store.claim_unseen(revisions)[0]
|
||||
|
||||
assert delivery.thread_ids == (1, 3)
|
||||
assert delivery.digest_ids == ()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_reconciles_stale_checkpoints_after_a_complete_snapshot(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"},
|
||||
})
|
||||
store.mark_delivered("session-a", {1: "r1"})
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": True}
|
||||
|
||||
async def send(_subscription, _payload):
|
||||
return None
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
|
||||
remaining = store.claim_unseen({1: "r1", 2: "r2"})
|
||||
assert remaining[0].thread_ids == (1,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_sends_nothing_and_preserves_checkpoints_for_an_incomplete_snapshot(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"},
|
||||
})
|
||||
store.mark_delivered("session-a", {1: "r1"})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": False}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
assert sent == []
|
||||
assert store.claim_unseen({1: "r1"}) == []
|
||||
|
||||
|
||||
def test_subscription_store_migrates_legacy_delivery_without_replaying_it(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE push_deliveries (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, thread_id)
|
||||
);
|
||||
""")
|
||||
connection.execute(
|
||||
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
|
||||
("session-a", subscription["endpoint"], json.dumps(subscription)),
|
||||
)
|
||||
connection.execute("INSERT INTO push_deliveries VALUES (?, ?)", ("session-a", 42))
|
||||
|
||||
store = PushSubscriptionStore(path)
|
||||
|
||||
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
|
||||
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
|
||||
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
|
||||
|
||||
|
||||
@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["unread"] = args[2]
|
||||
captured.update(kwargs)
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
||||
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["unread"] is main.gitea_proxy.unread_notification_snapshot
|
||||
assert captured["session_active"] is dashboard_auth.managed_session_active
|
||||
assert captured["max_concurrency"] == 3
|
||||
assert captured["max_individual_notifications"] == 4
|
||||
|
||||
|
||||
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_dispatch_realerts_when_the_same_thread_has_a_newer_revision(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"},
|
||||
})
|
||||
revision = "2026-08-11T12:00:00Z"
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{
|
||||
"id": 42,
|
||||
"updated_at": revision,
|
||||
"repository": "private/repo",
|
||||
"title": "Secret follow-up",
|
||||
}]}
|
||||
|
||||
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 await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
revision = "2026-08-11T12:05:00Z"
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert [payload["notification_id"] for payload in sent] == [42, 42]
|
||||
assert "updated_at" not in json.dumps(sent)
|
||||
assert "Secret follow-up" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_burst_sends_bounded_individual_pushes_and_one_private_digest(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": notification_id,
|
||||
"repository": "private/repo",
|
||||
"title": f"Secret update {notification_id}",
|
||||
}
|
||||
for notification_id in range(41, 46)
|
||||
]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
delivered = await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send,
|
||||
max_individual_notifications=2,
|
||||
)
|
||||
|
||||
assert delivered == 3
|
||||
assert [payload["tag"] for payload in sent] == [
|
||||
"stackchain-update-41",
|
||||
"stackchain-update-42",
|
||||
"stackchain-update-digest",
|
||||
]
|
||||
assert sent[-1] == {
|
||||
"title": "3 new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": 3,
|
||||
}
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret update" not in json.dumps(sent)
|
||||
assert await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send,
|
||||
max_individual_notifications=2,
|
||||
) == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_digest_retries_only_overflow_after_individual_checkpoints(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"},
|
||||
})
|
||||
attempts = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": notification_id} for notification_id in range(51, 56)]}
|
||||
|
||||
async def fail_digest(_subscription, payload):
|
||||
decoded = json.loads(payload)
|
||||
attempts.append(decoded["tag"])
|
||||
if decoded["tag"] == "stackchain-update-digest":
|
||||
raise RuntimeError("push provider unavailable")
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
fail_digest,
|
||||
max_individual_notifications=2,
|
||||
) == 2
|
||||
assert store.claim_unseen(range(51, 56))[0].thread_ids == (53, 54, 55)
|
||||
|
||||
async def succeed(_subscription, payload):
|
||||
attempts.append(json.loads(payload)["tag"])
|
||||
|
||||
assert await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
succeed,
|
||||
max_individual_notifications=2,
|
||||
) == 1
|
||||
assert attempts == [
|
||||
"stackchain-update-51",
|
||||
"stackchain-update-52",
|
||||
"stackchain-update-digest",
|
||||
"stackchain-update-digest",
|
||||
]
|
||||
assert store.claim_unseen(range(51, 56)) == []
|
||||
|
||||
|
||||
@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(**_kwargs):
|
||||
return {"items": [], "complete": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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_every_revision_from_the_complete_snapshot(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"
|
||||
|
||||
revisions = {
|
||||
thread_id: f"2026-08-11T12:{thread_id % 60:02d}:00Z"
|
||||
for thread_id in range(1, 126)
|
||||
}
|
||||
|
||||
async def complete_snapshot(**_kwargs):
|
||||
return {
|
||||
"items": [
|
||||
{"id": thread_id, "updated_at": revision}
|
||||
for thread_id, revision in revisions.items()
|
||||
],
|
||||
"total": 125,
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
async def first_page_must_not_be_used():
|
||||
raise AssertionError("subscription used the first-page inbox endpoint")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", complete_snapshot)
|
||||
monkeypatch.setattr(main, "notifications", first_page_must_not_be_used)
|
||||
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(revisions) == []
|
||||
|
||||
|
||||
@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(**_kwargs):
|
||||
return {"items": [{"id": 42}], "complete": True}
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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(**_kwargs):
|
||||
raise RuntimeError("Gitea unavailable")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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
|
||||
+258
-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-v91" 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-v91" 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-v91" 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-v91" 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-v91" 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-v91" 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-v91" 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-v91" 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-v91" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -373,6 +383,237 @@ 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_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'private details must-not-render',
|
||||
tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:7,
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/updates');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "7 new work updates",
|
||||
"options": {
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"tag": "stackchain-update-digest",
|
||||
"data": {"route": "#/my-work/updates"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/updates"
|
||||
]
|
||||
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 +638,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-v91" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -437,6 +678,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
@@ -452,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
@@ -467,9 +711,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-v91';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayStore
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
||||
|
||||
|
||||
async def recap_time_grant(client, headers, payload):
|
||||
challenge = await client.post(
|
||||
"/api/v1/today/recaps/log-time", json=payload, headers=headers
|
||||
)
|
||||
assert challenge.status_code == 428
|
||||
authorization = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "log_recap_time",
|
||||
"target": challenge.json()["detail"]["target"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert authorization.status_code == 201
|
||||
return authorization.json()["grant"]
|
||||
|
||||
|
||||
def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
source = TODAY_RECAP.read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="save-log-today-recap"' in html
|
||||
assert "Log selected time to Gitea" in html
|
||||
assert 'type="checkbox"' in source
|
||||
assert "data-recap-log-identity" in source
|
||||
assert "item.actual_minutes > 0" in source
|
||||
assert "querySelectorAll('[data-recap-log-identity]:checked')" in source
|
||||
assert ".today-recap-log" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
|
||||
first = {
|
||||
"session_id": "session-1",
|
||||
"items": [
|
||||
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
|
||||
{"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
|
||||
],
|
||||
}
|
||||
|
||||
saved = store.save_recap("Timmy", first["session_id"], first["items"])
|
||||
replay = store.save_recap("timmy", first["session_id"], first["items"])
|
||||
now[0] += 1
|
||||
store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
|
||||
now[0] += 1
|
||||
store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
|
||||
store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])
|
||||
|
||||
assert saved == replay
|
||||
assert saved["session_id"] == "session-1"
|
||||
assert saved["estimated_minutes"] == 30
|
||||
assert saved["actual_minutes"] == 54
|
||||
assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
|
||||
assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"items,message",
|
||||
[
|
||||
([], "at least one"),
|
||||
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
|
||||
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
|
||||
([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
|
||||
],
|
||||
)
|
||||
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
with pytest.raises(ValueError, match=message):
|
||||
store.save_recap("timmy", "session", items)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
payload = {"session_id": "mobile-session-1", "items": [
|
||||
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
|
||||
]}
|
||||
created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
||||
replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
||||
history = await client.get("/api/v1/today/recaps")
|
||||
|
||||
assert created.status_code == replay.status_code == 200
|
||||
assert created.json() == replay.json()
|
||||
assert history.json()["recaps"] == [created.json()]
|
||||
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(identity, seconds):
|
||||
calls.append((identity, seconds))
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
payload = {
|
||||
"session_id": "authorization-session",
|
||||
"items": [{
|
||||
"identity": "issue:stackchain/dashboard:589:",
|
||||
"estimate_minutes": 30,
|
||||
"actual_minutes": 42,
|
||||
}],
|
||||
"log_identities": ["issue:stackchain/dashboard:589:"],
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
history_before = await client.get("/api/v1/today/recaps")
|
||||
target = missing.json()["detail"]["target"]
|
||||
authorization = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "log_recap_time",
|
||||
"target": target,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
grant = authorization.json()["grant"]
|
||||
changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]}
|
||||
mismatched = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=changed,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
accepted = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
|
||||
assert missing.status_code == mismatched.status_code == replay.status_code == 428
|
||||
assert missing.json()["detail"]["action"] == "log_recap_time"
|
||||
assert target.startswith("today-recap:") and len(target) == 44
|
||||
assert history_before.json()["recaps"] == []
|
||||
assert accepted.status_code == 200
|
||||
assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
security_db = tmp_path / "security.sqlite3"
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(identity, _seconds):
|
||||
if ":590:" in identity:
|
||||
raise httpx.ReadTimeout("response was lost after sending")
|
||||
if ":591:" in identity:
|
||||
raise httpx.ConnectError("request was not sent")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
payload = {
|
||||
"session_id": "private-recap-session",
|
||||
"items": [
|
||||
{"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes}
|
||||
for number, minutes in [(589, 11), (590, 12), (591, 13)]
|
||||
],
|
||||
"log_identities": [
|
||||
f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591)
|
||||
],
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
authorization = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "log_recap_time",
|
||||
"target": challenge.json()["detail"]["target"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert response.json()["time_logs"] == [
|
||||
{"identity": "issue:stackchain/dashboard:589:", "status": "logged"},
|
||||
{"identity": "issue:stackchain/dashboard:590:", "status": "verify"},
|
||||
{"identity": "issue:stackchain/dashboard:591:", "status": "retry"},
|
||||
]
|
||||
time_events = [event for event in events if event["kind"] == "gitea_time_logged"]
|
||||
assert [(event["target"], event["status"]) for event in time_events] == [
|
||||
("stackchain/dashboard#590", "pending"),
|
||||
("stackchain/dashboard#589", "completed"),
|
||||
]
|
||||
persisted = security_db.read_bytes()
|
||||
assert b"private-recap-session" not in persisted
|
||||
assert challenge.json()["detail"]["target"].encode() not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(identity, seconds):
|
||||
calls.append((identity, seconds))
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
payload = {
|
||||
"session_id": "mobile-session-1",
|
||||
"items": [
|
||||
{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42},
|
||||
{"identity": "pull:stackchain/dashboard:586:", "estimate_minutes": 10, "actual_minutes": 0},
|
||||
],
|
||||
"log_identities": ["issue:stackchain/dashboard:587:"],
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
first_grant = await recap_time_grant(client, headers, payload)
|
||||
created = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": first_grant},
|
||||
)
|
||||
replay_grant = await recap_time_grant(client, headers, payload)
|
||||
replay = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": replay_grant},
|
||||
)
|
||||
|
||||
assert created.status_code == replay.status_code == 200
|
||||
assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
|
||||
assert created.json()["time_logs"] == [{
|
||||
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
|
||||
}]
|
||||
assert replay.json()["time_logs"] == [{
|
||||
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
|
||||
}]
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ambiguous_time_log_failure_is_not_reposted_automatically(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(_identity, _seconds):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise httpx.ReadTimeout("response was lost after sending")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
payload = {
|
||||
"session_id": "ambiguous-session",
|
||||
"items": [{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}],
|
||||
"log_identities": ["issue:stackchain/dashboard:587:"],
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
first_grant = await recap_time_grant(client, headers, payload)
|
||||
first = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": first_grant},
|
||||
)
|
||||
replay_grant = await recap_time_grant(client, headers, payload)
|
||||
replay = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": replay_grant},
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
|
||||
"identity": "issue:stackchain/dashboard:587:", "status": "verify"
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recap_time_logging_rejects_noncanonical_and_zero_targets_before_upstream(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(identity, seconds):
|
||||
calls.append((identity, seconds))
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
malformed = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
|
||||
"session_id": "bad-target",
|
||||
"items": [{"identity": "issue:https://evil.test:1:", "estimate_minutes": 5, "actual_minutes": 5}],
|
||||
"log_identities": ["issue:https://evil.test:1:"],
|
||||
})
|
||||
zero = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
|
||||
"session_id": "zero-target",
|
||||
"items": [{"identity": "issue:stackchain/dashboard:1:", "estimate_minutes": 5, "actual_minutes": 0}],
|
||||
"log_identities": ["issue:stackchain/dashboard:1:"],
|
||||
})
|
||||
history = await client.get("/api/v1/today/recaps")
|
||||
|
||||
assert malformed.status_code == zero.status_code == 422
|
||||
assert calls == []
|
||||
assert history.json()["recaps"] == []
|
||||
|
||||
|
||||
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const calls=[]; let cleared=0;
|
||||
const recap=createRecap({{
|
||||
save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
|
||||
clear:()=>{{cleared += 1;}},
|
||||
makeId:()=> 'session-fixed',
|
||||
}});
|
||||
const draft=recap.begin([
|
||||
{{identity:'issue:r:1:',elapsed_ms:42*60000}},
|
||||
{{identity:'pull:r:2:',elapsed_ms:12*60000}},
|
||||
], {{'issue:r:1:':30}});
|
||||
const invalidLow=recap.correct('issue:r:1:',-1);
|
||||
const invalidHigh=recap.correct('issue:r:1:',1441);
|
||||
const corrected=recap.correct('issue:r:1:',45);
|
||||
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
||||
draft,invalidLow,invalidHigh,corrected,saved,calls,cleared
|
||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["draft"]["estimated_minutes"] == 30
|
||||
assert output["draft"]["actual_minutes"] == 54
|
||||
assert output["draft"]["variance_minutes"] == 24
|
||||
assert output["invalidLow"] is False
|
||||
assert output["invalidHigh"] is False
|
||||
assert output["corrected"] is True
|
||||
assert output["calls"][0]["session_id"] == "session-fixed"
|
||||
assert output["calls"][0]["items"][0]["actual_minutes"] == 45
|
||||
assert output["cleared"] == 1
|
||||
|
||||
|
||||
def test_recap_controller_logs_only_selected_nonzero_items_and_retains_failed_draft():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const calls=[]; let fail=true; let cleared=0;
|
||||
const recap=createRecap({{
|
||||
save:payload=>Promise.resolve(payload),
|
||||
saveAndLog:payload=>{{calls.push(payload); return fail ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
||||
time_logs:payload.log_identities.map(identity=>({{identity,status:'logged'}}))
|
||||
}});}},
|
||||
clear:()=>{{cleared += 1;}}, makeId:()=> 'log-session',
|
||||
}});
|
||||
recap.begin([
|
||||
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:42*60000}},
|
||||
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:0}},
|
||||
]);
|
||||
(async()=>{{
|
||||
let error='';
|
||||
try {{ await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
const retained=recap.snapshot(); fail=false;
|
||||
const result=await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']);
|
||||
process.stdout.write(JSON.stringify({{error,retained,result,calls,cleared,after:recap.snapshot()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
output = json.loads(run.stdout)
|
||||
assert output["error"] == "offline"
|
||||
assert output["retained"]["session_id"] == "log-session"
|
||||
assert output["calls"][0]["log_identities"] == ["issue:stackchain/dashboard:587:"]
|
||||
assert output["result"]["time_logs"] == [{
|
||||
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
|
||||
}]
|
||||
assert output["cleared"] == 1
|
||||
assert output["after"] is None
|
||||
|
||||
|
||||
def test_recap_controller_exposes_per_item_partial_logging_results_for_retry():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const recap=createRecap({{
|
||||
save:payload=>Promise.resolve(payload), clear:()=>{{}}, makeId:()=> 'partial-session',
|
||||
saveAndLog:payload=>Promise.resolve({{time_logs:[
|
||||
{{identity:payload.log_identities[0],status:'logged'}},
|
||||
{{identity:payload.log_identities[1],status:'retry'}},
|
||||
]}}),
|
||||
}});
|
||||
recap.begin([
|
||||
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
|
||||
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
|
||||
]);
|
||||
recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:'])
|
||||
.then(()=>{{throw new Error('expected retry');}})
|
||||
.catch(error=>process.stdout.write(JSON.stringify({{message:error.message,draft:recap.snapshot()}})));
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
output = json.loads(run.stdout)
|
||||
assert output["message"] == "1 time entry logged; 1 needs retry."
|
||||
assert output["draft"]["time_logs"] == {
|
||||
"issue:stackchain/dashboard:587:": "logged",
|
||||
"pull:stackchain/dashboard:586:": "retry",
|
||||
}
|
||||
|
||||
|
||||
def test_recap_partial_time_logging_intent_survives_reload_until_every_item_is_logged():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map(); const calls=[]; let attempt=0; let cleared=0;
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const saveAndLog=payload=>{{
|
||||
calls.push(payload.log_identities); attempt += 1;
|
||||
return Promise.resolve({{time_logs:payload.log_identities.map((identity,index)=>({{
|
||||
identity,status:attempt === 1 && index === 1 ? 'retry' : 'logged'
|
||||
}}))}});
|
||||
}};
|
||||
const options={{save:payload=>Promise.resolve(payload),saveAndLog,clear:()=>{{cleared += 1;}},storage,getLogin:()=> 'timmy'}};
|
||||
const first=createRecap({{...options,makeId:()=> 'durable-log'}});
|
||||
first.begin([
|
||||
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
|
||||
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
|
||||
]);
|
||||
(async()=>{{
|
||||
try {{ await first.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (_error) {{}}
|
||||
const restored=createRecap(options);
|
||||
const pending=restored.snapshot();
|
||||
await restored.saveAndLog(['pull:stackchain/dashboard:586:']);
|
||||
process.stdout.write(JSON.stringify({{pending,calls,cleared,after:restored.snapshot(),stored:[...values.keys()]}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
output = json.loads(run.stdout)
|
||||
assert output["pending"]["log_identities"] == [
|
||||
"issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"
|
||||
]
|
||||
assert output["pending"]["time_logs"]["pull:stackchain/dashboard:586:"] == "retry"
|
||||
assert output["calls"] == [
|
||||
["issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"],
|
||||
["pull:stackchain/dashboard:586:"],
|
||||
]
|
||||
assert output["cleared"] == 1
|
||||
assert output["after"] is None
|
||||
assert output["stored"] == []
|
||||
|
||||
|
||||
def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const rows=createRecap.feedbackRows({{
|
||||
items:[
|
||||
{{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}},
|
||||
{{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}},
|
||||
]
|
||||
}}, identity => identity.startsWith('issue:') ? {{
|
||||
title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583'
|
||||
}} : null);
|
||||
process.stdout.write(JSON.stringify(rows));
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert json.loads(run.stdout) == [
|
||||
{
|
||||
"identity": "issue:stackchain/dashboard:583:",
|
||||
"label": "Turn recap feedback into a plan",
|
||||
"context": "stackchain/dashboard#583",
|
||||
"estimate_minutes": 30,
|
||||
"actual_minutes": 52,
|
||||
"variance_minutes": 22,
|
||||
},
|
||||
{
|
||||
"identity": "pull:stackchain/api:9:",
|
||||
"label": "pull:stackchain/api:9:",
|
||||
"context": "Work details unavailable",
|
||||
"estimate_minutes": None,
|
||||
"actual_minutes": 12,
|
||||
"variance_minutes": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const options = {{
|
||||
save:payload => Promise.resolve(payload), clear:() => {{}}, storage,
|
||||
getLogin:() => ' Timmy ', makeId:() => 'stable-session',
|
||||
}};
|
||||
const first = createRecap(options);
|
||||
first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}});
|
||||
first.correct('issue:r:1:', 17);
|
||||
const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot();
|
||||
process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["restored"] == {
|
||||
"session_id": "stable-session",
|
||||
"items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}],
|
||||
"estimated_minutes": 20,
|
||||
"actual_minutes": 17,
|
||||
"variance_minutes": -3,
|
||||
}
|
||||
assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"]
|
||||
|
||||
|
||||
def test_recap_controller_removes_corrupt_persisted_draft():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const key='stackchain.today-recap-draft.v1.timmy';
|
||||
const values=new Map([[key, '{{not-json']]);
|
||||
const storage={{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{draft:recap.snapshot(),hasKey:values.has(key)}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"draft": None, "hasKey": False}
|
||||
|
||||
|
||||
def test_recap_controller_restores_after_account_confirmation():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{
|
||||
session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}]
|
||||
}})]]);
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let login='';
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}});
|
||||
const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot();
|
||||
process.stdout.write(JSON.stringify({{before,didRestore,after}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": None,
|
||||
"didRestore": True,
|
||||
"after": {
|
||||
"session_id": "offline-session",
|
||||
"items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}],
|
||||
"estimated_minutes": 0,
|
||||
"actual_minutes": 9,
|
||||
"variance_minutes": 9,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recap_controller_never_exposes_draft_after_account_switch():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let login='timmy';
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}});
|
||||
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
|
||||
login='alexander';
|
||||
process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True}
|
||||
|
||||
|
||||
def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true;
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const recap=createRecap({{
|
||||
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload),
|
||||
clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session'
|
||||
}});
|
||||
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
|
||||
(async()=>{{
|
||||
try {{ await recap.save(); }} catch (_error) {{}}
|
||||
const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}};
|
||||
fail=false; await recap.save();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),cleared}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["afterFailure"]["draft"]["session_id"] == "retry-session"
|
||||
assert output["afterFailure"]["stored"] is True
|
||||
assert output["afterFailure"]["cleared"] == 0
|
||||
assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}
|
||||
|
||||
|
||||
def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
let fail=true;
|
||||
const recap=createRecap({{
|
||||
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}),
|
||||
clear:()=>{{}}, makeId:()=> 'replan-session'
|
||||
}});
|
||||
recap.begin([
|
||||
{{identity:'issue:r:1:',elapsed_ms:52*60000}},
|
||||
{{identity:'issue:r:2:',elapsed_ms:7*60000}},
|
||||
], {{'issue:r:1:':30,'issue:r:2:':7}});
|
||||
(async()=>{{
|
||||
let failed;
|
||||
try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }}
|
||||
const retained=recap.snapshot();
|
||||
fail=false;
|
||||
const confirmed=await recap.saveForReplan();
|
||||
process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
output = json.loads(run.stdout)
|
||||
assert output["failed"] == "offline"
|
||||
assert output["retained"]["session_id"] == "replan-session"
|
||||
assert output["confirmed"] == {
|
||||
"result": {"saved": True},
|
||||
"actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7},
|
||||
}
|
||||
assert output["after"] is None
|
||||
|
||||
|
||||
def test_recap_controller_restores_account_scoped_pending_replan_until_completed():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let login='timmy';
|
||||
const options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}};
|
||||
const first=createRecap({{...options,makeId:()=> 'saved-session'}});
|
||||
first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}});
|
||||
(async()=>{{
|
||||
await first.saveForReplan();
|
||||
const afterSave=first.pendingReplan();
|
||||
const restored=createRecap(options).pendingReplan();
|
||||
login='alexander';
|
||||
const otherAccount=createRecap(options).pendingReplan();
|
||||
login='timmy';
|
||||
const retainedAfterCancel=createRecap(options).pendingReplan();
|
||||
createRecap(options).completeReplan();
|
||||
const afterComplete=createRecap(options).pendingReplan();
|
||||
process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
output = json.loads(run.stdout)
|
||||
pending = {
|
||||
"session_id": "saved-session",
|
||||
"actual_minutes": {"issue:r:1:": 52},
|
||||
}
|
||||
assert output == {
|
||||
"afterSave": pending,
|
||||
"restored": pending,
|
||||
"otherAccount": None,
|
||||
"retainedAfterCancel": pending,
|
||||
"afterComplete": None,
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_recap_controller_removes_malformed_or_unbounded_pending_replan():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const key='stackchain.today-recap-handoff.v1.timmy';
|
||||
const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries(
|
||||
Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10])
|
||||
)}})]]);
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{pending:recap.pendingReplan(),hasKey:values.has(key)}}));
|
||||
"""
|
||||
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert json.loads(run.stdout) == {"pending": None, "hasKey": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_mobile_today_recap_flow():
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
timer = (ROOT / "frontend" / "today-timer.js").read_text()
|
||||
recap_source = TODAY_RECAP.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="today-recap-sheet" role="dialog"' in html
|
||||
assert 'id="today-recap-history"' in html
|
||||
assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
|
||||
assert "timer.recapEntries()" in recap_source
|
||||
assert "isActive: () => workSession.checkpointed()" in dashboard
|
||||
assert "openTodayRecapAfterSession(view" in recap_source
|
||||
assert "api/v1/today/recaps" in recap_source
|
||||
assert "storage:localStorage" in recap_source
|
||||
assert "() => planningOwnerLogin" in dashboard
|
||||
assert "recapEntries()" in timer and "clearRecap()" in timer
|
||||
assert ".today-recap-header button { min-height:44px;" in css
|
||||
assert ".today-recap-actions button { min-height:44px;" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "Save recap & adjust plan" in html
|
||||
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
|
||||
assert 'class="today-recap-variance"' in recap_source
|
||||
assert "await recap.saveForReplan()" in recap_source
|
||||
assert "adjustPlan(handoff.actual_minutes)" in recap_source
|
||||
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
|
||||
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
|
||||
assert ".today-recap-row { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback():
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
recap_source = TODAY_RECAP.read_text()
|
||||
|
||||
assert 'id="discard-recap-replan"' in html
|
||||
assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard
|
||||
assert "todayRecapView.completeReplan();" in dashboard
|
||||
assert "todayRecapView.discardReplan();" in dashboard
|
||||
assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard
|
||||
assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();")
|
||||
assert "pendingReplan:recap.pendingReplan" in recap_source
|
||||
assert "discardReplan:recap.discardReplan" in recap_source
|
||||
@@ -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-v91" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_acti
|
||||
assert "document.querySelectorAll('[data-work-session-complete]')" in html
|
||||
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
|
||||
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
+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