Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d97b2e84a | ||
|
|
502fcb30f5 | ||
|
|
b541c707fc | ||
|
|
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 | ||
|
|
be5603d41b | ||
|
|
4e074ae18e | ||
|
|
a4ae3e1c7f | ||
|
|
1a2297e943 | ||
|
|
611e79fc1e | ||
|
|
50817bed9a | ||
|
|
1f9f0f5c22 | ||
|
|
d776a2b003 | ||
|
|
7517d81046 | ||
|
|
091a01ba67 | ||
|
|
bad14d1b94 | ||
|
|
c4bb2f5eba | ||
|
|
d2a815c4bc | ||
|
|
3bc1d2f602 | ||
|
|
bd96c3caa3 | ||
|
|
f8ab6977ed | ||
|
|
8285b39e06 | ||
|
|
8c4969cc44 | ||
|
|
1f950f93dd | ||
|
|
505a40d077 | ||
|
|
0cabce66ed | ||
|
|
df90fb1e0e | ||
|
|
e39e92b025 | ||
|
|
07836f7176 | ||
|
|
0f36d3077b | ||
|
|
33c8761e80 | ||
|
|
157703527a | ||
|
|
5921027e80 | ||
|
|
31700d5c71 | ||
|
|
c3cb498ce1 | ||
|
|
f168a0187d | ||
|
|
877c995b47 | ||
|
|
e572b3410e | ||
|
|
a2501e47c3 | ||
|
|
2b6730288a | ||
|
|
0efd44fd90 | ||
|
|
187b0decae | ||
|
|
75846cf045 | ||
|
|
544ffb4ae7 | ||
|
|
eea9097740 | ||
|
|
35a88bb398 | ||
|
|
4e3f67df3d | ||
|
|
7131792ded | ||
|
|
41c28cc70a | ||
|
|
aa12c4ab9d | ||
|
|
c4bb7a9be1 | ||
|
|
fdf38a72ac | ||
|
|
29e3f55e07 | ||
|
|
0a18d1ac0f | ||
|
|
12c0e18dfe | ||
|
|
875ec45928 | ||
|
|
b79973fc2e | ||
|
|
6b98eb5cbe | ||
|
|
32e2a228b9 | ||
|
|
bbf397454d | ||
|
|
d3ba5975a0 | ||
|
|
6c12731215 | ||
|
|
4fe07bcd52 | ||
|
|
846c28001e | ||
|
|
5c43a5678a | ||
|
|
4b4e55dbe2 | ||
|
|
fe1049ee99 | ||
|
|
73017c1a30 | ||
|
|
9c187b5ae5 | ||
|
|
7fbbc2f942 |
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 -q
|
||||
run: python3 -m pytest tests/e2e -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -19,6 +19,12 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
After an exact merged commit fails release checks, its mobile release receipt can load the
|
||||
failed job evidence and prepare a draft rollback pull request. The server revalidates the
|
||||
operator's participation and push access, reverses only bounded UTF-8 files whose current
|
||||
default-branch content still matches the failed merge, and creates one atomic commit on a
|
||||
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
|
||||
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
|
||||
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
|
||||
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
@@ -107,11 +113,40 @@ device can reopen the exact Search with one tap while stale writes surface a con
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
|
||||
changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
|
||||
retired without leaving the preview; the next captured change opens immediately, and retiring the
|
||||
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
|
||||
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
|
||||
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
|
||||
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
|
||||
restores only the loaded revision to the unseen queue and continues the captured pass without changing
|
||||
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
|
||||
and current review position unchanged. Following counts never influence the recommended Work queue. Set
|
||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||
|
||||
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
|
||||
channel independently, validates the device session immediately before delivery, and checkpoints
|
||||
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
|
||||
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
|
||||
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
|
||||
unchanged.
|
||||
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
|
||||
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
|
||||
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
|
||||
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
|
||||
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
|
||||
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
|
||||
next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
@@ -381,7 +416,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
|
||||
# Following alerts use generic copy and deep-link to the changed-first Following review.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
|
||||
@@ -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();
|
||||
+110
-5
@@ -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; }
|
||||
@@ -955,12 +979,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.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 { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.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; }
|
||||
.pull-review-request select, .pull-review-request button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
@@ -1038,7 +1070,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-review[hidden] { display:none; }
|
||||
.search-preview-review h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-review button { min-height:44px; width:100%; }
|
||||
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
|
||||
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
@@ -1050,10 +1089,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
@@ -1077,12 +1119,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
#search-preview-overview, #search-preview-conversation,
|
||||
#search-preview-overview, #search-preview-conversation, #search-preview-review,
|
||||
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
||||
#search-preview-actions { position:static; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
|
||||
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
|
||||
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.following-panel h2, .following-panel p { margin:0; }
|
||||
.following-panel header button, #retry-following { min-height:44px; }
|
||||
.following-review-action { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
|
||||
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
|
||||
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
|
||||
.following-card em { color:var(--accent); font-size:.75rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.following-card.has-unseen-change { border-color:var(--accent); }
|
||||
.following-card strong, .following-card small { overflow-wrap:anywhere; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
@@ -1233,6 +1288,42 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-reviewer-summary { margin:10px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.pull-reviewer-summary h3 { margin:0 0 6px; font-size:14px; }
|
||||
.pull-reviewer-status { padding:8px 0; border-top:1px solid #203a5c; overflow-wrap:anywhere; }
|
||||
.pull-reviewer-status:first-child { border-top:0; }
|
||||
.pull-reviewer-status-heading { display:flex; justify-content:space-between; gap:10px; }
|
||||
.pull-reviewer-status-heading span { text-align:right; }
|
||||
.cancel-review-request { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review-feedback { margin-top:8px; border:1px solid #2a496e; border-radius:8px; padding:0 8px 8px; }
|
||||
.pull-review-feedback > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review-feedback-summary { margin:4px 0 10px; white-space:pre-wrap; }
|
||||
.pull-review-feedback-file { margin-top:8px; padding-top:8px; border-top:1px solid #203a5c; }
|
||||
.pull-review-feedback-file h4 { margin:0 0 4px; overflow-wrap:anywhere; }
|
||||
.pull-review-feedback-file ul { margin:0; padding-left:20px; }
|
||||
.pull-review-feedback-file li { margin:6px 0; }
|
||||
.pull-review-feedback-file small { display:block; color:#93c5fd; }
|
||||
.pull-review-feedback-file button { min-height:44px; width:100%; margin-top:4px; }
|
||||
.address-review-feedback, [data-load-review-feedback] { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-feedback-pass { margin-top:10px; padding:10px; border:1px solid #3b82f6; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.pull-feedback-pass-heading { display:flex; align-items:start; justify-content:space-between; gap:8px; }
|
||||
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
|
||||
.pull-feedback-pass p { white-space:pre-wrap; }
|
||||
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
|
||||
#make-pull-feedback-fix { width:100%; margin-top:8px; border-color:#4ade80; }
|
||||
.pull-feedback-file-editor { display:grid; min-width:0; gap:8px; margin-top:10px; padding:10px; border:1px solid #4ade80; border-radius:10px; overflow-x:hidden; }
|
||||
.pull-feedback-file-editor[hidden] { display:none; }
|
||||
.pull-feedback-file-editor h5 { margin:0; overflow-wrap:anywhere; }
|
||||
#pull-feedback-file-content { min-height:36dvh; resize:vertical; font:13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size:2; white-space:pre; overflow:auto; }
|
||||
#pull-feedback-commit-message { box-sizing:border-box; width:100%; min-width:0; }
|
||||
.pull-feedback-file-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
|
||||
@media (max-width:359px) { .pull-feedback-file-actions { grid-template-columns:1fr; } }
|
||||
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
|
||||
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
|
||||
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
|
||||
.pull-feedback-pass button { min-height:44px; }
|
||||
#finish-pull-feedback, #request-feedback-review { width:100%; margin-top:8px; }
|
||||
#request-updated-pull-review { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
@@ -1367,6 +1458,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
@@ -1468,3 +1560,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}}
|
||||
+156
-55
@@ -3,12 +3,12 @@
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const mobileAppBadge = createMobileAppBadge({
|
||||
const appBadge = createMobileAppBadge({
|
||||
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
|
||||
container:qs('#app-badge-setting'), navigator, storage:localStorage,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
});
|
||||
mobileAppBadge.start();
|
||||
appBadge.start();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
viewport: window.visualViewport,
|
||||
@@ -103,8 +103,38 @@
|
||||
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
|
||||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
let mobilePreparationItems = {};
|
||||
let queueCounts = {};
|
||||
let preparationItems = {};
|
||||
const followingQueue = attachFollowing(item => {
|
||||
searchPreviewReturnKind = 'following';
|
||||
return searchPreview.open(item);
|
||||
}, {
|
||||
onCount:(count, items) => {
|
||||
appBadge.reconcile('following', count, true);
|
||||
queueCounts.following = count;
|
||||
queueCounts.followingUnavailable = false;
|
||||
preparationItems.following = items.filter(item => item.has_unseen_change === true);
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onStatus:status => {
|
||||
if (status === 'loading') return;
|
||||
queueCounts.followingUnavailable = status === 'error';
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onReviewComplete:() => mobileStartDay.completePhase('following'),
|
||||
});
|
||||
function bindDetailWatch(kind, button, status) {
|
||||
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
|
||||
button.disabled=['loading','watching','unwatching'].includes(state);
|
||||
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
|
||||
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
|
||||
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
|
||||
}});
|
||||
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
|
||||
return feature;
|
||||
}
|
||||
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
|
||||
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
@@ -132,14 +162,16 @@
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
openFollowing:followingQueue.open,
|
||||
openFiled: openFiledFollowUp,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: announceWork,
|
||||
getCounts: () => mobileQueueCounts,
|
||||
getCounts: () => queueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
function openMobileStartDay() {
|
||||
followingQueue.load().catch(() => {});
|
||||
const state = mobileStartDay.state();
|
||||
mobileStartDay.render();
|
||||
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
||||
@@ -170,13 +202,15 @@
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileStartDay = createMobileStartDay({
|
||||
getCounts: () => mobileQueueCounts,
|
||||
getPhaseItems: () => mobilePreparationItems,
|
||||
getCounts: () => queueCounts,
|
||||
getPhaseItems: () => preparationItems,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
openQueue: name => {
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
if (sheet.open) sheet.close();
|
||||
return name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name);
|
||||
return name === 'find' ? qs('#find-work').click() :
|
||||
name === 'following' ? (queueCounts.followingUnavailable ? followingQueue.open() :
|
||||
followingQueue.review()) : mobileQueueLauncher.open(name);
|
||||
},
|
||||
onHandoff: current => {
|
||||
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
|
||||
@@ -294,7 +328,7 @@
|
||||
let launchFilterResolved = false;
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
selectedWorkFilter = savedFilter;
|
||||
savedWorkFilter = savedFilter;
|
||||
launchFilterResolved = true;
|
||||
@@ -334,6 +368,7 @@
|
||||
let selectedPullDetail = null;
|
||||
let pullConversation = null;
|
||||
let pullReviewState = null;
|
||||
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let pendingIssueFilingIntent = 'create-and-assign';
|
||||
@@ -352,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,
|
||||
@@ -1245,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);
|
||||
@@ -1253,9 +1289,9 @@
|
||||
if (continuing) return await completeOwnershipExitToday(item);
|
||||
paintMyWork(lastContextSnapshot);
|
||||
return false;
|
||||
});
|
||||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
rRC();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1271,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());
|
||||
@@ -1493,7 +1529,7 @@
|
||||
},
|
||||
onPagination: pagination => {
|
||||
notificationPagination = pagination;
|
||||
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
|
||||
appBadge.reconcile('updates', pagination.total, true);
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
qs('#notification-page-status').textContent = pagination.total ?
|
||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||
@@ -3396,7 +3432,7 @@
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
mobilePreparationItems = {
|
||||
preparationItems = {
|
||||
delivery:draftInbox.partition(lastDrafts).deliveries,
|
||||
agenda:agendaMyWork(activeMyWork),
|
||||
attention:activeMyWork.filter(item => item.needs_attention),
|
||||
@@ -3417,7 +3453,7 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileQueueCounts = counts;
|
||||
queueCounts = counts;
|
||||
mobileStartDay.reconcile({
|
||||
authoritative:authoritativeMyWorkRefresh,
|
||||
authoritativePhases:['delivery'],
|
||||
@@ -3463,10 +3499,10 @@
|
||||
if (selectedWorkFilter === 'agenda') return ['issue'];
|
||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'filed') return ['filed'];
|
||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||
if (selectedWorkFilter === 'review') return ['review'];
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
|
||||
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
|
||||
return [selectedWorkFilter];
|
||||
}
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3712,7 +3748,7 @@
|
||||
}
|
||||
|
||||
function updateWorkPaginationControls() {
|
||||
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' };
|
||||
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull requests' };
|
||||
const streams = activeWorkStreams();
|
||||
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
||||
const summaries = streams.flatMap(stream => {
|
||||
@@ -4254,6 +4290,15 @@
|
||||
issueConversation = null;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
if (offlineDetail) {
|
||||
qs('#watch-issue-detail').hidden = true;
|
||||
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
|
||||
} else {
|
||||
qs('#watch-issue-detail').hidden = item.state !== 'open';
|
||||
void issueDetailWatch.open(item).catch(error => {
|
||||
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
|
||||
});
|
||||
}
|
||||
qs('#issue-sheet-key').textContent = item.key || '';
|
||||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||||
@@ -4493,7 +4538,7 @@
|
||||
qs('#pull-review-progress').textContent = pullReviewState.total ?
|
||||
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
|
||||
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
|
||||
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
|
||||
const eligibility = createPullSheet.review(detail, pullReviewState, document);
|
||||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||||
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
||||
@@ -4530,9 +4575,7 @@
|
||||
return;
|
||||
}
|
||||
selectedPullDetail = { ...selectedPullDetail, ...status };
|
||||
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
|
||||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||||
renderPullReview(selectedPullDetail);
|
||||
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
|
||||
}
|
||||
|
||||
@@ -4563,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;
|
||||
@@ -4576,10 +4620,20 @@
|
||||
pullConversation = null;
|
||||
pullReviewState = null;
|
||||
qs('#pull-sheet').classList.add('open');
|
||||
if (offlineDetail) {
|
||||
qs('#watch-pull-detail').hidden = true;
|
||||
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
|
||||
} else {
|
||||
qs('#watch-pull-detail').hidden = item.state !== 'open';
|
||||
void pullDetailWatch.open(item).catch(error => {
|
||||
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
|
||||
});
|
||||
}
|
||||
qs('#pull-sheet-key').textContent = item.key || '';
|
||||
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
||||
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
||||
qs('#pull-sheet-body').textContent = '';
|
||||
|
||||
qs('#pull-files').textContent = '';
|
||||
qs('#pull-review').open = false;
|
||||
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
|
||||
@@ -4595,6 +4649,7 @@
|
||||
qs('#pull-checks-summary').textContent = 'Not loaded';
|
||||
qs('#pull-check-list').textContent = '';
|
||||
qs('#pull-checks').open = false;
|
||||
|
||||
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
||||
qs('#merge-pull').disabled = true;
|
||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||
@@ -4607,9 +4662,11 @@
|
||||
const detail = offlineDetail || await pullController.load(item);
|
||||
if (selectedPull !== item) return;
|
||||
selectedPullDetail = detail;
|
||||
pullController.setReviewDetail(detail);
|
||||
pullConversation = pullController.conversation(item, detail.conversation);
|
||||
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
||||
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||||
|
||||
renderPullConversation(pullConversation.snapshot());
|
||||
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
||||
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
||||
@@ -5610,6 +5667,7 @@
|
||||
const renderSearchConversation = conversation =>
|
||||
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
|
||||
function renderSearchPreview(state) {
|
||||
followingQueue.preview(state);
|
||||
const sheet = qs('#search-preview');
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
@@ -5617,10 +5675,11 @@
|
||||
const queueButton = qs('#queue-search-result');
|
||||
const planButton = qs('#plan-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
const watchButton = qs('#watch-search-result');
|
||||
const shareButton = qs('#share-search-result');
|
||||
|
||||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||||
? 'Back to blockers' : 'Back to search';
|
||||
? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search';
|
||||
if (state.status === 'closed') {
|
||||
searchVoiceReply.cancel();
|
||||
sheet.classList.remove('open');
|
||||
@@ -5636,8 +5695,10 @@
|
||||
planButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
watchButton.hidden = true;
|
||||
watchButton.disabled = false;
|
||||
shareButton.disabled = true;
|
||||
renderSearchPreviewReply(state, null, searchPreview, document);
|
||||
renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
searchReplyAttachmentTarget = { ...state.item };
|
||||
@@ -5660,6 +5721,7 @@
|
||||
const detail = state.detail;
|
||||
if (!detail) return;
|
||||
searchPreviewDetail = detail;
|
||||
|
||||
shareButton.disabled = state.status === 'sharing';
|
||||
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
||||
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
||||
@@ -5670,7 +5732,7 @@
|
||||
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
||||
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||||
renderSearchConversation(state.conversation);
|
||||
renderSearchPreviewReply(state, detail, searchPreview, document);
|
||||
renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
|
||||
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
||||
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
@@ -5685,11 +5747,9 @@
|
||||
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 = {
|
||||
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
|
||||
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
|
||||
@@ -5698,21 +5758,20 @@
|
||||
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||||
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||||
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
||||
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
|
||||
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
||||
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
||||
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||||
else status.textContent = 'Ready.';
|
||||
}
|
||||
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
|
||||
const searchPreview = createSearchPreview({
|
||||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
||||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
|
||||
headers:{Accept:'application/json'},
|
||||
}),
|
||||
mutate: (detail, action) => fetchReviewJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
fetchJson:searchSubscription.preview,
|
||||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
|
||||
fetchReview:searchSubscription.review,
|
||||
mutate:searchPreviewMutation(fetchReviewJson),
|
||||
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||||
followingQueue.load().catch(() => {}).then(() => result)),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
queueReply:async (item,body,operationId) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
@@ -5739,7 +5798,14 @@
|
||||
searchReplyAttachmentController.clear();
|
||||
},
|
||||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||
getSession:() => followingQueue.session() || commandSearchState,
|
||||
loadMore:() => commandSearch.loadMore(),
|
||||
onNavigate:item => {
|
||||
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
|
||||
},
|
||||
onOpened:item => followingQueue.previewLoaded(item),
|
||||
afterUnwatch:item => followingQueue.retire(item),
|
||||
navigationRoot:document,
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchWeekPlan = createSearchWeekPlan({
|
||||
@@ -5749,7 +5815,7 @@
|
||||
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
|
||||
getDetail:()=>searchPreviewDetail,
|
||||
announce:message=>{qs('#search-preview-status').textContent=message;},
|
||||
onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};},
|
||||
onPlanned:item=>(searchPreviewDetail=item,item.following&&next()),
|
||||
});
|
||||
searchBatchPlanning = mountSearchBatchPlanning(
|
||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||
@@ -5797,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'),
|
||||
@@ -5805,6 +5879,12 @@
|
||||
schedule: callback => requestAnimationFrame(callback),
|
||||
});
|
||||
function closeSearchPreview(navigate = true) {
|
||||
if (searchPreviewReturnKind === 'following') {
|
||||
searchPreview.close();
|
||||
searchPreviewReturnKind = null;
|
||||
followingQueue.returnToFollowing();
|
||||
return;
|
||||
}
|
||||
if (navigate && taskOverlayHistory.current() === 'search-preview') {
|
||||
taskOverlayHistory.close();
|
||||
return;
|
||||
@@ -6075,9 +6155,18 @@
|
||||
}
|
||||
});
|
||||
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && searchPreviewReturnKind === 'following' &&
|
||||
qs('#search-preview').classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
closeSearchPreview(false);
|
||||
}
|
||||
});
|
||||
|
||||
qs('#share-search-result').addEventListener('click', () => {
|
||||
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
|
||||
});
|
||||
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
|
||||
document.querySelectorAll('.share-work-route').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const status = qs('#work-route-share-status');
|
||||
@@ -6130,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';
|
||||
@@ -7160,6 +7257,7 @@
|
||||
if (event.currentTarget.open) loadPullReview();
|
||||
});
|
||||
qs('#pull-review-retry').addEventListener('click', loadPullReview);
|
||||
|
||||
qs('#refresh-pull-checks').addEventListener('click', async () => {
|
||||
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
||||
const item = selectedPull;
|
||||
@@ -7271,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)
|
||||
@@ -8153,6 +8251,7 @@
|
||||
return true;
|
||||
}
|
||||
async function openWorkQueueRoute(filter, action = null) {
|
||||
if (filter === 'following') return followingQueue.route();
|
||||
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
@@ -8230,6 +8329,8 @@
|
||||
startDayControl:qs('#push-start-day'),
|
||||
startDayStatus:qs('#push-start-day-status'),
|
||||
startDayHour:qs('#push-start-day-hour'),
|
||||
followingControl:qs('#push-following'),
|
||||
followingStatus:qs('#push-following-status'),
|
||||
deadlineSnooze:qs('#deadline-snooze'),
|
||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||
@@ -8299,8 +8400,8 @@
|
||||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
appBadgeReadiness:() => mobileAppBadge.readiness(),
|
||||
enableAppBadge:() => mobileAppBadge.enable(),
|
||||
appBadgeReadiness:() => appBadge.readiness(),
|
||||
enableAppBadge:() => appBadge.enable(),
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = exports.createFollowing;
|
||||
module.exports.attachFollowing = exports.attachFollowing;
|
||||
module.exports.planningDisposition = exports.planningDisposition;
|
||||
} else {
|
||||
root.createFollowing = exports.createFollowing;
|
||||
root.attachFollowing = exports.attachFollowing;
|
||||
root.followingPlanningDisposition = exports.planningDisposition;
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
function planningDisposition(detail, controls) {
|
||||
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
|
||||
Boolean(detail.claimable || detail.assigned_to_me);
|
||||
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
|
||||
if (!controls && globalThis.document) {
|
||||
const query = selector => globalThis.document.querySelector(selector);
|
||||
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
|
||||
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
|
||||
start:query('#start-search-result'),watch:query('#watch-search-result')};
|
||||
}
|
||||
controls?.root?.classList.toggle('following-disposition-mode', active);
|
||||
if (active && controls) {
|
||||
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
|
||||
controls.today.textContent = result.today;
|
||||
controls.later.textContent = result.later;
|
||||
controls.week.textContent = result.week;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFollowing(options) {
|
||||
let generation = 0;
|
||||
let snapshot = {revision:0, items:[]};
|
||||
let review = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
|
||||
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
|
||||
}
|
||||
|
||||
function publish(status, error) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
state.degraded = snapshot.degraded === true;
|
||||
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
|
||||
if (review?.acknowledged.size) state.reviewSummary = {
|
||||
reviewed:review.acknowledged.size,
|
||||
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
|
||||
};
|
||||
if (error) state.error = error;
|
||||
options.render?.(state);
|
||||
if (status === 'ready') options.onCount?.(
|
||||
snapshot.items.filter(item => item.has_unseen_change === true).length);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const requestGeneration = ++generation;
|
||||
publish('loading');
|
||||
try {
|
||||
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
revision:Number(result?.revision) || 0,
|
||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
|
||||
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
|
||||
degraded:result?.degraded === true,
|
||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||
};
|
||||
publish('ready');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation) publish('error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(item) {
|
||||
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||
candidate.updated_at === item?.updated_at);
|
||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||
await options.onAcknowledge(current);
|
||||
current.has_unseen_change = false;
|
||||
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
|
||||
publish('ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function keepForLater(item) {
|
||||
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
|
||||
const current = snapshot.items.find(exact);
|
||||
const index = review?.active ? review.items.findIndex(exact) : -1;
|
||||
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
|
||||
await options.onKeep(current);
|
||||
current.has_unseen_change = true;
|
||||
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
|
||||
current.number + '@' + current.updated_at);
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) review.active = false;
|
||||
publish('ready');
|
||||
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
await options.onOpen?.({...item, following:true});
|
||||
await acknowledge(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
const items = snapshot.items
|
||||
.filter(item => item.has_unseen_change === true)
|
||||
.map(item => ({...item, following:true}));
|
||||
if (!items.length) return false;
|
||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
const completed = Boolean(review?.completed || (review?.active &&
|
||||
!snapshot.items.some(item => item.has_unseen_change === true)));
|
||||
if (review) review.active = false;
|
||||
if (completed && !review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
publish('ready');
|
||||
return completed;
|
||||
}
|
||||
|
||||
function retire(item) {
|
||||
const same = candidate => sameItem(candidate, item);
|
||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||
if (index < 0) {
|
||||
publish('ready');
|
||||
return null;
|
||||
}
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) {
|
||||
review.active = false;
|
||||
if (!review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
}
|
||||
publish('ready');
|
||||
return next ? {...next} : null;
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
|
||||
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
||||
items:() => snapshot.items.map(item => ({...item})),
|
||||
count:() => snapshot.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function attachFollowing(onOpen, hooks = {}) {
|
||||
const document = globalThis.document;
|
||||
const query = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||
const formatTime = value => new Date(value).toLocaleString();
|
||||
const fetchJson = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||
return payload;
|
||||
};
|
||||
const changeRevision = (item, action) => {
|
||||
const [owner, repo] = item.repository.split('/');
|
||||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||||
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
|
||||
'?kind=' + encodeURIComponent(item.kind), {
|
||||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||||
body:JSON.stringify({updated_at:item.updated_at}),
|
||||
});
|
||||
};
|
||||
let feature;
|
||||
let activeReviewItem = null;
|
||||
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
|
||||
function render(state) {
|
||||
hooks.onStatus?.(state.status);
|
||||
const list = query('#following-list');
|
||||
const status = query('#following-status');
|
||||
const reviewButton = query('#review-following');
|
||||
query('#retry-following').hidden = state.status !== 'error';
|
||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
|
||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||
reviewButton.hidden = unseen === 0;
|
||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||
status.textContent = (state.reviewSummary
|
||||
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
||||
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||
'" type="button" data-following-index="' + index + '"><span>' +
|
||||
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
|
||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||||
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
|
||||
}));
|
||||
}
|
||||
feature = createFollowing({
|
||||
fetchJson, render,
|
||||
onCount:count => {
|
||||
const value = query('[data-mobile-queue-count="following"]');
|
||||
value.textContent = count;
|
||||
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||
(count === 1 ? ' unseen change' : ' unseen changes'));
|
||||
hooks.onCount?.(count, feature.items());
|
||||
},
|
||||
onOpen:item => {
|
||||
activeReviewItem = item;
|
||||
return onOpen(item);
|
||||
},
|
||||
onReviewComplete:hooks.onReviewComplete,
|
||||
onAcknowledge:item => changeRevision(item, 'seen'),
|
||||
onKeep:item => changeRevision(item, 'keep'),
|
||||
});
|
||||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||
query('#review-following').addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.startReview().catch(() => {
|
||||
show();
|
||||
});
|
||||
});
|
||||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||
const keepButton = query('#keep-following-for-later');
|
||||
keepButton.addEventListener('click', async () => {
|
||||
const status = query('#keep-following-status');
|
||||
keepButton.disabled = true;
|
||||
status.textContent = 'Keeping this change for later…';
|
||||
try {
|
||||
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
|
||||
status.textContent = 'Kept for later.';
|
||||
} catch (_) {
|
||||
status.textContent = 'Could not keep this change for later. Retry when ready.';
|
||||
keepButton.disabled = false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
load:feature.load,
|
||||
review:feature.startReview,
|
||||
async route() {
|
||||
await feature.load().then(feature.startReview).catch(() => false) || show();
|
||||
},
|
||||
open() {
|
||||
show();
|
||||
feature.load().catch(() => {});
|
||||
return 'opened-following';
|
||||
},
|
||||
session:feature.session,
|
||||
previewLoaded:feature.previewLoaded,
|
||||
keepForLater:feature.keepForLater,
|
||||
retire:feature.retire,
|
||||
preview(state) {
|
||||
const disposition = planningDisposition(state?.detail || state?.item);
|
||||
keepButton.hidden = state?.item?.following !== true || disposition.active;
|
||||
keepButton.disabled = false;
|
||||
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
|
||||
},
|
||||
returnToFollowing() {
|
||||
const completed = feature.finishReview();
|
||||
if (completed) return 'completed-following';
|
||||
show();
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
|
||||
target?.focus();
|
||||
});
|
||||
return 'returned-following';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {createFollowing, attachFollowing, planningDisposition};
|
||||
});
|
||||
+154
-3
@@ -197,6 +197,7 @@
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="authored" aria-pressed="false">My PRs <span data-work-count="authored">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
@@ -215,12 +216,14 @@
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<span class="app-badge-setting" id="app-badge-setting">
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show review attention on the app icon</label>
|
||||
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||
</span>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
||||
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
@@ -884,6 +887,7 @@
|
||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
|
||||
<button type="button" data-search-preview-section="reply">Reply</button>
|
||||
<button type="button" data-search-preview-section="actions">Act</button>
|
||||
</nav>
|
||||
@@ -899,6 +903,12 @@
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
|
||||
<h2 id="search-preview-review-title">Changes</h2>
|
||||
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
|
||||
<div id="search-preview-files"></div>
|
||||
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
@@ -957,8 +967,10 @@
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
<button id="watch-search-result" type="button" hidden>Watch issue</button>
|
||||
<button id="keep-following-for-later" type="button" hidden>Keep for later & next</button>
|
||||
</div>
|
||||
<span id="keep-following-status" class="small" role="status" aria-live="polite"></span>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
@@ -1181,11 +1193,13 @@
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-issue-detail" type="button">Watch issue</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -1267,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>
|
||||
@@ -1638,6 +1653,16 @@
|
||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
<button id="edit-pull-content" type="button" hidden>Edit review context</button>
|
||||
<form class="issue-edit-form" id="pull-edit-form" hidden>
|
||||
<label>Title<input id="pull-edit-title" maxlength="255" required /></label>
|
||||
<label>Description<textarea id="pull-edit-body" maxlength="10000"></textarea></label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-pull-content" type="submit">Save context</button>
|
||||
<button id="cancel-pull-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="pull-edit-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</form>
|
||||
</div>
|
||||
<section id="pull-conversation" tabindex="-1">
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
@@ -1698,15 +1723,83 @@
|
||||
<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>
|
||||
<div id="pull-reviewer-statuses"></div>
|
||||
<button id="request-updated-pull-review" type="button" hidden>Request updated review</button>
|
||||
<section class="pull-feedback-pass" id="pull-feedback-pass" aria-labelledby="pull-feedback-heading" hidden>
|
||||
<div class="pull-feedback-pass-heading">
|
||||
<div><span class="small" id="pull-feedback-progress"></span><h4 id="pull-feedback-heading">Address review feedback</h4></div>
|
||||
<button id="close-pull-feedback" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small" id="pull-feedback-location"></p>
|
||||
<p id="pull-feedback-body"></p>
|
||||
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
|
||||
<button id="make-pull-feedback-fix" type="button" hidden>Make code change</button>
|
||||
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
|
||||
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
|
||||
<label for="pull-feedback-file-content">File content</label>
|
||||
<textarea id="pull-feedback-file-content" spellcheck="false" maxlength="131072"></textarea>
|
||||
<label for="pull-feedback-commit-message">Commit message</label>
|
||||
<input id="pull-feedback-commit-message" maxlength="120" placeholder="fix: address review feedback">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-fix" type="button" hidden>Cancel change</button>
|
||||
<button id="commit-pull-feedback-fix" type="button" hidden>Commit to pull branch</button>
|
||||
</div>
|
||||
</section>
|
||||
<fieldset class="pull-feedback-dispositions">
|
||||
<legend>Disposition</legend>
|
||||
<button type="button" data-feedback-disposition="addressed">Addressed</button>
|
||||
<button type="button" data-feedback-disposition="discussion">Needs discussion</button>
|
||||
<button type="button" data-feedback-disposition="skipped">Skip for now</button>
|
||||
</fieldset>
|
||||
<label for="pull-feedback-note">Response note (optional)</label>
|
||||
<textarea id="pull-feedback-note" maxlength="1000" placeholder="What changed or needs discussion?"></textarea>
|
||||
<div class="pull-feedback-navigation">
|
||||
<button id="previous-pull-feedback" type="button">Previous</button>
|
||||
<button id="next-pull-feedback" type="button">Next</button>
|
||||
</div>
|
||||
<button id="finish-pull-feedback" type="button" disabled>Post feedback response</button>
|
||||
<button id="request-feedback-review" type="button" hidden>Request updated review from this reviewer</button>
|
||||
<div id="pull-feedback-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
</section>
|
||||
<details class="ci-checks" id="pull-checks">
|
||||
<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>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-review-request" id="pull-review-request">
|
||||
<summary>Request review</summary>
|
||||
<div>
|
||||
<button id="publish-draft-pull" type="button" hidden>Ready for review</button>
|
||||
<div id="publish-draft-pull-status" class="small" aria-live="assertive"></div>
|
||||
<button id="load-pull-reviewers" type="button">Choose reviewer</button>
|
||||
<label for="pull-review-recipient" class="small">Eligible repository reviewer</label>
|
||||
<select id="pull-review-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-review" type="button" disabled>Request review</button>
|
||||
<div id="pull-review-request-status" class="small" aria-live="assertive">Load teammates to request a review.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
@@ -1717,12 +1810,28 @@
|
||||
<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>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -2003,7 +2112,7 @@
|
||||
<div>
|
||||
<h3 id="mobile-start-day-heading">Prepare Today</h3>
|
||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
@@ -2016,7 +2125,9 @@
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
@@ -2025,6 +2136,16 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
|
||||
<section class="following-panel">
|
||||
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
|
||||
<p id="following-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="review-following" class="following-review-action" type="button" hidden>Review new activity</button>
|
||||
<div id="following-list" class="following-list"></div>
|
||||
<button id="retry-following" type="button" hidden>Retry</button>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
@@ -2063,6 +2184,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>
|
||||
@@ -2076,6 +2225,7 @@
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/following.js"></script>
|
||||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
@@ -2150,6 +2300,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;
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
let confirmedCount = 0;
|
||||
const confirmedCounts = {updates:0, following:0};
|
||||
let renderedCount = null;
|
||||
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
@@ -29,7 +30,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCount(channel, count) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
|
||||
} catch (_error) { /* A later refresh can retry. */ }
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
@@ -48,7 +58,13 @@
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (enabled) {
|
||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||
}
|
||||
if (!enabled && available()) {
|
||||
confirmedCounts.updates = 0;
|
||||
confirmedCounts.following = 0;
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
@@ -82,8 +98,8 @@
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
|
||||
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
|
||||
? {state:'complete', detail:'The app icon shows review attention.'}
|
||||
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
@@ -93,9 +109,11 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(count, {authoritative = false} = {}) {
|
||||
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
|
||||
confirmedCount = count;
|
||||
async function reconcile(channel, count, authoritative = false) {
|
||||
if (!Object.hasOwn(confirmedCounts, channel)
|
||||
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
|
||||
confirmedCounts[channel] = count;
|
||||
await syncCount(channel, count);
|
||||
return render();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
|
||||
@@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
|
||||
buttons:{
|
||||
overview:bySection('overview'),
|
||||
conversation:bySection('conversation'),
|
||||
changes:bySection('changes'),
|
||||
reply:bySection('reply'),
|
||||
actions:bySection('actions'),
|
||||
},
|
||||
targets:{
|
||||
overview:document.getElementById('search-preview-overview'),
|
||||
conversation:document.getElementById('search-preview-conversation'),
|
||||
changes:document.getElementById('search-preview-review'),
|
||||
reply,
|
||||
actions:document.getElementById('search-preview-actions'),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
['following', 'Following'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
@@ -102,19 +103,27 @@
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewPhases(counts);
|
||||
let phases = reviewPhases(counts);
|
||||
const followingUnavailable = counts.followingUnavailable === true;
|
||||
if (followingUnavailable) {
|
||||
phases = phases.filter(phase => phase.name !== 'following');
|
||||
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
|
||||
}
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
(next === 'following' && followingUnavailable ? 'Retry Following' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
|
||||
const followingRetry = next === 'following' && followingUnavailable;
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
|
||||
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
|
||||
+6
-1
@@ -71,6 +71,7 @@ function buildMyWork(data, now = new Date()) {
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
@@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_authored: isAuthored,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
@@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
|
||||
(isAuthored ? 'Authored by you' : 'Open work')))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
@@ -657,6 +660,7 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -1080,6 +1084,7 @@ function countMyWork(items) {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
authored: items.filter((item) => item.is_authored).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
||||
+997
-10
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
@@ -129,12 +130,15 @@
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
if (followingControl) followingControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
configuration.following_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
@@ -266,13 +270,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFollowing() {
|
||||
followingControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (followingControl.checked) pendingIntent = 'following';
|
||||
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (followingControl.checked && !subscription) {
|
||||
followingControl.checked = false;
|
||||
followingStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/following', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:followingControl.checked}),
|
||||
});
|
||||
configuration.following_enabled = followingControl.checked;
|
||||
pendingIntent = null;
|
||||
followingStatus.textContent = followingControl.checked
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
followingControl.checked = !followingControl.checked;
|
||||
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
followingControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
@@ -284,6 +320,10 @@
|
||||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
if (pendingIntent === 'following') {
|
||||
followingControl.checked = true;
|
||||
return changeFollowing();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
@@ -299,18 +339,21 @@
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
@@ -321,8 +364,11 @@
|
||||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
+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 };
|
||||
}
|
||||
|
||||
+274
-10
@@ -30,8 +30,116 @@
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.followingPullReviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||||
};
|
||||
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 => {
|
||||
const options = {
|
||||
load:async detail => {
|
||||
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||||
return {...detail, watching:true};
|
||||
}
|
||||
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||
return {...detail, watching:result.watching === true};
|
||||
},
|
||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||
}),
|
||||
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||||
};
|
||||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||
headers:{Accept:'application/json'},
|
||||
})});
|
||||
return options;
|
||||
};
|
||||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||||
let item,watching=false,mutation;
|
||||
return {
|
||||
async open(next) {
|
||||
item=next; onState('loading',watching);
|
||||
const result=await api.load(next).catch(error=>{
|
||||
if(item===next)onState('error',watching,error);throw error;
|
||||
});
|
||||
if (item !== next) return;
|
||||
watching=result.watching === true; onState('ready',watching);
|
||||
},
|
||||
toggle() {
|
||||
if (mutation) return mutation;
|
||||
const next=!watching;
|
||||
onState(next?'watching':'unwatching',watching);
|
||||
mutation=api.watch(item,next).then(async result => {
|
||||
if (result?.watching !== next || result?.following_synced !== true)
|
||||
throw new Error(result?.error || 'Unconfirmed.');
|
||||
watching=next; await refreshFollowing();
|
||||
onState(watching?'watched':'unwatched',watching);
|
||||
}).catch(error => {onState('error',watching,error);throw error;})
|
||||
.finally(()=>{mutation=null;});
|
||||
return mutation;
|
||||
},
|
||||
};
|
||||
};
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||||
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||
})[state.status] || '';
|
||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||||
const retiring = watchableKind && detail.state === 'closed' &&
|
||||
detail.following === true && detail.watching === true;
|
||||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||
button.textContent = retiring ? 'Stop watching & next' :
|
||||
(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(() => {});
|
||||
});
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
@@ -50,10 +158,15 @@
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
|
||||
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
|
||||
const newCount = (conversation.comments || []).filter(isNew).length;
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
|
||||
'" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
@@ -65,7 +178,8 @@
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
@@ -90,9 +204,58 @@
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||||
const section = document.querySelector('#search-preview-review');
|
||||
const status = document.querySelector('#search-preview-review-status');
|
||||
const files = document.querySelector('#search-preview-files');
|
||||
const retry = document.querySelector('#retry-search-preview-review');
|
||||
section.hidden = !review;
|
||||
retry.hidden = review?.status !== 'error';
|
||||
files.innerHTML = '';
|
||||
if (!review) {
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'loading') {
|
||||
status.textContent = 'Loading CI and changed files…';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'error') {
|
||||
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||||
return;
|
||||
}
|
||||
const data = review.data || {};
|
||||
const changed = Array.isArray(data.files) ? data.files : [];
|
||||
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||||
data.ci_state
|
||||
] || 'CI status unavailable';
|
||||
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||||
(changed.length === 1 ? 'file.' : 'files.');
|
||||
files.innerHTML = changed.map(file => {
|
||||
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||||
const line = String(raw);
|
||||
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||||
line.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||||
}).join('');
|
||||
const diff = file.diff_available
|
||||
? '<pre class="pull-diff">' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty">' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||||
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||||
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||||
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||||
}).join('');
|
||||
};
|
||||
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||||
root.renderSearchPreviewReply(state, detail, preview, document);
|
||||
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
@@ -105,13 +268,24 @@
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let watchRequest = null;
|
||||
let conversation = null;
|
||||
let review = null;
|
||||
let openedRevision = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
async function notifyOpened(item, requestGeneration) {
|
||||
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
|
||||
if (requestGeneration !== generation || openedRevision === revision) return false;
|
||||
await onOpened?.({...item});
|
||||
if (requestGeneration === generation) openedRevision = revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
@@ -143,6 +317,10 @@
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||||
state = {...state, conversation};
|
||||
}
|
||||
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
@@ -176,7 +354,10 @@
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
conversation = {
|
||||
status:'loading', comments:previousComments, olderPage:page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
@@ -188,6 +369,7 @@
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
@@ -196,6 +378,7 @@
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
@@ -203,6 +386,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function loadReview(detail, requestGeneration) {
|
||||
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||||
review = null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
review = {status:'loading', data:null};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
return fetchReview(detail).then(data => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'ready', data};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return data;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'error', data:null, error};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function acknowledgeWhenContextReady(requestGeneration) {
|
||||
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||||
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||||
typeof fetchReview === 'function';
|
||||
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
@@ -213,14 +427,24 @@
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
conversation = null;
|
||||
review = null;
|
||||
openedRevision = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
return fetchJson(current).then(async detail => {
|
||||
if (requestGeneration === generation) {
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
loadConversation(current, requestGeneration);
|
||||
const context = loadConversation(current, requestGeneration);
|
||||
if (current.following === true) {
|
||||
const reviewContext = loadReview(current, requestGeneration);
|
||||
await Promise.all([context, reviewContext]);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
} else {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
@@ -269,9 +493,19 @@
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
async retryConversation() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadConversation(current, requestGeneration);
|
||||
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
async retryReview() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadReview(current, requestGeneration);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
@@ -348,11 +582,41 @@
|
||||
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') {
|
||||
return Promise.reject(new Error('Watching is unavailable.'));
|
||||
}
|
||||
const detail = current;
|
||||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||
watchRequest = watch(detail, watching).then(async result => {
|
||||
current = { ...current, watching:result?.watching === true };
|
||||
const status = result?.following_synced === false
|
||||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||||
publish({ status, item:current, detail:current, result });
|
||||
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||||
const next = await afterUnwatch({...current});
|
||||
if (next) {
|
||||
onNavigate?.(next);
|
||||
await api.open(next);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:'watch-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { watchRequest = null; });
|
||||
return watchRequest;
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
|
||||
@@ -42,7 +42,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
|
||||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate};
|
||||
return {status:'planned-pending',date:options.date,estimate,item};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
@@ -99,8 +99,8 @@ function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=
|
||||
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
|
||||
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
|
||||
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
|
||||
@@ -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-v130';
|
||||
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;
|
||||
@@ -40,22 +40,55 @@ function createAppBadgePreference() {
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async getCounts() {
|
||||
const database = await open();
|
||||
const counts = {};
|
||||
for (const channel of ['updates', 'following']) {
|
||||
counts[channel] = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
|
||||
});
|
||||
}
|
||||
database.close();
|
||||
return counts;
|
||||
},
|
||||
async setCount(channel, count) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(count, 'count:' + channel);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async clearCounts() {
|
||||
await this.setCount('updates', 0);
|
||||
await this.setCount('following', 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
async function reconcileBackgroundAppBadge(channel, count) {
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled || renderedBackgroundBadgeCount === count) return false;
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
if (count > 0) await self.registration.setAppBadge(count);
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
const counts = await appBadgePreference.getCounts();
|
||||
const total = Math.min(9999, counts.updates + counts.following);
|
||||
if (renderedBackgroundBadgeCount === total) return false;
|
||||
if (total > 0) await self.registration.setAppBadge(total);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = count;
|
||||
renderedBackgroundBadgeCount = total;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
@@ -130,6 +163,7 @@ const SHELL = [
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/following.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
@@ -211,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',
|
||||
@@ -575,6 +610,21 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-count') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const channel = event.data.channel;
|
||||
const count = event.data.count;
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||
try {
|
||||
if (await appBadgePreference.get()) {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
}
|
||||
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
@@ -583,6 +633,7 @@ self.addEventListener('message', event => {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await appBadgePreference.clearCounts();
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
@@ -648,6 +699,7 @@ self.addEventListener('push', event => {
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const followingCount = Number(payload.following_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
@@ -684,6 +736,24 @@ self.addEventListener('push', event => {
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/following'
|
||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||
&& Number.isSafeInteger(followingCount)
|
||||
&& followingCount > 0
|
||||
&& followingCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('following', followingCount),
|
||||
self.registration.showNotification(
|
||||
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
|
||||
body: 'Open Following to review the latest activity.',
|
||||
tag,
|
||||
data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -692,7 +762,7 @@ self.addEventListener('push', event => {
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
@@ -717,7 +787,7 @@ self.addEventListener('push', event => {
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
|
||||
@@ -70,11 +70,12 @@ function createTodayWeekReschedule({
|
||||
throw conflict;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
function flush(retryConflict = false) {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
let conflictRetried = false;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
@@ -83,7 +84,25 @@ function createTodayWeekReschedule({
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) { await restoreConflict(error); }
|
||||
} catch (error) {
|
||||
if (error?.status === 409 && retryConflict && !conflictRetried) {
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
const latest = readQueue();
|
||||
if (today?.ids?.includes(record.body.identity) &&
|
||||
latest[0]?.body?.operation_id === record.body.operation_id) {
|
||||
latest[0] = {...latest[0], body:{
|
||||
...latest[0].body,
|
||||
today_revision:today.revision,
|
||||
week_revision:weekState.revision,
|
||||
}};
|
||||
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
|
||||
conflictRetried = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await restoreConflict(error);
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
@@ -207,9 +226,24 @@ function createTodayWeekReschedule({
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
if (admitted) {
|
||||
result = await flush(true);
|
||||
} else {
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
if (!today?.ids?.includes(body.identity)) throw error;
|
||||
body.today_revision = today.revision; body.week_revision = weekState.revision;
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
|
||||
+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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"open", "closed"}
|
||||
_KINDS = {"issue", "pull"}
|
||||
|
||||
|
||||
class FollowingStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 50,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="following",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS following_issues (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
items TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = str(login).strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "items": []}, False
|
||||
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
|
||||
if not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "items": items}, legacy
|
||||
|
||||
def _seal(self, login: str, items: list[dict]) -> str:
|
||||
return self._cipher.seal(items, binding=f"items:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_item(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("following item must be an object")
|
||||
repository = raw.get("repository")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("repository is invalid")
|
||||
number = raw.get("number")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
kind = raw.get("kind", "issue")
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
title = raw.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||
raise ValueError("title is invalid")
|
||||
state = raw.get("state")
|
||||
if state not in _STATES:
|
||||
raise ValueError("state is invalid")
|
||||
updated_at = raw.get("updated_at")
|
||||
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
|
||||
raise ValueError("updated_at is invalid")
|
||||
url = raw.get("url")
|
||||
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
|
||||
raise ValueError("url is invalid")
|
||||
last_seen_updated_at = raw.get("last_seen_updated_at", updated_at)
|
||||
if (
|
||||
not isinstance(last_seen_updated_at, str)
|
||||
or not last_seen_updated_at
|
||||
or len(last_seen_updated_at) > 64
|
||||
):
|
||||
raise ValueError("last seen update is invalid")
|
||||
item = {
|
||||
"repository": repository,
|
||||
"kind": kind,
|
||||
"number": number,
|
||||
"title": title.strip(),
|
||||
"state": state,
|
||||
"updated_at": updated_at,
|
||||
"url": url,
|
||||
"last_seen_updated_at": last_seen_updated_at,
|
||||
}
|
||||
kept_updated_at = raw.get("kept_updated_at")
|
||||
if kept_updated_at is not None:
|
||||
if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
|
||||
raise ValueError("kept update is invalid")
|
||||
item["kept_updated_at"] = kept_updated_at
|
||||
reviewed_title = raw.get("reviewed_title")
|
||||
if reviewed_title is not None:
|
||||
if not isinstance(reviewed_title, str) or not reviewed_title.strip() or len(reviewed_title.strip()) > 300:
|
||||
raise ValueError("reviewed title is invalid")
|
||||
item["reviewed_title"] = reviewed_title.strip()
|
||||
reviewed_state = raw.get("reviewed_state")
|
||||
if reviewed_state is not None:
|
||||
if reviewed_state not in _STATES:
|
||||
raise ValueError("reviewed state is invalid")
|
||||
item["reviewed_state"] = reviewed_state
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _present(cls, snapshot: dict) -> dict:
|
||||
changed = []
|
||||
unchanged = []
|
||||
for raw in snapshot["items"]:
|
||||
stored = cls._normalize_item(raw)
|
||||
unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
|
||||
stored.get("kept_updated_at") == stored["updated_at"])
|
||||
item = {key: value for key, value in stored.items()
|
||||
if key not in {"last_seen_updated_at", "kept_updated_at",
|
||||
"reviewed_title", "reviewed_state"}}
|
||||
item["has_unseen_change"] = unseen
|
||||
if unseen:
|
||||
item["reviewed_at"] = stored["last_seen_updated_at"]
|
||||
if stored.get("reviewed_state") == "open" and stored["state"] == "closed":
|
||||
item["change_summary"] = "Closed since last review"
|
||||
elif stored.get("reviewed_state") == "closed" and stored["state"] == "open":
|
||||
item["change_summary"] = "Reopened since last review"
|
||||
elif stored.get("reviewed_title") not in {None, stored["title"]}:
|
||||
item["change_summary"] = "Title changed since last review"
|
||||
else:
|
||||
item["change_summary"] = "New activity"
|
||||
(changed if unseen else unchanged).append(item)
|
||||
changed.sort(key=lambda item: item["updated_at"], reverse=True)
|
||||
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
||||
|
||||
@staticmethod
|
||||
def _identity(item: dict) -> tuple[str, str, int]:
|
||||
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
snapshot["items"] = [self._normalize_item(item) for item in snapshot["items"]]
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
|
||||
(self._seal(login, snapshot["items"]), login, row[1]),
|
||||
)
|
||||
return self._present(snapshot)
|
||||
|
||||
def preflight(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
"""Validate a requested change and capacity without mutating the registry."""
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
if not watching:
|
||||
return item
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if (
|
||||
len(current["items"]) >= self.limit
|
||||
and not any(self._identity(candidate) == identity for candidate in current["items"])
|
||||
):
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
return item
|
||||
|
||||
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
index = next(
|
||||
(position for position, candidate in enumerate(items)
|
||||
if self._identity(candidate) == identity),
|
||||
None,
|
||||
)
|
||||
if watching:
|
||||
if index is None:
|
||||
if len(items) >= self.limit:
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
items.insert(0, item)
|
||||
else:
|
||||
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in items[index]:
|
||||
item[key] = items[index][key]
|
||||
if items[index] == item:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
items.pop(index)
|
||||
items.insert(0, item)
|
||||
elif index is None:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
else:
|
||||
items.pop(index)
|
||||
revision = current["revision"] + 1
|
||||
connection.execute(
|
||||
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
|
||||
(login, revision, self._seal(login, items)),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def refresh(self, login: str, raw_items: list[dict]) -> dict:
|
||||
"""Merge successful upstream snapshots while preserving seen revisions."""
|
||||
login = self._login(login)
|
||||
fresh = {self._identity(item): self._normalize_item(item) for item in raw_items}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
changed = False
|
||||
for index, item in enumerate(items):
|
||||
update = fresh.get(self._identity(item))
|
||||
if update is None:
|
||||
continue
|
||||
update["last_seen_updated_at"] = item["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in item:
|
||||
update[key] = item[key]
|
||||
if update["updated_at"] != item["updated_at"]:
|
||||
update.setdefault("reviewed_title", item["title"])
|
||||
update.setdefault("reviewed_state", item["state"])
|
||||
if update != item:
|
||||
items[index] = update
|
||||
changed = True
|
||||
revision = current["revision"]
|
||||
if changed:
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def acknowledge(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Mark only the exact upstream revision successfully opened by the operator."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if (item["last_seen_updated_at"] != updated_at or
|
||||
item.get("kept_updated_at") == updated_at):
|
||||
item["last_seen_updated_at"] = updated_at
|
||||
item.pop("kept_updated_at", None)
|
||||
item.pop("reviewed_title", None)
|
||||
item.pop("reviewed_state", None)
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def keep_unseen(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Restore only the exact loaded revision to the unseen review queue."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if item.get("kept_updated_at") != updated_at:
|
||||
item["kept_updated_at"] = updated_at
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
@@ -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",),
|
||||
@@ -35,11 +35,11 @@ FEATURE_SOURCES = {
|
||||
"security-center": ("static/security-center.js",),
|
||||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
|
||||
+1371
-27
File diff suppressed because it is too large.
Load diff
+1291
-27
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
@@ -99,6 +100,135 @@ async def send_web_push(
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_following_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
following: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
"""Notify opted-in devices once for each privacy-safe Following change set."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.following_notification_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await following()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
changed = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
|
||||
continue
|
||||
repository = item.get("repository")
|
||||
kind = item.get("kind")
|
||||
number = item.get("number")
|
||||
updated_at = item.get("updated_at")
|
||||
if (
|
||||
isinstance(repository, str)
|
||||
and kind in {"issue", "pull"}
|
||||
and isinstance(number, int)
|
||||
and not isinstance(number, bool)
|
||||
and number > 0
|
||||
and isinstance(updated_at, str)
|
||||
and updated_at
|
||||
):
|
||||
changed.append((repository.lower(), kind, number, updated_at))
|
||||
changed.sort()
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(changed, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
if not changed:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
|
||||
for device in devices
|
||||
))
|
||||
return 0
|
||||
pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [
|
||||
device for device in pending if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
count = min(len(changed), 50)
|
||||
payload = json.dumps({
|
||||
"title": f"{count} watched item{'s' if count != 1 else ''} changed",
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"route": "#/my-work/following",
|
||||
"tag": f"stackchain-following-{fingerprint[:16]}",
|
||||
"following_count": count,
|
||||
}, separators=(",", ":"))
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"following",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "following"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_following_delivered, device.session_id, fingerprint
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
||||
@@ -52,6 +52,13 @@ class StartDayReminderDevice:
|
||||
delivered_plan_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FollowingNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_fingerprint: str | None
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
@@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore:
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
return []
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
@@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore:
|
||||
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -236,6 +255,13 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_following_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_fingerprint TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -569,6 +595,46 @@ class PushSubscriptionStore:
|
||||
(plan_date, session_id),
|
||||
)
|
||||
|
||||
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_following_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
|
||||
FROM push_subscriptions s
|
||||
JOIN push_following_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
FollowingNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(fingerprint, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -125,6 +125,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
|
||||
self._json(200, {"subscribed": False, "ignored": False})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_addresses_every_review_comment_and_requests_updated_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"review_id": 42,
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
"comments": [
|
||||
{"id": 99, "path": "src/api.py", "body": "Handle the empty state.", "line": 12},
|
||||
{"id": 100, "path": "frontend/app.js", "body": "Explain the retry.", "line": 8},
|
||||
],
|
||||
}],
|
||||
"files": [
|
||||
{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -11 +11 @@", "+return empty"]},
|
||||
{"filename": "frontend/app.js", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -7 +7 @@", "+showRetry()"]},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.feedbackCalls = [];
|
||||
globalThis.feedbackStored = new Map();
|
||||
globalThis.feedbackStorage = {
|
||||
getItem:key => feedbackStored.has(key) ? feedbackStored.get(key) : null,
|
||||
setItem:(key, value) => feedbackStored.set(key, String(value)),
|
||||
removeItem:key => feedbackStored.delete(key),
|
||||
};
|
||||
globalThis.feedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.feedbackDetail = detail;
|
||||
globalThis.feedbackController = createPullSheet({
|
||||
storage: feedbackStorage,
|
||||
createOperationId: () => 'feedback-operation',
|
||||
fetchJson: async (path, options = {}) => {
|
||||
feedbackCalls.push({path, options});
|
||||
if (path.endsWith('/comments')) return {id: 501, body:JSON.parse(options.body).body};
|
||||
if (path.endsWith('/request-review')) return {
|
||||
number:7, reviewer:'sam', head_sha:'current-head', requested_reviewers:['sam']
|
||||
};
|
||||
throw new Error('unexpected request');
|
||||
},
|
||||
});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, feedbackController,
|
||||
() => feedbackItem, () => feedbackDetail, () => 'timmy');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
assert page.locator("#pull-feedback-progress").text_content() == "Comment 1 of 2"
|
||||
assert page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator('[data-feedback-disposition="addressed"]').click()
|
||||
page.locator("#next-pull-feedback").click()
|
||||
page.locator("#pull-feedback-note").fill("Added a visible retry message.")
|
||||
page.locator('[data-feedback-disposition="discussion"]').click()
|
||||
assert not page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator("#finish-pull-feedback").click()
|
||||
page.locator("#request-feedback-review").click()
|
||||
|
||||
page.wait_for_function("feedbackCalls.length === 2")
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:feedbackCalls,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
controlHeights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => !button.hidden).map(button => button.getBoundingClientRect().height),
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
secondExpanded:document.querySelector('[data-pull-filename="frontend/app.js"] .pull-file-toggle')
|
||||
.getAttribute('aria-expanded'),
|
||||
persisted:Array.from(feedbackStored.keys()).some(key => key.includes('feedback-pass')),
|
||||
})"""
|
||||
)
|
||||
result["resetHidden"] = page.evaluate(
|
||||
"""() => {
|
||||
createPullSheet.resetReviewRequestControls(document, feedbackDetail);
|
||||
return document.querySelector('#pull-feedback-pass').hidden;
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["controlHeights"]) >= 44
|
||||
assert result["secondExpanded"] == "true"
|
||||
assert result["persisted"] is True
|
||||
assert result["resetHidden"] is True
|
||||
assert result["calls"][0]["path"].endswith("/comments")
|
||||
assert result["calls"][0]["options"]["headers"]["Idempotency-Key"] == "feedback-operation"
|
||||
summary = result["calls"][0]["options"]["body"]
|
||||
assert "Handle the empty state" in summary
|
||||
assert "Addressed" in summary
|
||||
assert "Needs discussion" in summary
|
||||
assert "Added a visible retry message" in summary
|
||||
assert result["calls"][1]["path"].endswith("/request-review")
|
||||
assert '"reviewer":"sam"' in result["calls"][1]["options"]["body"]
|
||||
assert result["status"] == "Response posted; updated review requested from @sam."
|
||||
@@ -0,0 +1,85 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_commits_one_review_feedback_fix(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [{"id": 99, "path": "src/api.py", "body": "Handle empty state.", "line": 12}],
|
||||
}],
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -12 +12 @@", "+return empty"]}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.fixCalls = [];
|
||||
globalThis.fixItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.fixDetail = detail;
|
||||
globalThis.fixController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
fixCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') return {
|
||||
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123', content:'return empty\\n'
|
||||
};
|
||||
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, fixController,
|
||||
() => fixItem, () => fixDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
page.locator("#make-pull-feedback-fix").click()
|
||||
page.locator("#pull-feedback-file-content").fill("return handled\n")
|
||||
page.locator("#pull-feedback-commit-message").fill("fix: handle empty state")
|
||||
page.locator("#commit-pull-feedback-fix").click()
|
||||
page.wait_for_function("fixCalls.length === 2")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:fixCalls, head:fixDetail.head_sha,
|
||||
editorHidden:document.querySelector('#pull-feedback-file-editor').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["editorHidden"] is True
|
||||
assert "committed" in result["status"].lower()
|
||||
assert "path=src%2Fapi.py" in result["calls"][0]["path"]
|
||||
body = result["calls"][1]["options"]["body"]
|
||||
assert '"expected_blob_sha":"blob123"' in body
|
||||
assert '"content":"return handled\\n"' in body
|
||||
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(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 / "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"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("My PRs")
|
||||
expect(row).to_contain_text("Pull requests you authored")
|
||||
assert row.bounding_box()["height"] >= 44
|
||||
|
||||
result = page.evaluate("""() => {
|
||||
const work = buildMyWork({
|
||||
user:{login:'timmy'},
|
||||
pull_requests:[{
|
||||
number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard',
|
||||
state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'],
|
||||
url:'https://forge.example/git/stackchain/dashboard/pulls/41'
|
||||
}]
|
||||
});
|
||||
const authored = filterMyWork(work, 'authored');
|
||||
let selected = '';
|
||||
let route = '';
|
||||
const action = document.createElement('button');
|
||||
action.className = 'my-work-card-main';
|
||||
action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); });
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:name => { selected = name; },
|
||||
firstAction:() => action,
|
||||
announce:() => {},
|
||||
});
|
||||
const opened = launcher.open('authored');
|
||||
return {selected, route, opened, count:authored.length};
|
||||
}""")
|
||||
assert result == {
|
||||
"selected": "authored",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/41",
|
||||
"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
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_empty_authored_queue_announces_the_standard_empty_message():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
|
||||
message = page.evaluate("""() => {
|
||||
let announcement = '';
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:() => {}, firstAction:() => null,
|
||||
announce:value => { announcement = value; },
|
||||
});
|
||||
launcher.open('authored');
|
||||
return announcement;
|
||||
}""")
|
||||
assert message == "No work is ready to open."
|
||||
browser.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
if os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("release browser journey is opt-in", allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_cancels_pending_review_and_opens_replacement_picker(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.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(
|
||||
"""() => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.cancelCalls = [];
|
||||
globalThis.cancelItem = {
|
||||
repository:'stackchain/api', number:7, key:'stackchain/api#7'
|
||||
};
|
||||
globalThis.cancelDetail = {
|
||||
state:'open', draft:false, mergeable:true, merged:false,
|
||||
ci_state:'success', head_sha:'abc1234', files:[],
|
||||
reviewers:[{
|
||||
login:'sam', status:'waiting', head_sha:'abc1234', blocking:true
|
||||
}],
|
||||
};
|
||||
globalThis.cancelController = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options={}) => {
|
||||
cancelCalls.push({path, options});
|
||||
return {
|
||||
repository:'stackchain/api', number:7, reviewer:'sam',
|
||||
head_sha:'abc1234', requested_reviewers:[],
|
||||
};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindReviewRequestControls(
|
||||
document, cancelController, () => cancelItem, () => cancelDetail
|
||||
);
|
||||
createPullSheet.review(cancelDetail, null, document);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
}"""
|
||||
)
|
||||
|
||||
cancel = page.locator('[data-cancel-review-request="sam"]')
|
||||
bounds = cancel.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
cancel.click()
|
||||
page.wait_for_function("cancelCalls.length === 1")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#pull-review-request-status').textContent.includes('Choose a replacement reviewer')"
|
||||
)
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:cancelCalls,
|
||||
waitingRows:document.querySelectorAll('[data-reviewer-status="waiting"]').length,
|
||||
pickerOpen:document.querySelector('#pull-review-request').open,
|
||||
focused:document.activeElement?.id,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert result["waitingRows"] == 0
|
||||
assert result["pickerOpen"] is True
|
||||
assert result["focused"] == "load-pull-reviewers"
|
||||
assert len(result["calls"]) == 1
|
||||
assert result["calls"][0]["options"]["method"] == "DELETE"
|
||||
assert result["calls"][0]["options"]["body"] == (
|
||||
'{"reviewer":"sam","expected_head_sha":"abc1234"}'
|
||||
)
|
||||
@@ -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,44 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_issue_and_pull_detail_watch_controls_are_phone_usable(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 / "search-preview.js")
|
||||
|
||||
for sheet, button, expected in [
|
||||
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
|
||||
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
|
||||
]:
|
||||
page.locator(sheet).evaluate("node => node.classList.add('open')")
|
||||
control = page.locator(button)
|
||||
expect(control).to_be_visible()
|
||||
expect(control).to_have_text(expected)
|
||||
assert control.bounding_box()["height"] >= 44
|
||||
control.focus()
|
||||
expect(control).to_be_focused()
|
||||
page.locator(sheet).evaluate("node => node.classList.remove('open')")
|
||||
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
@@ -0,0 +1,146 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_following_queue_is_phone_usable_at_narrow_viewport(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 / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
row = page.locator('[data-mobile-queue="following"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("Issues and pull requests you watch")
|
||||
following_alert = page.locator('label[for="push-following"]')
|
||||
expect(following_alert).to_have_count(1)
|
||||
expect(following_alert).to_contain_text("Notify me when Following changes")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
expect(following_alert).to_be_visible()
|
||||
assert following_alert.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok:true,
|
||||
json:async () => ({revision:1,items:[{
|
||||
repository:'stackchain/api', kind:'pull', number:42,
|
||||
title:'Changed pull request with a long mobile title', state:'open',
|
||||
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true,
|
||||
change_summary:'Closed since last review'
|
||||
}]})
|
||||
});
|
||||
globalThis.followingReleaseQueue = attachFollowing(() => {});
|
||||
globalThis.followingReleaseQueue.open();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_contain_text("Closed since last review")
|
||||
expect(page.locator(".following-card")).to_contain_text("Pull request")
|
||||
expect(page.locator("#review-following")).to_have_text("Review new activity")
|
||||
assert page.locator("#review-following").bounding_box()["height"] >= 44
|
||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
|
||||
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
|
||||
additions:1,deletions:1,diff_available:true,
|
||||
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
|
||||
'+safe long line that scrolls inside the diff only']
|
||||
}]}},document,value=>String(value));
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
|
||||
}""")
|
||||
changes = page.locator("#search-preview-review")
|
||||
expect(changes).to_be_visible()
|
||||
expect(changes).to_contain_text("CI passed · 1 changed file.")
|
||||
expect(changes).to_contain_text("review.py")
|
||||
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
|
||||
assert page.locator("#search-preview-review .pull-diff").evaluate(
|
||||
"node => node.scrollWidth >= node.clientWidth"
|
||||
) is True
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
|
||||
retire = page.locator("#watch-search-result")
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
const button = document.querySelector('#watch-search-result');
|
||||
renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'issue', state:'closed',
|
||||
following:true, watching:true},
|
||||
{status:'ready'},
|
||||
button
|
||||
);
|
||||
}""")
|
||||
expect(retire).to_be_visible()
|
||||
expect(retire).to_have_text("Stop watching & next")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
|
||||
{status:'ready'}, document.querySelector('#watch-search-result'))""")
|
||||
expect(retire).to_have_text("Watch pull request")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
keep = page.locator("#keep-following-for-later")
|
||||
page.evaluate("document.querySelector('#keep-following-for-later').hidden = false")
|
||||
expect(keep).to_be_visible()
|
||||
expect(keep).to_have_text("Keep for later & next")
|
||||
assert keep.bounding_box()["height"] >= 44
|
||||
assert page.locator("#keep-following-status").evaluate(
|
||||
"node => getComputedStyle(node).overflowWrap"
|
||||
) == "anywhere"
|
||||
assert page.locator("#keep-following-status").bounding_box()["height"] >= 20
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||
assert overflow is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_following_alert_route_opens_truthful_empty_state_with_one_refresh():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
result = page.evaluate("""async () => {
|
||||
let requests=0;
|
||||
globalThis.fetch=async () => {
|
||||
requests += 1;
|
||||
return {ok:true,json:async () => ({revision:2,items:[]})};
|
||||
};
|
||||
const queue=attachFollowing(() => {});
|
||||
await queue.route();
|
||||
return {requests,open:document.querySelector('#following-sheet').open,
|
||||
status:document.querySelector('#following-status').textContent};
|
||||
}""")
|
||||
|
||||
assert result == {
|
||||
"requests": 1,
|
||||
|
||||
"open": True,
|
||||
"status": "No watched items yet. Watch an issue or pull request from Search to keep it here.",
|
||||
}
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
browser.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_publishes_draft_and_continues_to_reviewer_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"author": "timmy",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"head_sha": "abc1234",
|
||||
}
|
||||
|
||||
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(
|
||||
"""detail => {
|
||||
window.publishDetail = detail;
|
||||
window.publishItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:(path, options) => {
|
||||
if (!path.endsWith('/ready') || options.method !== 'PATCH') {
|
||||
return Promise.reject(new Error('unexpected request'));
|
||||
}
|
||||
const payload = JSON.parse(options.body);
|
||||
if (payload.expected_head_sha !== detail.head_sha) {
|
||||
return Promise.reject(new Error('wrong head'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
repository:'stackchain/api', number:7, title:'Ship mobile flow',
|
||||
head_sha:detail.head_sha, state:'open', draft:false,
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindOwnershipControls(
|
||||
document, controller, () => window.publishItem, async () => false,
|
||||
() => window.publishDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
controller.setReviewDetail(detail);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
ready = page.get_by_role("button", name="Ready for review")
|
||||
before = ready.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
ready.click()
|
||||
page.get_by_text("Published and ready for review.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
readyHidden:document.querySelector('#publish-draft-pull').hidden,
|
||||
requestOpen:document.querySelector('#pull-review-request').open,
|
||||
reviewerFocused:document.activeElement === document.querySelector('#load-pull-reviewers'),
|
||||
title:window.publishDetail.title,
|
||||
draft:window.publishDetail.draft,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["readyHidden"] is True
|
||||
assert metrics["requestOpen"] is True
|
||||
assert metrics["reviewerFocused"] is True
|
||||
assert metrics["title"] == "Ship mobile flow"
|
||||
assert metrics["draft"] is False
|
||||
@@ -0,0 +1,172 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_received_review_feedback_opens_matching_change(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"login": "sam",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head-123456",
|
||||
"blocking": True,
|
||||
"summary": "Please handle the narrow empty state before merging.",
|
||||
"comments": [{
|
||||
"path": "src/a/very/long/mobile/path/review_target.py",
|
||||
"body": "Return before parsing when the payload is empty.",
|
||||
"line": 12,
|
||||
}],
|
||||
}],
|
||||
"files": [{
|
||||
"filename": "src/a/very/long/mobile/path/review_target.py",
|
||||
"status": "modified",
|
||||
"additions": 2,
|
||||
"deletions": 1,
|
||||
"diff_available": True,
|
||||
"diff_lines": ["@@ -10,2 +10,3 @@", "-parse(payload)", "+if not payload: return", "+parse(payload)"],
|
||||
}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
jump = page.locator('[data-review-feedback-file]')
|
||||
jump.click()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const jump = document.querySelector('[data-review-feedback-file]');
|
||||
const toggle = document.querySelector('.pull-file-toggle');
|
||||
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
jumpHeight: jump.getBoundingClientRect().height,
|
||||
expanded: toggle.getAttribute('aria-expanded'),
|
||||
panelHidden: panel.hidden,
|
||||
focused: document.activeElement === toggle,
|
||||
reviewedHead: document.querySelector('.pull-review-feedback').textContent,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["jumpHeight"] >= 44
|
||||
assert metrics["expanded"] == "true"
|
||||
assert metrics["panelHidden"] is False
|
||||
assert metrics["focused"] is True
|
||||
assert "Reviewed head old-head" in metrics["reviewedHead"]
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_review_retries_inline_feedback_without_losing_core_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success", "head_sha": "abc1234",
|
||||
"reviewers": [{
|
||||
"login": "sam", "review_id": 8, "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True,
|
||||
"summary": "Please handle the empty state.",
|
||||
}],
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.lazyFeedbackDetail = detail;
|
||||
window.lazyFeedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
let attempts = 0;
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error('feedback unavailable'));
|
||||
return Promise.resolve({
|
||||
review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234',
|
||||
comments:[{id:99, path:'src/api.py', body:'Return before parsing.', line:12}],
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindFeedbackControls(
|
||||
document, controller, () => window.lazyFeedbackItem,
|
||||
() => window.lazyFeedbackDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
const eligibility = createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-merge-state').textContent = eligibility.reason;
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
load = page.get_by_role("button", name="Load inline feedback")
|
||||
load.click()
|
||||
retry = page.get_by_role("button", name="Retry feedback")
|
||||
retry.wait_for()
|
||||
retry.click()
|
||||
page.get_by_text("Return before parsing.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const details = document.querySelector('.pull-review-feedback');
|
||||
const address = document.querySelector('[data-address-review-feedback]');
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
blocker: document.querySelector('#pull-merge-state').textContent,
|
||||
feedback: details.textContent,
|
||||
addressHeight: address.getBoundingClientRect().height,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert "requested changes" in metrics["blocker"]
|
||||
assert "Return before parsing." in metrics["feedback"]
|
||||
assert metrics["addressHeight"] >= 44
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_reviewer_status_wraps_and_keeps_updated_review_reachable(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [
|
||||
{
|
||||
"login": "casey-with-a-very-long-mobile-login",
|
||||
"status": "waiting",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "lee",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "pat",
|
||||
"status": "approved",
|
||||
"head_sha": "current-head",
|
||||
"blocking": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
updatedReviewHeight: document.querySelector('#request-updated-pull-review')
|
||||
.getBoundingClientRect().height,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
statuses: Array.from(document.querySelectorAll('.pull-reviewer-status span'))
|
||||
.map(node => node.textContent),
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["updatedReviewHeight"] >= 44
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
assert metrics["statuses"] == [
|
||||
"Waiting for review",
|
||||
"Changes requested",
|
||||
"Outdated after new commits",
|
||||
"Approved current head",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -32,12 +32,29 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
||||
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.evaluate("""() => {
|
||||
const assignedIssue = {
|
||||
kind:'issue', state:'open', claimable:false, assigned_to_me:false,
|
||||
assignees:['alexander'], watching:false,
|
||||
};
|
||||
globalThis.renderSearchPreviewWatch(
|
||||
assignedIssue, {status:'ready'}, document.querySelector('#watch-search-result')
|
||||
);
|
||||
}""")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
||||
|
||||
watch = page.locator("#watch-search-result")
|
||||
expect(watch).to_be_visible()
|
||||
expect(watch).to_have_text("Watch issue")
|
||||
watch_bounds = watch.bounding_box()
|
||||
assert watch_bounds and watch_bounds["height"] >= 44
|
||||
navigation = page.locator(".mobile-search-preview-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
buttons = navigation.locator("button")
|
||||
buttons = navigation.locator("button:visible")
|
||||
assert navigation.locator("button").count() == 5
|
||||
assert buttons.count() == 4
|
||||
expect(navigation.locator('[data-search-preview-section="changes"]')).to_be_hidden()
|
||||
for button in buttons.all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
@@ -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()
|
||||
@@ -419,11 +419,12 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
|
||||
return {"id": 249, "body": body}
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
original_complete = main._idempotency_ledger.complete
|
||||
|
||||
def slow_complete(key, response):
|
||||
started.set()
|
||||
time.sleep(0.2)
|
||||
assert release.wait(1)
|
||||
return original_complete(key, response)
|
||||
|
||||
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
|
||||
@@ -432,15 +433,16 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
authored = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "Persist without freezing"},
|
||||
headers={"Idempotency-Key": "slow-completion-248"},
|
||||
))
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
health = await client.get("/healthz")
|
||||
health_elapsed = asyncio.get_running_loop().time() - started_at
|
||||
release.set()
|
||||
authored_response = await authored
|
||||
|
||||
assert health.status_code == 200
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
def test_release_promotion_waits_for_every_packaged_mobile_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -58,26 +58,6 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
assert "pip install -r requirements-e2e.txt" in browser
|
||||
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_search_week_plan.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "python3 -m pytest tests/e2e -q" in browser
|
||||
assert "test_mobile_" not in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
||||
|
||||
def test_browser_job_runs_packaged_today_week_reschedule_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
|
||||
@@ -389,6 +389,27 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reports_only_successfully_loaded_revisions_to_its_source_queue():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const loaded=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:item => item.number === 9 ? Promise.reject(new Error('preview failed')) :
|
||||
Promise.resolve({{...item,title:'Loaded'}}),
|
||||
mutate:async()=>{{}}, onOpened:item=>loaded.push([item.number,item.updated_at]), onState:()=>{{}},
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',updated_at:'rev-1'}});
|
||||
await preview.open({{repository:'stackchain/api',number:9,kind:'issue',updated_at:'rev-2'}}).catch(()=>{{}});
|
||||
process.stdout.write(JSON.stringify(loaded));
|
||||
}})().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) == [[42, "rev-1"]]
|
||||
|
||||
|
||||
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -631,7 +652,7 @@ def test_mobile_search_preview_renders_session_navigation_and_advances_after_pla
|
||||
assert 'aria-label="Search result navigation"' in html
|
||||
assert ".search-preview-navigation" in css
|
||||
assert ".search-preview-header button, .search-preview-actions button" in css
|
||||
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
|
||||
assert "getSession:() => followingQueue.session() || commandSearchState" in html
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
assert "api.previous()" in preview_source
|
||||
assert "api.next()" in preview_source
|
||||
@@ -806,6 +827,356 @@ 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))});
|
||||
(async () => {{
|
||||
let calls = 0; let rejectWatch; const states = [];
|
||||
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:async()=>detail, mutate:async()=>{{}},
|
||||
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(detail);
|
||||
const first=preview.setWatching(true); const second=preview.setWatching(true);
|
||||
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
|
||||
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
|
||||
const final=states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
|
||||
watching:final.detail.watching,error:final.error.message}}));
|
||||
}})().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": "watch-error",
|
||||
"number": 42,
|
||||
"watching": False,
|
||||
"error": "offline",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_preserves_authoritative_watch_and_offers_following_repair():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:async()=>detail,
|
||||
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(detail);
|
||||
await preview.setWatching(true);
|
||||
const final=states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
status:final.status,
|
||||
watching:final.detail.watching,
|
||||
message:globalThis.searchPreviewWatchStatus(final),
|
||||
}}));
|
||||
}})().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) == {
|
||||
"status": "watch-partial",
|
||||
"watching": True,
|
||||
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_touch_safe_watch_action():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
|
||||
assert "wireSearchPreviewWatch" in dashboard
|
||||
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
|
||||
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const loaded = [];
|
||||
const options = globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
loaded.push(path);
|
||||
return {{watching:true}};
|
||||
}});
|
||||
const assigned = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',
|
||||
claimable:false,assignees:['alexander']}};
|
||||
const hydrated = await options.load(assigned);
|
||||
const button = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(hydrated, {{status:'ready'}}, button);
|
||||
const excluded = [];
|
||||
for (const detail of [
|
||||
{{...assigned,state:'closed'}},
|
||||
{{...assigned,kind:'pull'}},
|
||||
]) {{
|
||||
await options.load(detail);
|
||||
const candidate = {{hidden:false,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
|
||||
excluded.push(candidate.hidden);
|
||||
}}
|
||||
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
|
||||
const retire = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
|
||||
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
|
||||
}})().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["loaded"] == [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
|
||||
]
|
||||
assert payload["watching"] is True
|
||||
assert payload["button"] == {
|
||||
"hidden": False,
|
||||
"textContent": "Stop watching",
|
||||
"disabled": False,
|
||||
}
|
||||
assert payload["excluded"] == [True, False]
|
||||
assert payload["closedFollowing"]["watching"] is True
|
||||
assert payload["retire"] == {
|
||||
"hidden": False,
|
||||
"textContent": "Stop watching & next",
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
const states=[]; const retired=[]; const opened=[]; let calls=0;
|
||||
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
|
||||
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
|
||||
afterUnwatch:item => {{ retired.push(item.number); return next; }},
|
||||
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
|
||||
const first=preview.setWatching(false); const second=preview.setWatching(false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const before={{calls,retired:[...retired],opened:[...opened]}};
|
||||
finish({{watching:false,following_synced:true}});
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
|
||||
}})().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["before"] == {"calls": 1, "retired": [], "opened": []}
|
||||
assert payload["same"] is True
|
||||
assert payload["retired"] == [42]
|
||||
assert payload["opened"] == [9]
|
||||
assert payload["last"]["detail"]["number"] == 9
|
||||
|
||||
|
||||
def test_search_subscription_preview_hydrates_detail_before_watch_state():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls=[];
|
||||
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
calls.push(path);
|
||||
if (path.includes('/subscription')) return {{watching:true}};
|
||||
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
|
||||
}});
|
||||
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
|
||||
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||
}})().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": [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
],
|
||||
"detail": {
|
||||
"repository": "stackchain/api", "number": 42, "kind": "issue",
|
||||
"state": "open", "title": "Hydrated", "watching": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls=[];
|
||||
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
calls.push(path);
|
||||
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
|
||||
}});
|
||||
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
|
||||
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||
}})().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["calls"] == [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||
]
|
||||
assert payload["detail"]["following"] is True
|
||||
assert payload["detail"]["watching"] is True
|
||||
|
||||
|
||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -925,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]
|
||||
@@ -942,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
|
||||
@@ -1098,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-v130" 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(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "search-preview.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state = {{ requests:[], renders:[], refreshes:0 }};
|
||||
const feature = createDetailWatch({{
|
||||
fetchJson: async (path, options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
return {{watching:false,following_synced:true}};
|
||||
}},
|
||||
refreshFollowing: async () => {{ state.refreshes += 1; }},
|
||||
onState: (status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_detail_watch_loads_authoritative_typed_subscription_state():
|
||||
result = run("""
|
||||
await feature.open({repository:'stackchain/api',kind:'pull',number:84,state:'open'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == [{
|
||||
"path": "api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull",
|
||||
"method": "GET",
|
||||
}]
|
||||
assert result["renders"] == [
|
||||
{"status": "loading", "watching": False},
|
||||
{"status": "ready", "watching": False},
|
||||
]
|
||||
assert result["refreshes"] == 0
|
||||
|
||||
|
||||
def test_detail_watch_mutation_is_single_flight_and_refreshes_following_after_confirmation():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{requests:[],renders:[],refreshes:0}};
|
||||
let finishMutation;
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (path,options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
if (!options.method) return {{watching:false}};
|
||||
return new Promise(resolve => {{finishMutation=() => resolve({{watching:true,following_synced:true,following_count:3}});}});
|
||||
}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const item={{repository:'stackchain/api',kind:'issue',number:42,state:'open'}};
|
||||
await feature.open(item);
|
||||
const first=feature.toggle();
|
||||
const second=feature.toggle();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.samePromise=first === second;
|
||||
finishMutation();
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["samePromise"] is True
|
||||
assert [request["method"] for request in result["requests"]] == ["GET", "PUT"]
|
||||
assert result["refreshes"] == 1
|
||||
assert result["renders"][-2]["status"] == "watching"
|
||||
assert result["renders"][-1]["status"] == "watched"
|
||||
assert result["renders"][-1]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_partial_sync_never_claims_success_or_refreshes_following():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[],refreshes:0}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (_path,options={{}}) => options.method
|
||||
? {{watching:true,following_synced:false,error:'Following could not sync.'}}
|
||||
: {{watching:false}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.open({{repository:'stackchain/api',kind:'issue',number:42,state:'open'}});
|
||||
try {{ await feature.toggle(); }} catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "Following could not sync."
|
||||
assert result["refreshes"] == 0
|
||||
assert result["renders"][-1]["status"] == "error"
|
||||
assert result["renders"][-1]["watching"] is False
|
||||
assert all(state["status"] != "watched" for state in result["renders"])
|
||||
|
||||
|
||||
def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
|
||||
search_preview = (ROOT / "frontend" / "search-preview.js").read_text()
|
||||
|
||||
assert '<button id="watch-issue-detail" type="button">Watch issue</button>' in html
|
||||
assert '<button id="watch-pull-detail" type="button">Watch pull request</button>' in html
|
||||
assert 'id="issue-watch-status"' in html
|
||||
assert 'id="pull-watch-status"' in html
|
||||
assert 'static/search-preview.js' in bundle
|
||||
assert 'root.createDetailWatch' in search_preview
|
||||
assert 'function bindDetailWatch(' in dashboard
|
||||
assert 'issueDetailWatch.open(item)' in dashboard
|
||||
assert 'pullDetailWatch.open(item)' in dashboard
|
||||
assert '#watch-issue-detail' in css
|
||||
assert '#watch-pull-detail' in css
|
||||
assert "python3 -m pytest tests/e2e -q" in workflow
|
||||
assert (ROOT / "tests/e2e/test_mobile_detail_watch_release.py").is_file()
|
||||
|
||||
|
||||
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[]}};
|
||||
const completions={{}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:path => new Promise(resolve => {{completions[path]=resolve;}}),
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const first=feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}});
|
||||
const second=feature.open({{repository:'stackchain/api',kind:'issue',number:2,state:'open'}});
|
||||
completions[Object.keys(completions)[1]]({{watching:true}});
|
||||
await second;
|
||||
completions[Object.keys(completions)[0]]({{watching:false}});
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
ready = [state for state in result["renders"] if state["status"] == "ready"]
|
||||
assert len(ready) == 1
|
||||
assert ready[0]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_reports_subscription_load_failure_as_recoverable_error():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const state={{renders:[]}};
|
||||
const feature=globalThis.createDetailWatch({{
|
||||
fetchJson:async () => {{throw new Error('temporarily unavailable');}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}}); }}
|
||||
catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "temporarily unavailable"
|
||||
assert result["renders"][-1] == {
|
||||
"status": "error", "watching": False, "error": "temporarily unavailable"
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
|
||||
detail = {
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
}
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
|
||||
return detail
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watched = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
following = await client.get("/api/v1/following")
|
||||
|
||||
assert watched.status_code == 200
|
||||
assert watched.json() == {
|
||||
"watching": True,
|
||||
"following_synced": True,
|
||||
"following_revision": 1,
|
||||
"following_count": 1,
|
||||
}
|
||||
assert following.status_code == 200
|
||||
assert following.headers["cache-control"] == "no-store"
|
||||
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"has_unseen_change": False,
|
||||
}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32)
|
||||
detail = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||
"title": "Ship typed Following", "state": "open",
|
||||
"updated_at": "2026-08-23T05:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||
}
|
||||
previews = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return detail
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watched = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull"
|
||||
)
|
||||
following = await client.get("/api/v1/following")
|
||||
|
||||
assert watched.status_code == 200
|
||||
assert following.status_code == 200
|
||||
assert following.json()["items"][0]["kind"] == "pull"
|
||||
assert previews == [
|
||||
("stackchain/api", "pull", 84),
|
||||
("stackchain/api", "pull", 84),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
|
||||
store.set_watching("timmy", {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||
"title": "Review watched changes", "state": "open",
|
||||
"updated_at": "2026-08-24T05:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||
}, True)
|
||||
review = {
|
||||
"head_sha": "abc123", "ci_state": "success",
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
|
||||
}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def load_review(repository, number):
|
||||
assert (repository, number) == ("stackchain/api", 84)
|
||||
return review
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == review
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def unexpected_review(*_args):
|
||||
raise AssertionError("unwatched pull must be rejected before upstream access")
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
||||
store.set_watching("timmy", {
|
||||
"repository": "stackchain/api", "number": 7, "title": "Already followed",
|
||||
"state": "open", "updated_at": "2026-08-23T02:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
}, True)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Another issue", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(*args):
|
||||
calls.append(args)
|
||||
return {"watching": True}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert calls == []
|
||||
assert store.get("timmy")["items"][0]["number"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_write_failure_compensates_confirmed_gitea_watch(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"c" * 32)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Recoverable watch", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
calls.append(watching)
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
def fail_write(*args):
|
||||
raise OSError("disk unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(store, "set_watching", fail_write)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "Watch status was not changed. Please retry."}
|
||||
assert calls == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uncompensated_following_write_reports_authoritative_partial_outcome(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"d" * 32)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Split watch", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
calls.append(watching)
|
||||
return {"watching": True}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(store, "set_watching", lambda *args: (_ for _ in ()).throw(OSError()))
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"watching": True,
|
||||
"following_synced": False,
|
||||
"error": "Watching in Gitea, but Following could not sync. Retry this action.",
|
||||
}
|
||||
assert calls == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_refreshes_changed_items_and_preserves_failed_items(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"e" * 32)
|
||||
first = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Old title",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
failed = {**first, "number": 43, "title": "Last known",
|
||||
"url": "https://forge.example/stackchain/api/issues/43"}
|
||||
store.set_watching("timmy", failed, True)
|
||||
store.set_watching("timmy", first, True)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind) == ("stackchain/api", "issue")
|
||||
if number == 43:
|
||||
return {**failed, "kind": "issue", "title": ""}
|
||||
return {**first, "kind": "issue", "title": "Fresh title", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z"}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/following")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["degraded"] is True
|
||||
assert payload["refresh_failures"] == 1
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in payload["items"]] == [
|
||||
(42, True), (43, False)
|
||||
]
|
||||
assert payload["items"][0]["title"] == "Fresh title"
|
||||
assert payload["items"][1]["title"] == "Last known"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
item = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
store.set_watching("timmy", item, True)
|
||||
store.refresh("timmy", [{**item, "updated_at": "2026-08-23T04:00:00Z"}])
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/following/stackchain/api/issues/42/seen?kind=pull",
|
||||
json={"updated_at": "2026-08-23T04:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
item = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
}
|
||||
store.set_watching("timmy", item, True)
|
||||
changed = {**item, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull")
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/following/stackchain/api/issues/42/keep?kind=pull",
|
||||
json={"updated_at": changed["updated_at"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||
closed = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Finished work",
|
||||
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
store.set_watching("timmy", closed, True)
|
||||
mutations = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {**closed, "kind": "issue"}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
mutations.append((repository, number, watching))
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watch = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
unwatch = await client.delete(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert watch.status_code == 404
|
||||
assert unwatch.status_code == 200
|
||||
assert unwatch.json() == {
|
||||
"watching": False,
|
||||
"following_synced": True,
|
||||
"following_revision": 2,
|
||||
"following_count": 0,
|
||||
}
|
||||
assert mutations == [("stackchain/api", 42, False)]
|
||||
assert store.get("timmy")["items"] == []
|
||||
@@ -0,0 +1,615 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "following.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
|
||||
const feature = createFollowing({{
|
||||
fetchJson: async path => {{
|
||||
state.requests.push(path);
|
||||
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42',has_unseen_change:false}}]}};
|
||||
}},
|
||||
render: snapshot => state.renders.push(snapshot),
|
||||
onCount: count => state.counts.push(count),
|
||||
onOpen: item => state.opened.push(item),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_following_loads_account_collection_and_opens_existing_preview():
|
||||
result = run("""
|
||||
await feature.load();
|
||||
feature.open(0);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["api/v1/following"]
|
||||
assert result["counts"] == [0]
|
||||
assert result["renders"][-1]["status"] == "ready"
|
||||
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
|
||||
assert result["opened"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Quiet issue",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/issue/42",
|
||||
"has_unseen_change": False,
|
||||
"kind": "issue",
|
||||
"following": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_following_opens_pull_requests_in_the_pull_workspace():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const opened=[];
|
||||
const feature=createFollowing({{
|
||||
fetchJson:async () => ({{revision:1,items:[{{
|
||||
repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
|
||||
updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
|
||||
}}]}}),
|
||||
onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["opened"][0]["kind"] == "pull"
|
||||
assert result["opened"][0]["following"] is True
|
||||
assert result["session"]["items"][0]["kind"] == "pull"
|
||||
|
||||
|
||||
def test_following_route_loads_authoritative_state_before_starting_review():
|
||||
following = MODULE.read_text()
|
||||
start = following.index("async route()")
|
||||
route = following[start:following.index("open()", start)]
|
||||
|
||||
assert "await feature.load().then(feature.startReview)" in route
|
||||
assert ".catch(() => false)" in route
|
||||
assert "show();" in route
|
||||
|
||||
|
||||
def test_dashboard_dispatches_following_route_to_changed_first_review():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
start = dashboard.index("async function openWorkQueueRoute")
|
||||
route_handler = dashboard[start:dashboard.index("qs('#work-milestone-filter')", start)]
|
||||
assert "if (filter === 'following')" in route_handler
|
||||
assert "return followingQueue.route();" in route_handler
|
||||
assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue")
|
||||
|
||||
|
||||
def test_following_preview_wires_keep_for_later_action_and_retryable_failure():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
following = MODULE.read_text()
|
||||
|
||||
assert 'id="keep-following-for-later"' in html
|
||||
assert 'id="keep-following-status"' in html
|
||||
assert "followingQueue.preview(state)" in dashboard
|
||||
assert "query('#close-search-preview').click()" in following
|
||||
assert "state?.item?.following !== true" in following
|
||||
assert "feature.keepForLater(activeReviewItem)" in following
|
||||
assert "Kept for later" in following
|
||||
assert "Could not keep this change for later" in following
|
||||
assert "changeRevision(item, 'keep')" in following
|
||||
assert "keepForLater:feature.keepForLater" in following
|
||||
|
||||
|
||||
def test_following_conversation_marks_messages_newer_than_last_review():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
require({json.dumps(str(preview_module))});
|
||||
const nodes={{
|
||||
'#search-preview-comments':{{innerHTML:'',textContent:''}},
|
||||
'#search-preview-conversation-status':{{textContent:''}},
|
||||
'#retry-search-preview-conversation':{{hidden:true}},
|
||||
'#load-older-search-preview-comments':{{hidden:true,disabled:false}},
|
||||
}};
|
||||
const document={{querySelector:selector=>nodes[selector]}};
|
||||
global.renderSearchPreviewConversation({{
|
||||
status:'ready', reviewedAt:'2026-08-23T03:00:00Z', comments:[
|
||||
{{id:1,author:'Timmy',created_at:'2026-08-23T02:00:00Z',body:'Earlier'}},
|
||||
{{id:2,author:'Alex',created_at:'2026-08-23T04:00:00Z',body:'New decision'}},
|
||||
]
|
||||
}},document,String,String,String);
|
||||
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML,status:nodes['#search-preview-conversation-status'].textContent}}));
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["html"].count("new-since-review") == 1
|
||||
assert "New since last review" in result["html"]
|
||||
assert result["status"] == "2 messages · 1 new since last review."
|
||||
|
||||
|
||||
def test_following_revision_is_acknowledged_only_after_conversation_context_loads():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const createSearchPreview=require({json.dumps(str(preview_module))});
|
||||
let unavailable=true;
|
||||
const opened=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>({{...item,title:'Changed issue'}}),
|
||||
fetchConversation:async()=>{{if(unavailable)throw new Error('offline');return {{comments:[]}};}},
|
||||
onOpened:async item=>opened.push(item.updated_at),
|
||||
onState:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await preview.open({{
|
||||
repository:'stackchain/api',kind:'issue',number:42,following:true,
|
||||
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
|
||||
}});
|
||||
const afterFailure=[...opened];
|
||||
unavailable=false;
|
||||
await preview.retryConversation();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,opened}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"afterFailure": [],
|
||||
"opened": ["2026-08-23T04:00:00Z"],
|
||||
}
|
||||
|
||||
|
||||
def test_following_pull_revision_waits_for_changes_and_ci_context():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const createSearchPreview=require({json.dumps(str(preview_module))});
|
||||
let unavailable=true;
|
||||
const opened=[];
|
||||
const states=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>({{...item,title:'Changed pull request'}}),
|
||||
fetchConversation:async()=>({{comments:[]}}),
|
||||
fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
|
||||
onOpened:async item=>opened.push(item.updated_at),
|
||||
onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
|
||||
}});
|
||||
(async()=>{{
|
||||
await preview.open({{
|
||||
repository:'stackchain/api',kind:'pull',number:84,following:true,
|
||||
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
|
||||
}});
|
||||
const afterFailure=[...opened];
|
||||
unavailable=false;
|
||||
await preview.retryReview();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"afterFailure": [],
|
||||
"opened": ["2026-08-23T04:00:00Z"],
|
||||
"last": {"status": "ready", "ci": "success"},
|
||||
}
|
||||
|
||||
|
||||
def test_watched_pull_changes_render_bounded_diff_and_ci_state():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
require({json.dumps(str(preview_module))});
|
||||
const nodes={{
|
||||
'#search-preview-review':{{hidden:true}},
|
||||
'#search-preview-review-status':{{textContent:''}},
|
||||
'#search-preview-files':{{innerHTML:''}},
|
||||
'#retry-search-preview-review':{{hidden:true}},
|
||||
}};
|
||||
const document={{querySelector:selector=>nodes[selector]}};
|
||||
global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
|
||||
filename:'src/api.py',status:'modified',additions:1,deletions:0,
|
||||
diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
|
||||
}}]}}}},document,value=>String(value).replaceAll('<','<'));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
hidden:nodes['#search-preview-review'].hidden,
|
||||
status:nodes['#search-preview-review-status'].textContent,
|
||||
html:nodes['#search-preview-files'].innerHTML,
|
||||
retry:nodes['#retry-search-preview-review'].hidden,
|
||||
}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["hidden"] is False
|
||||
assert result["status"] == "CI passed · 1 changed file."
|
||||
assert "src/api.py" in result["html"]
|
||||
assert "pull-diff-line added" in result["html"]
|
||||
assert result["retry"] is True
|
||||
|
||||
|
||||
def test_following_typed_identity_prevents_issue_pull_collisions():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const acknowledged=[];
|
||||
const items=['issue','pull'].map(kind => ({{
|
||||
repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
|
||||
updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
|
||||
}}));
|
||||
const feature=createFollowing({{
|
||||
fetchJson:async () => ({{revision:1,items}}),
|
||||
onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.previewLoaded({{...items[1]}});
|
||||
feature.retire(items[1]);
|
||||
process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["acknowledged"] == ["pull"]
|
||||
assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
|
||||
("issue", True)
|
||||
]
|
||||
|
||||
|
||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(launcher))});
|
||||
const calls = [];
|
||||
const feature = createLauncher({{
|
||||
getCounts:() => ({{following:7}}),
|
||||
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
|
||||
selectFilter:name => calls.push(name), firstAction:() => null,
|
||||
announce:() => {{}}, openFindWork:() => calls.push('find'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result == {
|
||||
"opened": "opened-following",
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
|
||||
|
||||
def test_following_counts_unseen_changes_and_acknowledges_after_preview_loads():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{counts:[], opened:[], acknowledged:[], renders:[]}};
|
||||
let finishPreview;
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:4,items:[
|
||||
{{repository:'stackchain/api',number:42,title:'Changed',state:'open',updated_at:'2026-08-23T04:00:00Z',url:'https://forge/42',has_unseen_change:true}},
|
||||
{{repository:'stackchain/api',number:43,title:'Quiet',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge/43',has_unseen_change:false}}
|
||||
]}}),
|
||||
render:value => state.renders.push(value),
|
||||
onCount:value => state.counts.push(value),
|
||||
onOpen:item => new Promise(resolve => {{ finishPreview=() => {{ state.opened.push(item.number); resolve(); }}; }}),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
const opening=feature.open(0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.before={{acknowledged:[...state.acknowledged],counts:[...state.counts]}};
|
||||
finishPreview();
|
||||
await opening;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result["before"] == {"acknowledged": [], "counts": [1]}
|
||||
assert result["opened"] == [42]
|
||||
assert result["acknowledged"] == ["2026-08-23T04:00:00Z"]
|
||||
assert result["counts"] == [1, 0]
|
||||
assert result["renders"][-1]["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
def test_following_review_captures_only_current_unseen_changes_in_display_order():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{opened:[], acknowledged:[]}};
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items:[
|
||||
{{repository:'stackchain/api',number:42,title:'Newest',state:'open',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Also changed',state:'open',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/api',number:41,title:'Quiet',state:'open',updated_at:'2026-08-22T05:00:00Z',has_unseen_change:false}}
|
||||
]}}),
|
||||
onOpen:async item => state.opened.push(item.number),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
const started=await feature.startReview();
|
||||
process.stdout.write(JSON.stringify({{started,session:feature.session(),state}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["started"] is True
|
||||
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
|
||||
assert all(item["kind"] == "issue" for item in result["session"]["items"])
|
||||
assert all(item["following"] is True for item in result["session"]["items"])
|
||||
assert result["session"]["more"] is False
|
||||
assert result["state"] == {"opened": [42], "acknowledged": [42]}
|
||||
|
||||
|
||||
def test_following_review_acknowledges_each_loaded_revision_and_reports_completion():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{acknowledged:[], renders:[], counts:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
|
||||
onOpen:async () => {{}}, onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
await feature.previewLoaded(feature.session().items[1]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["acknowledged"] == [42, 9]
|
||||
assert result["counts"][-1] == 0
|
||||
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}
|
||||
|
||||
|
||||
def test_following_keep_for_later_preserves_revision_and_advances_without_looping():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{opened:[], acknowledged:[], kept:[], counts:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
onOpen:async item => state.opened.push(item.number),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
onKeep:async item => state.kept.push(item.number),
|
||||
onCount:value => state.counts.push(value),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const kept = await feature.keepForLater(feature.session().items[0]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
kept, session:feature.session(), items:feature.items(), state
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["kept"]["number"] == 9
|
||||
assert [item["number"] for item in result["session"]["items"]] == [9]
|
||||
assert result["items"][0]["has_unseen_change"] is True
|
||||
assert result["state"] == {
|
||||
"opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1]
|
||||
}
|
||||
|
||||
|
||||
def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const completed = [];
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
onOpen:async () => {{}},
|
||||
onAcknowledge:async () => {{}},
|
||||
onReviewComplete:() => completed.push('following'),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
await feature.previewLoaded(feature.session().items[1]);
|
||||
const beforeFinish = completed.slice();
|
||||
feature.finishReview();
|
||||
feature.finishReview();
|
||||
process.stdout.write(JSON.stringify({{beforeFinish, completed}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout) == {"beforeFinish": [], "completed": ["following"]}
|
||||
|
||||
|
||||
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{renders:[], counts:[], completed:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
|
||||
onOpen:async () => {{}}, onAcknowledge:async () => {{}},
|
||||
onReviewComplete:() => state.completed.push('following'),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const first=feature.session().items[0];
|
||||
const next=feature.retire(first);
|
||||
await feature.previewLoaded(next);
|
||||
const final=feature.retire(next);
|
||||
const handoff=feature.finishReview();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["next"] == 9
|
||||
assert result["final"] is None
|
||||
assert result["handoff"] is True
|
||||
assert result["items"] == []
|
||||
assert result["session"] is None
|
||||
assert result["state"]["counts"][-1] == 0
|
||||
assert result["state"]["completed"] == ["following"]
|
||||
|
||||
|
||||
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{acknowledged:[], counts:[]}};
|
||||
let loads=0;
|
||||
const original=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const refreshed=[
|
||||
{{...original[0],has_unseen_change:false}},
|
||||
{{...original[1],updated_at:'2026-08-23T07:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:++loads,items:loads === 1 ? original : refreshed}}),
|
||||
onCount:value => state.counts.push(value), onOpen:async () => {{}},
|
||||
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const captured=feature.session().items[1];
|
||||
await feature.load();
|
||||
const cleared=await feature.previewLoaded(captured);
|
||||
process.stdout.write(JSON.stringify({{cleared,state}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"cleared": False,
|
||||
"state": {
|
||||
"acknowledged": ["2026-08-23T06:00:00Z"],
|
||||
"counts": [2, 1, 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_following_review_controls_are_wired_into_the_phone_preview_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
following = MODULE.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="review-following"' in html
|
||||
assert '>Review new activity</button>' in html
|
||||
assert ".following-review-action" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "query('#review-following').addEventListener('click'" in following
|
||||
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
||||
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
||||
assert "changeRevision(item, 'seen')" in following
|
||||
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
|
||||
assert "followingQueue.returnToFollowing()" in dashboard
|
||||
assert "'Back to Following'" in dashboard
|
||||
|
||||
|
||||
def test_following_open_issue_exposes_only_action_and_continue_planning_dispositions():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const disposition = createFollowing.planningDisposition;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
active:disposition({{following:true,kind:'issue',state:'open',claimable:true}}),
|
||||
generic:disposition({{following:false,kind:'issue',state:'open',claimable:true}}),
|
||||
pull:disposition({{following:true,kind:'pull',state:'open',assigned_to_me:true}}),
|
||||
}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"active": {
|
||||
"active": True,
|
||||
"today": "Add to Today & next",
|
||||
"later": "Later & next",
|
||||
"week": "Week Ahead & next",
|
||||
},
|
||||
"generic": {"active": False},
|
||||
"pull": {"active": False},
|
||||
}
|
||||
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
assert "followingQueue.preview(state);" in dashboard
|
||||
assert "following-disposition-mode" in MODULE.read_text()
|
||||
assert "controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true" in MODULE.read_text()
|
||||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v139" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
following = MODULE.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert "function attachFollowing(onOpen, hooks = {})" in following
|
||||
assert "hooks.onCount?.(count, feature.items())" in following
|
||||
assert "hooks.onStatus?.(state.status)" in following
|
||||
assert "onReviewComplete:hooks.onReviewComplete" in following
|
||||
assert "review:feature.startReview" in following
|
||||
assert "const completed = feature.finishReview()" in following
|
||||
assert "if (completed) return 'completed-following'" in following
|
||||
assert "followingQueue.load().catch(() => {})" in dashboard
|
||||
assert "queueCounts.following = count" in dashboard
|
||||
assert "queueCounts.followingUnavailable = status === 'error'" in dashboard
|
||||
assert "preparationItems.following = items.filter" in dashboard
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in dashboard
|
||||
assert "mobileStartDay.completePhase('following')" in dashboard
|
||||
@@ -0,0 +1,177 @@
|
||||
import sqlite3
|
||||
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
KEY = b"f" * 32
|
||||
ITEM = {
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||
path = tmp_path / "following.sqlite3"
|
||||
store = FollowingStore(path, encryption_key=KEY, limit=50)
|
||||
|
||||
first = store.set_watching("Timmy", ITEM, True)
|
||||
repeated = store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert first == repeated == {
|
||||
"revision": 1,
|
||||
"items": [{**ITEM, "kind": "issue", "has_unseen_change": False}],
|
||||
}
|
||||
assert store.get("other") == {"revision": 0, "items": []}
|
||||
stored = sqlite3.connect(path).execute(
|
||||
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
||||
).fetchone()[0]
|
||||
assert ITEM["title"] not in stored
|
||||
assert ITEM["repository"] not in stored
|
||||
|
||||
|
||||
def test_refresh_marks_changed_items_unseen_and_sorts_them_first(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
quiet = {**ITEM, "number": 7, "title": "Quiet", "updated_at": "2026-08-23T02:00:00Z"}
|
||||
store.set_watching("timmy", quiet, True)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
snapshot = store.refresh("timmy", [{
|
||||
**quiet, "title": "Changed upstream", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z",
|
||||
}])
|
||||
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in snapshot["items"]] == [
|
||||
(7, True), (42, False)
|
||||
]
|
||||
assert snapshot["items"][0]["title"] == "Changed upstream"
|
||||
assert snapshot["items"][0]["state"] == "closed"
|
||||
|
||||
|
||||
def test_refresh_explains_change_from_last_reviewed_snapshot(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
closed = store.refresh("timmy", [{
|
||||
**ITEM,
|
||||
"title": "Make mobile review trustworthy",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z",
|
||||
}])
|
||||
|
||||
assert closed["items"][0]["reviewed_at"] == ITEM["updated_at"]
|
||||
assert closed["items"][0]["change_summary"] == "Closed since last review"
|
||||
|
||||
store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T04:00:00Z"
|
||||
)
|
||||
title_change = store.refresh("timmy", [{
|
||||
**ITEM,
|
||||
"title": "Make mobile review understandable",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-08-23T05:00:00Z",
|
||||
}])
|
||||
|
||||
assert title_change["items"][0]["reviewed_at"] == "2026-08-23T04:00:00Z"
|
||||
assert title_change["items"][0]["change_summary"] == "Title changed since last review"
|
||||
|
||||
|
||||
def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
stale = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], ITEM["updated_at"])
|
||||
assert stale["items"][0]["has_unseen_change"] is True
|
||||
seen = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
assert seen["items"][0]["has_unseen_change"] is False
|
||||
|
||||
store.refresh("timmy", [{**ITEM, "updated_at": "2026-08-23T05:00:00Z"}])
|
||||
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_keep_for_later_restores_the_exact_seen_revision_as_unseen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
|
||||
snapshot = store.keep_unseen(
|
||||
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
|
||||
)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_reopening_a_kept_revision_acknowledges_it_again(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
store.keep_unseen("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
|
||||
snapshot = store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
|
||||
)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
snapshot = store.set_watching("timmy", changed, True)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
issue = {**ITEM, "kind": "issue"}
|
||||
pull = {
|
||||
**ITEM,
|
||||
"kind": "pull",
|
||||
"title": "Review the mobile flow",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
}
|
||||
|
||||
store.set_watching("timmy", issue, True)
|
||||
snapshot = store.set_watching("timmy", pull, True)
|
||||
|
||||
assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [
|
||||
("pull", 42),
|
||||
("issue", 42),
|
||||
]
|
||||
|
||||
|
||||
def test_following_reads_legacy_items_as_issues(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert store.get("timmy")["items"][0]["kind"] == "issue"
|
||||
|
||||
|
||||
def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
for kind in ("issue", "pull"):
|
||||
store.set_watching("timmy", {**ITEM, "kind": kind}, True)
|
||||
store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}])
|
||||
|
||||
snapshot = store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull"
|
||||
)
|
||||
|
||||
assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == {
|
||||
"issue": True,
|
||||
"pull": False,
|
||||
}
|
||||
@@ -72,6 +72,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createFollowing" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createFollowing" not in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createPlanToday" not in first.runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
@@ -132,7 +134,7 @@ def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
||||
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
||||
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 111 * 1024
|
||||
|
||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||
|
||||
@@ -100,6 +100,32 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_page_loads_open_created_pulls_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "3"}, json=[{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"},
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/pulls/41",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("authored", page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=pulls&limit=50&page=1"
|
||||
]
|
||||
assert result["total"] == 3
|
||||
assert result["items"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||
async def page(stream, page=1, limit=50):
|
||||
@@ -628,7 +654,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
@@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
"authored": {"page": 1, "total": 12, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -666,11 +693,12 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
||||
("filed", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
("authored", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
|
||||
async def test_pull_requests_merge_assignment_review_and_authored_responsibilities(monkeypatch):
|
||||
assigned = {
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
@@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
"title": "Review mobile",
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
authored_only = {
|
||||
"id": 13,
|
||||
"number": 9,
|
||||
"title": "Ship authored flow",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
}
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
items = {
|
||||
"pull": [assigned],
|
||||
"review": [assigned.copy(), review_only],
|
||||
"authored": [assigned.copy(), authored_only],
|
||||
}[stream]
|
||||
reason = {
|
||||
"pull": "assigned_to_me",
|
||||
"review": "review_requested",
|
||||
"authored": "authored_by_me",
|
||||
}[stream]
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
@@ -697,9 +739,44 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
||||
assert [pull["id"] for pull in pulls] == [11, 12, 13]
|
||||
assert pulls[0]["work_reasons"] == [
|
||||
"assigned_to_me", "review_requested", "authored_by_me",
|
||||
]
|
||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
||||
assert pulls[2]["work_reasons"] == ["authored_by_me"]
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_failure_keeps_other_pull_work_and_marks_only_that_stream_unavailable(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
if stream == "authored":
|
||||
raise httpx.ConnectError("authored search unavailable")
|
||||
item = {
|
||||
"id": 11 if stream == "pull" else 12,
|
||||
"number": 7 if stream == "pull" else 8,
|
||||
"title": "Assigned" if stream == "pull" else "Review",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"work_reasons": ["assigned_to_me" if stream == "pull" else "review_requested"],
|
||||
}
|
||||
return {
|
||||
"stream": stream, "items": [item], "page": page,
|
||||
"total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls.pagination["pull"].get("unavailable", False) is False
|
||||
assert pulls.pagination["review"].get("unavailable", False) is False
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -218,6 +218,110 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
calls.append(("preview", repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number,
|
||||
"state": "open", "claimable": True}
|
||||
|
||||
async def subscription(repository, number):
|
||||
calls.append(("subscription", repository, number))
|
||||
return {"watching": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, 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/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"watching": True}
|
||||
assert calls == [
|
||||
("preview", "stackchain/api", "issue", 42),
|
||||
("subscription", "stackchain/api", 42),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
||||
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
||||
monkeypatch, tmp_path, method, watching
|
||||
):
|
||||
calls = []
|
||||
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
calls.append(("preview", repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Assigned issue", "state": "open", "claimable": False,
|
||||
"assignees": ["alexander"],
|
||||
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
|
||||
|
||||
async def set_subscription(repository, number, desired):
|
||||
calls.append(("set", repository, number, desired))
|
||||
return {"watching": desired}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
expected_count = 1 if watching else 0
|
||||
assert response.json() == {
|
||||
"watching": watching,
|
||||
"following_synced": True,
|
||||
"following_revision": expected_count,
|
||||
"following_count": expected_count,
|
||||
}
|
||||
assert calls == [
|
||||
("preview", "stackchain/api", "issue", 42),
|
||||
("set", "stackchain/api", 42, watching),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"watching": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/user"),
|
||||
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||
@@ -682,6 +786,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
@@ -701,6 +806,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
@@ -769,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-v130" 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-v130" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
@@ -45,12 +45,12 @@ const controller = createMobileAppBadge({
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(12, {authoritative:false});
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 12, false);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(0, {authoritative:true});
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
@@ -61,6 +61,33 @@ console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-ba
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_combines_authoritative_update_and_following_channels_independently():
|
||||
result = run_badge("""
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}},
|
||||
status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{
|
||||
async setAppBadge(value) { values.push(value); },
|
||||
async clearAppBadge() { clears++; },
|
||||
},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 0, true);
|
||||
await controller.reconcile('following', 4, false);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('updates', 5, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
console.log(JSON.stringify({values, clears}));
|
||||
""")
|
||||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
@@ -92,9 +119,11 @@ def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
|
||||
assert 'id="app-badge-control"' in index
|
||||
assert 'id="app-badge-status" role="status"' in index
|
||||
assert ".app-badge-control { min-height:44px;" in css
|
||||
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
|
||||
assert "mobileAppBadge.start();" in dashboard
|
||||
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
|
||||
assert "const appBadge = createMobileAppBadge({" in dashboard
|
||||
assert "appBadge.start();" in dashboard
|
||||
assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard
|
||||
assert "appBadge.reconcile('following', count, true);" in dashboard
|
||||
assert "Show review attention on the app icon" in index
|
||||
|
||||
|
||||
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
|
||||
@@ -113,7 +142,7 @@ const controller = createMobileAppBadge({
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(4, {authoritative:true});
|
||||
await controller.reconcile('updates', 4, true);
|
||||
const before = controller.readiness();
|
||||
await controller.enable();
|
||||
const after = controller.readiness();
|
||||
@@ -122,11 +151,11 @@ console.log(JSON.stringify({before, after, checked:control.checked, values}));
|
||||
assert result == {
|
||||
"before": {
|
||||
"state": "incomplete",
|
||||
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
|
||||
"detail": "Show review attention without opening Stackchain.",
|
||||
},
|
||||
"after": {
|
||||
"state": "complete",
|
||||
"detail": "The app icon shows the confirmed unread Updates count.",
|
||||
"detail": "The app icon shows review attention.",
|
||||
},
|
||||
"checked": True,
|
||||
"values": [4],
|
||||
@@ -142,8 +171,8 @@ def test_device_setup_offers_app_badge_as_an_optional_supported_step():
|
||||
assert 'id="device-setup-app-badge" type="button"' in index
|
||||
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
|
||||
assert "appBadge:options.appBadgeReadiness()," in setup
|
||||
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
|
||||
assert "appBadgeReadiness:() => appBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => appBadge.enable()," in dashboard
|
||||
|
||||
|
||||
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
|
||||
@@ -156,7 +185,7 @@ const controller = createMobileAppBadge({
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
const reconciled = await controller.reconcile(3, {authoritative:true});
|
||||
const reconciled = await controller.reconcile('updates', 3, true);
|
||||
console.log(JSON.stringify({reconciled, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
@@ -210,3 +239,51 @@ console.log(JSON.stringify(messages));
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-preference", "enabled": False},
|
||||
]
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_authoritative_channel_counts_with_worker():
|
||||
result = run_badge("""
|
||||
const messages = [];
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 9, false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
@@ -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-v130" 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-v130" 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-v130" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -146,6 +146,61 @@ process.stdout.write(JSON.stringify(controller.briefing()));
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_reviews_unseen_following_after_filed_before_today():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const opened = [];
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{filed:1, following:2, today:3}}),
|
||||
openQueue: name => opened.push(name),
|
||||
}});
|
||||
const briefing = controller.briefing();
|
||||
controller.startNext();
|
||||
process.stdout.write(JSON.stringify({{briefing, opened}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"briefing": {
|
||||
"total": 3,
|
||||
"next": "filed",
|
||||
"label": "Review Filed",
|
||||
"summary": "3 items before Today · 3 planned",
|
||||
"phases": [
|
||||
{"name": "filed", "label": "Filed", "count": 1},
|
||||
{"name": "following", "label": "Following", "count": 2},
|
||||
],
|
||||
},
|
||||
"opened": ["filed"],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_keeps_failed_following_refresh_retryable_before_today():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const opened = [];
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{followingUnavailable:true, today:2}}),
|
||||
openQueue: name => opened.push(name),
|
||||
}});
|
||||
const briefing = controller.briefing();
|
||||
controller.startNext();
|
||||
process.stdout.write(JSON.stringify({{briefing, opened}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"briefing": {
|
||||
"total": 0,
|
||||
"next": "following",
|
||||
"label": "Retry Following",
|
||||
"summary": "Following needs retry before Today · 2 planned",
|
||||
"phases": [{"name": "following", "label": "Following unavailable", "count": 0}],
|
||||
},
|
||||
"opened": ["following"],
|
||||
}
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
@@ -352,13 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
||||
assert "mobileStartDay.render();" in html
|
||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
||||
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-v130" in service_worker
|
||||
assert "stackchain-dashboard-shell-v139" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -371,9 +427,9 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
|
||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||
assert "qs('#mobile-start-day-action').focus();" in html
|
||||
assert "let mobilePreparationItems = {};" in html
|
||||
assert "getPhaseItems: () => mobilePreparationItems" in html
|
||||
assert "mobilePreparationItems = {" in html
|
||||
assert "let preparationItems = {};" in html
|
||||
assert "getPhaseItems: () => preparationItems" in html
|
||||
assert "preparationItems = {" in html
|
||||
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "agenda:agendaMyWork(activeMyWork)" in html
|
||||
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
|
||||
|
||||
@@ -834,7 +834,8 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
@@ -872,7 +873,7 @@ async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommen
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "queueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@@ -893,7 +894,8 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
|
||||
+593
-3
@@ -100,6 +100,100 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||
assert result[1]["reason"] == "Filed by you"
|
||||
|
||||
|
||||
def test_authored_pull_is_classified_for_the_my_prs_queue():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"pull_requests": [{
|
||||
"number": 41, "title": "Ship mobile flow", "repository": "stackchain/dashboard",
|
||||
"state": "open", "user": "timmy", "assignees": [],
|
||||
"work_reasons": ["authored_by_me"], "updated_at": "2026-08-24T12:00:00Z",
|
||||
}],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
result = json.loads(completed.stdout)
|
||||
assert result[0]["is_authored"] is True
|
||||
assert result[0]["reason"] == "Authored by you"
|
||||
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))});
|
||||
const items = buildMyWork({{
|
||||
user:{{login:'timmy'}},
|
||||
pull_requests:[
|
||||
{{number:41,title:'Authored',repository:'stackchain/dashboard',state:'open',user:'timmy',
|
||||
assignees:[],work_reasons:['authored_by_me']}},
|
||||
{{number:42,title:'Review',repository:'stackchain/dashboard',state:'open',user:'alex',
|
||||
assignees:[],work_reasons:['review_requested']}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
authored:buildMyWork.filterMyWork(items,'authored').map(item=>item.number),
|
||||
counts:buildMyWork.countMyWork(items),
|
||||
}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
result = json.loads(completed.stdout)
|
||||
assert result["authored"] == [41]
|
||||
assert result["counts"]["authored"] == 1
|
||||
|
||||
|
||||
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
|
||||
script = f"""
|
||||
const sheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -676,6 +770,34 @@ process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today
|
||||
assert json.loads(result.stdout) == {
|
||||
"kind": "queue", "filter": "agenda", "action": "protect-today"
|
||||
}
|
||||
|
||||
|
||||
def test_following_route_waits_for_hydration_then_opens_once():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/following'}};
|
||||
const calls = [];
|
||||
const controller = routes.createController({{
|
||||
location,
|
||||
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onQueue: (queue, action) => calls.push([queue, action]),
|
||||
onInvalid: () => calls.push(['invalid']),
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([]);
|
||||
controller.sync();
|
||||
process.stdout.write(JSON.stringify({{route:routes.parse(location.hash), calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"route": {"kind": "queue", "filter": "following"},
|
||||
"calls": [["following", None]],
|
||||
}
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
@@ -1031,6 +1153,24 @@ async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_sta
|
||||
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_prs_is_a_thumb_safe_mobile_queue_and_desktop_filter():
|
||||
html = await dashboard()
|
||||
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'data-work-filter="authored"' in markup
|
||||
assert 'data-work-count="authored"' in markup
|
||||
assert 'data-mobile-queue="authored"' in markup
|
||||
assert 'data-mobile-queue-count="authored"' in markup
|
||||
assert '<strong>My PRs</strong>' in markup
|
||||
assert ".mobile-queue-list button" in css and "min-height:56px" in css
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored'" in html
|
||||
assert "['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)" in html
|
||||
assert "authored: 'pull requests'" in html
|
||||
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
@@ -4960,7 +5100,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
"all": 3, "attention": 1, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
@@ -6137,7 +6278,8 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["updates"][1]["kind"] == "update"
|
||||
assert output["updates"][1]["update_reason"] == ""
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
"all": 2, "attention": 2, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
@@ -7396,6 +7538,80 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||
|
||||
|
||||
def test_pull_sheet_preserves_context_draft_until_confirmed_update():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
||||
const calls = [];
|
||||
let fail = true;
|
||||
const controller = createPullSheet({{
|
||||
storage,
|
||||
fetchJson: async (url, options={{}}) => {{
|
||||
const body = JSON.parse(options.body);
|
||||
calls.push({{url, method:options.method, body}});
|
||||
if (fail) {{ fail = false; throw new Error('network down'); }}
|
||||
return {{number:7,title:body.title,body:body.body,head_sha:body.expected_head_sha,state:'open'}};
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
const draft = {{title:'Clear review brief',body:'Review mobile handoff.',expectedHeadSha:'abc1234'}};
|
||||
(async () => {{
|
||||
await controller.updateContent(item, draft).catch(() => null);
|
||||
const kept = controller.loadEditDraft(item);
|
||||
const result = await controller.updateContent(item, draft);
|
||||
process.stdout.write(JSON.stringify({{kept,result,after:controller.loadEditDraft(item),calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["kept"] == {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expectedHeadSha": "abc1234",
|
||||
}
|
||||
assert output["after"] is None
|
||||
assert output["result"]["head_sha"] == "abc1234"
|
||||
assert output["calls"] == [
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
},
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_overview_exposes_author_only_context_editor_with_retry_safe_status():
|
||||
html = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||
source = await dashboard()
|
||||
pull_source = PULL_SHEET.read_text()
|
||||
|
||||
assert 'id="edit-pull-content"' in html
|
||||
assert 'id="pull-edit-form"' in html
|
||||
assert 'id="pull-edit-title"' in html
|
||||
assert 'id="pull-edit-body"' in html
|
||||
assert 'id="save-pull-content"' in html
|
||||
assert "detail.author === getLogin()" in pull_source
|
||||
assert "controller.loadEditDraft(item)" in pull_source
|
||||
assert "controller.updateContent(item" in pull_source
|
||||
assert "Your change is still here; retry." in pull_source
|
||||
assert "controller.setReviewDetail(detail)" in pull_source
|
||||
|
||||
|
||||
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7435,6 +7651,9 @@ const elements = new Map([
|
||||
['#load-pull-handoff', {{disabled:true}}],
|
||||
['#release-pull', {{disabled:true,textContent:''}}],
|
||||
['#pull-handoff-status', {{textContent:''}}],
|
||||
['#edit-pull-content', {{hidden:false}}],
|
||||
['#pull-edit-form', {{hidden:false}}],
|
||||
['#pull-edit-status', {{textContent:'stale'}}],
|
||||
]);
|
||||
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
||||
const doc = {{
|
||||
@@ -7470,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))});
|
||||
@@ -7511,6 +7763,123 @@ const item = {{repository:'stackchain/api',number:7}};
|
||||
assert output["released"] == {"assignees": []}
|
||||
|
||||
|
||||
def test_pull_sheet_loads_reviewers_and_single_flights_review_request():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let finish;
|
||||
const controller = createPullSheet({{
|
||||
storage: null,
|
||||
fetchJson: (url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/review-candidates')) return Promise.resolve([{{login:'casey',name:'Casey'}}]);
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}}
|
||||
}});
|
||||
const notified = [];
|
||||
controller.onReviewRequested = result => notified.push(result);
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
(async () => {{
|
||||
const candidates = await controller.loadReviewCandidates(item);
|
||||
const first = controller.requestReview(item, 'casey', 'abc1234');
|
||||
const duplicate = controller.requestReview(item, 'casey', 'abc1234');
|
||||
finish({{reviewer:'casey',requested_reviewers:['sam','casey']}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls,candidates,results,same:first===duplicate,notified}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/review-candidates", "method": "GET", "body": None},
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/request-review", "method": "POST", "body": {
|
||||
"reviewer": "casey", "expected_head_sha": "abc1234"
|
||||
}},
|
||||
],
|
||||
"candidates": [{"login": "casey", "name": "Casey"}],
|
||||
"results": [
|
||||
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
|
||||
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
|
||||
],
|
||||
"same": True,
|
||||
"notified": [{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]}],
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_single_flights_pending_review_cancellation():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let finish;
|
||||
const controller = createPullSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method, body:JSON.parse(options.body)}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}}
|
||||
}});
|
||||
const notified = [];
|
||||
controller.onReviewCancelled = result => notified.push(result);
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
(async () => {{
|
||||
const first = controller.cancelReview(item, 'sam', 'abc1234');
|
||||
const duplicate = controller.cancelReview(item, 'sam', 'abc1234');
|
||||
finish({{reviewer:'sam',head_sha:'abc1234',requested_reviewers:['casey']}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls,results,same:first===duplicate,notified}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/request-review",
|
||||
"method": "DELETE",
|
||||
"body": {"reviewer": "sam", "expected_head_sha": "abc1234"},
|
||||
}],
|
||||
"results": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}] * 2,
|
||||
"same": True,
|
||||
"notified": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}],
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_exposes_touch_safe_review_request_flow():
|
||||
root = PULL_SHEET.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="pull-review-request"' in html
|
||||
assert 'id="load-pull-reviewers"' in html
|
||||
assert 'id="pull-review-recipient"' in html
|
||||
assert 'id="confirm-pull-review"' in html
|
||||
assert 'id="pull-review-request-status" class="small" aria-live="assertive"' in html
|
||||
assert '.pull-review-request select, .pull-review-request button { min-height:44px;' in css
|
||||
assert "createPullSheet.bindOwnershipControls(document, pullController" in dashboard
|
||||
assert "pullController.setReviewDetail(detail)" in dashboard
|
||||
|
||||
|
||||
def test_pull_sheet_exposes_mobile_reviewer_status_and_updated_review_action():
|
||||
root = PULL_SHEET.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="pull-reviewer-statuses"' in html
|
||||
assert 'id="request-updated-pull-review"' in html
|
||||
assert 'id="pull-reviewer-status" class="small" aria-live="polite"' in html
|
||||
assert ".pull-reviewer-status" in css
|
||||
assert "overflow-wrap:anywhere" in css
|
||||
assert "#request-updated-pull-review { min-height:44px;" in css
|
||||
assert "createPullSheet.review(detail, pullReviewState, document)" in dashboard
|
||||
assert "reviewers.some(reviewer => reviewer.status === 'outdated')" in PULL_SHEET.read_text()
|
||||
|
||||
|
||||
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7557,6 +7926,126 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
|
||||
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
|
||||
|
||||
|
||||
def test_pull_sheet_renders_reviewer_decisions_and_blocks_unresolved_requests():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const base = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'new'}};
|
||||
const states = [
|
||||
[{{login:'casey', status:'waiting', head_sha:'new', blocking:true}}],
|
||||
[{{login:'sam', status:'changes_requested', head_sha:'new', blocking:true}}],
|
||||
[{{login:'lee', status:'outdated', head_sha:'old', blocking:true}}],
|
||||
[{{login:'pat', status:'approved', head_sha:'new', blocking:false}}],
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
eligibility:states.map(reviewers => createPullSheet.mergeEligibility({{...base, reviewers}})),
|
||||
html:createPullSheet.renderReviewerStatuses(states.flat(), value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')),
|
||||
}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["eligibility"] == [
|
||||
{"allowed": False, "reason": "Waiting for @casey’s review"},
|
||||
{"allowed": False, "reason": "@sam requested changes"},
|
||||
{"allowed": False, "reason": "@lee’s review is outdated after new commits"},
|
||||
{"allowed": True, "reason": "Ready to merge"},
|
||||
]
|
||||
assert "@casey" in output["html"] and "Waiting for review" in output["html"]
|
||||
assert "Changes requested" in output["html"]
|
||||
assert "Outdated after new commits" in output["html"]
|
||||
assert "Approved current head" in output["html"]
|
||||
|
||||
|
||||
def test_pull_sheet_offers_cancel_only_for_pending_review_requests():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const html = createPullSheet.renderReviewerStatuses([
|
||||
{{login:'sam',status:'waiting'}},
|
||||
{{login:'casey',status:'approved'}},
|
||||
{{login:'lee',status:'commented'}},
|
||||
], value => String(value));
|
||||
process.stdout.write(html);
|
||||
"""
|
||||
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
|
||||
assert html.count('data-cancel-review-request=') == 1
|
||||
assert 'data-cancel-review-request="sam"' in html
|
||||
assert "Cancel request" in html
|
||||
assert 'data-cancel-review-request="casey"' not in html
|
||||
assert 'data-cancel-review-request="lee"' not in html
|
||||
|
||||
|
||||
def test_pull_sheet_pending_review_cancellation_is_touch_safe_and_opens_replacement_picker():
|
||||
root = PULL_SHEET.parents[1]
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
script = PULL_SHEET.read_text()
|
||||
|
||||
assert ".cancel-review-request { min-height:44px;" in css
|
||||
assert "controller.cancelReview(selected, reviewer, detail.head_sha)" in script
|
||||
assert "Choose a replacement reviewer." in script
|
||||
assert "qs('#pull-review-request').open = true" in script
|
||||
|
||||
|
||||
def test_pull_sheet_renders_bounded_feedback_grouped_by_file():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const html = createPullSheet.renderReviewerStatuses([{{
|
||||
login:'sam', status:'changes_requested', head_sha:'abc123', blocking:true,
|
||||
summary:'Please <split> this helper.',
|
||||
comments:[
|
||||
{{path:'src/api.py', body:'Handle <empty>.', line:12}},
|
||||
{{path:'src/api.py', body:'Keep the return explicit.'}},
|
||||
{{path:'frontend/app.js', body:'Show the error.'}},
|
||||
],
|
||||
}}], value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'));
|
||||
process.stdout.write(html);
|
||||
"""
|
||||
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
|
||||
assert "Review feedback" in html
|
||||
assert "Please <split> this helper." in html
|
||||
assert html.count('data-review-feedback-file="src/api.py"') == 1
|
||||
assert 'data-review-feedback-file="frontend/app.js"' in html
|
||||
assert "Handle <empty>." in html
|
||||
assert "Line 12" in html
|
||||
assert "View in changes" in html
|
||||
assert "Please <split>" not in html
|
||||
|
||||
|
||||
def test_pull_sheet_marks_inline_feedback_as_on_demand_and_retryable():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const render = state => createPullSheet.renderReviewerStatuses([{{
|
||||
login:'sam', review_id:8, status:'changes_requested', head_sha:'abc1234',
|
||||
blocking:true, summary:'Please fix this.', feedback_state:state,
|
||||
}}], value => String(value));
|
||||
process.stdout.write(JSON.stringify({{idle:render(undefined), failed:render('error')}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert "Load inline feedback" in output["idle"]
|
||||
assert 'data-load-review-feedback="8"' in output["idle"]
|
||||
assert "Feedback unavailable" in output["failed"]
|
||||
assert "Retry feedback" in output["failed"]
|
||||
assert "Address feedback" not in output["idle"]
|
||||
|
||||
|
||||
def test_pull_sheet_binds_on_demand_feedback_with_visible_retry_state():
|
||||
script = PULL_SHEET.read_text()
|
||||
|
||||
assert "event.target.closest?.('[data-load-review-feedback]')" in script
|
||||
assert "await controller.loadFeedback(item, detail, reviewer" in script
|
||||
assert "reviewer.feedback_state = 'loading'" in script
|
||||
assert "reviewer.feedback_state = 'loaded'" in script
|
||||
assert "reviewer.feedback_state = 'error'" in script
|
||||
assert "result.head_sha !== detail.head_sha" in script
|
||||
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
assert "[data-load-review-feedback] { min-height:44px; width:100%;" in css
|
||||
|
||||
|
||||
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7632,6 +8121,37 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_feedback_is_single_flight_cached_by_head_and_review():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let resolveFeedback;
|
||||
const sheet = createPullSheet({{storage:null, fetchJson:url => {{
|
||||
calls.push(url);
|
||||
return new Promise(resolve => {{ resolveFeedback = resolve; }});
|
||||
}}}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const detail = {{head_sha:'abc1234'}};
|
||||
const reviewer = {{review_id:8, head_sha:'abc1234'}};
|
||||
const first = sheet.loadFeedback(item, detail, reviewer);
|
||||
const concurrent = sheet.loadFeedback(item, detail, reviewer);
|
||||
resolveFeedback({{review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234', comments:[{{id:99}}]}});
|
||||
Promise.all([first, concurrent]).then(async results => {{
|
||||
const cached = await sheet.loadFeedback(item, detail, reviewer);
|
||||
process.stdout.write(JSON.stringify({{same:first === concurrent, results, cached, calls}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["same"] is True
|
||||
assert output["cached"] == output["results"][0]
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -8107,7 +8627,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
@@ -8230,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-v130" 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
|
||||
+1868
-16
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
|
||||
INDEX = MODULE.parent / "index.html"
|
||||
DASHBOARD = MODULE.parent / "dashboard.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
@@ -27,6 +29,11 @@ const startDayControl = {
|
||||
};
|
||||
const startDayHour = {value:'9', disabled:false};
|
||||
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
|
||||
const followingControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||
};
|
||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
||||
const deadlineSnooze = {hidden:true};
|
||||
@@ -40,6 +47,7 @@ const registration = {pushManager:{
|
||||
const feature = createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
||||
@@ -77,6 +85,35 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
followingControl.checked = true;
|
||||
await state.followingChange();
|
||||
process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText}));
|
||||
""")
|
||||
|
||||
assert result["following"] is True
|
||||
assert result["deadline"] is True
|
||||
assert result["startDay"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"]
|
||||
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
|
||||
assert result["text"] == "Following change alerts enabled for this device."
|
||||
|
||||
|
||||
def test_device_settings_render_and_wire_the_following_alert_preference():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'for="push-following"' in index
|
||||
assert 'id="push-following" type="checkbox"' in index
|
||||
assert 'id="push-following-status" role="status" aria-live="polite"' in index
|
||||
assert "followingControl:qs('#push-following')" in dashboard
|
||||
assert "followingStatus:qs('#push-following-status')" in dashboard
|
||||
|
||||
|
||||
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
|
||||
|
||||
@@ -12,6 +12,7 @@ import requests
|
||||
from src import dashboard_auth, gitea_proxy, main
|
||||
from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_following_changes,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
@@ -41,6 +42,98 @@ def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
|
||||
assert second.release_dispatch_lease("worker-b") is True
|
||||
|
||||
|
||||
def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
assert store.following_preferences("session-a") == {"enabled": False}
|
||||
store.set_following_preferences("session-a", enabled=True)
|
||||
|
||||
devices = store.following_notification_devices()
|
||||
assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [
|
||||
("session-a", None)
|
||||
]
|
||||
store.mark_following_delivered("session-a", "revision-fingerprint")
|
||||
assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint"
|
||||
assert store.following_preferences("session-b") == {"enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active", "revoked", "disabled"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_following_preferences("active", enabled=True)
|
||||
store.set_following_preferences("revoked", enabled=True)
|
||||
sent = []
|
||||
|
||||
async def following():
|
||||
return {"items": [{
|
||||
"repository": "secret/project", "kind": "issue", "number": 42,
|
||||
"title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z",
|
||||
"has_unseen_change": True,
|
||||
}]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
sent.append((subscription["endpoint"], json.loads(payload)))
|
||||
|
||||
async def statuses(session_ids):
|
||||
return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids}
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, session_statuses=statuses
|
||||
) == 1
|
||||
assert sent[0][0].endswith("/active")
|
||||
assert sent[0][1] == {
|
||||
"title": "1 watched item changed",
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"route": "#/my-work/following",
|
||||
"tag": sent[0][1]["tag"],
|
||||
"following_count": 1,
|
||||
}
|
||||
assert sent[0][1]["tag"].startswith("stackchain-following-")
|
||||
assert "secret" not in json.dumps(sent[0][1]).lower()
|
||||
assert "acquisition" not in json.dumps(sent[0][1]).lower()
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, session_statuses=statuses
|
||||
) == 0
|
||||
assert len(sent) == 1
|
||||
assert store.subscription_for_session("revoked") is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
result = await main.update_following_notifications(
|
||||
main.FollowingNotificationPayload(enabled=True), request
|
||||
)
|
||||
|
||||
assert result == {"following_enabled": True}
|
||||
assert (await main.push_status(request))["following_enabled"] is True
|
||||
assert store.deadline_preferences("session-a")["enabled"] is False
|
||||
assert store.start_day_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
state_dir = tmp_path / "push-state"
|
||||
previous_umask = os.umask(0)
|
||||
@@ -395,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 = {}
|
||||
@@ -415,6 +561,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
||||
|
||||
@@ -422,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
|
||||
|
||||
@@ -437,8 +584,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def no_wait(_seconds):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps <= 3:
|
||||
if sleeps == 3:
|
||||
if sleeps <= 4:
|
||||
if sleeps == 4:
|
||||
first_tick.set()
|
||||
await first_tick.wait()
|
||||
else:
|
||||
@@ -454,15 +601,19 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def dispatch_start_day(*_args, **_kwargs):
|
||||
calls.append("start-day")
|
||||
|
||||
async def dispatch_following(*_args, **_kwargs):
|
||||
calls.append("following")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(calls) == ["deadline", "start-day", "unread"]
|
||||
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -487,6 +638,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
@@ -513,7 +665,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
||||
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(intervals) == [30.0, 600.0, 600.0]
|
||||
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1282,6 +1434,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"start_day_enabled": False,
|
||||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"following_enabled": False,
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
|
||||
@@ -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": ""},
|
||||
|
||||
@@ -98,7 +98,12 @@ const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true}
|
||||
console.log(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
||||
assert result == {
|
||||
"status": "planned-pending",
|
||||
"date": "2026-08-22",
|
||||
"estimate": 45,
|
||||
"item": {"kind": "issue", "state": "open", "assigned_to_me": True},
|
||||
}
|
||||
|
||||
|
||||
def test_search_week_plan_stages_canonical_details_before_flushing():
|
||||
@@ -122,3 +127,37 @@ console.log(JSON.stringify({outcome,calls}));
|
||||
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
|
||||
["flush"],
|
||||
]
|
||||
|
||||
|
||||
def test_search_week_plan_waits_for_success_callback_before_closing():
|
||||
result = run_planner("""
|
||||
const events=[];
|
||||
const nodes={};
|
||||
function node(extra={}) { return Object.assign({
|
||||
hidden:true,disabled:false,textContent:'',value:'',dataset:{},
|
||||
addEventListener(){},focus(){events.push('focus');},
|
||||
setAttribute(){},querySelectorAll(){return[];}
|
||||
},extra); }
|
||||
nodes['#search-week-plan-sheet']=node();
|
||||
nodes['#search-week-plan-status']=node();
|
||||
nodes['#confirm-search-week-plan']=node();
|
||||
nodes['#search-week-plan-copy']=node();
|
||||
nodes['#search-week-plan-days']=node({dataset:{selected:'2026-08-22'}});
|
||||
nodes['#search-week-plan-estimate']=node({value:'45'});
|
||||
nodes['#plan-search-result']=node(); nodes['#cancel-search-week-plan']=node();
|
||||
const document={querySelector:selector=>nodes[selector],addEventListener(){}};
|
||||
const window={history:{state:{},pushState(value){this.state=value;},back(){events.push('back');nodes['#search-week-plan-sheet'].hidden=true;}},location:{href:'/'},addEventListener(){}};
|
||||
const planner={pending:()=>false,preview:async()=>({days:[{date:'2026-08-22',label:'Sat',planned_minutes:0,capacity_minutes:90,ids:[]}]}),
|
||||
plan:async()=>({status:'planned',date:'2026-08-22',estimate:45,item:{number:42}})};
|
||||
const ui=createSearchWeekPlan.UI({planner,document,window,getDetail:()=>({title:'Issue',number:42}),
|
||||
escapeHtml:value=>value,escapeAttribute:value=>value,announce:message=>events.push(message),
|
||||
onPlanned:async()=>{events.push('callback-start');await Promise.resolve();events.push('callback-end');}});
|
||||
await ui.open(node());
|
||||
await ui.confirm();
|
||||
events.push(nodes['#search-week-plan-sheet'].hidden?'closed':'open');
|
||||
console.log(JSON.stringify({events}));
|
||||
""")
|
||||
|
||||
assert result["events"][-4:] == [
|
||||
"callback-start", "callback-end", "back", "closed"
|
||||
]
|
||||
@@ -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))});
|
||||
|
||||
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -81,6 +81,9 @@ const context = {{
|
||||
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
|
||||
get: async () => state.badgeEnabled,
|
||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
getCounts: async () => ({{...state.badgeCounts}}),
|
||||
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
@@ -186,7 +189,7 @@ async function dispatchPush(payload) {{
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
@@ -194,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-v130" 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-v130" 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-v130" 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
|
||||
@@ -216,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-v130" 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
|
||||
@@ -225,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-v130" 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
|
||||
|
||||
@@ -233,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-v130" 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-v130" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -248,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-v130" 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
|
||||
|
||||
@@ -256,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-v130" 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
|
||||
@@ -266,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-v130" 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-v130" 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
|
||||
@@ -282,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-v130" 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-v130" 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-v130" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -999,6 +1002,55 @@ def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn
|
||||
assert len(result["notifications"]) == 4
|
||||
|
||||
|
||||
def test_background_badge_combines_following_and_updates_without_channel_overwrite():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
await dispatchPush({tag:'stackchain-following-fedcba9876543210', route:'#/my-work/following', following_count:'invalid'});
|
||||
await dispatchPush({tag:'stackchain-following-1111111111111111', route:'#/my-work/following', following_count:1});
|
||||
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:1, unread_count:3});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["appBadges"] == [2, 5, 4]
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1}
|
||||
assert result["clearedAppBadges"] == 0
|
||||
|
||||
|
||||
def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:2}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'updates', count:8}, [], {url:'https://evil.example/dashboard/'});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:-1}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2}
|
||||
assert result["appBadges"] == [5]
|
||||
|
||||
|
||||
def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:0}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:2});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0}
|
||||
assert result["appBadges"] == [2, 2]
|
||||
|
||||
|
||||
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1302,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-v130" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -1333,6 +1385,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/saved-searches.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/following.js",
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
@@ -1414,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",
|
||||
@@ -1796,3 +1850,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell():
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "network"
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_following_push_uses_generic_copy_and_opens_the_following_route():
|
||||
result = run_worker_scenario("""
|
||||
await dispatchPush({
|
||||
title:'leaked title', body:'leaked body', route:'#/my-work/following',
|
||||
tag:'stackchain-following-0123456789abcdef', following_count:2,
|
||||
});
|
||||
await dispatchNotificationClick(
|
||||
'#/my-work/following', '', null, 'stackchain-following-0123456789abcdef'
|
||||
);
|
||||
console.log(JSON.stringify({notifications:state.notifications,opened:state.opened,closed:state.notificationClosed}));
|
||||
""")
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "2 watched items changed",
|
||||
"options": {
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"tag": "stackchain-following-0123456789abcdef",
|
||||
"data": {"route": "#/my-work/following"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/following"]
|
||||
assert result["closed"] is True
|
||||
@@ -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")
|
||||
|
||||
@@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
||||
|
||||
|
||||
@@ -93,6 +93,35 @@ process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:
|
||||
}
|
||||
|
||||
|
||||
def test_adopting_paused_state_for_same_item_preserves_valid_local_break():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
||||
timer.activate('issue:r:42:');
|
||||
timer.startBreak(5);
|
||||
const adopted = timer.adopt('issue:r:42:', 12000, false, [
|
||||
{identity:'issue:r:42:',elapsed_ms:12000},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"adopted": True,
|
||||
"snapshot": {
|
||||
"identity": "issue:r:42:",
|
||||
"elapsed_ms": 12000,
|
||||
"running": False,
|
||||
"break_deadline_at": 400_000,
|
||||
},
|
||||
"pending": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 400_000,
|
||||
"expired": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -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-v130';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v139';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -248,9 +248,9 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
|
||||
assert ".today-summary-actions button { min-height:44px;" in css
|
||||
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
||||
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
|
||||
assert "tests/e2e/test_mobile_today_summary_release.py" in (
|
||||
ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||
).read_text()
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
|
||||
assert "python3 -m pytest tests/e2e -q" in workflow
|
||||
assert (ROOT / "tests/e2e/test_mobile_today_summary_release.py").is_file()
|
||||
|
||||
|
||||
def test_summary_uses_the_human_label_from_recap_feedback_rows():
|
||||
|
||||
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v130" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,73 @@ console.log(JSON.stringify({opened,confirmed,requests,adoptedToday,weekState}));
|
||||
assert result["confirmed"]["today"]["revision"] == 5
|
||||
|
||||
|
||||
def test_reschedule_controller_retries_one_stale_revision_when_active_intent_is_unchanged():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
let attempts=0;
|
||||
const weekState={revision:7,offline_snapshot:false,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||
]};
|
||||
const week={
|
||||
load:async()=>weekState,
|
||||
review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}),
|
||||
adopt:value=>Object.assign(weekState,value),
|
||||
};
|
||||
const api=async(url,options)=>{
|
||||
if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}};
|
||||
if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||
]};
|
||||
const body=JSON.parse(options.body);requests.push(body);attempts++;
|
||||
if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;}
|
||||
return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}
|
||||
]}};
|
||||
};
|
||||
const controller=createReschedule({week,api,getToday:()=>null,adoptToday:()=>{},operationId:()=> 'move-active'});
|
||||
await controller.open('active');
|
||||
let message='',confirmed=null;
|
||||
try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;}
|
||||
console.log(JSON.stringify({message,confirmed,requests}));
|
||||
""")
|
||||
|
||||
assert result["message"] == ""
|
||||
assert result["confirmed"]["today"]["revision"] == 6
|
||||
assert [request["today_revision"] for request in result["requests"]] == [5, 5]
|
||||
assert [request["week_revision"] for request in result["requests"]] == [7, 8]
|
||||
assert result["requests"][0]["operation_id"] == result["requests"][1]["operation_id"]
|
||||
|
||||
|
||||
def test_reschedule_controller_rebases_admitted_move_once_after_stale_revision():
|
||||
result = run_controller("""
|
||||
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 requests=[];let attempts=0;
|
||||
const initialWeek={revision:7,offline_snapshot:false,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||
]};
|
||||
const week={load:async()=>initialWeek,review:()=>({days:[{...initialWeek.days[0],label:'Tue',planned_minutes:0}]}),adopt:()=>{}};
|
||||
const api=async(url,options)=>{
|
||||
if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}};
|
||||
if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:initialWeek.days};
|
||||
const body=JSON.parse(options.body);requests.push(body);attempts++;
|
||||
if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;}
|
||||
return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[]}};
|
||||
};
|
||||
const controller=createReschedule({week,api,adoptToday:()=>{},storage,getLogin:()=> 'timmy',operationId:()=> 'move-active'});
|
||||
await controller.open('active');
|
||||
let message='',confirmed=null;
|
||||
try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;}
|
||||
console.log(JSON.stringify({message,confirmed,requests,remaining:values.size}));
|
||||
""")
|
||||
|
||||
assert result["message"] == ""
|
||||
assert result["confirmed"]["today"]["revision"] == 6
|
||||
assert [request["today_revision"] for request in result["requests"]] == [5, 5]
|
||||
assert [request["week_revision"] for request in result["requests"]] == [7, 8]
|
||||
assert result["remaining"] == 0
|
||||
|
||||
|
||||
def test_reschedule_controller_blocks_offline_full_and_overloaded_days_without_mutation():
|
||||
result = run_controller("""
|
||||
let calls=0;
|
||||
|
||||
@@ -157,7 +157,9 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
|
||||
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
|
||||
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
||||
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
|
||||
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||
workflow = (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||
assert 'python3 -m pytest tests/e2e -q' in workflow
|
||||
assert (ROOT / 'tests/e2e/test_mobile_today_wrap_up_release.py').is_file()
|
||||
|
||||
|
||||
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():
|
||||
|
||||
@@ -90,6 +90,47 @@ async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
|
||||
assert response.json()["items"][0]["work_reasons"] == ["created_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_page_endpoint_preserves_authorship_reason(monkeypatch):
|
||||
async def page_loader(stream, page):
|
||||
assert (stream, page) == ("authored", 2)
|
||||
return {
|
||||
"stream": stream, "page": page, "total": 51, "has_more": False,
|
||||
"items": [{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"}, "labels": [], "assignees": [],
|
||||
"work_reasons": ["authored_by_me"],
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/pulls/41",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/work/authored?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
def test_initial_context_preserves_authored_pull_reason():
|
||||
payload = main._context_payload(
|
||||
{"id": 1, "login": "timmy"},
|
||||
[],
|
||||
[],
|
||||
[{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"}, "labels": [], "assignees": [],
|
||||
"work_reasons": ["authored_by_me"],
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/pulls/41",
|
||||
}],
|
||||
)
|
||||
|
||||
assert payload["pull_requests"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -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