Sync completed Filed acknowledgements across devices (#881)
Closes #880
This commit is contained in:
commit
403190e888
|
|
@ -85,6 +85,13 @@ 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.
|
||||||
|
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||||
|
Acknowledgements hide the exact Gitea `updated_at` revision immediately on the current device, synchronize in
|
||||||
|
bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea
|
||||||
|
update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next
|
||||||
|
healthy dashboard refresh without blocking **Acknowledge & next**. Set
|
||||||
|
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
|
||||||
|
`.stackchain-state/completed-filed-reviews.sqlite3` path.
|
||||||
Search previews also
|
Search previews also
|
||||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin() { return planningOwnerLogin; },
|
getLogin() { return planningOwnerLogin; },
|
||||||
});
|
});
|
||||||
|
let completedFiledSyncFlight = null;
|
||||||
let activeMyWork = [];
|
let activeMyWork = [];
|
||||||
let laterMyWork = [];
|
let laterMyWork = [];
|
||||||
let todayMyWork = [];
|
let todayMyWork = [];
|
||||||
|
|
@ -330,6 +331,35 @@
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncCompletedFiledReviews() {
|
||||||
|
if (!planningOwnerLogin) return Promise.resolve(false);
|
||||||
|
if (completedFiledSyncFlight) return completedFiledSyncFlight;
|
||||||
|
const pending = completedFiledReview.pending();
|
||||||
|
const options = pending.length ? {
|
||||||
|
method:'POST',
|
||||||
|
headers:{Accept:'application/json', 'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({ receipts:pending }),
|
||||||
|
} : { headers:{Accept:'application/json'} };
|
||||||
|
completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options)
|
||||||
|
.then(snapshot => {
|
||||||
|
const changed = completedFiledReview.adopt(snapshot);
|
||||||
|
if (lastContextSnapshot && changed) {
|
||||||
|
lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot));
|
||||||
|
refreshMyWorkView();
|
||||||
|
}
|
||||||
|
if (pending.length) qs('#my-work-action-status').textContent =
|
||||||
|
'Completed Filed review saved to account.';
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (pending.length) qs('#my-work-action-status').textContent =
|
||||||
|
'Acknowledged on this device · sync pending.';
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.finally(() => { completedFiledSyncFlight = null; });
|
||||||
|
return completedFiledSyncFlight;
|
||||||
|
}
|
||||||
|
|
||||||
let commentActions = { isOwned: () => false, wire: () => {} };
|
let commentActions = { isOwned: () => false, wire: () => {} };
|
||||||
|
|
||||||
function loadMentionCandidates(repository, query) {
|
function loadMentionCandidates(repository, query) {
|
||||||
|
|
@ -2517,6 +2547,7 @@
|
||||||
function paintMyWork(data) {
|
function paintMyWork(data) {
|
||||||
lastMyWork = completedFiledReview.visible(buildMyWork(data));
|
lastMyWork = completedFiledReview.visible(buildMyWork(data));
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
void syncCompletedFiledReviews();
|
||||||
}
|
}
|
||||||
|
|
||||||
function listDrafts() {
|
function listDrafts() {
|
||||||
|
|
@ -3446,7 +3477,9 @@
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
const target = filedFollowUpTarget(lastMyWork);
|
const target = filedFollowUpTarget(lastMyWork);
|
||||||
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
|
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
|
||||||
(target ? ' Opening the next Filed item.' : ' Filed review is complete.');
|
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
|
||||||
|
' Filed review is complete. Acknowledgement sync pending.');
|
||||||
|
void syncCompletedFiledReviews();
|
||||||
if (target) {
|
if (target) {
|
||||||
const index = lastMyWork.indexOf(target.item);
|
const index = lastMyWork.indexOf(target.item);
|
||||||
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||||||
|
|
|
||||||
|
|
@ -678,28 +678,64 @@ function createCompletedFiledReview({
|
||||||
const ownerKey = () => key + ':' + login();
|
const ownerKey = () => key + ':' + login();
|
||||||
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
||||||
const read = () => {
|
const read = () => {
|
||||||
if (!login()) return {};
|
if (!login()) return { items:{}, pending:{} };
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
||||||
return value && value.version === 1 && value.items && typeof value.items === 'object' ?
|
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
|
||||||
value.items : {};
|
items:value.items,
|
||||||
} catch (_error) { return {}; }
|
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
|
||||||
|
} : { items:{}, pending:{} };
|
||||||
|
} catch (_error) { return { items:{}, pending:{} }; }
|
||||||
};
|
};
|
||||||
|
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
|
||||||
|
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
|
||||||
|
});
|
||||||
|
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
|
||||||
return {
|
return {
|
||||||
visible(items) {
|
visible(items) {
|
||||||
const acknowledged = read();
|
const acknowledged = read().items;
|
||||||
return (items || []).filter(item =>
|
return (items || []).filter(item =>
|
||||||
!item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
|
!item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
pending() {
|
||||||
|
const state = read();
|
||||||
|
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
|
||||||
|
const boundary = itemIdentity.lastIndexOf('#');
|
||||||
|
return receipt({
|
||||||
|
repository:itemIdentity.slice(0, boundary),
|
||||||
|
number:Number(itemIdentity.slice(boundary + 1)),
|
||||||
|
}, stamp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
adopt(snapshot) {
|
||||||
|
const state = read();
|
||||||
|
let changed = false;
|
||||||
|
(snapshot?.receipts || []).forEach(remote => {
|
||||||
|
const itemIdentity = identity(remote);
|
||||||
|
const stamp = String(remote?.updated_at || '');
|
||||||
|
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
|
||||||
|
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
|
||||||
|
state.items[itemIdentity] = stamp;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
|
||||||
|
delete state.pending[itemIdentity];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try { save(state); } catch (_error) { return false; }
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
acknowledge(item) {
|
acknowledge(item) {
|
||||||
const stamp = String(item?.updated_at || '');
|
const stamp = String(item?.updated_at || '');
|
||||||
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const entries = Object.entries({ ...read(), [identity(item)]: stamp }).slice(-200);
|
const state = read();
|
||||||
|
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
|
||||||
|
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
|
||||||
try {
|
try {
|
||||||
storage?.setItem(ownerKey(), JSON.stringify({ version:1, items:Object.fromEntries(entries) }));
|
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) { return false; }
|
} catch (_error) { return false; }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
121
src/completed_filed_review_store.py
Normal file
121
src/completed_filed_review_store.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Durable, account-scoped completed Filed review receipts."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
class CompletedFiledReviewStore:
|
||||||
|
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.limit = limit
|
||||||
|
self.timeout = timeout
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
touched_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (login, repository, issue_number)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("receipt must be an object")
|
||||||
|
repository = raw.get("repository")
|
||||||
|
number = raw.get("number")
|
||||||
|
updated_at = raw.get("updated_at")
|
||||||
|
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||||
|
raise ValueError("repository is invalid")
|
||||||
|
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||||
|
raise ValueError("number is invalid")
|
||||||
|
if not isinstance(updated_at, str):
|
||||||
|
raise ValueError("updated_at is invalid")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("updated_at is invalid") from error
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("updated_at is invalid")
|
||||||
|
return repository, number, updated_at
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot(rows) -> dict:
|
||||||
|
return {"receipts": [
|
||||||
|
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||||
|
for row in rows
|
||||||
|
]}
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||||
|
"WHERE login = ? ORDER BY touched_at",
|
||||||
|
(self._login(login),),
|
||||||
|
).fetchall()
|
||||||
|
return self._snapshot(rows)
|
||||||
|
|
||||||
|
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(receipts, list) or len(receipts) > self.limit:
|
||||||
|
raise ValueError(f"receipts are limited to {self.limit}")
|
||||||
|
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
touched = int(connection.execute(
|
||||||
|
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||||
|
(login,),
|
||||||
|
).fetchone()[0])
|
||||||
|
for repository, number, updated_at in normalized:
|
||||||
|
current = connection.execute(
|
||||||
|
"SELECT updated_at FROM completed_filed_reviews "
|
||||||
|
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||||
|
(login, repository, number),
|
||||||
|
).fetchone()
|
||||||
|
if current is not None and current[0] >= updated_at:
|
||||||
|
continue
|
||||||
|
touched += 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO completed_filed_reviews "
|
||||||
|
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||||
|
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||||
|
(login, repository, number, updated_at, touched),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||||
|
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||||
|
"ORDER BY touched_at DESC LIMIT ?)",
|
||||||
|
(login, login, self.limit),
|
||||||
|
)
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||||
|
"WHERE login = ? ORDER BY touched_at",
|
||||||
|
(login,),
|
||||||
|
).fetchall()
|
||||||
|
return self._snapshot(rows)
|
||||||
60
src/main.py
60
src/main.py
|
|
@ -26,6 +26,7 @@ from starlette.datastructures import UploadFile
|
||||||
|
|
||||||
from src import dashboard_auth, gitea_proxy, passkeys
|
from src import dashboard_auth, gitea_proxy, passkeys
|
||||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||||
|
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||||
from src.compression import NegotiatedGZipMiddleware
|
from src.compression import NegotiatedGZipMiddleware
|
||||||
from src.gitea_proxy import (
|
from src.gitea_proxy import (
|
||||||
activity_events,
|
activity_events,
|
||||||
|
|
@ -633,6 +634,20 @@ class SavedSearchCollection(BaseModel):
|
||||||
views: list[SavedSearchView] = Field(max_length=20)
|
views: list[SavedSearchView] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class CompletedFiledReviewReceipt(BaseModel):
|
||||||
|
repository: str = Field(
|
||||||
|
min_length=3,
|
||||||
|
max_length=200,
|
||||||
|
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||||
|
)
|
||||||
|
number: PositiveInt
|
||||||
|
updated_at: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CompletedFiledReviewBatch(BaseModel):
|
||||||
|
receipts: list[CompletedFiledReviewReceipt] = Field(max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class UnfiledDraftBlocker(BaseModel):
|
class UnfiledDraftBlocker(BaseModel):
|
||||||
repository: str = Field(min_length=3, max_length=200)
|
repository: str = Field(min_length=3, max_length=200)
|
||||||
number: int = Field(ge=1)
|
number: int = Field(ge=1)
|
||||||
|
|
@ -1273,7 +1288,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -2210,6 +2225,15 @@ def _saved_search_store() -> SavedSearchStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||||
|
return CompletedFiledReviewStore(
|
||||||
|
os.getenv(
|
||||||
|
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
||||||
|
str(_state_dir / "completed-filed-reviews.sqlite3"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||||
return UnfiledDraftStore(
|
return UnfiledDraftStore(
|
||||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||||
|
|
@ -2231,6 +2255,40 @@ async def _confirmed_login() -> str:
|
||||||
return login.strip().lower()
|
return login.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/completed-filed-reviews")
|
||||||
|
async def get_completed_filed_reviews(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Completed Filed review synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/completed-filed-reviews")
|
||||||
|
async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_completed_filed_review_store().merge,
|
||||||
|
login,
|
||||||
|
[receipt.model_dump() for receipt in payload.receipts],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Completed Filed review synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/saved-searches")
|
@app.get("/api/v1/saved-searches")
|
||||||
async def get_saved_searches(response: Response):
|
async def get_saved_searches(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
73
tests/test_completed_filed_review_store.py
Normal file
73
tests/test_completed_filed_review_store.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
def receipt(repository="stackchain/api", number=9, updated_at="2026-08-15T12:00:00Z"):
|
||||||
|
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path):
|
||||||
|
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||||
|
|
||||||
|
first = store.merge(" Timmy ", [receipt()])
|
||||||
|
second = store.merge("timmy", [receipt("stackchain/web", 4)])
|
||||||
|
|
||||||
|
assert first == {"receipts": [receipt()]}
|
||||||
|
assert second == {"receipts": [receipt(), receipt("stackchain/web", 4)]}
|
||||||
|
assert store.get("TIMMY") == second
|
||||||
|
assert store.get("alexander") == {"receipts": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||||
|
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||||
|
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||||
|
|
||||||
|
store.merge("timmy", [newer])
|
||||||
|
assert store.merge("timmy", [receipt()]) == {"receipts": [newer]}
|
||||||
|
bounded = store.merge("timmy", [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)])
|
||||||
|
|
||||||
|
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("candidate", [
|
||||||
|
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||||
|
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||||
|
{"repository": "stackchain/api", "number": 1, "updated_at": "yesterday"},
|
||||||
|
])
|
||||||
|
def test_receipts_reject_invalid_records(tmp_path, candidate):
|
||||||
|
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
store.merge("timmy", [candidate])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_completed_filed_review_api_is_authenticated_csrf_protected_and_no_store(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_COMPLETED_FILED_REVIEW_DB", str(tmp_path / "completed.sqlite3"))
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
forbidden = await client.post("/api/v1/completed-filed-reviews", json={"receipts": [receipt()]})
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
saved = await client.post(
|
||||||
|
"/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}, headers=headers
|
||||||
|
)
|
||||||
|
fetched = await client.get("/api/v1/completed-filed-reviews")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json() == {"receipts": [receipt()]}
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
|
|
@ -141,6 +141,46 @@ if (typeof create !== 'function') {{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key, value) => values.set(key, value),
|
||||||
|
}};
|
||||||
|
const item = (repository, number, updated_at) => ({{
|
||||||
|
kind:'issue', key:repository + '#' + number, repository, number,
|
||||||
|
is_filed:true, is_completed:true, updated_at,
|
||||||
|
}});
|
||||||
|
const local = item('stackchain/api', 9, '2026-08-15T12:00:00Z');
|
||||||
|
const remote = item('stackchain/web', 4, '2026-08-14T12:00:00Z');
|
||||||
|
const review = buildMyWork.createCompletedFiledReview({{storage, getLogin:() => 'timmy'}});
|
||||||
|
review.acknowledge(local);
|
||||||
|
const pendingBefore = review.pending();
|
||||||
|
const changed = review.adopt({{receipts:[remote]}});
|
||||||
|
const visible = review.visible([local, remote]).map(value => value.key);
|
||||||
|
const pendingAfterRemote = review.pending();
|
||||||
|
review.adopt({{receipts:[
|
||||||
|
{{repository:local.repository, number:local.number, updated_at:local.updated_at}},
|
||||||
|
{{repository:remote.repository, number:remote.number, updated_at:remote.updated_at}},
|
||||||
|
]}});
|
||||||
|
process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAfterRemote, pendingFinal:review.pending()}}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"pendingBefore": [receipt := {
|
||||||
|
"repository": "stackchain/api", "number": 9, "updated_at": "2026-08-15T12:00:00Z"
|
||||||
|
}],
|
||||||
|
"changed": True,
|
||||||
|
"visible": [],
|
||||||
|
"pendingAfterRemote": [receipt],
|
||||||
|
"pendingFinal": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
||||||
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||||
|
|
@ -155,6 +195,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
||||||
assert "filed: 'filed issues'" in source
|
assert "filed: 'filed issues'" in source
|
||||||
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
|
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
|
||||||
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
|
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
|
||||||
|
assert "syncCompletedFiledReviews()" in source
|
||||||
|
assert "api/v1/completed-filed-reviews" in source
|
||||||
|
assert "sync pending" in source
|
||||||
assert "filedFollowUpTarget(lastMyWork)" in handler
|
assert "filedFollowUpTarget(lastMyWork)" in handler
|
||||||
assert "openRoutedWork" in handler
|
assert "openRoutedWork" in handler
|
||||||
assert ".completed-filed-actions" in css
|
assert ".completed-filed-actions" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user