From ca8620958a15824703e287d25da6fd7238d2cddf Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Wed, 26 Aug 2026 21:48:07 +0900 Subject: [PATCH] feat: sort repositories by recent update --- .../repositories/RepositoryListViewModel.kt | 36 +++++++++++++++---- .../RepositoryListViewModelTest.kt | 30 +++++++++++----- docs/development-log.md | 7 ++++ ...1-repository-global-recent-update-order.md | 33 +++++++++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 tasks/021-repository-global-recent-update-order.md diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index 36361fd..85a5326 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -157,6 +157,7 @@ class RepositoryListViewModel( private var hasAuthenticatedSession = false private var repositoryContent: RepositoryListUiState.Content? = null private var cachedOrganizations: List? = null + private val repositorySnapshots = mutableMapOf>() init { startOperation { restoreSession() } @@ -252,6 +253,7 @@ class RepositoryListViewModel( is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization) else -> return } + repositorySnapshots.remove(RepositorySnapshotKey(mode, organization)) startOperation { loadRepositoryPage(account, page, mode, organization) } } @@ -342,6 +344,7 @@ class RepositoryListViewModel( hasAuthenticatedSession = false repositoryContent = null cachedOrganizations = null + repositorySnapshots.clear() mutableState.value = RepositoryListUiState.SignedOut } } @@ -412,13 +415,11 @@ class RepositoryListViewModel( cancelRowBranchJobs() mutableState.value = RepositoryListUiState.Loading try { - val result = when { - mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE) - organization != null -> organizationsApi.listOrganizationRepositories(organization, page, REPOSITORY_PAGE_SIZE) - else -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE) - } + val repositories = loadRepositorySnapshot(mode, organization) + val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE + val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE) val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList() - val rows = result.items.map { it.toUiModel() } + val rows = pageItems.map { it.toUiModel() } if (rows.isEmpty() && page == 1) { repositoryContent = null mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization) @@ -428,7 +429,7 @@ class RepositoryListViewModel( accountName, rows, page, - result.nextPage != null, + pageStart + rows.size < repositories.size, mode, organization, organizations, @@ -452,6 +453,22 @@ class RepositoryListViewModel( } } + private suspend fun loadRepositorySnapshot( + mode: RepositoryListMode, + organization: String?, + ): List { + val key = RepositorySnapshotKey(mode, organization) + repositorySnapshots[key]?.let { return it } + val loaded = loadAllPages { page -> + when { + mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, PAGE_SIZE) + organization != null -> organizationsApi.listOrganizationRepositories(organization, page, PAGE_SIZE) + else -> browserApi.listRepositories(page, PAGE_SIZE) + } + }.sortedByDescending(RepositorySummary::updatedAtEpochMillis) + return loaded.also { repositorySnapshots[key] = it } + } + private suspend fun loadOrganizationsOnce(): List { cachedOrganizations?.let { return it } val loaded = try { @@ -623,6 +640,11 @@ class RepositoryListViewModel( } } +private data class RepositorySnapshotKey( + val mode: RepositoryListMode, + val organization: String?, +) + internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) { is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index 4dfe783..f15ae60 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -117,11 +117,11 @@ class RepositoryListViewModelTest { } @Test - fun `repository list requests recent-push order and pages five at a time`() = runTest(dispatcher.scheduler) { + fun `repository list globally sorts server pages by recent update and pages locally`() = runTest(dispatcher.scheduler) { val browser = FakeBrowser( repositories = listOf( - Page.of(listOf(repo("newest"), repo("older")), nextPage = 2), - Page.of(listOf(repo("oldest")), nextPage = null), + Page.of(listOf(repo("oldest", 1_000L), repo("middle", 2_000L)), nextPage = 2), + Page.of(listOf(repo("newest", 3_000L), repo("newer", 2_500L), repo("older", 1_500L), repo("old", 500L)), nextPage = null), ), ) val viewModel = viewModel(browser) @@ -130,19 +130,33 @@ class RepositoryListViewModelTest { val firstPage = viewModel.state.value as RepositoryListUiState.Content assertEquals(1, firstPage.page) assertTrue(firstPage.hasNextPage) - assertEquals(listOf("newest", "older"), firstPage.repositories.map { it.name }) - assertEquals(listOf(1 to 5), browser.repositoryPageRequests) + assertEquals(listOf("newest", "newer", "middle", "older", "oldest"), firstPage.repositories.map { it.name }) + assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests) viewModel.nextPage() advanceUntilIdle() val secondPage = viewModel.state.value as RepositoryListUiState.Content assertEquals(2, secondPage.page) assertFalse(secondPage.hasNextPage) - assertEquals(listOf("oldest"), secondPage.repositories.map { it.name }) + assertEquals(listOf("old"), secondPage.repositories.map { it.name }) + assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests) viewModel.previousPage() advanceUntilIdle() assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page) + assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests) + } + + @Test + fun `refresh invalidates the globally sorted repository snapshot`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo")))) + val viewModel = viewModel(browser) + advanceUntilIdle() + + viewModel.refreshRepositories() + advanceUntilIdle() + + assertEquals(listOf(1 to 50, 1 to 50), browser.repositoryPageRequests) } @Test @@ -406,13 +420,13 @@ class RepositoryListViewModelTest { private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null) - private fun repo(name: String) = RepositorySummary( + private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L) = RepositorySummary( coordinates = RepositoryCoordinates.of("owner", name), description = null, defaultBranch = RepositoryRef.of("main"), isPrivate = true, isFork = false, - updatedAtEpochMillis = 1_000L, + updatedAtEpochMillis = updatedAtEpochMillis, ) private fun viewModel( diff --git a/docs/development-log.md b/docs/development-log.md index ee2bfcb..9a046ca 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -313,3 +313,10 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Implementation: Repository rows now use the stable string key `owner/name`; repository components cannot contain `/`, so the key remains unique across owners and repositories while being Android-saveable. - Regression coverage: Added tests for deterministic key encoding and for equal repository names under different owners. - Checks: Focused tests and `assembleDebug testDebugUnitTest lintDebug` passed. The APK installed wirelessly over the authenticated device, `MainActivity` remained resumed, and the cleared crash buffer stayed empty. Final visual inspection is pending because the handset locked. + +## 2026-08-26 — TASK-021 global recent-update repository order + +- Agent and role: Codex, implementer and device verifier. +- Root cause: This Gitea server returned `/user/repos` in apparent name order despite the existing `sort=updated&order=desc` query, so trusting server paging could not guarantee that the newest repository appeared on page 1. +- Implementation: The ViewModel now loads all server pages in 50-row batches, performs one stable descending sort by `RepositorySummary.updatedAtEpochMillis`, and slices that immutable snapshot into five-row UI pages. Snapshots are scoped by all/favorites/organization selection, reused for next/previous navigation, and invalidated by refresh and logout. +- Checks: Focused regression tests and `assembleDebug testDebugUnitTest lintDebug` passed. The rebuilt APK was installed wirelessly; the authenticated first page visibly changed from alphabetical order to recent-update order, all five rows loaded their latest branch labels, and the crash buffer remained empty. diff --git a/tasks/021-repository-global-recent-update-order.md b/tasks/021-repository-global-recent-update-order.md new file mode 100644 index 0000000..cc2e5d1 --- /dev/null +++ b/tasks/021-repository-global-recent-update-order.md @@ -0,0 +1,33 @@ +# TASK-021: Enforce recent-push repository order across pages + +Status: implemented and device-verified +Owner: Codex +Reviewer: Claude + +## Outcome + +The repository list places the repository with the newest Gitea `updated_at` value at the top of page 1 and preserves that descending order across every local page, even when this Gitea server ignores `sort=updated&order=desc` on repository-list endpoints. + +## Scope + +- Read every accessible repository page for the selected list (all, organization, or favorites). +- Sort the complete result by `updatedAtEpochMillis` descending before splitting it into five-row UI pages. +- Cache the sorted snapshot during navigation and invalidate it on refresh or logout. + +## Acceptance criteria + +- [x] A newer repository returned on a later server page appears before older repositories from page 1. +- [x] Local next/previous navigation does not refetch the complete server list. +- [x] Refresh invalidates and rebuilds the sorted snapshot. +- [x] Empty, error, authentication-expired, organization, and favorites behavior remains intact. +- [x] Build, unit tests, lint, secret scan, and physical-device verification pass. + +## Note + +The Gitea repository payload exposes `updated_at`, which is the existing product proxy for recent repository push activity. Exact branch commit timestamps continue to be fetched only for the five visible rows so one repository with a broken branch endpoint cannot block the global list. + +## Evidence + +- Focused ViewModel tests cover cross-server-page sorting, five-row local paging, navigation caching, and refresh invalidation. +- `assembleDebug testDebugUnitTest lintDebug` passed. +- The APK was installed wirelessly over the existing authenticated app. Page 1 changed from the server's apparent alphabetical order to the newest-update order, rendered five repositories with their latest branch labels, retained next-page navigation, and produced no crash entry.