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:
parent
fdda16442a
commit
93b8d587ee
@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
|
||||
@ -74,7 +73,7 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.selectReference(name, kind)
|
||||
codeBrowserViewModel.open(
|
||||
CodeBrowserTarget(
|
||||
repository = RepositoryCoordinates.of(repository.owner, repository.name),
|
||||
repository = repository.coordinates,
|
||||
ref = RepositoryRef.of(name),
|
||||
kind = if (kind == ReferenceKind.BRANCH) {
|
||||
CodeReferenceKind.BRANCH
|
||||
@ -87,6 +86,10 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onReferencesRefresh = viewModel::refreshReferences,
|
||||
onBackToRepositories = viewModel::backToRepositories,
|
||||
onNextPage = viewModel::nextPage,
|
||||
onPreviousPage = viewModel::previousPage,
|
||||
onRefreshRepositories = viewModel::refreshRepositories,
|
||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) {
|
||||
|
||||
@ -42,18 +42,22 @@ class GiteaRepositoryBrowserApi(
|
||||
) : GiteaRepositoryBrowser {
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
validatePage(page, pageSize)
|
||||
val response = get(
|
||||
apiUrl("api", "v1", "user", "repos").withPage(page, pageSize),
|
||||
)
|
||||
val url = 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 owner = dto.owner?.login.required()
|
||||
val name = dto.name.required()
|
||||
RepositorySummary(
|
||||
coordinates = RepositoryCoordinates.of(owner, name),
|
||||
description = dto.description?.takeIf(String::isNotBlank),
|
||||
defaultBranch = RepositoryRef.of(dto.defaultBranch.required()),
|
||||
defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of),
|
||||
isPrivate = dto.isPrivate,
|
||||
isFork = dto.isFork,
|
||||
updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(),
|
||||
)
|
||||
}
|
||||
return response.toPage(items, page, pageSize)
|
||||
@ -71,6 +75,7 @@ class GiteaRepositoryBrowserApi(
|
||||
name = RepositoryRef.of(dto.name.required()),
|
||||
commitId = dto.commit?.id.required(),
|
||||
isProtected = dto.isProtected,
|
||||
commitAtEpochMillis = dto.commit?.timestamp.required().toEpochMillisOrInvalid(),
|
||||
)
|
||||
}
|
||||
return response.toPage(items, page, pageSize)
|
||||
@ -192,20 +197,21 @@ class GiteaRepositoryBrowserApi(
|
||||
private fun mapCommit(dto: CommitDto): CommitSummary {
|
||||
val id = dto.sha.required()
|
||||
val author = dto.commit?.author ?: throw ApiFailure.InvalidResponse()
|
||||
val timestamp = try {
|
||||
Instant.parse(author.date.required()).toEpochMilli()
|
||||
} catch (_: DateTimeParseException) {
|
||||
throw ApiFailure.InvalidResponse()
|
||||
}
|
||||
return CommitSummary(
|
||||
id = id,
|
||||
shortId = id.take(7),
|
||||
message = dto.commit.message.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 {
|
||||
if (!JsonParser.parseString(body).isJsonArray) throw ApiFailure.InvalidResponse()
|
||||
parseList(body)
|
||||
@ -268,13 +274,14 @@ class GiteaRepositoryBrowserApi(
|
||||
@SerializedName("default_branch") val defaultBranch: String?,
|
||||
@SerializedName("private") val isPrivate: Boolean,
|
||||
@SerializedName("fork") val isFork: Boolean,
|
||||
@SerializedName("updated_at") val updatedAt: String?,
|
||||
)
|
||||
private data class BranchDto(
|
||||
val name: String?,
|
||||
val commit: BranchCommitDto?,
|
||||
@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 TagCommitDto(val sha: String?)
|
||||
private data class ContentsDto(
|
||||
|
||||
@ -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?)
|
||||
}
|
||||
@ -5,15 +5,17 @@ import java.util.Collections
|
||||
data class RepositorySummary(
|
||||
val coordinates: RepositoryCoordinates,
|
||||
val description: String?,
|
||||
val defaultBranch: RepositoryRef,
|
||||
val defaultBranch: RepositoryRef?,
|
||||
val isPrivate: Boolean,
|
||||
val isFork: Boolean,
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
data class BranchSummary(
|
||||
val name: RepositoryRef,
|
||||
val commitId: String,
|
||||
val isProtected: Boolean,
|
||||
val commitAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
data class TagSummary(
|
||||
|
||||
@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.activity.compose.BackHandler
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
|
||||
@Composable
|
||||
fun RepositoryListScreen(
|
||||
@ -38,6 +39,10 @@ fun RepositoryListScreen(
|
||||
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
||||
onReferencesRefresh: () -> Unit,
|
||||
onBackToRepositories: () -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefreshRepositories: () -> Unit,
|
||||
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
@ -82,6 +87,10 @@ fun RepositoryListScreen(
|
||||
state,
|
||||
onLogout,
|
||||
onRepositorySelected,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
onRefreshRepositories,
|
||||
onRetryLastPushedBranch,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
is RepositoryListUiState.ReferencesLoading -> {
|
||||
@ -156,6 +165,10 @@ private fun RepositoryContent(
|
||||
state: RepositoryListUiState.Content,
|
||||
onLogout: () -> Unit,
|
||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefreshRepositories: () -> Unit,
|
||||
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
@ -169,10 +182,13 @@ private fun RepositoryContent(
|
||||
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||
}
|
||||
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(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@ -192,9 +208,56 @@ private fun RepositoryContent(
|
||||
} ?: stringResource(R.string.repositories_no_default_branch),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LastPushedBranchRow(repository, onRetryLastPushedBranch)
|
||||
}
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,12 +16,10 @@ import com.ubisam.ubgitea.data.auth.AuthApi
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
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.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
@ -39,7 +37,12 @@ sealed interface RepositoryListUiState {
|
||||
data object SignedOut : RepositoryListUiState
|
||||
data object WaitingForBrowser : 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 repositories: List<RepositoryRowUiModel> = Collections.unmodifiableList(repositories.toList())
|
||||
}
|
||||
@ -69,14 +72,24 @@ sealed interface 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(
|
||||
val id: Long,
|
||||
val owner: String,
|
||||
val name: String,
|
||||
val coordinates: RepositoryCoordinates,
|
||||
val description: String?,
|
||||
val isPrivate: Boolean,
|
||||
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 }
|
||||
|
||||
@ -100,7 +113,6 @@ class RepositoryListViewModel(
|
||||
private val tokenVault: TokenVault,
|
||||
private val pendingAuthStore: PendingAuthTransactionStore,
|
||||
private val authApi: AuthenticationGateway,
|
||||
private val repositoryApi: AccessibleRepositoriesGateway,
|
||||
private val browserApi: GiteaRepositoryBrowser,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ViewModel() {
|
||||
@ -112,6 +124,7 @@ class RepositoryListViewModel(
|
||||
private val mutableBrowserRequests = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
|
||||
private var operation: Job? = null
|
||||
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
|
||||
private var activeAccountName: String? = null
|
||||
private var hasAuthenticatedSession = false
|
||||
private var repositoryContent: RepositoryListUiState.Content? = null
|
||||
@ -153,6 +166,7 @@ class RepositoryListViewModel(
|
||||
|
||||
fun selectRepository(repository: RepositoryRowUiModel) {
|
||||
val account = activeAccountName ?: return
|
||||
cancelRowBranchJobs()
|
||||
startOperation { loadReferences(account, repository) }
|
||||
}
|
||||
|
||||
@ -177,10 +191,39 @@ class RepositoryListViewModel(
|
||||
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() {
|
||||
operation?.cancel()
|
||||
operation = null
|
||||
repositoryContent?.let { mutableState.value = it }
|
||||
val restored = repositoryContent ?: return
|
||||
mutableState.value = restored
|
||||
loadRowBranches(restored)
|
||||
}
|
||||
|
||||
fun cancelSignIn() {
|
||||
@ -202,6 +245,7 @@ class RepositoryListViewModel(
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
cancelRowBranchJobs()
|
||||
startOperation {
|
||||
pendingAuthStore.clear()
|
||||
tokenVault.clear()
|
||||
@ -271,22 +315,98 @@ class RepositoryListViewModel(
|
||||
val user = authApi.getAuthenticatedUser(tokens.accessToken)
|
||||
val accountName = user.displayName ?: user.login
|
||||
activeAccountName = accountName
|
||||
val repositories = repositoryApi.listAll(tokens.accessToken)
|
||||
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
|
||||
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
|
||||
else RepositoryListUiState.Content(accountName, rows).also { repositoryContent = it }
|
||||
loadRepositoryPage(accountName, page = 1)
|
||||
}
|
||||
|
||||
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) {
|
||||
mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository)
|
||||
val coordinates = RepositoryCoordinates.of(repository.owner, repository.name)
|
||||
try {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) },
|
||||
loadAllPages { page -> browserApi.listBranches(repository.coordinates, page, PAGE_SIZE) },
|
||||
repository.defaultBranch,
|
||||
)
|
||||
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(
|
||||
accountName,
|
||||
@ -328,8 +448,12 @@ class RepositoryListViewModel(
|
||||
operation = viewModelScope.launch { block() }
|
||||
}
|
||||
|
||||
private fun AccessibleRepository.toUiModel() = RepositoryRowUiModel(
|
||||
id, owner, name, description, isPrivate, defaultBranch,
|
||||
private fun RepositorySummary.toUiModel() = RepositoryRowUiModel(
|
||||
coordinates = coordinates,
|
||||
description = description,
|
||||
isPrivate = isPrivate,
|
||||
defaultBranch = defaultBranch?.value,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@ -338,12 +462,10 @@ class RepositoryListViewModel(
|
||||
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
||||
val tokenVault = AndroidKeystoreTokenVault(application)
|
||||
val authTransport = OkHttpTransport()
|
||||
val repositoryTransport = OkHttpTransport()
|
||||
return RepositoryListViewModel(
|
||||
tokenVault = tokenVault,
|
||||
pendingAuthStore = PendingAuthStore(application),
|
||||
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
|
||||
repositoryApi = RepositoryListApi(UBGiteaOAuthDefaults.config, repositoryTransport),
|
||||
browserApi = GiteaRepositoryBrowserApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
@ -355,6 +477,7 @@ class RepositoryListViewModel(
|
||||
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 50
|
||||
const val REPOSITORY_PAGE_SIZE = 5
|
||||
const val MAX_REFERENCE_PAGES = 100
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,4 +29,11 @@
|
||||
<string name="repositories_protected_branch">보호된 브랜치</string>
|
||||
<string name="repositories_back">저장소 목록</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>
|
||||
|
||||
@ -23,11 +23,43 @@ import org.junit.Test
|
||||
class GiteaRepositoryBrowserApiTest {
|
||||
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
|
||||
fun `branches and tags map pagination and encode repository components once`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
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"),
|
||||
),
|
||||
response("""[{"name":"v1.0","commit":{"sha":"fedcba987654"}}]"""),
|
||||
@ -39,6 +71,7 @@ class GiteaRepositoryBrowserApiTest {
|
||||
|
||||
assertEquals("feature/a b", branches.items.single().name.value)
|
||||
assertTrue(branches.items.single().isProtected)
|
||||
assertEquals(Instant.parse("2026-08-25T10:20:30Z").toEpochMilli(), branches.items.single().commitAtEpochMillis)
|
||||
assertEquals(2, branches.nextPage)
|
||||
assertEquals("v1.0", tags.items.single().name.value)
|
||||
assertNull(tags.nextPage)
|
||||
|
||||
@ -33,7 +33,9 @@ class GiteaRepositoryBrowserHttpTest {
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.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 config = OAuthClientConfig(
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
@ -7,8 +8,10 @@ import org.junit.Test
|
||||
class RepositoryListUiStateTest {
|
||||
@Test
|
||||
fun `content takes an immutable repository snapshot`() {
|
||||
val source = mutableListOf(RepositoryRowUiModel(1, "team", "repo", null, true, "main"))
|
||||
val content = RepositoryListUiState.Content("account", source)
|
||||
val source = mutableListOf(
|
||||
RepositoryRowUiModel(RepositoryCoordinates.of("team", "repo"), null, true, "main", 1_000L),
|
||||
)
|
||||
val content = RepositoryListUiState.Content("account", source, page = 1, hasNextPage = false)
|
||||
|
||||
source.clear()
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@ import com.ubisam.ubgitea.data.auth.AuthenticatedUser
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
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.CommitSummary
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
@ -46,7 +44,7 @@ class RepositoryListViewModelTest {
|
||||
|
||||
@Test
|
||||
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)
|
||||
advanceUntilIdle()
|
||||
val repositories = viewModel.state.value as RepositoryListUiState.Content
|
||||
@ -63,7 +61,7 @@ class RepositoryListViewModelTest {
|
||||
|
||||
@Test
|
||||
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()
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
viewModel.selectRepository(content.repositories.single())
|
||||
@ -75,12 +73,16 @@ class RepositoryListViewModelTest {
|
||||
assertTrue(selected.tags.single().isSelected)
|
||||
|
||||
viewModel.backToRepositories()
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.state.value is RepositoryListUiState.Content)
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
advanceUntilIdle()
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
@ -93,13 +95,135 @@ class RepositoryListViewModelTest {
|
||||
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(
|
||||
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = FakeAuthGateway(),
|
||||
repositoryApi = AccessibleRepositoriesGateway {
|
||||
listOf(AccessibleRepository(1, "owner", "repo", null, true, "main"))
|
||||
},
|
||||
browserApi = browser,
|
||||
now = { 1_000L },
|
||||
)
|
||||
@ -122,19 +246,29 @@ class RepositoryListViewModelTest {
|
||||
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null)
|
||||
}
|
||||
|
||||
private class FakeBrowser(private val branchFailure: Throwable? = null) : GiteaRepositoryBrowser {
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> =
|
||||
Page.of(emptyList(), null)
|
||||
private class FakeBrowser(
|
||||
private val repositories: List<Page<RepositorySummary>> = listOf(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> {
|
||||
branchFailure?.let { throw it }
|
||||
return Page.of(
|
||||
listOf(
|
||||
BranchSummary(RepositoryRef.of("main"), "a", false),
|
||||
BranchSummary(RepositoryRef.of("develop"), "b", true),
|
||||
),
|
||||
null,
|
||||
if (repository.name == branchFailureForRepository) throw ApiFailure.Network(java.io.IOException())
|
||||
val defaultBranches = listOf(
|
||||
BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L),
|
||||
BranchSummary(RepositoryRef.of("develop"), "b", true, 2_000L),
|
||||
)
|
||||
return Page.of(branchesByRepository[repository.name] ?: defaultBranches, null)
|
||||
}
|
||||
|
||||
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
|
||||
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> =
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
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.TagSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
@ -13,9 +14,9 @@ class RepositoryReferencePresenterTest {
|
||||
fun `default branch is selected and duplicate names are removed`() {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
listOf(
|
||||
BranchSummary(RepositoryRef.of("main"), "a", true),
|
||||
BranchSummary(RepositoryRef.of("main"), "b", false),
|
||||
BranchSummary(RepositoryRef.of("develop"), "c", false),
|
||||
BranchSummary(RepositoryRef.of("main"), "a", true, 1_000L),
|
||||
BranchSummary(RepositoryRef.of("main"), "b", false, 2_000L),
|
||||
BranchSummary(RepositoryRef.of("develop"), "c", false, 3_000L),
|
||||
),
|
||||
"main",
|
||||
)
|
||||
@ -29,7 +30,7 @@ class RepositoryReferencePresenterTest {
|
||||
@Test
|
||||
fun `selecting a tag clears branch selection and selects only that tag`() {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
listOf(BranchSummary(RepositoryRef.of("main"), "a", false)),
|
||||
listOf(BranchSummary(RepositoryRef.of("main"), "a", false, 1_000L)),
|
||||
"main",
|
||||
)
|
||||
val tags = RepositoryReferencePresenter.tags(
|
||||
@ -47,7 +48,7 @@ class RepositoryReferencePresenterTest {
|
||||
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true))
|
||||
val state = RepositoryListUiState.ReferencesContent(
|
||||
"account",
|
||||
RepositoryRowUiModel(1, "owner", "repo", null, true, "main"),
|
||||
RepositoryRowUiModel(RepositoryCoordinates.of("owner", "repo"), null, true, "main", 1_000L),
|
||||
branches,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
87
tasks/017-repository-list-recent-push.md
Normal file
87
tasks/017-repository-list-recent-push.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user