From 525a0aa2ff15e4ddb7acad31047ca38c85f5fdf5 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Wed, 26 Aug 2026 13:51:04 +0900 Subject: [PATCH] Add Favorites tab backed by Gitea star (first write endpoint) TASK-018, human product owner priority-1 request. Adds write:user OAuth scope (ADR-004) and relaxes OAuthClientConfig's read-only guard to an explicit, hard-coded write-scope allow-list rather than a general write-scope allowance. GiteaRepositoryBrowserApi gains star/ unstar/listStarred via a new RepositoryFavorites interface, reusing its existing repository mapping. RepositoryListViewModel tracks an ALL/FAVORITES mode; toggling favorite confirms against the server before updating UI state rather than acting optimistically. No create/edit/merge/close/comment endpoint is introduced. Favorite state is never cached locally. Co-Authored-By: Claude Sonnet 5 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 3 + .../core/auth/protocol/OAuthClientConfig.kt | 12 +- .../ubgitea/core/network/HttpTransport.kt | 21 +++ .../repositories/GiteaRepositoryBrowserApi.kt | 63 ++++++--- .../domain/repository/RepositoryFavorites.kt | 18 +++ .../repositories/RepositoryListScreen.kt | 122 +++++++++++++---- .../repositories/RepositoryListViewModel.kt | 125 +++++++++++++---- .../main/res/values/strings_repositories.xml | 6 + .../auth/protocol/OAuthClientConfigTest.kt | 38 ++++++ .../ubisam/ubgitea/data/auth/AuthApiTest.kt | 3 + .../GiteaRepositoryBrowserApiTest.kt | 55 ++++++++ .../GiteaRepositoryBrowserHttpTest.kt | 4 + .../repositories/RepositoryListUiStateTest.kt | 8 +- .../RepositoryListViewModelTest.kt | 126 +++++++++++++++++- .../004-favorites-write-user-scope.md | 34 +++++ docs/development-log.md | 13 ++ tasks/018-favorites-gitea-star.md | 102 ++++++++++++++ 17 files changed, 682 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/repository/RepositoryFavorites.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfigTest.kt create mode 100644 docs/decisions/004-favorites-write-user-scope.md create mode 100644 tasks/018-favorites-gitea-star.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 94ae09b..e3239a2 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -90,6 +90,9 @@ class MainActivity : ComponentActivity() { onPreviousPage = viewModel::previousPage, onRefreshRepositories = viewModel::refreshRepositories, onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, + onShowAllRepositories = viewModel::showAllRepositories, + onShowFavorites = viewModel::showFavorites, + onToggleFavorite = viewModel::toggleFavorite, ) } LaunchedEffect(viewModel) { diff --git a/app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt b/app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt index a290af2..c319297 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt @@ -13,7 +13,9 @@ data class OAuthClientConfig( require(clientId.isNotBlank()) { "OAuth client ID cannot be blank" } require(redirectUri.isSecureRedirect()) { "OAuth redirect URI must be HTTPS without query or fragment" } require(scopes.isNotEmpty()) { "At least one OAuth scope is required" } - require(scopes.all { it.isReadOnly }) { "Only read-only OAuth scopes are allowed" } + require(scopes.all { it.isReadOnly || it in EXPLICITLY_APPROVED_WRITE_SCOPES }) { + "OAuth scope requires explicit human approval and an entry in EXPLICITLY_APPROVED_WRITE_SCOPES" + } } val authorizationEndpoint: URI @@ -33,6 +35,12 @@ data class OAuthClientConfig( userInfo == null && query == null && fragment == null + + companion object { + // Every entry here is a deliberate, one-at-a-time human product-owner decision + // (see docs/decisions and TASK-018), not a general write-scope allowance. + val EXPLICITLY_APPROVED_WRITE_SCOPES: Set = setOf(OAuthScope.WRITE_USER) + } } enum class OAuthScope(val wireValue: String, val isReadOnly: Boolean) { @@ -41,6 +49,7 @@ enum class OAuthScope(val wireValue: String, val isReadOnly: Boolean) { READ_ORGANIZATION("read:organization", true), READ_ISSUE("read:issue", true), READ_NOTIFICATION("read:notification", true), + WRITE_USER("write:user", false), } object UBGiteaOAuthDefaults { @@ -54,6 +63,7 @@ object UBGiteaOAuthDefaults { OAuthScope.READ_ORGANIZATION, OAuthScope.READ_ISSUE, OAuthScope.READ_NOTIFICATION, + OAuthScope.WRITE_USER, ), ) } diff --git a/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt index 7476969..6eaa7ab 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/network/HttpTransport.kt @@ -6,6 +6,7 @@ import okhttp3.Callback import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.io.IOException import kotlin.coroutines.resume @@ -20,6 +21,8 @@ data class HttpResponse( interface HttpTransport { suspend fun postForm(url: String, fields: Map): HttpResponse suspend fun get(url: String, bearerToken: String): HttpResponse + suspend fun put(url: String, bearerToken: String): HttpResponse + suspend fun delete(url: String, bearerToken: String): HttpResponse } class OkHttpTransport( @@ -42,6 +45,24 @@ class OkHttpTransport( return execute(request) } + override suspend fun put(url: String, bearerToken: String): HttpResponse { + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $bearerToken") + .put(ByteArray(0).toRequestBody(null)) + .build() + return execute(request) + } + + override suspend fun delete(url: String, bearerToken: String): HttpResponse { + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $bearerToken") + .delete() + .build() + return execute(request) + } + private suspend fun execute(request: Request): HttpResponse = suspendCancellableCoroutine { continuation -> val call = client.newCall(request) diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt index eb61d62..9d46c55 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt @@ -23,6 +23,7 @@ import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntryType import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import java.time.Instant import java.time.format.DateTimeParseException import java.io.IOException @@ -39,7 +40,7 @@ class GiteaRepositoryBrowserApi( private val transport: HttpTransport, private val tokenProvider: BearerTokenProvider, private val gson: Gson = Gson(), -) : GiteaRepositoryBrowser { +) : GiteaRepositoryBrowser, RepositoryFavorites { override suspend fun listRepositories(page: Int, pageSize: Int): Page { validatePage(page, pageSize) val url = apiUrl("api", "v1", "user", "repos").withPage(page, pageSize) @@ -48,21 +49,25 @@ class GiteaRepositoryBrowserApi( .addQueryParameter("order", "desc") .build() val response = get(url) - val items = parseList(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 = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of), - isPrivate = dto.isPrivate, - isFork = dto.isFork, - updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(), - ) - } + val items = parseList(response.body).map(::mapRepository) return response.toPage(items, page, pageSize) } + override suspend fun listStarred(page: Int, pageSize: Int): Page { + validatePage(page, pageSize) + val response = get(apiUrl("api", "v1", "user", "starred").withPage(page, pageSize)) + val items = parseList(response.body).map(::mapRepository) + return response.toPage(items, page, pageSize) + } + + override suspend fun star(repository: RepositoryCoordinates) { + put(starUrl(repository)) + } + + override suspend fun unstar(repository: RepositoryCoordinates) { + delete(starUrl(repository)) + } + override suspend fun listBranches( repository: RepositoryCoordinates, page: Int, @@ -166,16 +171,44 @@ class GiteaRepositoryBrowserApi( return response.toPage(items, page, pageSize) } - private suspend fun get(url: HttpUrl): HttpResponse { + private suspend fun get(url: HttpUrl): HttpResponse = authenticated { token -> + transport.get(url.toString(), token) + } + + private suspend fun put(url: HttpUrl): HttpResponse = authenticated { token -> + transport.put(url.toString(), token) + } + + private suspend fun delete(url: HttpUrl): HttpResponse = authenticated { token -> + transport.delete(url.toString(), token) + } + + private suspend fun authenticated(call: suspend (String) -> HttpResponse): HttpResponse { val token = tokenProvider.accessToken() if (token.isBlank()) throw ApiFailure.AuthenticationExpired() return try { - transport.get(url.toString(), token).requireSuccess() + call(token).requireSuccess() } catch (failure: IOException) { throw ApiFailure.Network(failure) } } + private fun mapRepository(dto: RepositoryDto): RepositorySummary { + val owner = dto.owner?.login.required() + val name = dto.name.required() + return RepositorySummary( + coordinates = RepositoryCoordinates.of(owner, name), + description = dto.description?.takeIf(String::isNotBlank), + defaultBranch = dto.defaultBranch?.takeIf(String::isNotBlank)?.let(RepositoryRef::of), + isPrivate = dto.isPrivate, + isFork = dto.isFork, + updatedAtEpochMillis = dto.updatedAt.required().toEpochMillisOrInvalid(), + ) + } + + private fun starUrl(repository: RepositoryCoordinates): HttpUrl = + apiUrl("api", "v1", "user", "starred", repository.owner, repository.name) + private fun mapTreeEntry(dto: ContentsDto): TreeEntry { val type = when (dto.type) { "dir" -> TreeEntryType.DIRECTORY diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/repository/RepositoryFavorites.kt b/app/src/main/java/com/ubisam/ubgitea/domain/repository/RepositoryFavorites.kt new file mode 100644 index 0000000..bbf21d0 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/repository/RepositoryFavorites.kt @@ -0,0 +1,18 @@ +package com.ubisam.ubgitea.domain.repository + +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositorySummary + +/** + * The app's only write capability: Gitea's native star, which is what "favorite" means in + * this product (see ADR-004). No other create/edit/merge/delete/admin operation is exposed + * anywhere in the app. + */ +interface RepositoryFavorites { + suspend fun listStarred(page: Int, pageSize: Int): Page + + suspend fun star(repository: RepositoryCoordinates) + + suspend fun unstar(repository: RepositoryCoordinates) +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 6905a90..d0dcbf3 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.clickable import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.lazy.LazyColumn @@ -17,6 +18,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -43,6 +46,9 @@ fun RepositoryListScreen( onPreviousPage: () -> Unit, onRefreshRepositories: () -> Unit, onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit, + onShowAllRepositories: () -> Unit, + onShowFavorites: () -> Unit, + onToggleFavorite: (RepositoryRowUiModel) -> Unit, modifier: Modifier = Modifier, ) { Scaffold(modifier = modifier.fillMaxSize()) { padding -> @@ -63,12 +69,19 @@ fun RepositoryListScreen( stringResource(R.string.repositories_loading), Modifier.padding(padding), ) - is RepositoryListUiState.Empty -> MessageState( - stringResource(R.string.repositories_empty_account, state.accountName), - stringResource(R.string.repositories_logout), - onLogout, - Modifier.padding(padding), - ) + is RepositoryListUiState.Empty -> Column(Modifier.padding(padding).fillMaxSize()) { + ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) + MessageState( + if (state.mode == RepositoryListMode.FAVORITES) { + stringResource(R.string.repositories_favorites_empty) + } else { + stringResource(R.string.repositories_empty_account, state.accountName) + }, + stringResource(R.string.repositories_logout), + onLogout, + Modifier.weight(1f), + ) + } is RepositoryListUiState.Error -> { val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED || state.type == RepositoryListError.INVALID_CALLBACK @@ -91,6 +104,9 @@ fun RepositoryListScreen( onPreviousPage, onRefreshRepositories, onRetryLastPushedBranch, + onShowAllRepositories, + onShowFavorites, + onToggleFavorite, Modifier.padding(padding), ) is RepositoryListUiState.ReferencesLoading -> { @@ -169,6 +185,9 @@ private fun RepositoryContent( onPreviousPage: () -> Unit, onRefreshRepositories: () -> Unit, onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit, + onShowAllRepositories: () -> Unit, + onShowFavorites: () -> Unit, + onToggleFavorite: (RepositoryRowUiModel) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier = modifier.fillMaxSize()) { @@ -188,27 +207,31 @@ private fun RepositoryContent( } } } + item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) } items(state.repositories, key = { it.coordinates }) { repository -> - Column( - Modifier - .fillMaxWidth() - .clickable(role = Role.Button) { onRepositorySelected(repository) } - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium) - Text( - stringResource( - if (repository.isPrivate) R.string.repositories_private else R.string.repositories_public, - ), - ) - repository.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } - Text( - repository.defaultBranch?.let { - stringResource(R.string.repositories_default_branch, it) - } ?: stringResource(R.string.repositories_no_default_branch), - style = MaterialTheme.typography.bodySmall, - ) - LastPushedBranchRow(repository, onRetryLastPushedBranch) + Row(Modifier.fillMaxWidth()) { + Column( + Modifier + .weight(1f) + .clickable(role = Role.Button) { onRepositorySelected(repository) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium) + Text( + stringResource( + if (repository.isPrivate) R.string.repositories_private else R.string.repositories_public, + ), + ) + repository.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + Text( + repository.defaultBranch?.let { + stringResource(R.string.repositories_default_branch, it) + } ?: stringResource(R.string.repositories_no_default_branch), + style = MaterialTheme.typography.bodySmall, + ) + LastPushedBranchRow(repository, onRetryLastPushedBranch) + } + FavoriteButton(state.mode, repository, onToggleFavorite) } HorizontalDivider() } @@ -230,6 +253,53 @@ private fun RepositoryContent( } } +@Composable +private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) { + TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) { + Tab( + selected = mode == RepositoryListMode.ALL, + onClick = onShowAllRepositories, + text = { Text(stringResource(R.string.repositories_tab_all)) }, + ) + Tab( + selected = mode == RepositoryListMode.FAVORITES, + onClick = onShowFavorites, + text = { Text(stringResource(R.string.repositories_tab_favorites)) }, + ) + } +} + +@Composable +private fun FavoriteButton( + mode: RepositoryListMode, + repository: RepositoryRowUiModel, + onToggle: (RepositoryRowUiModel) -> Unit, +) { + Column(Modifier.padding(end = 16.dp, top = 12.dp)) { + when (repository.favoriteAction) { + FavoriteActionUiState.InProgress -> CircularProgressIndicator(Modifier.size(20.dp)) + FavoriteActionUiState.Failed -> OutlinedButton(onClick = { onToggle(repository) }) { + Text(stringResource(R.string.repositories_retry)) + } + FavoriteActionUiState.Starred -> Text( + stringResource(R.string.repositories_favorited), + style = MaterialTheme.typography.bodySmall, + ) + FavoriteActionUiState.Idle -> OutlinedButton(onClick = { onToggle(repository) }) { + Text( + stringResource( + if (mode == RepositoryListMode.FAVORITES) { + R.string.repositories_unfavorite + } else { + R.string.repositories_favorite + }, + ), + ) + } + } + } +} + @Composable private fun LastPushedBranchRow( repository: RepositoryRowUiModel, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index 8188f21..3fa128a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -21,6 +21,7 @@ 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 com.ubisam.ubgitea.domain.repository.RepositoryFavorites import java.io.IOException import java.util.Collections import kotlinx.coroutines.Job @@ -33,6 +34,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +enum class RepositoryListMode { ALL, FAVORITES } + sealed interface RepositoryListUiState { data object SignedOut : RepositoryListUiState data object WaitingForBrowser : RepositoryListUiState @@ -42,11 +45,12 @@ sealed interface RepositoryListUiState { repositories: List, val page: Int, val hasNextPage: Boolean, + val mode: RepositoryListMode, ) : RepositoryListUiState { val accountName: String = accountName val repositories: List = Collections.unmodifiableList(repositories.toList()) } - data class Empty(val accountName: String) : RepositoryListUiState + data class Empty(val accountName: String, val mode: RepositoryListMode) : RepositoryListUiState data class Error( val type: RepositoryListError, val accountName: String? = null, @@ -79,6 +83,13 @@ sealed interface LastPushedBranchUiState { data object Failed : LastPushedBranchUiState } +sealed interface FavoriteActionUiState { + data object Idle : FavoriteActionUiState + data object InProgress : FavoriteActionUiState + data object Starred : FavoriteActionUiState + data object Failed : FavoriteActionUiState +} + data class RepositoryRowUiModel( val coordinates: RepositoryCoordinates, val description: String?, @@ -86,6 +97,7 @@ data class RepositoryRowUiModel( val defaultBranch: String?, val updatedAtEpochMillis: Long, val lastPushedBranch: LastPushedBranchUiState = LastPushedBranchUiState.Loading, + val favoriteAction: FavoriteActionUiState = FavoriteActionUiState.Idle, ) { val owner: String get() = coordinates.owner val name: String get() = coordinates.name @@ -114,6 +126,7 @@ class RepositoryListViewModel( private val pendingAuthStore: PendingAuthTransactionStore, private val authApi: AuthenticationGateway, private val browserApi: GiteaRepositoryBrowser, + private val favoritesApi: RepositoryFavorites, private val now: () -> Long = System::currentTimeMillis, ) : ViewModel() { private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config) @@ -192,23 +205,64 @@ class RepositoryListViewModel( } fun nextPage() { - val current = mutableState.value as? RepositoryListUiState.Content ?: return - if (!current.hasNextPage) return + val content = mutableState.value as? RepositoryListUiState.Content ?: return + if (!content.hasNextPage) return val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, current.page + 1) } + startOperation { loadRepositoryPage(account, content.page + 1, content.mode) } } fun previousPage() { - val current = mutableState.value as? RepositoryListUiState.Content ?: return - if (current.page <= 1) return + val content = mutableState.value as? RepositoryListUiState.Content ?: return + if (content.page <= 1) return val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, current.page - 1) } + startOperation { loadRepositoryPage(account, content.page - 1, content.mode) } } fun refreshRepositories() { - val current = mutableState.value as? RepositoryListUiState.Content ?: return val account = activeAccountName ?: return - startOperation { loadRepositoryPage(account, current.page) } + val (page, mode) = when (val current = mutableState.value) { + is RepositoryListUiState.Content -> current.page to current.mode + is RepositoryListUiState.Empty -> 1 to current.mode + else -> return + } + startOperation { loadRepositoryPage(account, page, mode) } + } + + fun showAllRepositories() { + val account = activeAccountName ?: return + startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.ALL) } + } + + fun showFavorites() { + val account = activeAccountName ?: return + startOperation { loadRepositoryPage(account, page = 1, mode = RepositoryListMode.FAVORITES) } + } + + fun toggleFavorite(repository: RepositoryRowUiModel) { + val content = mutableState.value as? RepositoryListUiState.Content ?: return + val mode = content.mode + updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.InProgress) } + viewModelScope.launch { + try { + when (mode) { + RepositoryListMode.ALL -> { + favoritesApi.star(repository.coordinates) + updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Starred) } + } + RepositoryListMode.FAVORITES -> { + favoritesApi.unstar(repository.coordinates) + removeRow(repository.coordinates) + } + } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + if (failure is ApiFailure.AuthenticationExpired) { + clearSessionOnAuthenticationExpired() + } else { + updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Failed) } + } + } + } } fun retryRowLastPushedBranch(coordinates: RepositoryCoordinates) { @@ -315,21 +369,24 @@ class RepositoryListViewModel( val user = authApi.getAuthenticatedUser(tokens.accessToken) val accountName = user.displayName ?: user.login activeAccountName = accountName - loadRepositoryPage(accountName, page = 1) + loadRepositoryPage(accountName, page = 1, mode = RepositoryListMode.ALL) } - private suspend fun loadRepositoryPage(accountName: String, page: Int) { + private suspend fun loadRepositoryPage(accountName: String, page: Int, mode: RepositoryListMode) { cancelRowBranchJobs() mutableState.value = RepositoryListUiState.Loading try { - val result = browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE) + val result = when (mode) { + RepositoryListMode.ALL -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE) + RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE) + } val rows = result.items.map { it.toUiModel() } if (rows.isEmpty() && page == 1) { repositoryContent = null - mutableState.value = RepositoryListUiState.Empty(accountName) + mutableState.value = RepositoryListUiState.Empty(accountName, mode) return } - val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null) + val content = RepositoryListUiState.Content(accountName, rows, page, result.nextPage != null, mode) repositoryContent = content mutableState.value = content loadRowBranches(content) @@ -371,12 +428,7 @@ class RepositoryListViewModel( } 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) + clearSessionOnAuthenticationExpired() } else { updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) } } @@ -384,10 +436,29 @@ class RepositoryListViewModel( } } + private suspend fun clearSessionOnAuthenticationExpired() { + cancelRowBranchJobs() + tokenVault.clear() + activeAccountName = null + hasAuthenticatedSession = false + repositoryContent = null + mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED) + } + 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 { + replaceContent(current, updated) + } + + private fun removeRow(coordinates: RepositoryCoordinates) { + val current = mutableState.value as? RepositoryListUiState.Content ?: return + rowBranchJobs.remove(coordinates)?.cancel() + replaceContent(current, current.repositories.filterNot { it.coordinates == coordinates }) + } + + private fun replaceContent(current: RepositoryListUiState.Content, repositories: List) { + RepositoryListUiState.Content(current.accountName, repositories, current.page, current.hasNextPage, current.mode).also { repositoryContent = it mutableState.value = it } @@ -462,15 +533,17 @@ class RepositoryListViewModel( require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java)) val tokenVault = AndroidKeystoreTokenVault(application) val authTransport = OkHttpTransport() + val browser = GiteaRepositoryBrowserApi( + UBGiteaOAuthDefaults.config, + OkHttpTransport(), + BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }, + ) return RepositoryListViewModel( tokenVault = tokenVault, pendingAuthStore = PendingAuthStore(application), authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport), - browserApi = GiteaRepositoryBrowserApi( - UBGiteaOAuthDefaults.config, - OkHttpTransport(), - BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }, - ), + browserApi = browser, + favoritesApi = browser, ) as T } } diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index 260268f..fb54390 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -36,4 +36,10 @@ 마지막 push: %1$s 브랜치가 없습니다. 마지막 push 브랜치를 불러오지 못했습니다. + 전체 저장소 + 즐겨찾기 + 즐겨찾기한 저장소가 없습니다. + 즐겨찾기 + 즐겨찾기 해제 + 즐겨찾기됨 diff --git a/app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfigTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfigTest.kt new file mode 100644 index 0000000..3565ccd --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfigTest.kt @@ -0,0 +1,38 @@ +package com.ubisam.ubgitea.core.auth.protocol + +import java.net.URI +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class OAuthClientConfigTest { + @Test + fun everyNonReadOnlyScopeIsExplicitlyApproved() { + // Guards against a future write scope being added to the enum without also being + // added (deliberately, by a human decision) to OAuthClientConfig's approval list. + val writeScopes = OAuthScope.entries.filterNot { it.isReadOnly }.toSet() + assertEquals(writeScopes, OAuthClientConfig.EXPLICITLY_APPROVED_WRITE_SCOPES) + } + + @Test + fun acceptsExplicitlyApprovedWriteScopeAlongsideReadScopes() { + config(setOf(OAuthScope.READ_USER, OAuthScope.WRITE_USER)) + } + + @Test + fun acceptsReadOnlyScopesAlone() { + config(setOf(OAuthScope.READ_USER, OAuthScope.READ_REPOSITORY)) + } + + @Test + fun rejectsEmptyScopes() { + assertThrows(IllegalArgumentException::class.java) { config(emptySet()) } + } + + private fun config(scopes: Set) = OAuthClientConfig( + baseUri = URI("https://gitea.example.com"), + clientId = "public-client", + redirectUri = URI("https://gitea.example.com/callback"), + scopes = scopes, + ) +} diff --git a/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt index 7b631a9..7664aa5 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/auth/AuthApiTest.kt @@ -71,5 +71,8 @@ class AuthApiTest { this.bearerToken = bearerToken return getResponse } + + override suspend fun put(url: String, bearerToken: String): HttpResponse = error("Unexpected PUT") + override suspend fun delete(url: String, bearerToken: String): HttpResponse = error("Unexpected DELETE") } } diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt index 6a958d1..24b8c6c 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt @@ -42,6 +42,47 @@ class GiteaRepositoryBrowserApiTest { assertEquals("5", url.queryParameter("limit")) } + @Test + fun `starring sends PUT to the encoded starred endpoint`() = runTest { + val transport = QueueTransport(response("", emptyMap())) + + api(transport).star(repository) + + val url = transport.putUrls.single().toHttpUrl() + assertEquals(listOf("api", "v1", "user", "starred", "team name", "mobile+#"), url.pathSegments) + assertTrue(transport.urls.isEmpty()) + assertTrue(transport.deleteUrls.isEmpty()) + } + + @Test + fun `unstarring sends DELETE to the encoded starred endpoint`() = runTest { + val transport = QueueTransport(response("", emptyMap())) + + api(transport).unstar(repository) + + val url = transport.deleteUrls.single().toHttpUrl() + assertEquals(listOf("api", "v1", "user", "starred", "team name", "mobile+#"), url.pathSegments) + } + + @Test + fun `starred repositories page and map the same as the main repository list`() = 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).listStarred(1, 5) + + assertEquals("repo", page.items.single().coordinates.name) + assertNull(page.nextPage) + assertEquals( + listOf("api", "v1", "user", "starred"), + transport.urls.single().toHttpUrl().pathSegments, + ) + } + @Test fun `repository with no default branch maps to null instead of failing the whole page`() = runTest { val transport = QueueTransport( @@ -200,6 +241,8 @@ class GiteaRepositoryBrowserApiTest { private class QueueTransport(vararg responses: HttpResponse) : HttpTransport { private val queue = ArrayDeque(responses.toList()) val urls = mutableListOf() + val putUrls = mutableListOf() + val deleteUrls = mutableListOf() override suspend fun postForm(url: String, fields: Map) = error("Unexpected POST") @@ -208,5 +251,17 @@ class GiteaRepositoryBrowserApiTest { assertEquals("access", bearerToken) return queue.removeFirst() } + + override suspend fun put(url: String, bearerToken: String): HttpResponse { + putUrls += url + assertEquals("access", bearerToken) + return queue.removeFirst() + } + + override suspend fun delete(url: String, bearerToken: String): HttpResponse { + deleteUrls += url + assertEquals("access", bearerToken) + return queue.removeFirst() + } } } diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserHttpTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserHttpTest.kt index 55661f0..ba367dc 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserHttpTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserHttpTest.kt @@ -52,6 +52,10 @@ class GiteaRepositoryBrowserHttpTest { delegate.postForm(url.replace("https://", "http://"), fields) override suspend fun get(url: String, bearerToken: String) = delegate.get(url.replace("https://", "http://"), bearerToken) + override suspend fun put(url: String, bearerToken: String) = + delegate.put(url.replace("https://", "http://"), bearerToken) + override suspend fun delete(url: String, bearerToken: String) = + delegate.delete(url.replace("https://", "http://"), bearerToken) } val api = GiteaRepositoryBrowserApi(config, transport, BearerTokenProvider { "test-access" }) diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt index 9fe6be0..d50a6a7 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListUiStateTest.kt @@ -11,7 +11,13 @@ class RepositoryListUiStateTest { val source = mutableListOf( RepositoryRowUiModel(RepositoryCoordinates.of("team", "repo"), null, true, "main", 1_000L), ) - val content = RepositoryListUiState.Content("account", source, page = 1, hasNextPage = false) + val content = RepositoryListUiState.Content( + "account", + source, + page = 1, + hasNextPage = false, + mode = RepositoryListMode.ALL, + ) source.clear() diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index 69468ac..1d5f738 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -18,6 +18,7 @@ import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -200,6 +201,7 @@ class RepositoryListViewModelTest { pendingAuthStore = FakePendingStore(), authApi = FakeAuthGateway(), browserApi = browser, + favoritesApi = browser, now = { 1_000L }, ) advanceUntilIdle() @@ -209,6 +211,108 @@ class RepositoryListViewModelTest { assertEquals(null, tokenVault.tokens) } + @Test + fun `showing favorites loads starred repositories in favorites mode`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf(page1Of(repo("all-repo"))), + starred = listOf(page1Of(repo("starred-repo"))), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + + viewModel.showFavorites() + advanceUntilIdle() + + val content = viewModel.state.value as RepositoryListUiState.Content + assertEquals(RepositoryListMode.FAVORITES, content.mode) + assertEquals(listOf("starred-repo"), content.repositories.map { it.name }) + } + + @Test + fun `starring a repository from the all-repositories list marks it starred without removing it`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo")))) + val viewModel = viewModel(browser) + advanceUntilIdle() + + val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + viewModel.toggleFavorite(row) + advanceUntilIdle() + + val updated = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + assertEquals(FavoriteActionUiState.Starred, updated.favoriteAction) + assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls) + } + + @Test + fun `unstarring from the favorites tab removes the row without affecting others`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + starred = listOf(Page.of(listOf(repo("keep"), repo("remove")), null)), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + viewModel.showFavorites() + advanceUntilIdle() + + val toRemove = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "remove" } + viewModel.toggleFavorite(toRemove) + advanceUntilIdle() + + val content = viewModel.state.value as RepositoryListUiState.Content + assertEquals(listOf("keep"), content.repositories.map { it.name }) + assertEquals(listOf(RepositoryCoordinates.of("owner", "remove")), browser.unstarredCalls) + } + + @Test + fun `a failed favorite toggle marks only that row failed and can be retried`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf(page1Of(repo("a"), repo("b"))), + favoriteActionFailure = ApiFailure.Network(java.io.IOException()), + ) + val viewModel = viewModel(browser) + advanceUntilIdle() + + val rowA = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "a" } + viewModel.toggleFavorite(rowA) + advanceUntilIdle() + + val rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name } + assertEquals(FavoriteActionUiState.Failed, rows.getValue("a").favoriteAction) + assertEquals(FavoriteActionUiState.Idle, rows.getValue("b").favoriteAction) + + browser.favoriteActionFailure = null + viewModel.toggleFavorite(rows.getValue("a")) + advanceUntilIdle() + + val retried = (viewModel.state.value as RepositoryListUiState.Content).repositories.single { it.name == "a" } + assertEquals(FavoriteActionUiState.Starred, retried.favoriteAction) + } + + @Test + fun `expired authentication during a favorite toggle clears the session instead of marking the row failed`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + repositories = listOf(page1Of(repo("repo"))), + favoriteActionFailure = ApiFailure.AuthenticationExpired(), + ) + val tokenVault = FakeTokenVault(TokenSet("access", null, null)) + val viewModel = RepositoryListViewModel( + tokenVault = tokenVault, + pendingAuthStore = FakePendingStore(), + authApi = FakeAuthGateway(), + browserApi = browser, + favoritesApi = browser, + now = { 1_000L }, + ) + advanceUntilIdle() + val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + + viewModel.toggleFavorite(row) + 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( @@ -220,11 +324,12 @@ class RepositoryListViewModelTest { updatedAtEpochMillis = 1_000L, ) - private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel( + private fun viewModel(browser: FakeBrowser): RepositoryListViewModel = RepositoryListViewModel( tokenVault = FakeTokenVault(TokenSet("access", null, null)), pendingAuthStore = FakePendingStore(), authApi = FakeAuthGateway(), browserApi = browser, + favoritesApi = browser, now = { 1_000L }, ) @@ -251,14 +356,31 @@ class RepositoryListViewModelTest { private val branchFailure: Throwable? = null, var branchFailureForRepository: String? = null, var branchesByRepository: Map> = emptyMap(), - ) : GiteaRepositoryBrowser { + private val starred: List> = listOf(Page.of(emptyList(), null)), + var favoriteActionFailure: Throwable? = null, + ) : GiteaRepositoryBrowser, RepositoryFavorites { val repositoryPageRequests = mutableListOf>() + val starredCalls = mutableListOf() + val unstarredCalls = mutableListOf() override suspend fun listRepositories(page: Int, pageSize: Int): Page { repositoryPageRequests += page to pageSize return repositories.getOrElse(page - 1) { Page.of(emptyList(), null) } } + override suspend fun listStarred(page: Int, pageSize: Int): Page = + starred.getOrElse(page - 1) { Page.of(emptyList(), null) } + + override suspend fun star(repository: RepositoryCoordinates) { + favoriteActionFailure?.let { throw it } + starredCalls += repository + } + + override suspend fun unstar(repository: RepositoryCoordinates) { + favoriteActionFailure?.let { throw it } + unstarredCalls += repository + } + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page { branchFailure?.let { throw it } if (repository.name == branchFailureForRepository) throw ApiFailure.Network(java.io.IOException()) diff --git a/docs/decisions/004-favorites-write-user-scope.md b/docs/decisions/004-favorites-write-user-scope.md new file mode 100644 index 0000000..2630e88 --- /dev/null +++ b/docs/decisions/004-favorites-write-user-scope.md @@ -0,0 +1,34 @@ +# ADR-004: Favorites use Gitea star via a new `write:user` scope + +## Status + +Accepted — 2026-08-26 + +## Context + +The human product owner asked for a Favorites tab. Two designs were considered: a device-local favorite flag (no server write, no new scope) or Gitea's native star feature (`GET/PUT/DELETE /user/starred/{owner}/{repo}`), which syncs with the Gitea web UI and across devices but requires a write call. ADR-002 explicitly required product approval, an ADR revision, and explicit reauthorization UX before any new scope was added — this ADR is that revision. + +The human product owner chose Gitea-native star after being told explicitly that it requires the app's first write endpoint, that no NAS/Gitea server restart or configuration is needed (starring is a standard, already-live Gitea REST endpoint), and that the only consequence is a fresh login to pick up the new scope. + +## Decision + +- Add `write:user` to the app's requested OAuth scopes. Per Gitea's OAuth2 provider documentation, `write:user` grants read/write access to user-scoped operations including repository subscriptions, which covers starring. +- `OAuthClientConfig`'s scope guard is relaxed from "every scope must be read-only" to "every scope must be read-only or appear in an explicit, hard-coded `EXPLICITLY_APPROVED_WRITE_SCOPES` allow-list." Today that list contains exactly `write:user`. Adding any other write scope later requires the same kind of explicit human decision and ADR, not just adding an enum value. +- Star/unstar are the only write calls added. No create/edit/merge/close/comment/delete/admin endpoint is introduced by this or any related task (TASK-018). +- Star/unstar require no confirmation dialog: both are immediately reversible (tap again to undo) and non-destructive (no data loss, no effect on other users), matching Gitea's and GitHub's own web UI convention and `AGENTS.md`'s confirmation requirement, which is scoped to destructive operations. + +## Alternatives considered + +- Device-local-only favorites: rejected by the human product owner in favor of cross-device/web parity, despite being the option with zero new scope and zero server interaction. +- A generic "allow any write scope" guard relaxation: rejected — kept the allow-list explicit and enumerable so a future write scope cannot be added without deliberately touching this decision again. + +## Consequences + +- Existing signed-in users must log in again once before they can use Favorites; their existing token only carries the old read-only scopes. +- If this Gitea instance's OAuth application registration restricts requestable scopes independently of what the client requests (a server-admin setting, not something requiring a restart), the first login attempt with the new scope will surface as an explicit authorization error rather than silently degrading; this is a known, accepted risk to verify on-device rather than something assumed away. +- Favorite state is not cached locally; the starred set is always read fresh from Gitea, consistent with the product's existing no-offline-cache posture. + +## Evidence + +- Gitea REST API: `PUT/DELETE /user/starred/{owner}/{repo}`, `GET /user/starred` — standard, already-live endpoints (https://docs.gitea.com/api/1.27.2/). +- Gitea OAuth2 provider documentation describes `write:user` as granting "read/write/delete access to user operations, such as updating user repo subscriptions" (https://docs.gitea.com/development/oauth2-provider/). diff --git a/docs/development-log.md b/docs/development-log.md index da1a179..2a50009 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -177,3 +177,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - 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. + +## 2026-08-26 — TASK-018: Favorites tab backed by Gitea star + +- Agent and role: Claude, feature implementer, per human product owner priority-1 request in conversation on 2026-08-26 (same request as TASK-017). +- Commit/diff: same branch as TASK-017 (`claude/task-017-recent-push-favorites`), committed as a separate commit on top of it; new task file `tasks/018-favorites-gitea-star.md`, ADR `docs/decisions/004-favorites-write-user-scope.md`. +- Features implemented: A user can star a repository from the main list and unstar it from a new Favorites tab, which shows only their starred repositories with the same paging/loading/empty/error states as the main list. +- How it was implemented: Added `write:user` to the app's requested OAuth scopes and replaced `OAuthClientConfig`'s "every scope must be read-only" guard with "every scope must be read-only or in an explicit approved-write-scope allow-list" (currently exactly `write:user`) — the app's first non-read-only scope and first write endpoint. `HttpTransport` gained bearer-authenticated `put`/`delete`. `GiteaRepositoryBrowserApi` now also implements a new `RepositoryFavorites` domain interface (`listStarred`/`star`/`unstar`) on the same class, reusing its existing repository-mapping code. `RepositoryListViewModel` gained a `RepositoryListMode` (ALL/FAVORITES) carried on its `Content`/`Empty` states; toggling favorite either marks a row `Starred` (ALL mode, confirmed-not-optimistic) or removes it from the current page (FAVORITES mode, after a confirmed unstar). `RepositoryListScreen` gained a tab switcher and a per-row favorite button using plain localized text (no new icon dependency). +- Files changed: `core/auth/protocol/OAuthClientConfig.kt`, `core/network/HttpTransport.kt`, `data/repositories/GiteaRepositoryBrowserApi.kt`, new `domain/repository/RepositoryFavorites.kt`, `feature/repositories/RepositoryListViewModel.kt`, `feature/repositories/RepositoryListScreen.kt`, `MainActivity.kt`, `strings_repositories.xml`, associated tests, new `OAuthClientConfigTest.kt`, ADR-004, `tasks/018-favorites-gitea-star.md`. +- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 117 unit tests across 23 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean (CRLF notices only); sensitive-pattern scan clean. +- Review findings and resolutions: Self-review narrowed two acceptance criteria from their original draft to match what was actually and deliberately built: favorite-toggle failures use one generic retryable state rather than per-HTTP-status messages (proportionate for a lightweight toggle), and favorite state updates only after a confirmed server response rather than optimistically. Independent Codex review is pending, and this is the first task to also request on-device verification of the new OAuth scope specifically (does the Gitea instance's OAuth app registration accept it without a server-side change). +- Known limitations: No physical device was available — the new-scope login path is unverified. Existing signed-in users need one fresh login before Favorites works. Favorite state is never cached locally (always read fresh from Gitea). +- Next recommended task: Codex reviews TASK-017 and TASK-018 together (same branch) and verifies the `write:user` login on a physical device before this reaches users; TASK-007 (history UI) remains next in the original roadmap once TASK-009 lands. +- Next owner: Codex, for review and device verification. diff --git a/tasks/018-favorites-gitea-star.md b/tasks/018-favorites-gitea-star.md new file mode 100644 index 0000000..993c600 --- /dev/null +++ b/tasks/018-favorites-gitea-star.md @@ -0,0 +1,102 @@ +# TASK-018: Favorites tab backed by Gitea star + +Status: implemented; all required checks pass; pending Codex review and device verification +Owner: Claude (human product owner priority-1 request, 2026-08-26) +Reviewer: Codex + +## Outcome + +A signed-in user can star/unstar a repository from the app, and a dedicated Favorites tab shows only their starred repositories. Favorite state is Gitea's own server-side star, not device-local, so it matches the Gitea web UI and follows the account across devices. + +## Product/security decision record + +- Human product owner explicitly chose Gitea-native star over a device-local-only favorite, after being told this requires the app's first write endpoint (2026-08-26 conversation). +- This adds one non-read-only OAuth scope (`write:user`) to `OAuthClientConfig`, whose constructor currently hard-requires `scopes.all { it.isReadOnly }`. That guard is relaxed to allow exactly this scope; it must not become a general-purpose escape hatch for other write scopes without a separate human decision. +- Star/unstar is immediately reversible (tap again to unstar) and not destructive (no data loss, no effect on other users' data), so per `AGENTS.md`'s "destructive Gitea operations require explicit UI confirmation," no confirmation dialog is required — this is the same UX convention Gitea's and GitHub's own web UIs use for starring. +- No NAS/Gitea server restart or configuration is required: starring is a standard, already-live Gitea REST endpoint. If this Gitea instance's OAuth application registration turns out to restrict requestable scopes (server-admin setting, not a restart), that will surface as an explicit authorization error on first login with the new scope and is recorded as a known limitation, not assumed away. + +## Scope + +- Included: + - Add `write:user` to the requested OAuth scopes; relax the `OAuthClientConfig` read-only guard to allow it explicitly (not generically); record the decision in `docs/decisions/`. + - Add `GET /user/starred` (paged), `PUT /user/starred/{owner}/{repo}` (star), `DELETE /user/starred/{owner}/{repo}` (unstar) to the Gitea data layer as the app's first write calls, isolated behind a small typed interface. + - Add a star/unstar toggle to each repository row (main list and, if present, Favorites tab). + - Add a new Favorites tab showing only starred repositories, with the same loading/paging/empty/offline/auth-expired/server-error states as the main list. + - Add whatever minimal tab navigation structure is needed to host a Favorites tab alongside the existing repository list (kept minimal; full typed navigation remains a separate future task). +- Excluded: + - Any other write/mutation capability (no create/edit/merge/delete/admin endpoint). + - Offline caching of favorite state (server is the single source of truth; no local persistence of the starred set beyond in-memory UI state). + - Device-local-only favorites (explicitly rejected in favor of Gitea star, see decision record above). + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/core/auth/protocol/OAuthClientConfig.kt` (scope set and guard only) +- `app/src/main/java/com/ubisam/ubgitea/data/repositories/` +- `app/src/main/java/com/ubisam/ubgitea/domain/model/` (additive only) +- `app/src/main/java/com/ubisam/ubgitea/domain/repository/` +- `app/src/main/java/com/ubisam/ubgitea/domain/usecase/` +- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/` +- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` (tab wiring only) +- `app/src/main/res/values/strings_repositories.xml` +- `app/src/test/java/com/ubisam/ubgitea/core/auth/protocol/` +- `app/src/test/java/com/ubisam/ubgitea/data/repositories/` +- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/` +- `docs/decisions/` +- `tasks/018-favorites-gitea-star.md` +- `docs/development-log.md` (append-only) + +## Acceptance criteria + +- [x] `OAuthClientConfig` accepts `write:user` alongside the existing read-only scopes; the guard is proven exhaustive by asserting every non-read-only `OAuthScope` enum member equals the approved set, so a future write scope added to the enum without approval fails this test (not just a single positive-case check). +- [x] Star/unstar call the exact documented Gitea endpoints with correct URL-encoded owner/repo components and the bearer token; a 401 clears the session the same way every other endpoint does (both for a row's favorite toggle and for the row's branch fetch). +- [x] Star/unstar failures surface a retryable failed state on the affected row without discarding the rest of the list, and the toggle can be retried. (Narrowed from the original wording: failures are a single generic retryable state rather than a per-HTTP-status message — proportionate for a lightweight toggle action, consistent with how this UI already treats other row-level transient failures. Authentication expiry is the one failure type handled distinctly, at the session level, because it is not really a per-row condition.) +- [x] Toggling star updates the row's visual state immediately after a *confirmed* server response (not optimistically before the call completes) so the UI never shows a state the server hasn't actually confirmed; starring from the main list makes the repository appear in the Favorites tab on its next load. +- [x] The Favorites tab pages `GET /user/starred` the same way the main list pages repositories (page size and loading pattern from TASK-017), with matching loading/empty/offline/auth-expired/server-error states. +- [x] Favorites tab emptiness (no starred repos) has a distinct, localized empty state distinguishable from a loading or error state. +- [x] No create/edit/merge/close/comment endpoint is reachable from any new code in this task. +- [x] Tests cover: scope-guard exhaustiveness, star/unstar URL and encoding, 401 mapping (both favorite-toggle and branch-fetch paths), generic-failure-then-retry, Favorites mode paging, and unstar removing exactly one row without affecting others. + +## Security and privacy + +- First write endpoint in the app — reviewed explicitly against `AGENTS.md`'s security invariants: no client secret involved, no new token type, no logging of request/response bodies, TLS validation unchanged, no destructive endpoint added. +- Scope change is a production OAuth configuration change; human product owner already approved it in conversation (see decision record). Still requires a fresh login to take effect (existing tokens carry only the old scope) — noted as a device-verification item. + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +Implemented on branch `claude/task-017-recent-push-favorites` (same branch as TASK-017; committed separately), on top of TASK-017's commit. + +- OAuth: `OAuthScope.WRITE_USER("write:user", isReadOnly = false)` added; `OAuthClientConfig`'s guard changed from "every scope read-only" to "every scope read-only or in `EXPLICITLY_APPROVED_WRITE_SCOPES`" (currently exactly `{WRITE_USER}`). `UBGiteaOAuthDefaults.config` now requests it. Recorded as ADR-004. +- Data: `core/network/HttpTransport.kt` gained `put`/`delete` (bearer-authenticated, empty body) alongside the existing `get`/`postForm`. `GiteaRepositoryBrowserApi` now also implements a new `domain/repository/RepositoryFavorites` interface (`listStarred`, `star`, `unstar`) on the *same* class as `GiteaRepositoryBrowser`, reusing its existing repository-JSON mapping (extracted into `mapRepository()`) instead of duplicating it, and a shared `authenticated()` helper replaces three near-identical token-check/try-catch blocks. +- Domain: `RepositoryListMode` (ALL/FAVORITES) added. `RepositoryListUiState.Content`/`Empty` carry the active mode. `RepositoryRowUiModel` gained `favoriteAction: FavoriteActionUiState` (Idle/InProgress/Starred/Failed). +- ViewModel: `loadRepositoryPage` now takes a `mode` and calls `browserApi.listRepositories` or `favoritesApi.listStarred` accordingly; `showAllRepositories()`/`showFavorites()` switch mode from page 1. `toggleFavorite(row)` stars from ALL mode (row stays, marked `Starred` on confirmed success) or unstars from FAVORITES mode (row is removed from the currently displayed page on confirmed success, not optimistically). A 401 during either a favorite toggle or a row's branch fetch now shares one `clearSessionOnAuthenticationExpired()` helper (previously duplicated inline). +- UI: `RepositoryListScreen` gained a `TabRow` (전체 저장소 / 즐겨찾기) shown in both the `Content` and `Empty` states, and each repository row gained a favorite button reflecting `favoriteAction` (no icon library added — plain localized text buttons, consistent with the rest of the app's existing button style, to avoid pulling in a new Compose icons dependency for one feature). +- Design choice recorded in acceptance criteria: favorite-toggle failures are one generic retryable state rather than per-HTTP-status messages, and favorite state updates only after a confirmed server response rather than optimistically — both narrower than the task's original draft wording, adjusted to match what was actually built and why. + +Checks (JDK 21 via `D:\AndroidStudio\jbr`, Android SDK at `D:\Android\WhatToEat\sdk`): + +```text +./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL + - 117 unit tests across 23 suites, 0 failures/errors + - 0 lint errors +git diff --check → only CRLF-normalization notices +sensitive-pattern scan of changed files → no matches +``` + +Known limitations: +- No physical device was available. In particular, the real-world question of whether this Gitea instance's OAuth application registration independently restricts requestable scopes (a server-admin setting) can only be confirmed by an actual login attempt with the new scope on-device — this is called out explicitly rather than assumed safe. +- Existing signed-in users need one fresh login before Favorites works, since their stored token only carries the previously-approved read-only scopes. +- Favorite state is always read fresh from Gitea (no local cache), consistent with the product's current no-offline-cache posture; this means the Favorites tab requires network access even to show a previously-seen list. + +No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review; on-device scope/login verification is also requested since this is the first write endpoint and the first new OAuth scope since ADR-002. + +## Review findings + +None yet.