feat: keep Following changes for later (Closes #1320)
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 6m34s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 20:24:14 +00:00
1 parent f168a0187d
commit c3cb498ce1
25 files changed
+296 -47

No files matched your search

+53 -4
View File
@@ -97,7 +97,7 @@ class FollowingStore:
or len(last_seen_updated_at) > 64
):
raise ValueError("last seen update is invalid")
return {
item = {
"repository": repository,
"kind": kind,
"number": number,
@@ -107,6 +107,12 @@ class FollowingStore:
"url": url,
"last_seen_updated_at": last_seen_updated_at,
}
kept_updated_at = raw.get("kept_updated_at")
if kept_updated_at is not None:
if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
raise ValueError("kept update is invalid")
item["kept_updated_at"] = kept_updated_at
return item
@classmethod
def _present(cls, snapshot: dict) -> dict:
@@ -114,8 +120,10 @@ class FollowingStore:
unchanged = []
for raw in snapshot["items"]:
stored = cls._normalize_item(raw)
unseen = stored["updated_at"] != stored["last_seen_updated_at"]
item = {key: value for key, value in stored.items() if key != "last_seen_updated_at"}
unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
stored.get("kept_updated_at") == stored["updated_at"])
item = {key: value for key, value in stored.items()
if key not in {"last_seen_updated_at", "kept_updated_at"}}
item["has_unseen_change"] = unseen
(changed if unseen else unchanged).append(item)
changed.sort(key=lambda item: item["updated_at"], reverse=True)
@@ -186,6 +194,8 @@ class FollowingStore:
items.insert(0, item)
else:
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
if "kept_updated_at" in items[index]:
item["kept_updated_at"] = items[index]["kept_updated_at"]
if items[index] == item:
return self._present({"revision": current["revision"], "items": items})
items.pop(index)
@@ -219,6 +229,8 @@ class FollowingStore:
if update is None:
continue
update["last_seen_updated_at"] = item["last_seen_updated_at"]
if "kept_updated_at" in item:
update["kept_updated_at"] = item["kept_updated_at"]
if update != item:
items[index] = update
changed = True
@@ -256,8 +268,45 @@ class FollowingStore:
for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at:
continue
if item["last_seen_updated_at"] != updated_at:
if (item["last_seen_updated_at"] != updated_at or
item.get("kept_updated_at") == updated_at):
item["last_seen_updated_at"] = updated_at
item.pop("kept_updated_at", None)
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
(revision, self._seal(login, items), login),
)
break
return self._present({"revision": revision, "items": items})
def keep_unseen(
self,
login: str,
repository: str,
number: int,
updated_at: str,
*,
kind: str = "issue",
) -> dict:
"""Restore only the exact loaded revision to the unseen review queue."""
login = self._login(login)
if kind not in _KINDS:
raise ValueError("kind is invalid")
identity = (kind, str(repository).lower(), number)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
items = [self._normalize_item(candidate) for candidate in current["items"]]
revision = current["revision"]
for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at:
continue
if item.get("kept_updated_at") != updated_at:
item["kept_updated_at"] = updated_at
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
+2 -2
View File
@@ -35,11 +35,11 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/following.js", "static/mobile-composer-viewport.js",
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
+28
View File
@@ -2725,6 +2725,34 @@ async def acknowledge_following_revision(
)
@app.put("/api/v1/following/{owner}/{repo}/issues/{number}/keep")
async def keep_following_revision(
payload: FollowingSeenRevision,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(default="issue"),
):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_following_store().keep_unseen,
login,
f"{owner}/{repo}",
number,
payload.updated_at,
kind=kind,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Following synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()