Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d97b2e84a | ||
|
|
502fcb30f5 | ||
|
|
b541c707fc | ||
|
|
b40a44fc45 | ||
|
|
d1adbe72d6 | ||
|
|
d6e6cf2e67 | ||
|
|
53706f18ab | ||
|
|
f0be3a44a6 | ||
|
|
b753cdd922 | ||
|
|
400534f446 | ||
|
|
a43ecac94b |
No files matched your search
@@ -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 -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1310,6 +1310,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; }
|
||||
|
||||
+12
-11
@@ -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)
|
||||
|
||||
@@ -1741,6 +1741,18 @@
|
||||
<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>
|
||||
<button id="make-pull-feedback-fix" type="button" hidden>Make code change</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>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -432,6 +432,8 @@ 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 found = focusPullFile(doc, comment.path);
|
||||
qs('#pull-feedback-context').textContent = found ?
|
||||
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
|
||||
@@ -475,10 +477,77 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
|
||||
active = { item, detail, reviewer, login,
|
||||
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
|
||||
panel.hidden = false;
|
||||
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('#pull-feedback-status').textContent = active.state.posted ?
|
||||
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
|
||||
render();
|
||||
});
|
||||
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;
|
||||
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();
|
||||
@@ -701,6 +770,7 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
|
||||
let lifecycleMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
let feedbackFixRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const feedbackRequests = new Map();
|
||||
@@ -764,6 +834,30 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
|
||||
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;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
-4
@@ -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,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."""
|
||||
|
||||
@@ -2209,6 +2221,124 @@ async def reopen_authored_pull(
|
||||
)
|
||||
|
||||
|
||||
async def authored_pull_feedback_file(
|
||||
repository: str, number: int, path: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
"""Load one bounded UTF-8 file from an authored same-repository pull head."""
|
||||
if (
|
||||
not path or path.startswith("/") or "\\" in path
|
||||
or any(part in {"", ".", ".."} for part in path.split("/"))
|
||||
):
|
||||
raise IssueNotAvailableError("File path is not eligible")
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
|
||||
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
||||
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != "open" or pull.get("merged") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
or head_repo.get("full_name") != repository
|
||||
or base_repo.get("full_name") != repository
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
|
||||
headers=_auth(), params={"ref": expected_head_sha},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise IssueNotAvailableError("File is not editable text")
|
||||
encoded = payload.get("content")
|
||||
if (
|
||||
payload.get("type") != "file" or payload.get("encoding") != "base64"
|
||||
or not isinstance(encoded, str) or not isinstance(payload.get("sha"), str)
|
||||
):
|
||||
raise IssueNotAvailableError("File is not editable text")
|
||||
try:
|
||||
raw = base64.b64decode(encoded, validate=True)
|
||||
content = raw.decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise IssueNotAvailableError("File is not editable text") from None
|
||||
if len(raw) > 128 * 1024 or "\x00" in content:
|
||||
raise IssueNotAvailableError("File is not editable text")
|
||||
return {
|
||||
"repository": repository, "number": number, "path": path,
|
||||
"head_sha": expected_head_sha, "blob_sha": payload["sha"],
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
async def commit_authored_pull_feedback_fix(
|
||||
repository: str,
|
||||
number: int,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
expected_head_sha: str,
|
||||
expected_blob_sha: str,
|
||||
) -> dict:
|
||||
"""Commit and verify one race-guarded text-file fix on an authored pull."""
|
||||
encoded = content.encode("utf-8")
|
||||
message = message.strip()
|
||||
if len(encoded) > 128 * 1024 or "\x00" in content or not message or len(message) > 120:
|
||||
raise IssueNotAvailableError("Feedback fix is outside the editable bounds")
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
|
||||
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
||||
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
|
||||
branch = head.get("ref")
|
||||
if (
|
||||
pull.get("state") != "open" or pull.get("merged") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
or head_repo.get("full_name") != repository
|
||||
or base_repo.get("full_name") != repository
|
||||
or not isinstance(branch, str) or not branch
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
current = await authored_pull_feedback_file(
|
||||
repository, number, path, expected_head_sha
|
||||
)
|
||||
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
|
||||
raise IssueNotAvailableError("File changed or has no new content")
|
||||
response = await _get_client().put(
|
||||
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
|
||||
headers=_auth(),
|
||||
json={
|
||||
"branch": branch, "sha": expected_blob_sha, "message": message,
|
||||
"content": base64.b64encode(encoded).decode(),
|
||||
},
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise IssueNotAvailableError("Pull request or file changed")
|
||||
response.raise_for_status()
|
||||
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
|
||||
new_head_sha = confirmed_head.get("sha")
|
||||
if not isinstance(new_head_sha, str) or not new_head_sha or new_head_sha == expected_head_sha:
|
||||
raise ValueError("Gitea did not confirm a new pull request head")
|
||||
confirmed = await authored_pull_feedback_file(
|
||||
repository, number, path, new_head_sha
|
||||
)
|
||||
if confirmed["content"] != content:
|
||||
raise ValueError("Gitea did not confirm the committed file content")
|
||||
return {
|
||||
"repository": repository, "number": number, "path": path,
|
||||
"previous_head_sha": expected_head_sha, "head_sha": new_head_sha,
|
||||
"blob_sha": confirmed["blob_sha"], "message": message,
|
||||
}
|
||||
|
||||
|
||||
async def update_authored_pull_branch(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
@@ -3113,6 +3243,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}"
|
||||
|
||||
+236
-5
@@ -136,6 +136,18 @@ async def _following_push_snapshot() -> dict:
|
||||
return await get_following(Response())
|
||||
|
||||
|
||||
async def _identity_bound_push_session_statuses(
|
||||
management_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
user = await gitea_proxy.current_user()
|
||||
principal_id = user.get("id") if isinstance(user, dict) else None
|
||||
if isinstance(principal_id, bool) or not isinstance(principal_id, int) or principal_id <= 0:
|
||||
raise ValueError("Authenticated Gitea identity is unavailable")
|
||||
return await dashboard_auth.managed_session_statuses(
|
||||
management_ids, expected_principal_id=principal_id
|
||||
)
|
||||
|
||||
|
||||
async def _push_poll_loop() -> None:
|
||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||
deadline_interval = max(
|
||||
@@ -160,7 +172,7 @@ async def _push_poll_loop() -> None:
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
gitea_proxy.unread_notification_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
lease_seconds=lease_seconds,
|
||||
send_timeout_seconds=send_timeout,
|
||||
max_concurrency=max_concurrency,
|
||||
@@ -172,7 +184,7 @@ async def _push_poll_loop() -> None:
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
gitea_proxy.assigned_issue_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
@@ -183,7 +195,7 @@ async def _push_poll_loop() -> None:
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_following_push_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
@@ -194,7 +206,7 @@ async def _push_poll_loop() -> None:
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_start_day_plan_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
@@ -429,6 +441,21 @@ class ReadinessPayloadError(ValueError):
|
||||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
||||
|
||||
|
||||
async def _upstream_identity() -> tuple[int, str]:
|
||||
upstream = await current_user()
|
||||
principal_id = upstream.get("id") if isinstance(upstream, dict) else None
|
||||
principal_login = upstream.get("login") if isinstance(upstream, dict) else None
|
||||
if (
|
||||
not isinstance(principal_id, int)
|
||||
or isinstance(principal_id, bool)
|
||||
or principal_id <= 0
|
||||
or not isinstance(principal_login, str)
|
||||
or not principal_login
|
||||
):
|
||||
raise ValueError("Gitea identity response is incomplete")
|
||||
return principal_id, principal_login
|
||||
|
||||
|
||||
class DashboardSignIn(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||
@@ -497,6 +524,8 @@ class FollowingNotificationPayload(BaseModel):
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"delete_source_branch",
|
||||
"prepare_release_rollback",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"delete_comment",
|
||||
@@ -1123,6 +1152,16 @@ class PullReadyRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PullFeedbackFileUpdate(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
content: str = Field(max_length=131_072)
|
||||
message: str = Field(min_length=1, max_length=120)
|
||||
expected_head_sha: str = Field(
|
||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
||||
)
|
||||
expected_blob_sha: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PullContentUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
@@ -1561,6 +1600,27 @@ async def require_operator_session(request: Request, call_next):
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if session is not None:
|
||||
try:
|
||||
current_principal_id, current_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
if session.principal_id != current_principal_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"detail": "Connected Gitea account changed; sign in again",
|
||||
"code": "session_idle",
|
||||
"reason": "upstream_identity_changed",
|
||||
"previous_login": session.principal_login,
|
||||
"current_login": current_login,
|
||||
},
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if not public and session is None:
|
||||
if path.startswith("/api/"):
|
||||
payload = {"detail": "Authentication required"}
|
||||
@@ -1734,9 +1794,20 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
try:
|
||||
signed, session = await asyncio.to_thread(
|
||||
dashboard_auth.issue_session, device_label=payload.device_label
|
||||
dashboard_auth.issue_session,
|
||||
device_label=payload.device_label,
|
||||
principal_id=principal_id,
|
||||
principal_login=principal_login,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
@@ -2324,11 +2395,21 @@ async def verify_passkey_authentication(
|
||||
target="sign_in:dashboard",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||
signed, session = await asyncio.to_thread(
|
||||
dashboard_auth.issue_session,
|
||||
device_label=stored.device_label,
|
||||
management_id=stored.management_id,
|
||||
principal_id=principal_id,
|
||||
principal_login=principal_login,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
@@ -6883,6 +6964,59 @@ async def update_authored_pull_branch(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-file")
|
||||
async def authored_pull_feedback_file(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
path: str = Query(min_length=1, max_length=1024),
|
||||
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
||||
):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.authored_pull_feedback_file(
|
||||
f"{owner}/{repo}", number, path, expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse({"error": "The pull request or file changed. Reload before editing."}, status_code=409)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The review file could not be loaded. Please retry."},
|
||||
status_code=503, headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-file")
|
||||
async def commit_authored_pull_feedback_fix(
|
||||
update: PullFeedbackFileUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.commit_authored_pull_feedback_fix(
|
||||
f"{owner}/{repo}", number, update.path, update.content,
|
||||
update.message, update.expected_head_sha, update.expected_blob_sha,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request or file changed. Your draft was not committed."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The feedback fix could not be confirmed. Keep the draft and retry."},
|
||||
status_code=503, headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||
async def assigned_pull_review_data(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
@@ -7587,6 +7721,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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -48,7 +48,7 @@ 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:") :]
|
||||
@@ -58,66 +58,6 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
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
|
||||
|
||||
|
||||
def test_browser_job_gates_mobile_source_branch_cleanup_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_source_branch_cleanup_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_authored_pull_close_recovery_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_search_authored_pull_recovery_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_search_authored_pull_recovery_release.py" in browser
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
@@ -8,6 +9,144 @@ 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_loads_bounded_text_file_from_authored_pull_head():
|
||||
pull = {
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"head": {
|
||||
"sha": "abc1234", "ref": "alex/review-fix",
|
||||
"repo": {"full_name": "stackchain/api"},
|
||||
},
|
||||
"base": {"repo": {"full_name": "stackchain/api"}},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py":
|
||||
assert request.url.params["ref"] == "abc1234"
|
||||
return httpx.Response(200, json=_content_payload("return empty\n", "blob123"))
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.authored_pull_feedback_file(
|
||||
"stackchain/api", 7, "src/api.py", "abc1234"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 7, "path": "src/api.py",
|
||||
"head_sha": "abc1234", "blob_sha": "blob123", "content": "return empty\n",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
|
||||
requests = []
|
||||
pull = {
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"head": {
|
||||
"sha": "abc1234", "ref": "alex/review-fix",
|
||||
"repo": {"full_name": "stackchain/api"},
|
||||
},
|
||||
"base": {"repo": {"full_name": "stackchain/api"}},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "GET":
|
||||
ref = request.url.params["ref"]
|
||||
return httpx.Response(200, json=_content_payload(
|
||||
"return handled\n" if ref == "def5678" else "return empty\n",
|
||||
"blob456" if ref == "def5678" else "blob123",
|
||||
))
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "PUT":
|
||||
body = json.loads(request.content)
|
||||
assert body == {
|
||||
"branch": "alex/review-fix", "sha": "blob123",
|
||||
"message": "fix: handle empty state",
|
||||
"content": base64.b64encode(b"return handled\n").decode(),
|
||||
}
|
||||
pull["head"]["sha"] = "def5678"
|
||||
return httpx.Response(200, json={"commit": {"sha": "def5678"}})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.commit_authored_pull_feedback_fix(
|
||||
"stackchain/api", 7, "src/api.py", "return handled\n",
|
||||
"fix: handle empty state", "abc1234", "blob123",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["previous_head_sha"] == "abc1234"
|
||||
assert result["head_sha"] == "def5678"
|
||||
assert result["path"] == "src/api.py"
|
||||
assert requests[-2:] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/contents/src/api.py"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def load(repository, number, path, head):
|
||||
calls.append(("load", repository, number, path, head))
|
||||
return {"path": path, "head_sha": head, "blob_sha": "blob123", "content": "before\n"}
|
||||
|
||||
async def commit(repository, number, path, content, message, head, blob):
|
||||
calls.append(("commit", repository, number, path, content, message, head, blob))
|
||||
return {"path": path, "previous_head_sha": head, "head_sha": "def5678", "blob_sha": "blob456"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "authored_pull_feedback_file", load, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_fix", commit, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
loaded = await client.get(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/feedback-file",
|
||||
params={"path": "src/api.py", "expected_head_sha": "abc1234"},
|
||||
)
|
||||
committed = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/feedback-file",
|
||||
json={
|
||||
"path": "src/api.py", "content": "after\n",
|
||||
"message": "fix: address review", "expected_head_sha": "abc1234",
|
||||
"expected_blob_sha": "blob123",
|
||||
},
|
||||
)
|
||||
|
||||
assert loaded.status_code == 200
|
||||
assert loaded.json()["blob_sha"] == "blob123"
|
||||
assert committed.status_code == 200
|
||||
assert committed.json()["head_sha"] == "def5678"
|
||||
assert calls == [
|
||||
("load", "stackchain/api", 7, "src/api.py", "abc1234"),
|
||||
("commit", "stackchain/api", 7, "src/api.py", "after\n", "fix: address review", "abc1234", "blob123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_updates_authored_pull_branch_and_confirms_new_head():
|
||||
requests = []
|
||||
@@ -2320,3 +2459,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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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))});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user