From b753cdd92222c83f1085de68c97252856c2ebb58 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 24 Aug 2026 23:55:33 +0000 Subject: [PATCH 1/2] feat: prepare conflict-safe release rollback pulls (Closes #1370) --- .gitea/workflows/ci.yml | 2 +- README.md | 6 + frontend/dashboard.js | 23 +- frontend/release-receipt.js | 77 +++++- frontend/security-center.js | 1 + frontend/service-worker.js | 2 +- src/gitea_proxy.py | 199 +++++++++++++++ src/main.py | 98 ++++++++ .../test_mobile_release_failure_recovery.py | 44 ++++ tests/test_comment_next.py | 2 +- tests/test_dashboard_auth.py | 5 + tests/test_following_frontend.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_pull_api.py | 229 ++++++++++++++++++ tests/test_release_receipt.py | 51 +++- tests/test_security_center.py | 6 + tests/test_service_worker.py | 32 +-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 25 files changed, 754 insertions(+), 43 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 99b28cb..6108d85 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,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 tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py tests/e2e/test_mobile_source_branch_cleanup_release.py + 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 tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py tests/e2e/test_mobile_source_branch_cleanup_release.py tests/e2e/test_mobile_release_failure_recovery.py release-candidate: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 4afe766..42e6c9c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ threads, create and self-assign issues, discover, claim, and release issue assig 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. +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 diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 158381f..bf6ab29 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -387,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, @@ -1290,7 +1291,7 @@ 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()); @@ -7368,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) diff --git a/frontend/release-receipt.js b/frontend/release-receipt.js index 87b53d3..db83d63 100644 --- a/frontend/release-receipt.js +++ b/frontend/release-receipt.js @@ -1,10 +1,11 @@ -function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = 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; @@ -149,6 +150,12 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d : '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; @@ -202,6 +209,21 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d 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); } @@ -318,6 +340,57 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d 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') { @@ -385,7 +458,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d schedule(); } - return { capture, restore, refresh, reviewFailure, retryFailure, deleteBranch, dismiss, bind, fetchJson }; + return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson }; } if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt; diff --git a/frontend/security-center.js b/frontend/security-center.js index 8908301..8f05fbf 100644 --- a/frontend/security-center.js +++ b/frontend/security-center.js @@ -232,6 +232,7 @@ 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', diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 343a10e..a3accc7 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index b060a81..872de6b 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1,4 +1,5 @@ import asyncio +import base64 import os import re import shlex @@ -12,6 +13,9 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/") GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") REVIEW_DIFF_MAX_BYTES = 64 * 1024 REVIEW_DIFF_MAX_LINES = 400 +ROLLBACK_MAX_FILES = 20 +ROLLBACK_MAX_FILE_BYTES = 256 * 1024 +ROLLBACK_MAX_TOTAL_BYTES = 1024 * 1024 AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3 _client: "GiteaTransport | None" = None @@ -181,6 +185,14 @@ class PullCreateConflictError(ValueError): """Raised when the selected source branch changed before pull creation.""" +class ReleaseRollbackConflictError(ValueError): + """Raised when current target content no longer permits an exact rollback.""" + + +class ReleaseRollbackUnsupportedError(ValueError): + """Raised when a merge cannot be represented as one bounded text rollback.""" + + class SourceBranchChangedError(ValueError): """Raised before deletion when a merged source branch has advanced.""" @@ -3113,6 +3125,193 @@ async def can_recover_merged_release( ) +async def prepare_release_rollback( + repository: str, number: int, commit_sha: str +) -> dict: + """Create one bounded reverse commit and draft pull for an exact merged pull.""" + login, pull = await _current_login_and_target( + f"repos/{repository}/pulls/{number}" + ) + author = pull.get("user") if isinstance(pull.get("user"), dict) else {} + if not ( + pull.get("state") == "closed" + and pull.get("merged") is True + and pull.get("merge_commit_sha") == commit_sha + and ( + author.get("login", "").casefold() == login.casefold() + or _login_in_users(login, pull.get("assignees")) + ) + ): + raise IssueNotAvailableError("merged pull request not found") + + access = await repository_access(repository) + permissions = access.get("permissions", {}) if isinstance(access, dict) else {} + default_branch = access.get("default_branch") if isinstance(access, dict) else None + if permissions.get("push") is not True or not isinstance(default_branch, str): + raise IssueNotAvailableError("writable repository not found") + + commit_response = await _get_client().get( + f"/api/v1/repos/{repository}/git/commits/{commit_sha}", headers=_auth() + ) + commit_response.raise_for_status() + merge_commit = commit_response.json() + parents = merge_commit.get("parents") if isinstance(merge_commit, dict) else None + files = merge_commit.get("files") if isinstance(merge_commit, dict) else None + if ( + merge_commit.get("sha") != commit_sha + or not isinstance(parents, list) + or len(parents) < 2 + or not isinstance(parents[0], dict) + or not isinstance(parents[0].get("sha"), str) + or not isinstance(files, list) + or not 1 <= len(files) <= ROLLBACK_MAX_FILES + ): + raise ReleaseRollbackUnsupportedError("merge is not a bounded merge commit") + parent_sha = parents[0]["sha"] + branch = f"{login}/rollback-{number}-{commit_sha[:12]}" + branch_path = quote(branch, safe="") + branch_response = await _get_client().get( + f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth() + ) + + if branch_response.status_code == 404: + async def content(path: str, ref: str) -> tuple[bytes, str] | None: + response = await _get_client().get( + f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}", + headers=_auth(), + params={"ref": ref}, + ) + if response.status_code == 404: + return None + response.raise_for_status() + value = response.json() + if ( + not isinstance(value, dict) + or value.get("type") != "file" + or value.get("encoding") != "base64" + or not isinstance(value.get("content"), str) + or not isinstance(value.get("sha"), str) + ): + raise ReleaseRollbackUnsupportedError("rollback contains a non-file entry") + try: + raw = base64.b64decode(value["content"], validate=True) + raw.decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise ReleaseRollbackUnsupportedError( + "rollback contains binary or invalid content" + ) from exc + if len(raw) > ROLLBACK_MAX_FILE_BYTES: + raise ReleaseRollbackUnsupportedError("rollback file is too large") + return raw, value["sha"] + + operations = [] + total_bytes = 0 + for changed in files: + filename = changed.get("filename") if isinstance(changed, dict) else None + status = changed.get("status") if isinstance(changed, dict) else None + if ( + not isinstance(filename, str) + or not filename + or filename.startswith("/") + or ".." in filename.split("/") + or status not in {"added", "modified", "removed"} + ): + raise ReleaseRollbackUnsupportedError("rollback contains an unsupported change") + merged = await content(filename, commit_sha) + parent = await content(filename, parent_sha) + current = await content(filename, default_branch) + if ( + (current is None) != (merged is None) + or ( + current is not None + and merged is not None + and current[0] != merged[0] + ) + ): + raise ReleaseRollbackConflictError( + f"{filename} changed after the failed release" + ) + if status == "added" and merged is not None and parent is None: + operations.append({ + "operation": "delete", "path": filename, "sha": current[1], + }) + elif status == "removed" and merged is None and parent is not None: + operations.append({ + "operation": "create", "path": filename, + "content": base64.b64encode(parent[0]).decode(), + }) + total_bytes += len(parent[0]) + elif status == "modified" and merged is not None and parent is not None: + operations.append({ + "operation": "update", "path": filename, "sha": current[1], + "content": base64.b64encode(parent[0]).decode(), + }) + total_bytes += len(parent[0]) + else: + raise ReleaseRollbackUnsupportedError("change metadata did not match content") + if total_bytes > ROLLBACK_MAX_TOTAL_BYTES: + raise ReleaseRollbackUnsupportedError("rollback content is too large") + + mutation = await _get_client().post( + f"/api/v1/repos/{repository}/contents", + headers=_auth(), + json={ + "branch": default_branch, + "new_branch": branch, + "message": f"Revert {commit_sha} from pull #{number}", + "files": operations, + }, + ) + if mutation.status_code in {409, 422}: + reconciled = await _get_client().get( + f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth() + ) + reconciled.raise_for_status() + branch_payload = reconciled.json() + branch_commit = ( + branch_payload.get("commit") if isinstance(branch_payload, dict) else None + ) + rollback_sha = ( + branch_commit.get("id") if isinstance(branch_commit, dict) else None + ) + if not isinstance(rollback_sha, str): + raise ValueError("Gitea did not confirm the concurrent rollback branch") + else: + mutation.raise_for_status() + payload = mutation.json() + created_commit = payload.get("commit") if isinstance(payload, dict) else None + rollback_sha = created_commit.get("sha") if isinstance(created_commit, dict) else None + if not isinstance(rollback_sha, str): + raise ValueError("Gitea did not confirm rollback commit creation") + else: + branch_response.raise_for_status() + branch_payload = branch_response.json() + branch_commit = branch_payload.get("commit") if isinstance(branch_payload, dict) else None + rollback_sha = branch_commit.get("id") if isinstance(branch_commit, dict) else None + if not isinstance(rollback_sha, str): + raise ValueError("Gitea did not confirm the rollback branch") + + title = f"Rollback #{number}: {pull.get('title', 'failed release')}" + body = ( + f"Rollback of #{number} at `{commit_sha}` after failed release checks.\n\n" + "This draft reverses only files that still matched the failed merge." + ) + result = await create_pull( + repository, + head=branch, + base=default_branch, + title=title, + body=body, + draft=True, + expected_head_sha=rollback_sha, + ) + return { + **result, + "rollback_of": commit_sha, + "files_changed": len(files), + } + + async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" diff --git a/src/main.py b/src/main.py index 2ccdb7a..2d9799a 100644 --- a/src/main.py +++ b/src/main.py @@ -498,6 +498,7 @@ class FollowingNotificationPayload(BaseModel): StepUpAction = Literal[ "merge_pull", "delete_source_branch", + "prepare_release_rollback", "submit_pull_review", "close_issue", "delete_comment", @@ -7588,6 +7589,103 @@ async def retry_release_action_job( ) +@app.post( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/release-receipt/{commit_sha}/rollback" +) +async def prepare_release_rollback( + request: Request, + owner: str, + repo: str, + number: int = PathParam(gt=0), + commit_sha: str = PathParam( + min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$" + ), + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), +) -> JSONResponse: + repository = f"{owner}/{repo}" + target = f"{repository}#{number}@{commit_sha}" + await _require_step_up( + request, step_up_grant, action="prepare_release_rollback", target=target + ) + try: + status = await asyncio.wait_for( + gitea_proxy.release_receipt_status(repository, commit_sha), + timeout=REVIEW_DETAIL_TIMEOUT_SECONDS, + ) + except Exception: + return JSONResponse( + {"error": "The failed release could not be revalidated. No rollback was created."}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "1"}, + ) + if ( + status.get("commit_sha") != commit_sha + or status.get("ci_state") != "failure" + or status.get("release") is not None + ): + return JSONResponse( + {"error": "Only an unreleased commit with failed checks can be rolled back."}, + status_code=409, + headers={"Cache-Control": "no-store"}, + ) + + journal = _security_event_store() + try: + operation_id = await asyncio.to_thread( + journal.reserve, "release_rollback_prepared", target=target + ) + except SecurityEventStoreError: + return JSONResponse( + {"error": "Security activity is temporarily unavailable. No rollback was created."}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "1"}, + ) + try: + result = await asyncio.wait_for( + gitea_proxy.prepare_release_rollback(repository, number, commit_sha), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + pass + return JSONResponse( + result, + status_code=200 if result.get("existing") else 201, + headers={"Cache-Control": "no-store"}, + ) + except gitea_proxy.ReleaseRollbackConflictError as exc: + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass + return JSONResponse( + {"error": str(exc)}, status_code=409, headers={"Cache-Control": "no-store"} + ) + except gitea_proxy.ReleaseRollbackUnsupportedError as exc: + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass + return JSONResponse( + {"error": str(exc)}, status_code=422, headers={"Cache-Control": "no-store"} + ) + except gitea_proxy.IssueNotAvailableError: + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass + raise HTTPException(status_code=404, detail="Merged pull request not found") + except Exception: + return JSONResponse( + {"error": "Rollback preparation could not be confirmed. Retry to reconcile it."}, + status_code=503, + headers={"Cache-Control": "no-store", "Retry-After": "1"}, + ) + + @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) async def submit_review( submission: PullReviewSubmission, diff --git a/tests/e2e/test_mobile_release_failure_recovery.py b/tests/e2e/test_mobile_release_failure_recovery.py index 6787d62..839bd60 100644 --- a/tests/e2e/test_mobile_release_failure_recovery.py +++ b/tests/e2e/test_mobile_release_failure_recovery.py @@ -89,3 +89,47 @@ def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport): 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() diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index e70ed7e..cf09d15 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 8e41785..73e62c5 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -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 @@ -13,6 +14,10 @@ from src.session_store import 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") diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 63e6f0e..f7cc7ed 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -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(): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 77f827d..1ff636e 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 02397e3..7ef6a73 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 6fbff3f..15b2b2e 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index a548756..2d53e1b 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -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 diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index fcefe17..e59eccf 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -274,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 diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index c7a6570..526b826 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index a322539..e2afbc2 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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 diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py index a72cc49..6a0b8fb 100644 --- a/tests/test_pull_api.py +++ b/tests/test_pull_api.py @@ -1,4 +1,5 @@ import asyncio +import base64 import json import httpx @@ -8,6 +9,15 @@ from src import gitea_proxy, main from src.security_event_store import SecurityEventStoreError +def _content_payload(text: str, sha: str) -> dict: + return { + "type": "file", + "encoding": "base64", + "content": base64.b64encode(text.encode()).decode(), + "sha": sha, + } + + @pytest.mark.anyio async def test_gitea_updates_authored_pull_branch_and_confirms_new_head(): requests = [] @@ -2320,3 +2330,222 @@ async def test_source_branch_cleanup_resolves_a_lost_delete_response_with_one_ab assert result["deleted"] is True assert sum(method == "GET" and path.endswith("/branches/timmy%2Ffeature") for method, path in requests) == 2 + + +@pytest.mark.anyio +async def test_prepare_release_rollback_creates_one_atomic_draft_pull_from_exact_merge_parent(): + requests = [] + branch_created = False + merge_sha = "abc1234def5678" + parent_sha = "1111111aaaaaaa" + current_sha = "2222222bbbbbbb" + rollback_sha = "3333333ccccccc" + branch = "timmy/rollback-7-abc1234def56" + + async def handler(request): + nonlocal branch_created + path = request.url.raw_path.decode().split("?", 1)[0] + requests.append((request.method, path)) + if path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if path.endswith("/pulls/7") and request.method == "GET": + return httpx.Response(200, json={ + "number": 7, + "state": "closed", + "merged": True, + "merge_commit_sha": merge_sha, + "title": "Ship release", + "user": {"login": "timmy"}, + "assignees": [], + }) + if path.endswith("/repos/stackchain/api"): + return httpx.Response(200, json={ + "default_branch": "main", "permissions": {"push": True}, + }) + if path.endswith(f"/git/commits/{merge_sha}"): + return httpx.Response(200, json={ + "sha": merge_sha, + "parents": [{"sha": parent_sha}, {"sha": "feature-parent"}], + "files": [{"filename": "app.txt", "status": "modified"}], + }) + if path.endswith("/contents/app.txt"): + ref = request.url.params.get("ref") + if ref == merge_sha: + return httpx.Response(200, json=_content_payload("broken\n", "merge-blob")) + if ref == parent_sha: + return httpx.Response(200, json=_content_payload("working\n", "parent-blob")) + if ref == "main": + return httpx.Response(200, json=_content_payload("broken\n", "current-blob")) + if path.endswith("/branches/timmy%2Frollback-7-abc1234def56"): + if request.method == "GET": + return httpx.Response( + 200, json={"commit": {"id": rollback_sha}} + ) if branch_created else httpx.Response(404) + if path.endswith("/contents") and request.method == "POST": + body = json.loads(request.content) + assert body == { + "branch": "main", + "new_branch": branch, + "message": f"Revert {merge_sha} from pull #7", + "files": [{ + "operation": "update", + "path": "app.txt", + "sha": "current-blob", + "content": base64.b64encode(b"working\n").decode(), + }], + } + branch_created = True + return httpx.Response(201, json={"commit": {"sha": rollback_sha}}) + if path.endswith("/pulls") and request.method == "GET": + return httpx.Response(200, json=[]) + if path.endswith("/pulls") and request.method == "POST": + body = json.loads(request.content) + assert body["head"] == branch + assert body["base"] == "main" + assert body["draft"] is True + assert f"Rollback of #{7} at `{merge_sha}`" in body["body"] + return httpx.Response(201, json={ + "number": 8, + "title": body["title"], + "body": body["body"], + "state": "open", + "draft": True, + "head": {"ref": branch, "sha": rollback_sha}, + "base": {"ref": "main"}, + "user": {"login": "timmy"}, + "html_url": "https://forge.example/git/stackchain/api/pulls/8", + }) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.prepare_release_rollback( + "stackchain/api", 7, merge_sha + ) + finally: + await gitea_proxy.stop_client() + + assert result["number"] == 8 + assert result["draft"] is True + assert result["head"] == {"ref": branch, "sha": rollback_sha} + assert result["rollback_of"] == merge_sha + assert result["files_changed"] == 1 + assert sum(method == "POST" and path.endswith("/contents") for method, path in requests) == 1 + assert sum(method == "POST" and path.endswith("/pulls") for method, path in requests) == 1 + + +@pytest.mark.anyio +async def test_release_rollback_endpoint_requires_failed_exact_commit_and_audits_draft_pull(monkeypatch): + lifecycle = [] + + class Journal: + def reserve(self, kind, *, target): + lifecycle.append(("reserve", kind, target)) + return "rollback-operation" + + def finalize(self, operation_id): + lifecycle.append(("finalize", operation_id)) + + def discard(self, operation_id): + lifecycle.append(("discard", operation_id)) + + async def status(repository, commit_sha): + lifecycle.append(("status", repository, commit_sha)) + return {"commit_sha": commit_sha, "ci_state": "failure", "release": None} + + async def prepare(repository, number, commit_sha): + lifecycle.append(("prepare", repository, number, commit_sha)) + return { + "repository": repository, + "number": 9, + "state": "open", + "draft": True, + "head": {"ref": "timmy/rollback-7-abc1234", "sha": "def5678"}, + "base": {"ref": "main"}, + "url": "https://forge.example/git/stackchain/api/pulls/9", + "rollback_of": commit_sha, + "files_changed": 2, + "existing": False, + } + + monkeypatch.setattr(main, "_security_event_store", lambda: Journal()) + monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", status) + monkeypatch.setattr(main.gitea_proxy, "prepare_release_rollback", prepare, 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/7/release-receipt/abc1234/rollback" + ) + + assert response.status_code == 201 + assert response.headers["cache-control"] == "no-store" + assert response.json()["rollback_of"] == "abc1234" + assert lifecycle == [ + ("status", "stackchain/api", "abc1234"), + ("reserve", "release_rollback_prepared", "stackchain/api#7@abc1234"), + ("prepare", "stackchain/api", 7, "abc1234"), + ("finalize", "rollback-operation"), + ] + + +@pytest.mark.anyio +async def test_prepare_release_rollback_reconciles_a_concurrent_branch_creation_without_duplicate_commit(): + merge_sha = "abc1234def5678" + rollback_sha = "3333333ccccccc" + branch_reads = 0 + pull_created = False + + async def handler(request): + nonlocal branch_reads, pull_created + path = request.url.raw_path.decode().split("?", 1)[0] + if path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if path.endswith("/pulls/7"): + return httpx.Response(200, json={ + "state": "closed", "merged": True, "merge_commit_sha": merge_sha, + "title": "Ship release", "user": {"login": "timmy"}, "assignees": [], + }) + if path.endswith("/repos/stackchain/api"): + return httpx.Response(200, json={"default_branch": "main", "permissions": {"push": True}}) + if path.endswith(f"/git/commits/{merge_sha}"): + return httpx.Response(200, json={ + "sha": merge_sha, "parents": [{"sha": "parent123"}, {"sha": "feature123"}], + "files": [{"filename": "app.txt", "status": "modified"}], + }) + if path.endswith("/contents/app.txt"): + ref = request.url.params["ref"] + return httpx.Response(200, json=_content_payload( + "working\n" if ref == "parent123" else "broken\n", + "current-blob" if ref == "main" else ref + "-blob", + )) + if path.endswith("/branches/timmy%2Frollback-7-abc1234def56"): + branch_reads += 1 + return httpx.Response(404) if branch_reads == 1 else httpx.Response( + 200, json={"commit": {"id": rollback_sha}} + ) + if path.endswith("/contents") and request.method == "POST": + return httpx.Response(422, json={"message": "branch already exists"}) + if path.endswith("/pulls") and request.method == "GET": + return httpx.Response(200, json=[]) + if path.endswith("/pulls") and request.method == "POST": + pull_created = True + body = json.loads(request.content) + return httpx.Response(201, json={ + "number": 9, "title": body["title"], "body": body["body"], + "state": "open", "draft": True, + "head": {"ref": body["head"], "sha": rollback_sha}, + "base": {"ref": body["base"]}, "user": {"login": "timmy"}, + "html_url": "https://forge.example/git/stackchain/api/pulls/9", + }) + raise AssertionError(f"unexpected request {request.method} {request.url}") + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.prepare_release_rollback("stackchain/api", 7, merge_sha) + finally: + await gitea_proxy.stop_client() + + assert result["number"] == 9 + assert result["head"]["sha"] == rollback_sha + assert branch_reads == 3 # initial check, race reconciliation, pull-creation revalidation + assert pull_created is True diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py index fd62cc3..b9696fb 100644 --- a/tests/test_release_receipt.py +++ b/tests/test_release_receipt.py @@ -174,6 +174,54 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, { 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(); @@ -345,8 +393,9 @@ 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 diff --git a/tests/test_security_center.py b/tests/test_security_center.py index 26e1ff9..dfa8fa3 100644 --- a/tests/test_security_center.py +++ b/tests/test_security_center.py @@ -12,6 +12,12 @@ def test_source_branch_cleanup_has_a_specific_security_activity_label(): 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))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2e22437..4bae303 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 4f381ad..d80a9d7 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 9f9b024..2e2c710 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -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 From f0be3a44a6c5374c614c1ab10dd8363255145f92 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 00:21:58 +0000 Subject: [PATCH 2/2] ci: retry transient mobile navigation journey