Compare commits

...
Author SHA1 Message Date
rockachopa 7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m9s
CI / release-candidate (push) Successful in 7s
2026-08-25 07:26:00 +00:00
timmy 869543e992 feat: batch mobile review suggestions (Closes #1384)
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 07:14:54 +00:00
timmy c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 7s
2026-08-25 06:19:21 +00:00
timmy ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 06:08:22 +00:00
timmy 8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m3s
CI / release-candidate (push) Successful in 7s
2026-08-25 05:21:13 +00:00
timmy b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 05:09:32 +00:00
rockachopa fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
2026-08-25 04:21:41 +00:00
timmy 22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 24s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:57:29 +00:00
rockachopa 9d97b2e84a Merge pull request 'Commit bounded review feedback fixes from mobile' (#1377) from timmy/1376-mobile-review-feedback-fix into main
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m29s
CI / release-candidate (push) Successful in 6s
2026-08-25 03:17:59 +00:00
timmy 502fcb30f5 feat: commit review feedback fixes from mobile (Closes #1376)
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:08:53 +00:00
rockachopa b541c707fc Merge pull request 'Bind background push delivery to upstream operator identity' (#1375) from timmy/1374-bind-push-session-identity into main
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m40s
CI / release-candidate (push) Successful in 6s
2026-08-25 02:16:17 +00:00
timmy b40a44fc45 fix: bind background push to operator identity (Closes #1374)
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 02:06:25 +00:00
rockachopa d1adbe72d6 Merge pull request 'Bind operator sessions to upstream Gitea identity' (#1373) from timmy/1372-bind-sessions-upstream-identity into main
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m48s
CI / release-candidate (push) Successful in 7s
2026-08-25 01:33:53 +00:00
timmy d6e6cf2e67 feat: bind operator sessions to upstream identity (Closes #1372)
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 01:12:42 +00:00
rockachopa 53706f18ab Merge pull request 'Prepare conflict-safe rollback pull requests from failed releases' (#1371) from timmy/1370-conflict-safe-release-rollback into main
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 6s
2026-08-25 00:31:40 +00:00
timmy f0be3a44a6 ci: retry transient mobile navigation journey
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 00:21:58 +00:00
timmy b753cdd922 feat: prepare conflict-safe release rollback pulls (Closes #1370)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 23:55:33 +00:00
rockachopa 400534f446 Merge pull request 'Complete secure source-branch deletion in operator mode' (#1369) from timmy/1368-secure-source-branch-deletion into main
CI / lint (push) Successful in 3m15s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m13s
CI / release-candidate (push) Successful in 6s
Closes #1368
2026-08-24 22:40:22 +00:00
timmy a43ecac94b fix: authorize secure source branch deletion (Closes #1368)
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 22:30:56 +00:00
rockachopa d6993c40fb Merge pull request 'Recover failed post-merge checks from mobile release receipts' (#1367) from timmy/1366-post-merge-release-check-recovery into main
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m18s
CI / release-candidate (push) Successful in 7s
2026-08-24 21:45:51 +00:00
timmy 966116e5ee feat: recover failed post-merge checks (Closes #1366)
CI / lint (pull_request) Successful in 3m16s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 21:36:16 +00:00
timmy d4ddac07ab Merge pull request 'Delete merged source branches safely from mobile receipts' (#1365) from timmy/1364-delete-merged-source-branches into main
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m37s
CI / release-candidate (push) Successful in 6s
2026-08-24 20:54:32 +00:00
timmy b6684bb7ba feat: label source branch deletion security events
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 20:44:42 +00:00
timmy 07f5770aa8 feat: delete merged source branches safely (Closes #1364)
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 5m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 20:29:32 +00:00
timmy f3876d359d Merge pull request 'feat: Create pull requests from pushed branches on mobile' (#1363) from timmy/1362-create-pull-requests-from-pushed-branches-on-mob into main
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m22s
CI / release-candidate (push) Successful in 7s
2026-08-24 19:52:48 +00:00
timmy e5b9833096 feat: create mobile pull requests from pushed branches (Closes #1362)
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 5m16s
2026-08-24 19:31:16 +00:00
rockachopa ae98564d9b Merge pull request 'Update outdated authored pull branches from mobile' (#1361) from timmy/1360-update-authored-pull-branch into main
CI / lint (push) Successful in 3m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m23s
CI / release-candidate (push) Successful in 6s
2026-08-24 18:12:25 +00:00
timmy b85fe4a0a3 feat: update authored pull branches from mobile (Closes #1360)
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 18:02:23 +00:00
rockachopa a9becd079f Merge pull request 'Recover closed authored pull requests from mobile Search' (#1359) from timmy/1358-reopen-closed-authored-pull-search into main
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 17:22:36 +00:00
timmy 99750d44f5 feat: recover closed authored pulls from search (Closes #1358)
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 17:12:51 +00:00
rockachopa e4d0cc9115 Merge pull request 'Close obsolete authored pull requests from mobile with recovery' (#1357) from timmy/1356-close-authored-pull-recovery into main
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 7s
2026-08-24 16:28:53 +00:00
timmy 77f22f3cd5 feat: close authored pulls with recovery (Closes #1356)
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 16:18:24 +00:00
timmy fde3ba21ca Merge pull request 'Recover failed CI checks from the mobile pull workspace' (#1355) from timmy/1354-mobile-ci-recovery into main
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m55s
CI / release-candidate (push) Successful in 6s
2026-08-24 15:16:09 +00:00
timmy 8cb92c6858 feat: recover failed CI checks from mobile (Closes #1354)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 15:06:01 +00:00
rockachopa 3e7a6c81fc Merge pull request 'feat: CI browser journey: Insights overlay does not inert page header at 320px' (#1353) from timmy/1352-ci-browser-journey-insights-overlay-does-not-ine into main
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 14:12:39 +00:00
timmy 084c0b5732 Fix mobile overlay inert ownership (#1352)
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:49:48 +00:00
rockachopa c73fbf8379 Merge pull request 'Load pull workspaces through one authorized snapshot' (#1351) from timmy/1350-authorized-pull-snapshot into main
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m27s
CI / release-candidate (push) Has been skipped
2026-08-24 13:24:47 +00:00
timmy b47ed5d86e perf: reuse authorized pull snapshots (Closes #1350)
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:14:48 +00:00
timmy f0f415c65d Merge pull request 'Complete authored pull request workspace access' (#1349) from timmy/1348-authored-pull-workspace into main
CI / lint (push) Successful in 3m9s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 6s
2026-08-24 12:20:21 +00:00
timmy 2102b0e5da feat: complete authored pull workspace access (Closes #1348)
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 12:10:49 +00:00
rockachopa 050390d6f6 Merge pull request 'Surface authored pull requests in My Work' (#1347) from timmy/1346-authored-pull-queue into main
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m16s
CI / release-candidate (push) Successful in 6s
2026-08-24 11:24:14 +00:00
64 changed files with 7413 additions and 227 deletions

No files matched your search

+13 -10
View File
@@ -7,12 +7,15 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: pip install -r requirements-audit.txt
@@ -23,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
@@ -33,7 +36,7 @@ jobs:
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: release-bundle
path: dist/
@@ -44,11 +47,11 @@ jobs:
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@@ -57,7 +60,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
run: python3 -m pytest tests/e2e -q
release-candidate:
runs-on: ubuntu-latest
@@ -66,9 +69,9 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download tested release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
+13 -1
View File
@@ -18,7 +18,19 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
requests, merge assigned pull requests, and submit pull-request reviews. For authored
pulls, several non-overlapping one-line reviewer suggestions in the same bounded UTF-8
file can be staged during the mobile feedback pass, reviewed together, and committed as
one race-guarded branch update. The batch preserves the original blob identity, applies
replacements from the bottom up, rejects overlapping or stale lines, and verifies the
advanced pull head and final file before reporting success; suggestions in another file
wait for the next batch so Gitea's single-file contents API cannot produce partial commits.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
default-branch content still matches the failed merge, and creates one atomic commit on a
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
+95 -2
View File
@@ -1,4 +1,8 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
@@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
@@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
return controller.isOwned(comment) ?
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
+203
View File
@@ -0,0 +1,203 @@
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
let operationId = createId();
let current = {
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
title:'', body:'', draft:true,
};
try {
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
if (saved && typeof saved === 'object') {
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
if (typeof saved[field] === 'string') current[field] = saved[field];
}
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
}
} catch (_) {}
function snapshot() {
return JSON.parse(JSON.stringify(current));
}
function persist() {
storage?.setItem?.(storageKey, JSON.stringify({
repository:current.repository, head:current.head, base:current.base,
title:current.title, body:current.body, draft:current.draft,
}));
}
async function selectRepository(repository) {
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
const branches = Array.isArray(options?.branches) ? options.branches : [];
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
current = {
...current, repository, branches, base,
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
};
persist();
return snapshot();
}
function update(values) {
current = {...current, ...values};
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
}
persist();
return snapshot();
}
async function submit() {
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
method:'POST',
headers:{'Idempotency-Key':operationId},
body:{
head:current.head, base:current.base, title:current.title, body:current.body,
draft:current.draft, expected_head_sha:current.expected_head_sha,
},
});
operationId = createId();
return result;
}
return {
state:snapshot,
selectRepository,
update,
submit,
};
}
function createBinding({controller, render, status, onCreated}) {
return {
async repositoryChanged(repository) {
status('Loading branches…');
try {
const state = await controller.selectRepository(repository);
render(state);
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
return state;
} catch (error) {
status(error.message || 'Branches could not be loaded.');
throw error;
}
},
changed(values) {
const state = controller.update(values);
render(state);
return state;
},
async submit() {
status('Creating pull request…');
try {
const result = await controller.submit();
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
onCreated(result);
return result;
} catch (error) {
status(error.message || 'The pull request could not be created. Your draft is safe.');
throw error;
}
},
};
}
async function bindDashboard() {
const qs = selector => document.querySelector(selector);
const switchButton = qs('#switch-to-create-pull');
switchButton.disabled = true;
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
const identity = await identityResponse?.json().catch(()=>({}));
if (!identityResponse?.ok || !identity?.login) {
switchButton.title = 'Account identity is unavailable.';
return null;
}
const login = identity.login;
const root = qs('#create-pull-sheet');
const repository = qs('#create-pull-repository');
const head = qs('#create-pull-head');
const base = qs('#create-pull-base');
const title = qs('#create-pull-title');
const body = qs('#create-pull-body');
const submit = qs('#submit-create-pull');
const request = async (url, options) => {
const response = await fetch(url, options ? {
method:options.method,
headers:{'Content-Type':'application/json', ...options.headers},
body:JSON.stringify(options.body),
} : undefined);
const payload = await response.json().catch(()=>({}));
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
return payload;
};
const controller = createController({request, storage:localStorage, login});
const setOptions = (select, branches) => {
select.replaceChildren(...branches.map(branch => {
const option = document.createElement('option');
option.value = option.textContent = branch.name;
return option;
}));
};
const render = state => {
setOptions(head, state.branches);
setOptions(base, state.branches);
head.value = state.head;
base.value = state.base;
head.disabled = base.disabled = !state.branches.length;
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
};
const binding = createBinding({
controller, render,
status:message=>{ qs('#create-pull-status').textContent = message; },
onCreated:result=>{
root.hidden = true;
qs('main').inert = false;
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
location.reload();
},
});
const update = () => binding.changed({
head:head.value, base:base.value, title:title.value, body:body.value,
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
});
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
[head,base,title,body].forEach(field => field.addEventListener('input', update));
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
update();
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
}));
qs('#create-pull-form').addEventListener('submit', event => {
event.preventDefault();
update();
binding.submit().catch(()=>{});
});
const close = () => {
root.hidden = true;
qs('main').inert = false;
qs('#new-issue').focus();
};
qs('#cancel-create-pull').addEventListener('click', close);
switchButton.addEventListener('click', () => {
qs('#cancel-new-issue').click();
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
const option = document.createElement('option');
option.value = source.value;
option.textContent = source.textContent;
return option;
}));
root.hidden = false;
qs('main').inert = true;
qs('#cancel-create-pull').focus();
});
root.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
});
switchButton.disabled = false;
return binding;
}
const cp = {createController, createBinding, b:bindDashboard};
if (typeof module !== 'undefined') module.exports = cp;
else cp.b();
+74 -3
View File
@@ -35,7 +35,20 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
.release-watchlist-item > button { width:auto; min-width:88px; }
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
.release-watchlist-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:min(240px,46vw); }
.release-watchlist-actions > button { min-height:44px; width:100%; }
.release-branch-cleanup-status { color:#cbd5e1; }
.release-failure-summary { display:grid; gap:8px; min-width:0; margin-top:8px; padding-top:8px; border-top:1px solid #334155; overflow-x:hidden; }
.release-failure-summary > button { min-height:44px; }
.release-failure-recovery { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #7f1d1d; border-radius:8px; background:#180f17; overflow-x:hidden; }
.release-failure-recovery pre { box-sizing:border-box; max-width:100%; max-height:32dvh; margin:0; padding:10px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:#07101d; }
.release-failure-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
.release-failure-actions > button, .release-failure-actions > a { min-height:44px; width:100%; }
@media (max-width:359px) {
.release-receipt-actions, .release-watchlist-actions, .release-failure-actions { grid-template-columns:1fr; }
.release-watchlist-item { grid-template-columns:1fr; }
.release-watchlist-actions { min-width:0; width:100%; }
}
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
@@ -133,6 +146,12 @@ button:hover { filter: brightness(1.15); }
.issue-comment { min-width:0; overflow-wrap:anywhere; }
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
.comment-owned-actions button { min-height:44px; min-width:72px; }
.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; }
.comment-reactions > button { min-height:44px; min-width:72px; }
.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; }
.comment-reaction-menu[hidden] { display:none; }
.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; }
.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; }
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
@@ -746,6 +765,21 @@ textarea { resize: vertical; min-height: 120px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.pull-feedback-code-actions, .pull-feedback-file-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.pull-feedback-code-actions button, .pull-feedback-file-actions button { min-height:44px; }
.pull-feedback-suggestion-preview { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #31577f; border-radius:10px; background:#07101e; }
.pull-feedback-suggestion-preview[hidden] { display:none; }
.pull-feedback-suggestion-preview h5 { margin:0; }
.pull-feedback-suggestion-change { display:grid; gap:8px; min-width:0; }
.pull-feedback-suggestion-change > div { min-width:0; }
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
.pull-feedback-batch-review[hidden] { display:none; }
.pull-feedback-batch-review h5 { margin:0; }
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
@@ -792,6 +826,17 @@ textarea { resize: vertical; min-height: 120px; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
.ci-check-inspect { min-height:44px; white-space:nowrap; }
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
.ci-check-recovery[hidden] { display:none; }
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
.ci-check-recovery-actions button { min-height:44px; }
.pull-branch-update { max-width:100%; min-width:0; margin:10px 0; padding:12px; border:1px solid #60a5fa; border-radius:10px; overflow-wrap:anywhere; }
.pull-branch-update h3 { margin:0 0 6px; }
.pull-branch-update button { width:100%; min-height:44px; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
@@ -958,8 +1003,13 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
.pull-lifecycle { border-color:#b45353; }
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
.pull-close-receipt h3 { margin-top:0; }
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
@@ -1281,6 +1331,14 @@ textarea { resize: vertical; min-height: 120px; }
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
.pull-feedback-pass p { white-space:pre-wrap; }
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
#make-pull-feedback-fix { width:100%; margin-top:8px; border-color:#4ade80; }
.pull-feedback-file-editor { display:grid; min-width:0; gap:8px; margin-top:10px; padding:10px; border:1px solid #4ade80; border-radius:10px; overflow-x:hidden; }
.pull-feedback-file-editor[hidden] { display:none; }
.pull-feedback-file-editor h5 { margin:0; overflow-wrap:anywhere; }
#pull-feedback-file-content { min-height:36dvh; resize:vertical; font:13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size:2; white-space:pre; overflow:auto; }
#pull-feedback-commit-message { box-sizing:border-box; width:100%; min-width:0; }
.pull-feedback-file-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
@media (max-width:359px) { .pull-feedback-file-actions { grid-template-columns:1fr; } }
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
@@ -1523,3 +1581,16 @@ textarea { resize: vertical; min-height: 120px; }
transition: transform 120ms ease, border-color 120ms ease;
}
}
.create-pull-sheet{position:fixed;inset:0;z-index:75;background:rgba(3,7,18,.72);display:grid;place-items:end center;padding:16px}
.create-pull-sheet[hidden]{display:none}
.create-pull-panel{width:min(100%,680px);max-height:calc(100dvh - 32px);overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}
.create-pull-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.create-pull-header h3{margin:2px 0 0}
.create-pull-panel form,.create-pull-panel label{display:grid;gap:6px}
.create-pull-panel form{gap:14px}
.create-pull-branches{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.create-pull-mode{display:flex;gap:18px;border:1px solid var(--border);border-radius:12px;padding:10px 12px}
.create-pull-mode label{display:flex;align-items:center;gap:7px}
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
+33 -21
View File
@@ -368,6 +368,7 @@
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
@@ -386,23 +387,24 @@
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = '';
let releaseReceipt = null;
function attachReleaseReceipt() {
if (releaseReceipt) return releaseReceipt;
releaseReceipt = createReleaseReceipt({
let rR = null;
function rRC() {
if (rR) return rR;
rR = createReleaseReceipt({
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
releaseNode:qs('#release-receipt-link'),
listNode:qs('#release-watchlist'),
openPull:openPullSheet,
});
releaseReceipt.bind();
rR.bind();
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
}));
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
return releaseReceipt;
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
return rR;
}
const completedFiledReview = createCompletedFiledReview({
storage: localStorage,
@@ -1279,7 +1281,7 @@
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
const continuing = workSession.checkpointed(item);
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
@@ -1288,9 +1290,8 @@
paintMyWork(lastContextSnapshot);
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
rRC();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
if (!wrapPreference) {
wrapPreference = createReviewController.createWrapPreference({
@@ -1306,7 +1307,7 @@
try {
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
} catch (_error) { return; }
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
@@ -4605,6 +4606,7 @@
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
@@ -5745,11 +5747,7 @@
planButton.hidden = !searchWeekPlan.eligible(detail);
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
planButton.disabled = searchWeekPlan.pending();
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewStart(detail, state, startButton);
renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = {
@@ -5771,9 +5769,7 @@
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
fetchReview:searchSubscription.review,
mutate:(detail,action)=>fetchReviewJson(
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
),
mutate:searchPreviewMutation(fetchReviewJson),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
@@ -5867,6 +5863,14 @@
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const recoverSearchPull = createSearchAuthoredPullRecovery({
confirm,
reopen:detail => searchPreview.reopenPull(detail),
refresh:load,
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
open:item => openRoutedWork(item, qs('#start-search-result')),
unavailable:message => qs('#search-preview-status').textContent = message,
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@@ -6215,6 +6219,14 @@
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.authored_pull_reopenable) {
try {
await recoverSearchPull(detail);
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
return;
}
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';
@@ -7357,7 +7369,7 @@
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
releaseReceipt.capture(merging, mergeResult);
rR.capture(merging, mergeResult);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
+98
View File
@@ -1281,6 +1281,7 @@
<section class="create-issue-panel">
<div class="create-issue-header">
<h3 id="create-issue-heading">Capture work</h3>
<button id="switch-to-create-pull" type="button">Pull request</button>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
@@ -1722,6 +1723,11 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<section class="pull-branch-update" id="pull-branch-update" aria-labelledby="pull-branch-update-heading" hidden>
<h3 id="pull-branch-update-heading">Bring branch up to date</h3>
<p class="small" id="pull-branch-update-status" role="status" aria-live="assertive"></p>
<button id="update-pull-branch" type="button">Update branch</button>
</section>
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
<h3 id="pull-reviewer-heading">Reviewer status</h3>
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
@@ -1735,6 +1741,46 @@
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<div class="pull-feedback-code-actions">
<button id="review-pull-feedback-suggestion" type="button" hidden>Review suggested change</button>
<button id="make-pull-feedback-fix" type="button" hidden>Edit full file</button>
</div>
<section class="pull-feedback-suggestion-preview" id="pull-feedback-suggestion-preview" aria-labelledby="pull-feedback-suggestion-heading" hidden>
<h5 id="pull-feedback-suggestion-heading">Suggested change</h5>
<div class="pull-feedback-suggestion-change">
<div><span class="small">Current line</span><pre id="pull-feedback-suggestion-before"></pre></div>
<div><span class="small">Suggested replacement</span><pre id="pull-feedback-suggestion-after"></pre></div>
</div>
<label for="pull-feedback-suggestion-message">Commit message</label>
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
</div>
</section>
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
<ul id="pull-feedback-batch-items"></ul>
<label for="pull-feedback-batch-message">Commit message</label>
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
</div>
</section>
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
<label for="pull-feedback-file-content">File content</label>
<textarea id="pull-feedback-file-content" spellcheck="false" maxlength="131072"></textarea>
<label for="pull-feedback-commit-message">Commit message</label>
<input id="pull-feedback-commit-message" maxlength="120" placeholder="fix: address review feedback">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-fix" type="button" hidden>Cancel change</button>
<button id="commit-pull-feedback-fix" type="button" hidden>Commit to pull branch</button>
</div>
</section>
<fieldset class="pull-feedback-dispositions">
<legend>Disposition</legend>
<button type="button" data-feedback-disposition="addressed">Addressed</button>
@@ -1756,6 +1802,15 @@
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="pull-check-list"></div>
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
<div class="ci-check-recovery-actions">
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
<button id="close-pull-check-recovery" type="button">Close details</button>
</div>
</section>
</details>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
@@ -1783,6 +1838,20 @@
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
<summary>Close pull request</summary>
<div>
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
</div>
</details>
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>
@@ -2143,6 +2212,34 @@
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
</nav>
<div class="create-pull-sheet" id="create-pull-sheet" role="dialog" aria-modal="true" aria-labelledby="create-pull-heading" hidden>
<section class="create-pull-panel">
<header class="create-pull-header">
<div><span class="eyebrow">New</span><h3 id="create-pull-heading">Pull request</h3></div>
<button id="cancel-create-pull" type="button">Cancel</button>
</header>
<p class="small">Open a pull request from a branch already pushed to Gitea.</p>
<form id="create-pull-form">
<label for="create-pull-repository">Repository
<select id="create-pull-repository" required><option value="">Choose repository</option></select>
</label>
<div class="create-pull-branches">
<label for="create-pull-head">Source branch<select id="create-pull-head" required disabled></select></label>
<label for="create-pull-base">Base branch<select id="create-pull-base" required disabled></select></label>
</div>
<output id="create-pull-head-receipt" class="small" aria-live="polite"></output>
<label for="create-pull-title">Title<input id="create-pull-title" maxlength="255" required autocomplete="off" /></label>
<label for="create-pull-body">Context <span class="small">Optional · Markdown</span><textarea id="create-pull-body" maxlength="10000" rows="5"></textarea></label>
<fieldset class="create-pull-mode"><legend>Start as</legend>
<label><input type="radio" name="create-pull-mode" value="draft" checked /> Draft</label>
<label><input type="radio" name="create-pull-mode" value="ready" /> Ready for review</label>
</fieldset>
<p id="create-pull-status" role="status" aria-live="polite"></p>
<button id="submit-create-pull" type="submit" disabled>Create draft pull request</button>
</form>
</section>
</div>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script>
@@ -2231,6 +2328,7 @@
<script src="static/voice-issue-capture.js"></script>
<script src="static/voice-conversation-capture.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-pull-sheet.js"></script>
<script src="static/mobile-create-issue-nav.js"></script>
<script src="static/create-and-start.js"></script>
<script src="static/assign-and-start.js"></script>
+1 -1
View File
@@ -119,7 +119,7 @@
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
+15 -1
View File
@@ -8,12 +8,25 @@
let active = false;
let previous = '#/my-work';
let retries = 0;
let backgroundInert = null;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map(o.backgrounds.map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
@@ -28,7 +41,8 @@
attr(o.root, 'aria-modal', active ? 'true' : null);
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
o.root.inert = !active && o.mediaQuery.matches;
o.backgrounds.forEach(element => { element.inert = active; });
if (active) containBackground();
else releaseBackground();
o.dock.hidden = active;
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
if (active) o.closeButton.focus();
+592 -6
View File
@@ -187,17 +187,28 @@ function ownershipExitMessage(item, action, transitionResult) {
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function resetOwnershipControls(doc, item, checkpointed) {
function applyOwnershipCapabilities(doc, capabilities = null) {
const ownership = doc.querySelector('#pull-ownership');
const assigned = capabilities?.assigned !== false;
ownership.hidden = !assigned;
ownership.inert = !assigned;
return assigned;
}
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
const qs = selector => doc.querySelector(selector);
const assigned = applyOwnershipCapabilities(doc, capabilities);
resetLifecycleControls(doc, null);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = false;
qs('#release-pull').disabled = false;
qs('#load-pull-handoff').disabled = !assigned;
qs('#release-pull').disabled = !assigned;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#pull-handoff-status').textContent = assigned ?
'Load teammates to transfer ownership.' : 'Assignment controls are not available for this pull request.';
qs('#edit-pull-content').hidden = true;
qs('#pull-edit-form').hidden = true;
qs('#pull-edit-status').textContent = '';
@@ -220,6 +231,46 @@ function resetReviewRequestControls(doc, detail) {
qs('#pull-feedback-status').textContent = '';
}
function resetBranchUpdateControls(doc, detail) {
const panel = doc.querySelector('#pull-branch-update');
if (!panel) return;
const eligible = detail?.capabilities?.authored === true && detail?.state === 'open' &&
detail?.draft !== true && detail?.merged !== true && detail?.mergeable === false && detail?.head_sha;
panel.hidden = !eligible;
doc.querySelector('#update-pull-branch').disabled = false;
doc.querySelector('#pull-branch-update-status').textContent = eligible ?
'Merge the latest base branch into this pull request. Reviews and checks may run again.' : '';
}
function bindBranchUpdateControls(doc, controller, getSelected, getDetail) {
const button = doc.querySelector('#update-pull-branch');
if (!button || button.dataset.bound === 'true') return;
button.dataset.bound = 'true';
button.addEventListener('click', async () => {
const item = getSelected?.();
const detail = getDetail?.();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Update ' + item.key + ' with the latest base branch? Reviews and checks may run again.')) return;
button.disabled = true;
const status = doc.querySelector('#pull-branch-update-status');
status.textContent = 'Updating branch…';
try {
const result = await controller.updateBranch(item, detail.head_sha);
if (getSelected?.() !== item || getDetail?.() !== detail) return;
Object.assign(detail, result, { mergeable:null, ci_state:'pending', reviewers:[] });
status.textContent = 'Branch updated. Refreshing checks and review state…';
doc.querySelector('#pull-review-retry')?.click();
status.textContent = 'Branch updated to ' + result.head_sha.slice(0, 8) + '. Review the refreshed checks before merging.';
button.hidden = true;
doc.querySelector('#refresh-pull-checks')?.focus();
} catch (error) {
status.textContent = error.message + ' Reload the latest pull request state before retrying.';
button.disabled = false;
button.focus();
}
});
}
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-reviewers');
@@ -227,6 +278,9 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
load.dataset.reviewRequestBound = 'true';
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
resetBranchUpdateControls(doc, detail);
applyOwnershipCapabilities(doc, detail?.capabilities);
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
@@ -359,11 +413,49 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
const suggestedFile = (file, suggestion) => {
const line = Number(suggestion?.line);
if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null;
const trailingNewline = file.content.endsWith('\n');
const lines = file.content.split('\n');
if (trailingNewline) lines.pop();
if (line > lines.length) return null;
const before = lines[line - 1];
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
const renderBatchButton = () => {
const count = active?.batch ? Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
const button = qs('#review-pull-feedback-batch');
button.hidden = count < 2;
button.textContent = 'Review fixes (' + count + ')';
};
const rebuildBatchFile = entry => {
const trailingNewline = entry.file.content.endsWith('\n');
const lines = entry.file.content.split('\n');
if (trailingNewline) lines.pop();
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
for (const suggestion of ordered) {
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
}
return lines.join('\n') + (trailingNewline ? '\n' : '');
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
@@ -378,6 +470,16 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' &&
active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha &&
comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string';
qs('#review-pull-feedback-suggestion').hidden = !canSuggest;
qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false;
if (canSuggest && globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
}
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
@@ -418,13 +520,234 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
qs('#review-pull-feedback-suggestion').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
return;
}
const button = qs('#review-pull-feedback-suggestion');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current line for review…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
const change = suggestedFile(file, comment.suggestion);
if (!change) throw new Error('The suggested line is no longer available.');
active.suggestion = { file, content:change.content, commentId:comment.id };
qs('#pull-feedback-suggestion-before').textContent = change.before;
qs('#pull-feedback-suggestion-after').textContent = change.after;
qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion';
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.';
button.focus();
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => {
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
if (!active?.suggestion) return;
const pending = active.suggestion;
const path = pending.file.path;
const stagedPath = Object.keys(active.batch)[0];
if (stagedPath && stagedPath !== path) {
qs('#pull-feedback-status').textContent = 'Commit the staged file before batching suggestions from another file.';
return;
}
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
if (entry.file.blob_sha !== pending.file.blob_sha ||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
const comment = current();
entry.suggestions.push({
line:Number(comment.suggestion.line), content:comment.suggestion.content,
commentId:pending.commentId,
});
entry.content = rebuildBatchFile(entry);
if (entry.content === null) {
entry.suggestions.pop();
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
return;
}
active.batch[path] = entry;
clearSuggestion();
renderBatchButton();
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
qs('#next-pull-feedback').focus();
});
qs('#review-pull-feedback-batch').addEventListener('click', () => {
if (!active?.batch) return;
const list = qs('#pull-feedback-batch-items');
list.replaceChildren(...Object.values(active.batch).map(entry => {
const item = doc.createElement('li');
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
return item;
}));
qs('#pull-feedback-batch-review').hidden = false;
qs('#cancel-pull-feedback-batch').hidden = false;
qs('#commit-pull-feedback-batch').hidden = false;
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
qs('#commit-pull-feedback-batch').focus();
});
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
qs('#review-pull-feedback-batch').focus();
});
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
if (!active?.batch) return;
const entries = Object.values(active.batch);
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
const message = qs('#pull-feedback-batch-message').value.trim();
if (count < 2 || !message) return;
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-batch');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
try {
const result = await controller.commitFeedbackBatch(active.item, {
file:{
path:entries[0].file.path, content:entries[0].content,
expectedBlobSha:entries[0].file.blob_sha,
},
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.batch = {};
save();
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
renderBatchButton();
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
if (!message) {
qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return;
const button = qs('#apply-pull-feedback-suggestion');
button.disabled = true;
const pending = active.suggestion;
qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:pending.file.path, content:pending.content, message,
expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
clearSuggestion();
save();
qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
const button = qs('#make-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current pull file…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
active.file = file;
clearSuggestion();
qs('#pull-feedback-file-path').textContent = file.path;
qs('#pull-feedback-file-content').value = file.content;
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
qs('#pull-feedback-file-editor').hidden = false;
qs('#cancel-pull-feedback-fix').hidden = false;
qs('#commit-pull-feedback-fix').hidden = false;
qs('#pull-feedback-status').textContent = 'Edit the bounded file, then commit it to this pull branch.';
qs('#pull-feedback-file-content').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Retry without losing your feedback progress.';
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-fix').addEventListener('click', () => {
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
qs('#make-pull-feedback-fix').focus();
});
qs('#commit-pull-feedback-fix').addEventListener('click', async () => {
if (!active?.file) return;
const content = qs('#pull-feedback-file-content').value;
const message = qs('#pull-feedback-commit-message').value.trim();
if (!message || content === active.file.content) {
qs('#pull-feedback-status').textContent = 'Change the file and enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this change to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying the new pull head…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:active.file.path, content, message,
expectedHeadSha:active.file.head_sha, expectedBlobSha:active.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.file = null;
save();
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
qs('#pull-feedback-status').textContent = 'Code change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Draft kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
@@ -564,7 +887,10 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindBranchUpdateControls(doc, controller, getSelected, getDetail);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
bindCheckRecovery(doc, controller, getSelected, getDetail);
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
@@ -630,18 +956,21 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
});
}
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
let ownershipRequest = null;
let branchUpdateRequest = null;
let candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
let feedbackFixRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
@@ -668,6 +997,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}
return {
onState,
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
@@ -704,6 +1034,52 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
feedbackRequests.set(key, request);
return request;
},
loadFeedbackFile(item, path, expectedHeadSha) {
return fetchJson(pathFor(item) + '/feedback-file?path=' + encodeURIComponent(path) +
'&expected_head_sha=' + encodeURIComponent(expectedHeadSha), {
headers:{ Accept:'application/json' },
});
},
commitFeedbackFix(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-file', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
path:draft.path, content:draft.content, message:draft.message,
expected_head_sha:draft.expectedHeadSha,
expected_blob_sha:draft.expectedBlobSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || result.path !== draft.path) {
throw new Error('The feedback fix was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
commitFeedbackBatch(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
file:{
path:draft.file.path, content:draft.file.content,
expected_blob_sha:draft.file.expectedBlobSha,
},
suggestion_count:draft.suggestionCount,
message:draft.message, expected_head_sha:draft.expectedHeadSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
result.path !== draft.file.path || result.suggestion_count !== draft.suggestionCount) {
throw new Error('The suggestion batch was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
@@ -711,6 +1087,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
encodeURIComponent(expectedHeadSha), {
headers: { Accept: 'application/json' },
});
},
retryCheck(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
});
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
@@ -764,6 +1155,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { readyMutation = null; });
return readyMutation;
},
transition(item, expectedHeadSha, action) {
if (lifecycleMutation) return lifecycleMutation;
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
const expectedState = action === 'close' ? 'closed' : 'open';
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== expectedState || result?.merged === true) {
throw new Error('Pull request state change was not confirmed.');
}
return result;
}).finally(() => { lifecycleMutation = null; });
return lifecycleMutation;
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
@@ -927,9 +1334,185 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { mergeRequest = null; });
return mergeRequest;
},
updateBranch(item, expectedHeadSha) {
if (branchUpdateRequest) return branchUpdateRequest;
branchUpdateRequest = fetchJson(pathFor(item) + '/update-branch', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.head_sha || result.head_sha === expectedHeadSha) {
throw new Error('Branch update was not confirmed.');
}
return result;
}).finally(() => { branchUpdateRequest = null; });
return branchUpdateRequest;
},
};
}
function resetLifecycleControls(doc, detail) {
const authored = detail?.capabilities?.authored === true;
const lifecycle = doc.querySelector('#pull-lifecycle');
if (!lifecycle) return;
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-reopen-status').textContent = '';
}
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
const close = doc.querySelector('#close-authored-pull');
const reopen = doc.querySelector('#reopen-authored-pull');
close.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
close.disabled = true;
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'close');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-lifecycle').hidden = true;
doc.querySelector('#pull-close-receipt').hidden = false;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-close-receipt-title').focus();
} catch (error) {
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
close.disabled = false;
close.focus();
}
});
reopen.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha) return;
reopen.disabled = true;
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'reopen');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle').hidden = false;
close.hidden = false;
close.disabled = false;
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
doc.querySelector('#pull-reopen-status').textContent = '';
close.focus();
} catch (error) {
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
reopen.disabled = false;
reopen.focus();
}
});
}
function bindCheckRecovery(
doc, controller, selectedPull, selectedDetail,
confirmRetry = message => globalThis.confirm(message)
) {
let current = null;
const get = selector => doc.querySelector(selector);
function reset({ restoreFocus = true } = {}) {
const recovery = current;
current = null;
get('#pull-check-recovery').hidden = true;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = '';
get('#retry-pull-check').disabled = true;
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
}
async function poll(recovery, attempt = 0) {
if (current !== recovery || selectedPull() !== recovery.item) return;
if (attempt >= 12) {
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
return;
}
await new Promise(resolve => setTimeout(resolve, 1500));
if (current !== recovery || selectedPull() !== recovery.item) return;
try {
const status = await controller.loadChecks(recovery.item);
if (current !== recovery || selectedPull() !== recovery.item) return;
if (status.head_sha !== recovery.headSha) {
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
return;
}
const check = (status.checks || []).find(candidate =>
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
);
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
get('#refresh-pull-checks').click();
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
return;
}
get('#pull-check-recovery-status').textContent = check.state === 'pending'
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
} catch (_error) {
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
}
return poll(recovery, attempt + 1);
}
get('#pull-check-list').addEventListener('click', async event => {
const trigger = event.target.closest('.ci-check-inspect');
const item = selectedPull();
const detail = selectedDetail();
if (!trigger || !item || !detail?.head_sha) return;
const runId = Number(trigger.dataset.runId);
const jobIndex = Number(trigger.dataset.jobIndex);
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
const recovery = {
item, headSha: detail.head_sha, runId, jobIndex, trigger,
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
};
current = recovery;
get('#pull-check-recovery').hidden = false;
get('#pull-check-recovery-title').textContent = recovery.name;
get('#pull-check-log').textContent = 'Loading failure details…';
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
get('#retry-pull-check').disabled = true;
get('#pull-check-recovery-title').focus();
try {
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
get('#retry-pull-check').disabled = false;
} catch (error) {
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = error.message;
}
});
get('#close-pull-check-recovery').addEventListener('click', () => reset());
get('#pull-checks').addEventListener('toggle', () => {
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
});
get('#retry-pull-check').addEventListener('click', async () => {
const recovery = current;
if (!recovery || selectedPull() !== recovery.item) return;
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
const button = get('#retry-pull-check');
button.disabled = true;
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
try {
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
poll(recovery);
} catch (error) {
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = error.message;
button.disabled = false;
button.focus();
}
});
return { reset };
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
@@ -937,6 +1520,8 @@ createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.bindLifecycleControls = bindLifecycleControls;
createPullSheet.resetLifecycleControls = resetLifecycleControls;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
@@ -946,4 +1531,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
createPullSheet.bindCheckRecovery = bindCheckRecovery;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+268 -5
View File
@@ -1,8 +1,11 @@
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
const limit = 12;
let entries = [];
let refreshing = null;
const recoveries = new Map();
const retrying = new Map();
const rollingBack = new Map();
let timer = null;
let bound = false;
documentRef ||= typeof document !== 'undefined' ? document : null;
@@ -62,6 +65,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
number: Number(item.number),
key: item.key || item.repository + '#' + item.number,
commit_sha: commitSha,
...(
mergeResult?.source_repository === item.repository
&& mergeResult?.source_branch
&& mergeResult?.source_head_sha
? {
source_branch: String(mergeResult.source_branch),
source_head_sha: String(mergeResult.source_head_sha),
cleanup: { state: 'available', message: 'Merged branch retained.' },
}
: {}
),
status: null,
captured_at: new Date().toISOString(),
};
@@ -81,10 +95,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
function summarize(payload) {
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
const failedChecks = checks.filter(check => ['failure', 'error'].includes(check.state));
const failing = failedChecks.map(check => check.name).filter(Boolean);
const failures = failedChecks.filter(check => check?.recovery).map(check => ({
name: check.name || 'Failed check',
description: check.description || '',
url: check.url || '',
recovery: check.recovery,
}));
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing, failures };
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
return { ...payload, label: 'Checks running', checks: pending };
}
@@ -121,18 +142,117 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
state.className = 'small';
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
copy.append(title, state);
if (entry.source_branch && entry.source_head_sha) {
const branch = document.createElement('span');
branch.className = 'small release-branch-cleanup-status';
branch.textContent = entry.cleanup?.state === 'deleted'
? 'Branch ' + entry.source_branch + ' · deleted'
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
copy.append(branch);
}
if (entry.rollback?.state === 'prepared') {
const rollbackStatus = document.createElement('span');
rollbackStatus.className = 'small release-rollback-status';
rollbackStatus.textContent = entry.rollback.message;
copy.append(rollbackStatus);
}
if (entry.status?.release?.url) {
const link = document.createElement('a');
link.href = entry.status.release.url;
link.textContent = 'Open release ' + entry.status.release.tag;
copy.append(link);
}
for (const failure of (entry.status?.failures || [])) {
const runId = failure.recovery?.run_id;
const jobIndex = failure.recovery?.job_index;
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) continue;
const key = identity(entry) + '@' + runId + ':' + jobIndex;
const summary = document.createElement('section');
summary.className = 'release-failure-summary';
const failureName = document.createElement('span');
failureName.className = 'small';
failureName.textContent = failure.name + (failure.description ? ' · ' + failure.description : '');
const review = document.createElement('button');
review.type = 'button';
review.textContent = 'Review failure';
review.setAttribute('aria-label', 'Review failed check ' + failure.name + ' for ' + entry.commit_sha.slice(0, 8));
review.addEventListener('click', () => reviewFailure(
entry.repository, entry.commit_sha, runId, jobIndex
).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
}));
summary.append(failureName, review);
const detail = recoveries.get(key);
if (detail) {
const recovery = document.createElement('div');
recovery.className = 'release-failure-recovery';
const sha = document.createElement('strong');
sha.textContent = 'Merge ' + entry.commit_sha.slice(0, 8);
const excerpt = document.createElement('pre');
excerpt.textContent = detail.excerpt || 'No log excerpt was returned.';
const actions = document.createElement('div');
actions.className = 'release-failure-actions';
if (failure.url) {
const job = document.createElement('a');
job.href = failure.url;
job.textContent = 'Open job';
actions.append(job);
}
const retry = document.createElement('button');
retry.type = 'button';
retry.textContent = retrying.has(key) ? 'Retrying…' : 'Retry failed check';
retry.disabled = retrying.has(key);
retry.addEventListener('click', () => retryFailure(
entry.repository, entry.commit_sha, runId, jobIndex
).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
render();
}));
actions.append(retry);
const rollback = document.createElement('button');
rollback.type = 'button';
rollback.textContent = entry.rollback?.state === 'prepared'
? 'Open rollback #' + entry.rollback.number
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
rollback.disabled = rollingBack.has(identity(entry));
rollback.setAttribute(
'aria-label',
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
);
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
render();
}));
actions.append(rollback);
recovery.append(sha, excerpt, actions);
summary.append(recovery);
}
copy.append(summary);
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Dismiss';
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
row.append(copy, button);
if (entry.source_branch && entry.cleanup?.state !== 'deleted') {
const actions = document.createElement('div');
actions.className = 'release-watchlist-actions';
const cleanup = document.createElement('button');
cleanup.type = 'button';
cleanup.textContent = entry.cleanup?.state === 'deleting' ? 'Deleting…' : 'Delete source branch';
cleanup.disabled = entry.cleanup?.state === 'deleting' || entry.cleanup?.state === 'advanced';
cleanup.setAttribute(
'aria-label',
'Delete merged source branch ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8),
);
cleanup.addEventListener('click', () => deleteBranch(entry.repository, entry.commit_sha).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error);
}));
actions.append(cleanup, button);
row.append(copy, actions);
} else {
row.append(copy, button);
}
return row;
});
listNode.replaceChildren(...rows);
@@ -168,6 +288,149 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
finally { refreshing = null; }
}
function recoveryPath(entry, runId, jobIndex) {
return 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number)
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha)
+ '/checks/' + encodeURIComponent(runId) + '/jobs/' + encodeURIComponent(jobIndex);
}
async function reviewFailure(repository, commitSha, runId, jobIndex) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) throw new Error('Tracked release is unavailable.');
const key = identity(entry) + '@' + runId + ':' + jobIndex;
const detail = await fetchJson(recoveryPath(entry, runId, jobIndex) + '/failure', {
headers: { Accept: 'application/json' },
});
if (detail?.commit_sha !== entry.commit_sha) {
throw new Error('Release failure evidence did not match the merged commit.');
}
recoveries.set(key, detail);
render();
return detail;
}
function retryFailure(repository, commitSha, runId, jobIndex) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
const key = identity(entry) + '@' + runId + ':' + jobIndex;
if (retrying.has(key)) return retrying.get(key);
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
const failure = entry.status?.failures?.find(value =>
value.recovery?.run_id === runId && value.recovery?.job_index === jobIndex
);
if (!approve('Retry ' + (failure?.name || 'failed release check') + ' for ' + commitSha.slice(0, 8) + '?')) {
return Promise.resolve(false);
}
const operation = fetchJson(recoveryPath(entry, runId, jobIndex) + '/retry', {
method: 'POST',
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.commit_sha !== entry.commit_sha) {
throw new Error('Release retry did not match the merged commit.');
}
entry.status = { ...entry.status, ci_state: 'pending', label: 'Checks running', checks: [], failures: [] };
recoveries.delete(key);
persist();
render();
schedule(0);
return true;
}).finally(() => retrying.delete(key));
retrying.set(key, operation);
return operation;
}
function prepareRollback(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
if (entry.rollback?.state === 'prepared') {
openPull?.(entry.rollback.pull);
return Promise.resolve(entry.rollback.pull);
}
const key = identity(entry);
if (rollingBack.has(key)) return rollingBack.get(key);
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
return Promise.resolve(false);
}
const operation = fetchJson(
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number)
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
},
},
).then(result => {
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
throw new Error('Rollback preparation did not match the failed release.');
}
const pull = {
...result,
kind: 'pull',
key: result.repository + '#' + result.number,
state: 'open',
};
entry.rollback = {
state: 'prepared',
number: result.number,
message: 'Draft rollback #' + result.number + ' is ready for review.',
pull,
};
persist();
render();
if (dialog?.open) dialog.close();
openPull?.(pull);
return result;
}).finally(() => rollingBack.delete(key));
rollingBack.set(key, operation);
render();
return operation;
}
async function deleteBranch(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
throw new Error('Source branch cleanup is unavailable.');
}
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
if (!approve('Delete ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8) + '?')) return false;
entry.cleanup = { state: 'deleting', message: 'Deleting source branch…' };
persist();
render();
try {
await fetchJson(
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number) + '/source-branch',
{
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
source_branch: entry.source_branch,
expected_head_sha: entry.source_head_sha,
}),
},
);
entry.cleanup = { state: 'deleted', message: 'Source branch deleted.' };
persist();
render();
return true;
} catch (error) {
entry.cleanup = {
state: error?.status === 409 ? 'advanced' : 'available',
message: error?.status === 409
? 'Source branch has newer commits and was retained.'
: 'Branch retained. Retry when connected.',
};
persist();
render();
throw error;
}
}
function dismiss(repository, commitSha) {
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
else entries = [];
@@ -195,7 +458,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
schedule();
}
return { capture, restore, refresh, dismiss, bind, fetchJson };
return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
+11 -1
View File
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
const recovery = check.recovery;
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
: '';
const actions = inspect || link
? '<div class="ci-check-actions">' + inspect + link + '</div>'
: '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
+37
View File
@@ -36,6 +36,16 @@
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewMutation = fetchJson => (detail, action) => {
if (action === 'reopen-pull') {
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
return fetchJson(path, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({expected_head_sha:detail.head_sha}),
});
}
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
};
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => {
@@ -102,6 +112,30 @@
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.renderSearchPreviewStart = (detail, state, button) => {
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
detail.authored_pull_reopenable === true;
const issue = detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
button.hidden = !(detail.reviewable || pull || issue);
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
button.disabled = state.status === 'claiming' || state.status === 'reopening';
};
root.createSearchAuthoredPullRecovery = o =>
async d => {
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
await o.reopen(d);
await o.refresh();
const item = o.find(d);
if (!item) {
o.unavailable('Reopened. Refresh My Work.');
return 'unavailable';
}
o.open(item);
return 'opened';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
@@ -548,6 +582,9 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
reopenPull(detail) {
return run('reopen-pull', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {
+2
View File
@@ -231,6 +231,8 @@
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
source_branch_deleted: 'Source branch deleted',
release_rollback_prepared: 'Release rollback prepared',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
+2 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v138';
const CACHE = 'stackchain-dashboard-shell-v139';
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;
@@ -245,6 +245,7 @@ const SHELL = [
BASE + 'static/voice-issue-capture.js',
BASE + 'static/voice-conversation-capture.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-pull-sheet.js',
BASE + 'static/mobile-create-issue-nav.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
+15 -4
View File
@@ -32,6 +32,8 @@ class Session:
csrf: str
expires_at: int
idle_expires_at: int | None = None
principal_id: int | None = None
principal_login: str | None = None
@dataclass(frozen=True)
@@ -145,6 +147,8 @@ def issue_session(
*,
device_label: str = "This device",
management_id: str | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
@@ -165,6 +169,8 @@ def issue_session(
session.expires_at,
device_label=device_label,
management_id=management_id,
principal_id=principal_id,
principal_login=principal_login,
)
return f"{encoded}.{signature}", session
@@ -211,6 +217,8 @@ def verify_session_with_reason(
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
principal_id=getattr(status, "principal_id", None),
principal_login=getattr(status, "principal_login", None),
)
)
@@ -244,11 +252,14 @@ async def managed_session_active(management_id: str) -> bool:
return status == "active"
async def managed_session_statuses(management_ids) -> dict[str, str]:
async def managed_session_statuses(
management_ids, *, expected_principal_id: int | None = None
) -> dict[str, str]:
options = {"idle_timeout_seconds": idle_timeout_seconds()}
if expected_principal_id is not None:
options["expected_principal_id"] = expected_principal_id
return await asyncio.to_thread(
_session_store().managed_statuses,
management_ids,
idle_timeout_seconds=idle_timeout_seconds(),
_session_store().managed_statuses, management_ids, **options
)
+1 -1
View File
@@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/comment-actions.js",),
"issue-capture": (
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
"push-notifications": ("static/push-notifications.js",),
+1064 -15
View File
File diff suppressed because it is too large. Load diff
+954 -29
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -8,7 +8,7 @@ API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def request_body_limit(method: str, path: str) -> int | None:
+39 -9
View File
@@ -18,10 +18,20 @@ class SessionStatus(str):
"""String-compatible status carrying the server-confirmed idle deadline."""
idle_expires_at: int | None
principal_id: int | None
principal_login: str | None
def __new__(cls, value: str, idle_expires_at: int | None = None):
def __new__(
cls,
value: str,
idle_expires_at: int | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
):
instance = super().__new__(cls, value)
instance.idle_expires_at = idle_expires_at
instance.principal_id = principal_id
instance.principal_login = principal_login
return instance
@@ -71,7 +81,9 @@ class SessionStore:
management_id TEXT,
device_label TEXT,
created_at INTEGER,
last_active_at INTEGER
last_active_at INTEGER,
principal_id INTEGER,
principal_login TEXT
)
"""
)
@@ -98,6 +110,8 @@ class SessionStore:
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
"principal_id": "INTEGER",
"principal_login": "TEXT",
}
for name, column_type in additions.items():
if name not in columns:
@@ -135,6 +149,8 @@ class SessionStore:
*,
device_label: str = "This device",
management_id: str | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
) -> None:
label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock())
@@ -145,8 +161,9 @@ class SessionStore:
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
"session_hash, expires_at, management_id, device_label, created_at, last_active_at, "
"principal_id, principal_login"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
@@ -154,6 +171,8 @@ class SessionStore:
label,
now,
now,
principal_id,
principal_login,
),
)
except (OSError, sqlite3.Error) as exc:
@@ -176,7 +195,8 @@ class SessionStore:
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"SELECT expires_at, last_active_at, principal_id, principal_login "
"FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
@@ -185,7 +205,11 @@ class SessionStore:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
missing_migrated_column = any(
f"no such column: {name}" in str(exc)
for name in ("last_active_at", "principal_id", "principal_login")
)
if not missing_migrated_column:
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
@@ -196,7 +220,7 @@ class SessionStore:
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
if idle_expires_at <= now:
return SessionStatus("idle", idle_expires_at)
return SessionStatus("active", idle_expires_at)
return SessionStatus("active", idle_expires_at, row[2], row[3])
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
@@ -279,7 +303,11 @@ class SessionStore:
)[management_id]
def managed_statuses(
self, management_ids, *, idle_timeout_seconds: int
self,
management_ids,
*,
idle_timeout_seconds: int,
expected_principal_id: int | None = None,
) -> dict[str, str]:
"""Return authorization states for durable device identifiers in one read."""
requested = set(management_ids)
@@ -290,7 +318,7 @@ class SessionStore:
with self._connect() as connection:
placeholders = ",".join("?" for _ in requested)
rows = connection.execute(
"SELECT management_id, expires_at, last_active_at "
"SELECT management_id, expires_at, last_active_at, principal_id "
f"FROM active_sessions WHERE management_id IN ({placeholders})",
tuple(requested),
).fetchall()
@@ -305,6 +333,8 @@ class SessionStore:
statuses[management_id] = "revoked"
elif row[1] + idle_timeout <= now:
statuses[management_id] = "idle"
elif expected_principal_id is not None and row[2] != expected_principal_id:
statuses[management_id] = "principal_mismatch"
else:
statuses[management_id] = "active"
return statuses
@@ -0,0 +1,85 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_commits_one_review_feedback_fix(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{"id": 99, "path": "src/api.py", "body": "Handle empty state.", "line": 12}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -12 +12 @@", "+return empty"]}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.fixCalls = [];
globalThis.fixItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.fixDetail = detail;
globalThis.fixController = createPullSheet({fetchJson: async (path, options = {}) => {
fixCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123', content:'return empty\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, fixController,
() => fixItem, () => fixDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#make-pull-feedback-fix").click()
page.locator("#pull-feedback-file-content").fill("return handled\n")
page.locator("#pull-feedback-commit-message").fill("fix: handle empty state")
page.locator("#commit-pull-feedback-fix").click()
page.wait_for_function("fixCalls.length === 2")
result = page.evaluate("""() => ({
calls:fixCalls, head:fixDetail.head_sha,
editorHidden:document.querySelector('#pull-feedback-file-editor').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["editorHidden"] is True
assert "committed" in result["status"].lower()
assert "path=src%2Fapi.py" in result["calls"][0]["path"]
body = result["calls"][1]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"return handled\\n"' in body
@@ -0,0 +1,108 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_stages_and_commits_two_same_file_suggestions_atomically(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [
{"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
"suggestion": {"line": 2, "content": " return empty_result()"}},
{"id": 100, "path": "src/api.py", "line": 3, "body": "Keep the result typed.",
"suggestion": {"line": 3, "content": " return typed_result()"}},
],
}],
"files": [
{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]},
],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.batchCalls = [];
globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.batchDetail = detail;
globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
batchCalls.push({path, options});
if ((options.method || 'GET') === 'GET') {
return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
content:'def lookup():\\n return None\\n return result\\n'};
}
return {path:'src/api.py', suggestion_count:2, previous_head_sha:'abc1234', head_sha:'def5678'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, batchController,
() => batchItem, () => batchDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 1")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
page.locator("#next-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 2")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
page.locator("#review-pull-feedback-batch").click()
assert page.locator("#pull-feedback-batch-review").is_visible()
assert page.locator("#pull-feedback-batch-items li").count() == 1
page.locator("#commit-pull-feedback-batch").click()
page.wait_for_function("batchCalls.length === 3")
result = page.evaluate("""() => ({
calls:batchCalls, head:batchDetail.head_sha,
reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["reviewHidden"] is True
assert "2 suggested changes committed" in result["status"].lower()
request = result["calls"][2]
assert request["path"].endswith("/feedback-batch")
assert request["options"]["method"] == "POST"
body = request["options"]["body"]
assert '"expected_head_sha":"abc1234"' in body
assert '"file":{"path":"src/api.py"' in body
assert '"suggestion_count":2' in body
assert 'return empty_result()' in body
assert 'return typed_result()' in body
@@ -0,0 +1,103 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_previews_and_applies_review_suggestion(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{
"id": 99, "path": "src/api.py", "line": 2,
"body": "Return the typed empty result.\n```suggestion\n return empty_result()\n```",
"suggestion": {"line": 2, "content": " return empty_result()"},
}, {
"id": 100, "path": "src/api.py", "line": 3,
"body": "Keep the result typed.",
}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.suggestionCalls = [];
globalThis.suggestionItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.suggestionDetail = detail;
globalThis.suggestionController = createPullSheet({fetchJson: async (path, options = {}) => {
suggestionCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123',
content:'def lookup():\\n return None\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, suggestionController,
() => suggestionItem, () => suggestionDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
assert page.locator("#pull-feedback-file-editor").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_visible()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 1")
assert page.locator("#pull-feedback-suggestion-preview").is_visible()
assert page.locator("#pull-feedback-suggestion-before").text_content() == " return None"
assert page.locator("#pull-feedback-suggestion-after").text_content() == " return empty_result()"
assert page.locator("#pull-feedback-file-editor").is_hidden()
page.locator("#next-pull-feedback").click()
assert page.locator("#pull-feedback-suggestion-preview").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_hidden()
page.locator("#previous-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 2")
page.locator("#apply-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 3")
result = page.evaluate("""() => ({
calls:suggestionCalls, head:suggestionDetail.head_sha,
previewHidden:document.querySelector('#pull-feedback-suggestion-preview').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["previewHidden"] is True
assert "suggested change committed" in result["status"].lower()
body = result["calls"][2]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"def lookup():\\n return empty_result()\\n"' in body
@@ -26,6 +26,7 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
page.add_script_tag(path=FRONTEND / "my-work.js")
page.add_script_tag(path=FRONTEND / "work-route.js")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.add_script_tag(path=FRONTEND / "pull-sheet.js")
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
row = page.locator('[data-mobile-queue="authored"]')
@@ -63,6 +64,25 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
"opened": "opened",
"count": 1,
}
capabilities = page.evaluate("""() => {
const item = {key:'stackchain/dashboard#41'};
createPullSheet.resetOwnershipControls(
document, item, () => false, {authored:true, assigned:false}
);
const ownership = document.querySelector('#pull-ownership');
return {
hidden: ownership.hidden,
inert: ownership.inert,
releaseDisabled: document.querySelector('#release-pull').disabled,
handoffDisabled: document.querySelector('#load-pull-handoff').disabled,
};
}""")
assert capabilities == {
"hidden": True,
"inert": True,
"releaseDisabled": True,
"handoffDisabled": True,
}
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
@@ -0,0 +1,87 @@
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
pytestmark = pytest.mark.skipif(
os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1",
reason="release browser journey is opt-in",
)
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_closes_obsolete_pull_then_recovers_it(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.on("dialog", lambda dialog: dialog.accept())
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""() => {
window.lifecycleCalls = [];
window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
window.lifecycleDetail = {
number:7, title:'Obsolete experiment', author:'timmy', state:'open',
merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false},
};
const controller = createPullSheet({
storage:null,
fetchJson:async (path, options) => {
lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)});
const state = path.endsWith('/close') ? 'closed' : 'open';
return {repository:'stackchain/api', number:7, title:'Obsolete experiment',
head_sha:'abc1234', state, merged:false};
},
});
createPullSheet.bindLifecycleControls(
document, controller, () => lifecycleItem, () => lifecycleDetail,
result => { lifecycleDetail = {...lifecycleDetail, ...result}; }
);
createPullSheet.resetLifecycleControls(document, lifecycleDetail);
document.querySelector('#pull-sheet').classList.add('open');
}"""
)
page.locator("#pull-lifecycle summary").click()
close = page.get_by_role("button", name="Close pull request")
before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
close.click()
page.get_by_text("Pull request closed.").wait_for()
reopen = page.get_by_role("button", name="Reopen pull request")
receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})")
reopen.click()
page.get_by_text("Pull request reopened.").wait_for()
metrics = page.evaluate(
"""() => ({
calls:lifecycleCalls,
state:lifecycleDetail.state,
closeHidden:document.querySelector('#close-authored-pull').hidden,
receiptHidden:document.querySelector('#pull-close-receipt').hidden,
focused:document.activeElement === document.querySelector('#close-authored-pull'),
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
})"""
)
browser.close()
assert before["hidden"] is False
assert before["height"] >= 44
assert receipt["height"] >= 44
assert receipt["hidden"] is False
assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"]
assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"])
assert metrics["state"] == "open"
assert metrics["closeHidden"] is False
assert metrics["receiptHidden"] is True
assert metrics["focused"] is True
assert metrics["scrollWidth"] <= metrics["clientWidth"]
@@ -0,0 +1,77 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip(
"packaged mobile comment-reaction journey runs only in its gated CI job",
allow_module_level=True,
)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content(
'<main><div id="status" aria-live="polite"></div>'
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "comment-actions.js")
page.evaluate(
"""
window.reactionCalls = [];
window.offline = false;
const controller = createCommentActions({
getLogin: () => 'timmy',
fetchJson: async (path, options = {}) => {
reactionCalls.push([path, options.method || 'GET']);
return {
comment_id: 91,
reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1,
selected: options.method === 'PUT'}],
};
},
});
document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'});
controller.wire({
root: document.querySelector('#comments'),
getSurface: () => ({
context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}},
status: document.querySelector('#status'),
}),
isOffline: () => window.offline,
escapeHtml: value => String(value),
});
"""
)
trigger = page.get_by_role("button", name="React to this comment")
trigger.click()
menu = page.locator("[data-comment-reaction-menu]")
expect(menu).to_be_visible()
for control in menu.get_by_role("button").all():
box = control.bounding_box()
assert box is not None and box["height"] >= 44
page.get_by_role("button", name="Heart 1").click()
expect(menu).to_be_hidden()
expect(trigger).to_be_focused()
expect(page.locator("#status")).to_have_text("Reaction updated.")
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
assert page.evaluate("window.reactionCalls") == [
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"],
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"],
]
browser.close()
@@ -0,0 +1,135 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "release-receipt.js")
result = page.evaluate("""async () => {
const calls=[];
const values=new Map();
const storage={
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
removeItem:key=>values.delete(key),
};
const receipt=createReleaseReceipt({
storage,
getLogin:()=> 'timmy',
listNode:document.querySelector('#release-watchlist'),
confirmAction:()=>true,
fetchJson:async(path, options={})=>{
calls.push({path, method:options.method || 'GET'});
if (path.endsWith('/failure')) return {
commit_sha:'abc1234', run_id:91, job_index:3,
name:'CI / browser', excerpt:'AssertionError: mobile viewport overflow',
};
if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'};
return {
commit_sha:'abc1234', ci_state:'failure', release:null,
checks:[{
name:'CI / browser with a deliberately long workflow name',
state:'failure', description:'Playwright failed at the narrow viewport',
url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',
recovery:{run_id:91, job_index:3},
}],
};
},
});
receipt.capture(
{repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'},
{merge_commit_sha:'abc1234'},
);
document.querySelector('#release-receipt-sheet').showModal();
await receipt.refresh();
const review=document.querySelector('.release-failure-summary button');
const reviewHeight=review.getBoundingClientRect().height;
review.click();
await new Promise(resolve=>setTimeout(resolve, 0));
const recovery=document.querySelector('.release-failure-recovery');
const retry=recovery.querySelector('button');
const retryHeight=retry.getBoundingClientRect().height;
const excerpt=recovery.querySelector('pre').textContent;
const sha=recovery.querySelector('strong').textContent;
const job=recovery.querySelector('a').href;
const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth;
retry.click();
await new Promise(resolve=>setTimeout(resolve, 0));
return {
calls, reviewHeight, retryHeight, excerpt, sha, job, overflow,
state:receipt.restore()[0].status.label,
};
}""")
expect(page.locator("#release-receipt-sheet")).to_be_visible()
assert result["reviewHeight"] >= 44
assert result["retryHeight"] >= 44
assert result["excerpt"] == "AssertionError: mobile viewport overflow"
assert result["sha"] == "Merge abc1234"
assert result["job"].endswith("/actions/runs/91/jobs/3")
assert result["overflow"] is False
assert result["state"] == "Checks running"
assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"]
browser.close()
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_failed_release_prepares_a_visible_draft_rollback_receipt_on_phone(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "release-receipt.js")
result = page.evaluate("""async () => {
const values=new Map(), calls=[], opened=[];
const receipt=createReleaseReceipt({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', listNode:document.querySelector('#release-watchlist'),
confirmAction:()=>true, openPull:pull=>opened.push(pull),
fetchJson:async(path, options={})=>{
calls.push({path,method:options.method||'GET'});
if(path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'failed'};
if(path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,rollback_of:'abc1234',url:'https://forge.example/git/stackchain/api/pulls/9'};
return {commit_sha:'abc1234',ci_state:'failure',release:null,checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}]};
},
});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'},{merge_commit_sha:'abc1234'});
document.querySelector('#release-receipt-sheet').showModal();
await receipt.refresh();
document.querySelector('.release-failure-summary button').click();
await new Promise(resolve=>setTimeout(resolve,0));
const rollback=[...document.querySelectorAll('.release-failure-actions button')].find(button=>button.textContent==='Prepare rollback PR');
const actionHeight=rollback.getBoundingClientRect().height;
rollback.click();
await new Promise(resolve=>setTimeout(resolve,0));
const status=document.querySelector('.release-rollback-status');
return {calls,opened,actionHeight,status:status?.textContent||'',overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};
}""")
assert result["actionHeight"] >= 44
assert result["status"] == "Draft rollback #9 is ready for review."
assert result["opened"][0]["number"] == 9
assert result["calls"][-1]["path"].endswith("/release-receipt/abc1234/rollback")
assert result["overflow"] is False
browser.close()
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("rendered authored-pull recovery checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_mobile_search_reopens_closed_authored_pull_into_my_work(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
page.evaluate("""() => {
const detail = {
repository:'stackchain/web', number:9, kind:'pull', state:'closed',
authored_pull_reopenable:true, head_sha:'abc1234',
};
const button = document.querySelector('#start-search-result');
globalThis.renderSearchPreviewStart(detail, {status:'ready'}, button);
globalThis.recoveryMetrics = {calls:[], history:['search']};
const mutate = globalThis.searchPreviewMutation((path, options) => {
globalThis.recoveryMetrics.calls.push({path, body:JSON.parse(options.body)});
return Promise.resolve({...detail, state:'open'});
});
const preview = globalThis.createSearchPreview({
fetchJson:async () => detail,
mutate,
onState:() => {},
});
preview.open(detail);
const recover = globalThis.createSearchAuthoredPullRecovery({
confirm:message => window.confirm(message),
reopen:target => preview.reopenPull(target),
refresh:async () => { globalThis.recoveryMetrics.refreshed = true; },
find:() => ({repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}),
open:item => globalThis.recoveryMetrics.history.push(item.kind),
unavailable:message => { globalThis.recoveryMetrics.unavailable = message; },
});
button.addEventListener('click', () => recover(detail));
}""")
action = page.locator("#start-search-result")
expect(action).to_be_visible()
expect(action).to_have_text("Reopen in My Work")
bounds = action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.once("dialog", lambda dialog: dialog.accept())
action.click()
page.wait_for_function("globalThis.recoveryMetrics.history.length === 2")
metrics = page.evaluate("globalThis.recoveryMetrics")
assert metrics["calls"] == [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"body": {"expected_head_sha": "abc1234"},
}]
assert metrics["refreshed"] is True
assert metrics["history"] == ["search", "pull"]
assert "unavailable" not in metrics
browser.close()
@@ -0,0 +1,67 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged branch-cleanup journey runs only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_merged_source_branch_cleanup_is_phone_openable_and_exact(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "release-receipt.js")
result = page.evaluate("""async () => {
const calls=[];
const values=new Map();
const storage={
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
removeItem:key=>values.delete(key),
};
const receipt=createReleaseReceipt({
storage,
getLogin:()=> 'timmy',
listNode:document.querySelector('#release-watchlist'),
confirmAction:()=>true,
fetchJson:async(path, options={})=>{
calls.push({path, method:options.method, body:options.body});
return {deleted:true, source_branch:'timmy/feature', source_head_sha:'abc1234'};
},
});
receipt.capture(
{repository:'stackchain/api', number:7, key:'stackchain/api#7'},
{merge_commit_sha:'merge456', source_branch:'timmy/feature', source_head_sha:'abc1234', source_repository:'stackchain/api'},
);
document.querySelector('#release-receipt-sheet').showModal();
const button=document.querySelector('.release-watchlist-actions button');
const box=button.getBoundingClientRect();
const label=button.getAttribute('aria-label');
button.click();
await new Promise(resolve=>setTimeout(resolve, 0));
return {
calls, label, height:box.height,
branch:document.querySelector('.release-branch-cleanup-status').textContent,
overflow:document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}""")
expect(page.locator("#release-receipt-sheet")).to_be_visible()
assert result["height"] >= 44
assert result["label"] == "Delete merged source branch timmy/feature at abc1234"
assert result["calls"][0]["method"] == "DELETE"
assert result["branch"] == "Branch timmy/feature · deleted"
assert result["overflow"] is False
browser.close()
+37 -45
View File
@@ -1,13 +1,44 @@
from pathlib import Path
import re
WORKFLOW = Path(".gitea/workflows/ci.yml")
ACTION_REFERENCE = re.compile(
r"^\s*- uses: (?P<action>[^\s@]+)@(?P<revision>[^\s#]+)\s+# (?P<version>v\d+(?:\.\d+){1,2})$",
re.MULTILINE,
)
DOWNLOAD_ARTIFACT = (
"actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2"
)
def test_ci_uses_gitea_compatible_artifact_action():
text = WORKFLOW.read_text()
assert "actions/upload-artifact@v4" not in text
assert "actions/upload-artifact@v3" in text
assert re.search(
r"actions/upload-artifact@[0-9a-f]{40}\s+# v3\.\d+\.\d+", text
)
def test_ci_pins_every_external_action_to_a_reviewable_commit():
text = WORKFLOW.read_text()
uses_lines = [line for line in text.splitlines() if line.lstrip().startswith("- uses:")]
references = list(ACTION_REFERENCE.finditer(text))
assert len(references) == len(uses_lines)
assert references
for reference in references:
assert re.fullmatch(r"[0-9a-f]{40}", reference["revision"])
def test_ci_grants_repository_write_access_only_to_release_job():
text = WORKFLOW.read_text()
jobs = text[text.index("jobs:") :]
before_release, release = jobs.split(" release-candidate:", 1)
assert "permissions:\n contents: read" in text[: text.index("jobs:")]
assert "contents: write" not in before_release
assert "permissions:\n contents: write" in release
def test_ci_installs_declared_requirements_before_tests():
@@ -40,7 +71,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert DOWNLOAD_ARTIFACT in release
assert (
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
in release
@@ -48,55 +79,16 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
def test_release_promotion_waits_for_packaged_mobile_journeys():
def test_release_promotion_waits_for_every_packaged_mobile_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
release = text[text.index(" release-candidate:") :]
assert "needs: build-release" in browser
assert "actions/download-artifact@v3" in browser
assert DOWNLOAD_ARTIFACT in browser
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_search_week_plan.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_today_week_reschedule_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
"tests/e2e/test_mobile_following_release.py "
"tests/e2e/test_mobile_detail_watch_release.py "
"tests/e2e/test_mobile_pull_reviewer_status_release.py "
"tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q"
) in browser
assert "python3 -m pytest tests/e2e -q" in browser
assert "test_mobile_" not in browser
assert "needs: [lint, build-release, browser-journey]" in release
def test_browser_job_runs_packaged_today_week_reschedule_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
def test_browser_job_gates_latest_pull_review_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
def test_browser_job_gates_authored_pull_queue_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
+146 -5
View File
@@ -827,6 +827,143 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_closed_authored_pull_reopen_sends_head_guard_to_pull_api():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls = [];
const mutate = globalThis.searchPreviewMutation(async (path, options) => {{
calls.push({{path, options}});
return {{repository:'stackchain/web',number:9,state:'open',head_sha:'abc1234'}};
}});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const result = await mutate(detail, 'reopen-pull');
process.stdout.write(JSON.stringify({{calls,result}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [{
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
"options": {
"method": "PATCH",
"headers": {"Content-Type": "application/json"},
"body": '{"expected_head_sha":"abc1234"}',
},
}],
"result": {
"repository": "stackchain/web",
"number": 9,
"state": "open",
"head_sha": "abc1234",
},
}
def test_search_preview_closed_authored_pull_exposes_reopen_in_my_work_action():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const ready = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'ready'}}, ready);
const pending = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewStart(detail, {{status:'reopening'}}, pending);
process.stdout.write(JSON.stringify({{ready,pending}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"ready": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": False,
},
"pending": {
"hidden": False,
"textContent": "Reopen in My Work",
"disabled": True,
},
}
def test_search_preview_closed_authored_pull_reopen_is_single_flight():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
let resolveMutation;
const states = [];
const detail = {{
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
authored_pull_reopenable:true,head_sha:'abc1234',
}};
const preview = createSearchPreview({{
fetchJson:async () => detail,
mutate:(_detail, action) => {{
if (action !== 'reopen-pull') throw new Error('wrong action');
calls += 1;
return new Promise(resolve => {{ resolveMutation = resolve; }});
}},
onState:state => states.push(state),
}});
await preview.open(detail);
const first = preview.reopenPull(detail);
const second = preview.reopenPull(detail);
if (first !== second || calls !== 1) throw new Error('pull recovery was not single-flight');
resolveMutation({{...detail,state:'open'}});
await first;
process.stdout.write(JSON.stringify({{calls,status:states.at(-1).status}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"calls": 1, "status": "reopened"}
def test_authored_pull_recovery_confirms_refreshes_and_opens_above_search_history():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const events = [];
const history = ['search'];
const detail = {{repository:'stackchain/web',number:9,kind:'pull',head_sha:'abc1234'}};
const item = {{repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}};
const recover = globalThis.createSearchAuthoredPullRecovery({{
confirm:message => {{ events.push(['confirm',message]); return true; }},
reopen:target => {{ events.push(['reopen',target.head_sha]); return Promise.resolve({{...target,state:'open'}}); }},
refresh:() => {{ events.push(['refresh']); return Promise.resolve(); }},
find:target => target.number === 9 ? item : null,
open:opened => {{ events.push(['open',opened.key]); history.push('pull'); }},
unavailable:message => events.push(['unavailable',message]),
}});
const outcome = await recover(detail);
process.stdout.write(JSON.stringify({{events,history,outcome}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["events"] == [
["confirm", "Reopen stackchain/web #9?"],
["reopen", "abc1234"],
["refresh"],
["open", "stackchain/web#9"],
]
assert payload["history"] == ["search", "pull"]
assert payload["outcome"] == "opened"
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -1159,9 +1296,11 @@ def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claim
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert "startButton.hidden = !(detail.reviewable" in html
assert "detail.reviewable ? 'Review now'" in html
assert "renderSearchPreviewStart(detail, state, startButton)" in html
assert "button.hidden = !(detail.reviewable" in preview
assert "detail.reviewable ? 'Review now'" in preview
handler = html.split(
"qs('#start-search-result').addEventListener('click'", 1
)[1].split("qs('#close-whiteboard')", 1)[0]
@@ -1176,9 +1315,10 @@ def test_requested_pull_preview_opens_native_review_without_assignment_mutation(
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert 'id="start-search-result"' in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in preview
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
@@ -1332,9 +1472,10 @@ process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
preview = SEARCH_PREVIEW.read_text()
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate:(detail,action)=>fetchReviewJson(" in html
assert "detail.reopenable ? 'Reopen & resume'" in preview
assert "mutate:searchPreviewMutation(fetchReviewJson)" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v139" in worker
+308
View File
@@ -0,0 +1,308 @@
import json
from pathlib import Path
import httpx
import pytest
from src import gitea_proxy, main
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.anyio
async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "user":
return {"login": "timmy"}
if path == "repos/stackchain/api/issues/comments/91":
return {
"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
}
raise AssertionError(path)
class Response:
def raise_for_status(self):
return None
def json(self):
return [
{"content": "heart", "user": {"login": "alex"}},
{"content": "heart", "user": {"login": "timmy"}},
{"content": "+1", "user": {"login": "alex"}},
{"content": "unsupported", "user": {"login": "alex"}},
{"content": "heart", "user": {}},
]
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {
"comment_id": 91,
"reactions": [
{"content": "+1", "count": 1, "selected": False},
{"content": "heart", "count": 2, "selected": True},
],
}
assert calls == [
("fetch", "user"),
("fetch", "repos/stackchain/api/issues/comments/91"),
("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"),
]
@pytest.mark.anyio
async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch):
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"issue_url": "https://forge.example/git/stackchain/api/issues/7",
}
class Response:
def raise_for_status(self):
return None
def json(self):
return []
class Client:
async def get(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {"comment_id": 91, "reactions": []}
@pytest.mark.anyio
async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch):
payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]]
mutations = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response(payloads.pop(0))
async def post(self, path, headers, json):
mutations.append(("post", path, json))
return Response()
async def delete(self, path, headers, json):
mutations.append(("delete", path, json))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True)
path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert mutations == [("post", path, {"content": "heart"})]
assert result == {
"comment_id": 91,
"reactions": [{"content": "heart", "count": 1, "selected": True}],
}
@pytest.mark.anyio
async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch):
reads = []
writes = []
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def pull_capabilities(repository, number):
return {"can_open": (repository, number) == ("stackchain/api", 8)}
async def notification_target(thread_id):
assert thread_id == 42
return "stackchain/api", 9
async def reactions(repository, number, comment_id):
reads.append((repository, number, comment_id))
return {"comment_id": comment_id, "reactions": []}
async def set_reaction(repository, number, comment_id, content, active):
writes.append((repository, number, comment_id, content, active))
return {
"comment_id": comment_id,
"reactions": [{"content": content, "count": 1, "selected": active}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities)
monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True)
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False)
monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions")
pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions")
update = await client.get("/api/v1/notifications/42/comments/93/reactions")
changed = await client.put(
"/api/v1/notifications/42/comments/93/reactions/heart",
json={"active": True},
)
assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200]
assert reads == [
("stackchain/api", 7, 91),
("stackchain/api", 8, 92),
("stackchain/api", 9, 93),
]
assert writes == [("stackchain/api", 9, 93, "heart", True)]
assert changed.json()["reactions"][0]["selected"] is True
def run_node(script: str) -> dict:
import subprocess
result = subprocess.run(
["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True
)
return json.loads(result.stdout)
def test_comment_action_controller_loads_and_toggles_authoritative_reactions():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const actions=createActions({{
fetchJson:async (path, options={{}})=>{{
calls.push([path, options.method || 'GET', options.body || null]);
return options.method === 'PUT'
? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}}
: {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}};
}},
getLogin:()=> 'timmy',
}});
(async()=>{{
const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}};
const loaded=await actions.loadReactions(context,91);
const changed=await actions.setReaction(context,91,'heart',true);
process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions"
assert output["calls"] == [
[base, "GET", None],
[base + "/heart", "PUT", '{"active":true}'],
]
assert output["loaded"]["reactions"][0]["count"] == 1
assert output["changed"]["reactions"][0]["selected"] is True
assert 'data-comment-reactions-open' in output["html"]
assert 'aria-label="React to this comment"' in output["html"]
def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{
comment_id:91,
reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}],
}})}}));
"""
html = run_node(script)["html"]
assert 'data-comment-reaction="heart"' in html
assert 'aria-pressed="true"' in html
assert "Heart 2" in html
assert 'data-comment-reaction="rocket"' in html
assert "Rocket 0" in html
assert 'data-comment-reactions-close' in html
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".comment-reactions" in css
assert "flex-wrap:wrap" in css
assert ".comment-reaction-menu button" in css
assert "min-height:44px" in css
def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const menu={{hidden:true,innerHTML:''}};
const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}};
const actions=createActions({{
fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}},
getLogin:()=> 'timmy',
}});
actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}});
(async()=>{{
await root.listeners.click({{target:trigger}});
await root.listeners.click({{target:reaction}});
process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/notifications/42/comments/91/reactions"
assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]]
assert output["hidden"] is True
assert output["expanded"] == "false"
assert output["focused"] == 1
assert output["status"] == "Reaction updated."
def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
let calls=0;
const menu={{hidden:true,innerHTML:''}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}};
const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}});
actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}});
(async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})();
"""
assert run_node(script) == {"calls": 0, "status": "Reconnect to react."}
+203
View File
@@ -0,0 +1,203 @@
import httpx
import pytest
from src import gitea_proxy
from src import main
@pytest.fixture(autouse=True)
def clear_pull_creation_operations():
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
async def test_pull_creation_options_return_visible_repository_branches(monkeypatch):
async def access(repository):
assert repository == "stackchain/api"
return {"full_name": repository, "default_branch": "main", "permissions": {"push": True}}
async def branches(repository):
assert repository == "stackchain/api"
return [
{"name": "feature/mobile", "commit": {"id": "abc1234"}},
{"name": "main", "commit": {"id": "def5678"}},
]
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "repo_branches", branches, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pull-creation-options")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"default_branch": "main",
"branches": [
{"name": "feature/mobile", "sha": "abc1234"},
{"name": "main", "sha": "def5678"},
],
}
@pytest.mark.anyio
async def test_create_pull_endpoint_returns_confirmed_mobile_pull(monkeypatch):
calls = []
async def access(repository):
return {"full_name": repository, "permissions": {"push": True}}
async def create(repository, *, head, base, title, body, draft, expected_head_sha):
calls.append((repository, head, base, title, body, draft, expected_head_sha))
return {
"number": 42,
"repository": repository,
"title": title,
"body": body,
"head": {"ref": head, "sha": expected_head_sha},
"base": {"ref": base},
"draft": draft,
"state": "open",
"url": "https://forge.example/stackchain/api/pulls/42",
"existing": False,
}
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "create_pull", create, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls",
headers={"Idempotency-Key": "mobile-create-42"},
json={
"head": "feature/mobile",
"base": "main",
"title": " Ship mobile create ",
"body": " Context ",
"draft": True,
"expected_head_sha": "abc1234",
},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json()["number"] == 42
assert calls == [
("stackchain/api", "feature/mobile", "main", "Ship mobile create", "Context", True, "abc1234")
]
@pytest.mark.anyio
async def test_gitea_create_pull_revalidates_head_and_confirms_created_pull():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/branches/feature/mobile"):
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
if request.method == "GET" and request.url.path.endswith("/pulls"):
return httpx.Response(200, json=[])
if request.method == "POST" and request.url.path.endswith("/pulls"):
return httpx.Response(201, json={
"number": 42,
"title": "Ship mobile create",
"body": "Context",
"state": "open",
"draft": True,
"head": {"ref": "feature/mobile", "sha": "abc1234"},
"base": {"ref": "main"},
"user": {"login": "timmy"},
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/42",
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_pull(
"stackchain/api",
head="feature/mobile",
base="main",
title="Ship mobile create",
body="Context",
draft=True,
expected_head_sha="abc1234",
)
finally:
await gitea_proxy.stop_client()
assert result["number"] == 42
assert result["head"] == {"ref": "feature/mobile", "sha": "abc1234"}
assert result["author"] == "timmy"
assert result["existing"] is False
mutation = next(request for request in requests if request.method == "POST")
assert mutation.read() == b'{"head":"feature/mobile","base":"main","title":"Ship mobile create","body":"Context","draft":true}'
@pytest.mark.anyio
async def test_gitea_create_pull_returns_existing_source_base_without_mutation():
requests = []
existing = {
"number": 41,
"title": "Already open",
"body": "Existing context",
"state": "open",
"draft": False,
"head": {"ref": "feature/mobile", "sha": "abc1234"},
"base": {"ref": "main"},
"user": {"login": "timmy"},
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/41",
}
async def handler(request):
requests.append(request)
if "/branches/" in request.url.path:
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
if request.method == "GET" and request.url.path.endswith("/pulls"):
return httpx.Response(200, json=[existing])
return httpx.Response(500)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_pull(
"stackchain/api", head="feature/mobile", base="main",
title="Duplicate attempt", body="", draft=True,
expected_head_sha="abc1234",
)
finally:
await gitea_proxy.stop_client()
assert result["number"] == 41
assert result["existing"] is True
assert all(request.method != "POST" for request in requests)
@pytest.mark.anyio
async def test_create_pull_endpoint_reports_stale_source_without_retrying(monkeypatch):
async def access(repository):
return {"full_name": repository, "permissions": {"push": True}}
async def stale(*args, **kwargs):
raise gitea_proxy.PullCreateConflictError("Source branch changed before pull creation")
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "create_pull", stale)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls",
json={
"head": "feature/mobile", "base": "main", "title": "Ship it",
"expected_head_sha": "abc1234",
},
)
assert response.status_code == 409
assert response.json() == {
"error": "The source branch changed. Refresh branches before creating the pull request."
}
+142
View File
@@ -0,0 +1,142 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "create-pull-sheet.js"
def run_node(scenario: str) -> dict:
script = f"""
const createPullSheet = require({json.dumps(str(MODULE))});
{scenario}
"""
result = subprocess.run(["node", "-e", script], text=True, capture_output=True, cwd=ROOT)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_create_pull_controller_loads_branches_and_defaults_base():
result = run_node("""
const calls=[];
const controller=createPullSheet.createController({
request:async (url) => {
calls.push(url);
return {default_branch:'main',branches:[
{name:'feature/mobile',sha:'abc1234'},
{name:'main',sha:'def5678'}
]};
},
storage:{getItem:()=>null,setItem:()=>{}},
login:'timmy'
});
controller.selectRepository('stackchain/api').then(value => {
process.stdout.write(JSON.stringify({value,calls,state:controller.state()}));
}).catch(error=>{console.error(error);process.exit(1)});
""")
assert result["calls"] == ["api/v1/repos/stackchain/api/pull-creation-options"]
assert result["state"]["base"] == "main"
assert result["state"]["head"] == "feature/mobile"
assert result["state"]["expected_head_sha"] == "abc1234"
def test_create_pull_controller_submits_idempotently_and_keeps_draft_on_failure():
result = run_node("""
const calls=[]; const saved=[]; let fail=true;
const controller=createPullSheet.createController({
request:async (url, options) => {
calls.push([url,options]);
if (!options) return {default_branch:'main',branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}]};
if (fail) { fail=false; throw new Error('offline'); }
return {number:9,repository:'acme/app',head:{ref:'topic',sha:'abc1234'},base:{ref:'main'},draft:true};
},
storage:{getItem:()=>null,setItem:(key,value)=>saved.push([key,JSON.parse(value)])},
login:'timmy', createId:()=> 'stable-id'
});
(async()=>{
await controller.selectRepository('acme/app');
controller.update({title:'Ship it',body:'Context'});
let error=''; try { await controller.submit(); } catch (caught) { error=caught.message; }
const retained=controller.state();
const confirmed=await controller.submit();
process.stdout.write(JSON.stringify({calls,error,retained,confirmed,saved}));
})().catch(error=>{console.error(error);process.exit(1)});
""")
assert result["error"] == "offline"
assert result["retained"]["title"] == "Ship it"
posts = [call for call in result["calls"] if call[1]]
assert len(posts) == 2
assert posts[0][1]["headers"]["Idempotency-Key"] == "stable-id"
assert posts[1][1]["headers"]["Idempotency-Key"] == "stable-id"
assert posts[0][1]["body"]["expected_head_sha"] == "abc1234"
assert result["confirmed"]["number"] == 9
def test_create_pull_controller_restores_only_the_current_accounts_draft():
result = run_node("""
const values={
'stackchain.create-pull.v1.timmy':JSON.stringify({repository:'acme/app',head:'topic',base:'main',title:'Saved title',body:'Saved body',draft:false}),
'stackchain.create-pull.v1.alexander':JSON.stringify({repository:'private/repo',title:'Other account'})
};
const controller=createPullSheet.createController({
request:async()=>({}), storage:{getItem:key=>values[key] || null,setItem:()=>{}}, login:'Timmy'
});
process.stdout.write(JSON.stringify(controller.state()));
""")
assert result == {
"repository": "acme/app",
"branches": [],
"head": "topic",
"base": "main",
"expected_head_sha": "",
"title": "Saved title",
"body": "Saved body",
"draft": False,
}
def test_mobile_pull_creation_sheet_is_packaged_and_touch_safe():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="create-pull-sheet" role="dialog" aria-modal="true"' in html
assert 'id="create-pull-form"' in html
assert 'id="create-pull-repository"' in html
assert 'id="create-pull-head"' in html
assert 'id="create-pull-base"' in html
assert 'id="submit-create-pull"' in html
assert 'static/create-pull-sheet.js' in bundle
assert ".create-pull-panel button" in css
assert "min-height:44px" in css.replace(" ", "")
def test_create_pull_binding_renders_branch_receipt_and_created_pull():
result = run_node("""
const rendered=[]; const statuses=[]; const opened=[];
const controller={
selectRepository:async repository=>({repository,branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}],head:'topic',base:'main',expected_head_sha:'abc1234'}),
update:values=>values,
submit:async()=>({number:12,repository:'acme/app',existing:false})
};
const binding=createPullSheet.createBinding({controller,
render:state=>rendered.push(state), status:value=>statuses.push(value),
onCreated:value=>opened.push(value)
});
(async()=>{
await binding.repositoryChanged('acme/app');
await binding.submit();
process.stdout.write(JSON.stringify({rendered,statuses,opened}));
})().catch(error=>{console.error(error);process.exit(1)});
""")
assert result["rendered"][0]["expected_head_sha"] == "abc1234"
assert result["statuses"][-1] == "Pull request #12 created."
assert result["opened"][0]["number"] == 12
def test_dashboard_wires_create_pull_inside_lazy_issue_capture_chunk():
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
module = MODULE.read_text()
assert "cp.b();" not in dashboard
assert "else cp.b();" in module
+172 -5
View File
@@ -2,6 +2,7 @@ import asyncio
import sqlite3
import threading
import time
from typing import get_args
from urllib.parse import parse_qs, urlsplit
import httpx
@@ -9,10 +10,14 @@ import pytest
from fastapi import Request
from src import main
from src.session_store import SessionStoreError
from src.session_store import SessionStatus, SessionStoreError
from src.views import FRONTEND_BUILD
def test_release_rollback_is_a_supported_step_up_action():
assert "prepare_release_rollback" in get_args(main.StepUpAction)
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -24,6 +29,83 @@ def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
async def upstream_user():
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", upstream_user)
def test_signed_session_carries_bound_upstream_identity(access_control):
signed, _ = main.dashboard_auth.issue_session(
now=1_000,
principal_id=42,
principal_login="timmy",
)
verification = main.dashboard_auth.verify_session_with_reason(signed, now=1_001)
assert verification.session is not None
assert verification.session.principal_id == 42
assert verification.session.principal_login == "timmy"
@pytest.mark.anyio
async def test_session_is_blocked_when_upstream_account_changes(
access_control, monkeypatch
):
identity = {"id": 42, "login": "timmy"}
async def upstream_user():
return dict(identity)
monkeypatch.setattr(main, "current_user", upstream_user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
identity.update(login="renamed-timmy")
same_principal = await client.get("/api/v1/security-events")
identity.update(id=84, login="other-operator")
protected = await client.get("/api/v1/security-events")
assert signed_in.status_code == 200
assert same_principal.status_code == 200
assert protected.status_code == 401
assert protected.json() == {
"detail": "Connected Gitea account changed; sign in again",
"code": "session_idle",
"reason": "upstream_identity_changed",
"previous_login": "timmy",
"current_login": "other-operator",
}
@pytest.mark.anyio
async def test_session_identity_lookup_outage_is_retryable(access_control, monkeypatch):
available = True
async def upstream_user():
if not available:
raise RuntimeError("credential lookup details")
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", upstream_user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
available = False
protected = await client.get("/api/v1/security-events")
assert signed_in.status_code == 200
assert protected.status_code == 503
assert protected.json() == {"detail": "Gitea identity is temporarily unavailable"}
assert protected.headers["retry-after"] == "5"
assert "credential lookup" not in protected.text
async def fresh_grant(client, action: str, target: str) -> str:
@@ -43,6 +125,84 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"]
@pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "cleanup-operation"
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def cleanup(repository, number, source_branch, expected_head_sha):
lifecycle.append(
("delete", repository, number, source_branch, expected_head_sha)
)
return {"number": number, "deleted": True}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main.gitea_proxy, "delete_merged_source_branch", cleanup)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
request = {
"source_branch": "timmy/feature",
"expected_head_sha": "abc1234",
}
path = "/api/v1/repos/stackchain/api/pulls/7/source-branch"
challenged = await client.request("DELETE", path, json=request, headers=headers)
grant = await fresh_grant(
client, "delete_source_branch", "stackchain/api#7@abc1234"
)
deleted = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
replayed = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
assert challenged.status_code == 428
assert challenged.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "delete_source_branch",
"target": "stackchain/api#7@abc1234",
}
assert deleted.status_code == 200
assert replayed.status_code == 428
assert lifecycle == [
("reserve", "source_branch_deleted", "stackchain/api#7@abc1234"),
("delete", "stackchain/api", 7, "timmy/feature", "abc1234"),
("finalize", "cleanup-operation"),
]
@pytest.mark.anyio
async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time(
access_control,
@@ -1821,7 +1981,7 @@ async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, mon
assert before.status_code == 200
assert after.status_code == 401
assert after.headers["cache-control"] == "no-store"
assert calls == 1
assert calls == 4
@pytest.mark.anyio
@@ -1835,7 +1995,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
class SlowStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.15)
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
@@ -2001,7 +2163,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
class SlowStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.02)
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
def revoke(self, session_id):
time.sleep(0.15)
@@ -2043,6 +2207,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
called = False
class BrokenStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
@@ -2091,7 +2256,9 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
class BrokenStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
+22
View File
@@ -558,6 +558,28 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True
def test_upstream_identity_change_preserves_private_work_and_requires_sign_in():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {
detail:'Connected Gitea account changed; sign in again',
code:'session_idle', reason:'upstream_identity_changed',
previous_login:'timmy', current_login:'other-operator',
};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""
+2 -1
View File
@@ -130,7 +130,8 @@ def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
assert 'pullDetailWatch.open(item)' in dashboard
assert '#watch-issue-detail' in css
assert '#watch-pull-detail' in css
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
assert "python3 -m pytest tests/e2e -q" in workflow
assert (ROOT / "tests/e2e/test_mobile_detail_watch_release.py").is_file()
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
+1 -1
View File
@@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v138" in service_worker
assert "stackchain-dashboard-shell-v139" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
+102
View File
@@ -875,6 +875,108 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_offers_closed_authored_unmerged_pull_recovery_with_head_guard():
requested_paths = []
async def handler(request):
requested_paths.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
"requested_reviewers": [],
})
return httpx.Response(200, json={
"number": 9,
"title": "Recover mobile flow",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is True
assert preview["head_sha"] == "abc1234"
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_does_not_offer_closed_pull_recovery_to_non_author():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": False,
"user": {"login": "alexander"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Foreign pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "alexander"},
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_does_not_offer_recovery_for_merged_pull():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"number": 9,
"state": "closed",
"merged": True,
"user": {"login": "timmy"},
"head": {"sha": "abc1234"},
})
return httpx.Response(200, json={
"number": 9,
"title": "Merged pull",
"state": "closed",
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
"user": {"login": "timmy"},
"pull_request": {"merged": True},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
finally:
await gitea_proxy.stop_client()
assert preview["authored_pull_reopenable"] is False
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):
+1 -1
View File
@@ -435,5 +435,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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -54,7 +54,7 @@ process.stdout.write(JSON.stringify({{ status: status.textContent }}));
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"Stackchain locked. Your drafts and queued work are still "
"on this device. Sign in to resume."
)
+1 -1
View File
@@ -256,4 +256,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-v138" in worker
assert "stackchain-dashboard-shell-v139" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v139" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v139" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+32 -1
View File
@@ -133,6 +133,37 @@ process.stdout.write(JSON.stringify({{current:controller.current(),hash:location
}
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
this.state=state; location.hash=url.slice(url.indexOf('#'));
}}}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
header.inert = true;
listeners.hashchange();
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
"""
assert run_node(script) == {"current": False, "headerInert": True}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
@@ -243,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v139" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+1 -1
View File
@@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v138" in service_worker
assert "stackchain-dashboard-shell-v139" in service_worker
@pytest.mark.anyio
+150
View File
@@ -122,6 +122,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
assert result[0]["kind"] == "pull"
def test_authored_pull_branch_update_is_single_flight_and_head_guarded():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let resolve;
const pending = new Promise(done => {{ resolve = done; }});
const controller = createPullSheet({{
fetchJson:(url, options) => {{ calls.push([url, options]); return pending; }},
storage:{{}},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.updateBranch(item, 'abc1234');
const second = controller.updateBranch(item, 'abc1234');
resolve({{previous_head_sha:'abc1234', head_sha:'def5678'}});
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
same:first === second,
calls:calls.map(([url, options]) => [url, options.method, JSON.parse(options.body)]),
heads:results.map(result => result.head_sha),
}}))).catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", script], text=True, capture_output=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"same": True,
"calls": [[
"api/v1/repos/stackchain/api/pulls/7/update-branch",
"POST",
{"expected_head_sha": "abc1234"},
]],
"heads": ["def5678", "def5678"],
}
def test_mobile_pull_review_exposes_update_branch_recovery():
frontend = Path(__file__).parents[1] / "frontend"
html = (frontend / "index.html").read_text()
source = PULL_SHEET.read_text()
css = (frontend / "dashboard.css").read_text()
assert 'id="update-pull-branch"' in html
assert 'id="pull-branch-update-status"' in html
assert "controller.updateBranch(item, detail.head_sha)" in source
assert "doc.querySelector('#pull-review-retry')?.click()" in source
assert ".pull-branch-update button" in css and "min-height:44px" in css
def test_my_prs_filter_and_count_include_only_authored_pulls():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -7642,6 +7689,39 @@ process.stdout.write(JSON.stringify({{
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
def test_authored_unassigned_pull_hides_assignment_ownership_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const elements = new Map([
['#pull-ownership', {{open:true,hidden:false,inert:false}}],
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
['#load-pull-handoff', {{disabled:false}}],
['#release-pull', {{disabled:false,textContent:''}}],
['#pull-handoff-status', {{textContent:''}}],
['#edit-pull-content', {{hidden:false}}],
['#pull-edit-form', {{hidden:false}}],
['#pull-edit-status', {{textContent:'stale'}}],
]);
const doc = {{querySelector:selector => elements.get(selector)}};
createPullSheet.resetOwnershipControls(
doc,
{{key:'stackchain/api#7'}},
() => false,
{{authored:true,assigned:false}}
);
process.stdout.write(JSON.stringify(Object.fromEntries(elements)));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["#pull-ownership"]["hidden"] is True
assert output["#pull-ownership"]["inert"] is True
assert output["#load-pull-handoff"]["disabled"] is True
assert output["#release-pull"]["disabled"] is True
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -8670,6 +8750,76 @@ process.stdout.write(JSON.stringify({{result, offline}}));
assert output["offline"]["summary"].startswith("Last known · ")
def test_failed_actions_check_renders_in_app_recovery_control_only_when_supported():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const supported = reviewSheet.renderChecks([
{{name:'CI / lint', state:'failure', url:'https://forge.example/jobs/3', recovery:{{run_id:91, job_index:3}}}},
], escapeHtml);
const external = reviewSheet.renderChecks([
{{name:'Vendor scan', state:'failure', url:'https://forge.example/jobs/4'}},
], escapeHtml);
process.stdout.write(JSON.stringify({{supported, external}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert 'class="ci-check-inspect"' in output["supported"]["html"]
assert 'data-run-id="91"' in output["supported"]["html"]
assert 'data-job-index="3"' in output["supported"]["html"]
assert "Inspect failure" in output["supported"]["html"]
assert "ci-check-inspect" not in output["external"]["html"]
assert "Open job" in output["external"]["html"]
def test_pull_controller_loads_failure_and_retries_exact_head():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
const controller = createPullSheet({{
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{status:'queued'}}; }},
storage: null,
}});
const item = {{repository:'stackchain/api', number:7}};
controller.loadCheckFailure(item, 'abc1234', 91, 3).then(() =>
controller.retryCheck(item, 'abc1234', 91, 3)
).then(() => process.stdout.write(JSON.stringify(calls)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
calls = json.loads(result.stdout)
assert calls[0]["url"].endswith(
"/pulls/7/checks/91/jobs/3/failure?expected_head_sha=abc1234"
)
assert calls[0]["options"]["headers"]["Accept"] == "application/json"
assert calls[1]["url"].endswith("/pulls/7/checks/91/jobs/3/retry")
assert calls[1]["options"]["method"] == "POST"
assert json.loads(calls[1]["options"]["body"]) == {"expected_head_sha": "abc1234"}
@pytest.mark.anyio
async def test_mobile_pull_workspace_packages_accessible_ci_recovery_panel():
html = await dashboard()
assert 'id="pull-check-recovery"' in html
assert 'id="pull-check-recovery-title"' in html
assert 'id="pull-check-log"' in html
assert 'id="retry-pull-check"' in html
assert 'id="close-pull-check-recovery"' in html
assert 'aria-live="assertive"' in html
assert "createPullSheet.bindOwnershipControls" in html
assert "#pull-check-list" in html and "ci-check-inspect" in html
assert ".ci-check-recovery" in html and "max-width:100%" in html
assert ".ci-check-log" in html and "overflow-x:auto" in html
assert ".ci-check-actions" in html and "min-height:44px" in html
@pytest.mark.anyio
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
html = await dashboard()
+1 -1
View File
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+1356 -26
View File
File diff suppressed because it is too large. Load diff
+54 -1
View File
@@ -488,6 +488,59 @@ async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_ca
assert calls == [(["device-b", "device-a", "device-b"], 321)]
@pytest.mark.anyio
async def test_managed_session_statuses_bind_batch_to_expected_principal(monkeypatch):
calls = []
class Store:
def managed_statuses(
self, management_ids, *, idle_timeout_seconds, expected_principal_id
):
calls.append(
(list(management_ids), idle_timeout_seconds, expected_principal_id)
)
return {management_id: "active" for management_id in set(management_ids)}
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
assert await dashboard_auth.managed_session_statuses(
["device-b", "device-a"], expected_principal_id=42
) == {"device-a": "active", "device-b": "active"}
assert calls == [(["device-b", "device-a"], 321, 42)]
@pytest.mark.anyio
async def test_push_session_statuses_use_current_upstream_identity(monkeypatch):
calls = []
async def current_user():
return {"id": 42, "login": "timmy"}
async def statuses(management_ids, *, expected_principal_id):
calls.append((list(management_ids), expected_principal_id))
return {management_id: "active" for management_id in management_ids}
monkeypatch.setattr(main.gitea_proxy, "current_user", current_user)
monkeypatch.setattr(main.dashboard_auth, "managed_session_statuses", statuses)
assert await main._identity_bound_push_session_statuses(["phone-device"]) == {
"phone-device": "active"
}
assert calls == [(["phone-device"], 42)]
@pytest.mark.anyio
async def test_push_session_statuses_fail_closed_when_identity_is_invalid(monkeypatch):
async def current_user():
return {"id": "not-an-integer", "login": "timmy"}
monkeypatch.setattr(main.gitea_proxy, "current_user", current_user)
with pytest.raises(ValueError, match="identity"):
await main._identity_bound_push_session_statuses(["phone-device"])
@pytest.mark.anyio
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
captured = {}
@@ -516,7 +569,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
await main._push_poll_loop()
assert captured["unread"] is main.gitea_proxy.unread_notification_snapshot
assert captured["session_statuses"] is dashboard_auth.managed_session_statuses
assert captured["session_statuses"] is main._identity_bound_push_session_statuses
assert captured["max_concurrency"] == 3
assert captured["max_individual_notifications"] == 4
+191 -1
View File
@@ -36,6 +36,40 @@ restored && first.refresh().then(status=>{
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
def test_release_receipt_deletes_the_exact_captured_source_branch_without_stopping_release_tracking():
output = run_node(r"""
const values=new Map(), calls=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
calls.push({path,method:options.method||'GET',body:options.body||null});
if ((options.method||'GET') === 'DELETE') return {number:7,deleted:true,source_branch:'timmy/feature',source_head_sha:'abc1234'};
return {commit_sha:'merge456',ci_state:'pending',checks:[],release:null};
}});
const captured=receipt.capture(
{repository:'stackchain/api',number:7,key:'stackchain/api#7'},
{merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'}
);
(async()=>{
await receipt.deleteBranch('stackchain/api','merge456');
await receipt.refresh();
process.stdout.write(JSON.stringify({captured,calls,restored:receipt.restore()}));
})();
""")
assert output["captured"]["source_branch"] == "timmy/feature"
assert output["calls"][0] == {
"path": "api/v1/repos/stackchain/api/pulls/7/source-branch",
"method": "DELETE",
"body": json.dumps({
"source_branch": "timmy/feature",
"expected_head_sha": "abc1234",
}, separators=(",", ":")),
}
restored = output["restored"][0]
assert restored["cleanup"] == {"state": "deleted", "message": "Source branch deleted."}
assert restored["status"]["label"] == "Checks running"
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
output = run_node(r"""
const values=new Map();
@@ -62,6 +96,132 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {
assert output["restored"] == []
def test_failed_release_check_loads_exact_commit_diagnostics_and_retries_single_flight():
output = run_node(r"""
const values=new Map(), calls=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let finishRetry;
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
calls.push({path,method:options.method||'GET'});
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
if (path.endsWith('/retry')) return await new Promise(resolve=>{finishRetry=()=>resolve({commit_sha:'abc1234',status:'queued'});});
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}],release:null};
}});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
(async()=>{
const failed=(await receipt.refresh())[0];
const detail=await receipt.reviewFailure('stackchain/api','abc1234',91,3);
const first=receipt.retryFailure('stackchain/api','abc1234',91,3);
const second=receipt.retryFailure('stackchain/api','abc1234',91,3);
await new Promise(resolve=>setTimeout(resolve,0)); finishRetry();
const results=await Promise.all([first,second]);
process.stdout.write(JSON.stringify({failed,detail,results,calls,after:receipt.restore()[0].status}));
})();
""")
assert output["failed"]["failures"] == [{
"name": "CI / browser",
"description": "Playwright failed",
"url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
"recovery": {"run_id": 91, "job_index": 3},
}]
assert output["detail"]["commit_sha"] == "abc1234"
assert output["detail"]["excerpt"] == "AssertionError: failed"
assert output["results"] == [True, True]
assert [call["method"] for call in output["calls"]] == ["GET", "GET", "POST"]
assert output["calls"][1]["path"].endswith(
"/pulls/7/release-receipt/abc1234/checks/91/jobs/3/failure"
)
assert output["calls"][2]["path"].endswith("/checks/91/jobs/3/retry")
assert output["after"]["label"] == "Checks running"
assert output["after"]["checks"] == []
def test_failed_release_row_renders_phone_recovery_with_exact_sha_log_and_job_fallback():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map(), listNode=node();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async path=>{
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: mobile viewport overflow'};
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}]};
}});
const flatten=root=>[root,...root.children.flatMap(flatten)];
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
(async()=>{
await receipt.refresh();
const review=flatten(listNode).find(value=>value.textContent==='Review failure');
await review.click();
const nodes=flatten(listNode);
process.stdout.write(JSON.stringify({
reviewLabel:review['aria-label'],
texts:nodes.map(value=>value.textContent).filter(Boolean),
links:nodes.filter(value=>value.tag==='a').map(value=>({text:value.textContent,href:value.href})),
classes:nodes.map(value=>value.className).filter(Boolean),
}));
})();
""")
assert output["reviewLabel"] == "Review failed check CI / browser for abc1234"
assert "Merge abc1234" in output["texts"]
assert "AssertionError: mobile viewport overflow" in output["texts"]
assert "Retry failed check" in output["texts"]
assert output["links"] == [{
"text": "Open job",
"href": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
}]
assert "release-failure-recovery" in output["classes"]
def test_diagnosed_failed_release_prepares_one_rollback_pull_and_opens_mobile_workspace():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map(), calls=[], opened=[], listNode=node(), dialog={open:true,close(){this.open=false}};
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({
storage,getLogin:()=> 'timmy',listNode,dialog,confirmAction:()=>true,openPull:pull=>opened.push(pull),
fetchJson:async(path,options={})=>{
calls.push({path,method:options.method||'GET'});
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
if (path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,url:'https://forge.example/git/stackchain/api/pulls/9',rollback_of:'abc1234'};
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}],release:null};
},
});
const flatten=root=>[root,...root.children.flatMap(flatten)];
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
(async()=>{
await receipt.refresh();
const before=flatten(listNode).filter(value=>value.textContent==='Prepare rollback PR').length;
await flatten(listNode).find(value=>value.textContent==='Review failure').click();
const rollback=flatten(listNode).find(value=>value.textContent==='Prepare rollback PR');
if (rollback) await rollback.click();
process.stdout.write(JSON.stringify({before,found:!!rollback,calls,opened,dialogOpen:dialog.open,status:receipt.restore()[0].rollback||null}));
})();
""")
assert output["before"] == 0
assert output["found"] is True
assert output["calls"][-1] == {
"path": "api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/rollback",
"method": "POST",
}
assert output["opened"] == [{
"repository": "stackchain/api",
"number": 9,
"draft": True,
"url": "https://forge.example/git/stackchain/api/pulls/9",
"rollback_of": "abc1234",
"kind": "pull",
"key": "stackchain/api#9",
"state": "open",
}]
assert output["dialogOpen"] is False
assert output["status"]["state"] == "prepared"
assert output["status"]["number"] == 9
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
output = run_node(r"""
const values=new Map();
@@ -142,6 +302,25 @@ receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {
assert [item["commit_sha"] for item in output["after"]] == ["pending"]
def test_watchlist_renders_confirmed_touch_cleanup_for_the_exact_source_branch():
output = run_node(r"""
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
global.document={createElement:tag=>node(tag)};
const values=new Map(), calls=[], listNode=node();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async(path,options={})=>{calls.push({path,method:options.method});return {deleted:true};}});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'});
const row=listNode.children[0], copy=row.children[0], actions=row.children[1], cleanup=actions.children[0];
(async()=>{ await cleanup.click(); process.stdout.write(JSON.stringify({branch:copy.children[2].textContent,cleanupText:cleanup.textContent,minLabel:cleanup['aria-label'],calls,after:receipt.restore()[0].cleanup})); })();
""")
assert output["branch"] == "Branch timmy/feature · abc1234"
assert output["cleanupText"] == "Delete source branch"
assert output["minLabel"] == "Delete merged source branch timmy/feature at abc1234"
assert output["calls"][0]["method"] == "DELETE"
assert output["after"]["state"] == "deleted"
def test_watchlist_polls_pending_merges_only_while_foregrounded():
output = run_node(r"""
const values=new Map(), scheduled=[], calls=[];
@@ -214,8 +393,19 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
assert 'id="release-receipt-launcher"' in html
assert 'id="release-receipt-sheet"' in html
assert 'id="release-watchlist"' in html
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
assert 'rR.capture(merging, mergeResult)' in dashboard
assert "listNode:qs('#release-watchlist')" in dashboard
assert "openPull:openPullSheet" in dashboard
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
assert ".release-watchlist-item" in css
assert ".release-watchlist-actions" in css
cleanup_css = css[css.index(".release-watchlist-actions"):]
assert "min-height:44px" in cleanup_css
assert "grid-template-columns:1fr" in cleanup_css
recovery_css = css[css.index(".release-failure-summary"):]
assert "overflow-x:hidden" in recovery_css
assert "white-space:pre-wrap" in recovery_css
assert "min-height:44px" in recovery_css
assert ".release-failure-actions" in recovery_css
assert "grid-template-columns:1fr" in recovery_css
+38
View File
@@ -123,6 +123,44 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
assert main.request_body_limit("POST", "/unrelated") is None
@pytest.mark.anyio
async def test_oversized_source_branch_delete_is_rejected_before_endpoint_work():
downstream_called = False
sent = []
async def downstream(_scope, _receive, _send):
nonlocal downstream_called
downstream_called = True
messages = iter(
[
{"type": "http.request", "body": b"x" * 40_000, "more_body": True},
{"type": "http.request", "body": b"y" * 40_000, "more_body": False},
]
)
async def receive():
return next(messages)
async def send(message):
sent.append(message)
middleware = RequestBodyLimitMiddleware(downstream)
await middleware(
{
"type": "http",
"method": "DELETE",
"path": "/api/v1/repos/stackchain/api/pulls/7/source-branch",
"headers": [],
},
receive,
send,
)
assert sent[0]["status"] == 413
assert downstream_called is False
def test_pull_screenshot_upload_uses_binary_attachment_boundary():
assert (
main.request_body_limit(
+1
View File
@@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
"name": "lint/<unsafe>", "state": "failure",
"description": "Formatting failed " + "x" * 222,
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
"recovery": {"run_id": 9, "job_index": 4},
},
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
+5
View File
@@ -18,6 +18,11 @@ def security_access(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
async def upstream_user():
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", upstream_user)
return tmp_path
+12
View File
@@ -6,6 +6,18 @@ from pathlib import Path
SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js"
def test_source_branch_cleanup_has_a_specific_security_activity_label():
source = SECURITY_CENTER.read_text()
assert "source_branch_deleted: 'Source branch deleted'" in source
def test_release_rollback_has_a_specific_security_activity_label():
source = SECURITY_CENTER.read_text()
assert "release_rollback_prepared: 'Release rollback prepared'" in source
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
+17 -16
View File
@@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -197,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -219,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -228,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -236,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" 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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -251,7 +251,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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -259,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -269,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v139" 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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -285,21 +285,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-v138" in source
assert "stackchain-dashboard-shell-v139" 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-v138" in source
assert "stackchain-dashboard-shell-v139" 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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1354,7 +1354,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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -1467,6 +1467,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/voice-issue-capture.js",
"/dashboard/static/voice-conversation-capture.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-pull-sheet.js",
"/dashboard/static/mobile-create-issue-nav.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js",
+84
View File
@@ -88,6 +88,23 @@ def test_session_status_reports_idle_without_background_validation_extending_act
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_active_session_status_carries_bound_upstream_identity(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate(
"phone-session",
3_000,
principal_id=42,
principal_login="timmy",
)
status = store.status("phone-session", 3_000, idle_timeout_seconds=900)
assert status == "active"
assert status.principal_id == 42
assert status.principal_login == "timmy"
def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
@@ -134,6 +151,49 @@ def test_managed_session_statuses_classify_a_batch_with_one_read(tmp_path, monke
]
def test_managed_session_statuses_reject_other_and_legacy_principals_in_one_read(
tmp_path, monkeypatch
):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate(
"matching-session",
3_000,
management_id="matching-device",
principal_id=42,
principal_login="timmy",
)
store.activate(
"other-session",
3_000,
management_id="other-device",
principal_id=84,
principal_login="other",
)
store.activate("legacy-session", 3_000, management_id="legacy-device")
statements = []
connect = sqlite3.connect
def traced_connect(*args, **kwargs):
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
assert store.managed_statuses(
["matching-device", "other-device", "legacy-device"],
idle_timeout_seconds=900,
expected_principal_id=42,
) == {
"matching-device": "active",
"other-device": "principal_mismatch",
"legacy-device": "principal_mismatch",
}
assert [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")] == [
next(statement for statement in statements if "FROM active_sessions" in statement)
]
def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, monkeypatch):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
monkeypatch.setattr(store, "_connect", lambda *args, **kwargs: pytest.fail("database opened"))
@@ -230,6 +290,30 @@ def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now
assert last_active_at == 1_000
def test_identity_status_migrates_registry_that_already_has_idle_tracking(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions ("
"session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, "
"management_id TEXT, device_label TEXT, created_at INTEGER, last_active_at INTEGER"
")"
)
connection.execute(
"INSERT INTO active_sessions VALUES (?, ?, ?, ?, ?, ?)",
(digest, 2_000, "legacy-device", "Existing device", 900, 1_000),
)
status = SessionStore(database, clock=lambda: 1_000.0).status(
"existing-session", 2_000, idle_timeout_seconds=900
)
assert status == "active"
assert status.principal_id is None
assert status.principal_login is None
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v138';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v139';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+3 -3
View File
@@ -248,9 +248,9 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
assert ".today-summary-actions button { min-height:44px;" in css
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
assert "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
assert "python3 -m pytest tests/e2e -q" in workflow
assert (ROOT / "tests/e2e/test_mobile_today_summary_release.py").is_file()
def test_summary_uses_the_human_label_from_recap_feedback_rows():
+1 -1
View File
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
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-v138" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/today-sync.js'" in source
+3 -1
View File
@@ -157,7 +157,9 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
assert '.today-wrap-up-actions button { min-height:44px;' in css
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
workflow = (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
assert 'python3 -m pytest tests/e2e -q' in workflow
assert (ROOT / 'tests/e2e/test_mobile_today_wrap_up_release.py').is_file()
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():
+30
View File
@@ -107,6 +107,36 @@ async def test_resolve_work_route_returns_only_a_requested_review():
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_authored_unassigned_pull():
async def upstream(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 703,
"number": 90,
"title": "Ship authored flow",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/90",
"assignees": [],
"requested_reviewers": [],
"user": {"login": "timmy"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"pull", "stackchain/dashboard", 90, None
)
finally:
await gitea_proxy.stop_client()
assert result["kind"] == "pull"
assert result["number"] == 90
assert result["is_assigned"] is False
assert result["work_reasons"] == ["authored_by_me"]
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
requests = []