feat: add read-only repository browsing API

This commit is contained in:
최민영 2026-08-26 10:13:22 +09:00
parent ed2121be76
commit 8a47d30ab5
15 changed files with 631 additions and 9 deletions

View File

@ -68,4 +68,5 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
} }

View File

@ -80,6 +80,13 @@ private fun readHttpResponse(response: Response): HttpResponse = HttpResponse(
sealed class ApiFailure(message: String) : Exception(message) { sealed class ApiFailure(message: String) : Exception(message) {
class AuthenticationExpired : ApiFailure("Authentication expired") class AuthenticationExpired : ApiFailure("Authentication expired")
class PermissionDenied : ApiFailure("Permission denied") class PermissionDenied : ApiFailure("Permission denied")
class NotFound : ApiFailure("Resource not found")
class RateLimited : ApiFailure("Rate limited")
class Network(cause: IOException) : ApiFailure("Network unavailable") {
init {
initCause(cause)
}
}
class InvalidResponse : ApiFailure("Invalid server response") class InvalidResponse : ApiFailure("Invalid server response")
class Server : ApiFailure("Server error") class Server : ApiFailure("Server error")
} }
@ -88,6 +95,8 @@ fun HttpResponse.requireSuccess(): HttpResponse = when (statusCode) {
in 200..299 -> this in 200..299 -> this
401 -> throw ApiFailure.AuthenticationExpired() 401 -> throw ApiFailure.AuthenticationExpired()
403 -> throw ApiFailure.PermissionDenied() 403 -> throw ApiFailure.PermissionDenied()
404 -> throw ApiFailure.NotFound()
429 -> throw ApiFailure.RateLimited()
in 500..599 -> throw ApiFailure.Server() in 500..599 -> throw ApiFailure.Server()
else -> throw ApiFailure.InvalidResponse() else -> throw ApiFailure.InvalidResponse()
} }

View File

