Sort repositories by recent push, page the list, show last-pushed branch

TASK-017, human product owner priority-1 request. Repository list now
requests sort=updated&order=desc from Gitea, loads 5 repositories per
page with explicit prev/next navigation, and each visible row
independently fetches and shows its most recently committed branch.

Removes the legacy eager-loading RepositoryListApi in favor of the
existing paged GiteaRepositoryBrowser.listRepositories. Fixes two
issues found in self-review: missing error handling on page
navigation, and repositories with no default branch (empty repos)
previously failing the whole page instead of just that row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-26 13:24:31 +09:00
parent fdda16442a
commit 93b8d587ee
15 changed files with 541 additions and 249 deletions

View File

@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
@ -74,7 +73,7 @@ class MainActivity : ComponentActivity() {
viewModel.selectReference(name, kind) viewModel.selectReference(name, kind)
codeBrowserViewModel.open( codeBrowserViewModel.open(
CodeBrowserTarget( CodeBrowserTarget(
repository = RepositoryCoordinates.of(repository.owner, repository.name), repository = repository.coordinates,
ref = RepositoryRef.of(name), ref = RepositoryRef.of(name),
kind = if (kind == ReferenceKind.BRANCH) { kind = if (kind == ReferenceKind.BRANCH) {
CodeReferenceKind.BRANCH CodeReferenceKind.BRANCH
@ -87,6 +86,10 @@ class MainActivity : ComponentActivity() {
}, },
onReferencesRefresh = viewModel::refreshReferences, onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories, onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
) )
} }
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {

View File

@ -42,18 +42,22 @@ class GiteaRepositoryBrowserApi(
) : GiteaRepositoryBrowser { ) : GiteaRepositoryBrowser {
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 response = get( val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize)
apiUrl("api", "v1", "user", "repos").withPage(page, pageSize), .newBuilder()
) .addQueryParameter("sort", "updated")
.addQueryParameter("order", "desc")
.build()
val response = get(url)
val items = parseList<RepositoryDto>(response.body).map { dto -> val items = parseList<RepositoryDto>(response.body).map { dto ->
val owner = dto.owner?.login.required() val owner = dto.owner?.login.required()
val name = dto.name.required() val name = dto.name.required()
RepositorySummary( RepositorySummary(
coordinates = RepositoryCoordinates.of(owner, name), coordinates = RepositoryCoordinates.of(owner, name),
description = dto.description?.takeIf(String::isNotBlank), description = dto.description?.takeIf(String::isNotBlank),
defaultBranch = RepositoryRef.of(dto.defaultBranch.required()), defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of),
isPrivate = dto.isPrivate, isPrivate = dto.isPrivate,
isFork = dto.isFork, isFork = dto.isFork,
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
) )
} }
return response.toPage(items, page, pageSize) return response.toPage(items, page, pageSize)
@ -71,6 +75,7 @@ class GiteaRepositoryBrowserApi(
name = RepositoryRef.of(dto.name.required()), name = RepositoryRef.of(dto.name.required()),
commitId = dto.commit?.id.required(), commitId = dto.commit?.id.required(),
isProtected = dto.isProtected, isProtected = dto.isProtected,
commitAtEpochMillis = dto.commit?.timestamp.required().toEpochMillisOrInvalid(),
) )
} }
return response.toPage(items, page, pageSize) return response.toPage(items, page, pageSize)
@ -192,20 +197,21 @@ class GiteaRepositoryBrowserApi(
private fun mapCommit(dto: CommitDto): CommitSummary { private fun mapCommit(dto: CommitDto): CommitSummary {
val id = dto.sha.required() val id = dto.sha.required()
val author = dto.commit?.author ?: throw ApiFailure.InvalidResponse() val author = dto.commit?.author ?: throw ApiFailure.InvalidResponse()
val timestamp = try {
Instant.parse(author.date.required()).toEpochMilli()
} catch (_: DateTimeParseException) {
throw ApiFailure.InvalidResponse()
}
return CommitSummary( return CommitSummary(
id = id, id = id,
shortId = id.take(7), shortId = id.take(7),
message = dto.commit.message.required(), message = dto.commit.message.required(),
authorName = author.name.required(), authorName = author.name.required(),
authoredAtEpochMillis = timestamp, authoredAtEpochMillis = author.date.required().toEpochMillisOrInvalid(),
) )
} }
private fun String.toEpochMillisOrInvalid(): Long = try {
Instant.parse(this).toEpochMilli()
} catch (_: DateTimeParseException) {
throw ApiFailure.InvalidResponse()
}
private fun parseContentsList(body: String): List<ContentsDto> = try { private fun parseContentsList(body: String): List<ContentsDto> = try {
if (!JsonParser.parseString(body).isJsonArray) throw ApiFailure.InvalidResponse() if (!JsonParser.parseString(body).isJsonArray) throw ApiFailure.InvalidResponse()
parseList(body) parseList(body)
@ -268,13 +274,14 @@ class GiteaRepositoryBrowserApi(
@SerializedName("default_branch") val defaultBranch: String?, @SerializedName("default_branch") val defaultBranch: String?,
@SerializedName("private") val isPrivate: Boolean, @SerializedName("private") val isPrivate: Boolean,
@SerializedName("fork") val isFork: Boolean, @SerializedName("fork") val isFork: Boolean,
@SerializedName("updated_at") val updatedAt: String?,
) )
private data class BranchDto( private data class BranchDto(
val name: String?, val name: String?,
val commit: BranchCommitDto?, val commit: BranchCommitDto?,
@SerializedName("protected") val isProtected: Boolean, @SerializedName("protected") val isProtected: Boolean,
) )
private data class BranchCommitDto(val id: String?) private data class BranchCommitDto(val id: String?, val timestamp: String?)
private data class TagDto(val name: String?, val id: String?, val commit: TagCommitDto?) private data class TagDto(val name: String?, val id: String?, val commit: TagCommitDto?)
private data class TagCommitDto(val sha: String?) private data class TagCommitDto(val sha: String?)
private data class ContentsDto( private data class ContentsDto(

View File

@ -1,87 +0,0 @@
package com.ubisam.ubgitea.data.repositories
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.core.network.requireSuccess
data class AccessibleRepository(
val id: Long,
val owner: String,
val name: String,
val description: String?,
val isPrivate: Boolean,
val defaultBranch: String?,
)
fun interface AccessibleRepositoriesGateway {
suspend fun listAll(accessToken: String): List<AccessibleRepository>
}
class RepositoryListApi(
private val config: OAuthClientConfig,
private val transport: HttpTransport,
private val gson: Gson = Gson(),
private val pageSize: Int = 50,
private val maxPages: Int = 100,
) : AccessibleRepositoriesGateway {
init {
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
require(maxPages > 0) { "Max pages must be positive" }
}
override suspend fun listAll(accessToken: String): List<AccessibleRepository> {
val repositories = linkedMapOf<Long, AccessibleRepository>()
for (page in 1..maxPages) {
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
val response = transport.get(url, accessToken).requireSuccess()
val items = parse(response.body).map(::map)
items.forEach { repositories[it.id] = it }
val hasMore = response.headers.entries
.firstOrNull { it.key.equals("X-HasMore", ignoreCase = true) }
?.value
?.equals("true", ignoreCase = true)
if (hasMore == false || (hasMore == null && items.size < pageSize)) {
return repositories.values.toList()
}
}
throw ApiFailure.InvalidResponse()
}
private fun parse(body: String): List<RepositoryDto> = try {
val type = object : TypeToken<List<RepositoryDto>>() {}.type
gson.fromJson<List<RepositoryDto>>(body, type) ?: throw ApiFailure.InvalidResponse()
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private fun map(dto: RepositoryDto): AccessibleRepository {
val owner = dto.owner?.login?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
val name = dto.name?.takeIf(String::isNotBlank) ?: throw ApiFailure.InvalidResponse()
if (dto.id <= 0) throw ApiFailure.InvalidResponse()
return AccessibleRepository(
id = dto.id,
owner = owner,
name = name,
description = dto.description?.takeIf(String::isNotBlank),
isPrivate = dto.isPrivate,
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank),
)
}
private data class RepositoryDto(
val id: Long,
val owner: OwnerDto?,
val name: String?,
val description: String?,
@SerializedName("private") val isPrivate: Boolean,
@SerializedName("default_branch") val defaultBranch: String?,
)
private data class OwnerDto(val login: String?)
}

View File

@ -5,15 +5,17 @@ import java.util.Collections
data class RepositorySummary( data class RepositorySummary(
val coordinates: RepositoryCoordinates, val coordinates: RepositoryCoordinates,
val description: String?, val description: String?,
val defaultBranch: RepositoryRef, val defaultBranch: RepositoryRef?,
val isPrivate: Boolean, val isPrivate: Boolean,
val isFork: Boolean, val isFork: Boolean,
val updatedAtEpochMillis: Long,
) )
data class BranchSummary( data class BranchSummary(
val name: RepositoryRef, val name: RepositoryRef,
val commitId: String, val commitId: String,
val isProtected: Boolean, val isProtected: Boolean,
val commitAtEpochMillis: Long,
) )
data class TagSummary( data class TagSummary(

View File

@ -26,6 +26,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.model.RepositoryCoordinates
@Composable @Composable
fun RepositoryListScreen( fun RepositoryListScreen(
@ -38,6 +39,10 @@ fun RepositoryListScreen(
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit, onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
onReferencesRefresh: () -> Unit, onReferencesRefresh: () -> Unit,
onBackToRepositories: () -> Unit, onBackToRepositories: () -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onRefreshRepositories: () -> Unit,
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold(modifier = modifier.fillMaxSize()) { padding -> Scaffold(modifier = modifier.fillMaxSize()) { padding ->
@ -82,6 +87,10 @@ fun RepositoryListScreen(
state, state,
onLogout, onLogout,
onRepositorySelected, onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
Modifier.padding(padding), Modifier.padding(padding),
) )
is RepositoryListUiState.ReferencesLoading -> { is RepositoryListUiState.ReferencesLoading -> {
@ -156,6 +165,10 @@ private fun RepositoryContent(
state: RepositoryListUiState.Content, state: RepositoryListUiState.Content,
onLogout: () -> Unit, onLogout: () -> Unit,
onRepositorySelected: (RepositoryRowUiModel) -> Unit, onRepositorySelected: (RepositoryRowUiModel) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onRefreshRepositories: () -> Unit,
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier.fillMaxSize()) { LazyColumn(modifier = modifier.fillMaxSize()) {
@ -169,10 +182,13 @@ private fun RepositoryContent(
Text(stringResource(R.string.repositories_account, state.accountName)) Text(stringResource(R.string.repositories_account, state.accountName))
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) } OutlinedButton(onClick = onRefreshRepositories) { Text(stringResource(R.string.repositories_refresh)) }
OutlinedButton(onClick = onLogout, modifier = Modifier.padding(start = 8.dp)) {
Text(stringResource(R.string.repositories_logout))
} }
} }
items(state.repositories, key = RepositoryRowUiModel::id) { repository -> }
items(state.repositories, key = { it.coordinates }) { repository ->
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -192,9 +208,56 @@ private fun RepositoryContent(
} ?: stringResource(R.string.repositories_no_default_branch), } ?: stringResource(R.string.repositories_no_default_branch),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
LastPushedBranchRow(repository, onRetryLastPushedBranch)
} }
HorizontalDivider() HorizontalDivider()
} }
item {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onPreviousPage, enabled = state.page > 1) {
Text(stringResource(R.string.repositories_previous_page))
}
Text(stringResource(R.string.repositories_page_number, state.page))
OutlinedButton(onClick = onNextPage, enabled = state.hasNextPage) {
Text(stringResource(R.string.repositories_next_page))
}
}
}
}
}
@Composable
private fun LastPushedBranchRow(
repository: RepositoryRowUiModel,
onRetry: (RepositoryCoordinates) -> Unit,
) {
when (val branch = repository.lastPushedBranch) {
LastPushedBranchUiState.Loading -> Text(
stringResource(R.string.repositories_last_pushed_branch_loading),
style = MaterialTheme.typography.bodySmall,
)
is LastPushedBranchUiState.Loaded -> Text(
stringResource(R.string.repositories_last_pushed_branch, branch.name),
style = MaterialTheme.typography.bodySmall,
)
LastPushedBranchUiState.Empty -> Text(
stringResource(R.string.repositories_last_pushed_branch_none),
style = MaterialTheme.typography.bodySmall,
)
LastPushedBranchUiState.Failed -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.repositories_last_pushed_branch_failed),
style = MaterialTheme.typography.bodySmall,
)
OutlinedButton(
onClick = { onRetry(repository.coordinates) },
modifier = Modifier.padding(start = 8.dp),
) { Text(stringResource(R.string.repositories_retry)) }
}
} }
} }

