Files
werkator/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt
T
mhoennig f0f996a53c A build definition says when it runs in a trigger block of its own
`onPush`, `atTimes`, `branches`, and `activeWithin` move into a nested
`trigger`. The split is structural on purpose: the inheritance from
`builds.default` now subtracts one key instead of a list of four, so a
selector added to `TriggerConfig` later is non-inheritable by
construction rather than because someone remembered to extend the list.

A definition still writing those keys flat is refused by name, per file
and scoped like the version check — the machine and project config abort
the start, a branch's committed config fails only that branch. Ignoring
them would leave the build with no trigger at all, which is a job that
quietly stops running: the failure this refusal exists to prevent.

Two more things a definition can now say:

- A `!` prefix in `trigger.branches` excludes, and an exclusion wins
  whatever the order. `["*", "!master"]` gives one branch a build of its
  own without the default build running over it as well — until now the
  only way out of that double build was to drop the second definition's
  push trigger.
- `statusContext` overrides the Gitea check this build reports as, empty
  keeping the repository-wide one. Two builds of a commit shared a
  context and overwrote each other's result, so a quick check beside a
  long build was not readable in Gitea. Pinned like `requirePullRequest`:
  a branch that could pick its context could take over the check a branch
  protection rule depends on.

Fixed on the way: a branch whose builds all belong to named definitions
rendered an empty row in the branches view, reading as "never built"
directly beside its real builds. That row was unreachable before the
exclusion patterns made such a branch possible.
2026-08-29 12:05:10 +02:00

202 lines
7.8 KiB
Kotlin

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.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import java.net.http.HttpClient
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration
/** 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.
* [context] is the build's own status context (`builds.<name>.statusContext`); blank or
* null uses the repository-wide `gitea.statusContext`. Two builds of one commit sharing
* a context overwrite each other's result, which is what a per-build context avoids.
*/
fun publishStatus(
sha: String,
status: BuildStatus,
description: String,
targetUrl: String? = null,
workingDir: Path = Paths.get("."),
context: String? = null,
): 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"] = context?.takeIf { it.isNotBlank() } ?: 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()
/** Timeouts like the legacy status proxy: callers (e.g. `/api/status`) must never hang. */
private val requestFactory =
JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).build(),
).apply { setReadTimeout(REQUEST_TIMEOUT) }
private fun restClient(config: GitTallyConfig): RestClient =
RestClient
.builder()
.requestFactory(requestFactory)
.baseUrl(config.gitea.baseUrl.trimEnd('/'))
.defaultHeader("Authorization", "token ${config.git.token}")
.build()
private data class GiteaCommitStatus(
val context: String? = null,
val state: String? = null,
)
companion object {
private val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(10)
}
}