From 4fe3bf1c9f21179186bc97a18e023d8b1b9eafdc Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Wed, 26 Aug 2026 15:34:22 +0900 Subject: [PATCH] Implement TASK-014: organizations and repository org filter Adds read-only GET /user/orgs and GET /orgs/{org}/repos to GiteaRepositoryBrowserApi (a third interface, OrganizationsRepository), reusing existing repository mapping rather than duplicating it. Repository list gains an organization filter chip row (all + one per org), keeping the existing recent-push sort and paging. Organizations are fetched once per session and cached; a failure to load them degrades to an empty filter row instead of blocking the repository list itself. Filtering only applies to the all-repositories mode, since Gitea star is not organization-scoped. Closes MVP outcome #2 in docs/product.md. No task file existed beforehand; created it first per AGENTS.md. Co-Authored-By: Claude Sonnet 5 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 1 + .../repositories/GiteaRepositoryBrowserApi.kt | 33 ++++++- .../organizations/OrganizationModels.kt | 6 ++ .../organizations/OrganizationsRepository.kt | 11 +++ .../repositories/RepositoryListScreen.kt | 31 +++++++ .../repositories/RepositoryListViewModel.kt | 86 ++++++++++++++---- .../main/res/values/strings_repositories.xml | 1 + .../GiteaRepositoryBrowserApiTest.kt | 36 ++++++++ .../repositories/RepositoryListUiStateTest.kt | 2 + .../RepositoryListViewModelTest.kt | 88 ++++++++++++++++++- docs/development-log.md | 13 +++ tasks/014-organizations.md | 71 +++++++++++++++ 12 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationModels.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationsRepository.kt create mode 100644 tasks/014-organizations.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index c5c7b88..831f868 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -180,6 +180,7 @@ class MainActivity : ComponentActivity() { onViewWorkItems = { repository -> push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) }, + onSelectOrganization = viewModel::selectOrganization, ) } LaunchedEffect(viewModel) { diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt index 9d46c55..165000b 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt @@ -22,6 +22,8 @@ import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntryType +import com.ubisam.ubgitea.domain.organizations.OrganizationSummary +import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import java.time.Instant @@ -40,7 +42,7 @@ class GiteaRepositoryBrowserApi( private val transport: HttpTransport, private val tokenProvider: BearerTokenProvider, private val gson: Gson = Gson(), -) : GiteaRepositoryBrowser, RepositoryFavorites { +) : GiteaRepositoryBrowser, RepositoryFavorites, OrganizationsRepository { override suspend fun listRepositories(page: Int, pageSize: Int): Page { validatePage(page, pageSize) val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize) @@ -68,6 +70,34 @@ class GiteaRepositoryBrowserApi( delete(starUrl(repository)) } + override suspend fun listOrganizations(page: Int, pageSize: Int): Page { + validatePage(page, pageSize) + val response = get(apiUrl("api", "v1", "user", "orgs").withPage(page, pageSize)) + val items = parseList(response.body).map { dto -> + OrganizationSummary( + name = dto.name.required(), + displayName = dto.fullName?.takeIf(String::isNotBlank), + ) + } + return response.toPage(items, page, pageSize) + } + + override suspend fun listOrganizationRepositories( + organization: String, + page: Int, + pageSize: Int, + ): Page { + validatePage(page, pageSize) + val url = apiUrl("api", "v1", "orgs", organization, "repos").withPage(page, pageSize) + .newBuilder() + .addQueryParameter("sort", "updated") + .addQueryParameter("order", "desc") + .build() + val response = get(url) + val items = parseList(response.body).map(::mapRepository) + return response.toPage(items, page, pageSize) + } + override suspend fun listBranches( repository: RepositoryCoordinates, page: Int, @@ -299,6 +329,7 @@ class GiteaRepositoryBrowserApi( private fun Int.safeNextPage(): Int = if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1 + private data class OrganizationDto(val name: String?, @SerializedName("full_name") val fullName: String?) private data class OwnerDto(val login: String?) private data class RepositoryDto( val owner: OwnerDto?, diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationModels.kt new file mode 100644 index 0000000..85954ed --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationModels.kt @@ -0,0 +1,6 @@ +package com.ubisam.ubgitea.domain.organizations + +data class OrganizationSummary( + val name: String, + val displayName: String?, +) diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationsRepository.kt b/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationsRepository.kt new file mode 100644 index 0000000..d401b8d --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/organizations/OrganizationsRepository.kt @@ -0,0 +1,11 @@ +package com.ubisam.ubgitea.domain.organizations + +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositorySummary + +/** Read-only. No organization/team administration or membership-write method exists here. */ +interface OrganizationsRepository { + suspend fun listOrganizations(page: Int, pageSize: Int): Page + + suspend fun listOrganizationRepositories(organization: String, page: Int, pageSize: Int): Page +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index c238ced..55b7117 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -29,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.activity.compose.BackHandler import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.organizations.OrganizationSummary import com.ubisam.ubgitea.domain.model.RepositoryCoordinates @Composable @@ -50,6 +54,7 @@ fun RepositoryListScreen( onShowFavorites: () -> Unit, onToggleFavorite: (RepositoryRowUiModel) -> Unit, onViewWorkItems: (RepositoryRowUiModel) -> Unit, + onSelectOrganization: (String?) -> Unit, modifier: Modifier = Modifier, ) { Scaffold(modifier = modifier.fillMaxSize()) { padding -> @@ -108,6 +113,7 @@ fun RepositoryListScreen( onShowAllRepositories, onShowFavorites, onToggleFavorite, + onSelectOrganization, Modifier.padding(padding), ) is RepositoryListUiState.ReferencesLoading -> { @@ -190,6 +196,7 @@ private fun RepositoryContent( onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit, onToggleFavorite: (RepositoryRowUiModel) -> Unit, + onSelectOrganization: (String?) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier = modifier.fillMaxSize()) { @@ -210,6 +217,9 @@ private fun RepositoryContent( } } item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) } + if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) { + item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) } + } items(state.repositories, key = { it.coordinates }) { repository -> Row(Modifier.fillMaxWidth()) { Column( @@ -255,6 +265,27 @@ private fun RepositoryContent( } } +@Composable +private fun OrganizationFilterRow( + selected: String?, + organizations: List, + onSelect: (String?) -> Unit, +) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.repositories_organization_all)) }) + organizations.forEach { organization -> + FilterChip( + selected == organization.name, + { onSelect(organization.name) }, + { Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) }, + ) + } + } +} + @Composable private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) { TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) { 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 3fa128a..72ef7f0 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 @@ -20,6 +20,8 @@ import com.ubisam.ubgitea.data.repositories.BearerTokenProvider import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositorySummary +import com.ubisam.ubgitea.domain.organizations.OrganizationSummary +import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import java.io.IOException @@ -46,11 +48,18 @@ sealed interface RepositoryListUiState { val page: Int, val hasNextPage: Boolean, val mode: RepositoryListMode, + val organization: String?, + organizations: List, ) : RepositoryListUiState { val accountName: String = accountName val repositories: List = Collections.unmodifiableList(repositories.toList()) + val organizations: List = Collections.unmodifiableList(organizations.toList()) } - data class Empty(val accountName: String, val mode: RepositoryListMode) : RepositoryListUiState + data class Empty( + val accountName: String, + val mode: RepositoryListMode, + val organization: String? = null, + ) : RepositoryListUiState data class Error( val type: RepositoryListError, val accountName: String? = null, @@ -127,6 +136,7 @@ class RepositoryListViewModel( private val authApi: AuthenticationGateway, private val browserApi: GiteaRepositoryBrowser, private val favoritesApi: RepositoryFavorites, + private val organizationsApi: OrganizationsRepository, private val now: () -> Long = System::currentTimeMillis, ) : ViewModel() { private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config) @@ -141,6 +151,7 @@ class RepositoryListViewModel( private var activeAccountName: String? = null private var hasAuthenticatedSession = false private var repositoryContent: RepositoryListUiState.Content? = null + private var cachedOrganizations: List? = null init { startOperation { restoreSession() } @@ -208,34 +219,42 @@ class RepositoryListViewModel( val content = mutableState.value as? RepositoryListUiState.Content ?: return if (!content.hasNextPage) return val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, content.page + 1, content.mode) } + startOperation { loadRepositoryPage(account, content.page + 1, content.mode, content.organization) } } fun previousPage() { val content = mutableState.value as? RepositoryListUiState.Content ?: return if (content.page <= 1) return val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, content.page - 1, content.mode) } + startOperation { loadRepositoryPage(account, content.page - 1, content.mode, content.organization) } } fun refreshRepositories() { val account = activeAccountName ?: return - val (page, mode) = when (val current = mutableState.value) { - is RepositoryListUiState.Content -> current.page to current.mode - is RepositoryListUiState.Empty -> 1 to current.mode + val (page, mode, organization) = when (val current = mutableState.value) { + is RepositoryListUiState.Content -> Triple(current.page, current.mode, current.organization) + is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization) else -> return } - startOperation { loadRepositoryPage(account, page, mode) } + startOperation { loadRepositoryPage(account, page, mode, organization) } } fun showAllRepositories() { val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL) } + startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL, organization = null) } } fun showFavorites() { val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.FAVORITES) } + startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.FAVORITES, organization = null) } + } + + /** Organization filtering only applies to ALL mode; Gitea star is not organization-scoped. */ + fun selectOrganization(organization: String?) { + val content = mutableState.value as? RepositoryListUiState.Content ?: return + if (content.mode != RepositoryListMode.ALL) return + val account = activeAccountName ?: return + startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL, organization = organization) } } fun toggleFavorite(repository: RepositoryRowUiModel) { @@ -306,6 +325,7 @@ class RepositoryListViewModel( activeAccountName = null hasAuthenticatedSession = false repositoryContent = null + cachedOrganizations = null mutableState.value = RepositoryListUiState.SignedOut } } @@ -369,24 +389,34 @@ class RepositoryListViewModel( val user = authApi.getAuthenticatedUser(tokens.accessToken) val accountName = user.displayName ?: user.login activeAccountName = accountName - loadRepositoryPage(accountName, page = 1, mode = RepositoryListMode.ALL) + loadRepositoryPage(accountName, page = 1, mode = RepositoryListMode.ALL, organization = null) } - private suspend fun loadRepositoryPage(accountName: String, page: Int, mode: RepositoryListMode) { + private suspend fun loadRepositoryPage(accountName: String, page: Int, mode: RepositoryListMode, organization: String?) { cancelRowBranchJobs() mutableState.value = RepositoryListUiState.Loading try { - val result = when (mode) { - RepositoryListMode.ALL -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE) - RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE) + 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 organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList() val rows = result.items.map { it.toUiModel() } if (rows.isEmpty() && page == 1) { repositoryContent = null - mutableState.value = RepositoryListUiState.Empty(accountName, mode) + mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization) return } - val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null, mode) + val content = RepositoryListUiState.Content( + accountName, + rows, + page, + result.nextPage != null, + mode, + organization, + organizations, + ) repositoryContent = content mutableState.value = content loadRowBranches(content) @@ -406,6 +436,19 @@ class RepositoryListViewModel( } } + private suspend fun loadOrganizationsOnce(): List { + cachedOrganizations?.let { return it } + val loaded = try { + loadAllPages { page -> organizationsApi.listOrganizations(page, PAGE_SIZE) } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + // Non-fatal: the repository list itself is more important than the filter row. + emptyList() + } + cachedOrganizations = loaded + return loaded + } + private fun loadRowBranches(content: RepositoryListUiState.Content) { content.repositories .filter { it.lastPushedBranch is LastPushedBranchUiState.Loading } @@ -458,7 +501,15 @@ class RepositoryListViewModel( } private fun replaceContent(current: RepositoryListUiState.Content, repositories: List) { - RepositoryListUiState.Content(current.accountName, repositories, current.page, current.hasNextPage, current.mode).also { + RepositoryListUiState.Content( + current.accountName, + repositories, + current.page, + current.hasNextPage, + current.mode, + current.organization, + current.organizations, + ).also { repositoryContent = it mutableState.value = it } @@ -544,6 +595,7 @@ class RepositoryListViewModel( authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport), browserApi = browser, favoritesApi = browser, + organizationsApi = browser, ) as T } } diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index f5d32b0..5228a00 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -43,4 +43,5 @@ 즐겨찾기 해제 즐겨찾기됨 PR/이슈 + 전체 diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt index 24b8c6c..178995b 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt @@ -42,6 +42,42 @@ class GiteaRepositoryBrowserApiTest { assertEquals("5", url.queryParameter("limit")) } + @Test + fun `organizations map name and full name`() = runTest { + val transport = QueueTransport( + response( + """[{"name":"my-org","full_name":"My Organization"},{"name":"other","full_name":""}]""", + mapOf("X-HasMore" to "false"), + ), + ) + + val page = api(transport).listOrganizations(1, 20) + + assertEquals("my-org", page.items[0].name) + assertEquals("My Organization", page.items[0].displayName) + assertNull(page.items[1].displayName) + val url = transport.urls.single().toHttpUrl() + assertEquals(listOf("api", "v1", "user", "orgs"), url.pathSegments) + } + + @Test + fun `organization repositories reuse repository mapping and request recent-push order`() = runTest { + val transport = QueueTransport( + response( + """[{"owner":{"login":"my-org"},"name":"repo","default_branch":"main","private":false,"fork":false,"updated_at":"2026-08-25T10:20:30Z"}]""", + mapOf("X-HasMore" to "false"), + ), + ) + + val page = api(transport).listOrganizationRepositories("my org+#", 1, 5) + + assertEquals("repo", page.items.single().coordinates.name) + val url = transport.urls.single().toHttpUrl() + assertEquals(listOf("api", "v1", "orgs", "my org+#", "repos"), url.pathSegments) + assertEquals("updated", url.queryParameter("sort")) + assertEquals("desc", url.queryParameter("order")) + } + @Test fun `starring sends PUT to the encoded starred endpoint`() = runTest { val transport = QueueTransport(response("", emptyMap())) diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt index d50a6a7..5fe51d7 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt @@ -17,6 +17,8 @@ class RepositoryListUiStateTest { page = 1, hasNextPage = false, mode = RepositoryListMode.ALL, + organization = null, + organizations = emptyList(), ) source.clear() 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 1d5f738..b339b8b 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 @@ -17,6 +17,8 @@ import com.ubisam.ubgitea.domain.model.RepositorySummary import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.organizations.OrganizationSummary +import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import kotlinx.coroutines.Dispatchers @@ -202,6 +204,7 @@ class RepositoryListViewModelTest { authApi = FakeAuthGateway(), browserApi = browser, favoritesApi = browser, + organizationsApi = browser, now = { 1_000L }, ) advanceUntilIdle() @@ -300,6 +303,7 @@ class RepositoryListViewModelTest { authApi = FakeAuthGateway(), browserApi = browser, favoritesApi = browser, + organizationsApi = browser, now = { 1_000L }, ) advanceUntilIdle() @@ -313,6 +317,75 @@ class RepositoryListViewModelTest { assertEquals(null, tokenVault.tokens) } + @Test + fun `selecting an organization filters the list and organizations are exposed for the picker`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf(page1Of(repo("personal-repo"))), + organizations = listOf(Page.of(listOf(OrganizationSummary("my-org", "My Organization")), null)), + organizationRepositories = mapOf("my-org" to listOf(page1Of(repo("org-repo")))), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + + val allContent = viewModel.state.value as RepositoryListUiState.Content + assertEquals(listOf("personal-repo"), allContent.repositories.map { it.name }) + assertEquals(listOf(OrganizationSummary("my-org", "My Organization")), allContent.organizations) + assertEquals(null, allContent.organization) + + viewModel.selectOrganization("my-org") + advanceUntilIdle() + + val filtered = viewModel.state.value as RepositoryListUiState.Content + assertEquals(listOf("org-repo"), filtered.repositories.map { it.name }) + assertEquals("my-org", filtered.organization) + assertEquals(listOf("my-org"), browser.organizationRepositoryRequests) + + viewModel.selectOrganization(null) + advanceUntilIdle() + + val backToAll = viewModel.state.value as RepositoryListUiState.Content + assertEquals(listOf("personal-repo"), backToAll.repositories.map { it.name }) + assertEquals(null, backToAll.organization) + } + + @Test + fun `the organizations list is only fetched once and reused across page loads`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf( + Page.of(listOf(repo("a")), nextPage = 2), + Page.of(listOf(repo("b")), nextPage = null), + ), + organizations = listOf(Page.of(listOf(OrganizationSummary("org", null)), null)), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + + viewModel.nextPage() + advanceUntilIdle() + + val state = viewModel.state.value as RepositoryListUiState.Content + assertEquals(listOf(OrganizationSummary("org", null)), state.organizations) + } + + @Test + fun `organization filtering does not apply to the favorites tab`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf(page1Of(repo("repo"))), + starred = listOf(page1Of(repo("starred-repo"))), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + viewModel.showFavorites() + advanceUntilIdle() + + viewModel.selectOrganization("my-org") + advanceUntilIdle() + + val state = viewModel.state.value as RepositoryListUiState.Content + assertEquals(RepositoryListMode.FAVORITES, state.mode) + assertEquals(listOf("starred-repo"), state.repositories.map { it.name }) + } + private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null) private fun repo(name: String) = RepositorySummary( @@ -330,6 +403,7 @@ class RepositoryListViewModelTest { authApi = FakeAuthGateway(), browserApi = browser, favoritesApi = browser, + organizationsApi = browser, now = { 1_000L }, ) @@ -358,10 +432,22 @@ class RepositoryListViewModelTest { var branchesByRepository: Map> = emptyMap(), private val starred: List> = listOf(Page.of(emptyList(), null)), var favoriteActionFailure: Throwable? = null, - ) : GiteaRepositoryBrowser, RepositoryFavorites { + private val organizations: List> = listOf(Page.of(emptyList(), null)), + private val organizationRepositories: Map>> = emptyMap(), + ) : GiteaRepositoryBrowser, RepositoryFavorites, OrganizationsRepository { val repositoryPageRequests = mutableListOf>() val starredCalls = mutableListOf() val unstarredCalls = mutableListOf() + val organizationRepositoryRequests = mutableListOf() + + override suspend fun listOrganizations(page: Int, pageSize: Int): Page = + organizations.getOrElse(page - 1) { Page.of(emptyList(), null) } + + override suspend fun listOrganizationRepositories(organization: String, page: Int, pageSize: Int): Page { + organizationRepositoryRequests += organization + return organizationRepositories[organization]?.getOrElse(page - 1) { Page.of(emptyList(), null) } + ?: Page.of(emptyList(), null) + } override suspend fun listRepositories(page: Int, pageSize: Int): Page { repositoryPageRequests += page to pageSize diff --git a/docs/development-log.md b/docs/development-log.md index 132e81b..e46ce1d 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -255,3 +255,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: Only the route itself survives process death, not deeper in-feature state (open commit, scroll position) — explicitly out of scope. No physical device was available to verify process-death restoration interactively; the `Saver` round-trip is unit-tested directly instead. - Next recommended task: TASK-014 (organizations) and TASK-015 (notifications) are real MVP scope items from `docs/product.md` with no task files yet; TASK-016 (release hardening) depends on those and needs a physical device. Continuing per standing direction. - Next owner: Claude. + +## 2026-08-26 — TASK-014: organizations and organization repository filter + +- Agent and role: Claude, feature implementer, continuing the standing direction to own all subsequent tasks. No task file existed beforehand; created it first per `AGENTS.md`. Closes MVP outcome #2 in `docs/product.md` ("View the signed-in user and organizations"). +- Commit/diff: branch `claude/task-014-organizations` from `7756ec4`; new `domain/organizations/` package, additive changes to `GiteaRepositoryBrowserApi`/`RepositoryListViewModel`/`RepositoryListScreen`. +- Features implemented: The repository list can be filtered to one organization (a horizontally-scrollable chip row: 전체 + one per org) and back, keeping the existing recent-push sort and paging. +- How it was implemented: Verified Gitea's actual `Organization` struct (`name`/`full_name`) before implementing. `GiteaRepositoryBrowserApi` gained a third interface (`OrganizationsRepository`), reusing its existing `mapRepository` for org-scoped repos rather than duplicating mapping. The organizations list itself is fetched once per session and cached, since it changes rarely and every page load re-fetching it would be wasteful; a failure to load it degrades to an empty filter row rather than blocking the repository list, which is the primary content. +- Files changed: `domain/organizations/OrganizationModels.kt`, `domain/organizations/OrganizationsRepository.kt`, `GiteaRepositoryBrowserApi.kt`, `GiteaRepositoryBrowserApiTest.kt`, `RepositoryListViewModel.kt`, `RepositoryListViewModelTest.kt`, `RepositoryListUiStateTest.kt`, `RepositoryListScreen.kt`, `strings_repositories.xml`, `MainActivity.kt`, `tasks/014-organizations.md`. +- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 169 unit tests across 28 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean; sensitive-pattern scan clean. +- Review findings and resolutions: None — self-review found no P0-P3. +- Known limitations: No physical device or real multi-organization account was available to verify interactively. Organization membership is read once per session, not refreshed mid-session (matches the app's existing no-background-refresh posture). +- Next recommended task: TASK-015 (notifications) is the last remaining MVP-scope item without a task file; TASK-016 (release hardening) depends on it and needs a physical device. +- Next owner: Claude. diff --git a/tasks/014-organizations.md b/tasks/014-organizations.md new file mode 100644 index 0000000..a7372df --- /dev/null +++ b/tasks/014-organizations.md @@ -0,0 +1,71 @@ +# TASK-014: Browse organizations and filter repositories by organization + +Status: implemented; all required checks pass; pending Codex review +Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26) +Reviewer: Codex + +## Outcome + +A signed-in user can see the organizations their account belongs to and filter the repository list by one organization while still being able to return to "all repositories." This closes MVP outcome #2 in `docs/product.md` ("View the signed-in user and organizations"). + +## Scope + +- Included: + - Read-only `GET /user/orgs` and `GET /orgs/{org}/repos`, added to `GiteaRepositoryBrowserApi` as a third small interface, reusing its existing repository-mapping code. + - An organization filter row on the repository list (ALL mode only — favorites are not org-scoped in Gitea), defaulting to "전체" (all), with the existing loading/empty/offline/auth-expired/server-error states applying to the filtered list the same way they already do for the unfiltered one. +- Excluded: + - Any organization administration or membership write (create/join/leave/manage team). + - Filtering the Favorites tab by organization — starred repos are not org-scoped in Gitea's own model. + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/domain/organizations/` +- `app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt` (additive: new interface implementation only) +- `app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt` (additive) +- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/` +- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/` +- `app/src/main/res/values/strings_repositories.xml` +- `tasks/014-organizations.md` + +## Acceptance criteria + +- [x] Organizations list and organization-scoped repository list are both paginated read-only calls; no membership/team-write endpoint is reachable — `GiteaRepositoryBrowserApi` only adds `get()` calls for these two new methods (grep-confirmed, same as every other read-only endpoint in that class). +- [x] The repository list can be filtered to one organization and back to "all repositories" without losing the recent-push sort or paging behavior already built (TASK-017) — `listOrganizationRepositories` requests the same `sort=updated&order=desc`, uses the same `REPOSITORY_PAGE_SIZE`/page-based navigation, and reuses `mapRepository` so field mapping cannot drift between the two code paths. +- [x] Loading, empty, offline, authentication-expired, and server-error states apply to the organization-filtered list the same way they already do for the unfiltered list — the org-scoped call goes through the exact same `loadRepositoryPage` try/catch and `RepositoryListUiState` cases as the unfiltered call; there is no separate, parallel state machine for it. +- [x] Tests cover organization list mapping, organization-repository mapping (reusing existing repository mapping, not duplicating it), and filter switching — 2 data-layer tests plus 3 ViewModel tests (see Evidence). + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +Implemented on branch `claude/task-014-organizations` from commit `7756ec4`. + +- Domain: `domain/organizations/OrganizationModels.kt` (`OrganizationSummary(name, displayName)`), `domain/organizations/OrganizationsRepository.kt`. Data: `GiteaRepositoryBrowserApi` gained a third interface implementation (`listOrganizations`, `listOrganizationRepositories`), reusing its existing `mapRepository` for the latter rather than duplicating repository-JSON mapping a third time. +- Verified against Gitea's actual `Organization` Go struct (`name`/`full_name` fields) before implementing, not guessed. +- `RepositoryListViewModel`: `Content`/`Empty` gained `organization: String?` (null = all) and `Content` gained `organizations: List` for the filter row. The organizations list is fetched once per session (`loadOrganizationsOnce`, cached in `cachedOrganizations`, cleared on logout) rather than on every page load — a failure to load it is treated as non-fatal (empty filter row) and does not block showing the repository list itself, since the list is the primary content and the filter is secondary. `selectOrganization(String?)` only applies in ALL mode (Gitea star is not organization-scoped, so Favorites ignores it, tested explicitly). +- UI: `RepositoryListScreen` gained a horizontally-scrollable `FilterChip` row (전체 + one chip per organization) shown only in ALL mode when at least one organization exists, using each organization's `displayName` when present and falling back to `name`. + +Test coverage: 2 new data-layer tests (`organizations map name and full name`, `organization repositories reuse repository mapping and request recent-push order`) plus 3 new ViewModel tests (`selecting an organization filters the list and organizations are exposed for the picker`, `the organizations list is only fetched once and reused across page loads`, `organization filtering does not apply to the favorites tab`). + +Checks run from the new worktree (`local.properties` created locally, not committed): + +```text +./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.feature.repositories.RepositoryListViewModelTest" → BUILD SUCCESSFUL +./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL + - 169 unit tests across 28 suites, 0 failures/errors + - 0 lint errors +git diff --check → clean (CRLF notice only) +sensitive-pattern scan of changed files → no matches +``` + +Known limitations: +- No physical device was available to verify the filter chip row or an account with real multiple-organization membership interactively. +- Organization membership itself (which orgs the account belongs to) is read once per session and not refreshed mid-session if the user's org membership changes on the server; a manual logout/login or app restart picks up changes. This matches the app's existing no-background-refresh posture elsewhere. + +No open P0-P3 findings from self-review. Requesting Codex read-only review.