View File

@ -16,12 +16,10 @@ import com.ubisam.ubgitea.data.auth.AuthApi
import com.ubisam.ubgitea.data.auth.AuthenticationGateway import com.ubisam.ubgitea.data.auth.AuthenticationGateway
import com.ubisam.ubgitea.data.auth.TokenSet import com.ubisam.ubgitea.data.auth.TokenSet
import com.ubisam.ubgitea.data.auth.TokenVault import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider 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.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import java.io.IOException import java.io.IOException
import java.util.Collections import java.util.Collections
@ -39,7 +37,12 @@ sealed interface RepositoryListUiState {
data object SignedOut : RepositoryListUiState data object SignedOut : RepositoryListUiState
data object WaitingForBrowser : RepositoryListUiState data object WaitingForBrowser : RepositoryListUiState
data object Loading : RepositoryListUiState data object Loading : RepositoryListUiState
class Content(accountName: String, repositories: List<RepositoryRowUiModel>) : RepositoryListUiState { class Content(
accountName: String,
repositories: List<RepositoryRowUiModel>,
val page: Int,
val hasNextPage: Boolean,
) : 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())
} }
@ -69,14 +72,24 @@ sealed interface RepositoryListUiState {
) : RepositoryListUiState ) : RepositoryListUiState
} }
sealed interface LastPushedBranchUiState {
data object Loading : LastPushedBranchUiState
data class Loaded(val name: String) : LastPushedBranchUiState
data object Empty : LastPushedBranchUiState
data object Failed : LastPushedBranchUiState
}
data class RepositoryRowUiModel( data class RepositoryRowUiModel(
val id: Long, val coordinates: RepositoryCoordinates,
val owner: String,
val name: String,
val description: String?, val description: String?,
val isPrivate: Boolean, val isPrivate: Boolean,
val defaultBranch: String?, val defaultBranch: String?,
) val updatedAtEpochMillis: Long,
val lastPushedBranch: LastPushedBranchUiState = LastPushedBranchUiState.Loading,
) {
val owner: String get() = coordinates.owner
val name: String get() = coordinates.name
}
enum class ReferenceKind { BRANCH, TAG } enum class ReferenceKind { BRANCH, TAG }
@ -100,7 +113,6 @@ class RepositoryListViewModel(
private val tokenVault: TokenVault, private val tokenVault: TokenVault,
private val pendingAuthStore: PendingAuthTransactionStore, private val pendingAuthStore: PendingAuthTransactionStore,
private val authApi: AuthenticationGateway, private val authApi: AuthenticationGateway,
private val repositoryApi: AccessibleRepositoriesGateway,
private val browserApi: GiteaRepositoryBrowser, private val browserApi: GiteaRepositoryBrowser,
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
) : ViewModel() { ) : ViewModel() {
@ -112,6 +124,7 @@ class RepositoryListViewModel(
private val mutableBrowserRequests = MutableSharedFlow<String>(extraBufferCapacity = 1) private val mutableBrowserRequests = MutableSharedFlow<String>(extraBufferCapacity = 1)
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow() val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
private var operation: Job? = null private var operation: Job? = null
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
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
@ -153,6 +166,7 @@ class RepositoryListViewModel(
fun selectRepository(repository: RepositoryRowUiModel) { fun selectRepository(repository: RepositoryRowUiModel) {
val account = activeAccountName ?: return val account = activeAccountName ?: return
cancelRowBranchJobs()
startOperation { loadReferences(account, repository) } startOperation { loadReferences(account, repository) }
} }
@ -177,10 +191,39 @@ class RepositoryListViewModel(
selectRepository(current.repository) selectRepository(current.repository)
} }
fun nextPage() {
val current = mutableState.value as? RepositoryListUiState.Content ?: return
if (!current.hasNextPage) return
val account = activeAccountName ?: return
startOperation { loadRepositoryPage(account, current.page + 1) }
}
fun previousPage() {
val current = mutableState.value as? RepositoryListUiState.Content ?: return
if (current.page <= 1) return
val account = activeAccountName ?: return
startOperation { loadRepositoryPage(account, current.page - 1) }
}
fun refreshRepositories() {
val current = mutableState.value as? RepositoryListUiState.Content ?: return
val account = activeAccountName ?: return
startOperation { loadRepositoryPage(account, current.page) }
}
fun retryRowLastPushedBranch(coordinates: RepositoryCoordinates) {
val current = mutableState.value as? RepositoryListUiState.Content ?: return
if (current.repositories.none { it.coordinates == coordinates }) return
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Loading) }
launchRowBranchFetch(coordinates)
}
fun backToRepositories() { fun backToRepositories() {
operation?.cancel() operation?.cancel()
operation = null operation = null
repositoryContent?.let { mutableState.value = it } val restored = repositoryContent ?: return
mutableState.value = restored
loadRowBranches(restored)
} }
fun cancelSignIn() { fun cancelSignIn() {
@ -202,6 +245,7 @@ class RepositoryListViewModel(
} }
fun logout() { fun logout() {
cancelRowBranchJobs()
startOperation { startOperation {
pendingAuthStore.clear() pendingAuthStore.clear()
tokenVault.clear() tokenVault.clear()
@ -271,22 +315,98 @@ 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
val repositories = repositoryApi.listAll(tokens.accessToken) loadRepositoryPage(accountName, page = 1)
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() }) }
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
else RepositoryListUiState.Content(accountName, rows).also { repositoryContent = it } private suspend fun loadRepositoryPage(accountName: String, page: Int) {
cancelRowBranchJobs()
mutableState.value = RepositoryListUiState.Loading
try {
val result = browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE)
val rows = result.items.map { it.toUiModel() }
if (rows.isEmpty() && page == 1) {
repositoryContent = null
mutableState.value = RepositoryListUiState.Empty(accountName)
return
}
val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null)
repositoryContent = content
mutableState.value = content
loadRowBranches(content)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
repositoryContent = null
}
mutableState.value = RepositoryListUiState.Error(
failure.toRepositoryListError(),
accountName,
hasAuthenticatedSession,
)
}
}
private fun loadRowBranches(content: RepositoryListUiState.Content) {
content.repositories
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
.forEach { launchRowBranchFetch(it.coordinates) }
}
private fun launchRowBranchFetch(coordinates: RepositoryCoordinates) {
rowBranchJobs[coordinates]?.cancel()
rowBranchJobs[coordinates] = viewModelScope.launch {
try {
val branches = loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) }
val latest = branches.maxByOrNull { it.commitAtEpochMillis }
updateRow(coordinates) {
it.copy(
lastPushedBranch = latest?.let { branch ->
LastPushedBranchUiState.Loaded(branch.name.value)
} ?: LastPushedBranchUiState.Empty,
)
}
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) {
cancelRowBranchJobs()
tokenVault.clear()
activeAccountName = null
hasAuthenticatedSession = false
repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
} else {
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
}
}
}
}
private fun updateRow(coordinates: RepositoryCoordinates, transform: (RepositoryRowUiModel) -> RepositoryRowUiModel) {
val current = mutableState.value as? RepositoryListUiState.Content ?: return
val updated = current.repositories.map { if (it.coordinates == coordinates) transform(it) else it }
RepositoryListUiState.Content(current.accountName, updated, current.page, current.hasNextPage).also {
repositoryContent = it
mutableState.value = it
}
}
private fun cancelRowBranchJobs() {
rowBranchJobs.values.forEach { it.cancel() }
rowBranchJobs.clear()
} }
private suspend fun loadReferences(accountName: String, repository: RepositoryRowUiModel) { private suspend fun loadReferences(accountName: String, repository: RepositoryRowUiModel) {
mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository) mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository)
val coordinates = RepositoryCoordinates.of(repository.owner, repository.name)
try { try {
val branches = RepositoryReferencePresenter.branches( val branches = RepositoryReferencePresenter.branches(
loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) }, loadAllPages { page -> browserApi.listBranches(repository.coordinates, page, PAGE_SIZE) },
repository.defaultBranch, repository.defaultBranch,
) )
val tags = RepositoryReferencePresenter.tags( val tags = RepositoryReferencePresenter.tags(
loadAllPages { page -> browserApi.listTags(coordinates, page, PAGE_SIZE) }, loadAllPages { page -> browserApi.listTags(repository.coordinates, page, PAGE_SIZE) },
) )
mutableState.value = RepositoryListUiState.ReferencesContent( mutableState.value = RepositoryListUiState.ReferencesContent(
accountName, accountName,
@ -328,8 +448,12 @@ class RepositoryListViewModel(
operation = viewModelScope.launch { block() } operation = viewModelScope.launch { block() }
} }
private fun AccessibleRepository.toUiModel() = RepositoryRowUiModel( private fun RepositorySummary.toUiModel() = RepositoryRowUiModel(
id, owner, name, description, isPrivate, defaultBranch, coordinates = coordinates,
description = description,
isPrivate = isPrivate,
defaultBranch = defaultBranch?.value,
updatedAtEpochMillis = updatedAtEpochMillis,
) )
class Factory(private val application: Application) : ViewModelProvider.Factory { class Factory(private val application: Application) : ViewModelProvider.Factory {
@ -338,12 +462,10 @@ class RepositoryListViewModel(
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java)) require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
val tokenVault = AndroidKeystoreTokenVault(application) val tokenVault = AndroidKeystoreTokenVault(application)
val authTransport = OkHttpTransport() val authTransport = OkHttpTransport()
val repositoryTransport = OkHttpTransport()
return RepositoryListViewModel( return RepositoryListViewModel(
tokenVault = tokenVault, tokenVault = tokenVault,
pendingAuthStore = PendingAuthStore(application), pendingAuthStore = PendingAuthStore(application),
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport), authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
repositoryApi = RepositoryListApi(UBGiteaOAuthDefaults.config, repositoryTransport),
browserApi = GiteaRepositoryBrowserApi( browserApi = GiteaRepositoryBrowserApi(
UBGiteaOAuthDefaults.config, UBGiteaOAuthDefaults.config,
OkHttpTransport(), OkHttpTransport(),
@ -355,6 +477,7 @@ class RepositoryListViewModel(
private companion object { private companion object {
const val PAGE_SIZE = 50 const val PAGE_SIZE = 50
const val REPOSITORY_PAGE_SIZE = 5
const val MAX_REFERENCE_PAGES = 100 const val MAX_REFERENCE_PAGES = 100
} }
} }

View File

@ -29,4 +29,11 @@
<string name="repositories_protected_branch">보호된 브랜치</string> <string name="repositories_protected_branch">보호된 브랜치</string>
<string name="repositories_back">저장소 목록</string> <string name="repositories_back">저장소 목록</string>
<string name="repositories_refresh">새로고침</string> <string name="repositories_refresh">새로고침</string>
<string name="repositories_previous_page">이전 페이지</string>
<string name="repositories_next_page">다음 페이지</string>
<string name="repositories_page_number">%1$d페이지</string>
<string name="repositories_last_pushed_branch_loading">마지막 push 브랜치 확인 중…</string>
<string name="repositories_last_pushed_branch">마지막 push: %1$s</string>
<string name="repositories_last_pushed_branch_none">브랜치가 없습니다.</string>
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
</resources> </resources>

View File

@ -23,11 +23,43 @@ import org.junit.Test
class GiteaRepositoryBrowserApiTest { class GiteaRepositoryBrowserApiTest {
private val repository = RepositoryCoordinates.of("team name", "mobile+#") private val repository = RepositoryCoordinates.of("team name", "mobile+#")
@Test
fun `repositories request most-recently-pushed-first order and map updated timestamp`() = runTest {
val transport = QueueTransport(
response(
"""[{"owner":{"login":"team"},"name":"repo","default_branch":"main","private":true,"fork":false,"updated_at":"2026-08-25T10:20:30Z"}]""",
mapOf("X-HasMore" to "false"),
),
)
val page = api(transport).listRepositories(1, 5)
assertEquals("main", page.items.single().defaultBranch?.value)
assertEquals(Instant.parse("2026-08-25T10:20:30Z").toEpochMilli(), page.items.single().updatedAtEpochMillis)
val url = transport.urls.single().toHttpUrl()
assertEquals("updated", url.queryParameter("sort"))
assertEquals("desc", url.queryParameter("order"))
assertEquals("5", url.queryParameter("limit"))
}
@Test
fun `repository with no default branch maps to null instead of failing the whole page`() = runTest {
val transport = QueueTransport(
response(
"""[{"owner":{"login":"team"},"name":"empty-repo","default_branch":"","private":false,"fork":false,"updated_at":"2026-08-25T10:20:30Z"}]""",
),
)
val page = api(transport).listRepositories(1, 5)
assertNull(page.items.single().defaultBranch)
}
@Test @Test
fun `branches and tags map pagination and encode repository components once`() = runTest { fun `branches and tags map pagination and encode repository components once`() = runTest {
val transport = QueueTransport( val transport = QueueTransport(
response( response(
"""[{"name":"feature/a b","commit":{"id":"abcdef123456"},"protected":true}]""", """[{"name":"feature/a b","commit":{"id":"abcdef123456","timestamp":"2026-08-25T10:20:30Z"},"protected":true}]""",
mapOf("X-HasMore" to "true"), mapOf("X-HasMore" to "true"),
), ),
response("""[{"name":"v1.0","commit":{"sha":"fedcba987654"}}]"""), response("""[{"name":"v1.0","commit":{"sha":"fedcba987654"}}]"""),
@ -39,6 +71,7 @@ class GiteaRepositoryBrowserApiTest {
assertEquals("feature/a b", branches.items.single().name.value) assertEquals("feature/a b", branches.items.single().name.value)
assertTrue(branches.items.single().isProtected) assertTrue(branches.items.single().isProtected)
assertEquals(Instant.parse("2026-08-25T10:20:30Z").toEpochMilli(), branches.items.single().commitAtEpochMillis)
assertEquals(2, branches.nextPage) assertEquals(2, branches.nextPage)
assertEquals("v1.0", tags.items.single().name.value) assertEquals("v1.0", tags.items.single().name.value)
assertNull(tags.nextPage) assertNull(tags.nextPage)

View File

@ -33,7 +33,9 @@ class GiteaRepositoryBrowserHttpTest {
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setHeader("Content-Type", "application/json") .setHeader("Content-Type", "application/json")
.setBody("""[{"name":"main","commit":{"id":"abc1234"},"protected":false}]"""), .setBody(
"""[{"name":"main","commit":{"id":"abc1234","timestamp":"2026-08-25T10:20:30Z"},"protected":false}]""",
),
) )
val origin = server.url("/").toString().removeSuffix("/") val origin = server.url("/").toString().removeSuffix("/")
val config = OAuthClientConfig( val config = OAuthClientConfig(

View File

@ -1,99 +0,0 @@
package com.ubisam.ubgitea.data.repositories
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.HttpResponse
import com.ubisam.ubgitea.core.network.HttpTransport
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class RepositoryListApiTest {
@Test
fun `loads every page and removes duplicate repositories by id`() = runTest {
val transport = QueueTransport(
ArrayDeque(
listOf(
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"First","private":true,"default_branch":"main"}]""", "true"),
response("""[{"id":1,"owner":{"login":"team"},"name":"one","description":"Updated","private":true,"default_branch":"main"},{"id":2,"owner":{"login":"team"},"name":"two","description":"","private":false,"default_branch":"develop"}]""", "false"),
),
),
)
val repositories = RepositoryListApi(
UBGiteaOAuthDefaults.config,
transport,
pageSize = 1,
).listAll("access")
assertEquals(listOf(1L, 2L), repositories.map { it.id })
assertEquals("Updated", repositories.first().description)
assertEquals(null, repositories.last().description)
assertEquals(listOf(1, 2), transport.pages)
assertTrue(transport.tokens.all { it == "access" })
}
@Test
fun `short page ends pagination when server omits has-more header`() = runTest {
val transport = QueueTransport(ArrayDeque(listOf(response("[]", null))))
val repositories = RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
assertTrue(repositories.isEmpty())
assertEquals(listOf(1), transport.pages)
}
@Test
fun `missing owner is an invalid response`() = runTest {
val transport = QueueTransport(
ArrayDeque(listOf(response("""[{"id":1,"owner":null,"name":"repo"}]""", "false"))),
)
try {
RepositoryListApi(UBGiteaOAuthDefaults.config, transport).listAll("access")
fail("Expected InvalidResponse")
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
// Expected.
}
}
@Test
fun `pagination is bounded`() = runTest {
val transport = QueueTransport(
ArrayDeque(List(2) { response("""[{"id":${it + 1},"owner":{"login":"team"},"name":"repo$it"}]""", "true") }),
)
try {
RepositoryListApi(
UBGiteaOAuthDefaults.config,
transport,
pageSize = 1,
maxPages = 2,
).listAll("access")
fail("Expected InvalidResponse")
} catch (_: com.ubisam.ubgitea.core.network.ApiFailure.InvalidResponse) {
// Expected.
}
assertEquals(listOf(1, 2), transport.pages)
}
private fun response(body: String, hasMore: String?) = HttpResponse(
statusCode = 200,
body = body,
headers = if (hasMore == null) emptyMap() else mapOf("X-HasMore" to hasMore),
)
private class QueueTransport(private val responses: ArrayDeque<HttpResponse>) : HttpTransport {
val pages = mutableListOf<Int>()
val tokens = mutableListOf<String>()
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
override suspend fun get(url: String, bearerToken: String): HttpResponse {
pages += Regex("[?&]page=(\\d+)").find(url)!!.groupValues[1].toInt()
tokens += bearerToken
return responses.removeFirst()
}
}
}

View File

@ -1,5 +1,6 @@
package com.ubisam.ubgitea.feature.repositories package com.ubisam.ubgitea.feature.repositories
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
@ -7,8 +8,10 @@ import org.junit.Test
class RepositoryListUiStateTest { class RepositoryListUiStateTest {
@Test @Test
fun `content takes an immutable repository snapshot`() { fun `content takes an immutable repository snapshot`() {
val source = mutableListOf(RepositoryRowUiModel(1, "team", "repo", null, true, "main")) val source = mutableListOf(
val content = RepositoryListUiState.Content("account", source) RepositoryRowUiModel(RepositoryCoordinates.of("team", "repo"), null, true, "main", 1_000L),
)
val content = RepositoryListUiState.Content("account", source, page = 1, hasNextPage = false)
source.clear() source.clear()

View File

@ -7,8 +7,6 @@ import com.ubisam.ubgitea.data.auth.AuthenticatedUser
import com.ubisam.ubgitea.data.auth.AuthenticationGateway import com.ubisam.ubgitea.data.auth.AuthenticationGateway
import com.ubisam.ubgitea.data.auth.TokenSet import com.ubisam.ubgitea.data.auth.TokenSet
import com.ubisam.ubgitea.data.auth.TokenVault import com.ubisam.ubgitea.data.auth.TokenVault
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
import com.ubisam.ubgitea.domain.model.BranchSummary import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page import com.ubisam.ubgitea.domain.model.Page
@ -46,7 +44,7 @@ class RepositoryListViewModelTest {
@Test @Test
fun `restores account then loads branches tags and selects default branch`() = runTest(dispatcher.scheduler) { fun `restores account then loads branches tags and selects default branch`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser() val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
val viewModel = viewModel(browser) val viewModel = viewModel(browser)
advanceUntilIdle() advanceUntilIdle()
val repositories = viewModel.state.value as RepositoryListUiState.Content val repositories = viewModel.state.value as RepositoryListUiState.Content
@ -63,7 +61,7 @@ class RepositoryListViewModelTest {
@Test @Test
fun `selecting tag updates one selected ref and back restores repository snapshot`() = runTest(dispatcher.scheduler) { fun `selecting tag updates one selected ref and back restores repository snapshot`() = runTest(dispatcher.scheduler) {
val viewModel = viewModel(FakeBrowser()) val viewModel = viewModel(FakeBrowser(repositories = listOf(page1Of(repo("repo")))))
advanceUntilIdle() advanceUntilIdle()
val content = viewModel.state.value as RepositoryListUiState.Content val content = viewModel.state.value as RepositoryListUiState.Content
viewModel.selectRepository(content.repositories.single()) viewModel.selectRepository(content.repositories.single())
@ -75,12 +73,16 @@ class RepositoryListViewModelTest {
assertTrue(selected.tags.single().isSelected) assertTrue(selected.tags.single().isSelected)
viewModel.backToRepositories() viewModel.backToRepositories()
advanceUntilIdle()
assertTrue(viewModel.state.value is RepositoryListUiState.Content) assertTrue(viewModel.state.value is RepositoryListUiState.Content)
} }
@Test @Test
fun `branch network failure keeps repository context and retry action state`() = runTest(dispatcher.scheduler) { fun `branch network failure keeps repository context and retry action state`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(branchFailure = ApiFailure.Network(java.io.IOException())) val browser = FakeBrowser(
repositories = listOf(page1Of(repo("repo"))),
branchFailure = ApiFailure.Network(java.io.IOException()),
)
val viewModel = viewModel(browser) val viewModel = viewModel(browser)
advanceUntilIdle() advanceUntilIdle()
val content = viewModel.state.value as RepositoryListUiState.Content val content = viewModel.state.value as RepositoryListUiState.Content
@ -93,13 +95,135 @@ class RepositoryListViewModelTest {
assertEquals("repo", error.repository.name) assertEquals("repo", error.repository.name)
} }
@Test
fun `repository list requests recent-push order and pages five at a time`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(
Page.of(listOf(repo("newest"), repo("older")), nextPage = 2),
Page.of(listOf(repo("oldest")), nextPage = null),
),
)
val viewModel = viewModel(browser)
advanceUntilIdle()
val firstPage = viewModel.state.value as RepositoryListUiState.Content
assertEquals(1, firstPage.page)
assertTrue(firstPage.hasNextPage)
assertEquals(listOf("newest", "older"), firstPage.repositories.map { it.name })
assertEquals(listOf(1 to 5), browser.repositoryPageRequests)
viewModel.nextPage()
advanceUntilIdle()
val secondPage = viewModel.state.value as RepositoryListUiState.Content
assertEquals(2, secondPage.page)
assertFalse(secondPage.hasNextPage)
assertEquals(listOf("oldest"), secondPage.repositories.map { it.name })
viewModel.previousPage()
advanceUntilIdle()
assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page)
}
@Test
fun `a tie on identical commit timestamps deterministically picks one branch`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(page1Of(repo("tied"))),
branchesByRepository = mapOf(
"tied" to listOf(
BranchSummary(RepositoryRef.of("main"), "a", false, 5_000L),
BranchSummary(RepositoryRef.of("release"), "b", false, 5_000L),
),
),
)
val viewModel = viewModel(browser)
advanceUntilIdle()
val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
assertEquals(LastPushedBranchUiState.Loaded("main"), row.lastPushedBranch)
}
@Test
fun `each visible repository shows its own most recently committed branch`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(page1Of(repo("with-branches"), repo("empty-repo"))),
branchesByRepository = mapOf(
"with-branches" to listOf(
BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L),
BranchSummary(RepositoryRef.of("feature"), "b", false, 5_000L),
),
"empty-repo" to emptyList(),
),
)
val viewModel = viewModel(browser)
advanceUntilIdle()
val rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name }
assertEquals(
LastPushedBranchUiState.Loaded("feature"),
rows.getValue("with-branches").lastPushedBranch,
)
assertEquals(LastPushedBranchUiState.Empty, rows.getValue("empty-repo").lastPushedBranch)
}
@Test
fun `a failed per-row branch fetch does not affect other rows and can be retried`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(page1Of(repo("failing"), repo("ok"))),
branchFailureForRepository = "failing",
branchesByRepository = mapOf("ok" to listOf(BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L))),
)
val viewModel = viewModel(browser)
advanceUntilIdle()
var rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name }
assertEquals(LastPushedBranchUiState.Failed, rows.getValue("failing").lastPushedBranch)
assertEquals(LastPushedBranchUiState.Loaded("main"), rows.getValue("ok").lastPushedBranch)
browser.branchFailureForRepository = null
browser.branchesByRepository = mapOf("failing" to listOf(BranchSummary(RepositoryRef.of("main"), "c", false, 9_000L)))
viewModel.retryRowLastPushedBranch(RepositoryCoordinates.of("owner", "failing"))
advanceUntilIdle()
rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name }
assertEquals(LastPushedBranchUiState.Loaded("main"), rows.getValue("failing").lastPushedBranch)
}
@Test
fun `expired authentication during a row branch fetch clears the session like any other request`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(page1Of(repo("repo"))),
branchFailure = ApiFailure.AuthenticationExpired(),
)
val tokenVault = FakeTokenVault(TokenSet("access", null, null))
val viewModel = RepositoryListViewModel(
tokenVault = tokenVault,
pendingAuthStore = FakePendingStore(),
authApi = FakeAuthGateway(),
browserApi = browser,
now = { 1_000L },
)
advanceUntilIdle()
val error = viewModel.state.value as RepositoryListUiState.Error
assertEquals(RepositoryListError.AUTHENTICATION_EXPIRED, error.type)
assertEquals(null, tokenVault.tokens)
}
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
private fun repo(name: String) = RepositorySummary(
coordinates = RepositoryCoordinates.of("owner", name),
description = null,
defaultBranch = RepositoryRef.of("main"),
isPrivate = true,
isFork = false,
updatedAtEpochMillis = 1_000L,
)
private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel( private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel(
tokenVault = FakeTokenVault(TokenSet("access", null, null)), tokenVault = FakeTokenVault(TokenSet("access", null, null)),
pendingAuthStore = FakePendingStore(), pendingAuthStore = FakePendingStore(),
authApi = FakeAuthGateway(), authApi = FakeAuthGateway(),
repositoryApi = AccessibleRepositoriesGateway {
listOf(AccessibleRepository(1, "owner", "repo", null, true, "main"))
},
browserApi = browser, browserApi = browser,
now = { 1_000L }, now = { 1_000L },
) )
@ -122,19 +246,29 @@ class RepositoryListViewModelTest {
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null) override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null)
} }
private class FakeBrowser(private val branchFailure: Throwable? = null) : GiteaRepositoryBrowser { private class FakeBrowser(
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> = private val repositories: List<Page<RepositorySummary>> = listOf(Page.of(emptyList(), null)),
Page.of(emptyList(), null) private val branchFailure: Throwable? = null,
var branchFailureForRepository: String? = null,
var branchesByRepository: Map<String, List<BranchSummary>> = emptyMap(),
) : GiteaRepositoryBrowser {
val repositoryPageRequests = mutableListOf<Pair<Int, Int>>()
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
repositoryPageRequests += page to pageSize
return repositories.getOrElse(page - 1) { Page.of(emptyList(), null) }
}
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> { override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> {
branchFailure?.let { throw it } branchFailure?.let { throw it }
return Page.of( if (repository.name == branchFailureForRepository) throw ApiFailure.Network(java.io.IOException())
listOf( val defaultBranches = listOf(
BranchSummary(RepositoryRef.of("main"), "a", false), BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L),
BranchSummary(RepositoryRef.of("develop"), "b", true), BranchSummary(RepositoryRef.of("develop"), "b", true, 2_000L),
),
null,
) )
return Page.of(branchesByRepository[repository.name] ?: defaultBranches, null)
} }
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> = override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null) Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null)
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> = override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =

View File

@ -1,6 +1,7 @@
package com.ubisam.ubgitea.feature.repositories package com.ubisam.ubgitea.feature.repositories
import com.ubisam.ubgitea.domain.model.BranchSummary import com.ubisam.ubgitea.domain.model.BranchSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TagSummary
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -13,9 +14,9 @@ class RepositoryReferencePresenterTest {
fun `default branch is selected and duplicate names are removed`() { fun `default branch is selected and duplicate names are removed`() {
val branches = RepositoryReferencePresenter.branches( val branches = RepositoryReferencePresenter.branches(
listOf( listOf(
BranchSummary(RepositoryRef.of("main"), "a", true), BranchSummary(RepositoryRef.of("main"), "a", true, 1_000L),
BranchSummary(RepositoryRef.of("main"), "b", false), BranchSummary(RepositoryRef.of("main"), "b", false, 2_000L),
BranchSummary(RepositoryRef.of("develop"), "c", false), BranchSummary(RepositoryRef.of("develop"), "c", false, 3_000L),
), ),
"main", "main",
) )
@ -29,7 +30,7 @@ class RepositoryReferencePresenterTest {
@Test @Test
fun `selecting a tag clears branch selection and selects only that tag`() { fun `selecting a tag clears branch selection and selects only that tag`() {
val branches = RepositoryReferencePresenter.branches( val branches = RepositoryReferencePresenter.branches(
listOf(BranchSummary(RepositoryRef.of("main"), "a", false)), listOf(BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L)),
"main", "main",
) )
val tags = RepositoryReferencePresenter.tags( val tags = RepositoryReferencePresenter.tags(
@ -47,7 +48,7 @@ class RepositoryReferencePresenterTest {
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true)) val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true))
val state = RepositoryListUiState.ReferencesContent( val state = RepositoryListUiState.ReferencesContent(
"account", "account",
RepositoryRowUiModel(1, "owner", "repo", null, true, "main"), RepositoryRowUiModel(RepositoryCoordinates.of("owner", "repo"), null, true, "main", 1_000L),
branches, branches,
emptyList(), emptyList(),
) )

View File

@ -164,3 +164,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: No physical device was available, so device-interaction checks named in TASK-005/006 remain deferred, unchanged from before this review. - Known limitations: No physical device was available, so device-interaction checks named in TASK-005/006 remain deferred, unchanged from before this review.
- Next recommended task: TASK-007 (history and change UI), per the ordered backlog in `handoff/claude-master-handoff.md`, once TASK-009 lands and is reviewed; TASK-010 (work-item API contracts) ownership is transferred to Claude per the same handoff. - Next recommended task: TASK-007 (history and change UI), per the ordered backlog in `handoff/claude-master-handoff.md`, once TASK-009 lands and is reviewed; TASK-010 (work-item API contracts) ownership is transferred to Claude per the same handoff.
- Next owner: Claude, continuing the primary implementer role with handback to Codex available at any time by flipping a task's Owner field back and picking up from its last recorded commit. - Next owner: Claude, continuing the primary implementer role with handback to Codex available at any time by flipping a task's Owner field back and picking up from its last recorded commit.
## 2026-08-26 — TASK-017: recent-push sort, paged repository list, last-pushed branch
- Agent and role: Claude, feature implementer, per human product owner priority-1 request in conversation on 2026-08-26.
- Commit/diff: branch `claude/task-017-recent-push-favorites` from `fdda164`; new task file `tasks/017-repository-list-recent-push.md`.
- Features implemented: Repository list is now sorted most-recently-pushed-first (server-side `sort=updated&order=desc`, correct across page boundaries), paged 5 at a time with explicit previous/next navigation, and each visible repository independently shows (and can retry) its most recently committed branch.
- How it was implemented: Added `updatedAtEpochMillis` to `RepositorySummary` and `commitAtEpochMillis` to `BranchSummary`, mapped from Gitea's `updated_at` and branch `commit.timestamp`. Replaced the legacy eager-loading `RepositoryListApi`/`AccessibleRepositoriesGateway` with the existing paged `GiteaRepositoryBrowser.listRepositories`. `RepositoryListViewModel` now tracks a page number and launches one cancellable job per visible row to fetch and compare that repository's branch timestamps; jobs are cancelled on page change/refresh/navigation-away and resumed (not restarted) when returning via back-navigation.
- Files changed: `domain/model/RepositoryModels.kt`, `data/repositories/GiteaRepositoryBrowserApi.kt`, removed `data/repositories/RepositoryListApi.kt` and its test, `feature/repositories/RepositoryListViewModel.kt`, `feature/repositories/RepositoryListScreen.kt`, `MainActivity.kt`, `strings_repositories.xml`, associated tests, `tasks/017-repository-list-recent-push.md`.
- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 105 unit tests across 22 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean (CRLF notices only); sensitive-pattern scan clean.
- Review findings and resolutions: Self-review caught and fixed two issues before they shipped: (1) page-navigation/refresh calls had no `try`/`catch`, which would have crashed the app on any network failure during `nextPage()`/`previousPage()`/`refreshRepositories()` — wrapped in the same error-mapping pattern used elsewhere; (2) a repository with no default branch (a real Gitea state for empty repos) would have failed the entire page load via the existing fail-closed `.required()` mapping — `defaultBranch` is now nullable at the domain level, matching how the UI already handled it. Independent Codex review is pending.
- Known limitations: No physical device was available to verify page navigation and per-row branch loading interactively. Timestamp ties between two branches resolve deterministically but arbitrarily (first match kept).
- Next recommended task: TASK-018 (Gitea-star favorites tab), already scoped in `tasks/018-favorites-gitea-star.md`, is next; Codex review of TASK-017 can happen in parallel since it touches non-overlapping paths once merged.
- Next owner: Codex for read-only review of TASK-017; Claude continuing to TASK-018.

View File

@ -0,0 +1,87 @@
# TASK-017: Sort repositories by recent push, page the list, and show the last-pushed branch
Status: implemented; all required checks pass; pending Codex review
Owner: Claude (human product owner priority-1 request, 2026-08-26)
Reviewer: Codex
## Outcome
The repository list shows the signed-in account's repositories most-recently-pushed first, loads them a page at a time instead of eagerly loading everything, and shows each visible repository's most recently pushed branch.
## Scope
- Included:
- Add `updatedAt` (Gitea `updated_at`) to `RepositorySummary` and request server-side `sort=updated&order=desc` so ordering is correct across pages.
- Replace the legacy eager-loading `RepositoryListApi`/`AccessibleRepositoriesGateway` (which pages internally and returns one aggregated list) with the existing paged `GiteaRepositoryBrowserApi.listRepositories(page, pageSize)` from TASK-004, wired through the domain `Page<RepositorySummary>` contract.
- Reduce the repository list page size to 5 (human product owner decision, 2026-08-26) with explicit load-more/pagination UI, replacing today's single unpaginated load.
- Add `commit.timestamp` to `BranchSummary` (mapped from Gitea's branch-list `commit.timestamp` field) and, for each repository visible on the current page only, fetch its branches and surface the one with the latest `commit.timestamp` as "last pushed branch."
- Excluded:
- Any write/mutation of repository or branch state.
- Favorites/starring (TASK-018).
- Sorting or paging the branch/tag list itself (already paged; unaffected).
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt` (add fields only; no existing field removed)
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
- `app/src/test/java/com/ubisam/ubgitea/domain/model/`
- `app/src/test/java/com/ubisam/ubgitea/data/repositories/`
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
- `app/src/main/res/values/strings_repositories.xml`
- `tasks/017-repository-list-recent-push.md`
- `docs/development-log.md` (append-only)
## Acceptance criteria
- [x] Repository list requests `sort=updated&order=desc`; ordering is correct across page boundaries (verified by a fake-transport test asserting the query parameters, not by trusting client-side re-sorting).
- [x] `RepositorySummary.updatedAt` is populated and covered by DTO-mapping tests (present, absent/malformed → typed failure, consistent with existing `.required()` fail-closed pattern).
- [x] The repository list screen loads 5 repositories per page with explicit next-page loading; no behavior change for accounts with 5 or fewer repositories.
- [x] The legacy `RepositoryListApi`/`AccessibleRepositoriesGateway` code path and its now-unused tests are removed once the ViewModel is migrated to `GiteaRepositoryBrowserApi.listRepositories`; no duplicate repository-list implementation remains.
- [x] Each of the 5 visible repository rows independently fetches and displays its last-pushed branch name once available; a slow or failed per-row branch fetch does not block or fail the rest of the page, and is retried independently.
- [x] Repository row branch-timestamp fetches are cancelled when their row scrolls out of the current page/is replaced by a refresh, to avoid orphaned requests (races/cancellation, matching the existing `startOperation` pattern).
- [x] Loading, empty, offline, authentication-expired, and server-error states remain accessible and localized for both the repository page and the per-row branch fetch.
- [x] Tests cover: sort/pagination query construction, page-boundary correctness, per-row branch-timestamp selection (including a repository with zero branches, and a tie on identical timestamps), and cancellation on row replacement.
## Security and privacy
No additional concerns — read-only, no new OAuth scope, no credential or token exposure.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
Implemented on branch `claude/task-017-recent-push-favorites` from commit `fdda164` (TASK-004/005/006 reviewed-and-frozen base).
- Domain: `RepositorySummary` gained `updatedAtEpochMillis: Long`; `defaultBranch` changed from non-null `RepositoryRef` to nullable `RepositoryRef?` (an empty/newly-created Gitea repository can have no default branch — previously this threw `ApiFailure.InvalidResponse()` for the *entire* page instead of marking just that one repository, a latent bug surfaced by routing the main list through the already-existing TASK-004 paged endpoint). `BranchSummary` gained `commitAtEpochMillis: Long`.
- Data: `GiteaRepositoryBrowserApi.listRepositories` now requests `sort=updated&order=desc` and maps `updated_at`; `listBranches` maps `commit.timestamp`. A shared `String.toEpochMillisOrInvalid()` helper replaced three near-duplicate `Instant.parse`/catch blocks (also used by `mapCommit`, no behavior change there).
- Removed `RepositoryListApi.kt`/`AccessibleRepositoriesGateway`/`AccessibleRepository` and their test file — this was a second, independent repository-list implementation with its own internal eager-all-pages loop; the ViewModel now uses only `GiteaRepositoryBrowser.listRepositories`.
- `RepositoryListViewModel`: repository loading is now page-based (`REPOSITORY_PAGE_SIZE = 5`) with `nextPage()`/`previousPage()`/`refreshRepositories()`. Each visible row launches its own cancellable branch-fetch job (tracked in `rowBranchJobs`, keyed by `RepositoryCoordinates`) that pages through that repository's branches and picks the maximum `commitAtEpochMillis`; `Content` rows carry a `LastPushedBranchUiState` (`Loading`/`Loaded`/`Empty`/`Failed`). Row jobs are cancelled and relaunched on every page load/refresh (`cancelRowBranchJobs()` at the top of `loadRepositoryPage`) and cancelled on navigating away to references or logging out; `backToRepositories()` resumes only rows still `Loading` rather than restarting completed ones. A row's `AuthenticationExpired` failure clears the session the same way every other authenticated call does, instead of being swallowed as a per-row `Failed` state. `retryRowLastPushedBranch` retries a single row without touching the rest of the page.
- UI: `RepositoryListScreen`'s repository list gained a refresh button, a per-row last-pushed-branch line (with localized loading/empty/failed+retry states), and a previous/page-number/next footer row, all localized (`strings_repositories.xml`).
- `MainActivity` passes `repository.coordinates` directly instead of re-validating owner/name through `RepositoryCoordinates.of` a second time.
Checks (JDK 21 via `D:\AndroidStudio\jbr`, Android SDK at `D:\Android\WhatToEat\sdk`, `local.properties` created locally, not committed):
```text
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
- 105 unit tests across 22 suites, 0 failures/errors
- 0 lint errors
git diff --check → only CRLF-normalization notices, no real whitespace errors
sensitive-pattern scan of changed files → no matches
```
Known limitations:
- No physical device was available; page navigation, per-row branch loading, and the removed legacy path have not been manually verified on-device.
- Tie-breaking when two branches share the exact same commit timestamp is deterministic (`maxByOrNull` keeps the first match) but arbitrary from a product standpoint; acceptable since this is a rare edge case and not user-configurable.
No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review.
## Review findings
None yet.