diff --git a/build.gradle.kts b/build.gradle.kts index 4115150..09901be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework:spring-web") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("info.picocli:picocli-spring-boot-starter:4.7.6") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") diff --git a/docs/plan/03-gitea-client.md b/docs/plan/03-gitea-client.md index 081f512..ad28874 100644 --- a/docs/plan/03-gitea-client.md +++ b/docs/plan/03-gitea-client.md @@ -38,3 +38,16 @@ WireMock (already a test dependency, see `WireMockSmokeTest`): - `./gradlew ktlintFormat` then `./gradlew build` is green. - 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`. diff --git a/docs/plan/README.md b/docs/plan/README.md index d04a3b6..122791c 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -41,7 +41,7 @@ Foundation: - [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) -- [ ] `03-gitea-client.md` — Gitea API client for commit statuses +- [x] `03-gitea-client.md` — Gitea API client for commit statuses Core engine: diff --git a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt new file mode 100644 index 0000000..230a24d --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt @@ -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() + 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>() {}) + } 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, + ) +} diff --git a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt new file mode 100644 index 0000000..985eea2 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaStateMapping.kt @@ -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 + } diff --git a/src/test/kotlin/de/hoennig/gittally/gitea/GiteaClientTest.kt b/src/test/kotlin/de/hoennig/gittally/gitea/GiteaClientTest.kt new file mode 100644 index 0000000..dc9cfb2 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/gitea/GiteaClientTest.kt @@ -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() + 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() + } + + test("returns Error for an unknown state value") { + server.stubFor(get(readUrl).willReturn(okJson("""[{"context": "GitTally", "state": "hovering"}]"""))) + + client.readStatus("abc123").shouldBeInstanceOf() + } + + test("returns Error on HTTP 4xx/5xx instead of throwing") { + server.stubFor(get(readUrl).willReturn(aResponse().withStatus(404))) + client.readStatus("abc123").shouldBeInstanceOf() + + server.stubFor(get(readUrl).willReturn(aResponse().withStatus(500))) + client.readStatus("abc123").shouldBeInstanceOf() + } + + test("returns Error when Gitea is unreachable instead of throwing") { + configure(baseUrl = "http://localhost:1") + + client.readStatus("abc123").shouldBeInstanceOf() + } + + 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() + } + } + }) diff --git a/src/test/kotlin/de/hoennig/gittally/gitea/GiteaStateMappingTest.kt b/src/test/kotlin/de/hoennig/gittally/gitea/GiteaStateMappingTest.kt new file mode 100644 index 0000000..4770441 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/gitea/GiteaStateMappingTest.kt @@ -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() + } + })