Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40a44fc45 | ||
|
|
d1adbe72d6 | ||
|
|
d6e6cf2e67 | ||
|
|
53706f18ab | ||
|
|
f0be3a44a6 | ||
|
|
b753cdd922 | ||
|
|
400534f446 | ||
|
|
a43ecac94b | ||
|
|
d6993c40fb | ||
|
|
966116e5ee | ||
|
|
d4ddac07ab | ||
|
|
b6684bb7ba | ||
|
|
07f5770aa8 | ||
|
|
f3876d359d | ||
|
|
e5b9833096 | ||
|
|
ae98564d9b | ||
|
|
b85fe4a0a3 | ||
|
|
a9becd079f | ||
|
|
99750d44f5 | ||
|
|
e4d0cc9115 | ||
|
|
77f22f3cd5 | ||
|
|
fde3ba21ca | ||
|
|
8cb92c6858 | ||
|
|
3e7a6c81fc | ||
|
|
084c0b5732 | ||
|
|
c73fbf8379 | ||
|
|
b47ed5d86e | ||
|
|
f0f415c65d | ||
|
|
2102b0e5da | ||
|
|
050390d6f6 |
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
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py tests/e2e/test_mobile_source_branch_cleanup_release.py tests/e2e/test_mobile_release_failure_recovery.py
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
|
||||
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
|
||||
let operationId = createId();
|
||||
let current = {
|
||||
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
|
||||
title:'', body:'', draft:true,
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
|
||||
if (saved && typeof saved === 'object') {
|
||||
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
|
||||
if (typeof saved[field] === 'string') current[field] = saved[field];
|
||||
}
|
||||
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function snapshot() {
|
||||
return JSON.parse(JSON.stringify(current));
|
||||
}
|
||||
|
||||
function persist() {
|
||||
storage?.setItem?.(storageKey, JSON.stringify({
|
||||
repository:current.repository, head:current.head, base:current.base,
|
||||
title:current.title, body:current.body, draft:current.draft,
|
||||
}));
|
||||
}
|
||||
|
||||
async function selectRepository(repository) {
|
||||
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
|
||||
const branches = Array.isArray(options?.branches) ? options.branches : [];
|
||||
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
|
||||
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
|
||||
current = {
|
||||
...current, repository, branches, base,
|
||||
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function update(values) {
|
||||
current = {...current, ...values};
|
||||
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
|
||||
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
|
||||
}
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
|
||||
method:'POST',
|
||||
headers:{'Idempotency-Key':operationId},
|
||||
body:{
|
||||
head:current.head, base:current.base, title:current.title, body:current.body,
|
||||
draft:current.draft, expected_head_sha:current.expected_head_sha,
|
||||
},
|
||||
});
|
||||
operationId = createId();
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
state:snapshot,
|
||||
selectRepository,
|
||||
update,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
function createBinding({controller, render, status, onCreated}) {
|
||||
return {
|
||||
async repositoryChanged(repository) {
|
||||
status('Loading branches…');
|
||||
try {
|
||||
const state = await controller.selectRepository(repository);
|
||||
render(state);
|
||||
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
|
||||
return state;
|
||||
} catch (error) {
|
||||
status(error.message || 'Branches could not be loaded.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
changed(values) {
|
||||
const state = controller.update(values);
|
||||
render(state);
|
||||
return state;
|
||||
},
|
||||
async submit() {
|
||||
status('Creating pull request…');
|
||||
try {
|
||||
const result = await controller.submit();
|
||||
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
|
||||
onCreated(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
status(error.message || 'The pull request could not be created. Your draft is safe.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function bindDashboard() {
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const switchButton = qs('#switch-to-create-pull');
|
||||
switchButton.disabled = true;
|
||||
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
|
||||
const identity = await identityResponse?.json().catch(()=>({}));
|
||||
if (!identityResponse?.ok || !identity?.login) {
|
||||
switchButton.title = 'Account identity is unavailable.';
|
||||
return null;
|
||||
}
|
||||
const login = identity.login;
|
||||
const root = qs('#create-pull-sheet');
|
||||
const repository = qs('#create-pull-repository');
|
||||
const head = qs('#create-pull-head');
|
||||
const base = qs('#create-pull-base');
|
||||
const title = qs('#create-pull-title');
|
||||
const body = qs('#create-pull-body');
|
||||
const submit = qs('#submit-create-pull');
|
||||
const request = async (url, options) => {
|
||||
const response = await fetch(url, options ? {
|
||||
method:options.method,
|
||||
headers:{'Content-Type':'application/json', ...options.headers},
|
||||
body:JSON.stringify(options.body),
|
||||
} : undefined);
|
||||
const payload = await response.json().catch(()=>({}));
|
||||
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
|
||||
return payload;
|
||||
};
|
||||
const controller = createController({request, storage:localStorage, login});
|
||||
const setOptions = (select, branches) => {
|
||||
select.replaceChildren(...branches.map(branch => {
|
||||
const option = document.createElement('option');
|
||||
option.value = option.textContent = branch.name;
|
||||
return option;
|
||||
}));
|
||||
};
|
||||
const render = state => {
|
||||
setOptions(head, state.branches);
|
||||
setOptions(base, state.branches);
|
||||
head.value = state.head;
|
||||
base.value = state.base;
|
||||
head.disabled = base.disabled = !state.branches.length;
|
||||
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
|
||||
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
|
||||
};
|
||||
const binding = createBinding({
|
||||
controller, render,
|
||||
status:message=>{ qs('#create-pull-status').textContent = message; },
|
||||
onCreated:result=>{
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const update = () => binding.changed({
|
||||
head:head.value, base:base.value, title:title.value, body:body.value,
|
||||
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
|
||||
});
|
||||
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
|
||||
[head,base,title,body].forEach(field => field.addEventListener('input', update));
|
||||
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
|
||||
update();
|
||||
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
|
||||
}));
|
||||
qs('#create-pull-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
update();
|
||||
binding.submit().catch(()=>{});
|
||||
});
|
||||
const close = () => {
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
qs('#new-issue').focus();
|
||||
};
|
||||
qs('#cancel-create-pull').addEventListener('click', close);
|
||||
switchButton.addEventListener('click', () => {
|
||||
qs('#cancel-new-issue').click();
|
||||
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.value;
|
||||
option.textContent = source.textContent;
|
||||
return option;
|
||||
}));
|
||||
root.hidden = false;
|
||||
qs('main').inert = true;
|
||||
qs('#cancel-create-pull').focus();
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
});
|
||||
switchButton.disabled = false;
|
||||
return binding;
|
||||
}
|
||||
|
||||
const cp = {createController, createBinding, b:bindDashboard};
|
||||
if (typeof module !== 'undefined') module.exports = cp;
|
||||
else cp.b();
|
||||
+45
-3
@@ -35,7 +35,20 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
|
||||
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
|
||||
.release-watchlist-item > button { width:auto; min-width:88px; }
|
||||
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
|
||||
.release-watchlist-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:min(240px,46vw); }
|
||||
.release-watchlist-actions > button { min-height:44px; width:100%; }
|
||||
.release-branch-cleanup-status { color:#cbd5e1; }
|
||||
.release-failure-summary { display:grid; gap:8px; min-width:0; margin-top:8px; padding-top:8px; border-top:1px solid #334155; overflow-x:hidden; }
|
||||
.release-failure-summary > button { min-height:44px; }
|
||||
.release-failure-recovery { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #7f1d1d; border-radius:8px; background:#180f17; overflow-x:hidden; }
|
||||
.release-failure-recovery pre { box-sizing:border-box; max-width:100%; max-height:32dvh; margin:0; padding:10px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:#07101d; }
|
||||
.release-failure-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
|
||||
.release-failure-actions > button, .release-failure-actions > a { min-height:44px; width:100%; }
|
||||
@media (max-width:359px) {
|
||||
.release-receipt-actions, .release-watchlist-actions, .release-failure-actions { grid-template-columns:1fr; }
|
||||
.release-watchlist-item { grid-template-columns:1fr; }
|
||||
.release-watchlist-actions { min-width:0; width:100%; }
|
||||
}
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
@@ -792,6 +805,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
|
||||
.ci-check-inspect { min-height:44px; white-space:nowrap; }
|
||||
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
|
||||
.ci-check-recovery[hidden] { display:none; }
|
||||
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
|
||||
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
|
||||
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
|
||||
.ci-check-recovery-actions button { min-height:44px; }
|
||||
.pull-branch-update { max-width:100%; min-width:0; margin:10px 0; padding:12px; border:1px solid #60a5fa; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.pull-branch-update h3 { margin:0 0 6px; }
|
||||
.pull-branch-update button { width:100%; min-height:44px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
@@ -958,8 +982,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
|
||||
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.pull-lifecycle { border-color:#b45353; }
|
||||
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
|
||||
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
|
||||
.pull-close-receipt h3 { margin-top:0; }
|
||||
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
|
||||
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
@@ -1523,3 +1552,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.create-pull-sheet{position:fixed;inset:0;z-index:75;background:rgba(3,7,18,.72);display:grid;place-items:end center;padding:16px}
|
||||
.create-pull-sheet[hidden]{display:none}
|
||||
.create-pull-panel{width:min(100%,680px);max-height:calc(100dvh - 32px);overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}
|
||||
.create-pull-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.create-pull-header h3{margin:2px 0 0}
|
||||
.create-pull-panel form,.create-pull-panel label{display:grid;gap:6px}
|
||||
.create-pull-panel form{gap:14px}
|
||||
.create-pull-branches{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.create-pull-mode{display:flex;gap:18px;border:1px solid var(--border);border-radius:12px;padding:10px 12px}
|
||||
.create-pull-mode label{display:flex;align-items:center;gap:7px}
|
||||
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
|
||||
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
|
||||
+33
-21
@@ -368,6 +368,7 @@
|
||||
let selectedPullDetail = null;
|
||||
let pullConversation = null;
|
||||
let pullReviewState = null;
|
||||
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let pendingIssueFilingIntent = 'create-and-assign';
|
||||
@@ -386,23 +387,24 @@
|
||||
let confirmedOwnerLogin = '';
|
||||
let planningOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
let releaseReceipt = null;
|
||||
function attachReleaseReceipt() {
|
||||
if (releaseReceipt) return releaseReceipt;
|
||||
releaseReceipt = createReleaseReceipt({
|
||||
let rR = null;
|
||||
function rRC() {
|
||||
if (rR) return rR;
|
||||
rR = createReleaseReceipt({
|
||||
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
|
||||
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
|
||||
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
|
||||
releaseNode:qs('#release-receipt-link'),
|
||||
listNode:qs('#release-watchlist'),
|
||||
openPull:openPullSheet,
|
||||
});
|
||||
releaseReceipt.bind();
|
||||
rR.bind();
|
||||
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
|
||||
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
|
||||
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
|
||||
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
|
||||
}));
|
||||
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
|
||||
return releaseReceipt;
|
||||
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
|
||||
return rR;
|
||||
}
|
||||
const completedFiledReview = createCompletedFiledReview({
|
||||
storage: localStorage,
|
||||
@@ -1279,7 +1281,7 @@
|
||||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||||
}, () => {
|
||||
if (!pullController) {
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
|
||||
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
|
||||
const continuing = workSession.checkpointed(item);
|
||||
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
|
||||
@@ -1288,9 +1290,8 @@
|
||||
paintMyWork(lastContextSnapshot);
|
||||
return false;
|
||||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
||||
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
rRC();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1306,7 +1307,7 @@
|
||||
try {
|
||||
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
|
||||
} catch (_error) { return; }
|
||||
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
|
||||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
@@ -4605,6 +4606,7 @@
|
||||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
|
||||
pullDetailPosition.open(workDetailIdentity('pull', item));
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
@@ -5745,11 +5747,7 @@
|
||||
planButton.hidden = !searchWeekPlan.eligible(detail);
|
||||
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
|
||||
planButton.disabled = searchWeekPlan.pending();
|
||||
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
|
||||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
|
||||
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
|
||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||
renderSearchPreviewStart(detail, state, startButton);
|
||||
renderSearchPreviewWatch(detail, state, watchButton);
|
||||
followingQueue.preview(state);
|
||||
const shareStatus = {
|
||||
@@ -5771,9 +5769,7 @@
|
||||
fetchJson:searchSubscription.preview,
|
||||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
|
||||
fetchReview:searchSubscription.review,
|
||||
mutate:(detail,action)=>fetchReviewJson(
|
||||
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
|
||||
),
|
||||
mutate:searchPreviewMutation(fetchReviewJson),
|
||||
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||||
followingQueue.load().catch(() => {}).then(() => result)),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
@@ -5867,6 +5863,14 @@
|
||||
}
|
||||
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
|
||||
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
|
||||
const recoverSearchPull = createSearchAuthoredPullRecovery({
|
||||
confirm,
|
||||
reopen:detail => searchPreview.reopenPull(detail),
|
||||
refresh:load,
|
||||
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
|
||||
open:item => openRoutedWork(item, qs('#start-search-result')),
|
||||
unavailable:message => qs('#search-preview-status').textContent = message,
|
||||
});
|
||||
const mobileSearchViewport = createMobileSearchViewport({
|
||||
palette: qs('#cmd-palette'),
|
||||
results: qs('#cmd-results'),
|
||||
@@ -6215,6 +6219,14 @@
|
||||
});
|
||||
qs('#start-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (detail?.authored_pull_reopenable) {
|
||||
try {
|
||||
await recoverSearchPull(detail);
|
||||
} catch (error) {
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (detail?.reviewable) {
|
||||
taskOverlayHistory.leave();
|
||||
detail.kind = 'review';
|
||||
@@ -7357,7 +7369,7 @@
|
||||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||||
try {
|
||||
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||||
releaseReceipt.capture(merging, mergeResult);
|
||||
rR.capture(merging, mergeResult);
|
||||
closePullSheet();
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||||
|
||||
@@ -1281,6 +1281,7 @@
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="switch-to-create-pull" type="button">Pull request</button>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||
@@ -1722,6 +1723,11 @@
|
||||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<section class="pull-branch-update" id="pull-branch-update" aria-labelledby="pull-branch-update-heading" hidden>
|
||||
<h3 id="pull-branch-update-heading">Bring branch up to date</h3>
|
||||
<p class="small" id="pull-branch-update-status" role="status" aria-live="assertive"></p>
|
||||
<button id="update-pull-branch" type="button">Update branch</button>
|
||||
</section>
|
||||
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
|
||||
<h3 id="pull-reviewer-heading">Reviewer status</h3>
|
||||
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
|
||||
@@ -1756,6 +1762,15 @@
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
|
||||
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
|
||||
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
|
||||
<div class="ci-check-recovery-actions">
|
||||
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
|
||||
<button id="close-pull-check-recovery" type="button">Close details</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
@@ -1783,6 +1798,20 @@
|
||||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
|
||||
<summary>Close pull request</summary>
|
||||
<div>
|
||||
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
|
||||
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
|
||||
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</details>
|
||||
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
|
||||
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
|
||||
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
|
||||
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
|
||||
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-pull-detail" type="button">Watch pull request</button>
|
||||
@@ -2143,6 +2172,34 @@
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="create-pull-sheet" id="create-pull-sheet" role="dialog" aria-modal="true" aria-labelledby="create-pull-heading" hidden>
|
||||
<section class="create-pull-panel">
|
||||
<header class="create-pull-header">
|
||||
<div><span class="eyebrow">New</span><h3 id="create-pull-heading">Pull request</h3></div>
|
||||
<button id="cancel-create-pull" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Open a pull request from a branch already pushed to Gitea.</p>
|
||||
<form id="create-pull-form">
|
||||
<label for="create-pull-repository">Repository
|
||||
<select id="create-pull-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
<div class="create-pull-branches">
|
||||
<label for="create-pull-head">Source branch<select id="create-pull-head" required disabled></select></label>
|
||||
<label for="create-pull-base">Base branch<select id="create-pull-base" required disabled></select></label>
|
||||
</div>
|
||||
<output id="create-pull-head-receipt" class="small" aria-live="polite"></output>
|
||||
<label for="create-pull-title">Title<input id="create-pull-title" maxlength="255" required autocomplete="off" /></label>
|
||||
<label for="create-pull-body">Context <span class="small">Optional · Markdown</span><textarea id="create-pull-body" maxlength="10000" rows="5"></textarea></label>
|
||||
<fieldset class="create-pull-mode"><legend>Start as</legend>
|
||||
<label><input type="radio" name="create-pull-mode" value="draft" checked /> Draft</label>
|
||||
<label><input type="radio" name="create-pull-mode" value="ready" /> Ready for review</label>
|
||||
</fieldset>
|
||||
<p id="create-pull-status" role="status" aria-live="polite"></p>
|
||||
<button id="submit-create-pull" type="submit" disabled>Create draft pull request</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
@@ -2231,6 +2288,7 @@
|
||||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/voice-conversation-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-pull-sheet.js"></script>
|
||||
<script src="static/mobile-create-issue-nav.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -8,12 +8,25 @@
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
let backgroundInert = null;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map(o.backgrounds.map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
@@ -28,7 +41,8 @@
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
if (active) containBackground();
|
||||
else releaseBackground();
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
|
||||
+275
-5
@@ -187,17 +187,28 @@ function ownershipExitMessage(item, action, transitionResult) {
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
function applyOwnershipCapabilities(doc, capabilities = null) {
|
||||
const ownership = doc.querySelector('#pull-ownership');
|
||||
const assigned = capabilities?.assigned !== false;
|
||||
ownership.hidden = !assigned;
|
||||
ownership.inert = !assigned;
|
||||
return assigned;
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const assigned = applyOwnershipCapabilities(doc, capabilities);
|
||||
resetLifecycleControls(doc, null);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#load-pull-handoff').disabled = !assigned;
|
||||
qs('#release-pull').disabled = !assigned;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
qs('#pull-handoff-status').textContent = assigned ?
|
||||
'Load teammates to transfer ownership.' : 'Assignment controls are not available for this pull request.';
|
||||
qs('#edit-pull-content').hidden = true;
|
||||
qs('#pull-edit-form').hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
@@ -220,6 +231,46 @@ function resetReviewRequestControls(doc, detail) {
|
||||
qs('#pull-feedback-status').textContent = '';
|
||||
}
|
||||
|
||||
function resetBranchUpdateControls(doc, detail) {
|
||||
const panel = doc.querySelector('#pull-branch-update');
|
||||
if (!panel) return;
|
||||
const eligible = detail?.capabilities?.authored === true && detail?.state === 'open' &&
|
||||
detail?.draft !== true && detail?.merged !== true && detail?.mergeable === false && detail?.head_sha;
|
||||
panel.hidden = !eligible;
|
||||
doc.querySelector('#update-pull-branch').disabled = false;
|
||||
doc.querySelector('#pull-branch-update-status').textContent = eligible ?
|
||||
'Merge the latest base branch into this pull request. Reviews and checks may run again.' : '';
|
||||
}
|
||||
|
||||
function bindBranchUpdateControls(doc, controller, getSelected, getDetail) {
|
||||
const button = doc.querySelector('#update-pull-branch');
|
||||
if (!button || button.dataset.bound === 'true') return;
|
||||
button.dataset.bound = 'true';
|
||||
button.addEventListener('click', async () => {
|
||||
const item = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
|
||||
!globalThis.confirm('Update ' + item.key + ' with the latest base branch? Reviews and checks may run again.')) return;
|
||||
button.disabled = true;
|
||||
const status = doc.querySelector('#pull-branch-update-status');
|
||||
status.textContent = 'Updating branch…';
|
||||
try {
|
||||
const result = await controller.updateBranch(item, detail.head_sha);
|
||||
if (getSelected?.() !== item || getDetail?.() !== detail) return;
|
||||
Object.assign(detail, result, { mergeable:null, ci_state:'pending', reviewers:[] });
|
||||
status.textContent = 'Branch updated. Refreshing checks and review state…';
|
||||
doc.querySelector('#pull-review-retry')?.click();
|
||||
status.textContent = 'Branch updated to ' + result.head_sha.slice(0, 8) + '. Review the refreshed checks before merging.';
|
||||
button.hidden = true;
|
||||
doc.querySelector('#refresh-pull-checks')?.focus();
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' Reload the latest pull request state before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-reviewers');
|
||||
@@ -227,6 +278,9 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
|
||||
load.dataset.reviewRequestBound = 'true';
|
||||
controller.setReviewDetail = detail => {
|
||||
resetReviewRequestControls(doc, detail);
|
||||
resetBranchUpdateControls(doc, detail);
|
||||
applyOwnershipCapabilities(doc, detail?.capabilities);
|
||||
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
|
||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
||||
const publishable = detail?.state === 'open' && detail?.draft === true &&
|
||||
detail?.author === getLogin?.() && detail?.head_sha;
|
||||
@@ -564,7 +618,10 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindBranchUpdateControls(doc, controller, getSelected, getDetail);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindCheckRecovery(doc, controller, getSelected, getDetail);
|
||||
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
@@ -630,16 +687,18 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let branchUpdateRequest = null;
|
||||
let candidateRequest = null;
|
||||
let reviewCandidateRequest = null;
|
||||
let reviewRequestMutation = null;
|
||||
let reviewCancelMutation = null;
|
||||
let readyMutation = null;
|
||||
let lifecycleMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
@@ -668,6 +727,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}
|
||||
|
||||
return {
|
||||
onState,
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
@@ -711,6 +771,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
|
||||
encodeURIComponent(expectedHeadSha), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
retryCheck(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
@@ -764,6 +839,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { readyMutation = null; });
|
||||
return readyMutation;
|
||||
},
|
||||
transition(item, expectedHeadSha, action) {
|
||||
if (lifecycleMutation) return lifecycleMutation;
|
||||
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
|
||||
method:'PATCH',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
|
||||
}).then(result => {
|
||||
const expectedState = action === 'close' ? 'closed' : 'open';
|
||||
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
|
||||
result?.state !== expectedState || result?.merged === true) {
|
||||
throw new Error('Pull request state change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { lifecycleMutation = null; });
|
||||
return lifecycleMutation;
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
@@ -927,9 +1018,185 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
},
|
||||
updateBranch(item, expectedHeadSha) {
|
||||
if (branchUpdateRequest) return branchUpdateRequest;
|
||||
branchUpdateRequest = fetchJson(pathFor(item) + '/update-branch', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.head_sha || result.head_sha === expectedHeadSha) {
|
||||
throw new Error('Branch update was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { branchUpdateRequest = null; });
|
||||
return branchUpdateRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetLifecycleControls(doc, detail) {
|
||||
const authored = detail?.capabilities?.authored === true;
|
||||
const lifecycle = doc.querySelector('#pull-lifecycle');
|
||||
if (!lifecycle) return;
|
||||
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
|
||||
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
|
||||
doc.querySelector('#pull-close-receipt').hidden = true;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = '';
|
||||
doc.querySelector('#pull-reopen-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
|
||||
const close = doc.querySelector('#close-authored-pull');
|
||||
const reopen = doc.querySelector('#reopen-authored-pull');
|
||||
close.addEventListener('click', async () => {
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
|
||||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
|
||||
close.disabled = true;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
|
||||
try {
|
||||
const result = await controller.transition(item, detail.head_sha, 'close');
|
||||
Object.assign(detail, result);
|
||||
applyResult(result);
|
||||
doc.querySelector('#pull-lifecycle').hidden = true;
|
||||
doc.querySelector('#pull-close-receipt').hidden = false;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = '';
|
||||
doc.querySelector('#pull-close-receipt-title').focus();
|
||||
} catch (error) {
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
|
||||
close.disabled = false;
|
||||
close.focus();
|
||||
}
|
||||
});
|
||||
reopen.addEventListener('click', async () => {
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!item || !detail?.head_sha) return;
|
||||
reopen.disabled = true;
|
||||
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
|
||||
try {
|
||||
const result = await controller.transition(item, detail.head_sha, 'reopen');
|
||||
Object.assign(detail, result);
|
||||
applyResult(result);
|
||||
doc.querySelector('#pull-close-receipt').hidden = true;
|
||||
doc.querySelector('#pull-lifecycle').hidden = false;
|
||||
close.hidden = false;
|
||||
close.disabled = false;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
|
||||
doc.querySelector('#pull-reopen-status').textContent = '';
|
||||
close.focus();
|
||||
} catch (error) {
|
||||
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
|
||||
reopen.disabled = false;
|
||||
reopen.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindCheckRecovery(
|
||||
doc, controller, selectedPull, selectedDetail,
|
||||
confirmRetry = message => globalThis.confirm(message)
|
||||
) {
|
||||
let current = null;
|
||||
const get = selector => doc.querySelector(selector);
|
||||
function reset({ restoreFocus = true } = {}) {
|
||||
const recovery = current;
|
||||
current = null;
|
||||
get('#pull-check-recovery').hidden = true;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = '';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
|
||||
}
|
||||
async function poll(recovery, attempt = 0) {
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (attempt >= 12) {
|
||||
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
try {
|
||||
const status = await controller.loadChecks(recovery.item);
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (status.head_sha !== recovery.headSha) {
|
||||
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
|
||||
return;
|
||||
}
|
||||
const check = (status.checks || []).find(candidate =>
|
||||
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
|
||||
);
|
||||
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
|
||||
get('#refresh-pull-checks').click();
|
||||
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
|
||||
return;
|
||||
}
|
||||
get('#pull-check-recovery-status').textContent = check.state === 'pending'
|
||||
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
|
||||
} catch (_error) {
|
||||
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
|
||||
}
|
||||
return poll(recovery, attempt + 1);
|
||||
}
|
||||
get('#pull-check-list').addEventListener('click', async event => {
|
||||
const trigger = event.target.closest('.ci-check-inspect');
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!trigger || !item || !detail?.head_sha) return;
|
||||
const runId = Number(trigger.dataset.runId);
|
||||
const jobIndex = Number(trigger.dataset.jobIndex);
|
||||
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
|
||||
const recovery = {
|
||||
item, headSha: detail.head_sha, runId, jobIndex, trigger,
|
||||
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
|
||||
};
|
||||
current = recovery;
|
||||
get('#pull-check-recovery').hidden = false;
|
||||
get('#pull-check-recovery-title').textContent = recovery.name;
|
||||
get('#pull-check-log').textContent = 'Loading failure details…';
|
||||
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
get('#pull-check-recovery-title').focus();
|
||||
try {
|
||||
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
|
||||
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
|
||||
get('#retry-pull-check').disabled = false;
|
||||
} catch (error) {
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
}
|
||||
});
|
||||
get('#close-pull-check-recovery').addEventListener('click', () => reset());
|
||||
get('#pull-checks').addEventListener('toggle', () => {
|
||||
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
|
||||
});
|
||||
get('#retry-pull-check').addEventListener('click', async () => {
|
||||
const recovery = current;
|
||||
if (!recovery || selectedPull() !== recovery.item) return;
|
||||
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
|
||||
const button = get('#retry-pull-check');
|
||||
button.disabled = true;
|
||||
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
|
||||
try {
|
||||
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
|
||||
poll(recovery);
|
||||
} catch (error) {
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
return { reset };
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.review = reviewEligibility;
|
||||
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
|
||||
@@ -937,6 +1204,8 @@ createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.bindLifecycleControls = bindLifecycleControls;
|
||||
createPullSheet.resetLifecycleControls = resetLifecycleControls;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
@@ -946,4 +1215,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
|
||||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||
createPullSheet.bindFeedbackControls = bindFeedbackControls;
|
||||
createPullSheet.bindCheckRecovery = bindCheckRecovery;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
+268
-5
@@ -1,8 +1,11 @@
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
const prefix = 'stackchain.release-receipt.v1:';
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
const recoveries = new Map();
|
||||
const retrying = new Map();
|
||||
const rollingBack = new Map();
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
@@ -62,6 +65,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
number: Number(item.number),
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
commit_sha: commitSha,
|
||||
...(
|
||||
mergeResult?.source_repository === item.repository
|
||||
&& mergeResult?.source_branch
|
||||
&& mergeResult?.source_head_sha
|
||||
? {
|
||||
source_branch: String(mergeResult.source_branch),
|
||||
source_head_sha: String(mergeResult.source_head_sha),
|
||||
cleanup: { state: 'available', message: 'Merged branch retained.' },
|
||||
}
|
||||
: {}
|
||||
),
|
||||
status: null,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -81,10 +95,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
|
||||
function summarize(payload) {
|
||||
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
||||
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
|
||||
const failedChecks = checks.filter(check => ['failure', 'error'].includes(check.state));
|
||||
const failing = failedChecks.map(check => check.name).filter(Boolean);
|
||||
const failures = failedChecks.filter(check => check?.recovery).map(check => ({
|
||||
name: check.name || 'Failed check',
|
||||
description: check.description || '',
|
||||
url: check.url || '',
|
||||
recovery: check.recovery,
|
||||
}));
|
||||
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
|
||||
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing, failures };
|
||||
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
|
||||
return { ...payload, label: 'Checks running', checks: pending };
|
||||
}
|
||||
@@ -121,18 +142,117 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
state.className = 'small';
|
||||
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
|
||||
copy.append(title, state);
|
||||
if (entry.source_branch && entry.source_head_sha) {
|
||||
const branch = document.createElement('span');
|
||||
branch.className = 'small release-branch-cleanup-status';
|
||||
branch.textContent = entry.cleanup?.state === 'deleted'
|
||||
? 'Branch ' + entry.source_branch + ' · deleted'
|
||||
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
|
||||
copy.append(branch);
|
||||
}
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
const rollbackStatus = document.createElement('span');
|
||||
rollbackStatus.className = 'small release-rollback-status';
|
||||
rollbackStatus.textContent = entry.rollback.message;
|
||||
copy.append(rollbackStatus);
|
||||
}
|
||||
if (entry.status?.release?.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = entry.status.release.url;
|
||||
link.textContent = 'Open release ' + entry.status.release.tag;
|
||||
copy.append(link);
|
||||
}
|
||||
for (const failure of (entry.status?.failures || [])) {
|
||||
const runId = failure.recovery?.run_id;
|
||||
const jobIndex = failure.recovery?.job_index;
|
||||
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) continue;
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
const summary = document.createElement('section');
|
||||
summary.className = 'release-failure-summary';
|
||||
const failureName = document.createElement('span');
|
||||
failureName.className = 'small';
|
||||
failureName.textContent = failure.name + (failure.description ? ' · ' + failure.description : '');
|
||||
const review = document.createElement('button');
|
||||
review.type = 'button';
|
||||
review.textContent = 'Review failure';
|
||||
review.setAttribute('aria-label', 'Review failed check ' + failure.name + ' for ' + entry.commit_sha.slice(0, 8));
|
||||
review.addEventListener('click', () => reviewFailure(
|
||||
entry.repository, entry.commit_sha, runId, jobIndex
|
||||
).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
}));
|
||||
summary.append(failureName, review);
|
||||
const detail = recoveries.get(key);
|
||||
if (detail) {
|
||||
const recovery = document.createElement('div');
|
||||
recovery.className = 'release-failure-recovery';
|
||||
const sha = document.createElement('strong');
|
||||
sha.textContent = 'Merge ' + entry.commit_sha.slice(0, 8);
|
||||
const excerpt = document.createElement('pre');
|
||||
excerpt.textContent = detail.excerpt || 'No log excerpt was returned.';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'release-failure-actions';
|
||||
if (failure.url) {
|
||||
const job = document.createElement('a');
|
||||
job.href = failure.url;
|
||||
job.textContent = 'Open job';
|
||||
actions.append(job);
|
||||
}
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.textContent = retrying.has(key) ? 'Retrying…' : 'Retry failed check';
|
||||
retry.disabled = retrying.has(key);
|
||||
retry.addEventListener('click', () => retryFailure(
|
||||
entry.repository, entry.commit_sha, runId, jobIndex
|
||||
).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
render();
|
||||
}));
|
||||
actions.append(retry);
|
||||
const rollback = document.createElement('button');
|
||||
rollback.type = 'button';
|
||||
rollback.textContent = entry.rollback?.state === 'prepared'
|
||||
? 'Open rollback #' + entry.rollback.number
|
||||
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
|
||||
rollback.disabled = rollingBack.has(identity(entry));
|
||||
rollback.setAttribute(
|
||||
'aria-label',
|
||||
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
|
||||
);
|
||||
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
render();
|
||||
}));
|
||||
actions.append(rollback);
|
||||
recovery.append(sha, excerpt, actions);
|
||||
summary.append(recovery);
|
||||
}
|
||||
copy.append(summary);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'Dismiss';
|
||||
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
|
||||
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
|
||||
row.append(copy, button);
|
||||
if (entry.source_branch && entry.cleanup?.state !== 'deleted') {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'release-watchlist-actions';
|
||||
const cleanup = document.createElement('button');
|
||||
cleanup.type = 'button';
|
||||
cleanup.textContent = entry.cleanup?.state === 'deleting' ? 'Deleting…' : 'Delete source branch';
|
||||
cleanup.disabled = entry.cleanup?.state === 'deleting' || entry.cleanup?.state === 'advanced';
|
||||
cleanup.setAttribute(
|
||||
'aria-label',
|
||||
'Delete merged source branch ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8),
|
||||
);
|
||||
cleanup.addEventListener('click', () => deleteBranch(entry.repository, entry.commit_sha).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error);
|
||||
}));
|
||||
actions.append(cleanup, button);
|
||||
row.append(copy, actions);
|
||||
} else {
|
||||
row.append(copy, button);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
listNode.replaceChildren(...rows);
|
||||
@@ -168,6 +288,149 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
finally { refreshing = null; }
|
||||
}
|
||||
|
||||
function recoveryPath(entry, runId, jobIndex) {
|
||||
return 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number)
|
||||
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha)
|
||||
+ '/checks/' + encodeURIComponent(runId) + '/jobs/' + encodeURIComponent(jobIndex);
|
||||
}
|
||||
|
||||
async function reviewFailure(repository, commitSha, runId, jobIndex) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) throw new Error('Tracked release is unavailable.');
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
const detail = await fetchJson(recoveryPath(entry, runId, jobIndex) + '/failure', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (detail?.commit_sha !== entry.commit_sha) {
|
||||
throw new Error('Release failure evidence did not match the merged commit.');
|
||||
}
|
||||
recoveries.set(key, detail);
|
||||
render();
|
||||
return detail;
|
||||
}
|
||||
|
||||
function retryFailure(repository, commitSha, runId, jobIndex) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
if (retrying.has(key)) return retrying.get(key);
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
const failure = entry.status?.failures?.find(value =>
|
||||
value.recovery?.run_id === runId && value.recovery?.job_index === jobIndex
|
||||
);
|
||||
if (!approve('Retry ' + (failure?.name || 'failed release check') + ' for ' + commitSha.slice(0, 8) + '?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const operation = fetchJson(recoveryPath(entry, runId, jobIndex) + '/retry', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.commit_sha !== entry.commit_sha) {
|
||||
throw new Error('Release retry did not match the merged commit.');
|
||||
}
|
||||
entry.status = { ...entry.status, ci_state: 'pending', label: 'Checks running', checks: [], failures: [] };
|
||||
recoveries.delete(key);
|
||||
persist();
|
||||
render();
|
||||
schedule(0);
|
||||
return true;
|
||||
}).finally(() => retrying.delete(key));
|
||||
retrying.set(key, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function prepareRollback(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
openPull?.(entry.rollback.pull);
|
||||
return Promise.resolve(entry.rollback.pull);
|
||||
}
|
||||
const key = identity(entry);
|
||||
if (rollingBack.has(key)) return rollingBack.get(key);
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const operation = fetchJson(
|
||||
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number)
|
||||
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
|
||||
},
|
||||
},
|
||||
).then(result => {
|
||||
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
|
||||
throw new Error('Rollback preparation did not match the failed release.');
|
||||
}
|
||||
const pull = {
|
||||
...result,
|
||||
kind: 'pull',
|
||||
key: result.repository + '#' + result.number,
|
||||
state: 'open',
|
||||
};
|
||||
entry.rollback = {
|
||||
state: 'prepared',
|
||||
number: result.number,
|
||||
message: 'Draft rollback #' + result.number + ' is ready for review.',
|
||||
pull,
|
||||
};
|
||||
persist();
|
||||
render();
|
||||
if (dialog?.open) dialog.close();
|
||||
openPull?.(pull);
|
||||
return result;
|
||||
}).finally(() => rollingBack.delete(key));
|
||||
rollingBack.set(key, operation);
|
||||
render();
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function deleteBranch(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
|
||||
throw new Error('Source branch cleanup is unavailable.');
|
||||
}
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
if (!approve('Delete ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8) + '?')) return false;
|
||||
entry.cleanup = { state: 'deleting', message: 'Deleting source branch…' };
|
||||
persist();
|
||||
render();
|
||||
try {
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number) + '/source-branch',
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_branch: entry.source_branch,
|
||||
expected_head_sha: entry.source_head_sha,
|
||||
}),
|
||||
},
|
||||
);
|
||||
entry.cleanup = { state: 'deleted', message: 'Source branch deleted.' };
|
||||
persist();
|
||||
render();
|
||||
return true;
|
||||
} catch (error) {
|
||||
entry.cleanup = {
|
||||
state: error?.status === 409 ? 'advanced' : 'available',
|
||||
message: error?.status === 409
|
||||
? 'Source branch has newer commits and was retained.'
|
||||
: 'Branch retained. Retry when connected.',
|
||||
};
|
||||
persist();
|
||||
render();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss(repository, commitSha) {
|
||||
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
|
||||
else entries = [];
|
||||
@@ -195,7 +458,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, dismiss, bind, fetchJson };
|
||||
return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
const recovery = check.recovery;
|
||||
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
|
||||
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
|
||||
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
|
||||
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
|
||||
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
|
||||
: '';
|
||||
const actions = inspect || link
|
||||
? '<div class="ci-check-actions">' + inspect + link + '</div>'
|
||||
: '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewMutation = fetchJson => (detail, action) => {
|
||||
if (action === 'reopen-pull') {
|
||||
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
|
||||
return fetchJson(path, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({expected_head_sha:detail.head_sha}),
|
||||
});
|
||||
}
|
||||
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
|
||||
};
|
||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||
@@ -102,6 +112,30 @@
|
||||
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||
};
|
||||
root.renderSearchPreviewStart = (detail, state, button) => {
|
||||
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
|
||||
detail.authored_pull_reopenable === true;
|
||||
const issue = detail.kind === 'issue' &&
|
||||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
|
||||
button.hidden = !(detail.reviewable || pull || issue);
|
||||
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
|
||||
detail.reopenable ? 'Reopen & resume' :
|
||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||
button.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||
};
|
||||
root.createSearchAuthoredPullRecovery = o =>
|
||||
async d => {
|
||||
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
|
||||
await o.reopen(d);
|
||||
await o.refresh();
|
||||
const item = o.find(d);
|
||||
if (!item) {
|
||||
o.unavailable('Reopened. Refresh My Work.');
|
||||
return 'unavailable';
|
||||
}
|
||||
o.open(item);
|
||||
return 'opened';
|
||||
};
|
||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||
const detail = getDetail();
|
||||
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||||
@@ -548,6 +582,9 @@
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
reopenPull(detail) {
|
||||
return run('reopen-pull', 'reopening', 'reopened', detail);
|
||||
},
|
||||
setWatching(watching) {
|
||||
if (watchRequest) return watchRequest;
|
||||
if (!current || typeof watch !== 'function') {
|
||||
|
||||
@@ -231,6 +231,8 @@
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
source_branch_deleted: 'Source branch deleted',
|
||||
release_rollback_prepared: 'Release rollback prepared',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v138';
|
||||
const CACHE = 'stackchain-dashboard-shell-v139';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -245,6 +245,7 @@ const SHELL = [
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/voice-conversation-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-pull-sheet.js',
|
||||
BASE + 'static/mobile-create-issue-nav.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
||||
+15
-4
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
|
||||
+831
-15
@@ -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
|
||||
|
||||
@@ -173,6 +177,30 @@ class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class PullUpdateConflictError(ValueError):
|
||||
"""Raised when Gitea cannot merge a pull request's base into its head."""
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
class SourceBranchCleanupForbiddenError(ValueError):
|
||||
"""Raised before deletion when a merged branch is not safe for this operator."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
@@ -365,6 +393,20 @@ async def repository_access(repository: str) -> dict | None:
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
async def repo_branches(repository: str) -> list[dict]:
|
||||
"""Return the bounded branch choices visible in one repository."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/branches",
|
||||
headers=_auth(),
|
||||
params={"page": 1, "limit": 100},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea branch response was not a list")
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
|
||||
|
||||
WORK_SEARCHES = {
|
||||
"issue": ("assigned=true", "issues", None),
|
||||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
@@ -590,11 +632,13 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
reviewable = False
|
||||
if actual_kind == "pull" and state == "open" and login:
|
||||
pull = None
|
||||
if actual_kind == "pull" and login:
|
||||
try:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
except Exception:
|
||||
pull = None
|
||||
if actual_kind == "pull" and state == "open" and login:
|
||||
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
|
||||
if not isinstance(requested_reviewers, list):
|
||||
requested_reviewers = []
|
||||
@@ -602,6 +646,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
isinstance(reviewer, dict) and reviewer.get("login") == login
|
||||
for reviewer in requested_reviewers
|
||||
)
|
||||
pull_author = pull.get("user") if isinstance(pull, dict) and isinstance(pull.get("user"), dict) else {}
|
||||
pull_head = pull.get("head") if isinstance(pull, dict) and isinstance(pull.get("head"), dict) else {}
|
||||
head_sha = pull_head.get("sha") if isinstance(pull_head.get("sha"), str) else ""
|
||||
authored_pull_reopenable = bool(
|
||||
actual_kind == "pull"
|
||||
and state == "closed"
|
||||
and isinstance(pull, dict)
|
||||
and pull.get("state") == "closed"
|
||||
and pull.get("merged") is not True
|
||||
and login
|
||||
and pull_author.get("login", "").casefold() == login.casefold()
|
||||
and head_sha
|
||||
)
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
@@ -623,6 +680,10 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
"reviewable": reviewable,
|
||||
"commentable": bool(login),
|
||||
**({
|
||||
"authored_pull_reopenable": authored_pull_reopenable,
|
||||
"head_sha": head_sha,
|
||||
} if actual_kind == "pull" else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -793,13 +854,25 @@ def _normalize_commit_checks(status: Any) -> list[dict]:
|
||||
if state not in rank:
|
||||
state = "unknown"
|
||||
description = entry.get("description")
|
||||
checks.append({
|
||||
url = _safe_gitea_web_url(entry.get("target_url"))
|
||||
check = {
|
||||
"name": name.strip()[:120],
|
||||
"state": state,
|
||||
"description": description.strip()[:240] if isinstance(description, str) else "",
|
||||
"url": _safe_gitea_web_url(entry.get("target_url")),
|
||||
"url": url,
|
||||
"_index": index,
|
||||
})
|
||||
}
|
||||
configured_path = urlsplit(GITEA_URL).path.rstrip("/")
|
||||
action_match = re.fullmatch(
|
||||
re.escape(configured_path) + r"/[^/]+/[^/]+/actions/runs/(\d+)/jobs/(\d+)",
|
||||
urlsplit(url).path,
|
||||
)
|
||||
if action_match:
|
||||
check["recovery"] = {
|
||||
"run_id": int(action_match.group(1)),
|
||||
"job_index": int(action_match.group(2)),
|
||||
}
|
||||
checks.append(check)
|
||||
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
|
||||
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
|
||||
|
||||
@@ -1537,6 +1610,96 @@ async def repo_milestones(repository: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def create_pull(
|
||||
repository: str,
|
||||
*,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool,
|
||||
expected_head_sha: str,
|
||||
) -> dict:
|
||||
"""Create one pull only after revalidating its source and duplicate identity."""
|
||||
branch_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/branches/{quote(head, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
if branch_response.status_code == 404:
|
||||
raise PullCreateConflictError("Source branch is no longer available")
|
||||
branch_response.raise_for_status()
|
||||
branch = branch_response.json()
|
||||
commit = branch.get("commit", {}) if isinstance(branch, dict) else {}
|
||||
if commit.get("id") != expected_head_sha:
|
||||
raise PullCreateConflictError("Source branch changed before pull creation")
|
||||
|
||||
existing_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls",
|
||||
headers=_auth(),
|
||||
params={"state": "open", "page": 1, "limit": 50},
|
||||
)
|
||||
existing_response.raise_for_status()
|
||||
existing_items = existing_response.json()
|
||||
if not isinstance(existing_items, list):
|
||||
raise ValueError("Gitea pull response was not a list")
|
||||
existing = next((
|
||||
item for item in existing_items
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("head"), dict)
|
||||
and isinstance(item.get("base"), dict)
|
||||
and item["head"].get("ref") == head
|
||||
and item["base"].get("ref") == base
|
||||
and item.get("state") == "open"
|
||||
), None)
|
||||
pull = existing
|
||||
if pull is None:
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/pulls",
|
||||
headers=_auth(),
|
||||
json={
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
pull = response.json()
|
||||
|
||||
if not isinstance(pull, dict) or not isinstance(pull.get("number"), int):
|
||||
raise ValueError("Gitea did not confirm pull creation")
|
||||
pull_head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
pull_base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
|
||||
pull_user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull_head.get("ref") != head
|
||||
or pull_head.get("sha") != expected_head_sha
|
||||
or pull_base.get("ref") != base
|
||||
or not isinstance(pull_user.get("login"), str)
|
||||
or (existing is None and (
|
||||
pull.get("title") != title
|
||||
or pull.get("body", "") != body
|
||||
or bool(pull.get("draft")) is not draft
|
||||
))
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the requested pull")
|
||||
return {
|
||||
"number": pull["number"],
|
||||
"repository": repository,
|
||||
"title": pull.get("title", ""),
|
||||
"body": pull.get("body", ""),
|
||||
"state": "open",
|
||||
"draft": bool(pull.get("draft")),
|
||||
"head": {"ref": head, "sha": expected_head_sha},
|
||||
"base": {"ref": base},
|
||||
"author": pull_user["login"],
|
||||
"url": _safe_gitea_web_url(pull.get("html_url")),
|
||||
"existing": existing is not None,
|
||||
}
|
||||
|
||||
|
||||
async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
@@ -1984,6 +2147,132 @@ async def pull_review_candidates(repository: str, number: int) -> list[dict]:
|
||||
][:25]
|
||||
|
||||
|
||||
def _login_can_manage_pull(login: str, pull: dict) -> bool:
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
return (
|
||||
author.get("login", "").casefold() == login.casefold()
|
||||
or _login_in_users(login, pull.get("assignees"))
|
||||
)
|
||||
|
||||
|
||||
async def _transition_authored_pull(
|
||||
repository: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
*,
|
||||
from_state: str,
|
||||
to_state: str,
|
||||
) -> dict:
|
||||
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 {}
|
||||
if (
|
||||
pull.get("state") != from_state
|
||||
or pull.get("merged") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": to_state},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != to_state
|
||||
or confirmed.get("merged") is True
|
||||
or confirmed_head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the pull request state")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": confirmed.get("title", ""),
|
||||
"head_sha": expected_head_sha,
|
||||
"state": to_state,
|
||||
"merged": False,
|
||||
}
|
||||
|
||||
|
||||
async def close_authored_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
return await _transition_authored_pull(
|
||||
repository, number, expected_head_sha, from_state="open", to_state="closed"
|
||||
)
|
||||
|
||||
|
||||
async def reopen_authored_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
return await _transition_authored_pull(
|
||||
repository, number, expected_head_sha, from_state="closed", to_state="open"
|
||||
)
|
||||
|
||||
|
||||
async def update_authored_pull_branch(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
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 {}
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or pull.get("draft") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}/update",
|
||||
headers=_auth(),
|
||||
params={"style": "merge"},
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise PullUpdateConflictError("Pull request has merge conflicts")
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
|
||||
new_head_sha = confirmed_head.get("sha")
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or confirmed.get("merged") is True
|
||||
or 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")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": confirmed.get("title", ""),
|
||||
"previous_head_sha": expected_head_sha,
|
||||
"head_sha": new_head_sha,
|
||||
"state": "open",
|
||||
"draft": confirmed.get("draft") is True,
|
||||
}
|
||||
|
||||
|
||||
async def request_assigned_pull_review(
|
||||
repository: str, number: int, reviewer: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
@@ -1994,7 +2283,7 @@ async def request_assigned_pull_review(
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or not _login_can_manage_pull(login, pull)
|
||||
or head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
|
||||
@@ -2050,7 +2339,6 @@ async def publish_authored_assigned_pull(
|
||||
or pull.get("merged") is True
|
||||
or pull.get("draft") is not True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or head.get("sha") != expected_head_sha
|
||||
or not ready_title
|
||||
or ready_title == title
|
||||
@@ -2106,7 +2394,7 @@ async def cancel_assigned_pull_review(
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or not _login_can_manage_pull(login, pull)
|
||||
or head.get("sha") != expected_head_sha
|
||||
or reviewer not in requested
|
||||
):
|
||||
@@ -2711,7 +2999,7 @@ async def resolve_work_route(
|
||||
and (
|
||||
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
|
||||
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
|
||||
or (kind == "pull" and assigned)
|
||||
or (kind == "pull" and (assigned or authored))
|
||||
or (kind == "review" and requested)
|
||||
)
|
||||
)
|
||||
@@ -2730,6 +3018,14 @@ async def resolve_work_route(
|
||||
"is_assigned": assigned,
|
||||
"work_reasons": ["created_by_me"],
|
||||
} if kind == "filed" else {}),
|
||||
**({
|
||||
"is_assigned": assigned,
|
||||
"work_reasons": [
|
||||
reason for reason, present in (
|
||||
("assigned_to_me", assigned), ("authored_by_me", authored)
|
||||
) if present
|
||||
],
|
||||
} if kind == "pull" else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2809,6 +3105,241 @@ async def is_requested_review(repository: str, number: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def can_recover_merged_release(
|
||||
repository: str, number: int, commit_sha: str
|
||||
) -> bool:
|
||||
"""Confirm the current operator participated in the 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 {}
|
||||
participant = (
|
||||
author.get("login", "").casefold() == login.casefold()
|
||||
or _login_in_users(login, pull.get("assignees"))
|
||||
)
|
||||
return (
|
||||
participant
|
||||
and pull.get("state") == "closed"
|
||||
and pull.get("merged") is True
|
||||
and pull.get("merge_commit_sha") == commit_sha
|
||||
)
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
|
||||
return {
|
||||
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
|
||||
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
|
||||
}
|
||||
|
||||
|
||||
async def pull_workspace_snapshot(repository: str, number: int) -> dict:
|
||||
"""Resolve one canonical pull and the access granted by that exact snapshot."""
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
|
||||
return {
|
||||
"pull": pull,
|
||||
"capabilities": {
|
||||
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
|
||||
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def is_assigned_pull(repository: str, number: int) -> bool:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
@@ -2833,8 +3364,7 @@ async def update_authored_assigned_pull(
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or author.get("login") != login
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
):
|
||||
raise IssueNotAvailableError("pull request not found")
|
||||
if head.get("sha") != expected_head_sha:
|
||||
@@ -2865,9 +3395,12 @@ async def update_authored_assigned_pull(
|
||||
}
|
||||
|
||||
|
||||
async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
async def pull_completion_detail(
|
||||
repository: str, number: int, pull: dict | None = None
|
||||
) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if pull is None:
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
conversation = await issue_conversation_page(repository, number)
|
||||
@@ -2987,9 +3520,12 @@ def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) ->
|
||||
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
|
||||
|
||||
|
||||
async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
async def pull_completion_review(
|
||||
repository: str, number: int, pull: dict | None = None
|
||||
) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if pull is None:
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
head_value = pull.get("head")
|
||||
@@ -3082,7 +3618,21 @@ async def release_receipt_status(repository: str, commit_sha: str) -> dict:
|
||||
)
|
||||
normalized_checks = _normalize_commit_checks(status)
|
||||
checks = [
|
||||
{"name": check["name"], "state": check["state"], "url": check["url"]}
|
||||
{
|
||||
"name": check["name"],
|
||||
"state": check["state"],
|
||||
"url": check["url"],
|
||||
**(
|
||||
{"description": check["description"]}
|
||||
if check.get("description")
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"recovery": check["recovery"]}
|
||||
if isinstance(check.get("recovery"), dict)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for check in normalized_checks
|
||||
]
|
||||
matching = next(
|
||||
@@ -3144,6 +3694,187 @@ async def pull_check_status(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str:
|
||||
redacted = re.sub(
|
||||
r"(?im)^(authorization\s*:)\s*[^\r\n]*$",
|
||||
r"\1 [redacted]",
|
||||
value,
|
||||
)
|
||||
redacted = re.sub(
|
||||
r"(?im)\b(gitea_token|github_token|access_token)=\S+",
|
||||
r"\1=[redacted]",
|
||||
redacted,
|
||||
)
|
||||
encoded = redacted.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return redacted
|
||||
return encoded[-max_bytes:].decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
async def release_action_failure_excerpt(
|
||||
repository: str,
|
||||
commit_sha: str,
|
||||
run_id: int,
|
||||
job_index: int,
|
||||
) -> dict:
|
||||
"""Return a bounded failed-job excerpt only when it still belongs to a merge commit."""
|
||||
status = await fetch(f"repos/{repository}/commits/{commit_sha}/status")
|
||||
matching = next(
|
||||
(
|
||||
check
|
||||
for check in _normalize_commit_checks(status)
|
||||
if check.get("state") in {"failure", "error"}
|
||||
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching is None:
|
||||
raise ValueError("Failed Gitea Actions job is unavailable")
|
||||
response = await _get_client().get(
|
||||
f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"name": matching["name"],
|
||||
"excerpt": _bounded_action_log_excerpt(response.text),
|
||||
}
|
||||
|
||||
|
||||
async def action_failure_excerpt(
|
||||
repository: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
run_id: int,
|
||||
job_index: int,
|
||||
) -> dict:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
sha = head.get("sha") if isinstance(head, dict) else None
|
||||
if sha != expected_head_sha:
|
||||
raise StalePullError("Pull request head changed")
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
matching = next(
|
||||
(
|
||||
check
|
||||
for check in _normalize_commit_checks(status)
|
||||
if check.get("state") in {"failure", "error"}
|
||||
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching is None:
|
||||
raise ValueError("Failed Gitea Actions job is unavailable")
|
||||
response = await _get_client().get(
|
||||
f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {
|
||||
"head_sha": sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"name": matching["name"],
|
||||
"excerpt": _bounded_action_log_excerpt(response.text),
|
||||
}
|
||||
|
||||
|
||||
async def retry_release_action_job(
|
||||
repository: str,
|
||||
commit_sha: str,
|
||||
run_id: int,
|
||||
job_index: int,
|
||||
) -> dict:
|
||||
"""Re-run a failed Actions job only while it belongs to the tracked merge commit."""
|
||||
status = await fetch(f"repos/{repository}/commits/{commit_sha}/status")
|
||||
matching = next(
|
||||
(
|
||||
check
|
||||
for check in _normalize_commit_checks(status)
|
||||
if check.get("state") in {"failure", "error"}
|
||||
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching is None:
|
||||
raise ValueError("Failed Gitea Actions job is unavailable")
|
||||
job_url = (
|
||||
f"{GITEA_URL}/{quote(repository, safe='/')}"
|
||||
f"/actions/runs/{run_id}/jobs/{job_index}"
|
||||
)
|
||||
page = await _get_client().get(job_url, headers=_auth())
|
||||
page.raise_for_status()
|
||||
csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text)
|
||||
if not csrf_match:
|
||||
raise ValueError("Gitea did not provide a retry authorization token")
|
||||
csrf_token = csrf_match.group(1)
|
||||
response = await _get_client().post(
|
||||
f"{job_url}/rerun",
|
||||
headers={**_auth(), "X-Csrf-Token": csrf_token},
|
||||
data={"_csrf": csrf_token},
|
||||
)
|
||||
if response.is_error:
|
||||
response.raise_for_status()
|
||||
return {
|
||||
"commit_sha": commit_sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"status": "queued",
|
||||
}
|
||||
|
||||
|
||||
async def retry_action_job(
|
||||
repository: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
run_id: int,
|
||||
job_index: int,
|
||||
) -> dict:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
sha = head.get("sha") if isinstance(head, dict) else None
|
||||
if sha != expected_head_sha:
|
||||
raise StalePullError("Pull request head changed")
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
matching = next(
|
||||
(
|
||||
check
|
||||
for check in _normalize_commit_checks(status)
|
||||
if check.get("state") in {"failure", "error"}
|
||||
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching is None:
|
||||
raise ValueError("Failed Gitea Actions job is unavailable")
|
||||
job_url = (
|
||||
f"{GITEA_URL}/{quote(repository, safe='/')}"
|
||||
f"/actions/runs/{run_id}/jobs/{job_index}"
|
||||
)
|
||||
page = await _get_client().get(job_url, headers=_auth())
|
||||
page.raise_for_status()
|
||||
csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text)
|
||||
if not csrf_match:
|
||||
raise ValueError("Gitea did not provide a retry authorization token")
|
||||
csrf_token = csrf_match.group(1)
|
||||
response = await _get_client().post(
|
||||
f"{job_url}/rerun",
|
||||
headers={**_auth(), "X-Csrf-Token": csrf_token},
|
||||
data={"_csrf": csrf_token},
|
||||
)
|
||||
if response.is_error:
|
||||
response.raise_for_status()
|
||||
return {
|
||||
"head_sha": sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"status": "queued",
|
||||
}
|
||||
|
||||
|
||||
async def is_pull_merged_at_head(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> bool:
|
||||
@@ -3197,11 +3928,96 @@ async def merge_assigned_pull(
|
||||
merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None
|
||||
if not isinstance(merge_commit_sha, str) or not merge_commit_sha:
|
||||
raise ValueError("Gitea merge response did not include the merge commit")
|
||||
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
||||
source_branch = head.get("ref")
|
||||
source_repository = head_repo.get("full_name")
|
||||
return {
|
||||
"number": number,
|
||||
"merged": True,
|
||||
"state": "closed",
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
**(
|
||||
{
|
||||
"source_branch": source_branch,
|
||||
"source_head_sha": current_sha,
|
||||
"source_repository": source_repository,
|
||||
}
|
||||
if isinstance(source_branch, str)
|
||||
and source_branch
|
||||
and isinstance(source_repository, str)
|
||||
and source_repository
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def delete_merged_source_branch(
|
||||
repository: str,
|
||||
number: int,
|
||||
source_branch: str,
|
||||
expected_head_sha: str,
|
||||
) -> dict:
|
||||
"""Delete an author's merged same-repository branch only at its exact head."""
|
||||
pull, operator, repo = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/pulls/{number}"),
|
||||
fetch("user"),
|
||||
fetch(f"repos/{repository}"),
|
||||
)
|
||||
if not all(isinstance(value, dict) for value in (pull, operator, repo)):
|
||||
raise SourceBranchCleanupForbiddenError("Branch cleanup identity is unavailable")
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
||||
if (
|
||||
pull.get("merged") is not True
|
||||
or pull.get("state") != "closed"
|
||||
or author.get("login") != operator.get("login")
|
||||
or head.get("ref") != source_branch
|
||||
or head.get("sha") != expected_head_sha
|
||||
or head_repo.get("full_name") != repository
|
||||
or repo.get("default_branch") == source_branch
|
||||
):
|
||||
raise SourceBranchCleanupForbiddenError("Source branch is not eligible for cleanup")
|
||||
|
||||
branch_path = f"/api/v1/repos/{repository}/branches/{quote(source_branch, safe='')}"
|
||||
branch_response = await _get_client().get(branch_path, headers=_auth())
|
||||
if branch_response.status_code == 404:
|
||||
return {
|
||||
"number": number,
|
||||
"deleted": True,
|
||||
"source_branch": source_branch,
|
||||
"source_head_sha": expected_head_sha,
|
||||
}
|
||||
branch_response.raise_for_status()
|
||||
branch = branch_response.json()
|
||||
commit = branch.get("commit") if isinstance(branch, dict) and isinstance(branch.get("commit"), dict) else {}
|
||||
if not isinstance(branch, dict) or branch.get("protected") is True:
|
||||
raise SourceBranchCleanupForbiddenError("Protected source branches cannot be deleted")
|
||||
if commit.get("id") != expected_head_sha:
|
||||
raise SourceBranchChangedError("Source branch advanced after merge")
|
||||
|
||||
try:
|
||||
deleted = await _get_client().delete(branch_path, headers=_auth())
|
||||
deleted.raise_for_status()
|
||||
except Exception:
|
||||
confirmation = await _get_client().get(branch_path, headers=_auth())
|
||||
if confirmation.status_code == 404:
|
||||
return {
|
||||
"number": number,
|
||||
"deleted": True,
|
||||
"source_branch": source_branch,
|
||||
"source_head_sha": expected_head_sha,
|
||||
}
|
||||
raise
|
||||
confirmation = await _get_client().get(branch_path, headers=_auth())
|
||||
if confirmation.status_code != 404:
|
||||
confirmation.raise_for_status()
|
||||
raise RuntimeError("Source branch deletion could not be confirmed")
|
||||
return {
|
||||
"number": number,
|
||||
"deleted": True,
|
||||
"source_branch": source_branch,
|
||||
"source_head_sha": expected_head_sha,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+706
-29
@@ -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",
|
||||
@@ -1139,6 +1168,36 @@ class PullContentUpdate(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class PullCreateRequest(BaseModel):
|
||||
head: str = Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_./-]+$")
|
||||
base: str = Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_./-]+$")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
draft: bool = True
|
||||
expected_head_sha: str = Field(
|
||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
||||
)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def strip_title(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("title cannot be blank")
|
||||
return value
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_different_branches(self):
|
||||
if self.head == self.base:
|
||||
raise ValueError("source and base branches must differ")
|
||||
return self
|
||||
|
||||
|
||||
class IssueReassignment(IssueHandoff):
|
||||
expected_assignees: list[str] = Field(min_length=1, max_length=10)
|
||||
|
||||
@@ -1207,6 +1266,15 @@ class PullMergeSubmission(BaseModel):
|
||||
expected_head_sha: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class SourceBranchCleanupSubmission(BaseModel):
|
||||
source_branch: str = Field(
|
||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_./-]+$"
|
||||
)
|
||||
expected_head_sha: str = Field(
|
||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
||||
)
|
||||
|
||||
|
||||
async def _run_idempotent_authored_action(
|
||||
operation: Coroutine[Any, Any, Any],
|
||||
*,
|
||||
@@ -1522,6 +1590,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"}
|
||||
@@ -1695,9 +1784,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(
|
||||
@@ -2285,11 +2385,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")
|
||||
@@ -3803,6 +3913,95 @@ async def repository_page(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pull-creation-options")
|
||||
async def pull_creation_options(owner: str, repo: str) -> JSONResponse:
|
||||
"""Return the current, writable branch choices for mobile pull creation."""
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
access = await asyncio.wait_for(
|
||||
gitea_proxy.repository_access(repository),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
permissions = access.get("permissions", {}) if isinstance(access, dict) else {}
|
||||
if not access or permissions.get("push") is not True:
|
||||
raise HTTPException(status_code=404, detail="Writable repository not found")
|
||||
raw_branches = await asyncio.wait_for(
|
||||
gitea_proxy.repo_branches(repository),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
branches = [
|
||||
{"name": item["name"], "sha": item["commit"]["id"]}
|
||||
for item in raw_branches
|
||||
if isinstance(item.get("name"), str)
|
||||
and isinstance(item.get("commit"), dict)
|
||||
and isinstance(item["commit"].get("id"), str)
|
||||
]
|
||||
return JSONResponse(
|
||||
{"default_branch": access.get("default_branch"), "branches": branches},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Branches could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls")
|
||||
async def create_pull(
|
||||
request: PullCreateRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
fingerprint = (
|
||||
"create_pull", repository, request.head, request.base, request.title,
|
||||
request.body, request.draft, request.expected_head_sha,
|
||||
)
|
||||
try:
|
||||
access = await gitea_proxy.repository_access(repository)
|
||||
permissions = access.get("permissions", {}) if isinstance(access, dict) else {}
|
||||
if not access or permissions.get("push") is not True:
|
||||
raise HTTPException(status_code=404, detail="Writable repository not found")
|
||||
result = await _run_idempotent_authored_action(
|
||||
gitea_proxy.create_pull(
|
||||
repository,
|
||||
head=request.head,
|
||||
base=request.base,
|
||||
title=request.title,
|
||||
body=request.body,
|
||||
draft=request.draft,
|
||||
expected_head_sha=request.expected_head_sha,
|
||||
),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=fingerprint,
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
return JSONResponse(
|
||||
result,
|
||||
status_code=200 if result.get("existing") else 201,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.PullCreateConflictError:
|
||||
return JSONResponse(
|
||||
{"error": "The source branch changed. Refresh branches before creating the pull request."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request could not be created. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/background-identity")
|
||||
async def background_identity() -> JSONResponse:
|
||||
"""Return only the account key required to safely drain a browser outbox."""
|
||||
@@ -6173,8 +6372,10 @@ async def edit_assigned_pull_comment(
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
|
||||
|
||||
|
||||
@@ -6190,8 +6391,10 @@ async def delete_assigned_pull_comment(
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _delete_conversation_comment(
|
||||
request, step_up_grant, repository, number, comment_id
|
||||
)
|
||||
@@ -6555,14 +6758,31 @@ async def requested_review_checks(
|
||||
)
|
||||
|
||||
|
||||
async def _pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
|
||||
# Preserve the established fast path for assigned work while widening access
|
||||
# only after Gitea confirms authorship of the same open pull request.
|
||||
if await gitea_proxy.is_assigned_pull(repository, number):
|
||||
return {"authored": False, "assigned": True}
|
||||
return await gitea_proxy.pull_workspace_capabilities(repository, number)
|
||||
|
||||
|
||||
def _has_pull_workspace_access(capabilities: dict[str, bool]) -> bool:
|
||||
return capabilities.get("authored") is True or capabilities.get("assigned") is True
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
|
||||
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assigned_pull():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await gitea_proxy.pull_completion_detail(repository, number)
|
||||
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
|
||||
capabilities = snapshot["capabilities"]
|
||||
if not _has_pull_workspace_access(capabilities):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
detail = await gitea_proxy.pull_completion_detail(
|
||||
repository, number, snapshot["pull"]
|
||||
)
|
||||
return {**detail, "capabilities": capabilities}
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
@@ -6648,6 +6868,92 @@ async def publish_authored_assigned_pull(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _transition_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
transition,
|
||||
action: str,
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
transition(repository, number, update.expected_head_sha),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": f"The pull request changed. Reload before {action}."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": f"The pull request could not be {action}. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/close")
|
||||
async def close_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
return await _transition_authored_pull(
|
||||
update, owner, repo, number, gitea_proxy.close_authored_pull, "closed"
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/reopen")
|
||||
async def reopen_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
return await _transition_authored_pull(
|
||||
update, owner, repo, number, gitea_proxy.reopen_authored_pull, "reopened"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/update-branch")
|
||||
async def update_authored_pull_branch(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_authored_pull_branch(
|
||||
repository, number, update.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request changed. Reload the latest state before updating its branch."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.PullUpdateConflictError:
|
||||
return JSONResponse(
|
||||
{"error": "Automatic branch update is unavailable because the branches have conflicts."},
|
||||
status_code=422,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The branch could not be updated. Please 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)
|
||||
@@ -6655,9 +6961,14 @@ async def assigned_pull_review_data(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assigned_pull_review():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await gitea_proxy.pull_completion_review(repository, number)
|
||||
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
|
||||
capabilities = snapshot["capabilities"]
|
||||
if not _has_pull_workspace_access(capabilities):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
review = await gitea_proxy.pull_completion_review(
|
||||
repository, number, snapshot["pull"]
|
||||
)
|
||||
return {**review, "capabilities": capabilities}
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
@@ -6690,8 +7001,10 @@ async def assigned_pull_review_feedback(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_feedback():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.pull_review_feedback(
|
||||
repository, number, review_id, expected_head_sha
|
||||
)
|
||||
@@ -6730,8 +7043,10 @@ async def assigned_pull_checks(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_checks():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.pull_check_status(repository, number)
|
||||
|
||||
try:
|
||||
@@ -6754,6 +7069,106 @@ async def assigned_pull_checks(
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/failure"
|
||||
)
|
||||
async def pull_action_failure(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_excerpt():
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.action_failure_excerpt(
|
||||
repository, number, expected_head_sha, run_id, job_index
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
return JSONResponse(
|
||||
{"error": "New commits arrived. Reload checks before diagnosing this job."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This failed check does not expose a recoverable Actions job."},
|
||||
status_code=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The failure log is temporarily unavailable. Open the job or retry."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/retry"
|
||||
)
|
||||
async def retry_pull_action_job(
|
||||
retry: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def retry_job():
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.retry_action_job(
|
||||
repository, number, retry.expected_head_sha, run_id, job_index
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
return JSONResponse(
|
||||
{"error": "New commits arrived. Reload checks before retrying this job."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The failed job could not be queued. Open the job or retry."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
|
||||
async def pull_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
@@ -6811,8 +7226,10 @@ async def pull_review_candidates(
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.pull_review_candidates(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
@@ -6915,8 +7332,10 @@ async def assigned_pull_conversation(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_conversation():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
|
||||
|
||||
try:
|
||||
@@ -6944,8 +7363,10 @@ async def comment_on_assigned_pull(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def post_comment():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
try:
|
||||
@@ -6986,8 +7407,8 @@ async def merge_assigned_pull(
|
||||
)
|
||||
|
||||
try:
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_pull(repository, number),
|
||||
capabilities = await asyncio.wait_for(
|
||||
_pull_workspace_capabilities(repository, number),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
@@ -6996,8 +7417,8 @@ async def merge_assigned_pull(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if not assigned:
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
if not _has_pull_workspace_access(capabilities):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
@@ -7072,12 +7493,268 @@ async def merge_assigned_pull(
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/source-branch")
|
||||
async def delete_merged_source_branch(
|
||||
submission: SourceBranchCleanupSubmission,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="delete_source_branch",
|
||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||
)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"source_branch_deleted",
|
||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. The branch was retained."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.delete_merged_source_branch(
|
||||
repository,
|
||||
number,
|
||||
submission.source_branch,
|
||||
submission.expected_head_sha,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
except gitea_proxy.SourceBranchChangedError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "The source branch has newer commits and was retained."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.SourceBranchCleanupForbiddenError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise HTTPException(status_code=422, detail="Source branch cleanup is unavailable")
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Branch deletion could not be confirmed. The merge remains complete."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/release-receipt/{commit_sha}")
|
||||
async def release_receipt(owner: str, repo: str, commit_sha: str):
|
||||
result = await gitea_proxy.release_receipt_status(f"{owner}/{repo}", commit_sha)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/release-receipt/{commit_sha}"
|
||||
"/checks/{run_id}/jobs/{job_index}/failure"
|
||||
)
|
||||
async def release_action_failure(
|
||||
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]+$"),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_excerpt():
|
||||
if not await gitea_proxy.can_recover_merged_release(
|
||||
repository, number, commit_sha
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Merged pull request not found")
|
||||
return await gitea_proxy.release_action_failure_excerpt(
|
||||
repository, commit_sha, run_id, job_index
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This failed release check is no longer recoverable."},
|
||||
status_code=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The release failure log is temporarily unavailable. Open the job or retry."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/release-receipt/{commit_sha}"
|
||||
"/checks/{run_id}/jobs/{job_index}/retry"
|
||||
)
|
||||
async def retry_release_action_job(
|
||||
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]+$"),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def retry_job():
|
||||
if not await gitea_proxy.can_recover_merged_release(
|
||||
repository, number, commit_sha
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Merged pull request not found")
|
||||
return await gitea_proxy.retry_release_action_job(
|
||||
repository, commit_sha, run_id, job_index
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This release check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The failed release job could not be queued. Open the job or retry."},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -26,6 +26,7 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "work-route.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.add_script_tag(path=FRONTEND / "pull-sheet.js")
|
||||
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
|
||||
|
||||
row = page.locator('[data-mobile-queue="authored"]')
|
||||
@@ -63,6 +64,25 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
|
||||
"opened": "opened",
|
||||
"count": 1,
|
||||
}
|
||||
capabilities = page.evaluate("""() => {
|
||||
const item = {key:'stackchain/dashboard#41'};
|
||||
createPullSheet.resetOwnershipControls(
|
||||
document, item, () => false, {authored:true, assigned:false}
|
||||
);
|
||||
const ownership = document.querySelector('#pull-ownership');
|
||||
return {
|
||||
hidden: ownership.hidden,
|
||||
inert: ownership.inert,
|
||||
releaseDisabled: document.querySelector('#release-pull').disabled,
|
||||
handoffDisabled: document.querySelector('#load-pull-handoff').disabled,
|
||||
};
|
||||
}""")
|
||||
assert capabilities == {
|
||||
"hidden": True,
|
||||
"inert": True,
|
||||
"releaseDisabled": True,
|
||||
"handoffDisabled": True,
|
||||
}
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1",
|
||||
reason="release browser journey is opt-in",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_closes_obsolete_pull_then_recovers_it(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.lifecycleCalls = [];
|
||||
window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
window.lifecycleDetail = {
|
||||
number:7, title:'Obsolete experiment', author:'timmy', state:'open',
|
||||
merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false},
|
||||
};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options) => {
|
||||
lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)});
|
||||
const state = path.endsWith('/close') ? 'closed' : 'open';
|
||||
return {repository:'stackchain/api', number:7, title:'Obsolete experiment',
|
||||
head_sha:'abc1234', state, merged:false};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindLifecycleControls(
|
||||
document, controller, () => lifecycleItem, () => lifecycleDetail,
|
||||
result => { lifecycleDetail = {...lifecycleDetail, ...result}; }
|
||||
);
|
||||
createPullSheet.resetLifecycleControls(document, lifecycleDetail);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
}"""
|
||||
)
|
||||
|
||||
page.locator("#pull-lifecycle summary").click()
|
||||
close = page.get_by_role("button", name="Close pull request")
|
||||
before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
close.click()
|
||||
page.get_by_text("Pull request closed.").wait_for()
|
||||
reopen = page.get_by_role("button", name="Reopen pull request")
|
||||
receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})")
|
||||
reopen.click()
|
||||
page.get_by_text("Pull request reopened.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
calls:lifecycleCalls,
|
||||
state:lifecycleDetail.state,
|
||||
closeHidden:document.querySelector('#close-authored-pull').hidden,
|
||||
receiptHidden:document.querySelector('#pull-close-receipt').hidden,
|
||||
focused:document.activeElement === document.querySelector('#close-authored-pull'),
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert receipt["height"] >= 44
|
||||
assert receipt["hidden"] is False
|
||||
assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"]
|
||||
assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"])
|
||||
assert metrics["state"] == "open"
|
||||
assert metrics["closeHidden"] is False
|
||||
assert metrics["receiptHidden"] is True
|
||||
assert metrics["focused"] is True
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const calls=[];
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
const receipt=createReleaseReceipt({
|
||||
storage,
|
||||
getLogin:()=> 'timmy',
|
||||
listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true,
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path, method:options.method || 'GET'});
|
||||
if (path.endsWith('/failure')) return {
|
||||
commit_sha:'abc1234', run_id:91, job_index:3,
|
||||
name:'CI / browser', excerpt:'AssertionError: mobile viewport overflow',
|
||||
};
|
||||
if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'};
|
||||
return {
|
||||
commit_sha:'abc1234', ci_state:'failure', release:null,
|
||||
checks:[{
|
||||
name:'CI / browser with a deliberately long workflow name',
|
||||
state:'failure', description:'Playwright failed at the narrow viewport',
|
||||
url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',
|
||||
recovery:{run_id:91, job_index:3},
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
receipt.capture(
|
||||
{repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'},
|
||||
{merge_commit_sha:'abc1234'},
|
||||
);
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
await receipt.refresh();
|
||||
const review=document.querySelector('.release-failure-summary button');
|
||||
const reviewHeight=review.getBoundingClientRect().height;
|
||||
review.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
const recovery=document.querySelector('.release-failure-recovery');
|
||||
const retry=recovery.querySelector('button');
|
||||
const retryHeight=retry.getBoundingClientRect().height;
|
||||
const excerpt=recovery.querySelector('pre').textContent;
|
||||
const sha=recovery.querySelector('strong').textContent;
|
||||
const job=recovery.querySelector('a').href;
|
||||
const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
retry.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
return {
|
||||
calls, reviewHeight, retryHeight, excerpt, sha, job, overflow,
|
||||
state:receipt.restore()[0].status.label,
|
||||
};
|
||||
}""")
|
||||
expect(page.locator("#release-receipt-sheet")).to_be_visible()
|
||||
assert result["reviewHeight"] >= 44
|
||||
assert result["retryHeight"] >= 44
|
||||
assert result["excerpt"] == "AssertionError: mobile viewport overflow"
|
||||
assert result["sha"] == "Merge abc1234"
|
||||
assert result["job"].endswith("/actions/runs/91/jobs/3")
|
||||
assert result["overflow"] is False
|
||||
assert result["state"] == "Checks running"
|
||||
assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"]
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_failed_release_prepares_a_visible_draft_rollback_receipt_on_phone(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const values=new Map(), calls=[], opened=[];
|
||||
const receipt=createReleaseReceipt({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true, openPull:pull=>opened.push(pull),
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path,method:options.method||'GET'});
|
||||
if(path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'failed'};
|
||||
if(path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,rollback_of:'abc1234',url:'https://forge.example/git/stackchain/api/pulls/9'};
|
||||
return {commit_sha:'abc1234',ci_state:'failure',release:null,checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}]};
|
||||
},
|
||||
});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'},{merge_commit_sha:'abc1234'});
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
await receipt.refresh();
|
||||
document.querySelector('.release-failure-summary button').click();
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const rollback=[...document.querySelectorAll('.release-failure-actions button')].find(button=>button.textContent==='Prepare rollback PR');
|
||||
const actionHeight=rollback.getBoundingClientRect().height;
|
||||
rollback.click();
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const status=document.querySelector('.release-rollback-status');
|
||||
return {calls,opened,actionHeight,status:status?.textContent||'',overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};
|
||||
}""")
|
||||
assert result["actionHeight"] >= 44
|
||||
assert result["status"] == "Draft rollback #9 is ready for review."
|
||||
assert result["opened"][0]["number"] == 9
|
||||
assert result["calls"][-1]["path"].endswith("/release-receipt/abc1234/rollback")
|
||||
assert result["overflow"] is False
|
||||
browser.close()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered authored-pull recovery checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_mobile_search_reopens_closed_authored_pull_into_my_work(width, height):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
|
||||
page.evaluate("""() => {
|
||||
const detail = {
|
||||
repository:'stackchain/web', number:9, kind:'pull', state:'closed',
|
||||
authored_pull_reopenable:true, head_sha:'abc1234',
|
||||
};
|
||||
const button = document.querySelector('#start-search-result');
|
||||
globalThis.renderSearchPreviewStart(detail, {status:'ready'}, button);
|
||||
globalThis.recoveryMetrics = {calls:[], history:['search']};
|
||||
const mutate = globalThis.searchPreviewMutation((path, options) => {
|
||||
globalThis.recoveryMetrics.calls.push({path, body:JSON.parse(options.body)});
|
||||
return Promise.resolve({...detail, state:'open'});
|
||||
});
|
||||
const preview = globalThis.createSearchPreview({
|
||||
fetchJson:async () => detail,
|
||||
mutate,
|
||||
onState:() => {},
|
||||
});
|
||||
preview.open(detail);
|
||||
const recover = globalThis.createSearchAuthoredPullRecovery({
|
||||
confirm:message => window.confirm(message),
|
||||
reopen:target => preview.reopenPull(target),
|
||||
refresh:async () => { globalThis.recoveryMetrics.refreshed = true; },
|
||||
find:() => ({repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}),
|
||||
open:item => globalThis.recoveryMetrics.history.push(item.kind),
|
||||
unavailable:message => { globalThis.recoveryMetrics.unavailable = message; },
|
||||
});
|
||||
button.addEventListener('click', () => recover(detail));
|
||||
}""")
|
||||
|
||||
action = page.locator("#start-search-result")
|
||||
expect(action).to_be_visible()
|
||||
expect(action).to_have_text("Reopen in My Work")
|
||||
bounds = action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
action.click()
|
||||
page.wait_for_function("globalThis.recoveryMetrics.history.length === 2")
|
||||
metrics = page.evaluate("globalThis.recoveryMetrics")
|
||||
|
||||
assert metrics["calls"] == [{
|
||||
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
|
||||
"body": {"expected_head_sha": "abc1234"},
|
||||
}]
|
||||
assert metrics["refreshed"] is True
|
||||
assert metrics["history"] == ["search", "pull"]
|
||||
assert "unavailable" not in metrics
|
||||
browser.close()
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged branch-cleanup journey runs only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_merged_source_branch_cleanup_is_phone_openable_and_exact(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const calls=[];
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
const receipt=createReleaseReceipt({
|
||||
storage,
|
||||
getLogin:()=> 'timmy',
|
||||
listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true,
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path, method:options.method, body:options.body});
|
||||
return {deleted:true, source_branch:'timmy/feature', source_head_sha:'abc1234'};
|
||||
},
|
||||
});
|
||||
receipt.capture(
|
||||
{repository:'stackchain/api', number:7, key:'stackchain/api#7'},
|
||||
{merge_commit_sha:'merge456', source_branch:'timmy/feature', source_head_sha:'abc1234', source_repository:'stackchain/api'},
|
||||
);
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
const button=document.querySelector('.release-watchlist-actions button');
|
||||
const box=button.getBoundingClientRect();
|
||||
const label=button.getAttribute('aria-label');
|
||||
button.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
return {
|
||||
calls, label, height:box.height,
|
||||
branch:document.querySelector('.release-branch-cleanup-status').textContent,
|
||||
overflow:document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}""")
|
||||
expect(page.locator("#release-receipt-sheet")).to_be_visible()
|
||||
assert result["height"] >= 44
|
||||
assert result["label"] == "Delete merged source branch timmy/feature at abc1234"
|
||||
assert result["calls"][0]["method"] == "DELETE"
|
||||
assert result["branch"] == "Branch timmy/feature · deleted"
|
||||
assert result["overflow"] is False
|
||||
browser.close()
|
||||
@@ -100,3 +100,24 @@ def test_browser_job_gates_authored_pull_queue_journey():
|
||||
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
|
||||
@@ -827,6 +827,143 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_closed_authored_pull_reopen_sends_head_guard_to_pull_api():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
const mutate = globalThis.searchPreviewMutation(async (path, options) => {{
|
||||
calls.push({{path, options}});
|
||||
return {{repository:'stackchain/web',number:9,state:'open',head_sha:'abc1234'}};
|
||||
}});
|
||||
const detail = {{
|
||||
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
||||
authored_pull_reopenable:true,head_sha:'abc1234',
|
||||
}};
|
||||
const result = await mutate(detail, 'reopen-pull');
|
||||
process.stdout.write(JSON.stringify({{calls,result}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [{
|
||||
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
|
||||
"options": {
|
||||
"method": "PATCH",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": '{"expected_head_sha":"abc1234"}',
|
||||
},
|
||||
}],
|
||||
"result": {
|
||||
"repository": "stackchain/web",
|
||||
"number": 9,
|
||||
"state": "open",
|
||||
"head_sha": "abc1234",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_closed_authored_pull_exposes_reopen_in_my_work_action():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
const detail = {{
|
||||
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
||||
authored_pull_reopenable:true,head_sha:'abc1234',
|
||||
}};
|
||||
const ready = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewStart(detail, {{status:'ready'}}, ready);
|
||||
const pending = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewStart(detail, {{status:'reopening'}}, pending);
|
||||
process.stdout.write(JSON.stringify({{ready,pending}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"ready": {
|
||||
"hidden": False,
|
||||
"textContent": "Reopen in My Work",
|
||||
"disabled": False,
|
||||
},
|
||||
"pending": {
|
||||
"hidden": False,
|
||||
"textContent": "Reopen in My Work",
|
||||
"disabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_closed_authored_pull_reopen_is_single_flight():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
let resolveMutation;
|
||||
const states = [];
|
||||
const detail = {{
|
||||
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
||||
authored_pull_reopenable:true,head_sha:'abc1234',
|
||||
}};
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:async () => detail,
|
||||
mutate:(_detail, action) => {{
|
||||
if (action !== 'reopen-pull') throw new Error('wrong action');
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ resolveMutation = resolve; }});
|
||||
}},
|
||||
onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open(detail);
|
||||
const first = preview.reopenPull(detail);
|
||||
const second = preview.reopenPull(detail);
|
||||
if (first !== second || calls !== 1) throw new Error('pull recovery was not single-flight');
|
||||
resolveMutation({{...detail,state:'open'}});
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify({{calls,status:states.at(-1).status}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"calls": 1, "status": "reopened"}
|
||||
|
||||
|
||||
def test_authored_pull_recovery_confirms_refreshes_and_opens_above_search_history():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const events = [];
|
||||
const history = ['search'];
|
||||
const detail = {{repository:'stackchain/web',number:9,kind:'pull',head_sha:'abc1234'}};
|
||||
const item = {{repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}};
|
||||
const recover = globalThis.createSearchAuthoredPullRecovery({{
|
||||
confirm:message => {{ events.push(['confirm',message]); return true; }},
|
||||
reopen:target => {{ events.push(['reopen',target.head_sha]); return Promise.resolve({{...target,state:'open'}}); }},
|
||||
refresh:() => {{ events.push(['refresh']); return Promise.resolve(); }},
|
||||
find:target => target.number === 9 ? item : null,
|
||||
open:opened => {{ events.push(['open',opened.key]); history.push('pull'); }},
|
||||
unavailable:message => events.push(['unavailable',message]),
|
||||
}});
|
||||
const outcome = await recover(detail);
|
||||
process.stdout.write(JSON.stringify({{events,history,outcome}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["events"] == [
|
||||
["confirm", "Reopen stackchain/web #9?"],
|
||||
["reopen", "abc1234"],
|
||||
["refresh"],
|
||||
["open", "stackchain/web#9"],
|
||||
]
|
||||
assert payload["history"] == ["search", "pull"]
|
||||
assert payload["outcome"] == "opened"
|
||||
|
||||
|
||||
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -1159,9 +1296,11 @@ def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claim
|
||||
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert "startButton.hidden = !(detail.reviewable" in html
|
||||
assert "detail.reviewable ? 'Review now'" in html
|
||||
assert "renderSearchPreviewStart(detail, state, startButton)" in html
|
||||
assert "button.hidden = !(detail.reviewable" in preview
|
||||
assert "detail.reviewable ? 'Review now'" in preview
|
||||
handler = html.split(
|
||||
"qs('#start-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#close-whiteboard')", 1)[0]
|
||||
@@ -1176,9 +1315,10 @@ def test_requested_pull_preview_opens_native_review_without_assignment_mutation(
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
|
||||
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in preview
|
||||
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
@@ -1332,9 +1472,10 @@ process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate:(detail,action)=>fetchReviewJson(" in html
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in preview
|
||||
assert "mutate:searchPreviewMutation(fetchReviewJson)" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
)[0]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,203 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_pull_creation_operations():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_creation_options_return_visible_repository_branches(monkeypatch):
|
||||
async def access(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return {"full_name": repository, "default_branch": "main", "permissions": {"push": True}}
|
||||
|
||||
async def branches(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [
|
||||
{"name": "feature/mobile", "commit": {"id": "abc1234"}},
|
||||
{"name": "main", "commit": {"id": "def5678"}},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_branches", branches, raising=False)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repos/stackchain/api/pull-creation-options")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"default_branch": "main",
|
||||
"branches": [
|
||||
{"name": "feature/mobile", "sha": "abc1234"},
|
||||
{"name": "main", "sha": "def5678"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_pull_endpoint_returns_confirmed_mobile_pull(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository, "permissions": {"push": True}}
|
||||
|
||||
async def create(repository, *, head, base, title, body, draft, expected_head_sha):
|
||||
calls.append((repository, head, base, title, body, draft, expected_head_sha))
|
||||
return {
|
||||
"number": 42,
|
||||
"repository": repository,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": {"ref": head, "sha": expected_head_sha},
|
||||
"base": {"ref": base},
|
||||
"draft": draft,
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
"existing": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_pull", create, raising=False)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls",
|
||||
headers={"Idempotency-Key": "mobile-create-42"},
|
||||
json={
|
||||
"head": "feature/mobile",
|
||||
"base": "main",
|
||||
"title": " Ship mobile create ",
|
||||
"body": " Context ",
|
||||
"draft": True,
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["number"] == 42
|
||||
assert calls == [
|
||||
("stackchain/api", "feature/mobile", "main", "Ship mobile create", "Context", True, "abc1234")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_pull_revalidates_head_and_confirms_created_pull():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/branches/feature/mobile"):
|
||||
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
|
||||
if request.method == "GET" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(200, json=[])
|
||||
if request.method == "POST" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(201, json={
|
||||
"number": 42,
|
||||
"title": "Ship mobile create",
|
||||
"body": "Context",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"head": {"ref": "feature/mobile", "sha": "abc1234"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "timmy"},
|
||||
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/42",
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_pull(
|
||||
"stackchain/api",
|
||||
head="feature/mobile",
|
||||
base="main",
|
||||
title="Ship mobile create",
|
||||
body="Context",
|
||||
draft=True,
|
||||
expected_head_sha="abc1234",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 42
|
||||
assert result["head"] == {"ref": "feature/mobile", "sha": "abc1234"}
|
||||
assert result["author"] == "timmy"
|
||||
assert result["existing"] is False
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.read() == b'{"head":"feature/mobile","base":"main","title":"Ship mobile create","body":"Context","draft":true}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_pull_returns_existing_source_base_without_mutation():
|
||||
requests = []
|
||||
existing = {
|
||||
"number": 41,
|
||||
"title": "Already open",
|
||||
"body": "Existing context",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"head": {"ref": "feature/mobile", "sha": "abc1234"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "timmy"},
|
||||
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/41",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if "/branches/" in request.url.path:
|
||||
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
|
||||
if request.method == "GET" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(200, json=[existing])
|
||||
return httpx.Response(500)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_pull(
|
||||
"stackchain/api", head="feature/mobile", base="main",
|
||||
title="Duplicate attempt", body="", draft=True,
|
||||
expected_head_sha="abc1234",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 41
|
||||
assert result["existing"] is True
|
||||
assert all(request.method != "POST" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_pull_endpoint_reports_stale_source_without_retrying(monkeypatch):
|
||||
async def access(repository):
|
||||
return {"full_name": repository, "permissions": {"push": True}}
|
||||
|
||||
async def stale(*args, **kwargs):
|
||||
raise gitea_proxy.PullCreateConflictError("Source branch changed before pull creation")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_pull", stale)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls",
|
||||
json={
|
||||
"head": "feature/mobile", "base": "main", "title": "Ship it",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The source branch changed. Refresh branches before creating the pull request."
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "create-pull-sheet.js"
|
||||
|
||||
|
||||
def run_node(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(MODULE))});
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], text=True, capture_output=True, cwd=ROOT)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_create_pull_controller_loads_branches_and_defaults_base():
|
||||
result = run_node("""
|
||||
const calls=[];
|
||||
const controller=createPullSheet.createController({
|
||||
request:async (url) => {
|
||||
calls.push(url);
|
||||
return {default_branch:'main',branches:[
|
||||
{name:'feature/mobile',sha:'abc1234'},
|
||||
{name:'main',sha:'def5678'}
|
||||
]};
|
||||
},
|
||||
storage:{getItem:()=>null,setItem:()=>{}},
|
||||
login:'timmy'
|
||||
});
|
||||
controller.selectRepository('stackchain/api').then(value => {
|
||||
process.stdout.write(JSON.stringify({value,calls,state:controller.state()}));
|
||||
}).catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["calls"] == ["api/v1/repos/stackchain/api/pull-creation-options"]
|
||||
assert result["state"]["base"] == "main"
|
||||
assert result["state"]["head"] == "feature/mobile"
|
||||
assert result["state"]["expected_head_sha"] == "abc1234"
|
||||
|
||||
|
||||
def test_create_pull_controller_submits_idempotently_and_keeps_draft_on_failure():
|
||||
result = run_node("""
|
||||
const calls=[]; const saved=[]; let fail=true;
|
||||
const controller=createPullSheet.createController({
|
||||
request:async (url, options) => {
|
||||
calls.push([url,options]);
|
||||
if (!options) return {default_branch:'main',branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}]};
|
||||
if (fail) { fail=false; throw new Error('offline'); }
|
||||
return {number:9,repository:'acme/app',head:{ref:'topic',sha:'abc1234'},base:{ref:'main'},draft:true};
|
||||
},
|
||||
storage:{getItem:()=>null,setItem:(key,value)=>saved.push([key,JSON.parse(value)])},
|
||||
login:'timmy', createId:()=> 'stable-id'
|
||||
});
|
||||
(async()=>{
|
||||
await controller.selectRepository('acme/app');
|
||||
controller.update({title:'Ship it',body:'Context'});
|
||||
let error=''; try { await controller.submit(); } catch (caught) { error=caught.message; }
|
||||
const retained=controller.state();
|
||||
const confirmed=await controller.submit();
|
||||
process.stdout.write(JSON.stringify({calls,error,retained,confirmed,saved}));
|
||||
})().catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["error"] == "offline"
|
||||
assert result["retained"]["title"] == "Ship it"
|
||||
posts = [call for call in result["calls"] if call[1]]
|
||||
assert len(posts) == 2
|
||||
assert posts[0][1]["headers"]["Idempotency-Key"] == "stable-id"
|
||||
assert posts[1][1]["headers"]["Idempotency-Key"] == "stable-id"
|
||||
assert posts[0][1]["body"]["expected_head_sha"] == "abc1234"
|
||||
assert result["confirmed"]["number"] == 9
|
||||
|
||||
|
||||
def test_create_pull_controller_restores_only_the_current_accounts_draft():
|
||||
result = run_node("""
|
||||
const values={
|
||||
'stackchain.create-pull.v1.timmy':JSON.stringify({repository:'acme/app',head:'topic',base:'main',title:'Saved title',body:'Saved body',draft:false}),
|
||||
'stackchain.create-pull.v1.alexander':JSON.stringify({repository:'private/repo',title:'Other account'})
|
||||
};
|
||||
const controller=createPullSheet.createController({
|
||||
request:async()=>({}), storage:{getItem:key=>values[key] || null,setItem:()=>{}}, login:'Timmy'
|
||||
});
|
||||
process.stdout.write(JSON.stringify(controller.state()));
|
||||
""")
|
||||
assert result == {
|
||||
"repository": "acme/app",
|
||||
"branches": [],
|
||||
"head": "topic",
|
||||
"base": "main",
|
||||
"expected_head_sha": "",
|
||||
"title": "Saved title",
|
||||
"body": "Saved body",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_creation_sheet_is_packaged_and_touch_safe():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="create-pull-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'id="create-pull-form"' in html
|
||||
assert 'id="create-pull-repository"' in html
|
||||
assert 'id="create-pull-head"' in html
|
||||
assert 'id="create-pull-base"' in html
|
||||
assert 'id="submit-create-pull"' in html
|
||||
assert 'static/create-pull-sheet.js' in bundle
|
||||
assert ".create-pull-panel button" in css
|
||||
assert "min-height:44px" in css.replace(" ", "")
|
||||
|
||||
|
||||
def test_create_pull_binding_renders_branch_receipt_and_created_pull():
|
||||
result = run_node("""
|
||||
const rendered=[]; const statuses=[]; const opened=[];
|
||||
const controller={
|
||||
selectRepository:async repository=>({repository,branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}],head:'topic',base:'main',expected_head_sha:'abc1234'}),
|
||||
update:values=>values,
|
||||
submit:async()=>({number:12,repository:'acme/app',existing:false})
|
||||
};
|
||||
const binding=createPullSheet.createBinding({controller,
|
||||
render:state=>rendered.push(state), status:value=>statuses.push(value),
|
||||
onCreated:value=>opened.push(value)
|
||||
});
|
||||
(async()=>{
|
||||
await binding.repositoryChanged('acme/app');
|
||||
await binding.submit();
|
||||
process.stdout.write(JSON.stringify({rendered,statuses,opened}));
|
||||
})().catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["rendered"][0]["expected_head_sha"] == "abc1234"
|
||||
assert result["statuses"][-1] == "Pull request #12 created."
|
||||
assert result["opened"][0]["number"] == 12
|
||||
|
||||
|
||||
def test_dashboard_wires_create_pull_inside_lazy_issue_capture_chunk():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
module = MODULE.read_text()
|
||||
assert "cp.b();" not in dashboard
|
||||
assert "else cp.b();" in module
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -875,6 +875,108 @@ async def test_work_preview_marks_authoritative_requested_pull_review_actionable
|
||||
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_closed_authored_unmerged_pull_recovery_with_head_guard():
|
||||
requested_paths = []
|
||||
|
||||
async def handler(request):
|
||||
requested_paths.append(request.url.path)
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"state": "closed",
|
||||
"merged": False,
|
||||
"user": {"login": "timmy"},
|
||||
"head": {"sha": "abc1234"},
|
||||
"requested_reviewers": [],
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Recover mobile flow",
|
||||
"state": "closed",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
"user": {"login": "timmy"},
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["authored_pull_reopenable"] is True
|
||||
assert preview["head_sha"] == "abc1234"
|
||||
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_does_not_offer_closed_pull_recovery_to_non_author():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"state": "closed",
|
||||
"merged": False,
|
||||
"user": {"login": "alexander"},
|
||||
"head": {"sha": "abc1234"},
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Foreign pull",
|
||||
"state": "closed",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
"user": {"login": "alexander"},
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["authored_pull_reopenable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_does_not_offer_recovery_for_merged_pull():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"user": {"login": "timmy"},
|
||||
"head": {"sha": "abc1234"},
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Merged pull",
|
||||
"state": "closed",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/web/pulls/9",
|
||||
"user": {"login": "timmy"},
|
||||
"pull_request": {"merged": True},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "pull", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["authored_pull_reopenable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -133,6 +133,37 @@ process.stdout.write(JSON.stringify({{current:controller.current(),hash:location
|
||||
}
|
||||
|
||||
|
||||
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = () => ({{
|
||||
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
||||
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
||||
focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
|
||||
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
|
||||
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
|
||||
this.state=state; location.hash=url.slice(url.indexOf('#'));
|
||||
}}}};
|
||||
const controller=createInsights({{
|
||||
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
|
||||
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
||||
mediaQuery:{{matches:true,addEventListener() {{}}}},
|
||||
}});
|
||||
controller.start();
|
||||
closeButton.listeners.click();
|
||||
header.inert = true;
|
||||
listeners.hashchange();
|
||||
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"current": False, "headerInert": True}
|
||||
|
||||
|
||||
def test_escape_requests_back_navigation_only_while_insights_is_open():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
@@ -243,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -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
|
||||
|
||||
@@ -122,6 +122,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||
assert result[0]["kind"] == "pull"
|
||||
|
||||
|
||||
def test_authored_pull_branch_update_is_single_flight_and_head_guarded():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let resolve;
|
||||
const pending = new Promise(done => {{ resolve = done; }});
|
||||
const controller = createPullSheet({{
|
||||
fetchJson:(url, options) => {{ calls.push([url, options]); return pending; }},
|
||||
storage:{{}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const first = controller.updateBranch(item, 'abc1234');
|
||||
const second = controller.updateBranch(item, 'abc1234');
|
||||
resolve({{previous_head_sha:'abc1234', head_sha:'def5678'}});
|
||||
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
|
||||
same:first === second,
|
||||
calls:calls.map(([url, options]) => [url, options.method, JSON.parse(options.body)]),
|
||||
heads:results.map(result => result.head_sha),
|
||||
}}))).catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], text=True, capture_output=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"same": True,
|
||||
"calls": [[
|
||||
"api/v1/repos/stackchain/api/pulls/7/update-branch",
|
||||
"POST",
|
||||
{"expected_head_sha": "abc1234"},
|
||||
]],
|
||||
"heads": ["def5678", "def5678"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_review_exposes_update_branch_recovery():
|
||||
frontend = Path(__file__).parents[1] / "frontend"
|
||||
html = (frontend / "index.html").read_text()
|
||||
source = PULL_SHEET.read_text()
|
||||
css = (frontend / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="update-pull-branch"' in html
|
||||
assert 'id="pull-branch-update-status"' in html
|
||||
assert "controller.updateBranch(item, detail.head_sha)" in source
|
||||
assert "doc.querySelector('#pull-review-retry')?.click()" in source
|
||||
assert ".pull-branch-update button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_my_prs_filter_and_count_include_only_authored_pulls():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -7642,6 +7689,39 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
|
||||
|
||||
|
||||
def test_authored_unassigned_pull_hides_assignment_ownership_controls():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const elements = new Map([
|
||||
['#pull-ownership', {{open:true,hidden:false,inert:false}}],
|
||||
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
|
||||
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
|
||||
['#load-pull-handoff', {{disabled:false}}],
|
||||
['#release-pull', {{disabled:false,textContent:''}}],
|
||||
['#pull-handoff-status', {{textContent:''}}],
|
||||
['#edit-pull-content', {{hidden:false}}],
|
||||
['#pull-edit-form', {{hidden:false}}],
|
||||
['#pull-edit-status', {{textContent:'stale'}}],
|
||||
]);
|
||||
const doc = {{querySelector:selector => elements.get(selector)}};
|
||||
createPullSheet.resetOwnershipControls(
|
||||
doc,
|
||||
{{key:'stackchain/api#7'}},
|
||||
() => false,
|
||||
{{authored:true,assigned:false}}
|
||||
);
|
||||
process.stdout.write(JSON.stringify(Object.fromEntries(elements)));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["#pull-ownership"]["hidden"] is True
|
||||
assert output["#pull-ownership"]["inert"] is True
|
||||
assert output["#load-pull-handoff"]["disabled"] is True
|
||||
assert output["#release-pull"]["disabled"] is True
|
||||
|
||||
|
||||
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -8670,6 +8750,76 @@ process.stdout.write(JSON.stringify({{result, offline}}));
|
||||
assert output["offline"]["summary"].startswith("Last known · ")
|
||||
|
||||
|
||||
def test_failed_actions_check_renders_in_app_recovery_control_only_when_supported():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
const supported = reviewSheet.renderChecks([
|
||||
{{name:'CI / lint', state:'failure', url:'https://forge.example/jobs/3', recovery:{{run_id:91, job_index:3}}}},
|
||||
], escapeHtml);
|
||||
const external = reviewSheet.renderChecks([
|
||||
{{name:'Vendor scan', state:'failure', url:'https://forge.example/jobs/4'}},
|
||||
], escapeHtml);
|
||||
process.stdout.write(JSON.stringify({{supported, external}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert 'class="ci-check-inspect"' in output["supported"]["html"]
|
||||
assert 'data-run-id="91"' in output["supported"]["html"]
|
||||
assert 'data-job-index="3"' in output["supported"]["html"]
|
||||
assert "Inspect failure" in output["supported"]["html"]
|
||||
assert "ci-check-inspect" not in output["external"]["html"]
|
||||
assert "Open job" in output["external"]["html"]
|
||||
|
||||
|
||||
def test_pull_controller_loads_failure_and_retries_exact_head():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
const controller = createPullSheet({{
|
||||
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{status:'queued'}}; }},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
controller.loadCheckFailure(item, 'abc1234', 91, 3).then(() =>
|
||||
controller.retryCheck(item, 'abc1234', 91, 3)
|
||||
).then(() => process.stdout.write(JSON.stringify(calls)));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
calls = json.loads(result.stdout)
|
||||
|
||||
assert calls[0]["url"].endswith(
|
||||
"/pulls/7/checks/91/jobs/3/failure?expected_head_sha=abc1234"
|
||||
)
|
||||
assert calls[0]["options"]["headers"]["Accept"] == "application/json"
|
||||
assert calls[1]["url"].endswith("/pulls/7/checks/91/jobs/3/retry")
|
||||
assert calls[1]["options"]["method"] == "POST"
|
||||
assert json.loads(calls[1]["options"]["body"]) == {"expected_head_sha": "abc1234"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_workspace_packages_accessible_ci_recovery_panel():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="pull-check-recovery"' in html
|
||||
assert 'id="pull-check-recovery-title"' in html
|
||||
assert 'id="pull-check-log"' in html
|
||||
assert 'id="retry-pull-check"' in html
|
||||
assert 'id="close-pull-check-recovery"' in html
|
||||
assert 'aria-live="assertive"' in html
|
||||
assert "createPullSheet.bindOwnershipControls" in html
|
||||
assert "#pull-check-list" in html and "ci-check-inspect" in html
|
||||
assert ".ci-check-recovery" in html and "max-width:100%" in html
|
||||
assert ".ci-check-log" in html and "overflow-x:auto" in html
|
||||
assert ".ci-check-actions" in html and "min-height:44px" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -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
|
||||
+1131
-26
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
|
||||
@@ -36,6 +36,40 @@ restored && first.refresh().then(status=>{
|
||||
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
|
||||
|
||||
|
||||
def test_release_receipt_deletes_the_exact_captured_source_branch_without_stopping_release_tracking():
|
||||
output = run_node(r"""
|
||||
const values=new Map(), calls=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
|
||||
calls.push({path,method:options.method||'GET',body:options.body||null});
|
||||
if ((options.method||'GET') === 'DELETE') return {number:7,deleted:true,source_branch:'timmy/feature',source_head_sha:'abc1234'};
|
||||
return {commit_sha:'merge456',ci_state:'pending',checks:[],release:null};
|
||||
}});
|
||||
const captured=receipt.capture(
|
||||
{repository:'stackchain/api',number:7,key:'stackchain/api#7'},
|
||||
{merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'}
|
||||
);
|
||||
(async()=>{
|
||||
await receipt.deleteBranch('stackchain/api','merge456');
|
||||
await receipt.refresh();
|
||||
process.stdout.write(JSON.stringify({captured,calls,restored:receipt.restore()}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["captured"]["source_branch"] == "timmy/feature"
|
||||
assert output["calls"][0] == {
|
||||
"path": "api/v1/repos/stackchain/api/pulls/7/source-branch",
|
||||
"method": "DELETE",
|
||||
"body": json.dumps({
|
||||
"source_branch": "timmy/feature",
|
||||
"expected_head_sha": "abc1234",
|
||||
}, separators=(",", ":")),
|
||||
}
|
||||
restored = output["restored"][0]
|
||||
assert restored["cleanup"] == {"state": "deleted", "message": "Source branch deleted."}
|
||||
assert restored["status"]["label"] == "Checks running"
|
||||
|
||||
|
||||
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
@@ -62,6 +96,132 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {
|
||||
assert output["restored"] == []
|
||||
|
||||
|
||||
def test_failed_release_check_loads_exact_commit_diagnostics_and_retries_single_flight():
|
||||
output = run_node(r"""
|
||||
const values=new Map(), calls=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let finishRetry;
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
|
||||
calls.push({path,method:options.method||'GET'});
|
||||
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
|
||||
if (path.endsWith('/retry')) return await new Promise(resolve=>{finishRetry=()=>resolve({commit_sha:'abc1234',status:'queued'});});
|
||||
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}],release:null};
|
||||
}});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
||||
(async()=>{
|
||||
const failed=(await receipt.refresh())[0];
|
||||
const detail=await receipt.reviewFailure('stackchain/api','abc1234',91,3);
|
||||
const first=receipt.retryFailure('stackchain/api','abc1234',91,3);
|
||||
const second=receipt.retryFailure('stackchain/api','abc1234',91,3);
|
||||
await new Promise(resolve=>setTimeout(resolve,0)); finishRetry();
|
||||
const results=await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({failed,detail,results,calls,after:receipt.restore()[0].status}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["failed"]["failures"] == [{
|
||||
"name": "CI / browser",
|
||||
"description": "Playwright failed",
|
||||
"url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
|
||||
"recovery": {"run_id": 91, "job_index": 3},
|
||||
}]
|
||||
assert output["detail"]["commit_sha"] == "abc1234"
|
||||
assert output["detail"]["excerpt"] == "AssertionError: failed"
|
||||
assert output["results"] == [True, True]
|
||||
assert [call["method"] for call in output["calls"]] == ["GET", "GET", "POST"]
|
||||
assert output["calls"][1]["path"].endswith(
|
||||
"/pulls/7/release-receipt/abc1234/checks/91/jobs/3/failure"
|
||||
)
|
||||
assert output["calls"][2]["path"].endswith("/checks/91/jobs/3/retry")
|
||||
assert output["after"]["label"] == "Checks running"
|
||||
assert output["after"]["checks"] == []
|
||||
|
||||
|
||||
def test_failed_release_row_renders_phone_recovery_with_exact_sha_log_and_job_fallback():
|
||||
output = run_node(r"""
|
||||
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
||||
global.document={createElement:tag=>node(tag)};
|
||||
const values=new Map(), listNode=node();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async path=>{
|
||||
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: mobile viewport overflow'};
|
||||
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}]};
|
||||
}});
|
||||
const flatten=root=>[root,...root.children.flatMap(flatten)];
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
||||
(async()=>{
|
||||
await receipt.refresh();
|
||||
const review=flatten(listNode).find(value=>value.textContent==='Review failure');
|
||||
await review.click();
|
||||
const nodes=flatten(listNode);
|
||||
process.stdout.write(JSON.stringify({
|
||||
reviewLabel:review['aria-label'],
|
||||
texts:nodes.map(value=>value.textContent).filter(Boolean),
|
||||
links:nodes.filter(value=>value.tag==='a').map(value=>({text:value.textContent,href:value.href})),
|
||||
classes:nodes.map(value=>value.className).filter(Boolean),
|
||||
}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["reviewLabel"] == "Review failed check CI / browser for abc1234"
|
||||
assert "Merge abc1234" in output["texts"]
|
||||
assert "AssertionError: mobile viewport overflow" in output["texts"]
|
||||
assert "Retry failed check" in output["texts"]
|
||||
assert output["links"] == [{
|
||||
"text": "Open job",
|
||||
"href": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
|
||||
}]
|
||||
assert "release-failure-recovery" in output["classes"]
|
||||
|
||||
|
||||
def test_diagnosed_failed_release_prepares_one_rollback_pull_and_opens_mobile_workspace():
|
||||
output = run_node(r"""
|
||||
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
||||
global.document={createElement:tag=>node(tag)};
|
||||
const values=new Map(), calls=[], opened=[], listNode=node(), dialog={open:true,close(){this.open=false}};
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({
|
||||
storage,getLogin:()=> 'timmy',listNode,dialog,confirmAction:()=>true,openPull:pull=>opened.push(pull),
|
||||
fetchJson:async(path,options={})=>{
|
||||
calls.push({path,method:options.method||'GET'});
|
||||
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
|
||||
if (path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,url:'https://forge.example/git/stackchain/api/pulls/9',rollback_of:'abc1234'};
|
||||
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}],release:null};
|
||||
},
|
||||
});
|
||||
const flatten=root=>[root,...root.children.flatMap(flatten)];
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
||||
(async()=>{
|
||||
await receipt.refresh();
|
||||
const before=flatten(listNode).filter(value=>value.textContent==='Prepare rollback PR').length;
|
||||
await flatten(listNode).find(value=>value.textContent==='Review failure').click();
|
||||
const rollback=flatten(listNode).find(value=>value.textContent==='Prepare rollback PR');
|
||||
if (rollback) await rollback.click();
|
||||
process.stdout.write(JSON.stringify({before,found:!!rollback,calls,opened,dialogOpen:dialog.open,status:receipt.restore()[0].rollback||null}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert output["before"] == 0
|
||||
assert output["found"] is True
|
||||
assert output["calls"][-1] == {
|
||||
"path": "api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/rollback",
|
||||
"method": "POST",
|
||||
}
|
||||
assert output["opened"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 9,
|
||||
"draft": True,
|
||||
"url": "https://forge.example/git/stackchain/api/pulls/9",
|
||||
"rollback_of": "abc1234",
|
||||
"kind": "pull",
|
||||
"key": "stackchain/api#9",
|
||||
"state": "open",
|
||||
}]
|
||||
assert output["dialogOpen"] is False
|
||||
assert output["status"]["state"] == "prepared"
|
||||
assert output["status"]["number"] == 9
|
||||
|
||||
|
||||
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
|
||||
output = run_node(r"""
|
||||
const values=new Map();
|
||||
@@ -142,6 +302,25 @@ receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {
|
||||
assert [item["commit_sha"] for item in output["after"]] == ["pending"]
|
||||
|
||||
|
||||
def test_watchlist_renders_confirmed_touch_cleanup_for_the_exact_source_branch():
|
||||
output = run_node(r"""
|
||||
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
||||
global.document={createElement:tag=>node(tag)};
|
||||
const values=new Map(), calls=[], listNode=node();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async(path,options={})=>{calls.push({path,method:options.method});return {deleted:true};}});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'});
|
||||
const row=listNode.children[0], copy=row.children[0], actions=row.children[1], cleanup=actions.children[0];
|
||||
(async()=>{ await cleanup.click(); process.stdout.write(JSON.stringify({branch:copy.children[2].textContent,cleanupText:cleanup.textContent,minLabel:cleanup['aria-label'],calls,after:receipt.restore()[0].cleanup})); })();
|
||||
""")
|
||||
|
||||
assert output["branch"] == "Branch timmy/feature · abc1234"
|
||||
assert output["cleanupText"] == "Delete source branch"
|
||||
assert output["minLabel"] == "Delete merged source branch timmy/feature at abc1234"
|
||||
assert output["calls"][0]["method"] == "DELETE"
|
||||
assert output["after"]["state"] == "deleted"
|
||||
|
||||
|
||||
def test_watchlist_polls_pending_merges_only_while_foregrounded():
|
||||
output = run_node(r"""
|
||||
const values=new Map(), scheduled=[], calls=[];
|
||||
@@ -214,8 +393,19 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
|
||||
assert 'id="release-receipt-launcher"' in html
|
||||
assert 'id="release-receipt-sheet"' in html
|
||||
assert 'id="release-watchlist"' in html
|
||||
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
|
||||
assert 'rR.capture(merging, mergeResult)' in dashboard
|
||||
assert "listNode:qs('#release-watchlist')" in dashboard
|
||||
assert "openPull:openPullSheet" in dashboard
|
||||
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
|
||||
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
|
||||
assert ".release-watchlist-item" in css
|
||||
assert ".release-watchlist-actions" in css
|
||||
cleanup_css = css[css.index(".release-watchlist-actions"):]
|
||||
assert "min-height:44px" in cleanup_css
|
||||
assert "grid-template-columns:1fr" in cleanup_css
|
||||
recovery_css = css[css.index(".release-failure-summary"):]
|
||||
assert "overflow-x:hidden" in recovery_css
|
||||
assert "white-space:pre-wrap" in recovery_css
|
||||
assert "min-height:44px" in recovery_css
|
||||
assert ".release-failure-actions" in recovery_css
|
||||
assert "grid-template-columns:1fr" in recovery_css
|
||||
@@ -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(
|
||||
|
||||
@@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
|
||||
"name": "lint/<unsafe>", "state": "failure",
|
||||
"description": "Formatting failed " + "x" * 222,
|
||||
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
|
||||
"recovery": {"run_id": 9, "job_index": 4},
|
||||
},
|
||||
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
|
||||
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@ from pathlib import Path
|
||||
SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
def test_source_branch_cleanup_has_a_specific_security_activity_label():
|
||||
source = SECURITY_CENTER.read_text()
|
||||
|
||||
assert "source_branch_deleted: 'Source branch deleted'" in source
|
||||
|
||||
|
||||
def test_release_rollback_has_a_specific_security_activity_label():
|
||||
source = SECURITY_CENTER.read_text()
|
||||
|
||||
assert "release_rollback_prepared: 'Release rollback prepared'" in source
|
||||
|
||||
|
||||
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
|
||||
@@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
@@ -197,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -219,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -228,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -236,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -251,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -259,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -269,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -285,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1354,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -1467,6 +1467,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/voice-conversation-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-pull-sheet.js",
|
||||
"/dashboard/static/mobile-create-issue-nav.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,36 @@ async def test_resolve_work_route_returns_only_a_requested_review():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_returns_an_open_authored_unassigned_pull():
|
||||
async def upstream(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 703,
|
||||
"number": 90,
|
||||
"title": "Ship authored flow",
|
||||
"state": "open",
|
||||
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/90",
|
||||
"assignees": [],
|
||||
"requested_reviewers": [],
|
||||
"user": {"login": "timmy"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.resolve_work_route(
|
||||
"pull", "stackchain/dashboard", 90, None
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["kind"] == "pull"
|
||||
assert result["number"] == 90
|
||||
assert result["is_assigned"] is False
|
||||
assert result["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
|
||||
requests = []
|
||||
|
||||
Reference in new issue
Block a user