Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c15f7768e4 | ||
|
|
024087d051 | ||
|
|
297ae7550a | ||
|
|
e44f49708a | ||
|
|
3e7d758782 | ||
|
|
185f3f9706 | ||
|
|
aed278629b | ||
|
|
d273de3506 | ||
|
|
fa876e1e38 | ||
|
|
306d6e3056 | ||
|
|
978351dff2 | ||
|
|
553a01217a | ||
|
|
cc74b7b8db | ||
|
|
2acd9431e0 | ||
|
|
4855bc7c8d | ||
|
|
6e19639b19 | ||
|
|
7662cca9e6 | ||
|
|
0682bbcc2c | ||
|
|
1c02dcb217 | ||
|
|
7bb22e3525 | ||
|
|
869543e992 | ||
|
|
c8ffde7d96 | ||
|
|
ed662c3a3a | ||
|
|
8a881419db | ||
|
|
b4f3c6d419 | ||
|
|
fc7a83fbd7 | ||
|
|
22fe5dd1d8 |
No files matched your search
+12
-9
@@ -7,12 +7,15 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
@@ -23,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Build deterministic release bundle
|
||||
run: |
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
@@ -33,7 +36,7 @@ jobs:
|
||||
--commit "$GITHUB_SHA" \
|
||||
--source-date-epoch "$SOURCE_DATE_EPOCH"
|
||||
- name: Upload tested release bundle
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
@@ -44,11 +47,11 @@ jobs:
|
||||
env:
|
||||
STACKCHAIN_RUN_RELEASE_E2E: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with: { python-version: "3.11" }
|
||||
- name: Download assembled release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
@@ -66,9 +69,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Download tested release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
|
||||
@@ -18,7 +18,14 @@ token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
requests, merge assigned pull requests, and submit pull-request reviews. For authored
|
||||
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
|
||||
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
|
||||
and committed as one atomic branch update. The batch preserves every original blob
|
||||
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
|
||||
lines, and verifies the advanced pull head and every final file before reporting success.
|
||||
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
|
||||
fix cannot be partially committed.
|
||||
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
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const reactionLabels = {
|
||||
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
|
||||
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
|
||||
};
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
@@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
function reactionPath(context, commentId, content = '') {
|
||||
return pathFor(context, commentId) + '/reactions' +
|
||||
(content ? '/' + encodeURIComponent(content) : '');
|
||||
}
|
||||
|
||||
const pendingReactions = new Set();
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
@@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
||||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async loadReactions(context, commentId) {
|
||||
return fetchJson(reactionPath(context, commentId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
async setReaction(context, commentId, content, active) {
|
||||
return fetchJson(reactionPath(context, commentId, content), {
|
||||
method: 'PUT',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: Boolean(active) }),
|
||||
});
|
||||
},
|
||||
reactionHtml(state) {
|
||||
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
|
||||
return Object.entries(reactionLabels).map(([content, label]) => {
|
||||
const item = current.get(content) || {};
|
||||
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
|
||||
const selected = item.selected === true;
|
||||
return '<button type="button" data-comment-reaction="' + content + '" ' +
|
||||
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
|
||||
label + ' ' + count + '</button>';
|
||||
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
|
||||
},
|
||||
actionHtml(comment) {
|
||||
return controller.isOwned(comment) ?
|
||||
const owned = controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
|
||||
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
|
||||
'aria-label="React to this comment">React</button>' +
|
||||
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
const actionButton = event.target.closest('[data-comment-action]');
|
||||
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
|
||||
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
|
||||
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
|
||||
if (reactionClose) {
|
||||
const card = reactionClose.closest('.issue-comment');
|
||||
const menu = card?.querySelector('[data-comment-reaction-menu]');
|
||||
const trigger = card?.querySelector('[data-comment-reactions-open]');
|
||||
if (menu && trigger) {
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (reactionButton || reactionTrigger) {
|
||||
const control = reactionButton || reactionTrigger;
|
||||
const card = control.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const menu = card?.querySelector('[data-comment-reaction-menu]');
|
||||
const trigger = card?.querySelector('[data-comment-reactions-open]');
|
||||
if (!commentId || !menu || !trigger) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to react.';
|
||||
return;
|
||||
}
|
||||
if (pendingReactions.has(commentId)) return;
|
||||
pendingReactions.add(commentId);
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (reactionButton) {
|
||||
const content = reactionButton.dataset.commentReaction;
|
||||
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
|
||||
const state = await controller.setReaction(surface.context, commentId, content, active);
|
||||
menu.innerHTML = controller.reactionHtml(state);
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.focus();
|
||||
surface.status.textContent = 'Reaction updated.';
|
||||
} else {
|
||||
surface.status.textContent = 'Loading reactions…';
|
||||
const state = await controller.loadReactions(surface.context, commentId);
|
||||
menu.innerHTML = controller.reactionHtml(state);
|
||||
menu.hidden = false;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
surface.status.textContent = '';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
|
||||
} finally {
|
||||
pendingReactions.delete(commentId);
|
||||
control.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = actionButton;
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
@@ -24,7 +24,11 @@
|
||||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try { return await save; }
|
||||
try {
|
||||
const saved = await save;
|
||||
onChange();
|
||||
return saved;
|
||||
}
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@
|
||||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
onChange();
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
@@ -97,7 +98,10 @@
|
||||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, scope:recordScope, ...normalized,
|
||||
updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -122,6 +126,38 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact || draftScope !== 'conversation') return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
(record.scope && record.scope !== 'conversation') ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
const updatedAt = Number(record.updatedAt || 0);
|
||||
if (record.kind === 'update') {
|
||||
const notificationId = Number(record.notificationId || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
|
||||
photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
|
||||
}];
|
||||
}
|
||||
const repository = String(record.repository || '');
|
||||
const number = Number(record.number || 0);
|
||||
if (!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1) return [];
|
||||
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label, repository, number,
|
||||
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
@@ -81,4 +81,37 @@ function createConversationPager({ loadPage }) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderConversationComment(comment, controller, escapeHtml, formatTime, renderMarkdown) {
|
||||
const actions = controller?.actionHtml?.(comment) || '';
|
||||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
|
||||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
function createConversationRenderers({qs,renderComment,updateReadPosition,getSelectedUpdate}) {
|
||||
function status(kind, comments, total) {
|
||||
qs('#' + kind + '-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
function paint(kind, state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#' + kind + '-comments').innerHTML = comments.length ? comments.map(comment =>
|
||||
kind === 'pull' ? '<div class="pull-comment-card">' + renderComment(comment,controller) + '</div>' :
|
||||
renderComment(comment,controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-' + kind + '-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
status(kind,comments,state?.total);
|
||||
if (kind === 'update') {
|
||||
const newest = qs('#update-comments .issue-comment:last-child');
|
||||
updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest);
|
||||
}
|
||||
}
|
||||
return {
|
||||
paintIssueConversation:(state,controller)=>paint('issue',state,controller),
|
||||
paintPullConversation:(state,controller)=>paint('pull',state,controller),
|
||||
paintUpdateConversation:(state,controller)=>paint('update',state,controller),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
|
||||
@@ -146,6 +146,12 @@ button:hover { filter: brightness(1.15); }
|
||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; }
|
||||
.comment-reactions > button { min-height:44px; min-width:72px; }
|
||||
.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; }
|
||||
.comment-reaction-menu[hidden] { display:none; }
|
||||
.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; }
|
||||
.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
@@ -258,6 +264,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-badge-control { min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.push-quiet-hours-times { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; width:100%; }
|
||||
.push-quiet-hours-times label { min-width:0; }
|
||||
.offline-work-controls .push-quiet-hours-times input { box-sizing:border-box; width:100%; min-width:0; height:44px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.offline-today-readiness[hidden] { display:none; }
|
||||
@@ -759,6 +768,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.pull-feedback-code-actions, .pull-feedback-file-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.pull-feedback-code-actions button, .pull-feedback-file-actions button { min-height:44px; }
|
||||
.pull-feedback-suggestion-preview { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #31577f; border-radius:10px; background:#07101e; }
|
||||
.pull-feedback-suggestion-preview[hidden] { display:none; }
|
||||
.pull-feedback-suggestion-preview h5 { margin:0; }
|
||||
.pull-feedback-suggestion-change { display:grid; gap:8px; min-width:0; }
|
||||
.pull-feedback-suggestion-change > div { min-width:0; }
|
||||
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
|
||||
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
|
||||
.pull-feedback-batch-review[hidden] { display:none; }
|
||||
.pull-feedback-batch-review h5 { margin:0; }
|
||||
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
|
||||
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
|
||||
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
|
||||
+59
-51
@@ -1,5 +1,8 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
|
||||
await workspaceLifecycle.optionalReady;
|
||||
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
|
||||
window.stackchainProgressiveMyWork?.stop();
|
||||
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();
|
||||
@@ -320,15 +323,17 @@
|
||||
let offlineWorkMode = false;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
|
||||
const hasProgressiveWorkFilter = WORK_FILTERS.includes(progressiveWorkHandoff?.selectedFilter);
|
||||
let selectedWorkFilter = hasProgressiveWorkFilter ? progressiveWorkHandoff.selectedFilter : 'all';
|
||||
let selectedFiledView = 'needs-review';
|
||||
let selectedWorkMilestone = 'all';
|
||||
let queueFindQuery = '';
|
||||
let savedWorkFilter = null;
|
||||
let launchFilterResolved = false;
|
||||
let launchFilterResolved = hasProgressiveWorkFilter;
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
if (!hasProgressiveWorkFilter && ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
selectedWorkFilter = savedFilter;
|
||||
savedWorkFilter = savedFilter;
|
||||
launchFilterResolved = true;
|
||||
@@ -777,6 +782,7 @@
|
||||
try {
|
||||
const attachments = await searchReplyAttachmentController.serialize();
|
||||
await searchReplyDraftStore.save(target, attachments);
|
||||
void refreshPhotoDraftInbox();
|
||||
} catch (error) {
|
||||
qs('#search-preview-reply-status').textContent = error?.message ||
|
||||
'Photos could not be saved. They remain in this preview; retry before leaving.';
|
||||
@@ -1002,6 +1008,7 @@
|
||||
} });
|
||||
const conversationPhotoDrafts = createConversationPhotoDrafts({
|
||||
store:conversationPhotoDraftStore,
|
||||
onChange:() => { void refreshPhotoDraftInbox(); },
|
||||
lanes:{
|
||||
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
|
||||
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
|
||||
@@ -1310,6 +1317,10 @@
|
||||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
const photoDraftInbox = createPhotoDraftInbox({
|
||||
conversation:conversationPhotoDraftStore,
|
||||
search:searchReplyDraftStore,
|
||||
});
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
let offlineStorageReady = await offlineWorkStore.ready();
|
||||
@@ -3401,12 +3412,17 @@
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
}));
|
||||
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||||
return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||||
);
|
||||
}
|
||||
|
||||
function refreshMyWorkView({ reconcileSession = true } = {}) {
|
||||
async function refreshPhotoDraftInbox() {
|
||||
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false }); }
|
||||
catch (_error) { /* Local photo inventory must not break My Work. */ }
|
||||
}
|
||||
|
||||
function refreshMyWorkView({ reconcileSession = true, refreshFirstTask = true } = {}) {
|
||||
lastDrafts = listDrafts();
|
||||
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
|
||||
const partitioned = laterWork.partition(actionableMyWork, {
|
||||
@@ -3460,7 +3476,7 @@
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileFirstTask.refresh();
|
||||
if (refreshFirstTask) mobileFirstTask.refresh();
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -3579,7 +3595,7 @@
|
||||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||||
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
'<span class="draft-preview">' + escapeHtml(photoDraftInbox.description(item)) + '</span>' + state + attempt +
|
||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
@@ -3618,6 +3634,9 @@
|
||||
await dFS.open(item.capture_id, item.ready ? button : null);
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
|
||||
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
|
||||
}
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
};
|
||||
});
|
||||
@@ -3736,6 +3755,7 @@
|
||||
}
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'photo-reply') await photoDraftInbox.discard(item);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
lastDrafts = listDrafts();
|
||||
const count = qs('[data-work-count="draft"]');
|
||||
@@ -4097,46 +4117,17 @@
|
||||
toggle?.focus();
|
||||
}
|
||||
|
||||
function renderIssueComment(comment, controller = commentActions) {
|
||||
const actions = controller?.actionHtml?.(comment) || '';
|
||||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||||
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
|
||||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
function paintIssueConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#issue-comments').innerHTML = comments.length ?
|
||||
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#issue-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
|
||||
function paintUpdateConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#update-comments').innerHTML = comments.length ?
|
||||
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#update-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
const newest = qs('#update-comments .issue-comment:last-child');
|
||||
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
|
||||
}
|
||||
|
||||
function paintPullConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
|
||||
'<div class="pull-comment-card">' + renderIssueComment(comment, controller) + '</div>'
|
||||
).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#pull-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
const {paintIssueConversation,paintPullConversation,paintUpdateConversation}=createConversationRenderers({
|
||||
qs,renderComment:(comment,controller)=>renderConversationComment(
|
||||
comment,controller,escapeHtml,fmt,renderMarkdown),
|
||||
updateReadPosition,getSelectedUpdate:()=>selectedUpdate,
|
||||
});
|
||||
|
||||
function commentSurface(selector) {
|
||||
if (selector === '#search-preview-comments') return {
|
||||
context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}, pager:searchPreview.commentPager(),
|
||||
render:state=>showSearchConversationWithActions(state), status:qs('#search-preview-conversation-status'),
|
||||
};
|
||||
if (selector === '#issue-comments') return {
|
||||
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
|
||||
render:renderIssueConversation, status:qs('#issue-sheet-status'),
|
||||
@@ -5522,6 +5513,7 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
@@ -5664,8 +5656,14 @@
|
||||
commandSearch.setScope(scope);
|
||||
}
|
||||
let searchPreviewDetail = null;
|
||||
const renderSearchConversation = conversation =>
|
||||
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
|
||||
const showSearchConversationWithActions = createSearchPreviewConversationActions({
|
||||
hydrator:actionHydrator, rootNode:qs('#search-preview-comments'),
|
||||
retry:qs('#retry-search-preview-comment-actions'),
|
||||
paint:(conversation,controller)=>renderSearchPreviewConversation(
|
||||
conversation,document,escapeHtml,fmt,renderMarkdown,controller),
|
||||
wire:controller=>wireCommentActions('#search-preview-comments',controller),
|
||||
});
|
||||
const renderSearchConversation = conversation => { void showSearchConversationWithActions(conversation); };
|
||||
function renderSearchPreview(state) {
|
||||
followingQueue.preview(state);
|
||||
const sheet = qs('#search-preview');
|
||||
@@ -5878,6 +5876,7 @@
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
schedule: callback => requestAnimationFrame(callback),
|
||||
});
|
||||
const searchModal = createMobileSearchModal({document});
|
||||
function closeSearchPreview(navigate = true) {
|
||||
if (searchPreviewReturnKind === 'following') {
|
||||
searchPreview.close();
|
||||
@@ -5893,7 +5892,7 @@
|
||||
qs('#cmd-palette').classList.add('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||
renderCommands(qs('#cmd-input').value);
|
||||
qs('#cmd-input').focus();
|
||||
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
|
||||
}
|
||||
const next = searchPreview.next;
|
||||
async function openPreviewWorkInMyWork(detail) {
|
||||
@@ -5915,6 +5914,7 @@
|
||||
mobileSearchViewport.rememberScroll();
|
||||
searchPreviewReturnKind = 'search';
|
||||
searchPreview.open(item.result).catch(() => {});
|
||||
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
|
||||
taskOverlayHistory.open('search-preview', {
|
||||
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
|
||||
});
|
||||
@@ -5961,7 +5961,10 @@
|
||||
mobileSearchViewport.open();
|
||||
mobileSearchViewport.restoreScroll();
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||
qs('#cmd-input').focus();
|
||||
searchModal.activate(qs('#cmd-palette'), {
|
||||
opener:document.activeElement,
|
||||
initialFocus:qs('#cmd-input'),
|
||||
});
|
||||
commandSelection = -1;
|
||||
renderCommands(qs('#cmd-input').value);
|
||||
}
|
||||
@@ -5980,6 +5983,7 @@
|
||||
else {
|
||||
searchPreview.close();
|
||||
mobileSearchViewport.close();
|
||||
searchModal.deactivate();
|
||||
}
|
||||
searchPreviewReturnKind = null;
|
||||
}
|
||||
@@ -5987,7 +5991,7 @@
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
mobileSearchViewport.close();
|
||||
qs('#open-palette').focus();
|
||||
searchModal.deactivate();
|
||||
}
|
||||
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
|
||||
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
|
||||
@@ -6015,6 +6019,10 @@
|
||||
if (detail?.scope) applySearchScope(detail.scope);
|
||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||||
searchPreviewReturnKind = 'search';
|
||||
searchModal.activate(qs('#search-preview'), {
|
||||
opener:document.activeElement,
|
||||
initialFocus:qs('#close-search-preview'),
|
||||
});
|
||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||||
}
|
||||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||
|
||||
+40
-2
@@ -224,6 +224,12 @@
|
||||
<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-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
|
||||
<div class="push-quiet-hours-times">
|
||||
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
|
||||
<label for="push-quiet-end">Until <input id="push-quiet-end" type="time" value="07:00" /></label>
|
||||
</div>
|
||||
<span class="small" id="push-quiet-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>
|
||||
@@ -780,7 +786,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div id="cmd-palette" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
<div class="cmd-palette-header-actions">
|
||||
@@ -901,6 +907,7 @@
|
||||
<div id="search-preview-comments"></div>
|
||||
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-search-preview-comment-actions" type="button" hidden>Retry comment tools</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>
|
||||
@@ -1741,7 +1748,35 @@
|
||||
<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>
|
||||
<div class="pull-feedback-code-actions">
|
||||
<button id="review-pull-feedback-suggestion" type="button" hidden>Review suggested change</button>
|
||||
<button id="make-pull-feedback-fix" type="button" hidden>Edit full file</button>
|
||||
</div>
|
||||
<section class="pull-feedback-suggestion-preview" id="pull-feedback-suggestion-preview" aria-labelledby="pull-feedback-suggestion-heading" hidden>
|
||||
<h5 id="pull-feedback-suggestion-heading">Suggested change</h5>
|
||||
<div class="pull-feedback-suggestion-change">
|
||||
<div><span class="small">Current line</span><pre id="pull-feedback-suggestion-before"></pre></div>
|
||||
<div><span class="small">Suggested replacement</span><pre id="pull-feedback-suggestion-after"></pre></div>
|
||||
</div>
|
||||
<label for="pull-feedback-suggestion-message">Commit message</label>
|
||||
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
|
||||
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
|
||||
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
|
||||
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
|
||||
<ul id="pull-feedback-batch-items"></ul>
|
||||
<label for="pull-feedback-batch-message">Commit message</label>
|
||||
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
|
||||
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
|
||||
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
|
||||
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
|
||||
<label for="pull-feedback-file-content">File content</label>
|
||||
@@ -2229,6 +2264,7 @@
|
||||
<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>
|
||||
<script src="static/photo-draft-inbox.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
@@ -2248,6 +2284,7 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/progressive-my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
@@ -2338,6 +2375,7 @@
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-search-modal.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
(function (root, factory) {
|
||||
const createMobileSearchModal = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
|
||||
else root.createMobileSearchModal = createMobileSearchModal;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const FOCUSABLE = [
|
||||
'a[href]', 'button', 'input', 'select', 'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
|
||||
].join(',');
|
||||
|
||||
return function createMobileSearchModal(options) {
|
||||
const documentRef = options.document;
|
||||
const backgrounds = Array.from(options.backgrounds || [
|
||||
documentRef.querySelector?.('body > header'),
|
||||
documentRef.querySelector?.('main'),
|
||||
documentRef.querySelector?.('#mobile-task-dock'),
|
||||
]).filter(Boolean);
|
||||
let surface = null;
|
||||
let opener = null;
|
||||
let priorInert = null;
|
||||
|
||||
function focusable() {
|
||||
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
|
||||
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
|
||||
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
|
||||
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
|
||||
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function focus(element) {
|
||||
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (!surface || event.key !== 'Tab') return;
|
||||
const controls = focusable();
|
||||
if (!controls.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && documentRef.activeElement === first) {
|
||||
event.preventDefault();
|
||||
focus(last);
|
||||
} else if (!event.shiftKey && documentRef.activeElement === last) {
|
||||
event.preventDefault();
|
||||
focus(first);
|
||||
} else if (!controls.includes(documentRef.activeElement)) {
|
||||
event.preventDefault();
|
||||
focus(event.shiftKey ? last : first);
|
||||
}
|
||||
}
|
||||
|
||||
function transition(nextSurface, transitionOptions = {}) {
|
||||
surface = nextSurface;
|
||||
focus(transitionOptions.initialFocus || focusable()[0]);
|
||||
}
|
||||
|
||||
function activate(nextSurface, activateOptions = {}) {
|
||||
if (!surface) {
|
||||
opener = activateOptions.opener || documentRef.activeElement || null;
|
||||
priorInert = backgrounds.map(element => element.inert === true);
|
||||
backgrounds.forEach(element => { element.inert = true; });
|
||||
}
|
||||
transition(nextSurface, activateOptions);
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!surface) return;
|
||||
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
|
||||
surface = null;
|
||||
priorInert = null;
|
||||
const restore = opener;
|
||||
opener = null;
|
||||
focus(restore);
|
||||
}
|
||||
|
||||
documentRef.addEventListener('keydown', onKeydown);
|
||||
return { activate, transition, deactivate, current: () => surface };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
(function (root, factory) {
|
||||
const createPhotoDraftInbox = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
|
||||
else root.createPhotoDraftInbox = createPhotoDraftInbox;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function targetKey(route) {
|
||||
if (route?.kind === 'update') {
|
||||
const notificationId = Number(route.notification_id || 0);
|
||||
return notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
|
||||
const repository = String(route?.repository || '');
|
||||
const number = Number(route?.number || 0);
|
||||
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
|
||||
kind + ':' + repository.toLowerCase() + '#' + number : '';
|
||||
}
|
||||
|
||||
function removeTarget(item) {
|
||||
const route = item?.route || {};
|
||||
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
|
||||
return {
|
||||
kind:route.kind === 'search' ? route.target_kind : route.kind,
|
||||
repository:route.repository,
|
||||
number:Number(route.number),
|
||||
};
|
||||
}
|
||||
|
||||
return function createPhotoDraftInbox({ conversation, search }) {
|
||||
let items = [];
|
||||
|
||||
async function refresh(existing = []) {
|
||||
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
|
||||
const results = await Promise.allSettled([
|
||||
conversation?.list?.() || [],
|
||||
search?.list?.() || [],
|
||||
]);
|
||||
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
|
||||
.filter(item => {
|
||||
const key = targetKey(item?.route);
|
||||
if (!key || occupied.has(key)) return false;
|
||||
occupied.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
|
||||
String(left.id || '').localeCompare(String(right.id || '')));
|
||||
return items.slice();
|
||||
}
|
||||
|
||||
function list() { return items.slice(); }
|
||||
|
||||
async function discard(item) {
|
||||
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
|
||||
return false;
|
||||
}
|
||||
const store = item.photo_store === 'search' ? search : conversation;
|
||||
if (!store?.remove) return false;
|
||||
await store.remove(removeTarget(item));
|
||||
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
|
||||
return true;
|
||||
}
|
||||
|
||||
function description(item) {
|
||||
return item?.kind === 'photo-reply' ?
|
||||
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
|
||||
(item?.preview || 'Unfinished draft');
|
||||
}
|
||||
|
||||
function searchTarget(item) {
|
||||
const route = item?.route || {};
|
||||
return {
|
||||
kind:route.target_kind, repository:route.repository,
|
||||
number:route.number, title:item?.title,
|
||||
};
|
||||
}
|
||||
|
||||
return { refresh, list, discard, description, searchTarget };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||
const list = document.querySelector('#my-work-list');
|
||||
const status = document.querySelector('#my-work-status');
|
||||
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||
const listeners = [];
|
||||
let items = [];
|
||||
let active = 'all';
|
||||
let stopped = false;
|
||||
let selectedByUser = false;
|
||||
const deferredQueues = {
|
||||
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
};
|
||||
|
||||
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
const matches = (item, filter) =>
|
||||
filter === 'review' ? item.is_review :
|
||||
filter === 'update' ? item.has_update :
|
||||
filter === 'attention' ? item.needs_attention :
|
||||
filter === 'filed' ? item.is_filed :
|
||||
filter === 'authored' ? item.is_authored :
|
||||
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
|
||||
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
|
||||
const updateCounts = () => {
|
||||
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
|
||||
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
|
||||
const element = document.querySelector('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = String(count);
|
||||
});
|
||||
};
|
||||
const render = () => {
|
||||
if (stopped || !list) return;
|
||||
const visible = visibleItems();
|
||||
list.innerHTML = deferredQueues[active] ?
|
||||
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map(item => {
|
||||
const href = safeUrl(item.url);
|
||||
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
||||
const context = escapeHtml(item.key || '');
|
||||
const reason = escapeHtml(item.reason || 'Assigned to you');
|
||||
return '<article class="my-work-card progressive-my-work-card">' +
|
||||
(href ? '<a class="my-work-card-main" href="' + escapeHtml(href) + '">' : '<div class="my-work-card-main">') +
|
||||
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
|
||||
(href ? '</a>' : '</div>') + '</article>';
|
||||
}).join('') : '<div class="muted">No work in this queue.</div>';
|
||||
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
|
||||
};
|
||||
filters.forEach(button => {
|
||||
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
|
||||
button.addEventListener('click', listener);
|
||||
listeners.push([button, listener]);
|
||||
});
|
||||
|
||||
return {
|
||||
handoff() {
|
||||
return { selectedFilter:selectedByUser ? active : null };
|
||||
},
|
||||
async start() {
|
||||
if (status) status.textContent = 'Loading assigned work…';
|
||||
try {
|
||||
const snapshot = await fetchSnapshot();
|
||||
if (stopped) return false;
|
||||
const context = snapshot?.context || snapshot || {};
|
||||
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||
updateCounts();
|
||||
render();
|
||||
const assigned = items.filter(item => item.is_assigned).length;
|
||||
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
||||
document,
|
||||
fetchSnapshot: async () => {
|
||||
const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} });
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
void window.stackchainProgressiveMyWork.start();
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
|
||||
+227
-1
@@ -413,11 +413,49 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
|
||||
const save = () => {
|
||||
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
|
||||
};
|
||||
const clearSuggestion = () => {
|
||||
qs('#pull-feedback-suggestion-preview').hidden = true;
|
||||
qs('#cancel-pull-feedback-suggestion').hidden = true;
|
||||
qs('#stage-pull-feedback-suggestion').hidden = true;
|
||||
qs('#apply-pull-feedback-suggestion').hidden = true;
|
||||
if (active) active.suggestion = null;
|
||||
};
|
||||
const suggestedFile = (file, suggestion) => {
|
||||
const line = Number(suggestion?.line);
|
||||
if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null;
|
||||
const trailingNewline = file.content.endsWith('\n');
|
||||
const lines = file.content.split('\n');
|
||||
if (trailingNewline) lines.pop();
|
||||
if (line > lines.length) return null;
|
||||
const before = lines[line - 1];
|
||||
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
|
||||
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
|
||||
};
|
||||
const renderBatchButton = () => {
|
||||
const count = active?.batch ? Object.values(active.batch)
|
||||
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
|
||||
const button = qs('#review-pull-feedback-batch');
|
||||
button.hidden = count < 2;
|
||||
button.textContent = 'Review fixes (' + count + ')';
|
||||
};
|
||||
const rebuildBatchFile = entry => {
|
||||
const trailingNewline = entry.file.content.endsWith('\n');
|
||||
const lines = entry.file.content.split('\n');
|
||||
if (trailingNewline) lines.pop();
|
||||
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
|
||||
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
|
||||
for (const suggestion of ordered) {
|
||||
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
|
||||
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
|
||||
}
|
||||
return lines.join('\n') + (trailingNewline ? '\n' : '');
|
||||
};
|
||||
const render = () => {
|
||||
if (!active) return;
|
||||
const items = comments();
|
||||
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
|
||||
const comment = current();
|
||||
if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion();
|
||||
const response = active.state.responses[String(comment.id)] || {};
|
||||
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
|
||||
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
|
||||
@@ -434,6 +472,14 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
|
||||
qs('#request-feedback-review').hidden = active.state.posted !== true;
|
||||
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
|
||||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
|
||||
const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' &&
|
||||
active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha &&
|
||||
comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string';
|
||||
qs('#review-pull-feedback-suggestion').hidden = !canSuggest;
|
||||
qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false;
|
||||
if (canSuggest && globalThis.navigator?.onLine === false) {
|
||||
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
|
||||
}
|
||||
const found = focusPullFile(doc, comment.path);
|
||||
qs('#pull-feedback-context').textContent = found ?
|
||||
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
|
||||
@@ -474,17 +520,171 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
|
||||
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
|
||||
if (!detail || !item || !reviewer) return;
|
||||
const login = getLogin?.() || '';
|
||||
active = { item, detail, reviewer, login,
|
||||
active = { item, detail, reviewer, login, batch:{},
|
||||
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
|
||||
panel.hidden = false;
|
||||
qs('#pull-feedback-file-editor').hidden = true;
|
||||
qs('#pull-feedback-suggestion-preview').hidden = true;
|
||||
qs('#pull-feedback-batch-review').hidden = true;
|
||||
qs('#cancel-pull-feedback-fix').hidden = true;
|
||||
qs('#commit-pull-feedback-fix').hidden = true;
|
||||
active.file = null;
|
||||
active.suggestion = null;
|
||||
renderBatchButton();
|
||||
qs('#pull-feedback-status').textContent = active.state.posted ?
|
||||
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
|
||||
render();
|
||||
});
|
||||
qs('#review-pull-feedback-suggestion').addEventListener('click', async () => {
|
||||
const comment = current();
|
||||
if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) {
|
||||
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
|
||||
return;
|
||||
}
|
||||
const button = qs('#review-pull-feedback-suggestion');
|
||||
button.disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Loading the current line for review…';
|
||||
try {
|
||||
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
|
||||
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
|
||||
const change = suggestedFile(file, comment.suggestion);
|
||||
if (!change) throw new Error('The suggested line is no longer available.');
|
||||
active.suggestion = { file, content:change.content, commentId:comment.id };
|
||||
qs('#pull-feedback-suggestion-before').textContent = change.before;
|
||||
qs('#pull-feedback-suggestion-after').textContent = change.after;
|
||||
qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion';
|
||||
qs('#pull-feedback-file-editor').hidden = true;
|
||||
qs('#pull-feedback-suggestion-preview').hidden = false;
|
||||
qs('#cancel-pull-feedback-suggestion').hidden = false;
|
||||
qs('#stage-pull-feedback-suggestion').hidden = false;
|
||||
qs('#apply-pull-feedback-suggestion').hidden = false;
|
||||
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
|
||||
qs('#apply-pull-feedback-suggestion').focus();
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.';
|
||||
button.focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => {
|
||||
clearSuggestion();
|
||||
qs('#review-pull-feedback-suggestion').focus();
|
||||
});
|
||||
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
|
||||
if (!active?.suggestion) return;
|
||||
const pending = active.suggestion;
|
||||
const path = pending.file.path;
|
||||
const stagedCount = Object.values(active.batch)
|
||||
.reduce((total, file) => total + file.suggestions.length, 0);
|
||||
if (stagedCount >= 8) {
|
||||
qs('#pull-feedback-status').textContent = 'Commit this bounded batch before staging more suggestions.';
|
||||
return;
|
||||
}
|
||||
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
|
||||
if (entry.file.blob_sha !== pending.file.blob_sha ||
|
||||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
|
||||
const comment = current();
|
||||
entry.suggestions.push({
|
||||
line:Number(comment.suggestion.line), content:comment.suggestion.content,
|
||||
commentId:pending.commentId,
|
||||
});
|
||||
entry.content = rebuildBatchFile(entry);
|
||||
if (entry.content === null) {
|
||||
entry.suggestions.pop();
|
||||
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
|
||||
return;
|
||||
}
|
||||
active.batch[path] = entry;
|
||||
clearSuggestion();
|
||||
renderBatchButton();
|
||||
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
|
||||
qs('#next-pull-feedback').focus();
|
||||
});
|
||||
qs('#review-pull-feedback-batch').addEventListener('click', () => {
|
||||
if (!active?.batch) return;
|
||||
const list = qs('#pull-feedback-batch-items');
|
||||
list.replaceChildren(...Object.values(active.batch).map(entry => {
|
||||
const item = doc.createElement('li');
|
||||
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
|
||||
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
|
||||
return item;
|
||||
}));
|
||||
qs('#pull-feedback-batch-review').hidden = false;
|
||||
qs('#cancel-pull-feedback-batch').hidden = false;
|
||||
qs('#commit-pull-feedback-batch').hidden = false;
|
||||
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
|
||||
qs('#commit-pull-feedback-batch').focus();
|
||||
});
|
||||
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
|
||||
qs('#pull-feedback-batch-review').hidden = true;
|
||||
qs('#cancel-pull-feedback-batch').hidden = true;
|
||||
qs('#commit-pull-feedback-batch').hidden = true;
|
||||
qs('#review-pull-feedback-batch').focus();
|
||||
});
|
||||
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
|
||||
if (!active?.batch) return;
|
||||
const entries = Object.values(active.batch);
|
||||
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
|
||||
const message = qs('#pull-feedback-batch-message').value.trim();
|
||||
if (count < 2 || !message) return;
|
||||
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
|
||||
const button = qs('#commit-pull-feedback-batch');
|
||||
button.disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
|
||||
try {
|
||||
const result = await controller.commitFeedbackBatch(active.item, {
|
||||
files:entries.map(entry => ({
|
||||
path:entry.file.path, content:entry.content,
|
||||
expectedBlobSha:entry.file.blob_sha,
|
||||
})),
|
||||
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
|
||||
});
|
||||
active.detail.head_sha = result.head_sha;
|
||||
active.state.headSha = result.head_sha;
|
||||
active.batch = {};
|
||||
save();
|
||||
qs('#pull-feedback-batch-review').hidden = true;
|
||||
qs('#cancel-pull-feedback-batch').hidden = true;
|
||||
qs('#commit-pull-feedback-batch').hidden = true;
|
||||
renderBatchButton();
|
||||
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
|
||||
render();
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
|
||||
if (!active?.suggestion) return;
|
||||
const message = qs('#pull-feedback-suggestion-message').value.trim();
|
||||
if (!message) {
|
||||
qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.';
|
||||
return;
|
||||
}
|
||||
if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return;
|
||||
const button = qs('#apply-pull-feedback-suggestion');
|
||||
button.disabled = true;
|
||||
const pending = active.suggestion;
|
||||
qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…';
|
||||
try {
|
||||
const result = await controller.commitFeedbackFix(active.item, {
|
||||
path:pending.file.path, content:pending.content, message,
|
||||
expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha,
|
||||
});
|
||||
active.detail.head_sha = result.head_sha;
|
||||
active.state.headSha = result.head_sha;
|
||||
clearSuggestion();
|
||||
save();
|
||||
qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.';
|
||||
render();
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
|
||||
const comment = current();
|
||||
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
|
||||
@@ -495,6 +695,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
|
||||
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
|
||||
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
|
||||
active.file = file;
|
||||
clearSuggestion();
|
||||
qs('#pull-feedback-file-path').textContent = file.path;
|
||||
qs('#pull-feedback-file-content').value = file.content;
|
||||
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
|
||||
@@ -858,6 +1059,31 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
|
||||
}).finally(() => { feedbackFixRequest = null; });
|
||||
return feedbackFixRequest;
|
||||
},
|
||||
commitFeedbackBatch(item, draft) {
|
||||
if (feedbackFixRequest) return feedbackFixRequest;
|
||||
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({
|
||||
files:draft.files.map(file => ({
|
||||
path:file.path, content:file.content,
|
||||
expected_blob_sha:file.expectedBlobSha,
|
||||
})),
|
||||
suggestion_count:draft.suggestionCount,
|
||||
message:draft.message, expected_head_sha:draft.expectedHeadSha,
|
||||
}),
|
||||
}).then(result => {
|
||||
const expectedPaths = draft.files.map(file => file.path);
|
||||
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
|
||||
!Array.isArray(result.paths) || result.paths.length !== expectedPaths.length ||
|
||||
result.paths.some((path, index) => path !== expectedPaths[index]) ||
|
||||
result.suggestion_count !== draft.suggestionCount) {
|
||||
throw new Error('The suggestion batch was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { feedbackFixRequest = null; });
|
||||
return feedbackFixRequest;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
||||
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
||||
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
||||
quietStatus = globalThis.document?.querySelector('#push-quiet-status'),
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
@@ -302,6 +306,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeQuietHours() {
|
||||
if (!quietControl) return false;
|
||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
await fetchJson('api/v1/push-subscription/quiet-hours', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
enabled:quietControl.checked,
|
||||
start:quietStart?.value || '22:00',
|
||||
end:quietEnd?.value || '07:00',
|
||||
timezone,
|
||||
}),
|
||||
});
|
||||
configuration.quiet_hours_enabled = quietControl.checked;
|
||||
configuration.quiet_hours_start = quietStart?.value || '22:00';
|
||||
configuration.quiet_hours_end = quietEnd?.value || '07:00';
|
||||
quietStatus.textContent = quietControl.checked
|
||||
? `Routine alerts paused from ${configuration.quiet_hours_start} to ${configuration.quiet_hours_end} local time.`
|
||||
: 'Routine alert quiet hours are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
quietStatus.textContent = 'Could not save quiet hours. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
@@ -340,6 +374,9 @@
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
quietControl?.addEventListener('change', changeQuietHours);
|
||||
quietStart?.addEventListener('change', changeQuietHours);
|
||||
quietEnd?.addEventListener('change', changeQuietHours);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
@@ -347,6 +384,7 @@
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
if (quietControl) quietControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
@@ -354,6 +392,9 @@
|
||||
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 (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
|
||||
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
||||
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
||||
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);
|
||||
@@ -367,8 +408,11 @@
|
||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
|
||||
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
||||
: 'Routine alert quiet hours are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -148,7 +148,7 @@
|
||||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown, actions) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
@@ -162,11 +162,12 @@
|
||||
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' + (isNew(comment) ? ' new-since-review' : '') +
|
||||
'<article class="search-preview-comment issue-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)) : '') +
|
||||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||||
(actions?.actionHtml?.(comment) || '') +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
@@ -181,6 +182,19 @@
|
||||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||||
};
|
||||
root.createSearchPreviewConversationActions = ({hydrator,rootNode,retry,paint,wire}) => {
|
||||
let latest = null;
|
||||
const show = conversation => {
|
||||
latest = conversation;
|
||||
if (!conversation) {
|
||||
paint(null, null);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return hydrator.show({root:rootNode,state:conversation,paint,retry,wire});
|
||||
};
|
||||
retry.addEventListener('click', () => { if (latest) void show(latest); });
|
||||
return show;
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
const input = document.querySelector('#search-preview-reply');
|
||||
@@ -417,7 +431,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
const conversationPager = {
|
||||
snapshot() {
|
||||
return {...(conversation || {}), comments:(conversation?.comments || []).map(comment => ({...comment}))};
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id) || !conversation) return conversationPager.snapshot();
|
||||
conversation = {...conversation, comments:(conversation.comments || []).map(existing =>
|
||||
existing.id === comment.id ? {...comment} : existing)};
|
||||
publish({status:'ready', item:current, detail:current, conversation});
|
||||
return conversationPager.snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
if (!conversation) return conversationPager.snapshot();
|
||||
conversation = {...conversation, comments:(conversation.comments || []).filter(comment => comment.id !== commentId)};
|
||||
publish({status:'ready', item:current, detail:current, conversation});
|
||||
return conversationPager.snapshot();
|
||||
},
|
||||
};
|
||||
|
||||
const api = {
|
||||
commentPager() { return conversationPager; },
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
},
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
@@ -95,7 +96,9 @@
|
||||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -116,6 +119,28 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact) return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
const repository = String(record?.repository || '');
|
||||
const number = Number(record?.number || 0);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1 ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply',
|
||||
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
|
||||
repository, number, title:repository + '#' + number,
|
||||
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
|
||||
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
@@ -167,6 +167,7 @@ const SHELL = [
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/photo-draft-inbox.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
@@ -185,6 +186,7 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/progressive-my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
@@ -283,6 +285,7 @@ const SHELL = [
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-search-modal.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
|
||||
@@ -117,16 +117,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
const keepBreak = validBreak(state)?.identity === identity;
|
||||
state.active_identity = identity;
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.entries[identity].started_at = running && !keepBreak ? now() : null;
|
||||
state.entries[identity].running = Boolean(running) && !keepBreak;
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
|
||||
if (!keepBreak) state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
|
||||
@@ -337,10 +337,9 @@ function mountTodayWeekReschedule({
|
||||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
@@ -348,6 +347,7 @@ function mountTodayWeekReschedule({
|
||||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
if(!dialog.open)dialog.showModal();
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
|
||||
@@ -2,76 +2,110 @@ async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
schedule = callback => setTimeout(callback, 750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
const names = ['work-core', 'today-timer', 'planning'];
|
||||
const urls = Object.fromEntries(names.map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
const attempts = Object.fromEntries(names.map(name => [name, 0]));
|
||||
const failed = new Set();
|
||||
const recoveries = new Map();
|
||||
let retryInFlight = null;
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
const loadFeature = name => {
|
||||
attempts[name] += 1;
|
||||
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
|
||||
return loader.load(name);
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
const retryOnce = async name => {
|
||||
try { return await loadFeature(name); }
|
||||
catch (_error) {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
return loadFeature(name);
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
};
|
||||
const showRecovery = () => {
|
||||
if (status) status.textContent = failed.has('work-core') ?
|
||||
'Workspace unavailable. Reconnect or retry.' :
|
||||
'Today or planning tools unavailable. My Work is ready; reconnect or retry.';
|
||||
if (retryButton) retryButton.hidden = retryButton.disabled = false;
|
||||
};
|
||||
const hideRecovery = () => {
|
||||
if (failed.size) return;
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
};
|
||||
const retryFailed = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
const pending = Array.from(failed);
|
||||
retryInFlight = Promise.all(pending.map(async name => {
|
||||
try {
|
||||
await loadFeature(name);
|
||||
failed.delete(name);
|
||||
recoveries.get(name)?.resolve(true);
|
||||
recoveries.delete(name);
|
||||
} catch (_error) {}
|
||||
})).then(() => {
|
||||
if (failed.size) showRecovery();
|
||||
else hideRecovery();
|
||||
}).finally(() => { retryInFlight = null; });
|
||||
return retryInFlight;
|
||||
};
|
||||
retryButton?.addEventListener?.('click', retryFailed);
|
||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||
window?.addEventListener('online', handleOnline);
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
await retryOnce('work-core');
|
||||
} catch (_error) {
|
||||
failed.add('work-core');
|
||||
showRecovery();
|
||||
await new Promise(resolve => recoveries.set('work-core', {resolve}));
|
||||
}
|
||||
hideRecovery();
|
||||
|
||||
const optional = ['today-timer', 'planning'].map(async name => {
|
||||
try {
|
||||
await retryOnce(name);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
failed.add(name);
|
||||
showRecovery();
|
||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||
}
|
||||
});
|
||||
const optionalReady = Promise.all(optional).then(() => {
|
||||
hideRecovery();
|
||||
return true;
|
||||
});
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
optionalReady,
|
||||
retryFeature(name) {
|
||||
if (!failed.has(name)) return Promise.resolve(true);
|
||||
return retryFailed().then(() => !failed.has(name));
|
||||
},
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.removeEventListener('online', handleOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
|
||||
typeof createFeatureLoader === 'function') {
|
||||
window.stackchainWorkspaceLifecycle = loadWorkspace({document, window});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
@@ -21,6 +21,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"work-core": ("static/my-work.js", "static/progressive-my-work.js"),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"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",
|
||||
@@ -35,18 +36,18 @@ 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/following.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js", "static/search-preview.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-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/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-search-modal.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/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/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/photo-draft-inbox.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",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/dashboard.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
@@ -122,7 +123,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
)
|
||||
workspace_preload = (
|
||||
f'<link rel="preload" as="script" '
|
||||
f'href="{feature_bundles["today-timer"].runtime_name}">'
|
||||
f'href="{feature_bundles["work-core"].runtime_name}">'
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
||||
|
||||
@@ -1381,6 +1381,86 @@ def _normalize_issue_comment(comment: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
COMMENT_REACTIONS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes")
|
||||
|
||||
|
||||
async def _conversation_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
comment = await fetch(f"repos/{repository}/issues/comments/{comment_id}")
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
issue_url = comment.get("issue_url") if isinstance(comment, dict) else None
|
||||
safe_issue_url = _safe_gitea_web_url(issue_url)
|
||||
web_path = urlsplit(safe_issue_url).path.rstrip("/") if safe_issue_url else ""
|
||||
if (
|
||||
not isinstance(comment, dict)
|
||||
or (
|
||||
_gitea_api_path(issue_url) != expected_issue_path
|
||||
and not web_path.endswith(f"/{repository}/issues/{number}")
|
||||
)
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not part of this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def comment_reactions(repository: str, number: int, comment_id: int) -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Authenticated Gitea user is unavailable")
|
||||
await _conversation_comment(repository, number, comment_id)
|
||||
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
|
||||
response = await _get_client().get(path, headers=_auth())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
counts: dict[str, int] = {}
|
||||
selected: set[str] = set()
|
||||
for reaction in payload if isinstance(payload, list) else []:
|
||||
content = reaction.get("content") if isinstance(reaction, dict) else None
|
||||
author = reaction.get("user") if isinstance(reaction, dict) else None
|
||||
author_login = author.get("login") if isinstance(author, dict) else None
|
||||
if content not in COMMENT_REACTIONS or not isinstance(author_login, str) or not author_login:
|
||||
continue
|
||||
counts[content] = counts.get(content, 0) + 1
|
||||
if author_login == login:
|
||||
selected.add(content)
|
||||
return {
|
||||
"comment_id": comment_id,
|
||||
"reactions": [
|
||||
{"content": content, "count": counts[content], "selected": content in selected}
|
||||
for content in COMMENT_REACTIONS
|
||||
if counts.get(content)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def set_comment_reaction(
|
||||
repository: str, number: int, comment_id: int, content: str, active: bool
|
||||
) -> dict:
|
||||
if content not in COMMENT_REACTIONS:
|
||||
raise ValueError("Unsupported comment reaction")
|
||||
before = await comment_reactions(repository, number, comment_id)
|
||||
selected = any(
|
||||
item["content"] == content and item["selected"]
|
||||
for item in before["reactions"]
|
||||
)
|
||||
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
|
||||
if selected != active:
|
||||
client = _get_client()
|
||||
response = (
|
||||
await client.post(path, headers=_auth(), json={"content": content})
|
||||
if active
|
||||
else await client.delete(path, headers=_auth(), json={"content": content})
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = await comment_reactions(repository, number, comment_id)
|
||||
confirmed_selected = any(
|
||||
item["content"] == content and item["selected"]
|
||||
for item in confirmed["reactions"]
|
||||
)
|
||||
if confirmed_selected != active:
|
||||
raise ValueError("Gitea did not confirm the requested reaction state")
|
||||
return confirmed
|
||||
|
||||
|
||||
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/comments",
|
||||
@@ -2339,6 +2419,113 @@ async def commit_authored_pull_feedback_fix(
|
||||
}
|
||||
|
||||
|
||||
async def commit_authored_pull_feedback_batch(
|
||||
repository: str,
|
||||
number: int,
|
||||
files: list[dict],
|
||||
suggestion_count: int,
|
||||
message: str,
|
||||
expected_head_sha: str,
|
||||
) -> dict:
|
||||
"""Commit reviewed replacements across bounded files as one verified commit."""
|
||||
message = message.strip()
|
||||
if (
|
||||
not 2 <= suggestion_count <= 8
|
||||
or not 1 <= len(files) <= 8
|
||||
or len(files) > suggestion_count
|
||||
or not message
|
||||
or len(message) > 120
|
||||
):
|
||||
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
|
||||
|
||||
paths = [file.get("path", "") for file in files]
|
||||
if len(set(paths)) != len(paths):
|
||||
raise IssueNotAvailableError("Feedback batch contains duplicate files")
|
||||
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
|
||||
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
||||
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
|
||||
branch = head.get("ref")
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
or head_repo.get("full_name") != repository
|
||||
or base_repo.get("full_name") != repository
|
||||
or not isinstance(branch, str)
|
||||
or not branch
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
|
||||
operations = []
|
||||
submitted: dict[str, str] = {}
|
||||
total_bytes = 0
|
||||
for file in files:
|
||||
path = file.get("path", "")
|
||||
content = file.get("content", "")
|
||||
expected_blob_sha = file.get("expected_blob_sha", "")
|
||||
if not isinstance(content, str):
|
||||
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
|
||||
encoded = content.encode("utf-8")
|
||||
total_bytes += len(encoded)
|
||||
if len(encoded) > 128 * 1024 or total_bytes > 512 * 1024 or "\x00" in content:
|
||||
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
|
||||
current = await authored_pull_feedback_file(
|
||||
repository, number, path, expected_head_sha
|
||||
)
|
||||
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
|
||||
raise IssueNotAvailableError("File changed or has no new content")
|
||||
operations.append({
|
||||
"operation": "update",
|
||||
"path": path,
|
||||
"sha": expected_blob_sha,
|
||||
"content": base64.b64encode(encoded).decode(),
|
||||
})
|
||||
submitted[path] = content
|
||||
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/contents",
|
||||
headers=_auth(),
|
||||
json={"branch": branch, "message": message, "files": operations},
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise IssueNotAvailableError("Pull request or file changed")
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
commit = payload.get("commit") if isinstance(payload, dict) else None
|
||||
mutation_sha = commit.get("sha") if isinstance(commit, dict) else None
|
||||
if not isinstance(mutation_sha, str) or not mutation_sha:
|
||||
raise ValueError("Gitea did not confirm the feedback batch commit")
|
||||
|
||||
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
|
||||
new_head_sha = confirmed_head.get("sha")
|
||||
if new_head_sha != mutation_sha or new_head_sha == expected_head_sha:
|
||||
raise ValueError("Gitea did not confirm a new pull request head")
|
||||
for path, content in submitted.items():
|
||||
confirmed = await authored_pull_feedback_file(
|
||||
repository, number, path, new_head_sha
|
||||
)
|
||||
if confirmed["content"] != content:
|
||||
raise ValueError("Gitea did not confirm every committed file")
|
||||
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"paths": paths,
|
||||
"previous_head_sha": expected_head_sha,
|
||||
"head_sha": new_head_sha,
|
||||
"suggestion_count": suggestion_count,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
async def update_authored_pull_branch(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
@@ -3538,6 +3725,20 @@ async def pull_completion_detail(
|
||||
}
|
||||
|
||||
|
||||
def _single_line_suggestion(body: str, line: object) -> dict | None:
|
||||
if not isinstance(line, int) or line <= 0:
|
||||
return None
|
||||
blocks = re.findall(r"(?ms)^```suggestion[ \t]*\r?\n(.*?)\r?\n```[ \t]*$", body)
|
||||
if len(blocks) != 1:
|
||||
return None
|
||||
content = blocks[0].replace("\r\n", "\n")
|
||||
if not content or "\x00" in content or len(content.encode("utf-8")) > 4096:
|
||||
return None
|
||||
if len(content.splitlines()) > 20:
|
||||
return None
|
||||
return {"line": line, "content": content}
|
||||
|
||||
|
||||
def _normalize_review_comments(comments: object) -> list[dict]:
|
||||
normalized = []
|
||||
for comment in (comments if isinstance(comments, list) else [])[:100]:
|
||||
@@ -3554,6 +3755,9 @@ def _normalize_review_comments(comments: object) -> list[dict]:
|
||||
position = comment.get("new_position") or comment.get("old_position")
|
||||
if isinstance(position, int) and position > 0:
|
||||
item["line"] = position
|
||||
suggestion = _single_line_suggestion(body, position)
|
||||
if suggestion is not None:
|
||||
item["suggestion"] = suggestion
|
||||
normalized.append(item)
|
||||
if len(normalized) == 20:
|
||||
break
|
||||
|
||||
+251
@@ -522,6 +522,30 @@ class FollowingNotificationPayload(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class QuietHoursPayload(BaseModel):
|
||||
enabled: bool
|
||||
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
end: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as error:
|
||||
raise ValueError("Valid IANA timezone required") from error
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def distinct_boundaries(self):
|
||||
if self.start == self.end:
|
||||
raise ValueError("quiet hours require distinct start and end times")
|
||||
return self
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"delete_source_branch",
|
||||
@@ -987,6 +1011,10 @@ class IssueComment(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class CommentReactionState(BaseModel):
|
||||
active: bool
|
||||
|
||||
|
||||
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
|
||||
expected = {
|
||||
"image/png": {"png"},
|
||||
@@ -1162,6 +1190,30 @@ class PullFeedbackFileUpdate(BaseModel):
|
||||
expected_blob_sha: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PullFeedbackBatchFile(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
content: str = Field(max_length=131_072)
|
||||
expected_blob_sha: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PullFeedbackBatchUpdate(BaseModel):
|
||||
files: list[PullFeedbackBatchFile] = Field(min_length=1, max_length=8)
|
||||
suggestion_count: int = Field(ge=2, le=8)
|
||||
message: str = Field(min_length=1, max_length=120)
|
||||
expected_head_sha: str = Field(
|
||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_file_manifest(self):
|
||||
paths = [file.path for file in self.files]
|
||||
if len(set(paths)) != len(paths):
|
||||
raise ValueError("feedback batch file paths must be unique")
|
||||
if len(paths) > self.suggestion_count:
|
||||
raise ValueError("feedback batch cannot contain more files than suggestions")
|
||||
return self
|
||||
|
||||
|
||||
class PullContentUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
@@ -2501,6 +2553,9 @@ async def push_status(request: Request):
|
||||
following_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.following_preferences, device_id
|
||||
)
|
||||
quiet_hours = await asyncio.to_thread(
|
||||
_push_subscription_store.quiet_hours, device_id
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
@@ -2517,6 +2572,10 @@ async def push_status(request: Request):
|
||||
"start_day_timezone": start_day_preferences["timezone"],
|
||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||
"following_enabled": following_preferences["enabled"],
|
||||
"quiet_hours_enabled": quiet_hours["enabled"],
|
||||
"quiet_hours_start": quiet_hours["start"],
|
||||
"quiet_hours_end": quiet_hours["end"],
|
||||
"quiet_hours_timezone": quiet_hours["timezone"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
@@ -2684,6 +2743,31 @@ async def update_following_notifications(
|
||||
return {"following_enabled": payload.enabled}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/quiet-hours")
|
||||
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_quiet_hours,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
start=payload.start,
|
||||
end=payload.end,
|
||||
timezone=payload.timezone,
|
||||
)
|
||||
return {
|
||||
"quiet_hours_enabled": payload.enabled,
|
||||
"quiet_hours_start": payload.start,
|
||||
"quiet_hours_end": payload.end,
|
||||
"quiet_hours_timezone": payload.timezone,
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
||||
async def snooze_deadline_reminder(request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
@@ -6441,6 +6525,144 @@ async def delete_notification_comment(
|
||||
)
|
||||
|
||||
|
||||
async def _read_conversation_reactions(
|
||||
repository: str, number: int, comment_id: int
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.comment_reactions(repository, number, comment_id),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Reactions could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _set_conversation_reaction(
|
||||
repository: str,
|
||||
number: int,
|
||||
comment_id: int,
|
||||
content: str,
|
||||
active: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.set_comment_reaction(
|
||||
repository, number, comment_id, content, active
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The reaction could not be confirmed. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions")
|
||||
async def assigned_issue_comment_reactions(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_assigned_issue_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
content: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions")
|
||||
async def assigned_pull_comment_reactions(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_assigned_pull_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
content: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not _has_pull_workspace_access(
|
||||
await _pull_workspace_capabilities(repository, number)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Pull request not found")
|
||||
return await _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions")
|
||||
async def notification_comment_reactions(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _read_conversation_reactions(repository, number, comment_id)
|
||||
|
||||
|
||||
@app.put("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions/{content}")
|
||||
async def set_notification_comment_reaction(
|
||||
state: CommentReactionState,
|
||||
content: str,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _set_conversation_reaction(
|
||||
repository, number, comment_id, content, state.active
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_issue(
|
||||
request: Request,
|
||||
@@ -7017,6 +7239,35 @@ async def commit_authored_pull_feedback_fix(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-batch")
|
||||
async def commit_authored_pull_feedback_batch(
|
||||
update: PullFeedbackBatchUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.commit_authored_pull_feedback_batch(
|
||||
f"{owner}/{repo}", number,
|
||||
[file.model_dump() for file in update.files],
|
||||
update.suggestion_count, update.message, update.expected_head_sha,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request or a suggested file changed. No fixes were committed."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The suggestion batch could not be confirmed. Keep the batch and retry."},
|
||||
status_code=503, headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||
async def assigned_pull_review_data(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
|
||||
+42
-16
@@ -110,6 +110,7 @@ async def dispatch_following_changes(
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Notify opted-in devices once for each privacy-safe Following change set."""
|
||||
if not configuration.enabled:
|
||||
@@ -119,13 +120,15 @@ async def dispatch_following_changes(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
now=time.time() if now is None else now,
|
||||
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)
|
||||
devices = await asyncio.to_thread(
|
||||
store.following_notification_devices, now=now
|
||||
)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await following()
|
||||
@@ -174,17 +177,25 @@ async def dispatch_following_changes(
|
||||
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:
|
||||
payload = json.dumps({
|
||||
"title": (
|
||||
f"{count} watched update{'s' if count != 1 else ''} while alerts were paused"
|
||||
if device.catch_up
|
||||
else f"{count} watched item{'s' if count != 1 else ''} changed"
|
||||
),
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"route": "#/my-work/following",
|
||||
"tag": (
|
||||
"stackchain-following-catch-up"
|
||||
if device.catch_up
|
||||
else f"stackchain-following-{fingerprint[:16]}"
|
||||
),
|
||||
"following_count": count,
|
||||
}, separators=(",", ":"))
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
@@ -241,6 +252,7 @@ async def dispatch_unread_updates(
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
@@ -249,7 +261,7 @@ async def dispatch_unread_updates(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
now=time.time() if now is None else now,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not acquired:
|
||||
@@ -265,7 +277,9 @@ async def dispatch_unread_updates(
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(
|
||||
store.claim_unseen, thread_revisions, now=now
|
||||
)
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
@@ -306,10 +320,10 @@ async def dispatch_unread_updates(
|
||||
for thread_revision in delivery.thread_revisions
|
||||
if thread_revision not in digest_pending
|
||||
)
|
||||
individual_revisions = new_revisions[
|
||||
individual_revisions = () if delivery.catch_up else new_revisions[
|
||||
:max(0, max_individual_notifications)
|
||||
]
|
||||
overflow_revisions = (
|
||||
overflow_revisions = delivery.thread_revisions if delivery.catch_up else (
|
||||
delivery.digest_revisions
|
||||
+ new_revisions[len(individual_revisions):]
|
||||
)
|
||||
@@ -385,10 +399,22 @@ async def dispatch_unread_updates(
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": f"{len(overflow_revisions)} new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"title": (
|
||||
f"{len(overflow_revisions)} updates while alerts were paused"
|
||||
if delivery.catch_up
|
||||
else f"{len(overflow_revisions)} new work updates"
|
||||
),
|
||||
"body": (
|
||||
"Open Updates to catch up in Stackchain."
|
||||
if delivery.catch_up
|
||||
else "Tap to review them in Stackchain."
|
||||
),
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"tag": (
|
||||
"stackchain-update-catch-up"
|
||||
if delivery.catch_up
|
||||
else "stackchain-update-digest"
|
||||
),
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
|
||||
+110
-11
@@ -7,7 +7,9 @@ import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
@@ -22,6 +24,7 @@ class PushDelivery:
|
||||
subscription: dict
|
||||
thread_revisions: tuple[tuple[int, str], ...]
|
||||
digest_revisions: tuple[tuple[int, str], ...] = ()
|
||||
catch_up: bool = False
|
||||
|
||||
@property
|
||||
def thread_ids(self) -> tuple[int, ...]:
|
||||
@@ -57,6 +60,7 @@ class FollowingNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_fingerprint: str | None
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
@@ -91,10 +95,13 @@ class DisabledPushSubscriptionStore:
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
||||
|
||||
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
|
||||
@@ -133,6 +140,9 @@ class DisabledPushSubscriptionStore:
|
||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_quiet_hours(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -183,6 +193,16 @@ def _revisions(
|
||||
return tuple(sorted(normalized.items()))
|
||||
|
||||
|
||||
def _inside_quiet_hours(*, now: float, start: str, end: str, timezone: str) -> bool:
|
||||
local = datetime.fromtimestamp(now, tz=datetime_timezone.utc).astimezone(ZoneInfo(timezone))
|
||||
minute = local.hour * 60 + local.minute
|
||||
start_minute = int(start[:2]) * 60 + int(start[3:])
|
||||
end_minute = int(end[:2]) * 60 + int(end[3:])
|
||||
if start_minute < end_minute:
|
||||
return start_minute <= minute < end_minute
|
||||
return minute >= start_minute or minute < end_minute
|
||||
|
||||
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
@@ -262,6 +282,16 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_quiet_hours (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT NOT NULL DEFAULT '22:00',
|
||||
end_time TEXT NOT NULL DEFAULT '07:00',
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
suppressed INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -612,20 +642,64 @@ class PushSubscriptionStore:
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
def set_quiet_hours(
|
||||
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_quiet_hours(
|
||||
session_id, enabled, start_time, end_time, timezone
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
start_time = excluded.start_time,
|
||||
end_time = excluded.end_time,
|
||||
timezone = excluded.timezone,
|
||||
suppressed = CASE WHEN excluded.enabled = 1 THEN suppressed ELSE 0 END""",
|
||||
(session_id, int(enabled), start, end, timezone),
|
||||
)
|
||||
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, start_time, end_time, timezone
|
||||
FROM push_quiet_hours WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"start": row[1] if row else "22:00",
|
||||
"end": row[2] if row else "07:00",
|
||||
"timezone": row[3] if row else "UTC",
|
||||
}
|
||||
|
||||
def following_notification_devices(
|
||||
self, *, now: float | None = None
|
||||
) -> list[FollowingNotificationDevice]:
|
||||
checked_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint,
|
||||
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
|
||||
FROM push_subscriptions s
|
||||
JOIN push_following_preferences p ON p.session_id = s.session_id
|
||||
LEFT JOIN push_quiet_hours q ON q.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
|
||||
]
|
||||
devices = []
|
||||
for row in rows:
|
||||
if row[3] and _inside_quiet_hours(
|
||||
now=checked_at, start=row[4], end=row[5], timezone=row[6]
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
continue
|
||||
devices.append(FollowingNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
|
||||
))
|
||||
return devices
|
||||
|
||||
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
|
||||
with self._connect() as connection:
|
||||
@@ -634,9 +708,14 @@ class PushSubscriptionStore:
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(fingerprint, session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
*, now: float | None = None,
|
||||
) -> list[PushDelivery]:
|
||||
candidates = _revisions(thread_revisions)
|
||||
if not candidates:
|
||||
@@ -647,6 +726,21 @@ class PushSubscriptionStore:
|
||||
).fetchall()
|
||||
deliveries = []
|
||||
for session_id, encoded in rows:
|
||||
quiet = connection.execute(
|
||||
"""SELECT enabled, start_time, end_time, timezone, suppressed
|
||||
FROM push_quiet_hours WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
catch_up = bool(quiet and quiet[4])
|
||||
if quiet and quiet[0] and _inside_quiet_hours(
|
||||
now=time.time() if now is None else now,
|
||||
start=quiet[1], end=quiet[2], timezone=quiet[3],
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
continue
|
||||
delivered = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
@@ -700,6 +794,7 @@ class PushSubscriptionStore:
|
||||
self._open_subscription(session_id, encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
catch_up,
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
@@ -770,3 +865,7 @@ class PushSubscriptionStore:
|
||||
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
||||
((session_id, thread_id) for thread_id, _revision in values),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_stages_and_commits_cross_file_suggestions_atomically(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [
|
||||
{"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
|
||||
"suggestion": {"line": 2, "content": " return empty_result()"}},
|
||||
{"id": 100, "path": "tests/test_api.py", "line": 2, "body": "Prove the typed result.",
|
||||
"suggestion": {"line": 2, "content": " assert lookup() == empty_result()"}},
|
||||
],
|
||||
}],
|
||||
"files": [
|
||||
{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ return None"]},
|
||||
{"filename": "tests/test_api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ assert lookup() is None"]},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.batchCalls = [];
|
||||
globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.batchDetail = detail;
|
||||
globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
batchCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') {
|
||||
if (batchCalls.length === 1) return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
|
||||
content:'def lookup():\\n return None\\n'};
|
||||
return {path:'tests/test_api.py', head_sha:'abc1234', blob_sha:'blob2',
|
||||
content:'def test_lookup():\\n assert lookup() is None\\n'};
|
||||
}
|
||||
return {paths:['src/api.py','tests/test_api.py'], suggestion_count:2,
|
||||
previous_head_sha:'abc1234', head_sha:'def5678'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, batchController,
|
||||
() => batchItem, () => batchDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("batchCalls.length === 1")
|
||||
page.locator("#stage-pull-feedback-suggestion").click()
|
||||
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
|
||||
page.locator("#next-pull-feedback").click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("batchCalls.length === 2")
|
||||
page.locator("#stage-pull-feedback-suggestion").click()
|
||||
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
|
||||
page.locator("#review-pull-feedback-batch").click()
|
||||
assert page.locator("#pull-feedback-batch-review").is_visible()
|
||||
assert page.locator("#pull-feedback-batch-items li").count() == 2
|
||||
page.locator("#commit-pull-feedback-batch").click()
|
||||
page.wait_for_function("batchCalls.length === 3")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:batchCalls, head:batchDetail.head_sha,
|
||||
reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["reviewHidden"] is True
|
||||
assert "2 suggested changes committed" in result["status"].lower()
|
||||
request = result["calls"][2]
|
||||
assert request["path"].endswith("/feedback-batch")
|
||||
assert request["options"]["method"] == "POST"
|
||||
body = request["options"]["body"]
|
||||
assert '"expected_head_sha":"abc1234"' in body
|
||||
assert '"files":[{"path":"src/api.py"' in body
|
||||
assert '"path":"tests/test_api.py"' in body
|
||||
assert '"suggestion_count":2' in body
|
||||
assert 'return empty_result()' in body
|
||||
assert 'assert lookup() == empty_result()' in body
|
||||
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_previews_and_applies_review_suggestion(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [{
|
||||
"id": 99, "path": "src/api.py", "line": 2,
|
||||
"body": "Return the typed empty result.\n```suggestion\n return empty_result()\n```",
|
||||
"suggestion": {"line": 2, "content": " return empty_result()"},
|
||||
}, {
|
||||
"id": 100, "path": "src/api.py", "line": 3,
|
||||
"body": "Keep the result typed.",
|
||||
}],
|
||||
}],
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ return None"]}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.suggestionCalls = [];
|
||||
globalThis.suggestionItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.suggestionDetail = detail;
|
||||
globalThis.suggestionController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
suggestionCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') return {
|
||||
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123',
|
||||
content:'def lookup():\\n return None\\n'
|
||||
};
|
||||
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, suggestionController,
|
||||
() => suggestionItem, () => suggestionDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
assert page.locator("#pull-feedback-file-editor").is_hidden()
|
||||
assert page.locator("#review-pull-feedback-suggestion").is_visible()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 1")
|
||||
assert page.locator("#pull-feedback-suggestion-preview").is_visible()
|
||||
assert page.locator("#pull-feedback-suggestion-before").text_content() == " return None"
|
||||
assert page.locator("#pull-feedback-suggestion-after").text_content() == " return empty_result()"
|
||||
assert page.locator("#pull-feedback-file-editor").is_hidden()
|
||||
page.locator("#next-pull-feedback").click()
|
||||
assert page.locator("#pull-feedback-suggestion-preview").is_hidden()
|
||||
assert page.locator("#review-pull-feedback-suggestion").is_hidden()
|
||||
page.locator("#previous-pull-feedback").click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 2")
|
||||
page.locator("#apply-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 3")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:suggestionCalls, head:suggestionDetail.head_sha,
|
||||
previewHidden:document.querySelector('#pull-feedback-suggestion-preview').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["previewHidden"] is True
|
||||
assert "suggested change committed" in result["status"].lower()
|
||||
body = result["calls"][2]["options"]["body"]
|
||||
assert '"expected_blob_sha":"blob123"' in body
|
||||
assert '"content":"def lookup():\\n return empty_result()\\n"' in body
|
||||
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip(
|
||||
"packaged mobile comment-reaction journey runs only in its gated CI job",
|
||||
allow_module_level=True,
|
||||
)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content(
|
||||
'<main><div id="status" aria-live="polite"></div>'
|
||||
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
|
||||
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
|
||||
)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "comment-actions.js")
|
||||
page.evaluate(
|
||||
"""
|
||||
window.reactionCalls = [];
|
||||
window.offline = false;
|
||||
const controller = createCommentActions({
|
||||
getLogin: () => 'timmy',
|
||||
fetchJson: async (path, options = {}) => {
|
||||
reactionCalls.push([path, options.method || 'GET']);
|
||||
return {
|
||||
comment_id: 91,
|
||||
reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1,
|
||||
selected: options.method === 'PUT'}],
|
||||
};
|
||||
},
|
||||
});
|
||||
document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'});
|
||||
controller.wire({
|
||||
root: document.querySelector('#comments'),
|
||||
getSurface: () => ({
|
||||
context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}},
|
||||
status: document.querySelector('#status'),
|
||||
}),
|
||||
isOffline: () => window.offline,
|
||||
escapeHtml: value => String(value),
|
||||
});
|
||||
"""
|
||||
)
|
||||
|
||||
trigger = page.get_by_role("button", name="React to this comment")
|
||||
trigger.click()
|
||||
menu = page.locator("[data-comment-reaction-menu]")
|
||||
expect(menu).to_be_visible()
|
||||
for control in menu.get_by_role("button").all():
|
||||
box = control.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
page.get_by_role("button", name="Heart 1").click()
|
||||
expect(menu).to_be_hidden()
|
||||
expect(trigger).to_be_focused()
|
||||
expect(page.locator("#status")).to_have_text("Reaction updated.")
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
assert page.evaluate("window.reactionCalls") == [
|
||||
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"],
|
||||
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"],
|
||||
]
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_search_following_conversation_actions_are_touch_safe_at_320px():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content(
|
||||
'<main><section id="search-preview-conversation" class="search-preview-conversation">'
|
||||
'<div id="search-preview-comments"></div>'
|
||||
'<div id="search-preview-conversation-status"></div>'
|
||||
'<button id="retry-search-preview-conversation" hidden></button>'
|
||||
'<button id="load-older-search-preview-comments" hidden></button>'
|
||||
'</section></main>'
|
||||
)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "comment-actions.js")
|
||||
page.evaluate(
|
||||
"""
|
||||
const actions=createCommentActions({fetchJson:async()=>({}),getLogin:()=> 'timmy'});
|
||||
renderSearchPreviewConversation({status:'ready',comments:[
|
||||
{id:41,author:'timmy',body:'Correct this from Following'},
|
||||
{id:42,author:'alexander',body:'React without leaving Search'},
|
||||
]},document,value=>String(value),value=>String(value),value=>String(value),actions);
|
||||
"""
|
||||
)
|
||||
|
||||
expect(page.locator(".search-preview-comment.issue-comment")).to_have_count(2)
|
||||
expect(page.get_by_role("button", name="Edit")).to_have_count(1)
|
||||
expect(page.get_by_role("button", name="Delete")).to_have_count(1)
|
||||
expect(page.get_by_role("button", name="React to this comment")).to_have_count(2)
|
||||
for control in page.locator("[data-comment-action], [data-comment-reactions-open]").all():
|
||||
box = control.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
@@ -169,7 +169,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
workspace_requests.append(request.url),
|
||||
launch_transfer_events.append("workspace-requested"),
|
||||
)
|
||||
if "feature-today-timer-" in request.url else None,
|
||||
if "feature-work-core-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"requestfinished",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered mobile quiet-hours 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("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_notification_quiet_hours_are_phone_usable_without_horizontal_scroll(viewport):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
|
||||
toggle = page.locator('label[for="push-quiet-hours"]')
|
||||
start = page.locator("#push-quiet-start")
|
||||
end = page.locator("#push-quiet-end")
|
||||
expect(toggle).to_be_visible()
|
||||
expect(start).to_be_visible()
|
||||
expect(end).to_be_visible()
|
||||
expect(start).to_have_value("22:00")
|
||||
expect(end).to_have_value("07:00")
|
||||
for control in (toggle, start, end):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile photo-draft 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
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Photo draft release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""() => new Promise((resolve, reject) => {
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onupgradeneeded=()=>{
|
||||
if (!request.result.objectStoreNames.contains('drafts')) {
|
||||
request.result.createObjectStore('drafts',{keyPath:'id'});
|
||||
}
|
||||
};
|
||||
request.onsuccess=()=>{
|
||||
const transaction=request.result.transaction('drafts','readwrite');
|
||||
transaction.objectStore('drafts').put({
|
||||
id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
|
||||
scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
|
||||
updatedAt:Date.now(), attachments:[{
|
||||
filename:'rack-label.jpg', contentType:'image/jpeg',
|
||||
blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
|
||||
}],
|
||||
});
|
||||
transaction.oncomplete=resolve;
|
||||
transaction.onerror=()=>reject(transaction.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
page.reload(wait_until="networkidle")
|
||||
stored = page.evaluate(
|
||||
"""() => new Promise((resolve,reject)=>{
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
|
||||
values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
|
||||
values.onerror=()=>reject(values.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
|
||||
inventory = page.evaluate(
|
||||
"""() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
|
||||
)
|
||||
assert len(inventory) == 1
|
||||
expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="draft"]').click()
|
||||
|
||||
card = page.locator(".draft-card", has_text="acme/mobile#41")
|
||||
expect(card).to_contain_text("1 saved photo")
|
||||
resume = card.get_by_role("button", name="Resume draft")
|
||||
bounds = resume.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
resume.dispatch_event("click")
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-preview")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -88,3 +88,62 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
||||
page.set_viewport_size({"width": 800, "height": 700})
|
||||
expect(navigation).to_be_hidden()
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_rendered_search_to_preview_journey_contains_focus_until_final_close(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 / "mobile-search-modal.js")
|
||||
page.evaluate("""() => {
|
||||
const palette = document.querySelector('#cmd-palette');
|
||||
const preview = document.querySelector('#search-preview');
|
||||
palette.classList.add('open');
|
||||
window.searchModalTest = createMobileSearchModal({document});
|
||||
window.searchModalTest.activate(palette, {
|
||||
opener:document.querySelector('[data-mobile-task="search"]'),
|
||||
initialFocus:document.querySelector('#cmd-input'),
|
||||
});
|
||||
preview.append(Object.assign(document.createElement('button'), {id:'dynamic-preview-action', textContent:'Dynamic action'}));
|
||||
}""")
|
||||
|
||||
expect(page.locator("#cmd-input")).to_be_focused()
|
||||
expect(page.locator("#cmd-palette")).to_have_attribute("aria-modal", "true")
|
||||
assert page.locator("body > header").evaluate("node => node.inert") is True
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is True
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#cmd-palette').classList.remove('open');
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
searchModalTest.transition(document.querySelector('#search-preview'), {
|
||||
initialFocus:document.querySelector('#close-search-preview'),
|
||||
});
|
||||
document.querySelector('#dynamic-preview-action').focus();
|
||||
}""")
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-search-preview")).to_be_focused()
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#search-preview').classList.remove('open');
|
||||
document.querySelector('#cmd-palette').classList.add('open');
|
||||
searchModalTest.transition(document.querySelector('#cmd-palette'), {
|
||||
initialFocus:document.querySelector('#cmd-input'),
|
||||
});
|
||||
}""")
|
||||
expect(page.locator("#cmd-input")).to_be_focused()
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("searchModalTest.deactivate()")
|
||||
expect(page.locator('[data-mobile-task="search"]')).to_be_focused()
|
||||
assert page.locator("body > header").evaluate("node => node.inert") is False
|
||||
assert page.locator("main").evaluate("node => node.inert") is False
|
||||
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is False
|
||||
browser.close()
|
||||
@@ -1,13 +1,44 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
WORKFLOW = Path(".gitea/workflows/ci.yml")
|
||||
ACTION_REFERENCE = re.compile(
|
||||
r"^\s*- uses: (?P<action>[^\s@]+)@(?P<revision>[^\s#]+)\s+# (?P<version>v\d+(?:\.\d+){1,2})$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
DOWNLOAD_ARTIFACT = (
|
||||
"actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2"
|
||||
)
|
||||
|
||||
|
||||
def test_ci_uses_gitea_compatible_artifact_action():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "actions/upload-artifact@v4" not in text
|
||||
assert "actions/upload-artifact@v3" in text
|
||||
assert re.search(
|
||||
r"actions/upload-artifact@[0-9a-f]{40}\s+# v3\.\d+\.\d+", text
|
||||
)
|
||||
|
||||
|
||||
def test_ci_pins_every_external_action_to_a_reviewable_commit():
|
||||
text = WORKFLOW.read_text()
|
||||
uses_lines = [line for line in text.splitlines() if line.lstrip().startswith("- uses:")]
|
||||
references = list(ACTION_REFERENCE.finditer(text))
|
||||
|
||||
assert len(references) == len(uses_lines)
|
||||
assert references
|
||||
for reference in references:
|
||||
assert re.fullmatch(r"[0-9a-f]{40}", reference["revision"])
|
||||
|
||||
|
||||
def test_ci_grants_repository_write_access_only_to_release_job():
|
||||
text = WORKFLOW.read_text()
|
||||
jobs = text[text.index("jobs:") :]
|
||||
before_release, release = jobs.split(" release-candidate:", 1)
|
||||
|
||||
assert "permissions:\n contents: read" in text[: text.index("jobs:")]
|
||||
assert "contents: write" not in before_release
|
||||
assert "permissions:\n contents: write" in release
|
||||
|
||||
|
||||
def test_ci_installs_declared_requirements_before_tests():
|
||||
@@ -40,7 +71,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert DOWNLOAD_ARTIFACT in release
|
||||
assert (
|
||||
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
|
||||
in release
|
||||
@@ -54,7 +85,7 @@ def test_release_promotion_waits_for_every_packaged_mobile_journey():
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: build-release" in browser
|
||||
assert "actions/download-artifact@v3" in browser
|
||||
assert DOWNLOAD_ARTIFACT in browser
|
||||
assert "pip install -r requirements-e2e.txt" in browser
|
||||
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
calls.append(("fetch", path))
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
if path == "repos/stackchain/api/issues/comments/91":
|
||||
return {
|
||||
"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
|
||||
}
|
||||
raise AssertionError(path)
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{"content": "heart", "user": {"login": "alex"}},
|
||||
{"content": "heart", "user": {"login": "timmy"}},
|
||||
{"content": "+1", "user": {"login": "alex"}},
|
||||
{"content": "unsupported", "user": {"login": "alex"}},
|
||||
{"content": "heart", "user": {}},
|
||||
]
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
calls.append(("get", path))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
|
||||
|
||||
assert result == {
|
||||
"comment_id": 91,
|
||||
"reactions": [
|
||||
{"content": "+1", "count": 1, "selected": False},
|
||||
{"content": "heart", "count": 2, "selected": True},
|
||||
],
|
||||
}
|
||||
assert calls == [
|
||||
("fetch", "user"),
|
||||
("fetch", "repos/stackchain/api/issues/comments/91"),
|
||||
("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch):
|
||||
async def fake_fetch(path):
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
return {
|
||||
"issue_url": "https://forge.example/git/stackchain/api/issues/7",
|
||||
}
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return []
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
|
||||
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
|
||||
|
||||
assert result == {"comment_id": 91, "reactions": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch):
|
||||
payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]]
|
||||
mutations = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"}
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
return Response(payloads.pop(0))
|
||||
|
||||
async def post(self, path, headers, json):
|
||||
mutations.append(("post", path, json))
|
||||
return Response()
|
||||
|
||||
async def delete(self, path, headers, json):
|
||||
mutations.append(("delete", path, json))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True)
|
||||
|
||||
path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
|
||||
assert mutations == [("post", path, {"content": "heart"})]
|
||||
assert result == {
|
||||
"comment_id": 91,
|
||||
"reactions": [{"content": "heart", "count": 1, "selected": True}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch):
|
||||
reads = []
|
||||
writes = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def pull_capabilities(repository, number):
|
||||
return {"can_open": (repository, number) == ("stackchain/api", 8)}
|
||||
|
||||
async def notification_target(thread_id):
|
||||
assert thread_id == 42
|
||||
return "stackchain/api", 9
|
||||
|
||||
async def reactions(repository, number, comment_id):
|
||||
reads.append((repository, number, comment_id))
|
||||
return {"comment_id": comment_id, "reactions": []}
|
||||
|
||||
async def set_reaction(repository, number, comment_id, content, active):
|
||||
writes.append((repository, number, comment_id, content, active))
|
||||
return {
|
||||
"comment_id": comment_id,
|
||||
"reactions": [{"content": content, "count": 1, "selected": active}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities)
|
||||
monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True)
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions")
|
||||
pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions")
|
||||
update = await client.get("/api/v1/notifications/42/comments/93/reactions")
|
||||
changed = await client.put(
|
||||
"/api/v1/notifications/42/comments/93/reactions/heart",
|
||||
json={"active": True},
|
||||
)
|
||||
|
||||
assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200]
|
||||
assert reads == [
|
||||
("stackchain/api", 7, 91),
|
||||
("stackchain/api", 8, 92),
|
||||
("stackchain/api", 9, 93),
|
||||
]
|
||||
assert writes == [("stackchain/api", 9, 93, "heart", True)]
|
||||
assert changed.json()["reactions"][0]["selected"] is True
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_comment_action_controller_loads_and_toggles_authoritative_reactions():
|
||||
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
|
||||
script = f"""
|
||||
const createActions=require({module});
|
||||
const calls=[];
|
||||
const actions=createActions({{
|
||||
fetchJson:async (path, options={{}})=>{{
|
||||
calls.push([path, options.method || 'GET', options.body || null]);
|
||||
return options.method === 'PUT'
|
||||
? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}}
|
||||
: {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}};
|
||||
}},
|
||||
getLogin:()=> 'timmy',
|
||||
}});
|
||||
(async()=>{{
|
||||
const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}};
|
||||
const loaded=await actions.loadReactions(context,91);
|
||||
const changed=await actions.setReaction(context,91,'heart',true);
|
||||
process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions"
|
||||
assert output["calls"] == [
|
||||
[base, "GET", None],
|
||||
[base + "/heart", "PUT", '{"active":true}'],
|
||||
]
|
||||
assert output["loaded"]["reactions"][0]["count"] == 1
|
||||
assert output["changed"]["reactions"][0]["selected"] is True
|
||||
assert 'data-comment-reactions-open' in output["html"]
|
||||
assert 'aria-label="React to this comment"' in output["html"]
|
||||
|
||||
|
||||
def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection():
|
||||
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
|
||||
script = f"""
|
||||
const createActions=require({module});
|
||||
const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{
|
||||
comment_id:91,
|
||||
reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}],
|
||||
}})}}));
|
||||
"""
|
||||
html = run_node(script)["html"]
|
||||
assert 'data-comment-reaction="heart"' in html
|
||||
assert 'aria-pressed="true"' in html
|
||||
assert "Heart 2" in html
|
||||
assert 'data-comment-reaction="rocket"' in html
|
||||
assert "Rocket 0" in html
|
||||
assert 'data-comment-reactions-close' in html
|
||||
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
assert ".comment-reactions" in css
|
||||
assert "flex-wrap:wrap" in css
|
||||
assert ".comment-reaction-menu button" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle():
|
||||
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
|
||||
script = f"""
|
||||
const createActions=require({module});
|
||||
const calls=[];
|
||||
const menu={{hidden:true,innerHTML:''}};
|
||||
const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
|
||||
const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}};
|
||||
const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}};
|
||||
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
|
||||
const status={{textContent:''}};
|
||||
const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}};
|
||||
const actions=createActions({{
|
||||
fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}},
|
||||
getLogin:()=> 'timmy',
|
||||
}});
|
||||
actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}});
|
||||
(async()=>{{
|
||||
await root.listeners.click({{target:trigger}});
|
||||
await root.listeners.click({{target:reaction}});
|
||||
process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
base = "api/v1/notifications/42/comments/91/reactions"
|
||||
assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]]
|
||||
assert output["hidden"] is True
|
||||
assert output["expanded"] == "false"
|
||||
assert output["focused"] == 1
|
||||
assert output["status"] == "Reaction updated."
|
||||
|
||||
|
||||
def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance():
|
||||
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
|
||||
script = f"""
|
||||
const createActions=require({module});
|
||||
let calls=0;
|
||||
const menu={{hidden:true,innerHTML:''}};
|
||||
const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}};
|
||||
const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
|
||||
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
|
||||
const status={{textContent:''}};
|
||||
const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}});
|
||||
actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}});
|
||||
(async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {"calls": 0, "status": "Reconnect to react."}
|
||||
@@ -133,3 +133,75 @@ def test_comment_action_retry_is_a_phone_sized_inline_control():
|
||||
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
|
||||
assert "min-height:44px" in rule
|
||||
assert "max-width:100%" in rule
|
||||
|
||||
|
||||
def test_search_and_following_preview_hydrates_owned_comment_actions_lazily():
|
||||
preview = FRONTEND / "search-preview.js"
|
||||
script = f"""
|
||||
require({json.dumps(str(preview))});
|
||||
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]}};
|
||||
const actions = {{actionHtml: comment => comment.author === 'timmy'
|
||||
? '<button data-comment-action="edit">Edit</button><button data-comment-reactions-open>React</button>'
|
||||
: '<button data-comment-reactions-open>React</button>'}};
|
||||
renderSearchPreviewConversation({{
|
||||
status:'ready', reviewedAt:'2026-08-25T08:00:00Z',
|
||||
comments:[
|
||||
{{id:41,author:'timmy',body:'Mine',created_at:'2026-08-25T09:00:00Z'}},
|
||||
{{id:42,author:'alexander',body:'Theirs',created_at:'2026-08-25T07:00:00Z'}},
|
||||
],
|
||||
}}, document, String, String, String, actions);
|
||||
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML}}));
|
||||
"""
|
||||
output = run_node(script)["html"]
|
||||
|
||||
assert output.count('class="search-preview-comment issue-comment') == 2
|
||||
assert output.count("data-comment-reactions-open") == 2
|
||||
assert output.count('data-comment-action="edit"') == 1
|
||||
assert 'data-comment-id="41"' in output
|
||||
assert "new-since-review" in output
|
||||
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
javascript = (FRONTEND / "dashboard.js").read_text()
|
||||
assert 'id="retry-search-preview-comment-actions"' in html
|
||||
assert "showSearchConversationWithActions" in javascript
|
||||
assert "context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}" in javascript
|
||||
|
||||
|
||||
def test_search_preview_comment_pager_updates_exact_visible_comment_in_place():
|
||||
preview = FRONTEND / "search-preview.js"
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(preview))});
|
||||
(async () => {{
|
||||
const states=[];
|
||||
const controller=createSearchPreview({{
|
||||
fetchJson:async item=>({{...item,title:'Visible'}}),
|
||||
fetchConversation:async()=>({{comments:[
|
||||
{{id:41,author:'timmy',body:'Old'}},{{id:42,author:'alexander',body:'Keep'}},
|
||||
],older_page:null}}),
|
||||
mutate:async()=>({{}}), onState:state=>states.push(state),
|
||||
}});
|
||||
await controller.open({{repository:'stackchain/api',number:9,kind:'pull'}});
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const pager=controller.commentPager();
|
||||
pager.replace({{id:41,author:'timmy',body:'Corrected'}});
|
||||
const replaced=pager.snapshot().comments;
|
||||
pager.remove(41);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
replaced:replaced.map(comment=>[comment.id,comment.body]),
|
||||
remaining:pager.snapshot().comments.map(comment=>comment.id),
|
||||
published:states.at(-1).conversation.comments.map(comment=>comment.id),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"replaced": [[41, "Corrected"], [42, "Keep"]],
|
||||
"remaining": [42],
|
||||
"published": [42],
|
||||
}
|
||||
@@ -79,6 +79,50 @@ const photos = [
|
||||
}
|
||||
|
||||
|
||||
def test_store_lists_only_confirmed_accounts_conversation_photo_drafts_without_blobs():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = new Map([
|
||||
['timmy:issue:stackchain%2Fdashboard%3A7', {{id:'a',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'stackchain/dashboard',number:7,updatedAt:100,attachments:[{{filename:'rack.jpg',blob:new Blob(['secret'])}},{{filename:'label.jpg',blob:new Blob(['secret'])}}]}},],
|
||||
['timmy:update:8', {{id:'b',version:1,ownerLogin:'timmy',scope:'conversation',kind:'update',notificationId:8,updatedAt:200,attachments:[{{filename:'alert.jpg',blob:new Blob(['secret'])}}]}},],
|
||||
['alexander:pull:o%2Fr%3A9', {{id:'c',version:1,ownerLogin:'alexander',scope:'conversation',kind:'pull',repository:'o/r',number:9,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},],
|
||||
['broken', {{id:'broken',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'',number:0,updatedAt:400,attachments:[{{filename:'bad.jpg',blob:new Blob(['bad'])}}]}},],
|
||||
]);
|
||||
const transaction = async (operation, key, value) => {{
|
||||
if (operation === 'list') return Array.from(records.values()).map(value => structuredClone(value));
|
||||
}};
|
||||
const store = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
(async()=>{{
|
||||
const drafts=await store.list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == [
|
||||
{
|
||||
"id": "b",
|
||||
"kind": "photo-reply",
|
||||
"label": "Update photos",
|
||||
"title": "Update #8",
|
||||
"photo_count": 1,
|
||||
"updated_at": 200,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
"photo_store": "conversation",
|
||||
},
|
||||
{
|
||||
"id": "a",
|
||||
"kind": "photo-reply",
|
||||
"label": "Issue photos",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 7,
|
||||
"title": "stackchain/dashboard#7",
|
||||
"photo_count": 2,
|
||||
"updated_at": 100,
|
||||
"route": {"kind": "issue", "repository": "stackchain/dashboard", "number": 7},
|
||||
"photo_store": "conversation",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_store_isolates_today_progress_photos_from_conversation_photos():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
@@ -109,6 +153,24 @@ const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
||||
}
|
||||
|
||||
|
||||
def test_coordinator_notifies_inventory_after_checkpoint_and_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const changes=[];
|
||||
let current=[{{filename:'proof.jpg',blob:new Blob(['proof'])}}];
|
||||
const store={{save:async()=>null,load:async()=>null,remove:async()=>null}};
|
||||
const controller={{serialize:async()=>current,restore:()=>{{}},clear:()=>{{current=[];}}}};
|
||||
const drafts=createCoordinator({{store,lanes:{{issue:{{controller}}}},onChange:()=>changes.push('changed')}});
|
||||
(async()=>{{
|
||||
await drafts.open('issue',{{kind:'issue',repository:'o/r',number:7}});
|
||||
await drafts.checkpoint('issue');
|
||||
await drafts.complete('issue');
|
||||
process.stdout.write(JSON.stringify(changes));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == ["changed", "changed"]
|
||||
|
||||
|
||||
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center", "planning",
|
||||
"work-core", "today-timer", "security-center", "planning",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -95,7 +95,6 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||
|
||||
shell_block, optional_block = first.service_worker_source.split(
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
@@ -127,14 +126,29 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_my_work_has_a_small_blocking_bundle_before_optional_workspace_hydration():
|
||||
build = build_frontend(FRONTEND)
|
||||
work_core = build.feature_bundles["work-core"]
|
||||
today = build.feature_bundles["today-timer"]
|
||||
|
||||
assert b"function buildMyWork" in work_core.runtime_bytes
|
||||
assert b"function createProgressiveMyWork" in work_core.runtime_bytes
|
||||
assert b"function buildMyWork" not in today.runtime_bytes
|
||||
assert b"loadWorkspace({document,window})" in build.runtime_bytes
|
||||
assert b"const workspaceLifecycle" not in build.runtime_bytes
|
||||
assert b"const workspaceLifecycle" in today.runtime_bytes
|
||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [work_core.runtime_name]
|
||||
assert len(build.runtime_gzip_bytes) + len(work_core.runtime_gzip_bytes) <= 150 * 1024
|
||||
|
||||
|
||||
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||
build = build_frontend(FRONTEND)
|
||||
workspace = build.feature_bundles["today-timer"]
|
||||
workspace = build.feature_bundles["work-core"]
|
||||
|
||||
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) <= 111 * 1024
|
||||
assert len(build.runtime_gzip_bytes) + len(workspace.runtime_gzip_bytes) <= 150 * 1024
|
||||
|
||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||
|
||||
@@ -221,7 +221,7 @@ def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
|
||||
|
||||
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text() + (FRONTEND / "conversation.js").read_text()
|
||||
|
||||
expected_paths = (
|
||||
"renderMarkdown(comment.body || 'No comment body provided.')",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTROLLER = ROOT / "frontend" / "mobile-search-modal.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_preview_journey_keeps_background_inert_and_restores_prior_state():
|
||||
script = f"""
|
||||
const createModal = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = (name) => ({{
|
||||
name, inert:false, isConnected:true, hidden:false, disabled:false, focuses:0,
|
||||
focus() {{ this.focuses += 1; }},
|
||||
matches() {{ return true; }},
|
||||
}});
|
||||
const header=element('header'), main=element('main'), dock=element('dock'); dock.inert=true;
|
||||
const palette=element('palette'), preview=element('preview');
|
||||
const input=element('input'), close=element('close'), opener=element('opener');
|
||||
palette.querySelectorAll=()=>[input]; preview.querySelectorAll=()=>[close];
|
||||
const controller=createModal({{
|
||||
document:{{addEventListener(name,fn){{listeners[name]=fn;}}}},
|
||||
backgrounds:[header,main,dock],
|
||||
}});
|
||||
controller.activate(palette, {{opener, initialFocus:input}});
|
||||
const opened=[header.inert,main.inert,dock.inert,input.focuses];
|
||||
controller.transition(preview, {{initialFocus:close}});
|
||||
const previewed=[header.inert,main.inert,dock.inert,close.focuses,controller.current()===preview];
|
||||
controller.transition(palette, {{initialFocus:input}});
|
||||
const returned=[header.inert,main.inert,dock.inert,input.focuses];
|
||||
controller.deactivate();
|
||||
process.stdout.write(JSON.stringify({{opened,previewed,returned,restored:[header.inert,main.inert,dock.inert,opener.focuses]}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"opened": [True, True, True, 1],
|
||||
"previewed": [True, True, True, 1, True],
|
||||
"returned": [True, True, True, 2],
|
||||
"restored": [False, False, True, 1],
|
||||
}
|
||||
|
||||
|
||||
def test_tab_and_shift_tab_wrap_dynamic_visible_controls_on_active_surface():
|
||||
script = f"""
|
||||
const createModal = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=(name)=>({{name,inert:false,isConnected:true,hidden:false,disabled:false,focuses:0,
|
||||
focus(){{this.focuses+=1;}},matches(selector){{return !this.hidden;}}}});
|
||||
const surface=element('surface'), first=element('first'), middle=element('middle'), last=element('last');
|
||||
const hidden=element('hidden'); hidden.hidden=true; const disabled=element('disabled'); disabled.disabled=true;
|
||||
surface.querySelectorAll=()=>[first,hidden,middle,disabled,last];
|
||||
const document={{activeElement:last,addEventListener(name,fn){{listeners[name]=fn;}}}};
|
||||
const controller=createModal({{document,backgrounds:[]}});
|
||||
controller.activate(surface,{{initialFocus:first}});
|
||||
let prevented=0;
|
||||
document.activeElement=last; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
|
||||
document.activeElement=first; listeners.keydown({{key:'Tab',shiftKey:true,preventDefault(){{prevented+=1;}}}});
|
||||
// A newly rendered enabled control becomes the last stop without remounting.
|
||||
const dynamic=element('dynamic'); surface.querySelectorAll=()=>[first,middle,last,dynamic];
|
||||
document.activeElement=dynamic; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
|
||||
process.stdout.write(JSON.stringify({{first:first.focuses,last:last.focuses,prevented}}));
|
||||
"""
|
||||
assert run_node(script) == {"first": 3, "last": 1, "prevented": 3}
|
||||
|
||||
|
||||
def test_disconnected_opener_is_not_focused_on_final_close():
|
||||
script = f"""
|
||||
const createModal=require({json.dumps(str(CONTROLLER))});
|
||||
const surface={{querySelectorAll(){{return[];}}}};
|
||||
const opener={{isConnected:true,focuses:0,focus(){{this.focuses+=1;}}}};
|
||||
const document={{addEventListener(){{}}}};
|
||||
const controller=createModal({{document,backgrounds:[]}});
|
||||
controller.activate(surface,{{opener}}); opener.isConnected=false; controller.deactivate();
|
||||
process.stdout.write(JSON.stringify({{focuses:opener.focuses}}));
|
||||
"""
|
||||
assert run_node(script) == {"focuses": 0}
|
||||
|
||||
|
||||
def test_controls_inside_collapsed_sections_are_excluded_from_tab_order():
|
||||
script = f"""
|
||||
const createModal=require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const visible={{disabled:false,hidden:false,isConnected:true,focuses:0,matches(){{return true;}},getClientRects(){{return [{{}}];}},focus(){{this.focuses+=1;}}}};
|
||||
const collapsed={{...visible,getClientRects(){{return [];}}}};
|
||||
const surface={{querySelectorAll(){{return [visible,collapsed];}}}};
|
||||
const document={{activeElement:visible,addEventListener(name,fn){{listeners[name]=fn;}}}};
|
||||
const controller=createModal({{document,backgrounds:[]}}); controller.activate(surface);
|
||||
let prevented=0; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
|
||||
process.stdout.write(JSON.stringify({{prevented,focuses:visible.focuses}}));
|
||||
"""
|
||||
assert run_node(script) == {"prevented": 1, "focuses": 2}
|
||||
|
||||
|
||||
def test_search_modal_is_packaged_with_accessible_semantics():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="cmd-palette" role="dialog" aria-modal="true"' in html
|
||||
assert '<script src="static/mobile-search-modal.js"></script>' in html
|
||||
assert '"static/mobile-search-modal.js"' in bundle
|
||||
assert "createMobileSearchModal({" in dashboard
|
||||
assert "searchModal.transition(qs('#search-preview')" in dashboard
|
||||
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "photo-draft-inbox.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_photo_draft_inbox_combines_stores_without_duplicate_target_cards_and_discards_exact_bundle():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const removed=[];
|
||||
const conversation={{
|
||||
list:async()=>[
|
||||
{{id:'conversation-issue',kind:'photo-reply',photo_store:'conversation',title:'o/r#7',updated_at:30,route:{{kind:'issue',repository:'o/r',number:7}}}},
|
||||
{{id:'conversation-update',kind:'photo-reply',photo_store:'conversation',title:'Update #8',updated_at:20,route:{{kind:'update',notification_id:8}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['conversation',target]),
|
||||
}};
|
||||
const search={{
|
||||
list:async()=>[
|
||||
{{id:'search-pull',kind:'photo-reply',photo_store:'search',title:'o/r#9',updated_at:40,route:{{kind:'search',target_kind:'pull',repository:'o/r',number:9}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['search',target]),
|
||||
}};
|
||||
const inbox=createPhotoDraftInbox({{conversation,search}});
|
||||
(async()=>{{
|
||||
const drafts=await inbox.refresh([{{kind:'issue-comment',route:{{kind:'issue',repository:'o/r',number:7}}}}]);
|
||||
const description=inbox.description({{kind:'photo-reply',photo_count:2}});
|
||||
const searchTarget=inbox.searchTarget(drafts[0]);
|
||||
await inbox.discard(drafts[0]);
|
||||
process.stdout.write(JSON.stringify({{drafts,description,searchTarget,remaining:inbox.list(),removed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"drafts": [
|
||||
{
|
||||
"id": "search-pull",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "search",
|
||||
"title": "o/r#9",
|
||||
"updated_at": 40,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "o/r", "number": 9},
|
||||
},
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
},
|
||||
],
|
||||
"description": "2 saved photos",
|
||||
"searchTarget": {"kind": "pull", "repository": "o/r", "number": 9, "title": "o/r#9"},
|
||||
"remaining": [
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
}
|
||||
],
|
||||
"removed": [["search", {"kind": "pull", "repository": "o/r", "number": 9}]],
|
||||
}
|
||||
|
||||
|
||||
def test_photo_draft_inbox_keeps_available_store_when_other_inventory_fails():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const inbox=createPhotoDraftInbox({{
|
||||
conversation:{{list:async()=>{{throw new Error('IndexedDB unavailable')}}}},
|
||||
search:{{list:async()=>[{{id:'safe',route:{{kind:'search',target_kind:'issue',repository:'o/r',number:2}},updated_at:1}}]}},
|
||||
}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await inbox.refresh([]))))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == [
|
||||
{"id": "safe", "route": {"kind": "search", "target_kind": "issue", "repository": "o/r", "number": 2}, "updated_at": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_my_work_discovers_reopens_and_discards_photo_only_reply_drafts():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
worker = WORKER.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert '<script src="static/photo-draft-inbox.js"></script>' in index
|
||||
assert "BASE + 'static/photo-draft-inbox.js'" in worker
|
||||
assert '"static/photo-draft-inbox.js"' in bundle
|
||||
assert "const photoDraftInbox = createPhotoDraftInbox({" in dashboard
|
||||
assert "draftInbox.list().concat(photoDraftInbox.list(), unfiled)" in dashboard
|
||||
assert "await photoDraftInbox.refresh(draftInbox.list())" in dashboard
|
||||
assert "refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false })" in dashboard
|
||||
assert "void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "onChange:() => { void refreshPhotoDraftInbox(); }" in dashboard
|
||||
assert "await searchReplyDraftStore.save(target, attachments);\n void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "photoDraftInbox.description(item)" in dashboard
|
||||
assert "photoDraftInbox.searchTarget(item)" in dashboard
|
||||
assert "await photoDraftInbox.discard(item)" in dashboard
|
||||
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const buttons=[
|
||||
{{dataset:{{workFilter:'all'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
||||
{{dataset:{{workFilter:'issue'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
||||
];
|
||||
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||
const document={{querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,querySelectorAll:()=>buttons}};
|
||||
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||
const createProgressiveMyWork=context.module.exports;
|
||||
const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{
|
||||
user:{{login:'timmy'}},issues:[{{number:7,title:'Fix mobile queue',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
|
||||
}})}});
|
||||
(async()=>{{
|
||||
await flow.start(); const rendered=list.innerHTML;
|
||||
buttons[1].cb();
|
||||
console.log(JSON.stringify({{status:status.textContent,rendered,pressed:buttons.map(b=>b.attrs['aria-pressed'])}}));
|
||||
}})().catch(e=>{{console.error(e);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
state = json.loads(result.stdout)
|
||||
assert state["status"] == "1 assigned work item ready."
|
||||
assert "Fix mobile queue" in state["rendered"]
|
||||
assert 'href="https://forge.example/issues/7"' in state["rendered"]
|
||||
assert state["pressed"] == ["false", "true"]
|
||||
|
||||
|
||||
def test_progressive_my_work_keeps_supported_queues_truthful_while_planning_loads():
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const names=['all','today','agenda','attention','filed','authored','issue','pull','review','update','later','draft'];
|
||||
const counts=Object.fromEntries(names.map(name=>[name,{{textContent:'0'}}]));
|
||||
const buttons=names.map(name=>({{
|
||||
dataset:{{workFilter:name}},attrs:{{}},
|
||||
addEventListener(_name,cb){{this.cb=cb;}},
|
||||
removeEventListener(){{}},
|
||||
setAttribute(key,value){{this.attrs[key]=value;}},
|
||||
}}));
|
||||
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||
const document={{
|
||||
querySelector(selector){{
|
||||
if(selector==='#my-work-list')return list;
|
||||
if(selector==='#my-work-status')return status;
|
||||
const match=selector.match(/^\\[data-work-count="(.+)"\\]$/);
|
||||
return match?counts[match[1]]:null;
|
||||
}},
|
||||
querySelectorAll:()=>buttons,
|
||||
}};
|
||||
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
|
||||
user:{{login:'timmy'}},
|
||||
issues:[{{number:7,title:'Filed from phone',repository:'stackchain/dashboard',assignees:[],work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
|
||||
pull_requests:[{{number:8,title:'Authored release',repository:'stackchain/dashboard',assignees:[],work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
|
||||
}})}});
|
||||
(async()=>{{
|
||||
await flow.start();
|
||||
buttons[names.indexOf('filed')].cb(); const filed=list.innerHTML;
|
||||
buttons[names.indexOf('authored')].cb(); const authored=list.innerHTML;
|
||||
buttons[names.indexOf('today')].cb(); const today=list.innerHTML;
|
||||
console.log(JSON.stringify({{filed,authored,today,counts:Object.fromEntries(names.map(name=>[name,counts[name].textContent]))}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
state = json.loads(result.stdout)
|
||||
assert "Filed from phone" in state["filed"]
|
||||
assert "Authored release" in state["authored"]
|
||||
assert "Today is still loading" in state["today"]
|
||||
assert "No work" not in state["today"]
|
||||
assert state["counts"] == {
|
||||
"all": "2",
|
||||
"today": "0",
|
||||
"agenda": "0",
|
||||
"attention": "0",
|
||||
"filed": "1",
|
||||
"authored": "1",
|
||||
"issue": "1",
|
||||
"pull": "1",
|
||||
"review": "0",
|
||||
"update": "0",
|
||||
"later": "0",
|
||||
"draft": "0",
|
||||
}
|
||||
|
||||
|
||||
def test_progressive_queue_selection_is_handed_to_the_hydrated_workspace():
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const buttons=['all','filed'].map(name=>({{
|
||||
dataset:{{workFilter:name}},attrs:{{}},removed:false,
|
||||
addEventListener(_name,cb){{this.cb=cb;}},
|
||||
removeEventListener(_name,cb){{if(cb===this.cb)this.removed=true;}},
|
||||
setAttribute(key,value){{this.attrs[key]=value;}},
|
||||
}}));
|
||||
const document={{
|
||||
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
|
||||
querySelectorAll:()=>buttons,
|
||||
}};
|
||||
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{user:{{login:'timmy'}},issues:[],pull_requests:[]}})}});
|
||||
(async()=>{{
|
||||
await flow.start(); buttons[1].cb();
|
||||
const before=flow.handoff(); flow.stop(); const after=flow.handoff();
|
||||
console.log(JSON.stringify({{before,after,removed:buttons.map(button=>button.removed)}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
state = json.loads(result.stdout)
|
||||
assert state == {
|
||||
"before": {"selectedFilter": "filed"},
|
||||
"after": {"selectedFilter": "filed"},
|
||||
"removed": [True, True],
|
||||
}
|
||||
|
||||
dashboard = DASHBOARD.read_text()
|
||||
assert "const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();" in dashboard
|
||||
assert "progressiveWorkHandoff?.selectedFilter" in dashboard
|
||||
@@ -108,6 +108,150 @@ async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_commits_cross_file_suggestions_as_one_atomic_commit():
|
||||
requests = []
|
||||
pull = {
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc1234", "ref": "alex/review-fix", "repo": {"full_name": "stackchain/api"}},
|
||||
"base": {"repo": {"full_name": "stackchain/api"}},
|
||||
}
|
||||
originals = {"src/api.py": ("return empty\n", "blob1"), "tests/test_api.py": ("assert empty\n", "blob2")}
|
||||
updated = {"src/api.py": ("return handled\n", "blob3"), "tests/test_api.py": ("assert handled\n", "blob4")}
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
|
||||
return httpx.Response(200, json=pull)
|
||||
prefix = "/api/v1/repos/stackchain/api/contents/"
|
||||
if request.url.path.startswith(prefix) and request.method == "GET":
|
||||
path = request.url.path.removeprefix(prefix)
|
||||
content, sha = (updated if request.url.params["ref"] == "def5678" else originals)[path]
|
||||
return httpx.Response(200, json=_content_payload(content, sha))
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/contents" and request.method == "POST":
|
||||
body = json.loads(request.content)
|
||||
assert body == {
|
||||
"branch": "alex/review-fix",
|
||||
"message": "fix: apply review suggestions",
|
||||
"files": [
|
||||
{"operation": "update", "path": "src/api.py", "sha": "blob1",
|
||||
"content": base64.b64encode(b"return handled\n").decode()},
|
||||
{"operation": "update", "path": "tests/test_api.py", "sha": "blob2",
|
||||
"content": base64.b64encode(b"assert handled\n").decode()},
|
||||
],
|
||||
}
|
||||
pull["head"]["sha"] = "def5678"
|
||||
return httpx.Response(200, json={"commit": {"sha": "def5678"}})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.commit_authored_pull_feedback_batch(
|
||||
"stackchain/api", 7,
|
||||
[
|
||||
{"path": "src/api.py", "content": "return handled\n", "expected_blob_sha": "blob1"},
|
||||
{"path": "tests/test_api.py", "content": "assert handled\n", "expected_blob_sha": "blob2"},
|
||||
],
|
||||
2, "fix: apply review suggestions", "abc1234",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 7,
|
||||
"paths": ["src/api.py", "tests/test_api.py"],
|
||||
"previous_head_sha": "abc1234", "head_sha": "def5678",
|
||||
"suggestion_count": 2, "message": "fix: apply review suggestions",
|
||||
}
|
||||
assert len([request for request in requests if request == ("POST", "/api/v1/repos/stackchain/api/contents")]) == 1
|
||||
assert not [request for request in requests if request[0] == "PUT"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feedback_batch_api_requires_at_least_two_suggestions(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def commit(*args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
|
||||
json={"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 1,
|
||||
"file": {"path": "src/api.py", "content": "one\n", "expected_blob_sha": "blob1"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feedback_batch_api_accepts_unique_cross_file_manifest(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def commit(*args):
|
||||
calls.append(args)
|
||||
return {
|
||||
"repository": "stackchain/api", "number": 7,
|
||||
"paths": ["src/api.py", "tests/test_api.py"],
|
||||
"previous_head_sha": "abc1234", "head_sha": "def5678",
|
||||
"suggestion_count": 2, "message": "fix: suggestions",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
|
||||
json={
|
||||
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
|
||||
"files": [
|
||||
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
|
||||
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["paths"] == ["src/api.py", "tests/test_api.py"]
|
||||
assert calls[0][2] == [
|
||||
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
|
||||
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feedback_batch_api_rejects_duplicate_file_paths(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def commit(*args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
|
||||
json={
|
||||
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
|
||||
"files": [
|
||||
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
|
||||
{"path": "src/api.py", "content": "other\n", "expected_blob_sha": "blob2"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
|
||||
calls = []
|
||||
@@ -875,6 +1019,34 @@ def test_review_comments_are_bounded_and_require_a_file_and_body():
|
||||
assert all(item["path"] and item["body"] for item in normalized)
|
||||
|
||||
|
||||
def test_review_comments_expose_one_bounded_line_suggestion_only():
|
||||
comments = [
|
||||
{
|
||||
"id": 1,
|
||||
"path": "src/api.py",
|
||||
"new_position": 4,
|
||||
"body": "Handle the empty state:\n```suggestion\nreturn empty_result()\n```",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"path": "src/api.py",
|
||||
"new_position": 7,
|
||||
"body": "Choose one:\n```suggestion\nfirst()\n```\n```suggestion\nsecond()\n```",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"path": "src/api.py",
|
||||
"body": "No stable line:\n```suggestion\nunsafe()\n```",
|
||||
},
|
||||
]
|
||||
|
||||
normalized = gitea_proxy._normalize_review_comments(comments)
|
||||
|
||||
assert normalized[0]["suggestion"] == {"line": 4, "content": "return empty_result()"}
|
||||
assert "suggestion" not in normalized[1]
|
||||
assert "suggestion" not in normalized[2]
|
||||
|
||||
|
||||
def test_review_feedback_preserves_stable_review_and_comment_identities():
|
||||
pull = {"requested_reviewers": []}
|
||||
reviews = [{
|
||||
|
||||
@@ -34,6 +34,10 @@ const followingControl = {
|
||||
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||
};
|
||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
||||
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
|
||||
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
|
||||
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
|
||||
const quietStatus = {set textContent(value) { state.quietText = value; }, get textContent() { return state.quietText; }};
|
||||
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};
|
||||
@@ -48,6 +52,7 @@ const feature = createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
quietControl, quietStart, quietEnd, quietStatus,
|
||||
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; }},
|
||||
@@ -114,6 +119,41 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
|
||||
assert "followingStatus:qs('#push-following-status')" in dashboard
|
||||
|
||||
|
||||
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
quietStart.value = '22:15';
|
||||
quietEnd.value = '07:30';
|
||||
await state.quietStartChange();
|
||||
process.stdout.write(JSON.stringify({requests:state.requests, checked:quietControl.checked, start:quietStart.value, end:quietEnd.value, text:state.quietText}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/quiet-hours", "PUT"]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["start"] == "22:15"
|
||||
assert body["end"] == "07:30"
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["text"] == "Routine alerts paused from 22:15 to 07:30 local time."
|
||||
|
||||
|
||||
def test_device_settings_render_and_wire_mobile_quiet_hours():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="push-quiet-hours" type="checkbox"' in index
|
||||
assert 'id="push-quiet-start" type="time"' in index
|
||||
assert 'id="push-quiet-end" type="time"' in index
|
||||
assert 'id="push-quiet-status" role="status" aria-live="polite"' in index
|
||||
module = MODULE.read_text()
|
||||
assert "querySelector('#push-quiet-hours')" in module
|
||||
assert "querySelector('#push-quiet-start')" in module
|
||||
assert "querySelector('#push-quiet-end')" in module
|
||||
|
||||
|
||||
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'}}};
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
@@ -62,6 +63,67 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
|
||||
assert store.following_preferences("session-b") == {"enabled": False}
|
||||
|
||||
|
||||
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
assert store.quiet_hours("session-a") == {
|
||||
"enabled": True, "start": "22:00", "end": "07:00", "timezone": "UTC"
|
||||
}
|
||||
assert store.claim_unseen({42: "r1"}, now=inside) == []
|
||||
delivery = store.claim_unseen({42: "r1"}, now=after)
|
||||
assert len(delivery) == 1
|
||||
assert delivery[0].thread_revisions == ((42, "r1"),)
|
||||
assert delivery[0].catch_up is True
|
||||
store.mark_delivered("session-a", delivery[0].thread_revisions)
|
||||
assert store.claim_unseen({42: "r1"}, now=after) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_dispatch_sends_one_catch_up_digest_after_quiet_hours(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
assert store.claim_unseen({41: "r1", 42: "r1"}, now=inside) == []
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [
|
||||
{"id": 41, "updated_at": "r1"},
|
||||
{"id": 42, "updated_at": "r1"},
|
||||
]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send, now=after) == 1
|
||||
assert sent == [{
|
||||
"title": "2 updates while alerts were paused",
|
||||
"body": "Open Updates to catch up in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-catch-up",
|
||||
"update_count": 2,
|
||||
"unread_count": 2,
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -109,6 +171,43 @@ async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_
|
||||
assert store.subscription_for_session("revoked") is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_changes_wait_for_quiet_hours_and_resume_as_catch_up(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_following_preferences("session-a", enabled=True)
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
sent = []
|
||||
|
||||
async def following():
|
||||
return {"items": [{
|
||||
"repository": "private/project", "kind": "issue", "number": 42,
|
||||
"title": "Sensitive", "updated_at": "2026-08-25T23:00:00Z",
|
||||
"has_unseen_change": True,
|
||||
}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, now=inside
|
||||
) == 0
|
||||
assert sent == []
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, now=after
|
||||
) == 1
|
||||
assert sent[0]["title"] == "1 watched update while alerts were paused"
|
||||
assert sent[0]["route"] == "#/my-work/following"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -134,6 +233,43 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
|
||||
assert store.start_day_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_persists_validated_quiet_hours(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()))
|
||||
payload = main.QuietHoursPayload(
|
||||
enabled=True, start="22:30", end="06:45", timezone="America/New_York"
|
||||
)
|
||||
|
||||
result = await main.update_quiet_hours(payload, request)
|
||||
|
||||
assert result == {
|
||||
"quiet_hours_enabled": True,
|
||||
"quiet_hours_start": "22:30",
|
||||
"quiet_hours_end": "06:45",
|
||||
"quiet_hours_timezone": "America/New_York",
|
||||
}
|
||||
status = await main.push_status(request)
|
||||
assert {key: status[key] for key in result} == result
|
||||
|
||||
|
||||
def test_quiet_hours_reject_equal_boundaries_and_invalid_timezone():
|
||||
with pytest.raises(ValueError):
|
||||
main.QuietHoursPayload(enabled=True, start="22:00", end="22:00", timezone="UTC")
|
||||
with pytest.raises(ValueError):
|
||||
main.QuietHoursPayload(enabled=True, start="22:00", end="07:00", timezone="Moon/Base")
|
||||
|
||||
|
||||
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
state_dir = tmp_path / "push-state"
|
||||
previous_umask = os.umask(0)
|
||||
@@ -217,6 +353,7 @@ def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
|
||||
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
|
||||
assert disabled.is_subscribed("session-a") is False
|
||||
assert disabled.deadline_preferences("session-a")["enabled"] is False
|
||||
assert disabled.claim_unseen({42: "r1"}, now=0) == []
|
||||
assert not (tmp_path / "disabled.sqlite3").exists()
|
||||
with pytest.raises(RuntimeError, match="encryption key"):
|
||||
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
|
||||
@@ -1435,6 +1572,10 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"following_enabled": False,
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_hours_start": "22:00",
|
||||
"quiet_hours_end": "07:00",
|
||||
"quiet_hours_timezone": "UTC",
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
|
||||
@@ -65,6 +65,32 @@ const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
|
||||
}
|
||||
|
||||
|
||||
def test_search_store_lists_only_confirmed_accounts_photo_drafts_as_metadata():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = [
|
||||
{{id:'mine',version:1,ownerLogin:'timmy',kind:'pull',repository:'stackchain/dashboard',number:19,updatedAt:220,attachments:[{{filename:'proof.jpg',blob:new Blob(['secret'])}}]}},
|
||||
{{id:'other',version:1,ownerLogin:'alexander',kind:'issue',repository:'stackchain/private',number:3,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},
|
||||
{{id:'empty',version:1,ownerLogin:'timmy',kind:'issue',repository:'stackchain/dashboard',number:20,updatedAt:400,attachments:[]}},
|
||||
];
|
||||
const transaction=async operation=>operation === 'list' ? records.map(value=>structuredClone(value)) : null;
|
||||
const store=createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await store.list())))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == [{
|
||||
"id": "mine",
|
||||
"kind": "photo-reply",
|
||||
"label": "Search PR photos",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 19,
|
||||
"title": "stackchain/dashboard#19",
|
||||
"photo_count": 1,
|
||||
"updated_at": 220,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "stackchain/dashboard", "number": 19},
|
||||
"photo_store": "search",
|
||||
}]
|
||||
|
||||
|
||||
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
|
||||
@@ -1389,6 +1389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
"/dashboard/static/photo-draft-inbox.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
@@ -1407,6 +1408,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/progressive-my-work.js",
|
||||
"/dashboard/static/agenda-replan.js",
|
||||
"/dashboard/static/agenda-calendar.js",
|
||||
"/dashboard/static/protect-today.js",
|
||||
@@ -1505,6 +1507,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/device-storage.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-search-modal.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/push-notifications.js",
|
||||
|
||||
@@ -122,6 +122,35 @@ process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:t
|
||||
}
|
||||
|
||||
|
||||
def test_reload_running_checkpoint_cannot_restart_item_during_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, true, [
|
||||
{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();
|
||||
|
||||
@@ -326,6 +326,73 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
|
||||
assert result["remaining"] == 1
|
||||
|
||||
|
||||
def run_mounted_confirmation(*, fail_move: bool = False) -> dict:
|
||||
failure = "true" if fail_move else "false"
|
||||
scenario = """
|
||||
const mount=require('./frontend/today-week-reschedule.js').mount;
|
||||
class Element {
|
||||
constructor(){this.listeners={};this.dataset={};this.children=[];this.open=false;this.disabled=false;this.value='';this.textContent='';}
|
||||
addEventListener(type,handler){(this.listeners[type] ||= []).push(handler);}
|
||||
async emit(type,event={preventDefault(){}}){for(const handler of this.listeners[type]||[])await handler(event);}
|
||||
append(...children){this.children.push(...children);}
|
||||
replaceChildren(...children){this.children=children;}
|
||||
querySelectorAll(selector){return selector==='button' ? this.children : [];}
|
||||
querySelector(){return this.children.find(child=>!child.disabled)||null;}
|
||||
setAttribute(){}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;}
|
||||
focus(){}
|
||||
click(){return this.emit('click');}
|
||||
}
|
||||
const selectors={
|
||||
'#today-week-reschedule':new Element(), '#today-week-reschedule-days':new Element(),
|
||||
'#today-week-reschedule-estimate':new Element(), '#today-week-reschedule-status':new Element(),
|
||||
'#confirm-today-week-reschedule':new Element(), '[data-work-session-reschedule-week]':new Element(),
|
||||
'#cancel-today-week-reschedule':new Element(), '[data-mobile-today-more]':new Element(),
|
||||
};
|
||||
const document={hidden:false,addEventListener(){},createElement:()=>new Element()};
|
||||
const window={addEventListener(){},setTimeout(){}};
|
||||
let finishMove,failMove;
|
||||
const move=new Promise((resolve,reject)=>{finishMove=resolve;failMove=reject;});
|
||||
const today={revision:4,ids:['active'],estimates:{active:30}};
|
||||
const weekState={revision:7,offline_snapshot:false,days:[]};
|
||||
const week={load:async()=>weekState,review:()=>({days:[{
|
||||
plan_date:'2026-08-27',label:'Thu',ids:[],planned_minutes:0,capacity_minutes:90,eligible:true,
|
||||
}]}),adopt(){}};
|
||||
const api=async(url)=>url==='api/v1/today' ? today : move;
|
||||
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
|
||||
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{},warm(){},
|
||||
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
|
||||
await selectors['[data-work-session-reschedule-week]'].emit('click');
|
||||
await selectors['#today-week-reschedule-days'].children[0].emit('click');
|
||||
const confirming=selectors['#confirm-today-week-reschedule'].emit('click');
|
||||
await Promise.resolve();
|
||||
const openWhileSaving=selectors['#today-week-reschedule'].open;
|
||||
if(__FAIL_MOVE__){
|
||||
const error=new Error('Move unavailable. Try again.');error.status=500;failMove(error);
|
||||
}else{
|
||||
finishMove({today:{revision:5,ids:[],estimates:{}},week:{revision:8,days:[]}});
|
||||
}
|
||||
await confirming;
|
||||
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
|
||||
"""
|
||||
return run_controller(scenario.replace("__FAIL_MOVE__", failure))
|
||||
|
||||
|
||||
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
|
||||
assert run_mounted_confirmation() == {
|
||||
"openWhileSaving": False,
|
||||
"openAfterSettled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_reschedule_dialog_reopens_with_retry_state_when_confirm_fails():
|
||||
assert run_mounted_confirmation(fail_move=True) == {
|
||||
"openWhileSaving": False,
|
||||
"openAfterSettled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
@@ -82,6 +82,7 @@ async def test_dashboard_mounts_mobile_new_activity_positioning_flow():
|
||||
assert '<script src="static/update-read-position.js"></script>' in html
|
||||
assert 'id="jump-update-new-activity"' in html
|
||||
assert "updateReadPosition.open(String(item.notification_id))" in html
|
||||
assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html
|
||||
source = html + (POSITIONER.parent / "conversation.js").read_text()
|
||||
assert "updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest)" in source
|
||||
assert ".update-new-activity" in html
|
||||
assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
|
||||
@@ -24,6 +24,7 @@ def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
|
||||
const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-work-core"]') return {content:'feature-work-core-123.js'};
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
@@ -37,6 +38,7 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"requested": [
|
||||
"work-core:feature-work-core-123.js",
|
||||
"today-timer:feature-workspace-abc.js",
|
||||
"planning:feature-planning-def.js",
|
||||
],
|
||||
@@ -44,6 +46,30 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
|
||||
result = run_bootstrap("""
|
||||
const document={querySelector(selector) {
|
||||
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
||||
}};
|
||||
const requested=[]; const releases={};
|
||||
const createLoader=()=>({load:name=>{
|
||||
requested.push(name);
|
||||
if (name === 'work-core') return Promise.resolve();
|
||||
return new Promise(resolve=>{releases[name]=resolve;});
|
||||
}});
|
||||
const lifecycle=await loadWorkspace({document,createLoader});
|
||||
const returned=requested.slice();
|
||||
releases['today-timer'](); releases.planning();
|
||||
await lifecycle.optionalReady;
|
||||
console.log(JSON.stringify({returned,settled:requested}));
|
||||
""")
|
||||
assert result == {
|
||||
"returned": ["work-core", "today-timer", "planning"],
|
||||
"settled": ["work-core", "today-timer", "planning"],
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
||||
@@ -72,7 +98,7 @@ const document={querySelector(selector) {
|
||||
return null;
|
||||
}};
|
||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||
@@ -81,7 +107,7 @@ await Promise.all([first,second,loading]);
|
||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 6,
|
||||
"attempts": 5,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
@@ -116,7 +142,7 @@ const window={
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const waiting=Boolean(listeners.online);
|
||||
@@ -125,7 +151,7 @@ await loading;
|
||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 6,
|
||||
"attempts": 5,
|
||||
"waiting": True,
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
|
||||
Reference in new issue
Block a user