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 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-26 15:34:22 +09:00
parent 7756ec4d7e
commit 4fe3bf1c9f
12 changed files with 360 additions and 19 deletions

View File

@ -180,6 +180,7 @@ class MainActivity : ComponentActivity() {
onViewWorkItems = { repository -> onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
}, },
onSelectOrganization = viewModel::selectOrganization,
) )
} }
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {

View File

@ -22,6 +22,8 @@ import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TreeEntryType 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.GiteaRepositoryBrowser
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import java.time.Instant import java.time.Instant
@ -40,7 +42,7 @@ class GiteaRepositoryBrowserApi(
private val transport: HttpTransport, private val transport: HttpTransport,
private val tokenProvider: BearerTokenProvider, private val tokenProvider: BearerTokenProvider,
private val gson: Gson = Gson(), private val gson: Gson = Gson(),
) : GiteaRepositoryBrowser, RepositoryFavorites { ) : GiteaRepositoryBrowser, RepositoryFavorites, OrganizationsRepository {
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> { override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
validatePage(page, pageSize) validatePage(page, pageSize)
val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize) val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize)
@ -68,6 +70,34 @@ class GiteaRepositoryBrowserApi(
delete(starUrl(repository)) delete(starUrl(repository))
} }
override suspend fun listOrganizations(page: Int, pageSize: Int): Page<OrganizationSummary> {
validatePage(page, pageSize)
val response = get(apiUrl("api", "v1", "user", "orgs").withPage(page, pageSize))
val items = parseList<OrganizationDto>(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<RepositorySummary> {
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<RepositoryDto>(response.body).map(::mapRepository)
return response.toPage(items, page, pageSize)
}
override suspend fun listBranches( override suspend fun listBranches(
repository: RepositoryCoordinates, repository: RepositoryCoordinates,
page: Int, page: Int,
@ -299,6 +329,7 @@ class GiteaRepositoryBrowserApi(
private fun Int.safeNextPage(): Int = private fun Int.safeNextPage(): Int =
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1 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 OwnerDto(val login: String?)
private data class RepositoryDto( private data class RepositoryDto(
val owner: OwnerDto?, val owner: OwnerDto?,

View File

@ -0,0 +1,6 @@
package com.ubisam.ubgitea.domain.organizations
data class OrganizationSummary(
val name: String,
val displayName: String?,
)

View File

@ -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<OrganizationSummary>
suspend fun listOrganizationRepositories(organization: String, page: Int, pageSize: Int): Page<RepositorySummary>
}

View File

@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.clickable 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.selection.selectable
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -29,6 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import com.ubisam.ubgitea.R import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
@Composable @Composable
@ -50,6 +54,7 @@ fun RepositoryListScreen(
onShowFavorites: () -> Unit, onShowFavorites: () -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit, onToggleFavorite: (RepositoryRowUiModel) -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit, onViewWorkItems: (RepositoryRowUiModel) -> Unit,
onSelectOrganization: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold(modifier = modifier.fillMaxSize()) { padding -> Scaffold(modifier = modifier.fillMaxSize()) { padding ->
@ -108,6 +113,7 @@ fun RepositoryListScreen(
onShowAllRepositories, onShowAllRepositories,
onShowFavorites, onShowFavorites,
onToggleFavorite, onToggleFavorite,
onSelectOrganization,
Modifier.padding(padding), Modifier.padding(padding),
) )
is RepositoryListUiState.ReferencesLoading -> { is RepositoryListUiState.ReferencesLoading -> {
@ -190,6 +196,7 @@ private fun RepositoryContent(
onShowAllRepositories: () -> Unit, onShowAllRepositories: () -> Unit,
onShowFavorites: () -> Unit, onShowFavorites: () -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit, onToggleFavorite: (RepositoryRowUiModel) -> Unit,
onSelectOrganization: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier.fillMaxSize()) { LazyColumn(modifier = modifier.fillMaxSize()) {
@ -210,6 +217,9 @@ private fun RepositoryContent(
} }
} }
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) } 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 -> items(state.repositories, key = { it.coordinates }) { repository ->
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Column( Column(
@ -255,6 +265,27 @@ private fun RepositoryContent(
} }
} }
@Composable
private fun OrganizationFilterRow(
selected: String?,
organizations: List<OrganizationSummary>,
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 @Composable
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) { private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) { TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {

View File

@ -20,6 +20,8 @@ import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositorySummary 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.GiteaRepositoryBrowser
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import java.io.IOException import java.io.IOException
@ -46,11 +48,18 @@ sealed interface RepositoryListUiState {
val page: Int, val page: Int,
val hasNextPage: Boolean, val hasNextPage: Boolean,
val mode: RepositoryListMode, val mode: RepositoryListMode,
val organization: String?,
organizations: List<OrganizationSummary>,
) : RepositoryListUiState { ) : RepositoryListUiState {
val accountName: String = accountName val accountName: String = accountName
val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList()) val repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
val organizations: List<OrganizationSummary> = 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( data class Error(
val type: RepositoryListError, val type: RepositoryListError,
val accountName: String? = null, val accountName: String? = null,
@ -127,6 +136,7 @@ class RepositoryListViewModel(
private val authApi: AuthenticationGateway, private val authApi: AuthenticationGateway,
private val browserApi: GiteaRepositoryBrowser, private val browserApi: GiteaRepositoryBrowser,
private val favoritesApi: RepositoryFavorites, private val favoritesApi: RepositoryFavorites,
private val organizationsApi: OrganizationsRepository,
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
) : ViewModel() { ) : ViewModel() {
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config) private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
@ -141,6 +151,7 @@ class RepositoryListViewModel(
private var activeAccountName: String? = null private var activeAccountName: String? = null
private var hasAuthenticatedSession = false private var hasAuthenticatedSession = false
private var repositoryContent: RepositoryListUiState.Content? = null private var repositoryContent: RepositoryListUiState.Content? = null
private var cachedOrganizations: List<OrganizationSummary>? = null
init { init {
startOperation { restoreSession() } startOperation { restoreSession() }
@ -208,34 +219,42 @@ class RepositoryListViewModel(
val content = mutableState.value as? RepositoryListUiState.Content ?: return val content = mutableState.value as? RepositoryListUiState.Content ?: return
if (!content.hasNextPage) return if (!content.hasNextPage) return
val account = activeAccountName ?: 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() { fun previousPage() {
val content = mutableState.value as? RepositoryListUiState.Content ?: return val content = mutableState.value as? RepositoryListUiState.Content ?: return
if (content.page <= 1) return if (content.page <= 1) return
val account = activeAccountName ?: 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() { fun refreshRepositories() {
val account = activeAccountName ?: return val account = activeAccountName ?: return
val (page, mode) = when (val current = mutableState.value) { val (page, mode, organization) = when (val current = mutableState.value) {
is RepositoryListUiState.Content -> current.page to current.mode is RepositoryListUiState.Content -> Triple(current.page, current.mode, current.organization)
is RepositoryListUiState.Empty -> 1 to current.mode is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization)
else -> return else -> return
} }
startOperation { loadRepositoryPage(account, page, mode) } startOperation { loadRepositoryPage(account, page, mode, organization) }
} }
fun showAllRepositories() { fun showAllRepositories() {
val account = activeAccountName ?: return val account = activeAccountName ?: return
startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL) } startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL, organization = null) }
} }
fun showFavorites() { fun showFavorites() {
val account = activeAccountName ?: return 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) { fun toggleFavorite(repository: RepositoryRowUiModel) {
@ -306,6 +325,7 @@ class RepositoryListViewModel(
activeAccountName = null activeAccountName = null
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
cachedOrganizations = null
mutableState.value = RepositoryListUiState.SignedOut mutableState.value = RepositoryListUiState.SignedOut
} }
} }
@ -369,24 +389,34 @@ class RepositoryListViewModel(
val user = authApi.getAuthenticatedUser(tokens.accessToken) val user = authApi.getAuthenticatedUser(tokens.accessToken)
val accountName = user.displayName ?: user.login val accountName = user.displayName ?: user.login
activeAccountName = accountName 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() cancelRowBranchJobs()
mutableState.value = RepositoryListUiState.Loading mutableState.value = RepositoryListUiState.Loading
try { try {
val result = when (mode) { val result = when {
RepositoryListMode.ALL -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE) mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE)
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() } val rows = result.items.map { it.toUiModel() }
if (rows.isEmpty() && page == 1) { if (rows.isEmpty() && page == 1) {
repositoryContent = null repositoryContent = null
mutableState.value = RepositoryListUiState.Empty(accountName, mode) mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
return 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 repositoryContent = content
mutableState.value = content mutableState.value = content
loadRowBranches(content) loadRowBranches(content)
@ -406,6 +436,19 @@ class RepositoryListViewModel(
} }
} }
private suspend fun loadOrganizationsOnce(): List<OrganizationSummary> {
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) { private fun loadRowBranches(content: RepositoryListUiState.Content) {
content.repositories content.repositories
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading } .filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
@ -458,7 +501,15 @@ class RepositoryListViewModel(
} }
private fun replaceContent(current: RepositoryListUiState.Content, repositories: List<RepositoryRowUiModel>) { private fun replaceContent(current: RepositoryListUiState.Content, repositories: List<RepositoryRowUiModel>) {
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 repositoryContent = it
mutableState.value = it mutableState.value = it
} }
@ -544,6 +595,7 @@ class RepositoryListViewModel(
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport), authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
browserApi = browser, browserApi = browser,
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser,
) as T ) as T
} }
} }

View File

@ -43,4 +43,5 @@
<string name="repositories_unfavorite">즐겨찾기 해제</string> <string name="repositories_unfavorite">즐겨찾기 해제</string>
<string name="repositories_favorited">즐겨찾기됨</string> <string name="repositories_favorited">즐겨찾기됨</string>
<string name="repositories_work_items">PR/이슈</string> <string name="repositories_work_items">PR/이슈</string>
<string name="repositories_organization_all">전체</string>
</resources> </resources>

View File

@ -42,6 +42,42 @@ class GiteaRepositoryBrowserApiTest {
assertEquals("5", url.queryParameter("limit")) 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 @Test
fun `starring sends PUT to the encoded starred endpoint`() = runTest { fun `starring sends PUT to the encoded starred endpoint`() = runTest {
val transport = QueueTransport(response("", emptyMap())) val transport = QueueTransport(response("", emptyMap()))

View File

@ -17,6 +17,8 @@ class RepositoryListUiStateTest {
page = 1, page = 1,
hasNextPage = false, hasNextPage = false,
mode = RepositoryListMode.ALL, mode = RepositoryListMode.ALL,
organization = null,
organizations = emptyList(),
) )
source.clear() source.clear()

View File

@ -17,6 +17,8 @@ import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.model.TreeEntry 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.GiteaRepositoryBrowser
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -202,6 +204,7 @@ class RepositoryListViewModelTest {
authApi = FakeAuthGateway(), authApi = FakeAuthGateway(),
browserApi = browser, browserApi = browser,
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser,
now = { 1_000L }, now = { 1_000L },
) )
advanceUntilIdle() advanceUntilIdle()
@ -300,6 +303,7 @@ class RepositoryListViewModelTest {
authApi = FakeAuthGateway(), authApi = FakeAuthGateway(),
browserApi = browser, browserApi = browser,
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser,
now = { 1_000L }, now = { 1_000L },
) )
advanceUntilIdle() advanceUntilIdle()
@ -313,6 +317,75 @@ class RepositoryListViewModelTest {
assertEquals(null, tokenVault.tokens) 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 page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
private fun repo(name: String) = RepositorySummary( private fun repo(name: String) = RepositorySummary(
@ -330,6 +403,7 @@ class RepositoryListViewModelTest {
authApi = FakeAuthGateway(), authApi = FakeAuthGateway(),
browserApi = browser, browserApi = browser,
favoritesApi = browser, favoritesApi = browser,
organizationsApi = browser,
now = { 1_000L }, now = { 1_000L },
) )
@ -358,10 +432,22 @@ class RepositoryListViewModelTest {
var branchesByRepository: Map<String, List<BranchSummary>> = emptyMap(), var branchesByRepository: Map<String, List<BranchSummary>> = emptyMap(),
private val starred: List<Page<RepositorySummary>> = listOf(Page.of(emptyList(), null)), private val starred: List<Page<RepositorySummary>> = listOf(Page.of(emptyList(), null)),
var favoriteActionFailure: Throwable? = null, var favoriteActionFailure: Throwable? = null,
) : GiteaRepositoryBrowser, RepositoryFavorites { private val organizations: List<Page<OrganizationSummary>> = listOf(Page.of(emptyList(), null)),
private val organizationRepositories: Map<String, List<Page<RepositorySummary>>> = emptyMap(),
) : GiteaRepositoryBrowser, RepositoryFavorites, OrganizationsRepository {
val repositoryPageRequests = mutableListOf<Pair<Int, Int>>() val repositoryPageRequests = mutableListOf<Pair<Int, Int>>()
val starredCalls = mutableListOf<RepositoryCoordinates>() val starredCalls = mutableListOf<RepositoryCoordinates>()
val unstarredCalls = mutableListOf<RepositoryCoordinates>() val unstarredCalls = mutableListOf<RepositoryCoordinates>()
val organizationRepositoryRequests = mutableListOf<String>()
override suspend fun listOrganizations(page: Int, pageSize: Int): Page<OrganizationSummary> =
organizations.getOrElse(page - 1) { Page.of(emptyList(), null) }
override suspend fun listOrganizationRepositories(organization: String, page: Int, pageSize: Int): Page<RepositorySummary> {
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<RepositorySummary> { override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
repositoryPageRequests += page to pageSize repositoryPageRequests += page to pageSize

View File

@ -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. - 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 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. - 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.

View File

@ -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<OrganizationSummary>` 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.