From a2cc7d4ca3fa852227752b8b82188505c0007983 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Tue, 25 Aug 2026 21:52:40 +0900 Subject: [PATCH] Add repository browsing domain foundation --- .../domain/model/RepositoryCoordinates.kt | 26 ++++ .../ubgitea/domain/model/RepositoryModels.kt | 81 ++++++++++ .../ubgitea/domain/model/RepositoryPath.kt | 25 +++ .../ubgitea/domain/model/RepositoryRef.kt | 14 ++ .../repository/GiteaRepositoryBrowser.kt | 43 ++++++ .../usecase/RepositoryBrowseUseCases.kt | 82 ++++++++++ .../com/ubisam/ubgitea/ui/home/HomeScreen.kt | 19 ++- app/src/main/res/values/strings.xml | 2 +- .../domain/model/RepositoryCoordinatesTest.kt | 24 +++ .../domain/model/RepositoryModelsTest.kt | 41 +++++ .../domain/model/RepositoryPathTest.kt | 40 +++++ .../ubgitea/domain/model/RepositoryRefTest.kt | 29 ++++ .../usecase/RepositoryBrowseUseCasesTest.kt | 142 ++++++++++++++++++ docs/product.md | 15 ++ tasks/003-repository-browser-foundation.md | 55 +++++++ tasks/004-gitea-api-client.md | 27 ++++ tasks/005-repository-branch-ui.md | 17 +++ tasks/006-code-browser.md | 18 +++ tasks/007-history-and-changes.md | 18 +++ 19 files changed, 715 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinates.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryPath.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryRef.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/repository/GiteaRepositoryBrowser.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinatesTest.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryModelsTest.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryPathTest.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryRefTest.kt create mode 100644 app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt create mode 100644 tasks/003-repository-browser-foundation.md create mode 100644 tasks/004-gitea-api-client.md create mode 100644 tasks/005-repository-branch-ui.md create mode 100644 tasks/006-code-browser.md create mode 100644 tasks/007-history-and-changes.md diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinates.kt b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinates.kt new file mode 100644 index 0000000..e0a5af6 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinates.kt @@ -0,0 +1,26 @@ +package com.ubisam.ubgitea.domain.model + +@ConsistentCopyVisibility +data class RepositoryCoordinates private constructor( + val owner: String, + val name: String, +) { + companion object { + fun of(owner: String, name: String): RepositoryCoordinates { + return RepositoryCoordinates( + owner = owner.validatedComponent("owner"), + name = name.validatedComponent("name"), + ) + } + + private fun String.validatedComponent(label: String): String { + val normalized = trim() + require(normalized.isNotEmpty()) { "Repository $label cannot be blank" } + require('/' !in normalized) { "Repository $label must be one path component" } + require(normalized.none(Char::isISOControl)) { + "Repository $label cannot contain control characters" + } + return normalized + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt new file mode 100644 index 0000000..5e8a38f --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryModels.kt @@ -0,0 +1,81 @@ +package com.ubisam.ubgitea.domain.model + +import java.util.Collections + +data class RepositorySummary( + val coordinates: RepositoryCoordinates, + val description: String?, + val defaultBranch: RepositoryRef, + val isPrivate: Boolean, + val isFork: Boolean, +) + +data class BranchSummary( + val name: RepositoryRef, + val commitId: String, + val isProtected: Boolean, +) + +enum class TreeEntryType { + DIRECTORY, + FILE, + SYMLINK, + SUBMODULE, +} + +data class TreeEntry( + val name: String, + val path: RepositoryPath, + val type: TreeEntryType, + val sizeBytes: Long?, +) + +data class SourceFile( + val path: RepositoryPath, + val ref: RepositoryRef, + val content: SourceContent, + val mediaType: String?, +) + +class SourceContent private constructor(private val bytes: ByteArray) { + val sizeBytes: Int + get() = bytes.size + + fun copyBytes(): ByteArray = bytes.copyOf() + + override fun equals(other: Any?): Boolean = + other is SourceContent && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int { + return bytes.contentHashCode() + } + + companion object { + fun from(bytes: ByteArray): SourceContent = SourceContent(bytes.copyOf()) + } +} + +data class CommitSummary( + val id: String, + val shortId: String, + val message: String, + val authorName: String, + val authoredAtEpochMillis: Long, +) + +/** + * An immutable snapshot of one result page. + * + * The list structure is unmodifiable. Callers and adapters must also use immutable item types. + */ +class Page private constructor( + val items: List, + val nextPage: Int?, +) { + companion object { + fun of(items: Collection, nextPage: Int?): Page { + require(nextPage == null || nextPage > 0) { "Next page must be positive" } + return Page(Collections.unmodifiableList(ArrayList(items)), nextPage) + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryPath.kt b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryPath.kt new file mode 100644 index 0000000..a7bd81e --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryPath.kt @@ -0,0 +1,25 @@ +package com.ubisam.ubgitea.domain.model + +@JvmInline +value class RepositoryPath private constructor(val value: String) { + val isRoot: Boolean + get() = value.isEmpty() + + companion object { + val Root = RepositoryPath("") + + fun of(value: String): RepositoryPath { + val normalized = value.trimStart('/').trimEnd('/') + if (normalized.isEmpty()) return Root + + require(normalized.none(Char::isISOControl)) { + "Repository path cannot contain control characters" + } + val segments = normalized.split('/') + require(segments.none { it.isEmpty() || it == "." || it == ".." }) { + "Repository path cannot contain empty or traversal segments" + } + return RepositoryPath(normalized) + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryRef.kt b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryRef.kt new file mode 100644 index 0000000..3e8c6c0 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/model/RepositoryRef.kt @@ -0,0 +1,14 @@ +package com.ubisam.ubgitea.domain.model + +@JvmInline +value class RepositoryRef private constructor(val value: String) { + companion object { + fun of(value: String): RepositoryRef { + require(value.isNotBlank()) { "Repository ref cannot be blank" } + require(value.none(Char::isISOControl)) { + "Repository ref cannot contain control characters" + } + return RepositoryRef(value) + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/repository/GiteaRepositoryBrowser.kt b/app/src/main/java/com/ubisam/ubgitea/domain/repository/GiteaRepositoryBrowser.kt new file mode 100644 index 0000000..8524aa1 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/repository/GiteaRepositoryBrowser.kt @@ -0,0 +1,43 @@ +package com.ubisam.ubgitea.domain.repository + +import com.ubisam.ubgitea.domain.model.BranchSummary +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.model.RepositorySummary +import com.ubisam.ubgitea.domain.model.SourceFile +import com.ubisam.ubgitea.domain.model.TreeEntry + +interface GiteaRepositoryBrowser { + suspend fun listRepositories(page: Int, pageSize: Int): Page + + suspend fun listBranches( + repository: RepositoryCoordinates, + page: Int, + pageSize: Int, + ): Page + + suspend fun listTree( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + page: Int, + pageSize: Int, + ): Page + + suspend fun getFile( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + ): SourceFile + + suspend fun listCommits( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath?, + page: Int, + pageSize: Int, + ): Page +} diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt b/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt new file mode 100644 index 0000000..2c52b24 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt @@ -0,0 +1,82 @@ +package com.ubisam.ubgitea.domain.usecase + +import com.ubisam.ubgitea.domain.model.BranchSummary +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.model.RepositorySummary +import com.ubisam.ubgitea.domain.model.SourceFile +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser + +class ListRepositoriesUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke(page: Int, pageSize: Int): Page { + validatePagination(page, pageSize) + return repositoryBrowser.listRepositories(page, pageSize) + } +} + +class ListBranchesUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke( + repository: RepositoryCoordinates, + page: Int, + pageSize: Int, + ): Page { + validatePagination(page, pageSize) + return repositoryBrowser.listBranches(repository, page, pageSize) + } +} + +class BrowseTreeUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + page: Int, + pageSize: Int, + ): Page { + validatePagination(page, pageSize) + return repositoryBrowser.listTree(repository, ref, path, page, pageSize) + } +} + +class ListCommitsUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath?, + page: Int, + pageSize: Int, + ): Page { + validatePagination(page, pageSize) + return repositoryBrowser.listCommits(repository, ref, path, page, pageSize) + } +} + +private fun validatePagination(page: Int, pageSize: Int) { + require(page > 0) { "Page must be positive" } + require(pageSize in 1..100) { "Page size must be between 1 and 100" } +} + +class LoadSourceFileUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + ): SourceFile { + require(!path.isRoot) { "A source file path cannot be the repository root" } + return repositoryBrowser.getFile(repository, ref, path) + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/home/HomeScreen.kt b/app/src/main/java/com/ubisam/ubgitea/ui/home/HomeScreen.kt index b959b98..32b8b69 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/home/HomeScreen.kt @@ -16,7 +16,10 @@ import com.ubisam.ubgitea.R import com.ubisam.ubgitea.ui.theme.UBGiteaTheme @Composable -fun HomeScreen(modifier: Modifier = Modifier) { +fun HomeScreen( + state: HomeUiState = HomeUiState.Ready, + modifier: Modifier = Modifier, +) { Scaffold(modifier = modifier.fillMaxSize()) { innerPadding -> Column( modifier = Modifier @@ -30,13 +33,25 @@ fun HomeScreen(modifier: Modifier = Modifier) { style = MaterialTheme.typography.headlineLarge, ) Text( - text = stringResource(R.string.home_placeholder), + text = stringResource(state.messageResource), style = MaterialTheme.typography.bodyLarge, ) } } } +data class HomeUiState( + val isReadOnly: Boolean, + val messageResource: Int, +) { + companion object { + val Ready = HomeUiState( + isReadOnly = true, + messageResource = R.string.home_read_only_placeholder, + ) + } +} + @Preview(showBackground = true) @Composable private fun HomeScreenPreview() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2269d87..2d100ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ UBGitea - 회사 Gitea를 위한 Android 앱을 준비하고 있습니다. + 저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다. diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinatesTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinatesTest.kt new file mode 100644 index 0000000..c6910e8 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryCoordinatesTest.kt @@ -0,0 +1,24 @@ +package com.ubisam.ubgitea.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RepositoryCoordinatesTest { + @Test + fun trimsValidCoordinates() { + val coordinates = RepositoryCoordinates.of(" ubisam ", " mobile-app ") + + assertEquals("ubisam", coordinates.owner) + assertEquals("mobile-app", coordinates.name) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsBlankOwner() { + RepositoryCoordinates.of(" ", "mobile-app") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsSlashInName() { + RepositoryCoordinates.of("ubisam", "mobile/app") + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryModelsTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryModelsTest.kt new file mode 100644 index 0000000..9f7e2c2 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryModelsTest.kt @@ -0,0 +1,41 @@ +package com.ubisam.ubgitea.domain.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RepositoryModelsTest { + @Test + fun sourceContentCopiesInputAndOutput() { + val input = byteArrayOf(1, 2, 3) + val content = SourceContent.from(input) + input[0] = 9 + + val output = content.copyBytes() + output[1] = 9 + + assertArrayEquals(byteArrayOf(1, 2, 3), content.copyBytes()) + } + + @Test + fun pageCopiesMutableInput() { + val input = mutableListOf("first") + val page = Page.of(input, nextPage = 2) + input += "second" + + assertEquals(listOf("first"), page.items) + assertEquals(2, page.nextPage) + } + + @Test + fun pageDoesNotExposeMutableListImplementation() { + val page = Page.of(listOf("first", "second"), nextPage = null) + + assertThrows(UnsupportedOperationException::class.java) { + @Suppress("UNCHECKED_CAST") + (page.items as MutableList).add("third") + } + assertEquals(listOf("first", "second"), page.items) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryPathTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryPathTest.kt new file mode 100644 index 0000000..bd7584f --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryPathTest.kt @@ -0,0 +1,40 @@ +package com.ubisam.ubgitea.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RepositoryPathTest { + @Test + fun normalizesLeadingAndTrailingSlashes() { + assertEquals("src/main/App.kt", RepositoryPath.of("/src/main/App.kt/").value) + } + + @Test + fun representsRootPath() { + assertTrue(RepositoryPath.of("/").isRoot) + } + + @Test + fun preservesCharactersThatUrlLayerMustEncodeExactlyOnce() { + assertEquals( + "한글 경로/100%#?.kt", + RepositoryPath.of("한글 경로/100%#?.kt").value, + ) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsTraversalSegments() { + RepositoryPath.of("src/../secrets.txt") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsEmptySegments() { + RepositoryPath.of("src//App.kt") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsControlCharacters() { + RepositoryPath.of("src/App\u0000.kt") + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryRefTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryRefTest.kt new file mode 100644 index 0000000..fc875c0 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/domain/model/RepositoryRefTest.kt @@ -0,0 +1,29 @@ +package com.ubisam.ubgitea.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RepositoryRefTest { + @Test + fun acceptsNormalBranchTagAndCommitNames() { + assertEquals("feature/code-browser", RepositoryRef.of("feature/code-browser").value) + assertEquals("기능/코드-보기", RepositoryRef.of("기능/코드-보기").value) + assertEquals("v1.0.0", RepositoryRef.of("v1.0.0").value) + assertEquals("a1b2c3d", RepositoryRef.of("a1b2c3d").value) + } + + @Test + fun doesNotSilentlyTrimRef() { + assertEquals(" feature ", RepositoryRef.of(" feature ").value) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsBlankRef() { + RepositoryRef.of(" ") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsControlCharacters() { + RepositoryRef.of("main\nother") + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt new file mode 100644 index 0000000..888a69a --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt @@ -0,0 +1,142 @@ +package com.ubisam.ubgitea.domain.usecase + +import com.ubisam.ubgitea.domain.model.BranchSummary +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.model.RepositorySummary +import com.ubisam.ubgitea.domain.model.SourceContent +import com.ubisam.ubgitea.domain.model.SourceFile +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RepositoryBrowseUseCasesTest { + private val coordinates = RepositoryCoordinates.of("ubisam", "mobile") + private val ref = RepositoryRef.of("feature/code-browser") + private val browser = RecordingBrowser() + + @Test + fun validPagingDelegatesAcrossListUseCases() { + runSuspend { ListRepositoriesUseCase(browser)(1, 20) } + runSuspend { ListBranchesUseCase(browser)(coordinates, 2, 50) } + runSuspend { + BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 3, 100) + } + runSuspend { ListCommitsUseCase(browser)(coordinates, ref, null, 4, 1) } + + assertEquals( + listOf("repositories:1:20", "branches:2:50", "tree:3:100", "commits:4:1"), + browser.calls, + ) + } + + @Test + fun invalidPageDoesNotDelegate() { + assertThrows(IllegalArgumentException::class.java) { + runSuspend { ListBranchesUseCase(browser)(coordinates, 0, 20) } + } + + assertEquals(emptyList(), browser.calls) + } + + @Test + fun invalidPageSizeDoesNotDelegate() { + assertThrows(IllegalArgumentException::class.java) { + runSuspend { + BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 1, 101) + } + } + + assertEquals(emptyList(), browser.calls) + } + + @Test + fun rootSourceFileDoesNotDelegate() { + assertThrows(IllegalArgumentException::class.java) { + runSuspend { LoadSourceFileUseCase(browser)(coordinates, ref, RepositoryPath.Root) } + } + + assertEquals(emptyList(), browser.calls) + } + + @Test + fun validSourceFileDelegates() { + val path = RepositoryPath.of("src/App.kt") + + val result = runSuspend { LoadSourceFileUseCase(browser)(coordinates, ref, path) } + + assertEquals(path, result.path) + assertEquals(listOf("file:src/App.kt"), browser.calls) + } + + private class RecordingBrowser : GiteaRepositoryBrowser { + val calls = mutableListOf() + + override suspend fun listRepositories(page: Int, pageSize: Int): Page { + calls += "repositories:$page:$pageSize" + return Page.of(emptyList(), null) + } + + override suspend fun listBranches( + repository: RepositoryCoordinates, + page: Int, + pageSize: Int, + ): Page { + calls += "branches:$page:$pageSize" + return Page.of(emptyList(), null) + } + + override suspend fun listTree( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + page: Int, + pageSize: Int, + ): Page { + calls += "tree:$page:$pageSize" + return Page.of(emptyList(), null) + } + + override suspend fun getFile( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath, + ): SourceFile { + calls += "file:${path.value}" + return SourceFile(path, ref, SourceContent.from(byteArrayOf()), "text/plain") + } + + override suspend fun listCommits( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath?, + page: Int, + pageSize: Int, + ): Page { + calls += "commits:$page:$pageSize" + return Page.of(emptyList(), null) + } + } +} + +private fun runSuspend(block: suspend () -> T): T { + var outcome: Result? = null + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + outcome = result + } + }, + ) + return checkNotNull(outcome).getOrThrow() +} diff --git a/docs/product.md b/docs/product.md index 6308f8b..642f669 100644 --- a/docs/product.md +++ b/docs/product.md @@ -12,6 +12,20 @@ UBGitea gives employees a focused Android experience for the company Gitea insta 4. View issues, pull requests, and notifications. 5. Sign out and remove locally stored credentials. +## Repository browsing experience + +Within the permissions of the signed-in Gitea account, the application must support: + +- Listing organizations and repositories with pagination. +- Listing every branch and tag visible to the account. +- Browsing a repository tree at an explicit branch, tag, or commit. +- Viewing text source with the selected ref and path always visible. +- Viewing commit history and per-commit file changes. +- Comparing branches and commits without implying write access. +- Viewing pull requests, issues, reviews, comments, and notifications. + +Binary files, Git LFS pointers, submodules, and files too large for safe mobile rendering require an explicit fallback rather than being decoded as ordinary source text. + The first release is read-oriented. Creating, merging, deleting, or administering resources is out of scope until explicitly approved. ## UX principles @@ -29,6 +43,7 @@ The first release is read-oriented. Creating, merging, deleting, or administerin - Gitea Actions runner administration - Package publishing - Multiple Gitea instances +- Creating or editing files, pushing commits, merging, deleting branches, or administering repositories ## Product decisions diff --git a/tasks/003-repository-browser-foundation.md b/tasks/003-repository-browser-foundation.md new file mode 100644 index 0000000..9c3b789 --- /dev/null +++ b/tasks/003-repository-browser-foundation.md @@ -0,0 +1,55 @@ +# TASK-003: Establish repository browsing foundations + +Status: complete +Owner: Codex +Reviewer: Claude + +## Outcome + +Define the immutable domain contracts and presentation states needed to browse repositories, branches, directory entries, and source files without coupling the UI to Gitea DTOs or Android framework types. + +## Scope + +- Included: domain models, repository interface, validated repository location/ref/path value objects, browse use cases, immutable screen state, focused unit tests, and a placeholder home state that describes the read-only experience. +- Excluded: live HTTP calls, OAuth tokens, persistence, syntax highlighting, navigation between final screens, and write operations. + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/domain/` +- `app/src/main/java/com/ubisam/ubgitea/ui/home/` +- `app/src/main/res/values/strings.xml` +- `app/src/test/java/com/ubisam/ubgitea/` +- Documentation and task evidence required for this task + +## Acceptance criteria + +- [x] Repository, branch, tree-entry, source-file, and commit-summary models are immutable, defensively copy mutable input, and contain no network or Android types. +- [x] Repository coordinates reject blank owner/name values. +- [x] Refs reject blank values and control characters while permitting normal Gitea branch, tag, and commit names. +- [x] Repository paths normalize a leading slash, reject traversal segments and control characters, and preserve valid nested paths. +- [x] The repository interface is read-only and exposes paged repositories plus branch, tree, file, and commit queries. +- [x] Home UI uses an explicit immutable state and shows a Korean read-only placeholder using string resources. +- [x] Unit tests cover valid and invalid coordinates, refs, paths, defensive content copying, and paged-list copying. +- [x] No secret, token, network DTO, or Android framework dependency appears in the domain package. + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +Recorded on 2026-08-25 using JDK 17 and Android SDK 36: + +- `./gradlew assembleDebug` — passed. +- `./gradlew testDebugUnitTest` — passed; 22 tests across 6 suites, 0 failures, 0 errors. +- Focused domain/use-case tests — passed; page output mutation is rejected, all paged use cases share the same bounds, and invalid input does not reach the repository adapter. +- `./gradlew lintDebug` — passed. +- Sensitive-pattern repository scan excluding generated and Git metadata — no assigned secret, token, password, or private-key value found. +- `git diff --check` — passed. +- Actual device/emulator interaction was not required because this task establishes domain contracts and a placeholder state only. +- Independent read-only implementation review — initial P2 findings for page output mutability and incomplete use-case boundary tests were fixed; re-review found no remaining P0-P3 findings. +- Reviewer independently reran `assembleDebug`, `testDebugUnitTest`, and `lintDebug` successfully after the fixes. diff --git a/tasks/004-gitea-api-client.md b/tasks/004-gitea-api-client.md new file mode 100644 index 0000000..3d94198 --- /dev/null +++ b/tasks/004-gitea-api-client.md @@ -0,0 +1,27 @@ +# TASK-004: Implement the read-only Gitea API client + +Status: planned — depends on TASK-003 and authentication contract +Owner: Codex +Reviewer: Claude + +## Outcome + +Connect the repository browsing domain to authenticated, paginated Gitea API calls while keeping DTOs and tokens out of domain and UI layers. + +## Acceptance criteria + +- [ ] Base URL is configurable with the approved production default. +- [ ] DTOs are separate from domain models and mapping is tested. +- [ ] Repository, branch, tree, file, and commit endpoints support pagination where provided. +- [ ] Authentication is injected without logging or exposing tokens. +- [ ] HTTP, authorization, rate-limit, parsing, cancellation, and offline failures map to typed domain failures. +- [ ] Branch, ref, owner, repository, and path values are safely encoded as individual URL components. +- [ ] Tests use a local fake server and never contact production. + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` diff --git a/tasks/005-repository-branch-ui.md b/tasks/005-repository-branch-ui.md new file mode 100644 index 0000000..6aa62bf --- /dev/null +++ b/tasks/005-repository-branch-ui.md @@ -0,0 +1,17 @@ +# TASK-005: Browse repositories and branches + +Status: planned — depends on TASK-002 and TASK-004 +Owner: Codex +Reviewer: Claude + +## Outcome + +A signed-in user can browse every repository and branch visible to their Gitea account with loading, pagination, refresh, empty, offline, expired-authentication, and server-error states. + +## Acceptance criteria + +- [ ] Repository and branch lists preserve pagination and refresh state. +- [ ] Organization/owner, visibility, default branch, and selected branch are unambiguous. +- [ ] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized. +- [ ] Retry and pagination cannot create duplicate items or overlapping requests. +- [ ] UI and ViewModel tests cover primary states and selection behavior. diff --git a/tasks/006-code-browser.md b/tasks/006-code-browser.md new file mode 100644 index 0000000..bfd5fd9 --- /dev/null +++ b/tasks/006-code-browser.md @@ -0,0 +1,18 @@ +# TASK-006: Browse trees and source code by ref + +Status: planned — depends on TASK-005 +Owner: Codex +Reviewer: Claude + +## Outcome + +A user can navigate directories and view source files at an explicit branch, tag, or commit without silently changing refs. + +## Acceptance criteria + +- [ ] Repository, selected ref, and current path remain visible. +- [ ] Directory navigation is deterministic and safely handles encoded names. +- [ ] Text files render with bounded memory use and copy support. +- [ ] Binary, Git LFS, submodule, unsupported encoding, and oversized-file states have explicit fallbacks. +- [ ] Loading, empty, offline, authentication-expired, not-found, and server-error states are localized and accessible. +- [ ] Tests cover ref preservation, path navigation, content classification, and size limits. diff --git a/tasks/007-history-and-changes.md b/tasks/007-history-and-changes.md new file mode 100644 index 0000000..506bf67 --- /dev/null +++ b/tasks/007-history-and-changes.md @@ -0,0 +1,18 @@ +# TASK-007: View commit history and changes + +Status: planned — depends on TASK-004 and TASK-006 +Owner: Codex +Reviewer: Claude + +## Outcome + +A user can inspect commit history, changed files, per-commit patches, and comparisons between refs in a read-only interface. + +## Acceptance criteria + +- [ ] Commit history supports pagination and clearly identifies the active ref. +- [ ] Commit details show metadata and changed files without exposing private data in logs. +- [ ] Added, modified, renamed, and deleted files are distinguished. +- [ ] Large or truncated diffs are explicitly identified and bounded for mobile rendering. +- [ ] Ref comparisons validate and encode both refs independently. +- [ ] Tests cover change mapping, rename/delete behavior, pagination, truncation, and error states.