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 ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
)
}
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.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<RepositorySummary> {
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<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(
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?,

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.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<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
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
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.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<OrganizationSummary>,
) : RepositoryListUiState {
val accountName: String = accountName
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(
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<OrganizationSummary>? = 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<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) {
content.repositories
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
@ -458,7 +501,15 @@ class RepositoryListViewModel(
}
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
mutableState.value = it
}
@ -544,6 +595,7 @@ class RepositoryListViewModel(
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
browserApi = browser,
favoritesApi = browser,
organizationsApi = browser,
) as T
}
}

View File

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

View File

@ -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()))

View File

@ -17,6 +17,8 @@ class RepositoryListUiStateTest {
page = 1,
hasNextPage = false,
mode = RepositoryListMode.ALL,
organization = null,
organizations = emptyList(),
)
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.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<String, List<BranchSummary>> = emptyMap(),
private val starred: List<Page<RepositorySummary>> = listOf(Page.of(emptyList(), 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 starredCalls = 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> {
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.
- 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.

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.