Compare commits

...
Author SHA1 Message Date
timmy cf2e49300a feat: reply to updates and continue Today (#451)
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:15:53 +00:00
timmy 5e49f27142 Merge pull request 'Continue Today after ownership exits' (#450) from timmy/449-ownership-exit-next into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 03:43:53 +00:00
timmy 0280077e45 feat: continue Today after ownership exits (#449)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:42:01 +00:00
timmy f781410e80 Merge pull request 'Autocomplete teammate mentions in mobile conversations' (#448) from timmy/447-mobile-mentions into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 03:04:26 +00:00
timmy 0311f85d96 feat: autocomplete mobile teammate mentions (#447)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:02:35 +00:00
timmy 5a6ef62c4c Merge pull request 'Wrap mobile review diffs without horizontal panning' (#446) from timmy/445-wrap-mobile-review-diffs into main
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:39:38 +00:00
timmy 461c796227 feat: wrap mobile review diffs (#445)
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:37:43 +00:00
timmy 6309ed43cd Merge pull request 'Work Today blockers without leaving Stackchain' (#444) from timmy/443-today-blocker-preview into main
CI / lint (push) Successful in 50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:07:06 +00:00
timmy a0aa3c0a22 feat: work Today blockers in-app (#443)
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:05:11 +00:00
timmy 60bc7cd686 Merge pull request 'Bound Today readiness latency with demand-driven checks' (#442) from timmy/441-demand-driven-today-readiness into main
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 01:35:45 +00:00
timmy 79c8f1626b perf: bound Today readiness latency (#441)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:33:55 +00:00
timmy cd9f40a1c4 Keep notification acknowledgements responsive under snapshot contention (#440)
CI / lint (push) Successful in 51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
Closes #439
2026-08-10 01:16:02 +00:00
timmy 0a3fb180ed perf: batch notification snapshot maintenance (#439)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:14:11 +00:00
timmy 74d1435ca7 Merge pull request 'Skip blocked items while running the Today queue' (#438) from timmy/437-today-readiness into main
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:40:44 +00:00
timmy 8989e93c2c feat: skip blocked Today work (#437)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:39:13 +00:00
timmy 7b3b7dadb9 Merge pull request 'Surface blockers while planning Today work' (#436) from timmy/435-dependency-aware-today into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:02:47 +00:00
timmy 05f9e06a10 feat: surface Today issue blockers (#435)
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:00:51 +00:00
timmy e0de45ea90 Merge pull request 'Coalesce live snapshot refreshes across application workers' (#434) from timmy/433-shared-live-snapshot into main
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
2026-08-09 23:21:58 +00:00
timmy 3224389441 perf: share live refreshes across workers (#433)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 23:20:07 +00:00
timmy 63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
timmy 740e91a615 perf: precompress immutable dashboard runtime (#431)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
timmy 0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 22:02:02 +00:00
35 changed files with 2553 additions and 166 deletions

No files matched your search

+37 -11
View File
@@ -20,7 +20,10 @@ list repository labels and open milestones, set or clear due dates on assigned i
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
creation and assignment permission. Once a repository and meaningful title are selected,
the New issue sheet checks for similar open issues in that repository. Candidate links keep
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
@@ -37,13 +40,18 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Starting a Today work session also
five-item Today plan syncs across the operator's devices. Adding an issue through **Plan Today**
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
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
one without closing or merging it. Delivery or local-admission failure preserves both the draft
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
one without closing or merging it. **Reply & next** provides the same one-action continuation
for the current unread-update conversation. It deliberately leaves the notification unread;
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
@@ -91,7 +99,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
request. On phone-width review sheets, changed lines wrap inside a stable old/new
line-number gutter by default so long source lines remain readable and commentable
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
horizontal inspection, and an explicit choice persists on the device across files,
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
@@ -221,12 +234,25 @@ below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
Live snapshots expose independent `context`, `events`, and `notifications`
revision tokens. Each bounded opaque token includes a per-process generation, so a token
from another worker or from before a restart cannot suppress different content. The browser
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
while still returning current freshness and retry metadata. The client retains omitted data
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
rejected before any upstream work.
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
atomically through a private SQLite store shared by all application workers. An expiring
refresh lease ensures only one worker loads currently due sections; other workers serve the
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
worker-shared writable storage. The database and directory are restricted to the service
account and never contain the Gitea token. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
cannot suppress different content. The browser sends its known tokens on later polls, so
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the sections
that changed. Malformed or oversized tokens are rejected before any upstream work.
Confirmed notification acknowledgements update this worker's live view immediately, then
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
all successful notification IDs in one SQLite transaction and advance the notification
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
API keeps the confirmed result and process-local filtering rather than falsely reporting the
upstream write as failed; a later shared refresh reconciles the cache.
## Offline mobile shell
+9 -6
View File
@@ -1,4 +1,4 @@
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
function submit(item, body, operationId = '') {
@@ -7,14 +7,17 @@ function createCommentNext({ post, queue, canQueue, accept = () => undefined, co
try {
let comment;
try {
comment = await post(item, body);
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) {
if (!canQueue(error)) throw error;
const admission = await queue({
const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository,
number: item.number,
body,
repository: item.repository, number: item.number,
};
const admission = await queue({
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
if (!admission || (!admission.item && admission.durable !== true)) {
+33 -2
View File
@@ -100,6 +100,14 @@ 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-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; }
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-readiness-header h2 { margin:.2rem 0; }
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.today-readiness-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; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
@@ -179,9 +187,18 @@ textarea { resize: vertical; min-height: 120px; }
.review-copy-fallback { min-height:140px; }
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
.review-progress-actions button { min-height:44px; max-width:100%; }
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.review-display-tools button { min-height:44px; flex:0 0 auto; }
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:block; min-width:max-content; }
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
.review-inline-composer[hidden] { display:none; }
@@ -203,6 +220,8 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
.update-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; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -213,6 +232,12 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
@@ -226,6 +251,12 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
.mention-option:last-child { border-bottom:0; }
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
.mention-status:empty { display:none; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
+341 -33
View File
@@ -226,6 +226,29 @@
return payload;
}
function loadMentionCandidates(repository, query) {
return fetchReviewJson(
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
{headers:{Accept:'application/json'}},
);
}
const issueMentions = createMentionComposer({
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
loadCandidates:loadMentionCandidates,
});
const pullMentions = createMentionComposer({
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
loadCandidates:loadMentionCandidates,
});
const updateMentions = createMentionComposer({
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
loadCandidates:loadMentionCandidates,
});
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
@@ -237,6 +260,10 @@
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
const wrapPreference = createReviewController.createWrapPreference({
storage: localStorage,
mobile: window.matchMedia('(max-width: 600px)').matches,
});
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
@@ -513,6 +540,7 @@
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
selectedUpdate = item;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
@@ -526,6 +554,8 @@
qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false;
qs('#send-update-reply-next').disabled = false;
setUpdateReplyNextVisibility();
qs('#update-ownership-action').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
@@ -728,25 +758,131 @@
qs('[data-work-filter="today"]').click();
}
function continueTodaySession() {
selectTodayWork();
if (!workSession.reopen() && !workSession.reconcile()) {
qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
let todayReadinessTrigger = null;
let todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null;
function inspectTodayDependencies(item) {
if (offlineWorkMode) {
const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
const detail = offlineWorkStore.loadDetail(login, item);
return Promise.resolve({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
});
}
const [owner, repo] = String(item.repository || '').split('/');
return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
}));
}
function suspendTodayReadiness() {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
}
function closeTodayReadiness(navigate = true) {
suspendTodayReadiness();
if (navigate) taskOverlayHistory.close();
else {
todayReadiness.cancel();
todayReadinessBlockerFocus = null;
requestAnimationFrame(() => todayReadinessTrigger?.focus());
}
}
function renderTodayReadiness(state) {
if (!state?.target) return;
const unknown = state.status === 'unknown';
qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
qs('#today-readiness-summary').textContent = unknown
? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
: state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
qs('#today-readiness-item').innerHTML = '<strong>' + escapeHtml(state.target.title || 'Untitled issue') +
'</strong><div class="small">' + escapeHtml(state.target.repository + '#' + state.target.number) + '</div>';
const blockers = qs('#today-readiness-blockers');
blockers.innerHTML = state.dependencies.map((blocker, index) =>
'<button class="issue-blocker today-readiness-blocker" type="button" data-today-blocker-index="' + index + '"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + ' · Preview and work blocker</span></button>'
).join('');
blockers.querySelectorAll('[data-today-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = state.dependencies[Number(button.dataset.todayBlockerIndex)];
todayReadinessBlockerFocus = blocker?.repository + '#' + blocker?.number;
todayReadiness.previewBlocker(blocker);
});
});
qs('#today-readiness-next').hidden = !state.nextReady;
if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' +
(state.nextReady.title || state.nextReady.key || 'work item');
qs('#today-readiness-retry').hidden = !unknown;
qs('#today-readiness-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => {
const blocker = state.dependencies.findIndex(candidate =>
candidate?.repository + '#' + candidate?.number === todayReadinessBlockerFocus
);
if (blocker >= 0) blockers.querySelector('[data-today-blocker-index="' + blocker + '"]')?.focus();
else (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus();
});
}
function performTodayTransition(action, item = null) {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
const transitioned = ({
start: () => workSession.start(item),
resume: () => workSession.resume(item),
continue: () => workSession.reopen(item),
next: () => workSession.next(item),
complete: () => workSession.complete(item),
})[action]?.();
if (!transitioned && action !== 'next' && action !== 'complete') {
qs('#my-work-action-status').textContent = 'Saved Today item is no longer available.';
updateWorkSessionActions();
}
}
const todayReadiness = createTodayReadiness({
inspect:inspectTodayDependencies,
onOpen:performTodayTransition,
onGate:state => {
todayReadinessTrigger = document.activeElement;
renderTodayReadiness(state);
taskOverlayHistory.open('today-readiness');
},
onPreview:blocker => {
searchPreviewReturnKind = 'today-readiness';
searchPreview.open({ ...blocker, kind:'issue' }).catch(() => {});
taskOverlayHistory.open('search-preview');
},
});
function runTodayTransition(action) {
selectTodayWork();
const target = workSession.target(action);
if (!target) {
performTodayTransition(action);
return Promise.resolve('empty');
}
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run(action, workSession.items(), target);
}
function continueTodaySession() {
return runTodayTransition('continue');
}
function resumeTodaySession() {
selectTodayWork();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
return runTodayTransition('resume');
}
function startTodaySession() {
selectTodayWork();
workSession.start();
return runTodayTransition('start');
}
const createAndStart = createCreateAndStart({
@@ -781,11 +917,23 @@
refresh: () => refreshMyWorkView({ reconcileSession:false }),
warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; },
advance: () => runTodayTransition('complete'),
});
async function completeOwnershipExitToday(item) {
if (!item || !todayWork.remove(item)) return null;
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refreshMyWorkView({ reconcileSession:false });
warmTodayOffline();
return await runTodayTransition('complete');
}
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
}
function setUpdateReplyNextVisibility() {
qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate);
}
const issueCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await issueController.comment(item, body);
@@ -820,6 +968,26 @@
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const updateReplyNext = createCommentNext({
queueKind: 'update-reply',
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: (item, result) => {
notificationReplier.saveDraft(item, '');
if (selectedUpdate === item) {
qs('#update-reply').value = '';
if (result.comment) notificationReader.appendReply(result.comment);
qs('#update-reply-status').textContent = result.delivery === 'queued' ?
'Reply queued for background delivery.' : result.delivery === 'saved' ?
'Reply saved for next-launch delivery.' : 'Reply posted.';
}
},
complete: item => completeTodayItem(item, {
successMessage: 'Reply saved. Next Today item opened.',
failureMessage: 'Reply saved, but Today still needs completion.',
}),
});
const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options),
@@ -872,7 +1040,9 @@
const title = escapeHtml(item.title || 'Untitled work');
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 === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</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>';
(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></div>' + controls + '</article>';
}
@@ -941,7 +1111,7 @@
save: saveTodayPlan,
start: () => {
qs('[data-work-filter="today"]').click();
workSession.start();
startTodaySession();
},
});
@@ -955,6 +1125,7 @@
function openPlanPreviewDetail(item, trigger) {
qs('#plan-today-sheet').hidden = true;
qs('#plan-preview-actions').hidden = false;
if (item.kind === 'issue') renderPlanIssueDependencies(null, true);
if (offlineWorkMode) {
openRoutedWork(item, trigger);
} else if (item.kind === 'update' && item.has_update) {
@@ -981,19 +1152,68 @@
onClose: (_item, trigger) => {
closeOpenWorkSheets();
qs('#plan-preview-actions').hidden = true;
qs('#issue-blockers').hidden = true;
qs('#issue-blocker-list').textContent = '';
qs('#add-plan-preview').disabled = false;
qs('#add-plan-preview').textContent = 'Add to Today & back';
qs('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
if (!preview.open || preview.item?.kind !== 'issue') return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
const status = qs('#issue-blocker-status');
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
addButton.disabled = loading;
addButton.dataset.planOverride = '';
if (loading) {
status.textContent = 'Checking unresolved blockers…';
addButton.textContent = 'Checking blockers…';
return;
}
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
planTodayPreview.setDependencies({ available, dependencies });
if (!available) {
status.textContent = 'Blocker status unavailable. Adding requires an explicit override.';
addButton.textContent = 'Add without blocker status anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
if (dependencies.length) {
list.innerHTML = dependencies.map(blocker =>
'<a class="issue-blocker" href="' + escAttr(blocker.url || '#') + '" target="_blank" rel="noopener noreferrer"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + '</span></a>'
).join('');
status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.');
addButton.textContent = 'Add blocked item anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
panel.hidden = true;
status.textContent = '';
addButton.textContent = 'Add to Today & back';
}
let addPlanPreviewOnReturn = false;
let addPlanPreviewOverride = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true';
taskOverlayHistory.close();
});
@@ -1606,6 +1826,14 @@
);
}
function applyReviewWrap(snapshot = wrapPreference.snapshot()) {
const reviewFilesElement = qs('#review-files');
const button = qs('#review-wrap-lines');
reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped);
button.setAttribute('aria-pressed', String(snapshot.wrapped));
button.textContent = snapshot.wrapped ? 'Lines wrapped' : 'Wrap lines';
}
function showReviewProgress(snapshot) {
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
qs('#review-progress').textContent = complete ?
@@ -1731,6 +1959,7 @@
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
issueConversation = null;
@@ -1751,6 +1980,7 @@
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#issue-handoff-recipient').disabled = true;
qs('#confirm-issue-handoff').disabled = true;
qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-issue-handoff').disabled = false;
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
@@ -1778,11 +2008,13 @@
qs('#close-issue').textContent = offlineDetail ?
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
(workSession.active() ? 'Close & next' : 'Close issue');
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#close-issue-sheet').focus();
try {
const detail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
@@ -1907,6 +2139,7 @@
if (!item) return;
qs('#pull-review').inert = false;
selectedPull = item;
pullMentions.dismiss();
pullTrigger = trigger;
selectedPullDetail = null;
pullConversation = null;
@@ -2422,6 +2655,7 @@
qs('#retry-review-load').hidden = true;
qs('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
applyReviewWrap();
qs('#review-progress').textContent = '0 of 0 files reviewed';
qs('#next-unreviewed-review').disabled = true;
qs('#review-history').textContent = '';
@@ -2758,6 +2992,8 @@
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') {
sheet.classList.remove('open');
return;
@@ -2869,6 +3105,7 @@
qs('#cmd-input').value = '';
} else {
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
taskOverlayHistory.open('search-preview');
}
@@ -2928,6 +3165,7 @@
searchPreview.close();
mobileSearchViewport.close();
}
searchPreviewReturnKind = null;
}
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
qs('#cmd-palette').classList.remove('open');
@@ -2936,15 +3174,22 @@
qs('#open-palette').focus();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (previous === 'today-readiness' && kind !== 'today-readiness') {
if (kind === 'search-preview') suspendTodayReadiness();
else closeTodayReadiness(false);
}
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
});
taskOverlayHistory.start();
@@ -3469,6 +3714,7 @@
qs('#release-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
const releasing = selectedIssue;
const continuingSession = workSession.checkpointed(releasing);
const button = qs('#release-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
@@ -3478,9 +3724,20 @@
lastContextSnapshot, releasing.repository, releasing.number
);
closeIssueSheet();
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(releasing);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = releasing.key + ' released, but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
@@ -3524,6 +3781,7 @@
const recipient = qs('#issue-handoff-recipient').value;
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
const handingOff = selectedIssue;
const continuingSession = workSession.checkpointed(handingOff);
const button = qs('#confirm-issue-handoff');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
@@ -3533,8 +3791,19 @@
lastContextSnapshot, handingOff.repository, handingOff.number
);
closeIssueSheet();
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(handingOff);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
}
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
@@ -3575,9 +3844,11 @@
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
if (workSession.active()) workSession.complete();
if (continuingSession && workSession.active()) {
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + ' closed.';
}
@@ -3667,9 +3938,11 @@
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
if (workSession.active()) workSession.complete();
if (continuingSession && workSession.active()) {
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = merging.key + ' merged.';
}
@@ -3720,6 +3993,31 @@
qs('#update-reply').focus();
}
});
qs('#send-update-reply-next').addEventListener('click', async () => {
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
qs('#update-reply').focus();
return;
}
const button = qs('#send-update-reply-next');
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
qs('#update-reply-status').textContent = 'Sending reply…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
await updateReplyNext.submit(item, body, operationId);
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
} finally {
button.disabled = false;
sendButton.disabled = false;
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
@@ -3735,6 +4033,10 @@
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
qs('#review-wrap-lines').addEventListener('click', () => {
const next = !wrapPreference.snapshot().wrapped;
applyReviewWrap(wrapPreference.setWrapped(next));
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#save-inline-comment').addEventListener('click', () => {
@@ -3836,7 +4138,7 @@
qs('#continue-review-to-merge').focus();
} else {
await load();
if (workSession.active()) workSession.complete();
if (workSession.active()) await runTodayTransition('complete');
else button.focus();
}
} catch (error) {
@@ -4028,6 +4330,17 @@
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
qs('#today-readiness-close').addEventListener('click', () => closeTodayReadiness());
qs('#today-readiness-sheet').addEventListener('click', event => {
if (event.target === qs('#today-readiness-sheet')) closeTodayReadiness();
});
qs('#today-readiness-next').addEventListener('click', () => todayReadiness.startNextReady());
qs('#today-readiness-anyway').addEventListener('click', () => todayReadiness.workAnyway());
qs('#today-readiness-retry').addEventListener('click', () => todayReadiness.retry());
qs('#today-readiness-plan').addEventListener('click', () => {
closeTodayReadiness();
openPlanToday(qs('#plan-today'));
});
qs('#save-today-plan').addEventListener('click', () => {
const result = planToday.commit();
if (result === 'saved') taskOverlayHistory.leave();
@@ -4044,15 +4357,10 @@
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => {
qs('[data-work-filter="today"]').click();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
if (selectedWorkFilter === 'today') runTodayTransition('start');
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.';
@@ -4062,7 +4370,7 @@
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.next())
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
+39 -1
View File
@@ -196,6 +196,24 @@
</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">
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
<button id="today-readiness-close" type="button">Close</button>
</div>
<p id="today-readiness-summary" aria-live="polite"></p>
<div class="today-readiness-item" id="today-readiness-item"></div>
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
<div class="today-readiness-actions">
<button id="today-readiness-next" type="button">Start next ready</button>
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
<button id="today-readiness-retry" type="button">Retry status</button>
<button id="today-readiness-plan" type="button">Review Today plan</button>
</div>
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
@@ -262,6 +280,11 @@
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@@ -269,6 +292,8 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
@@ -439,7 +464,12 @@
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<button id="send-update-reply" type="button">Send reply</button>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-next" type="button" hidden>Reply &amp; next</button>
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
@@ -473,6 +503,8 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
@@ -516,6 +548,10 @@
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div class="review-display-tools">
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
<span class="small">Keep long diffs inside the review sheet.</span>
</div>
<div id="review-files" class="muted"></div>
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
<strong id="review-inline-title">Comment on changed line</strong>
@@ -608,6 +644,7 @@
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>
@@ -635,6 +672,7 @@
<script src="static/install-app.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/dashboard.js"></script>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMentionComposer = api.create;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';
function activeMention(value, caret) {
const before = String(value || '').slice(0, Math.max(0, caret));
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
if (!match) return null;
const start = before.length - match[2].length - 1;
return { start, end:before.length, query:match[2] };
}
function insertMention(value, mention, login) {
const text = String(value || '');
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
const inserted = '@' + login + ' ';
return {
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
caret:mention.start + inserted.length,
};
}
function create(options) {
const textarea = options.textarea;
const listbox = options.listbox;
const status = options.status;
const setTimer = options.setTimer || setTimeout;
const clearTimer = options.clearTimer || clearTimeout;
const createOption = options.createOption || (() => document.createElement('button'));
let timer = null;
let requestId = 0;
let mention = null;
let candidates = [];
let activeIndex = -1;
function dismiss() {
requestId += 1;
candidates = [];
activeIndex = -1;
listbox.replaceChildren();
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
textarea.removeAttribute('aria-activedescendant');
status.textContent = '';
}
function activate(index) {
if (!candidates.length) return;
activeIndex = (index + candidates.length) % candidates.length;
listbox.children.forEach((option, optionIndex) => {
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
});
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
}
function select(index) {
if (!mention || !candidates[index]) return;
const result = insertMention(textarea.value, mention, candidates[index].login);
textarea.value = result.value;
textarea.setSelectionRange(result.caret, result.caret);
dismiss();
textarea.focus();
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
}
function render(items) {
candidates = Array.isArray(items) ? items : [];
activeIndex = -1;
listbox.replaceChildren();
candidates.forEach((candidate, index) => {
const option = createOption();
option.type = 'button';
option.id = listbox.id + '-option-' + index;
option.className = 'mention-option';
option.dataset.login = candidate.login;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
option.addEventListener('pointerdown', event => {
event.preventDefault();
select(index);
});
listbox.appendChild(option);
});
listbox.hidden = !candidates.length;
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
}
function schedule() {
if (timer !== null) clearTimer(timer);
mention = activeMention(textarea.value, textarea.selectionStart);
const repository = options.getRepository();
if (!mention || !repository) {
dismiss();
return;
}
const currentRequest = ++requestId;
timer = setTimer(async () => {
try {
const items = await options.loadCandidates(repository, mention.query);
if (currentRequest === requestId && repository === options.getRepository()) render(items);
} catch (_error) {
if (currentRequest === requestId) {
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
}
}
}, options.delayMs ?? 180);
}
function keydown(event) {
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
dismiss();
} else if (event.key === 'ArrowDown' && candidates.length) {
event.preventDefault();
activate(activeIndex + 1);
} else if (event.key === 'ArrowUp' && candidates.length) {
event.preventDefault();
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
}
}
return {
start() {
textarea.setAttribute('autocomplete', 'off');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', listbox.id);
textarea.setAttribute('aria-expanded', 'false');
textarea.addEventListener('input', schedule);
textarea.addEventListener('click', schedule);
textarea.addEventListener('keydown', keydown);
},
dismiss,
};
}
return { activeMention, insertMention, create };
});
+37 -6
View File
@@ -566,24 +566,28 @@ function createWorkSession({
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
reopen(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
resume(requested = null) {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
const exact = items.findIndex(item => workIdentity(item) ===
(requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
@@ -609,21 +613,48 @@ function createWorkSession({
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
complete(requested = null) {
if (!running) return false;
const items = queue();
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
items: () => queue().slice(),
target(action) {
const items = queue();
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
}
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
if (action === 'continue') return current >= 0 ? items[current] : null;
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
if (action === 'complete') {
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
}
return null;
},
};
}
+21 -3
View File
@@ -22,8 +22,19 @@
return true;
}
function close({ add = false } = {}) {
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
@@ -37,9 +48,16 @@
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, snapshot };
return { open, close, setDependencies, snapshot };
};
});
+37 -5
View File
@@ -47,6 +47,28 @@ function createReviewController({
return { load, submit };
}
function createWrapPreference({ storage, mobile }) {
const key = 'stackchain.review-wrap.v1';
let explicit = false;
let wrapped = Boolean(mobile);
try {
const saved = storage?.getItem(key);
if (saved === 'true' || saved === 'false') {
explicit = true;
wrapped = saved === 'true';
}
} catch (_error) { /* keep the viewport default */ }
function snapshot() { return { wrapped, explicit }; }
function setWrapped(value) {
wrapped = Boolean(value);
explicit = true;
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
return snapshot();
}
return { snapshot, setWrapped, storageKey: key };
}
function diffLineClass(line) {
if (line.startsWith('@@')) return 'hunk';
if (line.startsWith('+')) return 'added';
@@ -70,16 +92,19 @@ function parseDiffLines(lines) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
const row = {
text, kind: 'context', commentable: true,
old_line: oldLine, new_line: newLine, new_position: newLine,
};
oldLine += 1;
newLine += 1;
return row;
@@ -91,8 +116,14 @@ function renderDiffFile(file, index, escapeHtml) {
let preview;
if (file.diff_available) {
const lines = parseDiffLines(file.diff_lines).map(row => {
const oldNumber = row.old_line || row.old_position || '';
const newNumber = row.new_line || row.new_position || '';
const content = '<span class="review-line-numbers" aria-hidden="true">' +
'<span class="review-line-number old">' + oldNumber + '</span>' +
'<span class="review-line-number new">' + newNumber + '</span></span>' +
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
@@ -100,7 +131,7 @@ function renderDiffFile(file, index, escapeHtml) {
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
(row.old_position || row.new_position) + '">' + content + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
@@ -325,6 +356,7 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
+3 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v72';
const CACHE = 'stackchain-dashboard-shell-v78';
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;
@@ -29,6 +29,7 @@ const SHELL = [
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
@@ -56,6 +57,7 @@ const SHELL = [
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/background-issue-sync.js',
];
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
+2 -2
View File
@@ -1,4 +1,4 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) {
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
@@ -8,7 +8,7 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
todaySync.flush();
refresh();
warm();
workSession.complete();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTodayReadiness = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
let pending = null;
let generation = 0;
async function classify(item) {
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
try {
const detail = await inspect(item);
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
const dependencies = Array.isArray(detail.dependencies)
? detail.dependencies.filter(dependency => dependency?.state === 'open')
: [];
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
} catch (_error) {
return { status:'unknown', dependencies:[] };
}
}
async function run(action, items, target = null) {
const request = ++generation;
const ordered = Array.isArray(items) ? items.slice() : [];
const preferred = target || ordered[0] || null;
if (!preferred) return 'empty';
const preferredIndex = ordered.indexOf(preferred);
const candidates = preferredIndex >= 0
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
: [preferred].concat(ordered);
const targetState = { item:preferred, ...(await classify(preferred)) };
if (request !== generation) return 'superseded';
if (targetState.status === 'ready') {
pending = null;
onOpen(action, preferred);
return 'opened';
}
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
item,
...(await classify(item)),
})));
const states = [targetState, ...fallbackStates];
if (request !== generation) return 'superseded';
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
pending = {
action,
items:ordered,
target:preferred,
status:targetState.status,
dependencies:targetState.dependencies,
nextReady:nextReadyState?.item || null,
};
onGate({ ...pending, items:pending.items.slice() });
return 'gated';
}
function open(item) {
if (!pending || !item) return false;
const action = pending.action;
pending = null;
generation += 1;
onOpen(action, item);
return true;
}
function previewBlocker(blocker) {
if (!pending || !blocker) return false;
const current = pending.dependencies.find(dependency =>
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
);
if (!current) return false;
onPreview(current);
return true;
}
return {
run,
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
previewBlocker,
startNextReady: () => open(pending?.nextReady),
workAnyway: () => open(pending?.target),
cancel() {
generation += 1;
pending = null;
},
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
};
};
});
+4 -2
View File
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
path = scope.get("path", "").rsplit("/", 1)[-1]
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
+4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import gzip
import hashlib
import re
from dataclasses import dataclass
@@ -16,6 +17,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
page_sources: tuple[str, ...]
@@ -38,6 +40,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
dashboard_html = SCRIPT_TAG.sub("", source_html)
@@ -63,6 +66,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_gzip_bytes=runtime_gzip,
runtime_digest=digest,
runtime_name=runtime_name,
page_sources=sources,
+78 -2
View File
@@ -1129,6 +1129,40 @@ async def issue_handoff_candidates(repository: str) -> list[dict]:
return candidates
async def mention_candidates(
repository: str, query: str, *, limit: int = 8
) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/assignees", headers=_auth()
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea assignees response was not a list")
needle = query.casefold()
matches = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if (
not isinstance(login, str)
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
):
continue
full_name = item.get("full_name")
name = full_name if isinstance(full_name, str) and full_name else login
login_match = needle in login.casefold()
name_match = needle in name.casefold()
if login_match or name_match:
matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), {
"login": login,
"name": name,
}))
matches.sort(key=lambda item: (item[0], item[1]))
return [item[2] for item in matches[:limit]]
async def handoff_assigned_issue(
repository: str, number: int, recipient: str
) -> dict:
@@ -1343,9 +1377,19 @@ async def issue_conversation_page(
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
async def load_dependencies() -> tuple[bool, list[dict]]:
try:
return True, await issue_dependencies(repository, number)
except Exception:
return False, []
issue, conversation, dependency_result = await asyncio.gather(
fetch(base),
issue_conversation_page(repository, number),
load_dependencies(),
)
dependencies_available, dependencies = dependency_result
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
@@ -1377,11 +1421,43 @@ async def issue_detail(repository: str, number: int) -> dict:
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"dependencies_available": dependencies_available,
"dependencies": dependencies,
"comments": normalized_comments,
"conversation": conversation,
}
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
"""Return bounded open prerequisites for an issue."""
response = await _get_client().get(
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
params={"limit": limit},
)
response.raise_for_status()
value = response.json()
items = value if isinstance(value, list) else []
dependencies = []
for item in items[:limit]:
if not isinstance(item, dict) or item.get("state") != "open":
continue
repo_value = item.get("repository")
repo = repo_value if isinstance(repo_value, dict) else {}
dependency_repository = repo.get("full_name")
dependency_number = item.get("number")
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
continue
dependencies.append({
"repository": dependency_repository,
"number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(item.get("html_url")),
})
return dependencies
async def update_assigned_issue(
repository: str,
number: int,
+295
View File
@@ -0,0 +1,295 @@
"""Process-shared live snapshot state and refresh coordination."""
from __future__ import annotations
import json
import os
import secrets
import sqlite3
import stat
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
SECTIONS = ("context", "events", "notifications")
class RefreshLeaseLost(RuntimeError):
"""The refresh is no longer authorized to publish shared state."""
@dataclass(frozen=True)
class LiveSnapshotState:
value: dict | None
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
class LiveSnapshotStore:
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
def __init__(
self,
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
):
self.path = Path(path)
self.clock = clock or time.time
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def _initialize(self) -> None:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
os.chmod(self.path.parent, stat.S_IRWXU)
except OSError:
pass
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS live_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
value_json TEXT,
created_at_json TEXT NOT NULL,
failure_count_json TEXT NOT NULL,
retry_at_json TEXT NOT NULL,
revisions_json TEXT NOT NULL,
generation TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS live_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
sections_json TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS live_read_notification (
notification_id INTEGER PRIMARY KEY
);
"""
)
connection.execute(
"""INSERT OR IGNORE INTO live_snapshot VALUES
(1, NULL, ?, ?, ?, ?, ?)""",
(
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
secrets.token_hex(8),
),
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def try_acquire_refresh(
self, sections: Iterable[str], *, lease_seconds: float
) -> str | None:
requested = sorted(set(sections))
if not requested or any(section not in SECTIONS for section in requested):
raise ValueError("refresh lease requires known sections")
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> LiveSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM live_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotState(
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish_refresh(
self,
owner: str,
*,
value: dict,
created_at: dict[str, float | None],
failure_count: dict[str, int],
retry_at: dict[str, float | None],
changed_sections: Iterable[str],
) -> LiveSnapshotState:
changed = set(changed_sections)
if changed - set(SECTIONS):
raise ValueError("publication contains unknown sections")
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
raise ValueError("publication metadata must contain every section")
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
if isinstance(notifications, list):
returned_ids = {
item.get("id") for item in notifications if isinstance(item, dict)
}
read_ids = {
item["notification_id"]
for item in connection.execute(
"SELECT notification_id FROM live_read_notification"
)
}
value = dict(value)
value["notifications"] = [
item for item in notifications
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if returned_ids:
placeholders = ",".join("?" for _ in returned_ids)
connection.execute(
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
tuple(returned_ids),
)
else:
connection.execute("DELETE FROM live_read_notification")
for section in changed:
revisions[section] += 1
connection.execute(
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
)
connection.commit()
return self.load()
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
read_ids = set(notification_ids)
if not read_ids:
return self.load()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.executemany(
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
((notification_id,) for notification_id in read_ids),
)
row = connection.execute(
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
value = json.loads(row["value_json"]) if row["value_json"] else None
revisions = json.loads(row["revisions_json"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
retained = [
item
for item in previous
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if retained != previous:
value["notifications"] = retained
revisions["notifications"] += 1
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
(
json.dumps(value, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.commit()
return self.load()
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
"""Filter one confirmed read from every worker and subsequent stale refresh."""
return self.remove_notifications([notification_id])
def release_refresh(self, owner: str) -> None:
"""Relinquish a lease that became unnecessary after a coherent recheck."""
with self._connect() as connection:
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(
self,
owner: str,
*,
failure_count: dict[str, int],
retry_at: dict[str, float | None],
) -> LiveSnapshotState:
"""Publish backoff metadata and relinquish a failed refresh atomically."""
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
connection.execute(
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
WHERE singleton = 1""",
(
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
),
)
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+224 -36
View File
@@ -34,6 +34,7 @@ from src.gitea_proxy import (
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
@@ -117,6 +118,11 @@ _authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
_live_snapshot_clock = time.time
_live_snapshot_store = LiveSnapshotStore(
os.getenv("STACKCHAIN_LIVE_SNAPSHOT_DB", str(_state_dir / "live-snapshot.sqlite3")),
clock=lambda: _live_snapshot_clock(),
)
_idempotency_ledger = IdempotencyLedger(
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
@@ -1530,7 +1536,7 @@ def _record_live_section_failure(section: str) -> None:
LIVE_SNAPSHOT_RETRY_BASE_SECONDS
* (2 ** (_live_section_failure_count[section] - 1)),
)
_live_section_retry_at[section] = time.monotonic() + delay
_live_section_retry_at[section] = _live_snapshot_clock() + delay
def _due_live_sections(now: float) -> set[str]:
@@ -1545,6 +1551,23 @@ def _due_live_sections(now: float) -> set[str]:
return due
def _apply_shared_live_state(state: LiveSnapshotState) -> None:
"""Replace this worker's fast local view with one coherent SQLite read."""
global _live_snapshot_value, _live_snapshot_created_at
global _live_section_created_at, _live_section_failure_count, _live_section_retry_at
global _live_section_revisions, _live_revision_generation
global _live_snapshot_refreshing_sections
_live_snapshot_value = state.value
_live_section_created_at = state.created_at
_live_section_failure_count = state.failure_count
_live_section_retry_at = state.retry_at
_live_section_revisions = state.revisions
_live_revision_generation = state.generation
_live_snapshot_refreshing_sections = state.refreshing_sections
successful = [value for value in state.created_at.values() if value is not None]
_live_snapshot_created_at = max(successful) if successful else None
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
if previous is None:
return refreshed
@@ -1567,15 +1590,32 @@ def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
return merged
async def _refresh_live_snapshot(sections: set[str]) -> dict:
async def _refresh_live_snapshot(sections: set[str], lease_owner: str) -> dict:
global _live_snapshot_value, _live_snapshot_created_at
try:
refreshed = await _build_live_snapshot_before_deadline(sections)
except asyncio.CancelledError:
try:
await asyncio.shield(
asyncio.to_thread(_live_snapshot_store.release_refresh, lease_owner)
)
finally:
raise
except Exception:
for section in sections:
_record_live_section_failure(section)
try:
state = await asyncio.to_thread(
_live_snapshot_store.fail_refresh,
lease_owner,
failure_count=_live_section_failure_count,
retry_at=_live_section_retry_at,
)
_apply_shared_live_state(state)
except RefreshLeaseLost:
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
raise
now = time.monotonic()
now = _live_snapshot_clock()
refreshed_states = refreshed.get("sections") or {}
for section in sections:
if refreshed_states.get(section) == "fresh":
@@ -1587,6 +1627,7 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
previous = _live_snapshot_value
result = _merge_live_snapshot(previous, refreshed)
result = _without_read_notifications(result)
changed_sections = set()
for section in sections:
if section not in refreshed_states:
continue
@@ -1596,11 +1637,24 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
"notification_pagination"
) != result.get("notification_pagination")
if changed:
_live_section_revisions[section] += 1
_live_snapshot_value = result
successful_times = [value for value in _live_section_created_at.values() if value is not None]
_live_snapshot_created_at = max(successful_times) if successful_times else None
return result
changed_sections.add(section)
try:
state = await asyncio.to_thread(
_live_snapshot_store.publish_refresh,
lease_owner,
value=result,
created_at=_live_section_created_at,
failure_count=_live_section_failure_count,
retry_at=_live_section_retry_at,
changed_sections=changed_sections,
)
except RefreshLeaseLost:
state = await asyncio.to_thread(_live_snapshot_store.load)
if state.value is None:
raise
_apply_shared_live_state(state)
assert state.value is not None
return state.value
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
@@ -1609,12 +1663,22 @@ def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
task.exception()
def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
def _start_live_snapshot_refresh(
sections: set[str], lease_owner: str | None = None
) -> asyncio.Task:
global _live_snapshot_task
global _live_snapshot_refreshing_sections
if _live_snapshot_task is None or _live_snapshot_task.done():
if lease_owner is None:
lease_owner = _live_snapshot_store.try_acquire_refresh(
sections, lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0
)
if lease_owner is None:
raise RuntimeError("live snapshot refresh lease is already held")
_live_snapshot_refreshing_sections = set(sections)
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot(sections))
_live_snapshot_task = asyncio.create_task(
_refresh_live_snapshot(sections, lease_owner)
)
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
return _live_snapshot_task
@@ -1627,7 +1691,7 @@ def _live_snapshot_payload(
known_revisions: dict[str, str | None] | None = None,
) -> dict:
payload = dict(value)
now = time.monotonic()
now = _live_snapshot_clock()
section_freshness = {}
for section in LIVE_SNAPSHOT_SECTIONS:
created_at = _live_section_created_at[section]
@@ -1671,23 +1735,30 @@ def _live_snapshot_payload(
return payload
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> None:
global _live_snapshot_value, _read_notification_ids
_read_notification_ids = _read_notification_ids | {thread_id}
if _live_snapshot_value is None:
read_ids = set(thread_ids)
if not read_ids:
return
retained_notifications = _live_snapshot_value.get("notifications")
if not isinstance(retained_notifications, list):
return
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
for notification in retained_notifications
if not isinstance(notification, dict) or notification.get("id") != thread_id
]
if updated["notifications"] != retained_notifications:
_live_section_revisions["notifications"] += 1
_live_snapshot_value = updated
_read_notification_ids = _read_notification_ids | read_ids
if _live_snapshot_value is not None:
retained_notifications = _live_snapshot_value.get("notifications")
if isinstance(retained_notifications, list):
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
for notification in retained_notifications
if not isinstance(notification, dict)
or notification.get("id") not in read_ids
]
if updated["notifications"] != retained_notifications:
_live_section_revisions["notifications"] += 1
_live_snapshot_value = updated
try:
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
except (OSError, sqlite3.Error):
# The upstream mutation already succeeded; keep process-local filtering.
pass
def _without_read_notifications(snapshot: dict) -> dict:
@@ -1711,8 +1782,7 @@ def _without_read_notifications(snapshot: dict) -> dict:
return updated
@app.get("/api/v1/live")
async def live_snapshot(
async def _live_snapshot_response(
context_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
@@ -1730,12 +1800,10 @@ async def live_snapshot(
"events": events_revision,
"notifications": notifications_revision,
}
now = time.monotonic()
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
now = _live_snapshot_clock()
due_sections = _due_live_sections(now)
if (
_live_snapshot_value is not None
and not due_sections
):
if _live_snapshot_value is not None and not due_sections:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
@@ -1747,7 +1815,77 @@ async def live_snapshot(
known_revisions=known_revisions,
)
)
task = _start_live_snapshot_refresh(due_sections)
if not due_sections:
retries = [
retry_at - now
for retry_at in _live_section_retry_at.values()
if retry_at is not None and retry_at > now
]
return JSONResponse(
{"error": "Gitea live snapshot is temporarily unavailable"},
status_code=503,
headers={"Retry-After": str(max(1, math.ceil(min(retries, default=1.0))))},
)
task = _live_snapshot_task
if task is None or task.done():
lease_owner = await asyncio.to_thread(
_live_snapshot_store.try_acquire_refresh,
due_sections,
lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0,
)
if lease_owner is not None:
latest = await asyncio.to_thread(_live_snapshot_store.load)
_apply_shared_live_state(latest)
due_sections = _due_live_sections(_live_snapshot_clock())
if not due_sections:
await asyncio.to_thread(
_live_snapshot_store.release_refresh, lease_owner
)
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
stale=any(
state != "fresh"
for state in (_live_snapshot_value or {}).get("sections", {}).values()
),
revalidating=False,
known_revisions=known_revisions,
)
)
task = _start_live_snapshot_refresh(due_sections, lease_owner)
else:
shared = await asyncio.to_thread(_live_snapshot_store.load)
_apply_shared_live_state(shared)
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
stale=bool(_due_live_sections(_live_snapshot_clock())),
revalidating=bool(shared.refreshing_sections),
known_revisions=known_revisions,
)
)
deadline = time.perf_counter() + CONTEXT_TIMEOUT_SECONDS
while time.perf_counter() < deadline:
await asyncio.sleep(0.01)
shared = await asyncio.to_thread(_live_snapshot_store.load)
if shared.value is not None:
_apply_shared_live_state(shared)
return JSONResponse(
_live_snapshot_payload(
shared.value,
stale=bool(_due_live_sections(_live_snapshot_clock())),
revalidating=bool(shared.refreshing_sections),
known_revisions=known_revisions,
)
)
return JSONResponse(
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
status_code=503,
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
)
assert task is not None
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
@@ -1780,6 +1918,32 @@ async def live_snapshot(
)
@app.get("/api/v1/live")
async def live_snapshot(
context_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
events_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
notifications_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
) -> JSONResponse:
try:
return await _live_snapshot_response(
context_revision=context_revision,
events_revision=events_revision,
notifications_revision=notifications_revision,
)
except (OSError, sqlite3.Error):
return JSONResponse(
{"error": "Gitea live snapshot state is temporarily unavailable"},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/events")
async def event_stream():
@@ -1822,7 +1986,6 @@ async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
)
except Exception:
return thread_id, False
_remove_notification_from_live_snapshot(thread_id)
return thread_id, True
@@ -1852,6 +2015,9 @@ async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
for thread_id, succeeded in [task.result()]
if succeeded
}
await _remove_notifications_from_live_snapshot(
[thread_id for thread_id in thread_ids if thread_id in succeeded_ids]
)
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
return JSONResponse(
{
@@ -1952,7 +2118,7 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
{"error": "The update could not be marked read. Please retry."},
status_code=503,
)
_remove_notification_from_live_snapshot(thread_id)
await _remove_notifications_from_live_snapshot([thread_id])
return JSONResponse({"id": thread_id, "status": "read"})
@@ -2066,6 +2232,28 @@ async def issue_handoff_candidates(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/mention-candidates")
async def repository_mention_candidates(
owner: str,
repo: str,
q: str = Query(min_length=2, max_length=39, pattern=r"^[A-Za-z0-9_.-]+$"),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.mention_candidates(
f"{owner}/{repo}", q.casefold(), limit=8
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "Teammate suggestions are temporarily unavailable."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
async def handoff_assigned_issue(
handoff: IssueHandoff,
+14 -4
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
from src.compression import _quality
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
@@ -51,11 +52,20 @@ async def service_worker() -> Response:
@router.get("/runtime-{digest}.js")
async def runtime_bundle(digest: str) -> Response:
async def runtime_bundle(digest: str, request: Request) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
FRONTEND_BUILD.runtime_bytes,
(
FRONTEND_BUILD.runtime_gzip_bytes
if accepts_gzip
else FRONTEND_BUILD.runtime_bytes
),
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
+72
View File
@@ -88,6 +88,61 @@ const controller = createCommentNext({{
}
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = Object.assign(new Error('offline'), {{status:503}});
let offline = false;
const controller = createCommentNext({{
queueKind: 'update-reply',
post: (item, body, operationId) => {{
calls.push({{post:[item.notification_id, body, operationId]}});
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
}},
canQueue: () => true,
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
}});
(async () => {{
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
offline = true;
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"posted": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 17},
"completed": True,
},
"saved": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{"post": [91, "Online reply", "reply-91-a"]},
{"complete": 91},
{"post": [91, "Offline reply", "reply-91-b"]},
{
"queue": {
"kind": "update-reply",
"notificationId": 91,
"body": "Offline reply",
"operationId": "reply-91-b",
}
},
{"complete": 91},
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
@@ -210,3 +265,20 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_current_today_update_offers_reply_and_next_without_marking_read():
html = await dashboard()
assert 'id="send-update-reply-next"' in html
assert '>Reply &amp; next</button>' in html
assert "setUpdateReplyNextVisibility();" in html
assert "const updateReplyNext = createCommentNext({" in html
assert "notificationReplier.saveDraft(item, '');" in html
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
assert "qs('#mark-update-read-next').click()" not in html
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-v78" in worker
+44 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import httpx
import pytest
from starlette.requests import Request
from src import main
from src.frontend_bundle import build_frontend
@@ -21,6 +22,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name == second.runtime_name
assert first.runtime_bytes == second.runtime_bytes
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 f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@@ -52,11 +55,46 @@ async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) <= 100 * 1024
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
async def test_runtime_route_returns_the_precomputed_gzip_representation():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"gzip")]}
)
response = await runtime_bundle(build.runtime_digest, request)
assert response.body == build.runtime_gzip_bytes
assert response.headers["content-encoding"] == "gzip"
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
assert response.headers["vary"] == "Accept-Encoding"
@pytest.mark.anyio
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
class UnexpectedDynamicCompression:
def __init__(self, *args, **kwargs):
raise AssertionError("immutable runtime entered dynamic compression")
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
@@ -108,9 +146,13 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest)
bundle = await runtime_bundle(build.runtime_digest, request)
worker = await service_worker()
assert build.runtime_name in html
+106
View File
@@ -1141,6 +1141,32 @@ async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
]
@pytest.mark.anyio
async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded():
async def handler(request):
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
return httpx.Response(200, json=[
{"login": "alexa", "full_name": "Alexa Dev"},
{"login": "buildbot", "full_name": "Alex Builder"},
{"login": "alexb", "full_name": "Alex Backup"},
{"login": "casey", "full_name": "Casey"},
{"login": "AL", "full_name": "Al"},
{"login": "bad space", "full_name": "Malformed"},
{"full_name": "Missing login"},
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2)
finally:
await gitea_proxy.stop_client()
assert result == [
{"login": "alexa", "name": "Alexa Dev"},
{"login": "alexb", "name": "Alex Backup"},
]
@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
requests = []
@@ -1255,6 +1281,31 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
]
@pytest.mark.anyio
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
calls = []
async def candidates(repository, query, *, limit):
calls.append((repository, query, limit))
return [{"login": "alex", "name": "Alexander"}]
monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
too_short = await client.get(
"/api/v1/repos/stackchain/api/mention-candidates?q=a"
)
response = await client.get(
"/api/v1/repos/stackchain/api/mention-candidates?q=AlEx"
)
assert too_short.status_code == 422
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == [{"login": "alex", "name": "Alexander"}]
assert calls == [("stackchain/api", "alex", 8)]
@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
async def handoff(_repository, _number, _recipient):
@@ -1543,6 +1594,25 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
async def handler(request):
requests.append((request.method, request.url.path, request.url.query.decode()))
if request.url.path.endswith("/dependencies"):
return httpx.Response(
200,
json=[
{
"number": 3,
"title": "Restore signing service",
"state": "open",
"html_url": "https://forge.example/stackchain/platform/issues/3",
"repository": {"full_name": "stackchain/platform"},
},
{
"number": 2,
"title": "Completed prerequisite",
"state": "closed",
"repository": {"full_name": "stackchain/platform"},
},
],
)
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
@@ -1580,6 +1650,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
assert requests == [
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"),
]
assert result == {
"repository": "stackchain/api",
@@ -1593,6 +1664,16 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"dependencies_available": True,
"dependencies": [
{
"repository": "stackchain/platform",
"number": 3,
"title": "Restore signing service",
"state": "open",
"url": "https://forge.example/stackchain/platform/issues/3",
}
],
"comments": [
{
"id": 81,
@@ -1619,6 +1700,31 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
}
@pytest.mark.anyio
async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable():
async def handler(request):
if request.url.path.endswith("/dependencies"):
return httpx.Response(503, json={"message": "upstream timeout"})
if request.url.path.endswith("/comments"):
return httpx.Response(200, json=[])
return httpx.Response(200, json={
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"assignees": [{"login": "timmy"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert result["title"] == "Fix mobile flow"
assert result["dependencies_available"] is False
assert result["dependencies"] == []
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
+1 -1
View File
@@ -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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/later-sync.js'" in source
+151 -19
View File
@@ -1,14 +1,21 @@
import asyncio
import json
import sqlite3
import httpx
import pytest
from src import main
from src.live_snapshot_store import LiveSnapshotStore
@pytest.fixture(autouse=True)
def reset_live_snapshot_task():
def reset_live_snapshot_task(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
@@ -131,7 +138,7 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
@pytest.mark.anyio
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch, tmp_path):
async def user():
return {"id": 1, "login": "timmy"}
@@ -144,8 +151,6 @@ async def test_live_snapshot_token_from_another_worker_never_suppresses_content(
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
worker_a = payload(await main.live_snapshot())
worker_a_token = worker_a["revisions"]["context"]
@@ -157,12 +162,15 @@ async def test_live_snapshot_token_from_another_worker_never_suppresses_content(
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
main._live_snapshot_store = LiveSnapshotStore(
tmp_path / "other-worker.sqlite3", clock=lambda: main._live_snapshot_clock()
)
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
assert worker_a_token == "worker-a.1"
assert worker_b["revisions"]["context"] == "worker-b.1"
assert worker_a_token.endswith(".1")
assert worker_b["revisions"]["context"].endswith(".1")
assert worker_b["revisions"]["context"] != worker_a_token
assert worker_b["context"]["user"]["login"] == "timmy"
@@ -302,7 +310,10 @@ async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(m
first = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
for _ in range(100):
if user_calls:
break
await asyncio.to_thread(lambda: None)
assert user_calls == 1
release.set()
@@ -332,7 +343,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
"sections": {},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
first = payload(await main.live_snapshot())
@@ -373,13 +384,14 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
stale = payload(await main.live_snapshot())
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await asyncio.gather(main._live_snapshot_task, return_exceptions=True)
degraded = payload(await main.live_snapshot())
assert stale["freshness"]["revalidating"] is True
@@ -421,13 +433,14 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await main._live_snapshot_task
result = payload(await main.live_snapshot())
assert result["context"] == {"generation": 2}
@@ -470,7 +483,7 @@ async def test_notification_cooldown_does_not_stop_due_work_and_activity_refresh
raise ConnectionError("notifications unavailable")
return []
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", work)
monkeypatch.setattr(main, "issues", empty_work)
@@ -537,20 +550,19 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await main._live_snapshot_task
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
first = asyncio.create_task(main.live_snapshot())
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
await asyncio.sleep(0)
assert retry_started.is_set()
await asyncio.wait_for(retry_started.wait(), timeout=1.0)
assert builds == 3
assert payload(await first)["freshness"]["revalidating"] is True
assert payload(await second)["freshness"]["revalidating"] is True
@@ -640,3 +652,123 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.anyio
async def test_cold_refresh_cooldown_remains_a_retryable_503(monkeypatch):
now = 100.0
async def unavailable(_sections=None):
raise ConnectionError("private upstream failure")
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", unavailable)
first = await main.live_snapshot()
second = await main.live_snapshot()
assert first.status_code == 503
assert second.status_code == 503
assert payload(second) == {
"error": "Gitea live snapshot is temporarily unavailable"
}
@pytest.mark.anyio
async def test_cancelled_refresh_releases_shared_lease(monkeypatch):
started = asyncio.Event()
async def blocked(_sections=None):
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(main, "_build_live_snapshot", blocked)
request = asyncio.create_task(main.live_snapshot())
await started.wait()
assert main._live_snapshot_task is not None
main._live_snapshot_task.cancel()
await asyncio.gather(request, main._live_snapshot_task, return_exceptions=True)
replacement = main._live_snapshot_store.try_acquire_refresh(
{"context"}, lease_seconds=1
)
assert replacement is not None
@pytest.mark.anyio
async def test_shared_store_outage_returns_retryable_503(monkeypatch):
def unavailable():
raise sqlite3.OperationalError("private database detail")
monkeypatch.setattr(main._live_snapshot_store, "load", unavailable)
response = await main.live_snapshot()
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert payload(response) == {
"error": "Gitea live snapshot state is temporarily unavailable"
}
@pytest.mark.anyio
async def test_independent_workers_reuse_shared_live_snapshot(monkeypatch, tmp_path):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
path = tmp_path / "worker-shared.sqlite3"
monkeypatch.setattr(main, "_live_snapshot_store", LiveSnapshotStore(path, clock=lambda: 100.0))
first = payload(await main.live_snapshot())
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_section_created_at = {section: None for section in main.LIVE_SNAPSHOT_SECTIONS}
main._live_snapshot_store = LiveSnapshotStore(path, clock=lambda: 100.0)
second = payload(await main.live_snapshot())
assert calls == 1
assert second["context"] == first["context"]
assert second["revisions"] == first["revisions"]
@pytest.mark.anyio
async def test_live_snapshot_persists_reboot_stable_wall_timestamps(monkeypatch, tmp_path):
epoch = 1_700_000_000.0
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: epoch, raising=False)
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "wall.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
response = await main.live_snapshot()
assert response.status_code == 200
assert set(main._live_snapshot_store.load().created_at.values()) == {epoch}
+157
View File
@@ -0,0 +1,157 @@
import threading
import os
import pytest
from src import live_snapshot_store
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
path = tmp_path / "live.sqlite3"
first = LiveSnapshotStore(path, clock=lambda: 100.0)
second = LiveSnapshotStore(path, clock=lambda: 100.0)
barrier = threading.Barrier(2)
results = []
def acquire(store):
barrier.wait()
results.append(store.try_acquire_refresh({"context", "events"}, lease_seconds=5))
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sum(result is not None for result in results) == 1
def test_snapshot_and_shared_revisions_publish_atomically(tmp_path):
path = tmp_path / "live.sqlite3"
writer = LiveSnapshotStore(path, clock=lambda: 100.0)
reader = LiveSnapshotStore(path, clock=lambda: 100.0)
lease = writer.try_acquire_refresh(set(("context", "events", "notifications")), lease_seconds=5)
assert lease is not None
state = writer.publish_refresh(
lease,
value={
"context": {"generation": 1},
"events": [],
"notifications": [],
"sections": {section: "fresh" for section in ("context", "events", "notifications")},
},
created_at={section: 100.0 for section in ("context", "events", "notifications")},
failure_count={section: 0 for section in ("context", "events", "notifications")},
retry_at={section: None for section in ("context", "events", "notifications")},
changed_sections=set(("context", "events", "notifications")),
)
observed = reader.load()
assert observed.value == state.value
assert observed.revisions == {"context": 1, "events": 1, "notifications": 1}
assert observed.refreshing_sections == set()
with pytest.raises(RefreshLeaseLost):
writer.publish_refresh(
lease,
value={"context": {"generation": 2}},
created_at=observed.created_at,
failure_count=observed.failure_count,
retry_at=observed.retry_at,
changed_sections={"context"},
)
def test_expired_refresh_lease_can_be_recovered(tmp_path):
now = 100.0
path = tmp_path / "live.sqlite3"
abandoned = LiveSnapshotStore(path, clock=lambda: now)
recovery = LiveSnapshotStore(path, clock=lambda: now)
first = abandoned.try_acquire_refresh({"context"}, lease_seconds=5)
assert first is not None
assert recovery.try_acquire_refresh({"context"}, lease_seconds=5) is None
now = 106.0
second = recovery.try_acquire_refresh({"context"}, lease_seconds=5)
assert second is not None
assert second != first
def test_default_store_clock_uses_reboot_stable_wall_time(tmp_path, monkeypatch):
epoch = 1_700_000_000.0
monkeypatch.setattr(live_snapshot_store.time, "time", lambda: epoch)
store = LiveSnapshotStore(tmp_path / "live.sqlite3")
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
assert lease is not None
assert store.load().lease_expires_at == epoch + 5
def test_store_is_private_and_does_not_persist_upstream_token(tmp_path, monkeypatch):
token = "gitea-super-secret-token-material"
monkeypatch.setenv("GITEA_TOKEN", token)
path = tmp_path / "private-state" / "live.sqlite3"
LiveSnapshotStore(path)
assert os.stat(path).st_mode & 0o777 == 0o600
assert os.stat(path.parent).st_mode & 0o777 == 0o700
assert token.encode() not in path.read_bytes()
def test_remove_notifications_filters_batch_with_one_revision_advance(tmp_path):
store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
lease = store.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
metadata = {section: None for section in ("context", "events", "notifications")}
published = store.publish_refresh(
lease,
value={
"notifications": [{"id": 7}, {"id": 8}, {"id": 9}],
"sections": {"notifications": "fresh"},
},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
removed = store.remove_notifications([7, 9, 7])
assert removed.value["notifications"] == [{"id": 8}]
assert removed.revisions["notifications"] == published.revisions["notifications"] + 1
def test_read_notification_filter_is_shared_with_future_publications(tmp_path):
path = tmp_path / "live.sqlite3"
first = LiveSnapshotStore(path, clock=lambda: 100.0)
second = LiveSnapshotStore(path, clock=lambda: 100.0)
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
metadata = {section: None for section in ("context", "events", "notifications")}
first.publish_refresh(
lease,
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
removed = second.remove_notification(7)
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
republished = first.publish_refresh(
lease,
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
assert removed.value["notifications"] == [{"id": 8}]
assert republished.value["notifications"] == [{"id": 8}]
+1 -1
View File
@@ -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-v72" in worker
assert "stackchain-dashboard-shell-v78" in worker
+85
View File
@@ -0,0 +1,85 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MENTIONS = ROOT / "frontend" / "mention-composer.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_active_mention_replaces_only_the_token_at_the_caret():
script = f"""
const mentions=require({json.dumps(str(MENTIONS))});
const text='Thanks @al for this; keep @casey informed';
const caret=text.indexOf(' for');
const active=mentions.activeMention(text, caret);
const inserted=mentions.insertMention(text, active, 'alex');
process.stdout.write(JSON.stringify({{active,inserted}}));
"""
result = run_node(script)
assert result["active"] == {"start": 7, "end": 10, "query": "al"}
assert result["inserted"] == {
"value": "Thanks @alex for this; keep @casey informed",
"caret": 13,
}
def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate():
script = f"""
const mentions=require({json.dumps(str(MENTIONS))});
class Element {{
constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }}
addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }}
dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }}
replaceChildren(...children) {{ this.children=children; }}
appendChild(child) {{ this.children.push(child); }}
setAttribute(name, value) {{ this.attrs[name]=String(value); }}
removeAttribute(name) {{ delete this.attrs[name]; }}
setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }}
focus() {{ this.focused=true; }}
}}
const textarea=new Element(), listbox=new Element(), status=new Element();
const pending=[];
const controller=mentions.create({{
textarea,listbox,status,getRepository:()=> 'stackchain/api',
loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})),
setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}},
createOption:()=>new Element(),
}});
controller.start();
(async()=>{{
textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve();
pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve();
const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}};
textarea.dispatch('keydown', {{key:'ArrowDown'}});
const enter=textarea.dispatch('keydown', {{key:'Enter'}});
process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = run_node(script)
assert result["before"] == {
"queries": ["al", "alex"],
"options": ["alex"],
"expanded": "true",
}
assert result["value"] == "Ping @alex "
assert result["caret"] == 11
assert result["focused"] is True
assert result["prevented"] is True
assert result["hidden"] is True
+19 -1
View File
@@ -35,4 +35,22 @@ 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-v72" in worker
assert "stackchain-dashboard-shell-v78" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mention-composer.js"></script>' in html
for composer in ("issue-comment", "pull-comment", "update-reply"):
assert f'id="{composer}-mentions"' in html
assert f'id="{composer}-mention-status"' in html
assert f"textarea:qs('#{composer}')" in dashboard
assert f"listbox:qs('#{composer}-mentions')" in dashboard
assert dashboard.count("createMentionComposer({") == 3
assert "mention-candidates?q=" in dashboard
assert ".mention-options" in css
assert "min-height:44px" in css
assert "max-width:100%" in css
+2 -1
View File
@@ -180,7 +180,8 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen()" in html
assert "workSession.reopen(item)" in html
assert "runTodayTransition('continue')" in html
@pytest.mark.anyio
+106 -15
View File
@@ -1648,7 +1648,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume()" in html
assert "workSession.resume(item)" in html
assert "runTodayTransition('resume')" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@@ -1662,7 +1663,7 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
assert "qs('#start-work-session').addEventListener('click'" in html
assert "document.querySelectorAll('[data-work-session-previous]')" in html
assert "document.querySelectorAll('[data-work-session-next]')" in html
assert 'workSession.complete();' in html
assert "runTodayTransition('complete')" in html
assert 'workSession.reconcile();' in html
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
assert opener in html
@@ -1678,12 +1679,50 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_
"qs('#close-pull-sheet').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
assert "if (workSession.active()) workSession.complete();" in close_handler
assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
@pytest.mark.anyio
async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition():
html = await dashboard()
assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html
release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split(
"qs('#load-issue-handoff').addEventListener", 1
)[0]
assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler
assert "await completeOwnershipExitToday(releasing)" in release_handler
assert "paintMyWork(lastContextSnapshot);" in release_handler
assert release_handler.index("await issueController.release") < release_handler.index(
"await completeOwnershipExitToday(releasing)"
)
assert release_handler.index("buildMyWork.removeIssue") < release_handler.index(
"await completeOwnershipExitToday(releasing)"
)
@pytest.mark.anyio
async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition():
html = await dashboard()
assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html
handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split(
"qs('#close-issue').addEventListener", 1
)[0]
assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler
assert "await completeOwnershipExitToday(handingOff)" in handoff_handler
assert "paintMyWork(lastContextSnapshot);" in handoff_handler
assert handoff_handler.index("await issueController.handoff") < handoff_handler.index(
"await completeOwnershipExitToday(handingOff)"
)
assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index(
"await completeOwnershipExitToday(handingOff)"
)
@@ -1697,13 +1736,13 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
assert "const continuingSession = workSession.active();" in merge_handler
assert "if (workSession.active()) workSession.complete();" in merge_handler
assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
assert "merged. Next work item opened." in merge_handler
assert merge_handler.index("await pullController.merge") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
@@ -1730,7 +1769,7 @@ async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
assert "lastMyWork = lastMyWork.filter" in success_path
assert "workSession.complete()" in success_path
assert "runTodayTransition('complete')" in success_path
assert "lastMyWork = lastMyWork.filter" not in failure_path
assert "workSession.complete()" not in failure_path
assert "The pull request remains in My Work; refresh and retry." in failure_path
@@ -3949,12 +3988,45 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
assert 'data-old-position="1"' in output["html"]
assert 'data-new-position="1"' in output["html"]
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old">1</span><span class="review-line-number new"></span></span>' in output["html"]
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old"></span><span class="review-line-number new">1</span></span>' in output["html"]
assert '<span class="review-line-code">-old &lt;token&gt;</span>' in output["html"]
assert '<span class="review-line-code">+new &amp; safe</span>' in output["html"]
assert 'aria-label="Comment on src/&lt;api&gt;.py line 1"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
}};
const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
const initial = phone.snapshot();
const disabled = phone.setWrapped(false);
const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
stored: values.get('stackchain.review-wrap.v1')}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"initial": {"wrapped": True, "explicit": False},
"disabled": {"wrapped": False, "explicit": True},
"restoredPhone": {"wrapped": False, "explicit": True},
"restoredDesktop": {"wrapped": False, "explicit": True},
"stored": "false",
}
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -3972,14 +4044,14 @@ process.stdout.write(JSON.stringify(rows));
assert json.loads(result.stdout) == [
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
{"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
]
@@ -4206,6 +4278,25 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-diff' in html and 'overflow-x:auto' in html
@pytest.mark.anyio
async def test_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
html = await dashboard()
assert 'id="review-wrap-lines"' in html
assert 'aria-pressed="false"' in html
assert 'aria-controls="review-files"' in html
assert "createReviewController.createWrapPreference" in html
assert "matchMedia('(max-width: 600px)').matches" in html
assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
assert "wrapPreference.setWrapped" in html
assert '.review-display-tools button' in html and 'min-height:44px' in html
assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html
@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
html = await dashboard()
+69
View File
@@ -5,6 +5,17 @@ import httpx
import pytest
from src import gitea_proxy, main
from src.live_snapshot_store import LiveSnapshotStore
@pytest.fixture(autouse=True)
def isolated_live_snapshot_store(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
monkeypatch.setattr(main, "_read_notification_ids", set())
@pytest.mark.anyio
@@ -50,6 +61,37 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
async def mark(_thread_id):
return None
calls = []
class BlockingStore:
def remove_notification(self, thread_id):
calls.append([thread_id])
time.sleep(0.08)
def remove_notifications(self, thread_ids):
calls.append(list(thread_ids))
time.sleep(0.08)
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", BlockingStore())
monkeypatch.setattr(main, "_live_snapshot_value", None)
started_at = asyncio.get_running_loop().time()
request = asyncio.create_task(main.read_notification(42))
await asyncio.sleep(0.01)
heartbeat_delay = asyncio.get_running_loop().time() - started_at
response = await request
assert heartbeat_delay < 0.05
assert response.status_code == 200
assert calls == [[42]]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
@@ -78,6 +120,33 @@ async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_updates_shared_snapshot_once_for_successes(monkeypatch):
async def mark(thread_id):
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
calls = []
class RecordingStore:
def remove_notifications(self, thread_ids):
calls.append(tuple(sorted(thread_ids)))
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", RecordingStore())
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
response = await main.read_notifications(main.NotificationReadBatch(ids=[42, 43, 44]))
assert response.status_code == 200
assert calls == [(42, 44)]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
active = 0
+65 -1
View File
@@ -126,6 +126,64 @@ process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:pla
}
def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
planner.open([], [item(2), item(3)]);
const preview = createPlanTodayPreview({{
planner,
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
}});
preview.open(item(2));
preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}});
const blockedState = preview.snapshot();
const blocked = preview.close({{add:true}});
const overridden = preview.close({{add:true, override:true}});
preview.open(item(3));
preview.setDependencies({{available:false, dependencies:[]}});
const unavailableState = preview.snapshot();
const unavailable = preview.close({{add:true}});
const unavailableOverride = preview.close({{add:true, override:true}});
process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}}));
"""
assert run_node(script) == {
"blockedState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"},
"trigger": None,
"scroll": 0,
"dependencies_available": True,
"dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}],
"requires_override": True,
},
"blocked": "blocked",
"overridden": "added",
"unavailableState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"},
"trigger": None,
"scroll": 0,
"dependencies_available": False,
"dependencies": [],
"requires_override": True,
},
"unavailable": "dependencies-unavailable",
"unavailableOverride": "added",
"planner": {
"open": True,
"ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"],
"count": 2,
"limit": 5,
},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
@@ -150,6 +208,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="add-plan-preview"' in html
assert "taskOverlayHistory.open('plan-today-preview')" in html
assert "planTodayPreview.close({ add:true })" in html
assert 'id="issue-blockers"' in html
assert "planTodayPreview.setDependencies({" in html
assert "Add blocked item anyway & back" in html
assert "Blocker status unavailable" in html
assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html
assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html
assert "Preview unavailable offline" in html
@@ -168,6 +232,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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+18 -9
View File
@@ -122,16 +122,23 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v78" 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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -139,7 +146,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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -147,14 +154,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-v72" in source
assert "stackchain-dashboard-shell-v78" 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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -163,21 +170,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-v72" in source
assert "stackchain-dashboard-shell-v78" 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-v72" in source
assert "stackchain-dashboard-shell-v78" 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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -358,7 +365,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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -397,6 +404,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
@@ -424,6 +432,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
"/dashboard/static/background-issue-sync.js",
}
+237
View File
@@ -0,0 +1,237 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
READINESS = ROOT / "frontend" / "today-readiness.js"
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_readiness_opens_ready_target_without_inspecting_the_rest_of_the_queue():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const inspections = [];
const item = (number, kind='issue') => ({{kind, repository:'stackchain/dashboard', number, title:'Item ' + number}});
const readiness = createTodayReadiness({{
inspect: async candidate => {{ inspections.push(candidate.number); return {{available:true, dependencies:[]}}; }},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status]),
}});
readiness.run('start', [item(1), item(2), item(3)]).then(result =>
process.stdout.write(JSON.stringify({{result, calls, inspections}}))
);
"""
assert json.loads(run_node(script)) == {
"result": "opened",
"calls": [["open", "start", 1]],
"inspections": [1],
}
def test_readiness_checks_fallbacks_concurrently_but_preserves_queue_order():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number}}));
const starts = [];
const resolvers = {{}};
const readiness = createTodayReadiness({{
inspect: candidate => {{
starts.push(candidate.number);
if (candidate.number === 1) return Promise.resolve({{available:true, dependencies:[{{state:'open'}}]}});
return new Promise(resolve => {{ resolvers[candidate.number] = resolve; }});
}},
onOpen: () => undefined,
onGate: state => {{ globalThis.gated = state.nextReady.number; }},
}});
(async () => {{
const running = readiness.run('start', items);
await new Promise(resolve => setImmediate(resolve));
const startedTogether = starts.slice();
if (resolvers[3]) resolvers[3]({{available:true, dependencies:[]}});
resolvers[2]({{available:true, dependencies:[]}});
await new Promise(resolve => setImmediate(resolve));
if (!startedTogether.includes(3)) resolvers[3]({{available:true, dependencies:[]}});
const result = await running;
process.stdout.write(JSON.stringify({{result, startedTogether, gated:globalThis.gated}}));
}})();
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"startedTogether": [1, 2, 3],
"gated": 2,
}
def test_readiness_gates_blocked_target_and_offers_earliest_ready_without_reordering():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number, title:'Issue ' + number}}));
const readiness = createTodayReadiness({{
inspect: async candidate => candidate.number === 1
? {{available:true, dependencies:[{{repository:'r', number:99, title:'Prerequisite', state:'open', url:'https://forge.example/r/issues/99'}}]}}
: {{available:true, dependencies:[]}},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status, state.target.number, state.nextReady.number, state.items.map(item => item.number)]),
}});
readiness.run('resume', items, items[0]).then(async result => {{
const opened = readiness.startNextReady();
process.stdout.write(JSON.stringify({{result, opened, calls, order:items.map(item => item.number)}}));
}});
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"opened": True,
"calls": [
["gate", "blocked", 1, 2, [1, 2, 3]],
["open", "resume", 2],
],
"order": [1, 2, 3],
}
def test_readiness_previews_only_a_current_blocker_without_closing_or_reordering_gate():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const previews = [];
const items = [1, 2].map(number => ({{kind:'issue', repository:'r', number}}));
const blocker = {{repository:'r', number:99, title:'Prerequisite', state:'open'}};
const readiness = createTodayReadiness({{
inspect: async candidate => candidate.number === 1
? {{available:true, dependencies:[blocker]}}
: {{available:true, dependencies:[]}},
onPreview: candidate => previews.push(candidate.number),
}});
(async () => {{
await readiness.run('start', items, items[0]);
const accepted = readiness.previewBlocker(blocker);
const rejected = readiness.previewBlocker({{repository:'r', number:100}});
const snapshot = readiness.snapshot();
process.stdout.write(JSON.stringify({{
accepted, rejected, previews,
open:snapshot.status === 'blocked',
target:snapshot.target.number,
order:snapshot.items.map(item => item.number),
}}));
}})();
"""
assert json.loads(run_node(script)) == {
"accepted": True,
"rejected": False,
"previews": [99],
"open": True,
"target": 1,
"order": [1, 2],
}
def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const target = {{kind:'issue', repository:'r', number:1}};
let available = false;
const readiness = createTodayReadiness({{
inspect: async () => available ? {{available:true, dependencies:[]}} : {{available:false, dependencies:[]}},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status]),
}});
readiness.run('next', [target], target).then(async result => {{
available = true;
const retried = await readiness.retry();
available = false;
await readiness.run('continue', [target], target);
const overridden = readiness.workAnyway();
process.stdout.write(JSON.stringify({{result, retried, overridden, calls}}));
}});
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"retried": "opened",
"overridden": True,
"calls": [
["gate", "unknown"],
["open", "next", 1],
["gate", "unknown"],
["open", "continue", 1],
],
}
def test_readiness_treats_non_issue_work_as_runnable_without_dependency_io():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
let inspections = 0;
const pull = {{kind:'pull', repository:'r', number:7}};
const readiness = createTodayReadiness({{
inspect: async () => {{ inspections += 1; throw new Error('not expected'); }},
onOpen: () => undefined,
onGate: () => undefined,
}});
readiness.run('start', [pull]).then(result =>
process.stdout.write(JSON.stringify({{result, inspections}}))
);
"""
assert json.loads(run_node(script)) == {"result": "opened", "inspections": 0}
@pytest.mark.anyio
async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime():
html = await dashboard()
assert '<script src="static/today-readiness.js"></script>' in html
assert 'id="today-readiness-sheet"' in html
assert 'id="today-readiness-next"' in html
assert 'id="today-readiness-anyway"' in html
assert 'id="today-readiness-retry"' in html
assert 'id="today-readiness-plan"' in html
assert "createTodayReadiness({" in html
assert "runTodayTransition('start'" in html
assert "runTodayTransition('resume'" in html
assert "runTodayTransition('continue'" in html
assert "runTodayTransition('next'" in html
assert "runTodayTransition('complete'" in html
@pytest.mark.anyio
async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate():
html = await dashboard()
assert 'data-today-blocker-index="' in html
assert "todayReadiness.previewBlocker(blocker)" in html
assert "searchPreview.open({ ...blocker, kind:'issue' })" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "suspendTodayReadiness()" in html
assert "todayReadiness.cancel()" in html
assert "todayReadinessBlockerFocus" in html
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v78';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
def test_mobile_readiness_gate_has_touch_safe_wrapping_actions():
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".today-readiness-actions button" in css
assert "min-height:44px" in css
assert ".today-readiness-item" in css
assert "overflow-wrap:anywhere" in css
assert "env(safe-area-inset-bottom)" in css
assert ".today-readiness-blocker" in css
assert "min-height:44px" in css
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
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-v72" in source
assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/today-sync.js'" in source