feat: add mobile Following queue for watched issues (Closes #1293)
This commit is contained in:
parent
4b4e55dbe2
commit
5c43a5678a
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
pip install -r requirements-e2e.txt
|
pip install -r requirements-e2e.txt
|
||||||
python3 -m playwright install --with-deps chromium
|
python3 -m playwright install --with-deps chromium
|
||||||
- name: Exercise packaged mobile work journeys
|
- name: Exercise packaged mobile work journeys
|
||||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py -q
|
||||||
|
|
||||||
release-candidate:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,15 @@ device can reopen the exact Search with one tap while stale writes surface a con
|
||||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||||
`.stackchain-state/saved-searches.sqlite3` path.
|
`.stackchain-state/saved-searches.sqlite3` path.
|
||||||
|
|
||||||
|
Confirmed **Watch issue** actions also feed the mobile **Following** queue, completing the
|
||||||
|
Search → watch → revisit flow without assigning or scheduling work. Following is a read-first,
|
||||||
|
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
|
||||||
|
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
|
||||||
|
**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection
|
||||||
|
unchanged. Following counts never influence the recommended Work queue. Set
|
||||||
|
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||||
|
|
||||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||||
|
|
|
||||||
|
|
@ -1083,6 +1083,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
|
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
|
||||||
|
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
|
||||||
|
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||||
|
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||||
|
.following-panel h2, .following-panel p { margin:0; }
|
||||||
|
.following-panel header button, #retry-following { min-height:44px; }
|
||||||
|
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
|
||||||
|
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
|
||||||
|
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
|
||||||
|
.following-card strong, .following-card small { overflow-wrap:anywhere; }
|
||||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||||
.markdown-content > :first-child { margin-top:0; }
|
.markdown-content > :first-child { margin-top:0; }
|
||||||
.markdown-content > :last-child { margin-bottom:0; }
|
.markdown-content > :last-child { margin-bottom:0; }
|
||||||
|
|
@ -1367,6 +1377,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||||
|
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
|
||||||
|
|
||||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@
|
||||||
}
|
}
|
||||||
let mobileQueueCounts = {};
|
let mobileQueueCounts = {};
|
||||||
let mobilePreparationItems = {};
|
let mobilePreparationItems = {};
|
||||||
|
const followingQueue = attachFollowing(item => searchPreview.open(item));
|
||||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||||
getIndex: item => lastDrafts.indexOf(item),
|
getIndex: item => lastDrafts.indexOf(item),
|
||||||
|
|
@ -132,6 +133,7 @@
|
||||||
openToday: () => mobileWorkEntry.open(),
|
openToday: () => mobileWorkEntry.open(),
|
||||||
openAgenda: openAgendaSession,
|
openAgenda: openAgendaSession,
|
||||||
openUpdates: openUpdateTriage,
|
openUpdates: openUpdateTriage,
|
||||||
|
openFollowing:followingQueue.open,
|
||||||
openFiled: openFiledFollowUp,
|
openFiled: openFiledFollowUp,
|
||||||
selectFilter: selectMobileQueue,
|
selectFilter: selectMobileQueue,
|
||||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||||
|
|
@ -5719,7 +5721,8 @@
|
||||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||||
),
|
),
|
||||||
watch:searchSubscription.watch,
|
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||||||
|
followingQueue.load().catch(() => {}).then(() => result)),
|
||||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||||
queueReply:async (item,body,operationId) => {
|
queueReply:async (item,body,operationId) => {
|
||||||
searchReplyAttachmentTarget = item;
|
searchReplyAttachmentTarget = item;
|
||||||
|
|
|
||||||
108
frontend/following.js
Normal file
108
frontend/following.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const exports = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = exports.createFollowing;
|
||||||
|
module.exports.attachFollowing = exports.attachFollowing;
|
||||||
|
} else {
|
||||||
|
root.createFollowing = exports.createFollowing;
|
||||||
|
root.attachFollowing = exports.attachFollowing;
|
||||||
|
}
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
|
function createFollowing(options) {
|
||||||
|
let generation = 0;
|
||||||
|
let snapshot = {revision:0, items:[]};
|
||||||
|
|
||||||
|
function publish(status, error) {
|
||||||
|
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||||
|
if (error) state.error = error;
|
||||||
|
options.render?.(state);
|
||||||
|
if (status === 'ready') options.onCount?.(snapshot.items.length);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestGeneration = ++generation;
|
||||||
|
publish('loading');
|
||||||
|
try {
|
||||||
|
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||||
|
if (requestGeneration !== generation) return snapshot;
|
||||||
|
snapshot = {
|
||||||
|
revision:Number(result?.revision) || 0,
|
||||||
|
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [],
|
||||||
|
};
|
||||||
|
publish('ready');
|
||||||
|
return snapshot;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestGeneration === generation) publish('error', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(index) {
|
||||||
|
const item = snapshot.items[Number(index)];
|
||||||
|
if (!item) return false;
|
||||||
|
Promise.resolve(options.onOpen?.({...item, kind:'issue'})).catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {load, open, count:() => snapshot.items.length};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFollowing(onOpen) {
|
||||||
|
const document = globalThis.document;
|
||||||
|
const query = selector => document.querySelector(selector);
|
||||||
|
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||||
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||||
|
const formatTime = value => new Date(value).toLocaleString();
|
||||||
|
const fetchJson = async (url, options) => {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
let feature;
|
||||||
|
function render(state) {
|
||||||
|
const list = query('#following-list');
|
||||||
|
const status = query('#following-status');
|
||||||
|
query('#retry-following').hidden = state.status !== 'error';
|
||||||
|
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
|
||||||
|
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||||
|
status.textContent = state.items.length
|
||||||
|
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
|
||||||
|
: 'No watched issues yet. Watch one from Search to keep it here.';
|
||||||
|
list.innerHTML = state.items.map((item, index) =>
|
||||||
|
'<button class="following-card" type="button" data-following-index="' + index + '"><span><strong>' +
|
||||||
|
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number +
|
||||||
|
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||||
|
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||||
|
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||||
|
query('#following-sheet').close();
|
||||||
|
feature.open(Number(button.dataset.followingIndex));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
feature = createFollowing({
|
||||||
|
fetchJson, render,
|
||||||
|
onCount:count => {
|
||||||
|
const value = query('[data-mobile-queue-count="following"]');
|
||||||
|
value.textContent = count;
|
||||||
|
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||||
|
(count === 1 ? ' watched issue' : ' watched issues'));
|
||||||
|
},
|
||||||
|
onOpen,
|
||||||
|
});
|
||||||
|
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||||
|
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||||
|
feature.load().catch(() => {});
|
||||||
|
return {
|
||||||
|
load:feature.load,
|
||||||
|
open() {
|
||||||
|
const sheet = query('#following-sheet');
|
||||||
|
if (!sheet.open) sheet.showModal();
|
||||||
|
feature.load().catch(() => {});
|
||||||
|
return 'opened-following';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {createFollowing, attachFollowing};
|
||||||
|
});
|
||||||
|
|
@ -2016,6 +2016,7 @@
|
||||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||||
|
<button data-mobile-queue="following" type="button" aria-label="Following, 0 watched issues"><span><strong>Following</strong><small>Issues you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||||
|
|
@ -2025,6 +2026,15 @@
|
||||||
</section>
|
</section>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
|
||||||
|
<section class="following-panel">
|
||||||
|
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
|
||||||
|
<p id="following-status" class="small" role="status" aria-live="polite"></p>
|
||||||
|
<div id="following-list" class="following-list"></div>
|
||||||
|
<button id="retry-following" type="button" hidden>Retry</button>
|
||||||
|
</section>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||||
<section class="mobile-delivery-recovery-panel">
|
<section class="mobile-delivery-recovery-panel">
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -2076,6 +2086,7 @@
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/saved-searches.js"></script>
|
<script src="static/saved-searches.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
|
<script src="static/following.js"></script>
|
||||||
<script src="static/search-reply-draft-store.js"></script>
|
<script src="static/search-reply-draft-store.js"></script>
|
||||||
<script src="static/conversation-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/conversation-photo-drafts.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
if (name === 'today') return options.openToday();
|
if (name === 'today') return options.openToday();
|
||||||
if (name === 'agenda') return options.openAgenda();
|
if (name === 'agenda') return options.openAgenda();
|
||||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||||
|
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||||
options.selectFilter(name);
|
options.selectFilter(name);
|
||||||
const action = options.firstAction(name);
|
const action = options.firstAction(name);
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
});
|
});
|
||||||
root.searchPreviewWatchStatus = state => ({
|
root.searchPreviewWatchStatus = state => ({
|
||||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||||
watched:'Watching. Future activity will appear in Updates.',
|
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||||
unwatched:'Stopped watching. Assignment and planning are unchanged.',
|
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||||
})[state.status] || '';
|
})[state.status] || '';
|
||||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ const SHELL = [
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
BASE + 'static/saved-searches.js',
|
BASE + 'static/saved-searches.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
|
BASE + 'static/following.js',
|
||||||
BASE + 'static/search-reply-draft-store.js',
|
BASE + 'static/search-reply-draft-store.js',
|
||||||
BASE + 'static/conversation-reply-draft-store.js',
|
BASE + 'static/conversation-reply-draft-store.js',
|
||||||
BASE + 'static/conversation-photo-drafts.js',
|
BASE + 'static/conversation-photo-drafts.js',
|
||||||
|
|
|
||||||
154
src/following_store.py
Normal file
154
src/following_store.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||||
|
|
||||||
|
|
||||||
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
_STATES = {"open", "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
class FollowingStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
encryption_key: bytes | None = None,
|
||||||
|
):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.limit = limit
|
||||||
|
self.timeout = timeout
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||||
|
store="following",
|
||||||
|
)
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS following_issues (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
items TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = str(login).strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||||
|
if row is None:
|
||||||
|
return {"revision": 0, "items": []}, False
|
||||||
|
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
return {"revision": int(row[0]), "items": items}, legacy
|
||||||
|
|
||||||
|
def _seal(self, login: str, items: list[dict]) -> str:
|
||||||
|
return self._cipher.seal(items, binding=f"items:{login}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_item(raw: dict) -> dict:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("following item must be an object")
|
||||||
|
repository = raw.get("repository")
|
||||||
|
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||||
|
raise ValueError("repository is invalid")
|
||||||
|
number = raw.get("number")
|
||||||
|
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||||
|
raise ValueError("number is invalid")
|
||||||
|
title = raw.get("title")
|
||||||
|
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||||
|
raise ValueError("title is invalid")
|
||||||
|
state = raw.get("state")
|
||||||
|
if state not in _STATES:
|
||||||
|
raise ValueError("state is invalid")
|
||||||
|
updated_at = raw.get("updated_at")
|
||||||
|
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
|
||||||
|
raise ValueError("updated_at is invalid")
|
||||||
|
url = raw.get("url")
|
||||||
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
|
||||||
|
raise ValueError("url is invalid")
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"title": title.strip(),
|
||||||
|
"state": state,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _identity(item: dict) -> tuple[str, int]:
|
||||||
|
return item["repository"].lower(), item["number"]
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
snapshot, legacy = self._snapshot(row, login)
|
||||||
|
if row is not None and legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
|
||||||
|
(self._seal(login, snapshot["items"]), login, row[1]),
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
item = self._normalize_item(raw_item)
|
||||||
|
if not isinstance(watching, bool):
|
||||||
|
raise ValueError("watching is invalid")
|
||||||
|
identity = self._identity(item)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
current, _legacy = self._snapshot(row, login)
|
||||||
|
items = list(current["items"])
|
||||||
|
index = next(
|
||||||
|
(position for position, candidate in enumerate(items)
|
||||||
|
if self._identity(candidate) == identity),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if watching:
|
||||||
|
if index is None:
|
||||||
|
if len(items) >= self.limit:
|
||||||
|
raise ValueError(f"following is limited to {self.limit} issues")
|
||||||
|
items.insert(0, item)
|
||||||
|
elif items[index] == item:
|
||||||
|
return current
|
||||||
|
else:
|
||||||
|
items.pop(index)
|
||||||
|
items.insert(0, item)
|
||||||
|
elif index is None:
|
||||||
|
return current
|
||||||
|
else:
|
||||||
|
items.pop(index)
|
||||||
|
revision = current["revision"] + 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
|
||||||
|
(login, revision, self._seal(login, items)),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "items": items}
|
||||||
|
|
@ -39,7 +39,7 @@ FEATURE_SOURCES = {
|
||||||
"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",
|
"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": (
|
"today-timer": (
|
||||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/following.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/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/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
|
|
|
||||||
54
src/main.py
54
src/main.py
|
|
@ -63,6 +63,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||||
from src.push_subscription_store import build_push_subscription_store
|
from src.push_subscription_store import build_push_subscription_store
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
|
from src.following_store import FollowingStore
|
||||||
from src.unfiled_draft_store import (
|
from src.unfiled_draft_store import (
|
||||||
UnfiledDraftConflict,
|
UnfiledDraftConflict,
|
||||||
UnfiledDraftEncryptionError,
|
UnfiledDraftEncryptionError,
|
||||||
|
|
@ -2557,6 +2558,12 @@ def _saved_search_store() -> SavedSearchStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _following_store() -> FollowingStore:
|
||||||
|
return FollowingStore(
|
||||||
|
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||||
return CompletedFiledReviewStore(
|
return CompletedFiledReviewStore(
|
||||||
os.getenv(
|
os.getenv(
|
||||||
|
|
@ -2601,6 +2608,21 @@ async def _confirmed_login() -> str:
|
||||||
return login.strip().lower()
|
return login.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/following")
|
||||||
|
async def get_following(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_following_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Following synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/completed-filed-reviews")
|
@app.get("/api/v1/completed-filed-reviews")
|
||||||
async def get_completed_filed_reviews(response: Response):
|
async def get_completed_filed_reviews(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
@ -3662,7 +3684,7 @@ async def global_search_preview(
|
||||||
|
|
||||||
async def _search_preview_subscription_target(
|
async def _search_preview_subscription_target(
|
||||||
owner: str, repo: str, number: int, kind: str
|
owner: str, repo: str, number: int, kind: str
|
||||||
) -> str:
|
) -> tuple[str, dict]:
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||||
if (
|
if (
|
||||||
|
|
@ -3673,7 +3695,7 @@ async def _search_preview_subscription_target(
|
||||||
or preview.get("state") != "open"
|
or preview.get("state") != "open"
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Watchable search result not found")
|
raise HTTPException(status_code=404, detail="Watchable search result not found")
|
||||||
return repository
|
return repository, preview
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
|
||||||
|
|
@ -3684,7 +3706,7 @@ async def global_search_preview_subscription(
|
||||||
kind: Literal["issue", "pull"] = Query(),
|
kind: Literal["issue", "pull"] = Query(),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
try:
|
try:
|
||||||
repository = await _search_preview_subscription_target(owner, repo, number, kind)
|
repository, _preview = await _search_preview_subscription_target(owner, repo, number, kind)
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
gitea_proxy.issue_subscription(repository, number),
|
gitea_proxy.issue_subscription(repository, number),
|
||||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||||
|
|
@ -3713,11 +3735,29 @@ async def mutate_global_search_preview_subscription(
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
watching = request.method == "PUT"
|
watching = request.method == "PUT"
|
||||||
try:
|
try:
|
||||||
repository = await _search_preview_subscription_target(owner, repo, number, kind)
|
repository, preview = await _search_preview_subscription_target(owner, repo, number, kind)
|
||||||
|
login = await _confirmed_login()
|
||||||
|
store = _following_store()
|
||||||
|
await asyncio.to_thread(store.get, login)
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
gitea_proxy.set_issue_subscription(repository, number, watching),
|
gitea_proxy.set_issue_subscription(repository, number, watching),
|
||||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
if result.get("watching") is not watching:
|
||||||
|
raise RuntimeError("Gitea did not confirm subscription state")
|
||||||
|
following = await asyncio.to_thread(
|
||||||
|
store.set_watching,
|
||||||
|
login,
|
||||||
|
{
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"title": preview.get("title", ""),
|
||||||
|
"state": preview.get("state", ""),
|
||||||
|
"updated_at": preview.get("updated_at", ""),
|
||||||
|
"url": preview.get("url", ""),
|
||||||
|
},
|
||||||
|
watching,
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -3726,7 +3766,11 @@ async def mutate_global_search_preview_subscription(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Retry-After": "1"},
|
headers={"Retry-After": "1"},
|
||||||
)
|
)
|
||||||
return JSONResponse(result)
|
return JSONResponse({
|
||||||
|
**result,
|
||||||
|
"following_revision": following["revision"],
|
||||||
|
"following_count": len(following["items"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
|
||||||
|
|
|
||||||
36
tests/e2e/test_mobile_following_release.py
Normal file
36
tests/e2e/test_mobile_following_release.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||||
|
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
|
||||||
|
pytest.importorskip("playwright.sync_api")
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
FRONTEND = ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_queue_is_phone_usable_at_narrow_viewport():
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||||
|
page.set_content((FRONTEND / "index.html").read_text())
|
||||||
|
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||||
|
|
||||||
|
row = page.locator('[data-mobile-queue="following"]')
|
||||||
|
expect(row).to_have_count(1)
|
||||||
|
page.locator("#following-list").evaluate("""node => {
|
||||||
|
node.innerHTML = '<button class="following-card" type="button"><span><strong>Quiet issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true">›</span></button>';
|
||||||
|
}""")
|
||||||
|
page.locator("#following-sheet").evaluate("node => node.showModal()")
|
||||||
|
|
||||||
|
expect(page.locator("#following-sheet")).to_be_visible()
|
||||||
|
expect(page.locator(".following-card")).to_be_visible()
|
||||||
|
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||||
|
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||||
|
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||||
|
assert overflow is False
|
||||||
|
browser.close()
|
||||||
|
|
@ -71,7 +71,8 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
|
||||||
|
"tests/e2e/test_mobile_following_release.py -q"
|
||||||
) in browser
|
) in browser
|
||||||
assert "needs: [lint, build-release, browser-journey]" in release
|
assert "needs: [lint, build-release, browser-journey]" in release
|
||||||
|
|
||||||
|
|
|
||||||
55
tests/test_following_api.py
Normal file
55
tests/test_following_api.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.following_store import FollowingStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
|
||||||
|
detail = {
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"kind": "issue",
|
||||||
|
"number": 42,
|
||||||
|
"title": "Make mobile review useful",
|
||||||
|
"state": "open",
|
||||||
|
"updated_at": "2026-08-23T03:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/42",
|
||||||
|
"claimable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
|
||||||
|
return detail
|
||||||
|
|
||||||
|
async def set_subscription(repository, number, watching):
|
||||||
|
return {"watching": watching}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "Timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store, raising=False)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
watched = await client.put(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||||
|
)
|
||||||
|
following = await client.get("/api/v1/following")
|
||||||
|
|
||||||
|
assert watched.status_code == 200
|
||||||
|
assert watched.json() == {"watching": True, "following_revision": 1, "following_count": 1}
|
||||||
|
assert following.status_code == 200
|
||||||
|
assert following.headers["cache-control"] == "no-store"
|
||||||
|
assert following.json() == {"revision": 1, "items": [{
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"number": 42,
|
||||||
|
"title": "Make mobile review useful",
|
||||||
|
"state": "open",
|
||||||
|
"updated_at": "2026-08-23T03:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/42",
|
||||||
|
}]}
|
||||||
71
tests/test_following_frontend.py
Normal file
71
tests/test_following_frontend.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
MODULE = ROOT / "frontend" / "following.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run(script: str) -> dict:
|
||||||
|
harness = f"""
|
||||||
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
|
||||||
|
const feature = createFollowing({{
|
||||||
|
fetchJson: async path => {{
|
||||||
|
state.requests.push(path);
|
||||||
|
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42'}}]}};
|
||||||
|
}},
|
||||||
|
render: snapshot => state.renders.push(snapshot),
|
||||||
|
onCount: count => state.counts.push(count),
|
||||||
|
onOpen: item => state.opened.push(item),
|
||||||
|
}});
|
||||||
|
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_loads_account_collection_and_opens_existing_preview():
|
||||||
|
result = run("""
|
||||||
|
await feature.load();
|
||||||
|
feature.open(0);
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["requests"] == ["api/v1/following"]
|
||||||
|
assert result["counts"] == [1]
|
||||||
|
assert result["renders"][-1]["status"] == "ready"
|
||||||
|
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
|
||||||
|
assert result["opened"] == [{
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"number": 42,
|
||||||
|
"title": "Quiet issue",
|
||||||
|
"state": "open",
|
||||||
|
"updated_at": "2026-08-23T03:00:00Z",
|
||||||
|
"url": "https://forge.example/issue/42",
|
||||||
|
"kind": "issue",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||||
|
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||||
|
script = f"""
|
||||||
|
const createLauncher = require({json.dumps(str(launcher))});
|
||||||
|
const calls = [];
|
||||||
|
const feature = createLauncher({{
|
||||||
|
getCounts:() => ({{following:7}}),
|
||||||
|
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
|
||||||
|
selectFilter:name => calls.push(name), firstAction:() => null,
|
||||||
|
announce:() => {{}}, openFindWork:() => calls.push('find'),
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True
|
||||||
|
).stdout)
|
||||||
|
assert result == {
|
||||||
|
"opened": "opened-following",
|
||||||
|
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||||
|
"calls": ["following"],
|
||||||
|
}
|
||||||
30
tests/test_following_store.py
Normal file
30
tests/test_following_store.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from src.following_store import FollowingStore
|
||||||
|
|
||||||
|
|
||||||
|
KEY = b"f" * 32
|
||||||
|
ITEM = {
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"number": 42,
|
||||||
|
"title": "Make mobile review useful",
|
||||||
|
"state": "open",
|
||||||
|
"updated_at": "2026-08-23T03:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||||
|
path = tmp_path / "following.sqlite3"
|
||||||
|
store = FollowingStore(path, encryption_key=KEY, limit=50)
|
||||||
|
|
||||||
|
first = store.set_watching("Timmy", ITEM, True)
|
||||||
|
repeated = store.set_watching("timmy", ITEM, True)
|
||||||
|
|
||||||
|
assert first == repeated == {"revision": 1, "items": [ITEM]}
|
||||||
|
assert store.get("other") == {"revision": 0, "items": []}
|
||||||
|
stored = sqlite3.connect(path).execute(
|
||||||
|
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
||||||
|
).fetchone()[0]
|
||||||
|
assert ITEM["title"] not in stored
|
||||||
|
assert ITEM["repository"] not in stored
|
||||||
|
|
@ -251,19 +251,26 @@ async def test_search_preview_subscription_reads_server_truth_without_caching(mo
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
||||||
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
||||||
monkeypatch, method, watching
|
monkeypatch, tmp_path, method, watching
|
||||||
):
|
):
|
||||||
calls = []
|
calls = []
|
||||||
|
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||||
|
|
||||||
async def preview(repository, kind, number):
|
async def preview(repository, kind, number):
|
||||||
calls.append(("preview", repository, kind, number))
|
calls.append(("preview", repository, kind, number))
|
||||||
return {"repository": repository, "kind": kind, "number": number,
|
return {"repository": repository, "kind": kind, "number": number,
|
||||||
"state": "open", "claimable": True}
|
"title": "Quiet issue", "state": "open", "claimable": True,
|
||||||
|
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
|
||||||
|
|
||||||
async def set_subscription(repository, number, desired):
|
async def set_subscription(repository, number, desired):
|
||||||
calls.append(("set", repository, number, desired))
|
calls.append(("set", repository, number, desired))
|
||||||
return {"watching": desired}
|
return {"watching": desired}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
@ -275,7 +282,12 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["cache-control"] == "no-store"
|
assert response.headers["cache-control"] == "no-store"
|
||||||
assert response.json() == {"watching": watching}
|
expected_count = 1 if watching else 0
|
||||||
|
assert response.json() == {
|
||||||
|
"watching": watching,
|
||||||
|
"following_revision": expected_count,
|
||||||
|
"following_count": expected_count,
|
||||||
|
}
|
||||||
assert calls == [
|
assert calls == [
|
||||||
("preview", "stackchain/api", "issue", 42),
|
("preview", "stackchain/api", "issue", 42),
|
||||||
("set", "stackchain/api", 42, watching),
|
("set", "stackchain/api", 42, watching),
|
||||||
|
|
|
||||||
|
|
@ -1333,6 +1333,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
"/dashboard/static/saved-searches.js",
|
"/dashboard/static/saved-searches.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
|
"/dashboard/static/following.js",
|
||||||
"/dashboard/static/search-reply-draft-store.js",
|
"/dashboard/static/search-reply-draft-store.js",
|
||||||
"/dashboard/static/conversation-reply-draft-store.js",
|
"/dashboard/static/conversation-reply-draft-store.js",
|
||||||
"/dashboard/static/conversation-photo-drafts.js",
|
"/dashboard/static/conversation-photo-drafts.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user