Merge pull request 'Follow pull requests from mobile Search into Following' (#1308) from timmy/1307-follow-pulls-from-search into main
This commit is contained in:
commit
4e3f67df3d
20
README.md
20
README.md
|
|
@ -108,16 +108,16 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
|
||||||
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 on any open issue also feed the mobile **Following** queue,
|
Confirmed **Watch issue** and **Watch pull request** actions on open Search results feed the mobile
|
||||||
including work already assigned to you or a teammate. This completes the Search → watch → revisit
|
**Following** queue, including work already assigned to you or a teammate. This completes the
|
||||||
flow without changing ownership or scheduling work. Following is a read-first,
|
Search → watch → revisit flow without changing ownership, review assignment, or scheduling work.
|
||||||
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
|
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||||
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
|
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||||
**Stop watching** removes an open item. When watched work closes, the sequential review exposes
|
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||||
**Stop watching & next** so the completed item can be retired without leaving the preview; the next
|
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
|
||||||
captured change opens immediately, and retiring the final item completes the Following phase. Failed
|
retired without leaving the preview; the next captured change opens immediately, and retiring the
|
||||||
or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following
|
final item completes the Following phase. Failed or unconfirmed Gitea mutations leave the collection
|
||||||
counts never influence the recommended Work queue. Set
|
and current review position unchanged. Following counts never influence the recommended Work queue. Set
|
||||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
let snapshot = {revision:0, items:[]};
|
let snapshot = {revision:0, items:[]};
|
||||||
let review = null;
|
let review = null;
|
||||||
|
|
||||||
|
function sameItem(left, right) {
|
||||||
|
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
|
||||||
|
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
|
||||||
|
}
|
||||||
|
|
||||||
function publish(status, error) {
|
function publish(status, error) {
|
||||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||||
state.degraded = snapshot.degraded === true;
|
state.degraded = snapshot.degraded === true;
|
||||||
|
|
@ -36,7 +41,8 @@
|
||||||
if (requestGeneration !== generation) return snapshot;
|
if (requestGeneration !== generation) return snapshot;
|
||||||
snapshot = {
|
snapshot = {
|
||||||
revision:Number(result?.revision) || 0,
|
revision:Number(result?.revision) || 0,
|
||||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [],
|
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
|
||||||
|
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
|
||||||
degraded:result?.degraded === true,
|
degraded:result?.degraded === true,
|
||||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||||
};
|
};
|
||||||
|
|
@ -49,13 +55,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acknowledge(item) {
|
async function acknowledge(item) {
|
||||||
const current = snapshot.items.find(candidate =>
|
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||||
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
|
||||||
candidate.updated_at === item?.updated_at);
|
candidate.updated_at === item?.updated_at);
|
||||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||||
await options.onAcknowledge(current);
|
await options.onAcknowledge(current);
|
||||||
current.has_unseen_change = false;
|
current.has_unseen_change = false;
|
||||||
review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at);
|
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
|
||||||
publish('ready');
|
publish('ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +68,7 @@
|
||||||
async function open(index) {
|
async function open(index) {
|
||||||
const item = snapshot.items[Number(index)];
|
const item = snapshot.items[Number(index)];
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
await options.onOpen?.({...item, kind:'issue', following:true});
|
await options.onOpen?.({...item, following:true});
|
||||||
await acknowledge(item);
|
await acknowledge(item);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -71,11 +76,10 @@
|
||||||
async function startReview() {
|
async function startReview() {
|
||||||
const items = snapshot.items
|
const items = snapshot.items
|
||||||
.filter(item => item.has_unseen_change === true)
|
.filter(item => item.has_unseen_change === true)
|
||||||
.map(item => ({...item, kind:'issue', following:true}));
|
.map(item => ({...item, following:true}));
|
||||||
if (!items.length) return false;
|
if (!items.length) return false;
|
||||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||||
await open(snapshot.items.indexOf(snapshot.items.find(item =>
|
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||||
item.repository === items[0].repository && Number(item.number) === Number(items[0].number))));
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,8 +96,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function retire(item) {
|
function retire(item) {
|
||||||
const same = candidate => candidate.repository === item?.repository &&
|
const same = candidate => sameItem(candidate, item);
|
||||||
Number(candidate.number) === Number(item?.number);
|
|
||||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
|
|
@ -140,21 +143,22 @@
|
||||||
const status = query('#following-status');
|
const status = query('#following-status');
|
||||||
const reviewButton = query('#review-following');
|
const reviewButton = query('#review-following');
|
||||||
query('#retry-following').hidden = state.status !== 'error';
|
query('#retry-following').hidden = state.status !== 'error';
|
||||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
|
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
|
||||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||||
reviewButton.hidden = unseen === 0;
|
reviewButton.hidden = unseen === 0;
|
||||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||||
status.textContent = (state.reviewSummary
|
status.textContent = (state.reviewSummary
|
||||||
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
||||||
: '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||||
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
|
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||||
: 'No watched issues yet. Watch one from Search to keep it here.');
|
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||||
list.innerHTML = state.items.map((item, index) =>
|
list.innerHTML = state.items.map((item, index) =>
|
||||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||||
'" type="button" data-following-index="' + index + '"><span>' +
|
'" type="button" data-following-index="' + index + '"><span>' +
|
||||||
(item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
|
(item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
|
||||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number +
|
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||||||
|
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||||
|
|
@ -176,7 +180,8 @@
|
||||||
onAcknowledge:item => {
|
onAcknowledge:item => {
|
||||||
const [owner, repo] = item.repository.split('/');
|
const [owner, repo] = item.repository.split('/');
|
||||||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||||||
encodeURIComponent(repo) + '/issues/' + item.number + '/seen', {
|
encodeURIComponent(repo) + '/issues/' + item.number +
|
||||||
|
'/seen?kind=' + encodeURIComponent(item.kind), {
|
||||||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||||||
body:JSON.stringify({updated_at:item.updated_at}),
|
body:JSON.stringify({updated_at:item.updated_at}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2016,7 +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="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
<button data-mobile-queue="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>
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@
|
||||||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||||
const options = {
|
const options = {
|
||||||
load:async detail => {
|
load:async detail => {
|
||||||
if (detail.kind === 'issue' && detail.state === 'closed' && detail.following === true) {
|
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||||||
return {...detail, watching:true};
|
return {...detail, watching:true};
|
||||||
}
|
}
|
||||||
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail;
|
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||||
return {...detail, watching:result.watching === true};
|
return {...detail, watching:result.watching === true};
|
||||||
},
|
},
|
||||||
|
|
@ -57,15 +57,17 @@
|
||||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.',
|
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||||||
|
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
'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) => {
|
||||||
const retiring = detail.kind === 'issue' && detail.state === 'closed' &&
|
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||||||
|
const retiring = watchableKind && detail.state === 'closed' &&
|
||||||
detail.following === true && detail.watching === true;
|
detail.following === true && detail.watching === true;
|
||||||
button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring;
|
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||||
button.textContent = retiring ? 'Stop watching & next' :
|
button.textContent = retiring ? 'Stop watching & next' :
|
||||||
(detail.watching ? 'Stop watching' : 'Watch issue');
|
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||||
};
|
};
|
||||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v134';
|
const CACHE = 'stackchain-dashboard-shell-v135';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError
|
||||||
|
|
||||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
_STATES = {"open", "closed"}
|
_STATES = {"open", "closed"}
|
||||||
|
_KINDS = {"issue", "pull"}
|
||||||
|
|
||||||
|
|
||||||
class FollowingStore:
|
class FollowingStore:
|
||||||
|
|
@ -74,6 +75,9 @@ class FollowingStore:
|
||||||
number = raw.get("number")
|
number = raw.get("number")
|
||||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||||
raise ValueError("number is invalid")
|
raise ValueError("number is invalid")
|
||||||
|
kind = raw.get("kind", "issue")
|
||||||
|
if kind not in _KINDS:
|
||||||
|
raise ValueError("kind is invalid")
|
||||||
title = raw.get("title")
|
title = raw.get("title")
|
||||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||||
raise ValueError("title is invalid")
|
raise ValueError("title is invalid")
|
||||||
|
|
@ -95,6 +99,7 @@ class FollowingStore:
|
||||||
raise ValueError("last seen update is invalid")
|
raise ValueError("last seen update is invalid")
|
||||||
return {
|
return {
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
|
"kind": kind,
|
||||||
"number": number,
|
"number": number,
|
||||||
"title": title.strip(),
|
"title": title.strip(),
|
||||||
"state": state,
|
"state": state,
|
||||||
|
|
@ -117,8 +122,8 @@ class FollowingStore:
|
||||||
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _identity(item: dict) -> tuple[str, int]:
|
def _identity(item: dict) -> tuple[str, str, int]:
|
||||||
return item["repository"].lower(), item["number"]
|
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
|
||||||
|
|
||||||
def get(self, login: str) -> dict:
|
def get(self, login: str) -> dict:
|
||||||
login = self._login(login)
|
login = self._login(login)
|
||||||
|
|
@ -226,10 +231,20 @@ class FollowingStore:
|
||||||
)
|
)
|
||||||
return self._present({"revision": revision, "items": items})
|
return self._present({"revision": revision, "items": items})
|
||||||
|
|
||||||
def acknowledge(self, login: str, repository: str, number: int, updated_at: str) -> dict:
|
def acknowledge(
|
||||||
|
self,
|
||||||
|
login: str,
|
||||||
|
repository: str,
|
||||||
|
number: int,
|
||||||
|
updated_at: str,
|
||||||
|
*,
|
||||||
|
kind: str = "issue",
|
||||||
|
) -> dict:
|
||||||
"""Mark only the exact upstream revision successfully opened by the operator."""
|
"""Mark only the exact upstream revision successfully opened by the operator."""
|
||||||
login = self._login(login)
|
login = self._login(login)
|
||||||
identity = (str(repository).lower(), number)
|
if kind not in _KINDS:
|
||||||
|
raise ValueError("kind is invalid")
|
||||||
|
identity = (kind, str(repository).lower(), number)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|
|
||||||
11
src/main.py
11
src/main.py
|
|
@ -2623,11 +2623,12 @@ async def get_following(response: Response):
|
||||||
async def refresh_item(item: dict) -> dict:
|
async def refresh_item(item: dict) -> dict:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
preview = await asyncio.wait_for(
|
preview = await asyncio.wait_for(
|
||||||
gitea_proxy.work_preview(item["repository"], "issue", item["number"]),
|
gitea_proxy.work_preview(item["repository"], item["kind"], item["number"]),
|
||||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
return store._normalize_item({
|
return store._normalize_item({
|
||||||
"repository": item["repository"],
|
"repository": item["repository"],
|
||||||
|
"kind": item["kind"],
|
||||||
"number": item["number"],
|
"number": item["number"],
|
||||||
"title": preview.get("title", ""),
|
"title": preview.get("title", ""),
|
||||||
"state": preview.get("state", ""),
|
"state": preview.get("state", ""),
|
||||||
|
|
@ -2658,6 +2659,7 @@ async def acknowledge_following_revision(
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
|
kind: Literal["issue", "pull"] = Query(default="issue"),
|
||||||
):
|
):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
try:
|
try:
|
||||||
|
|
@ -2667,6 +2669,7 @@ async def acknowledge_following_revision(
|
||||||
f"{owner}/{repo}",
|
f"{owner}/{repo}",
|
||||||
number,
|
number,
|
||||||
payload.updated_at,
|
payload.updated_at,
|
||||||
|
kind=kind,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
@ -3743,9 +3746,8 @@ async def _search_preview_subscription_target(
|
||||||
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 (
|
||||||
kind != "issue"
|
preview.get("repository") != repository
|
||||||
or preview.get("repository") != repository
|
or preview.get("kind") != kind
|
||||||
or preview.get("kind") != "issue"
|
|
||||||
or preview.get("number") != number
|
or preview.get("number") != number
|
||||||
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
|
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
|
||||||
):
|
):
|
||||||
|
|
@ -3797,6 +3799,7 @@ async def mutate_global_search_preview_subscription(
|
||||||
store = _following_store()
|
store = _following_store()
|
||||||
following_item = {
|
following_item = {
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
|
"kind": kind,
|
||||||
"number": number,
|
"number": number,
|
||||||
"title": preview.get("title", ""),
|
"title": preview.get("title", ""),
|
||||||
"state": preview.get("state", ""),
|
"state": preview.get("state", ""),
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,39 @@ ROOT = Path(__file__).parents[2]
|
||||||
FRONTEND = ROOT / "frontend"
|
FRONTEND = ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
def test_following_queue_is_phone_usable_at_narrow_viewport():
|
@pytest.mark.parametrize("viewport", [
|
||||||
|
{"width": 320, "height": 568},
|
||||||
|
{"width": 390, "height": 844},
|
||||||
|
])
|
||||||
|
def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
|
||||||
with sync_playwright() as playwright:
|
with sync_playwright() as playwright:
|
||||||
browser = playwright.chromium.launch(headless=True)
|
browser = playwright.chromium.launch(headless=True)
|
||||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
page = browser.new_page(viewport=viewport)
|
||||||
page.set_content((FRONTEND / "index.html").read_text())
|
page.set_content((FRONTEND / "index.html").read_text())
|
||||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||||
|
page.add_script_tag(path=FRONTEND / "following.js")
|
||||||
|
|
||||||
row = page.locator('[data-mobile-queue="following"]')
|
row = page.locator('[data-mobile-queue="following"]')
|
||||||
expect(row).to_have_count(1)
|
expect(row).to_have_count(1)
|
||||||
page.locator("#following-list").evaluate("""node => {
|
expect(row).to_contain_text("Issues and pull requests you watch")
|
||||||
node.innerHTML = '<button class="following-card has-unseen-change" type="button"><span><em>New activity</em><strong>Changed issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true">›</span></button>';
|
page.evaluate("""() => {
|
||||||
|
globalThis.fetch = async () => ({
|
||||||
|
ok:true,
|
||||||
|
json:async () => ({revision:1,items:[{
|
||||||
|
repository:'stackchain/api', kind:'pull', number:42,
|
||||||
|
title:'Changed pull request with a long mobile title', state:'open',
|
||||||
|
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true
|
||||||
|
}]})
|
||||||
|
});
|
||||||
|
globalThis.followingReleaseQueue = attachFollowing(() => {});
|
||||||
|
globalThis.followingReleaseQueue.open();
|
||||||
}""")
|
}""")
|
||||||
page.locator("#review-following").evaluate("node => node.hidden = false")
|
|
||||||
page.locator("#following-sheet").evaluate("node => node.showModal()")
|
|
||||||
|
|
||||||
expect(page.locator("#following-sheet")).to_be_visible()
|
expect(page.locator("#following-sheet")).to_be_visible()
|
||||||
expect(page.locator(".following-card")).to_be_visible()
|
expect(page.locator(".following-card")).to_be_visible()
|
||||||
expect(page.locator(".following-card")).to_contain_text("New activity")
|
expect(page.locator(".following-card")).to_contain_text("New activity")
|
||||||
|
expect(page.locator(".following-card")).to_contain_text("Pull request")
|
||||||
expect(page.locator("#review-following")).to_have_text("Review new activity")
|
expect(page.locator("#review-following")).to_have_text("Review new activity")
|
||||||
assert page.locator("#review-following").bounding_box()["height"] >= 44
|
assert page.locator("#review-following").bounding_box()["height"] >= 44
|
||||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||||
|
|
@ -52,6 +66,11 @@ def test_following_queue_is_phone_usable_at_narrow_viewport():
|
||||||
expect(retire).to_be_visible()
|
expect(retire).to_be_visible()
|
||||||
expect(retire).to_have_text("Stop watching & next")
|
expect(retire).to_have_text("Stop watching & next")
|
||||||
assert retire.bounding_box()["height"] >= 44
|
assert retire.bounding_box()["height"] >= 44
|
||||||
|
page.evaluate("""() => renderSearchPreviewWatch(
|
||||||
|
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
|
||||||
|
{status:'ready'}, document.querySelector('#watch-search-result'))""")
|
||||||
|
expect(retire).to_have_text("Watch pull request")
|
||||||
|
assert retire.bounding_box()["height"] >= 44
|
||||||
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||||
assert overflow is False
|
assert overflow is False
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ def test_search_preview_preserves_authoritative_watch_and_offers_following_repai
|
||||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
(async () => {{
|
(async () => {{
|
||||||
const states = [];
|
const states = [];
|
||||||
const detail = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',claimable:true,watching:false}};
|
const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
|
||||||
const preview = createSearchPreview({{
|
const preview = createSearchPreview({{
|
||||||
fetchJson:async()=>detail,
|
fetchJson:async()=>detail,
|
||||||
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
|
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
|
||||||
|
|
@ -886,7 +886,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert json.loads(result.stdout) == {
|
assert json.loads(result.stdout) == {
|
||||||
"status": "watch-partial",
|
"status": "watch-partial",
|
||||||
"watching": True,
|
"watching": True,
|
||||||
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.",
|
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -901,7 +901,7 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action():
|
||||||
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_watch_is_available_for_open_issues_and_closed_following_retirement():
|
def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
|
||||||
script = f"""
|
script = f"""
|
||||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
(async () => {{
|
(async () => {{
|
||||||
|
|
@ -936,7 +936,8 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
payload = json.loads(result.stdout)
|
payload = json.loads(result.stdout)
|
||||||
assert payload["loaded"] == [
|
assert payload["loaded"] == [
|
||||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||||
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
|
||||||
]
|
]
|
||||||
assert payload["watching"] is True
|
assert payload["watching"] is True
|
||||||
assert payload["button"] == {
|
assert payload["button"] == {
|
||||||
|
|
@ -944,7 +945,7 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
|
||||||
"textContent": "Stop watching",
|
"textContent": "Stop watching",
|
||||||
"disabled": False,
|
"disabled": False,
|
||||||
}
|
}
|
||||||
assert payload["excluded"] == [True, True]
|
assert payload["excluded"] == [True, False]
|
||||||
assert payload["closedFollowing"]["watching"] is True
|
assert payload["closedFollowing"]["watching"] is True
|
||||||
assert payload["retire"] == {
|
assert payload["retire"] == {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v134" in worker
|
assert "stackchain-dashboard-shell-v135" in worker
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
|
||||||
assert following.headers["cache-control"] == "no-store"
|
assert following.headers["cache-control"] == "no-store"
|
||||||
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
|
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
|
||||||
"repository": "stackchain/api",
|
"repository": "stackchain/api",
|
||||||
|
"kind": "issue",
|
||||||
"number": 42,
|
"number": 42,
|
||||||
"title": "Make mobile review useful",
|
"title": "Make mobile review useful",
|
||||||
"state": "open",
|
"state": "open",
|
||||||
|
|
@ -61,6 +62,47 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
|
||||||
}]}
|
}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32)
|
||||||
|
detail = {
|
||||||
|
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||||
|
"title": "Ship typed Following", "state": "open",
|
||||||
|
"updated_at": "2026-08-23T05:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||||
|
}
|
||||||
|
previews = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
previews.append((repository, kind, number))
|
||||||
|
return detail
|
||||||
|
|
||||||
|
async def set_subscription(repository, number, watching):
|
||||||
|
return {"watching": watching}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
watched = await client.put(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull"
|
||||||
|
)
|
||||||
|
following = await client.get("/api/v1/following")
|
||||||
|
|
||||||
|
assert watched.status_code == 200
|
||||||
|
assert following.status_code == 200
|
||||||
|
assert following.json()["items"][0]["kind"] == "pull"
|
||||||
|
assert previews == [
|
||||||
|
("stackchain/api", "pull", 84),
|
||||||
|
("stackchain/api", "pull", 84),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
||||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
||||||
|
|
@ -228,7 +270,7 @@ async def test_following_refreshes_changed_items_and_preserves_failed_items(monk
|
||||||
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
|
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
|
||||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||||
item = {
|
item = {
|
||||||
"repository": "stackchain/api", "number": 42, "title": "Changed",
|
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
|
||||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||||
"url": "https://forge.example/stackchain/api/issues/42",
|
"url": "https://forge.example/stackchain/api/issues/42",
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +285,7 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
"/api/v1/following/stackchain/api/issues/42/seen",
|
"/api/v1/following/stackchain/api/issues/42/seen?kind=pull",
|
||||||
json={"updated_at": "2026-08-23T04:00:00Z"},
|
json={"updated_at": "2026-08-23T04:00:00Z"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,61 @@ process.stdout.write(JSON.stringify(state));
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_opens_pull_requests_in_the_pull_workspace():
|
||||||
|
script = f"""
|
||||||
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
const opened=[];
|
||||||
|
const feature=createFollowing({{
|
||||||
|
fetchJson:async () => ({{revision:1,items:[{{
|
||||||
|
repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
|
||||||
|
updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
|
||||||
|
}}]}}),
|
||||||
|
onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await feature.load();
|
||||||
|
await feature.startReview();
|
||||||
|
process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
|
||||||
|
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result["opened"][0]["kind"] == "pull"
|
||||||
|
assert result["opened"][0]["following"] is True
|
||||||
|
assert result["session"]["items"][0]["kind"] == "pull"
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_typed_identity_prevents_issue_pull_collisions():
|
||||||
|
script = f"""
|
||||||
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
const acknowledged=[];
|
||||||
|
const items=['issue','pull'].map(kind => ({{
|
||||||
|
repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
|
||||||
|
updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
|
||||||
|
}}));
|
||||||
|
const feature=createFollowing({{
|
||||||
|
fetchJson:async () => ({{revision:1,items}}),
|
||||||
|
onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await feature.load();
|
||||||
|
await feature.previewLoaded({{...items[1]}});
|
||||||
|
feature.retire(items[1]);
|
||||||
|
process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
|
||||||
|
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result["acknowledged"] == ["pull"]
|
||||||
|
assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
|
||||||
|
("issue", True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||||
script = f"""
|
script = f"""
|
||||||
|
|
@ -292,12 +347,13 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
|
||||||
assert "query('#review-following').addEventListener('click'" in following
|
assert "query('#review-following').addEventListener('click'" in following
|
||||||
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
||||||
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
||||||
|
assert "'/seen?kind=' + encodeURIComponent(item.kind)" in following
|
||||||
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
|
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
|
||||||
assert "followingQueue.returnToFollowing()" in dashboard
|
assert "followingQueue.returnToFollowing()" in dashboard
|
||||||
assert "'Back to Following'" in dashboard
|
assert "'Back to Following'" in dashboard
|
||||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||||
assert "stackchain-dashboard-shell-v134" in service_worker
|
assert "stackchain-dashboard-shell-v135" in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||||
first = store.set_watching("Timmy", ITEM, True)
|
first = store.set_watching("Timmy", ITEM, True)
|
||||||
repeated = store.set_watching("timmy", ITEM, True)
|
repeated = store.set_watching("timmy", ITEM, True)
|
||||||
|
|
||||||
assert first == repeated == {"revision": 1, "items": [{**ITEM, "has_unseen_change": False}]}
|
assert first == repeated == {
|
||||||
|
"revision": 1,
|
||||||
|
"items": [{**ITEM, "kind": "issue", "has_unseen_change": False}],
|
||||||
|
}
|
||||||
assert store.get("other") == {"revision": 0, "items": []}
|
assert store.get("other") == {"revision": 0, "items": []}
|
||||||
stored = sqlite3.connect(path).execute(
|
stored = sqlite3.connect(path).execute(
|
||||||
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
||||||
|
|
@ -72,3 +75,45 @@ def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
|
||||||
snapshot = store.set_watching("timmy", changed, True)
|
snapshot = store.set_watching("timmy", changed, True)
|
||||||
|
|
||||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||||
|
issue = {**ITEM, "kind": "issue"}
|
||||||
|
pull = {
|
||||||
|
**ITEM,
|
||||||
|
"kind": "pull",
|
||||||
|
"title": "Review the mobile flow",
|
||||||
|
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
store.set_watching("timmy", issue, True)
|
||||||
|
snapshot = store.set_watching("timmy", pull, True)
|
||||||
|
|
||||||
|
assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [
|
||||||
|
("pull", 42),
|
||||||
|
("issue", 42),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_reads_legacy_items_as_issues(tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||||
|
store.set_watching("timmy", ITEM, True)
|
||||||
|
|
||||||
|
assert store.get("timmy")["items"][0]["kind"] == "issue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||||
|
for kind in ("issue", "pull"):
|
||||||
|
store.set_watching("timmy", {**ITEM, "kind": kind}, True)
|
||||||
|
store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}])
|
||||||
|
|
||||||
|
snapshot = store.acknowledge(
|
||||||
|
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == {
|
||||||
|
"issue": True,
|
||||||
|
"pull": False,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v134" in worker
|
assert "stackchain-dashboard-shell-v135" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v134" in worker
|
assert "stackchain-dashboard-shell-v135" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v134" in worker
|
assert "stackchain-dashboard-shell-v135" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in worker
|
assert "stackchain-dashboard-shell-v135" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v134" in service_worker
|
assert "stackchain-dashboard-shell-v135" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
|
||||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
||||||
|
|
@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
|
|
||||||
|
|
||||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1302,7 +1302,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v134';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v135';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v134" in source
|
assert "stackchain-dashboard-shell-v135" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user