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:
parent
7756ec4d7e
commit
4fe3bf1c9f
@ -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) {
|
||||||
|
|||||||
@ -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?,
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.ubisam.ubgitea.domain.organizations
|
||||||
|
|
||||||
|
data class OrganizationSummary(
|
||||||
|
val name: String,
|
||||||
|
val displayName: String?,
|
||||||
|
)
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()))
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
71
tasks/014-organizations.md
Normal file
71
tasks/014-organizations.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user