feat(gitea): der Commit-Status verlinkt endlich die Artefaktseite — repo-bezogen

Der letzte offene Punkt der Sitzung D („Gitea status links use the repo-scoped
URLs"). Beim Nachsehen war es kein Scoping-Problem, sondern ein fehlendes
Feature: `server.publicBaseUrl` ist als „used for all links posted to Gitea"
dokumentiert, der Executor postete aber `targetUrl = null` — es gab überhaupt
keinen Link. Jetzt zeigt der Status auf die Artefaktseite des Builds, also auf
die Logs, um die es geht. Ohne `publicBaseUrl` bleibt er null: ein relativer
Link in einem Gitea-Status wäre schlimmer als keiner, er löste gegen die Forge
auf.

Die Präfix-Regel wohnt jetzt an einer Stelle (`RepoLinks`), weil drei
dasselbe damit tun: die Seiten, die API-Antworten und diese Ziel-URLs — und
eine dreimal ausgeschriebene Regel driftet. Die Slice-Tests binden die echte
Komponente per @Import ein, statt die Regel im Test nachzubauen.

Ein neuer Test (mit publicBaseUrl trägt der Status die Artefaktseite; die
vorhandenen Tests halten fest, dass ohne sie weiterhin null gepostet wird).
499 Tests grün, ktlint sauber. Plan und Architektur-Skill nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 13:53:51 +02:00
co-authored by Claude Opus 5
parent 91e1dd47a3
commit 80a14a7467
11 changed files with 114 additions and 7 deletions
@@ -5,6 +5,7 @@ import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.event.ContextClosedEvent
@@ -36,6 +37,7 @@ import kotlin.concurrent.thread
@Service
class BuildExecutor(
private val configLoader: ConfigLoader,
private val repoLinks: RepoLinks,
private val giteaClient: GiteaClient,
private val buildRunner: BuildRunner,
private val workspaces: BranchWorkspaces,
@@ -397,7 +399,7 @@ class BuildExecutor(
sha = build.runningBuild.commit,
status = status,
description = description(status, duration),
targetUrl = null,
targetUrl = targetUrlOf(build),
workingDir = build.repo.workingDir,
// from the primary config, not the worktree: statusContext is pinned, so a
// branch cannot report under a check name it was not given
@@ -408,6 +410,25 @@ class BuildExecutor(
}
}
/**
* The artifact page of this build, so a commit status in the forge leads to the
* logs it is about — that is what `server.publicBaseUrl` is documented for, and
* until now nothing posted a link at all. Repository-scoped (ADR 0009): with
* several served repositories the unscoped path would resolve against whichever
* one the instance serves by default, which is the wrong build's page.
*/
private fun targetUrlOf(build: ActiveBuild): String? =
try {
repoLinks.buildUrl(
repo = build.repo,
publicBaseUrl = configLoader.load(build.repo.workingDir).server.publicBaseUrl,
artifactKey = build.runningBuild.artifactKey,
)
} catch (e: Exception) {
log.warn("could not build the status target URL of {}: {}", build.runningBuild.branch, e.message)
null
}
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
private fun statusContextOf(build: ActiveBuild): String =
try {
@@ -0,0 +1,37 @@
package de.hoennig.werkator.repo
import org.springframework.stereotype.Component
/**
* The path prefix a link to a repository carries (ADR 0009). One place knows the
* rule, because three do the same thing with it: the pages, the API answers, and
* the target URLs posted to Gitea — and a rule spelled out three times is a rule
* that drifts.
*
* It follows the NUMBER of served repositories, not the route a request arrived
* through: with one repository an installation keeps the URLs it always had, with
* several every link names its repository.
*/
@Component
class RepoLinks(
private val registry: RepoRegistry,
) {
fun base(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
/**
* The absolute artifact-page URL of a build, for links Werkator posts elsewhere
* (`server.publicBaseUrl`). Null without a public base URL: a relative link in a
* Gitea status is worse than none — it would resolve against the forge.
*/
fun buildUrl(
repo: RepoContext,
publicBaseUrl: String,
artifactKey: String,
): String? {
val root = publicBaseUrl.trim().trimEnd('/')
if (root.isEmpty()) return null
return "$root${base(repo)}/builds/$artifactKey"
}
}
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
@@ -37,6 +38,7 @@ class BuildsApiController(
private val gitService: GitService,
private val branchListing: BranchListing,
private val registry: RepoRegistry,
private val repoLinks: RepoLinks,
) {
/**
* Every route exists twice: repository-scoped (`/api/repos/<name>/…`) and unscoped.
@@ -52,7 +54,7 @@ class BuildsApiController(
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
/** The prefix the permanent links in the answers carry; empty with one served repository. */
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
@ExceptionHandler(UnknownRepositoryException::class)
@@ -8,6 +8,7 @@ import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.ObjectProvider
@@ -43,6 +44,7 @@ class UiController(
private val branchPermalinks: BranchPermalinks,
private val buildProperties: ObjectProvider<BuildProperties>,
private val registry: RepoRegistry,
private val repoLinks: RepoLinks,
) {
/**
* Every page exists twice, like the API (ADR 0009): repository-scoped under
@@ -272,9 +274,9 @@ class UiController(
* repository the installation keeps its existing URLs (the session-D acceptance
* criterion), with several every link names its repository.
*/
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
private fun uiBase(repo: RepoContext): String = repoLinks.base(repo)
private fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
private fun apiBase(repo: RepoContext): String = repoLinks.apiBase(repo)
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
private fun baseModel(
@@ -7,11 +7,14 @@ import de.hoennig.werkator.build.ProcessBuildRunner
import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import org.springframework.context.ApplicationEventPublisher
import java.nio.file.Files
@@ -42,6 +45,7 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
val executor =
BuildExecutor(
configLoader = ConfigLoader(),
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
giteaClient = mockk<GiteaClient>(relaxed = true),
buildRunner = ProcessBuildRunner(),
workspaces = BranchWorkspaces { _, _, _ -> workspace },
@@ -3,6 +3,8 @@ package de.hoennig.werkator.build
import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
@@ -53,6 +55,7 @@ class BuildExecutorTest : FunSpec() {
val executor =
BuildExecutor(
configLoader = ConfigLoader(),
repoLinks = RepoLinks(mockk<RepoRegistry>().also { every { it.all() } returns listOf(repo) }),
giteaClient = giteaClient,
buildRunner = buildRunner,
workspaces = workspaces,
@@ -138,6 +141,35 @@ class BuildExecutorTest : FunSpec() {
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
}
test("the commit status carries the artifact page when a public base URL is configured") {
val h =
Harness(
"""
server:
publicBaseUrl: https://ci.example.org/
branches:
default:
buildCommand: "true"
""".trimIndent(),
)
val build = h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
// one served repository: the installation's existing URLs, no repository segment
verify {
h.giteaClient.publishStatus(
"abc123",
BuildStatus.SUCCESS,
any(),
"https://ci.example.org/builds/" + build.artifactKey,
h.workingDir,
any(),
)
}
}
test("build commands run in the workspace prepared for the branch") {
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
@@ -9,6 +9,7 @@ import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.RunningBuild
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
@@ -17,6 +18,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -29,6 +31,7 @@ import java.time.Duration
import java.time.Instant
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
@Import(RepoLinks::class)
class BuildsApiControllerTest : FunSpec() {
private val tempDir: Path = Files.createTempDirectory("werkator-server-test")
@@ -11,6 +11,7 @@ import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
@@ -18,6 +19,7 @@ import io.mockk.every
import org.hamcrest.Matchers.containsString
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
@@ -37,6 +39,7 @@ import java.time.Instant
controllers = [UiController::class, ArtifactFileController::class],
properties = ["spring.main.web-application-type=servlet"],
)
@Import(RepoLinks::class)
class PermanentBranchRoutesTest : FunSpec() {
@Autowired
lateinit var mockMvc: MockMvc
@@ -18,6 +18,7 @@ import de.hoennig.werkator.metrics.MetricAggregate
import de.hoennig.werkator.metrics.SystemMetrics
import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoLinks
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
@@ -30,6 +31,7 @@ import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -44,6 +46,7 @@ import java.time.Duration
import java.time.Instant
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
@Import(RepoLinks::class)
class UiControllerTest : FunSpec() {
private val tempDir: Path = Files.createTempDirectory("werkator-ui-test")