From 24fd26656eba9f6e04f8907668debf948e945476 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Sat, 26 Sep 2026 09:10:08 +0530 Subject: [PATCH] fix: stale syncing count in the Activity Log after a quick sync (#454) (#455) Mirror job timestamps are stored to the second. A sync that starts and finishes in the same second writes its "syncing" and "synced" events with the same timestamp, and SQLite returned the tie oldest first, so the summary took the start event as the repository's current state. The activities and dashboard queries break ties on rowid, newest first, and the summary counter prefers a finish over a start from the same second whatever order they arrive in. --- CHANGELOG.md | 1 + src/components/activity/ActivityLog.tsx | 26 ++--------- src/lib/utils/activity-status.test.ts | 40 ++++++++++++++++ src/lib/utils/activity-status.ts | 62 +++++++++++++++++++++++++ src/pages/api/activities/index.ts | 4 +- src/pages/api/dashboard/index.ts | 4 +- 6 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 src/lib/utils/activity-status.test.ts create mode 100644 src/lib/utils/activity-status.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c9142..0f60ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- The Activity Log summary no longer shows a repository as syncing after a quick sync finished (#454). Events are stored to the second, and a sync that starts and finishes in the same second could come back in the wrong order, so the start event was counted as the current state. Activities now come back newest first within the same second too, and the summary counts a finish over a start from the same second - Issue and pull request passes over large repositories finish across rate limit resets (#449 follow-up) - A pass stopped by the rate limit keeps the numbers it finished, and the next run repeats the same pass and skips them unless they changed since it started; before, every run started over from the first item, so a repository too large for one hour of budget never completed a pass - The weekly full pass no longer fetches comments for issues, or the detail, commits and files of pull requests, that are already in the destination and have not changed since the last completed pass; it lists everything and corrects title, body, state and labels from the listing diff --git a/src/components/activity/ActivityLog.tsx b/src/components/activity/ActivityLog.tsx index ad73dfb..00f8426 100644 --- a/src/components/activity/ActivityLog.tsx +++ b/src/components/activity/ActivityLog.tsx @@ -1,3 +1,4 @@ +import { countLatestStatuses } from '@/lib/utils/activity-status'; import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import Fuse from 'fuse.js'; import { Button } from '@/components/ui/button'; @@ -407,28 +408,9 @@ export function ActivityLog() { // Check if any filters are active // Counts the current state of each repository or organization, not every - // event: a repo that failed and then synced is only "synced". Activities - // arrive newest first, so the first entry seen for a subject is its state. - // Taken from the unfiltered set so the chips stay put when one is clicked. - const statusCounts = useMemo(() => { - const latestBySubject = new Map(); - activities.forEach((a) => { - const subject = - a.repositoryId || - a.organizationId || - a.repositoryName || - a.organizationName; - // Events with no subject have no state of their own to report. - if (!subject || latestBySubject.has(subject)) return; - latestBySubject.set(subject, a.status); - }); - - const counts = new Map(); - latestBySubject.forEach((status) => { - counts.set(status, (counts.get(status) ?? 0) + 1); - }); - return counts; - }, [activities]); + // event: a repo that failed and then synced is only "synced". Taken from + // the unfiltered set so the chips stay put when one is clicked. + const statusCounts = useMemo(() => countLatestStatuses(activities), [activities]); // Most actionable first, and only statuses that actually occurred. const statChips = ['failed', 'syncing', 'mirroring', 'imported', 'synced', 'mirrored'] diff --git a/src/lib/utils/activity-status.test.ts b/src/lib/utils/activity-status.test.ts new file mode 100644 index 0000000..a8174cb --- /dev/null +++ b/src/lib/utils/activity-status.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { countLatestStatuses, latestStatusBySubject } from "./activity-status"; + +const at = (iso: string) => new Date(iso); + +describe("Activity Log status chips (#454)", () => { + test("a start and finish in the same second count as finished, in either order", () => { + const start = { repositoryId: "r1", status: "syncing", timestamp: at("2026-09-26T00:29:48.000Z") }; + const finish = { repositoryId: "r1", status: "synced", timestamp: at("2026-09-26T00:29:48.000Z") }; + const aggregate = { status: "mirrored", timestamp: at("2026-09-26T00:29:48.000Z") }; + + // The order the reporter's export came back in. + expect(countLatestStatuses([aggregate, start, finish])).toEqual(new Map([["synced", 1]])); + expect(countLatestStatuses([finish, start])).toEqual(new Map([["synced", 1]])); + }); + + test("a sync that is really still running stays syncing", () => { + const earlier = { repositoryId: "r1", status: "synced", timestamp: at("2026-09-26T00:00:00.000Z") }; + const running = { repositoryId: "r1", status: "syncing", timestamp: at("2026-09-26T01:00:00.000Z") }; + expect(latestStatusBySubject([running, earlier]).get("r1")).toBe("syncing"); + }); + + test("the newest event decides otherwise, and events without a subject are ignored", () => { + const counts = countLatestStatuses([ + { repositoryId: "r1", status: "synced", timestamp: "2026-09-26T02:00:00Z" }, + { repositoryId: "r1", status: "failed", timestamp: "2026-09-26T01:00:00Z" }, + { organizationId: "o1", status: "failed", timestamp: "2026-09-26T01:00:00Z" }, + { status: "mirrored", timestamp: "2026-09-26T01:00:00Z" }, + ]); + expect(counts).toEqual(new Map([["synced", 1], ["failed", 1]])); + }); + + test("a newer start never loses to an older finish from another second", () => { + const counts = countLatestStatuses([ + { repositoryName: "a", status: "mirroring", timestamp: "2026-09-26T00:00:02Z" }, + { repositoryName: "a", status: "mirrored", timestamp: "2026-09-26T00:00:01Z" }, + ]); + expect(counts).toEqual(new Map([["mirroring", 1]])); + }); +}); diff --git a/src/lib/utils/activity-status.ts b/src/lib/utils/activity-status.ts new file mode 100644 index 0000000..fd4be8e --- /dev/null +++ b/src/lib/utils/activity-status.ts @@ -0,0 +1,62 @@ +/** + * Current state per repository or organization for the Activity Log + * summary chips. Activities arrive newest first, so the first event seen + * for a subject is its state. + * + * Timestamps are stored to the second, and a quick sync writes its + * "syncing" and "synced" events in the same second (#454). When the newest + * event for a subject says work started, an event from the same second + * that says it finished wins, whatever order the tie came back in. + */ + +const IN_PROGRESS_STATUSES = new Set(["syncing", "mirroring", "deleting"]); + +interface ActivityLike { + status: string; + timestamp: Date | string | number; + repositoryId?: string | null; + organizationId?: string | null; + repositoryName?: string | null; + organizationName?: string | null; +} + +function toSecond(timestamp: ActivityLike["timestamp"]): number { + return Math.floor(new Date(timestamp).getTime() / 1000); +} + +export function latestStatusBySubject(activities: ActivityLike[]): Map { + const latest = new Map(); + for (const activity of activities) { + const subject = + activity.repositoryId || + activity.organizationId || + activity.repositoryName || + activity.organizationName; + // Events with no subject have no state of their own to report. + if (!subject) continue; + + const second = toSecond(activity.timestamp); + const seen = latest.get(subject); + if (!seen) { + latest.set(subject, { status: activity.status, second }); + continue; + } + if ( + IN_PROGRESS_STATUSES.has(seen.status) && + !IN_PROGRESS_STATUSES.has(activity.status) && + seen.second === second + ) { + latest.set(subject, { status: activity.status, second }); + } + } + + return new Map([...latest].map(([subject, { status }]) => [subject, status])); +} + +export function countLatestStatuses(activities: ActivityLike[]): Map { + const counts = new Map(); + latestStatusBySubject(activities).forEach((status) => { + counts.set(status, (counts.get(status) ?? 0) + 1); + }); + return counts; +} diff --git a/src/pages/api/activities/index.ts b/src/pages/api/activities/index.ts index c76d363..cb2934c 100644 --- a/src/pages/api/activities/index.ts +++ b/src/pages/api/activities/index.ts @@ -17,7 +17,9 @@ export const GET: APIRoute = async ({ request, locals }) => { .select() .from(mirrorJobs) .where(eq(mirrorJobs.userId, userId)) - .orderBy(sql`${mirrorJobs.timestamp} DESC`); + // Timestamps are stored to the second; rowid keeps events written in + // the same second newest first too (#454). + .orderBy(sql`${mirrorJobs.timestamp} DESC, ${mirrorJobs}.rowid DESC`); const activities: MirrorJob[] = jobs.map((job) => ({ id: job.id, diff --git a/src/pages/api/dashboard/index.ts b/src/pages/api/dashboard/index.ts index 13f4dce..c02e90e 100644 --- a/src/pages/api/dashboard/index.ts +++ b/src/pages/api/dashboard/index.ts @@ -36,7 +36,9 @@ export const GET: APIRoute = async ({ request, locals }) => { .select() .from(mirrorJobs) .where(eq(mirrorJobs.userId, userId)) - .orderBy(sql`${mirrorJobs.timestamp} DESC`) + // Timestamps are stored to the second; rowid keeps events written in + // the same second newest first too (#454). + .orderBy(sql`${mirrorJobs.timestamp} DESC, ${mirrorJobs}.rowid DESC`) .limit(10), db .select()