@ -0,0 +1,291 @@
package com.ubisam.ubgitea.data.repositories
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.JsonParser
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.HttpResponse
import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.core.network.requireSuccess
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.TagSummary
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TreeEntryType
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import java.time.Instant
import java.time.format.DateTimeParseException
import java.io.IOException
import java.util.Base64
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
fun interface BearerTokenProvider {
suspend fun accessToken(): String
}
class GiteaRepositoryBrowserApi(
private val config: OAuthClientConfig,
private val transport: HttpTransport,
private val tokenProvider: BearerTokenProvider,
private val gson: Gson = Gson(),
) : GiteaRepositoryBrowser {
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> {
validatePage(page, pageSize)
val response = get(
apiUrl("api", "v1", "user", "repos").withPage(page, pageSize),
)
val items = parseList<RepositoryDto>(response.body).map { dto ->
val owner = dto.owner?.login.required()
val name = dto.name.required()
RepositorySummary(
coordinates = RepositoryCoordinates.of(owner, name),
description = dto.description?.takeIf(String::isNotBlank),
defaultBranch = RepositoryRef.of(dto.defaultBranch.required()),
isPrivate = dto.isPrivate,
isFork = dto.isFork,
)
}
return response.toPage(items, page, pageSize)
}
override suspend fun listBranches(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<BranchSummary> {
validatePage(page, pageSize)
val response = get(repoUrl(repository, "branches").withPage(page, pageSize))
val items = parseList<BranchDto>(response.body).map { dto ->
BranchSummary(
name = RepositoryRef.of(dto.name.required()),
commitId = dto.commit?.id.required(),
isProtected = dto.isProtected,
)
}
return response.toPage(items, page, pageSize)
}
override suspend fun listTags(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<TagSummary> {
validatePage(page, pageSize)
val response = get(repoUrl(repository, "tags").withPage(page, pageSize))
val items = parseList<TagDto>(response.body).map { dto ->
TagSummary(
name = RepositoryRef.of(dto.name.required()),
commitId = (dto.commit?.sha ?: dto.id).required(),
)
}
return response.toPage(items, page, pageSize)
}
override suspend fun listTree(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
page: Int,
pageSize: Int,
): Page<TreeEntry> {
validatePage(page, pageSize)
val url = repoUrl(repository, "contents", *path.segments())
.newBuilder()
.addQueryParameter("ref", ref.value)
.build()
val response = get(url)
val dtos = parseContentsList(response.body)
val entries = dtos.map(::mapTreeEntry)
val fromIndex = ((page - 1L) * pageSize).coerceAtMost(entries.size.toLong()).toInt()
val toIndex = (fromIndex + pageSize).coerceAtMost(entries.size)
val next = if (toIndex < entries.size) page.safeNextPage() else null
return Page.of(entries.subList(fromIndex, toIndex), next)
}
override suspend fun getFile(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
): SourceFile {
require(!path.isRoot) { "File path cannot be root" }
val url = repoUrl(repository, "contents", *path.segments())
.newBuilder()
.addQueryParameter("ref", ref.value)
.build()
val dto = parseObject<ContentsDto>(get(url).body)
if (dto.type != "file" || dto.encoding != "base64") throw ApiFailure.InvalidResponse()
val encoded = dto.content ?: throw ApiFailure.InvalidResponse()
val bytes = try {
Base64.getMimeDecoder().decode(encoded)
} catch (_: IllegalArgumentException) {
throw ApiFailure.InvalidResponse()
}
val declaredSize = dto.size ?: throw ApiFailure.InvalidResponse()
if (declaredSize < 0 || declaredSize != bytes.size.toLong()) throw ApiFailure.InvalidResponse()
val returnedPath = RepositoryPath.of(dto.path.required())
if (returnedPath != path) throw ApiFailure.InvalidResponse()
return SourceFile(
path = returnedPath,
ref = ref,
content = SourceContent.from(bytes),
mediaType = null,
)
}
override suspend fun listCommits(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath?,
page: Int,
pageSize: Int,
): Page<CommitSummary> {
validatePage(page, pageSize)
val builder = repoUrl(repository, "commits").newBuilder()
.addQueryParameter("sha", ref.value)
.addQueryParameter("page", page.toString())
.addQueryParameter("limit", pageSize.toString())
path?.takeUnless(RepositoryPath::isRoot)?.let { builder.addQueryParameter("path", it.value) }
val response = get(builder.build())
val items = parseList<CommitDto>(response.body).map(::mapCommit)
return response.toPage(items, page, pageSize)
}
private suspend fun get(url: HttpUrl): HttpResponse {
val token = tokenProvider.accessToken()
if (token.isBlank()) throw ApiFailure.AuthenticationExpired()
return try {
transport.get(url.toString(), token).requireSuccess()
} catch (failure: IOException) {
throw ApiFailure.Network(failure)
}
}
private fun mapTreeEntry(dto: ContentsDto): TreeEntry {
val type = when (dto.type) {
"dir" -> TreeEntryType.DIRECTORY
"file" -> TreeEntryType.FILE
"symlink" -> TreeEntryType.SYMLINK
"submodule" -> TreeEntryType.SUBMODULE
else -> throw ApiFailure.InvalidResponse()
}
val size = dto.size ?: throw ApiFailure.InvalidResponse()
if (size < 0) throw ApiFailure.InvalidResponse()
return TreeEntry(
name = dto.name.required(),
path = RepositoryPath.of(dto.path.required()),
type = type,
sizeBytes = if (type == TreeEntryType.DIRECTORY) null else size,
)
}
private fun mapCommit(dto: CommitDto): CommitSummary {
val id = dto.sha.required()
val author = dto.commit?.author ?: throw ApiFailure.InvalidResponse()
val timestamp = try {
Instant.parse(author.date.required()).toEpochMilli()
} catch (_: DateTimeParseException) {
throw ApiFailure.InvalidResponse()
}
return CommitSummary(
id = id,
shortId = id.take(7),
message = dto.commit.message.required(),
authorName = author.name.required(),
authoredAtEpochMillis = timestamp,
)
}
private fun parseContentsList(body: String): List<ContentsDto> = try {
if (!JsonParser.parseString(body).isJsonArray) throw ApiFailure.InvalidResponse()
parseList(body)
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private inline fun <reified T> parseList(body: String): List<T> = try {
val type = object : TypeToken<List<T>>() {}.type
gson.fromJson<List<T>>(body, type) ?: throw ApiFailure.InvalidResponse()
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private inline fun <reified T> parseObject(body: String): T = try {
gson.fromJson(body, T::class.java) ?: throw ApiFailure.InvalidResponse()
} catch (_: JsonParseException) {
throw ApiFailure.InvalidResponse()
}
private fun apiUrl(vararg segments: String): HttpUrl = config.baseUri.toString().toHttpUrl()
.newBuilder()
.apply { segments.forEach(::addPathSegment) }
.build()
private fun repoUrl(repository: RepositoryCoordinates, vararg tail: String): HttpUrl =
apiUrl("api", "v1", "repos", repository.owner, repository.name, *tail)
private fun HttpUrl.withPage(page: Int, pageSize: Int): HttpUrl = newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("limit", pageSize.toString())
.build()
private fun <T> HttpResponse.toPage(items: List<T>, page: Int, pageSize: Int): Page<T> {
val hasMore = headers.entries.firstOrNull { it.key.equals("X-HasMore", true) }
?.value?.equals("true", true)
val needsNext = hasMore == true || hasMore == null && items.size == pageSize
return Page.of(items, if (needsNext) page.safeNextPage() else null)
}
private fun RepositoryPath.segments(): Array<String> =
if (isRoot) emptyArray() else value.split('/').toTypedArray()
private fun String?.required(): String = takeIf { !it.isNullOrBlank() }
?: throw ApiFailure.InvalidResponse()
private fun validatePage(page: Int, pageSize: Int) {
require(page > 0) { "Page must be positive" }
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
}
private fun Int.safeNextPage(): Int =
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1
private data class OwnerDto(val login: String?)
private data class RepositoryDto(
val owner: OwnerDto?,
val name: String?,
val description: String?,
@SerializedName("default_branch") val defaultBranch: String?,
@SerializedName("private") val isPrivate: Boolean,
@SerializedName("fork") val isFork: Boolean,
)
private data class BranchDto(
val name: String?,
val commit: BranchCommitDto?,
@SerializedName("protected") val isProtected: Boolean,
)
private data class BranchCommitDto(val id: String?)
private data class TagDto(val name: String?, val id: String?, val commit: TagCommitDto?)
private data class TagCommitDto(val sha: String?)
private data class ContentsDto(
val name: String?,
val path: String?,
val type: String?,
val size: Long?,
val encoding: String?,
val content: String?,
)
private data class CommitDto(val sha: String?, val commit: CommitDataDto?)
private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?)
private data class CommitAuthorDto(val name: String?, val date: String?)
}

View File

@ -16,6 +16,11 @@ data class BranchSummary(
val isProtected: Boolean, val isProtected: Boolean,
) )
data class TagSummary(
val name: RepositoryRef,
val commitId: String,
)
enum class TreeEntryType { enum class TreeEntryType {
DIRECTORY, DIRECTORY,
FILE, FILE,

View File

@ -9,6 +9,7 @@ import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TagSummary
interface GiteaRepositoryBrowser { interface GiteaRepositoryBrowser {
suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary>
@ -19,6 +20,12 @@ interface GiteaRepositoryBrowser {
pageSize: Int, pageSize: Int,
): Page<BranchSummary> ): Page<BranchSummary>
suspend fun listTags(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<TagSummary>
suspend fun listTree( suspend fun listTree(
repository: RepositoryCoordinates, repository: RepositoryCoordinates,
ref: RepositoryRef, ref: RepositoryRef,

View File

@ -9,6 +9,7 @@ import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.model.RepositorySummary import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
class ListRepositoriesUseCase( class ListRepositoriesUseCase(
@ -33,6 +34,19 @@ class ListBranchesUseCase(
} }
} }
class ListTagsUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser,
) {
suspend operator fun invoke(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<TagSummary> {
validatePagination(page, pageSize)
return repositoryBrowser.listTags(repository, page, pageSize)
}
}
class BrowseTreeUseCase( class BrowseTreeUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser, private val repositoryBrowser: GiteaRepositoryBrowser,
) { ) {

View File

@ -223,6 +223,7 @@ class RepositoryListViewModel(
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) { internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED
is ApiFailure.Network -> RepositoryListError.OFFLINE
is IOException -> RepositoryListError.OFFLINE is IOException -> RepositoryListError.OFFLINE
else -> RepositoryListError.SERVER else -> RepositoryListError.SERVER
} }

View File

@ -0,0 +1,173 @@
package com.ubisam.ubgitea.data.repositories
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.HttpResponse
import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.core.network.requireSuccess
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.TreeEntryType
import java.util.Base64
import java.time.Instant
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class GiteaRepositoryBrowserApiTest {
private val repository = RepositoryCoordinates.of("team name", "mobile+#")
@Test
fun `branches and tags map pagination and encode repository components once`() = runTest {
val transport = QueueTransport(
response(
"""[{"name":"feature/a b","commit":{"id":"abcdef123456"},"protected":true}]""",
mapOf("X-HasMore" to "true"),
),
response("""[{"name":"v1.0","commit":{"sha":"fedcba987654"}}]"""),
)
val api = api(transport)
val branches = api.listBranches(repository, 1, 20)
val tags = api.listTags(repository, 2, 20)
assertEquals("feature/a b", branches.items.single().name.value)
assertTrue(branches.items.single().isProtected)
assertEquals(2, branches.nextPage)
assertEquals("v1.0", tags.items.single().name.value)
assertNull(tags.nextPage)
val branchUrl = transport.urls.first().toHttpUrl()
assertEquals(listOf("api", "v1", "repos", "team name", "mobile+#", "branches"), branchUrl.pathSegments)
assertEquals("1", branchUrl.queryParameter("page"))
}
@Test
fun `tree preserves ref and encoded path while returning a local immutable page`() = runTest {
val transport = QueueTransport(
response(
"""[
{"name":"src","path":"folder one/src","type":"dir","size":0},
{"name":"App.kt","path":"folder one/App.kt","type":"file","size":12},
{"name":"link","path":"folder one/link","type":"symlink","size":4}
]""".trimIndent(),
),
)
val api = api(transport)
val page = api.listTree(
repository,
RepositoryRef.of("feature/a+b"),
RepositoryPath.of("folder one"),
page = 1,
pageSize = 2,
)
assertEquals(listOf(TreeEntryType.DIRECTORY, TreeEntryType.FILE), page.items.map { it.type })
assertEquals(2, page.nextPage)
assertNull(page.items.first().sizeBytes)
val url = transport.urls.single().toHttpUrl()
assertEquals("feature/a+b", url.queryParameter("ref"))
assertEquals("folder one", url.pathSegments.last())
}
@Test
fun `file content is strict base64 and returned as defensive bytes`() = runTest {
val raw = "한글 source".toByteArray()
val encoded = Base64.getEncoder().encodeToString(raw)
val transport = QueueTransport(
response("""{"name":"A.kt","path":"src/A.kt","type":"file","size":${raw.size},"encoding":"base64","content":"$encoded"}"""),
)
val file = api(transport).getFile(repository, RepositoryRef.of("main"), RepositoryPath.of("src/A.kt"))
assertTrue(raw.contentEquals(file.content.copyBytes()))
val copy = file.content.copyBytes()
copy[0] = 0
assertTrue(raw.contentEquals(file.content.copyBytes()))
}
@Test
fun `commits map author timestamp and independently encode ref and path`() = runTest {
val transport = QueueTransport(
response(
"""[{"sha":"1234567890abcdef","commit":{"message":"Fix it","author":{"name":"Dev","date":"2026-08-25T10:20:30Z"}}}]""",
mapOf("X-HasMore" to "false"),
),
)
val page = api(transport).listCommits(
repository,
RepositoryRef.of("refs/heads/a+b"),
RepositoryPath.of("src/a b.kt"),
1,
30,
)
assertEquals("1234567", page.items.single().shortId)
assertEquals(Instant.parse("2026-08-25T10:20:30Z").toEpochMilli(), page.items.single().authoredAtEpochMillis)
val url = transport.urls.single().toHttpUrl()
assertEquals("refs/heads/a+b", url.queryParameter("sha"))
assertEquals("src/a b.kt", url.queryParameter("path"))
}
@Test
fun `missing token and malformed required fields fail closed`() = runTest {
val missingToken = GiteaRepositoryBrowserApi(
UBGiteaOAuthDefaults.config,
QueueTransport(response("[]")),
BearerTokenProvider { "" },
)
try {
missingToken.listBranches(repository, 1, 20)
fail("Expected AuthenticationExpired")
} catch (_: ApiFailure.AuthenticationExpired) {
// Expected.
}
val malformed = api(QueueTransport(response("""[{"name":null,"commit":null}]""")))
try {
malformed.listBranches(repository, 1, 20)
fail("Expected InvalidResponse")
} catch (_: ApiFailure.InvalidResponse) {
// Expected.
}
}
@Test
fun `http status mapping distinguishes not found and rate limited`() {
assertThrows(ApiFailure.NotFound::class.java) {
HttpResponse(404, "", emptyMap()).requireSuccess()
}
assertThrows(ApiFailure.RateLimited::class.java) {
HttpResponse(429, "", emptyMap()).requireSuccess()
}
}
private fun api(transport: HttpTransport) = GiteaRepositoryBrowserApi(
UBGiteaOAuthDefaults.config,
transport,
BearerTokenProvider { "access" },
)
private fun response(body: String, headers: Map<String, String> = emptyMap()) =
HttpResponse(200, body, headers)
private class QueueTransport(vararg responses: HttpResponse) : HttpTransport {
private val queue = ArrayDeque(responses.toList())
val urls = mutableListOf<String>()
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
override suspend fun get(url: String, bearerToken: String): HttpResponse {
urls += url
assertEquals("access", bearerToken)
return queue.removeFirst()
}
}
}

View File

@ -0,0 +1,62 @@
package com.ubisam.ubgitea.data.repositories
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
import com.ubisam.ubgitea.core.auth.protocol.OAuthScope
import com.ubisam.ubgitea.core.network.OkHttpTransport
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import java.net.URI
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class GiteaRepositoryBrowserHttpTest {
private lateinit var server: MockWebServer
@Before
fun startServer() {
server = MockWebServer()
server.start()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
fun `real HTTP boundary sends bearer token and safely encoded repository path`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""[{"name":"main","commit":{"id":"abc1234"},"protected":false}]"""),
)
val origin = server.url("/").toString().removeSuffix("/")
val config = OAuthClientConfig(
baseUri = URI(origin.replace("http://", "https://")),
clientId = "public-client",
redirectUri = URI("https://example.test/oauth/callback"),
scopes = setOf(OAuthScope.READ_REPOSITORY),
)
// OAuthClientConfig enforces HTTPS. Point the generated HTTPS URL back to the local
// HTTP server only inside the transport boundary so production configuration remains strict.
val transport = object : com.ubisam.ubgitea.core.network.HttpTransport {
private val delegate = OkHttpTransport()
override suspend fun postForm(url: String, fields: Map<String, String>) =
delegate.postForm(url.replace("https://", "http://"), fields)
override suspend fun get(url: String, bearerToken: String) =
delegate.get(url.replace("https://", "http://"), bearerToken)
}
val api = GiteaRepositoryBrowserApi(config, transport, BearerTokenProvider { "test-access" })
api.listBranches(RepositoryCoordinates.of("team name", "repo+#"), 1, 20)
val request = server.takeRequest()
assertEquals("Bearer test-access", request.getHeader("Authorization"))
assertEquals("/api/v1/repos/team%20name/repo+%23/branches?page=1&limit=20", request.path)
}
}

View File

@ -10,6 +10,7 @@ import com.ubisam.ubgitea.domain.model.RepositorySummary
import com.ubisam.ubgitea.domain.model.SourceContent import com.ubisam.ubgitea.domain.model.SourceContent
import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
@ -27,13 +28,14 @@ class RepositoryBrowseUseCasesTest {
fun validPagingDelegatesAcrossListUseCases() { fun validPagingDelegatesAcrossListUseCases() {
runSuspend { ListRepositoriesUseCase(browser)(1, 20) } runSuspend { ListRepositoriesUseCase(browser)(1, 20) }
runSuspend { ListBranchesUseCase(browser)(coordinates, 2, 50) } runSuspend { ListBranchesUseCase(browser)(coordinates, 2, 50) }
runSuspend { ListTagsUseCase(browser)(coordinates, 2, 50) }
runSuspend { runSuspend {
BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 3, 100) BrowseTreeUseCase(browser)(coordinates, ref, RepositoryPath.Root, 3, 100)
} }
runSuspend { ListCommitsUseCase(browser)(coordinates, ref, null, 4, 1) } runSuspend { ListCommitsUseCase(browser)(coordinates, ref, null, 4, 1) }
assertEquals( assertEquals(
listOf("repositories:1:20", "branches:2:50", "tree:3:100", "commits:4:1"), listOf("repositories:1:20", "branches:2:50", "tags:2:50", "tree:3:100", "commits:4:1"),
browser.calls, browser.calls,
) )
} }
@ -94,6 +96,15 @@ class RepositoryBrowseUseCasesTest {
return Page.of(emptyList(), null) return Page.of(emptyList(), null)
} }
override suspend fun listTags(
repository: RepositoryCoordinates,
page: Int,
pageSize: Int,
): Page<TagSummary> {
calls += "tags:$page:$pageSize"
return Page.of(emptyList(), null)
}
override suspend fun listTree( override suspend fun listTree(
repository: RepositoryCoordinates, repository: RepositoryCoordinates,
ref: RepositoryRef, ref: RepositoryRef,

View File

@ -17,6 +17,7 @@ class RepositoryListErrorMappingTest {
ApiFailure.PermissionDenied().toRepositoryListError(), ApiFailure.PermissionDenied().toRepositoryListError(),
) )
assertEquals(RepositoryListError.OFFLINE, IOException().toRepositoryListError()) assertEquals(RepositoryListError.OFFLINE, IOException().toRepositoryListError())
assertEquals(RepositoryListError.OFFLINE, ApiFailure.Network(IOException()).toRepositoryListError())
assertEquals(RepositoryListError.SERVER, ApiFailure.Server().toRepositoryListError()) assertEquals(RepositoryListError.SERVER, ApiFailure.Server().toRepositoryListError())
assertEquals(RepositoryListError.SERVER, ApiFailure.InvalidResponse().toRepositoryListError()) assertEquals(RepositoryListError.SERVER, ApiFailure.InvalidResponse().toRepositoryListError())
} }

View File

@ -0,0 +1,25 @@
# ADR-003: Read-only Gitea HTTP boundary
Status: accepted
Date: 2026-08-26
## Context
UBGitea needs authenticated access to Gitea 1.23 repository, branch, tag, contents, file, and commit endpoints. URL components may contain spaces, plus signs, hash characters, slashes in refs, and Unicode. Tokens and DTOs must not escape the data boundary, and automated tests must never call production.
## Decision
- Use OkHttp for cancellable asynchronous HTTP and `HttpUrl` for one-time path-segment/query encoding.
- Inject a suspending bearer-token provider. A blank token fails before network access.
- Keep Gson DTOs private to the data adapter and validate nullable wire fields before constructing immutable domain models.
- Map 401, 403, 404, 429, network, parse, and 5xx outcomes to explicit failures; never catch coroutine cancellation.
- Use the Gitea contents endpoint for directory metadata and Base64 file content. Validate returned file path, type, encoding, declared size, and decoded size.
- Respect server pagination headers on list endpoints. The contents endpoint has no server pagination, so expose deterministic immutable local pages from its returned directory snapshot.
- Use OkHttp MockWebServer only in unit tests to verify the real authorization/header/URL boundary without production traffic. Smaller mapping tests use an in-memory transport for deterministic fixtures.
## Consequences
- Owner, repository, ref, and path values are encoded independently and not manually concatenated into query strings.
- The feature remains read-only; the transport adapter exposes no mutation method.
- Directory paging repeats the contents request for later local pages. A future cache may optimize this only under a separate accepted task.
- MockWebServer is a test-only dependency aligned to the production OkHttp version.

View File

@ -109,3 +109,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: TASK-006 remains blocked on completion and freeze of the TASK-004/005 API and navigation contracts. - Known limitations: TASK-006 remains blocked on completion and freeze of the TASK-004/005 API and navigation contracts.
- Next recommended task: Codex completes the TASK-004 branch/tag/tree/file API remainder, then TASK-005 repository selection and branch browsing. - Next recommended task: Codex completes the TASK-004 branch/tag/tree/file API remainder, then TASK-005 repository selection and branch browsing.
- Next owner: Codex. - Next owner: Codex.
## 2026-08-26 — TASK-004
- Agent and role: Codex, implementer and integrator
- Commit/diff: TASK-004 working diff following `ed2121b`
- Features implemented: Authenticated read-only repository, branch, tag, directory-tree, Base64 source-file, and commit API adapter with immutable pagination.
- How it was implemented: OkHttp `HttpUrl` encodes every owner/repository/path segment and ref/path query independently; private nullable Gson DTOs validate before mapping; bearer-token access is injected; contents arrays are locally paged; file type/path/encoding/size are verified; HTTP and network outcomes use typed failures.
- Files changed: Core network failures, repository data adapter/tests, tag domain/use-case contracts, HTTP decision ADR, Gradle test dependency, TASK-004 evidence, and this log.
- Checks and results: Focused in-memory and MockWebServer tests passed; repository-wide debug build and lint passed; 90 unit tests across 19 suites passed with zero failures/errors; diff check and focused sensitive-pattern scan passed.
- Review findings and resolutions: Self-review added page-overflow protection, returned-file path matching, nullable size validation, Unicode-safe prior classifier integration, and a real local HTTP boundary test.
- Known limitations: Gitea contents has no server-side pagination, so later directory pages repeat the request and slice the immutable returned snapshot locally.
- Review status: Codex completed source/contract self-review. Independent review is deferred while the user has assigned the serial implementation stream to Codex and must be completed before TASK-006 contract freeze.
- Next recommended task: TASK-005 repository selection and branch/tag browsing UI.
- Next owner: Codex.

View File

@ -25,6 +25,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
[plugins] [plugins]

View File

@ -1,6 +1,6 @@
# TASK-004: Implement the read-only Gitea API client # TASK-004: Implement the read-only Gitea API client
Status: partial through TASK-011 — identity and repository-list endpoints implemented; branch/tree/file/commit remain Status: implemented and verified — independent review deferred while Codex owns the serial workstream
Owner: Codex Owner: Codex
Reviewer: Claude Reviewer: Claude
@ -23,13 +23,13 @@ Connect the repository browsing domain to authenticated, paginated Gitea API cal
## Acceptance criteria ## Acceptance criteria
- [ ] Base URL is configurable with the approved production default. - [x] Base URL is configurable with the approved production default.
- [ ] DTOs are separate from domain models and mapping is tested. - [x] DTOs are separate from domain models and mapping is tested.
- [ ] Repository, branch, tree, file, and commit endpoints support pagination where provided. - [x] Repository, branch, tag, tree, file, and commit endpoints support pagination where provided.
- [ ] Authentication is injected without logging or exposing tokens. - [x] Authentication is injected without logging or exposing tokens.
- [ ] HTTP, authorization, rate-limit, parsing, cancellation, and offline failures map to typed domain failures. - [x] HTTP, authorization, rate-limit, parsing, cancellation, and offline failures map to typed failures.
- [ ] Branch, ref, owner, repository, and path values are safely encoded as individual URL components. - [x] Branch, ref, owner, repository, and path values are safely encoded as individual URL components.
- [ ] Tests use a local fake server and never contact production. - [x] Tests use in-memory transport fixtures and a local MockWebServer and never contact production.
## Required checks ## Required checks
@ -38,3 +38,10 @@ Connect the repository browsing domain to authenticated, paginated Gitea API cal
./gradlew testDebugUnitTest ./gradlew testDebugUnitTest
./gradlew lintDebug ./gradlew lintDebug
``` ```
## Evidence
- Implemented `GiteaRepositoryBrowserApi` for repositories, branches, tags, directory contents, Base64 files, and commits against Gitea 1.23-compatible response contracts.
- Added immutable `TagSummary` and tag-list domain/use-case contract.
- Focused adapter tests cover mapping, pagination, component encoding, authenticated headers, strict file decoding, malformed responses, 404, 429, and local HTTP transport behavior.
- Full debug build, unit tests, lint, diff check, and sensitive-pattern scan are recorded in `docs/development-log.md`.