196 lines
7.5 KiB
Kotlin
196 lines
7.5 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. */
|
|
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()
|
|
|
|
/** 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)
|
|
}
|
|
}
|