implemented 03-gitea-client.md: added Gitea API client for commit statuses, status mapping, and extensive tests

This commit is contained in:
Michael Hoennig
2026-07-07 07:34:18 +02:00
parent a427665ce8
commit ae3ae7aa04
7 changed files with 528 additions and 1 deletions
+1
View File
@@ -23,6 +23,7 @@ repositories {
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework:spring-web")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("info.picocli:picocli-spring-boot-starter:4.7.6") implementation("info.picocli:picocli-spring-boot-starter:4.7.6")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml")
+13
View File
@@ -38,3 +38,16 @@ WireMock (already a test dependency, see `WireMockSmokeTest`):
- `./gradlew ktlintFormat` then `./gradlew build` is green. - `./gradlew ktlintFormat` then `./gradlew build` is green.
- No call path throws when Gitea is unconfigured or down. - No call path throws when Gitea is unconfigured or down.
## Execution Notes (2026-07-07)
Implemented as designed; deviations and decisions:
- Added `org.springframework:spring-web` as a dependency; `RestClient` lives there and `spring-boot-starter` alone does not provide it.
- `publishStatus` takes a `BuildStatus` and maps it internally instead of a raw Gitea state string.
The forward mapping never produces `error` because the `BuildStatus` enum is exhaustive; legacy emitted `error` only for unknown status strings.
- `readStatus` returns a sealed `GiteaStatusResult` (`Found`/`None`/`Disabled`/`Error`) so callers get explicit non-fatal error states instead of exceptions.
- `resolveUsername` only requires `gitea.baseUrl` and `git.token`; legacy gated it on the full status-enabled check including owner/repo, which the `/api/v1/user` endpoint does not need.
- Responses are read as strings and parsed with a dedicated Jackson `ObjectMapper` instead of RestClient message converters, keeping malformed-JSON handling explicit and independent of converter auto-detection.
- The legacy "Build status deleted" description marker is not ported; it belongs to the result-delete feature of later steps.
- No config changes were needed: `gitea.*` and `git.token` already exist in `GitTallyConfig`, the `init` templates, and `docs/configuration.md`.
+1 -1
View File
@@ -41,7 +41,7 @@ Foundation:
- [x] `01-build-state-domain.md` — build result domain model and persistent repository - [x] `01-build-state-domain.md` — build result domain model and persistent repository
- [x] `02-git-gateway.md` — full git access layer (fetch, branches, commits, checkout) - [x] `02-git-gateway.md` — full git access layer (fetch, branches, commits, checkout)
- [ ] `03-gitea-client.md` — Gitea API client for commit statuses - [x] `03-gitea-client.md` — Gitea API client for commit statuses
Core engine: Core engine:
@@ -0,0 +1,181 @@
package de.hoennig.gittally.gitea
import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import java.nio.file.Path
import java.nio.file.Paths
/** Outcome of [GiteaClient.readStatus]; errors are values, never exceptions. */
sealed interface GiteaStatusResult {
/** The newest status matching the configured context. */
data class Found(
val status: BuildStatus,
) : GiteaStatusResult
/** Gitea answered, but no status matches the configured context. */
data object None : GiteaStatusResult
/** The client is not configured, see [GiteaClient.isEnabled]. */
data object Disabled : GiteaStatusResult
/** Gitea is unreachable, answered with an error, or sent an unusable response. */
data class Error(
val message: String,
) : GiteaStatusResult
}
/**
* Client for the Gitea commit-status API.
* All methods are non-fatal: when Gitea is unconfigured, unreachable, or failing,
* they log and return a fallback value instead of throwing.
*/
@Service
class GiteaClient(
private val configLoader: ConfigLoader,
) {
private val log = LoggerFactory.getLogger(GiteaClient::class.java)
private val json =
ObjectMapper()
.registerKotlinModule()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun isEnabled(workingDir: Path = Paths.get(".")): Boolean = isEnabled(configLoader.load(workingDir))
/** Publishes a commit status for [sha]; returns false when disabled or the request failed. */
fun publishStatus(
sha: String,
status: BuildStatus,
description: String,
targetUrl: String? = null,
workingDir: Path = Paths.get("."),
): Boolean {
val config = configLoader.load(workingDir)
if (!isEnabled(config)) {
log.debug("Gitea status publishing is disabled; not publishing status for {}.", sha)
return false
}
val body = mutableMapOf<String, String>()
body["state"] = status.toGiteaState()
body["context"] = config.gitea.statusContext
body["description"] = description
if (!targetUrl.isNullOrBlank()) {
body["target_url"] = targetUrl
}
return try {
restClient(config)
.post()
.uri("/api/v1/repos/{owner}/{repo}/statuses/{sha}", config.gitea.owner, config.gitea.repo, sha)
.contentType(MediaType.APPLICATION_JSON)
.body(json.writeValueAsString(body))
.retrieve()
.toBodilessEntity()
true
} catch (e: RestClientException) {
log.warn("Could not publish Gitea status for {}: {}", sha, e.message)
false
}
}
/** Reads the newest commit status for [sha] matching the configured `gitea.statusContext`. */
fun readStatus(
sha: String,
workingDir: Path = Paths.get("."),
): GiteaStatusResult {
val config = configLoader.load(workingDir)
if (!isEnabled(config)) {
return GiteaStatusResult.Disabled
}
val body =
try {
restClient(config)
.get()
.uri(
"/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses?sort=recentupdate",
config.gitea.owner,
config.gitea.repo,
sha,
).retrieve()
.body(String::class.java)
} catch (e: RestClientException) {
log.warn("Could not read Gitea status for {}: {}", sha, e.message)
return GiteaStatusResult.Error("Gitea status request failed: ${e.message}")
} ?: return GiteaStatusResult.Error("Gitea status request returned an empty response")
val statuses =
try {
json.readValue(body, object : TypeReference<List<GiteaCommitStatus>>() {})
} catch (e: JacksonException) {
log.warn("Could not parse Gitea status response for {}: {}", sha, e.message)
return GiteaStatusResult.Error("Gitea status response is not valid JSON: ${e.message}")
}
// sort=recentupdate returns newest first, so the first context match is the current status
val newest =
statuses.firstOrNull { it.context == config.gitea.statusContext }
?: return GiteaStatusResult.None
val status =
buildStatusFromGiteaState(newest.state.orEmpty())
?: return GiteaStatusResult.Error("Gitea reported unknown status state '${newest.state}'")
return GiteaStatusResult.Found(status)
}
/**
* Resolves the username owning `git.token` via the Gitea user API;
* used as fallback for `git.account`. Returns null when unconfigured or on failure.
* Unlike [isEnabled] this only needs `gitea.baseUrl` and `git.token`.
*/
fun resolveUsername(workingDir: Path = Paths.get(".")): String? {
val config = configLoader.load(workingDir)
if (config.gitea.baseUrl.isBlank() || config.git.token.isBlank()) {
return null
}
return try {
restClient(config)
.get()
.uri("/api/v1/user")
.retrieve()
.body(String::class.java)
?.let {
json
.readTree(it)
.path("login")
.asText()
.ifEmpty { null }
}
} catch (e: RestClientException) {
log.warn("Could not resolve Gitea username: {}", e.message)
null
} catch (e: JacksonException) {
log.warn("Could not parse Gitea user response: {}", e.message)
null
}
}
private fun isEnabled(config: GitTallyConfig): Boolean =
config.gitea.baseUrl.isNotBlank() &&
config.gitea.owner.isNotBlank() &&
config.gitea.repo.isNotBlank() &&
config.git.token.isNotBlank()
private fun restClient(config: GitTallyConfig): RestClient =
RestClient
.builder()
.baseUrl(config.gitea.baseUrl.trimEnd('/'))
.defaultHeader("Authorization", "token ${config.git.token}")
.build()
private data class GiteaCommitStatus(
val context: String? = null,
val state: String? = null,
)
}
@@ -0,0 +1,23 @@
package de.hoennig.gittally.gitea
import de.hoennig.gittally.build.BuildStatus
/** Gitea commit-status state published for this build status. */
fun BuildStatus.toGiteaState(): String =
when (this) {
BuildStatus.SUCCESS -> "success"
BuildStatus.FAILED, BuildStatus.INTERRUPTED, BuildStatus.CANCELLED -> "failure"
BuildStatus.PENDING, BuildStatus.RUNNING -> "pending"
}
/**
* Build status for a Gitea commit-status state, or null for unknown states.
* The reverse mapping is lossy: Gitea only distinguishes success/failure/pending.
*/
fun buildStatusFromGiteaState(state: String): BuildStatus? =
when (state) {
"success" -> BuildStatus.SUCCESS
"failure", "error", "warning" -> BuildStatus.FAILED
"pending" -> BuildStatus.RUNNING
else -> null
}
@@ -0,0 +1,276 @@
package de.hoennig.gittally.gitea
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
import com.github.tomakehurst.wiremock.client.WireMock.equalTo
import com.github.tomakehurst.wiremock.client.WireMock.equalToJson
import com.github.tomakehurst.wiremock.client.WireMock.get
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.okJson
import com.github.tomakehurst.wiremock.client.WireMock.post
import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitConfig
import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.config.GiteaConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
class GiteaClientTest :
FunSpec({
val server = WireMockServer(options().dynamicPort())
val configLoader = mockk<ConfigLoader>()
val client = GiteaClient(configLoader)
val publishUrl = "/api/v1/repos/the-owner/the-repo/statuses/abc123"
val readUrl = "/api/v1/repos/the-owner/the-repo/commits/abc123/statuses?sort=recentupdate"
fun configure(
baseUrl: String = "http://localhost:${server.port()}",
owner: String = "the-owner",
repo: String = "the-repo",
token: String = "secret-token",
) {
every { configLoader.load(any()) } returns
GitTallyConfig(
git = GitConfig(token = token),
gitea = GiteaConfig(baseUrl = baseUrl, owner = owner, repo = repo, statusContext = "GitTally"),
)
}
beforeSpec { server.start() }
afterSpec { server.stop() }
beforeEach {
server.resetAll()
configure()
}
context("isEnabled") {
test("is true when baseUrl, owner, repo, and token are configured") {
client.isEnabled() shouldBe true
}
test("is false when any required value is missing") {
configure(baseUrl = "")
client.isEnabled() shouldBe false
configure(owner = "")
client.isEnabled() shouldBe false
configure(repo = "")
client.isEnabled() shouldBe false
configure(token = "")
client.isEnabled() shouldBe false
}
}
context("publishStatus") {
test("posts state, context, description, and target_url with token auth") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
val published =
client.publishStatus(
sha = "abc123",
status = BuildStatus.SUCCESS,
description = "Build succeeded",
targetUrl = "https://ci.example.org/branches/main/index.html",
)
published shouldBe true
server.verify(
postRequestedFor(urlEqualTo(publishUrl))
.withHeader("Authorization", equalTo("token secret-token"))
.withHeader("Content-Type", equalTo("application/json"))
.withRequestBody(
equalToJson(
"""
{
"state": "success",
"context": "GitTally",
"description": "Build succeeded",
"target_url": "https://ci.example.org/branches/main/index.html"
}
""".trimIndent(),
),
),
)
}
test("omits target_url when none is given") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
client.publishStatus("abc123", BuildStatus.RUNNING, "Build running") shouldBe true
server.verify(
postRequestedFor(urlEqualTo(publishUrl))
.withRequestBody(
equalToJson("""{"state": "pending", "context": "GitTally", "description": "Build running"}"""),
),
)
}
test("maps every build status to the documented Gitea state") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
val expectedStates =
mapOf(
BuildStatus.SUCCESS to "success",
BuildStatus.FAILED to "failure",
BuildStatus.INTERRUPTED to "failure",
BuildStatus.CANCELLED to "failure",
BuildStatus.PENDING to "pending",
BuildStatus.RUNNING to "pending",
)
expectedStates.forEach { (status, state) ->
server.resetRequests()
client.publishStatus("abc123", status, "d") shouldBe true
server.verify(
postRequestedFor(urlEqualTo(publishUrl))
.withRequestBody(equalToJson("""{"state": "$state", "context": "GitTally", "description": "d"}""")),
)
}
}
test("returns false without a request when disabled") {
configure(token = "")
client.publishStatus("abc123", BuildStatus.SUCCESS, "d") shouldBe false
server.findAll(postRequestedFor(urlEqualTo(publishUrl))).size shouldBe 0
}
test("returns false on HTTP errors instead of throwing") {
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(500)))
client.publishStatus("abc123", BuildStatus.SUCCESS, "d") shouldBe false
}
test("returns false when Gitea is unreachable instead of throwing") {
configure(baseUrl = "http://localhost:1")
client.publishStatus("abc123", BuildStatus.SUCCESS, "d") shouldBe false
}
}
context("readStatus") {
test("requests statuses sorted by recentupdate with token auth") {
server.stubFor(get(readUrl).willReturn(okJson("[]")))
client.readStatus("abc123")
server.verify(
getRequestedFor(urlEqualTo(readUrl))
.withHeader("Authorization", equalTo("token secret-token")),
)
}
test("returns the newest status matching the configured context") {
server.stubFor(
get(readUrl).willReturn(
okJson(
"""
[
{"context": "other-ci", "state": "failure"},
{"context": "GitTally", "state": "success", "description": "Build succeeded"},
{"context": "GitTally", "state": "pending"}
]
""".trimIndent(),
),
),
)
client.readStatus("abc123") shouldBe GiteaStatusResult.Found(BuildStatus.SUCCESS)
}
test("returns None when no status matches the context") {
server.stubFor(get(readUrl).willReturn(okJson("""[{"context": "other-ci", "state": "success"}]""")))
client.readStatus("abc123") shouldBe GiteaStatusResult.None
}
test("returns None for an empty status list") {
server.stubFor(get(readUrl).willReturn(okJson("[]")))
client.readStatus("abc123") shouldBe GiteaStatusResult.None
}
test("returns Error for malformed JSON instead of throwing") {
server.stubFor(get(readUrl).willReturn(okJson("""{"oops": "not a list"!!!""")))
client.readStatus("abc123").shouldBeInstanceOf<GiteaStatusResult.Error>()
}
test("returns Error for an unknown state value") {
server.stubFor(get(readUrl).willReturn(okJson("""[{"context": "GitTally", "state": "hovering"}]""")))
client.readStatus("abc123").shouldBeInstanceOf<GiteaStatusResult.Error>()
}
test("returns Error on HTTP 4xx/5xx instead of throwing") {
server.stubFor(get(readUrl).willReturn(aResponse().withStatus(404)))
client.readStatus("abc123").shouldBeInstanceOf<GiteaStatusResult.Error>()
server.stubFor(get(readUrl).willReturn(aResponse().withStatus(500)))
client.readStatus("abc123").shouldBeInstanceOf<GiteaStatusResult.Error>()
}
test("returns Error when Gitea is unreachable instead of throwing") {
configure(baseUrl = "http://localhost:1")
client.readStatus("abc123").shouldBeInstanceOf<GiteaStatusResult.Error>()
}
test("returns Disabled when unconfigured, without a request") {
configure(owner = "")
client.readStatus("abc123") shouldBe GiteaStatusResult.Disabled
server.findAll(getRequestedFor(urlEqualTo(readUrl))).size shouldBe 0
}
}
context("resolveUsername") {
test("returns the login of the token's user") {
server.stubFor(get("/api/v1/user").willReturn(okJson("""{"id": 42, "login": "the-user"}""")))
client.resolveUsername() shouldBe "the-user"
server.verify(
getRequestedFor(urlEqualTo("/api/v1/user"))
.withHeader("Authorization", equalTo("token secret-token")),
)
}
test("only needs baseUrl and token, not owner/repo") {
configure(owner = "", repo = "")
server.stubFor(get("/api/v1/user").willReturn(okJson("""{"login": "the-user"}""")))
client.resolveUsername() shouldBe "the-user"
}
test("returns null when baseUrl or token is missing") {
configure(baseUrl = "")
client.resolveUsername().shouldBeNull()
configure(token = "")
client.resolveUsername().shouldBeNull()
}
test("returns null on HTTP errors, missing login, or malformed JSON instead of throwing") {
server.stubFor(get("/api/v1/user").willReturn(aResponse().withStatus(401)))
client.resolveUsername().shouldBeNull()
server.stubFor(get("/api/v1/user").willReturn(okJson("""{"id": 42}""")))
client.resolveUsername().shouldBeNull()
server.stubFor(get("/api/v1/user").willReturn(okJson("not json")))
client.resolveUsername().shouldBeNull()
}
}
})
@@ -0,0 +1,33 @@
package de.hoennig.gittally.gitea
import de.hoennig.gittally.build.BuildStatus
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
class GiteaStateMappingTest :
FunSpec({
test("maps each build status to its Gitea state") {
BuildStatus.SUCCESS.toGiteaState() shouldBe "success"
BuildStatus.FAILED.toGiteaState() shouldBe "failure"
BuildStatus.INTERRUPTED.toGiteaState() shouldBe "failure"
BuildStatus.CANCELLED.toGiteaState() shouldBe "failure"
BuildStatus.PENDING.toGiteaState() shouldBe "pending"
BuildStatus.RUNNING.toGiteaState() shouldBe "pending"
}
test("maps Gitea states back to build statuses") {
buildStatusFromGiteaState("success") shouldBe BuildStatus.SUCCESS
buildStatusFromGiteaState("failure") shouldBe BuildStatus.FAILED
buildStatusFromGiteaState("error") shouldBe BuildStatus.FAILED
buildStatusFromGiteaState("warning") shouldBe BuildStatus.FAILED
buildStatusFromGiteaState("pending") shouldBe BuildStatus.RUNNING
}
test("maps unknown Gitea states to null") {
buildStatusFromGiteaState("").shouldBeNull()
buildStatusFromGiteaState("deleted").shouldBeNull()
buildStatusFromGiteaState("SUCCESS").shouldBeNull()
}
})