feat: add read-only repository browsing API
This commit is contained in:
parent
ed2121be76
commit
8a47d30ab5
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
25
docs/decisions/003-http-client.md
Normal file
25
docs/decisions/003-http-client.md
Normal 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.
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user