Add repository browsing domain foundation
This commit is contained in:
parent
75aa5f83e5
commit
a2cc7d4ca3
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<T> private constructor(
|
||||
val items: List<T>,
|
||||
val nextPage: Int?,
|
||||
) {
|
||||
companion object {
|
||||
fun <T> of(items: Collection<T>, nextPage: Int?): Page<T> {
|
||||
require(nextPage == null || nextPage > 0) { "Next page must be positive" }
|
||||
return Page(Collections.unmodifiableList(ArrayList(items)), nextPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<RepositorySummary>
|
||||
|
||||
suspend fun listBranches(
|
||||
repository: RepositoryCoordinates,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<BranchSummary>
|
||||
|
||||
suspend fun listTree(
|
||||
repository: RepositoryCoordinates,
|
||||
ref: RepositoryRef,
|
||||
path: RepositoryPath,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<TreeEntry>
|
||||
|
||||
suspend fun getFile(
|
||||
repository: RepositoryCoordinates,
|
||||
ref: RepositoryRef,
|
||||
path: RepositoryPath,
|
||||
): SourceFile
|
||||
|
||||
suspend fun listCommits(
|
||||
repository: RepositoryCoordinates,
|
||||
ref: RepositoryRef,
|
||||
path: RepositoryPath?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<CommitSummary>
|
||||
}
|
||||
@ -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<RepositorySummary> {
|
||||
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<BranchSummary> {
|
||||
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<TreeEntry> {
|
||||
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<CommitSummary> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">UBGitea</string>
|
||||
<string name="home_placeholder">회사 Gitea를 위한 Android 앱을 준비하고 있습니다.</string>
|
||||
<string name="home_read_only_placeholder">저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다.</string>
|
||||
</resources>
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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<String>).add("third")
|
||||
}
|
||||
assertEquals(listOf("first", "second"), page.items)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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<String>(), browser.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidPageSizeDoesNotDelegate() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
runSuspend {
|
||||
BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 1, 101)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(emptyList<String>(), browser.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootSourceFileDoesNotDelegate() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
runSuspend { LoadSourceFileUseCase(browser)(coordinates, ref, RepositoryPath.Root) }
|
||||
}
|
||||
|
||||
assertEquals(emptyList<String>(), 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<String>()
|
||||
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
|
||||
calls += "repositories:$page:$pageSize"
|
||||
return Page.of(emptyList(), null)
|
||||
}
|
||||
|
||||
override suspend fun listBranches(
|
||||
repository: RepositoryCoordinates,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<BranchSummary> {
|
||||
calls += "branches:$page:$pageSize"
|
||||
return Page.of(emptyList(), null)
|
||||
}
|
||||
|
||||
override suspend fun listTree(
|
||||
repository: RepositoryCoordinates,
|
||||
ref: RepositoryRef,
|
||||
path: RepositoryPath,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<TreeEntry> {
|
||||
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<CommitSummary> {
|
||||
calls += "commits:$page:$pageSize"
|
||||
return Page.of(emptyList(), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> runSuspend(block: suspend () -> T): T {
|
||||
var outcome: Result<T>? = null
|
||||
block.startCoroutine(
|
||||
object : Continuation<T> {
|
||||
override val context = EmptyCoroutineContext
|
||||
|
||||
override fun resumeWith(result: Result<T>) {
|
||||
outcome = result
|
||||
}
|
||||
},
|
||||
)
|
||||
return checkNotNull(outcome).getOrThrow()
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
55
tasks/003-repository-browser-foundation.md
Normal file
55
tasks/003-repository-browser-foundation.md
Normal file
@ -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.
|
||||
27
tasks/004-gitea-api-client.md
Normal file
27
tasks/004-gitea-api-client.md
Normal file
@ -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
|
||||
```
|
||||
17
tasks/005-repository-branch-ui.md
Normal file
17
tasks/005-repository-branch-ui.md
Normal file
@ -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.
|
||||
18
tasks/006-code-browser.md
Normal file
18
tasks/006-code-browser.md
Normal file
@ -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.
|
||||
18
tasks/007-history-and-changes.md
Normal file
18
tasks/007-history-and-changes.md
Normal file
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user