From 1a84b1fc1bf5f34d0b171a08911961b4b789ed39 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Sat, 29 Aug 2026 07:52:22 +0200 Subject: [PATCH] Show the command a build actually runs on its artifact page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact page read `branches..buildCommand` from the primary config only, so for a named build on a branch with its own config it showed a command that build never ran — on vm4006 it showed the host config's command for a build that ran the branch definition's `pitestFull`. Resolving "what does this build run" now has one implementation, `GitTallyConfig.buildSettings(branch, build)`: the branch entry with the build definition's overrides applied last. The executor uses it, and the page resolves it against the branch layer committed at the build's own commit — the same inputs the executor had. Co-Authored-By: Claude Opus 5 --- .../hoennig/gittally/build/BuildExecutor.kt | 10 +++--- .../hoennig/gittally/config/GitTallyConfig.kt | 16 ++++++++++ .../hoennig/gittally/server/UiController.kt | 27 +++++++++++++--- src/main/resources/templates/artifact.html | 2 +- .../server/PermanentBranchRoutesTest.kt | 7 +++++ .../gittally/server/UiControllerTest.kt | 31 +++++++++++++++++++ 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt index aef2021..de4588a 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -474,12 +474,10 @@ class BuildExecutor( runningBuild: RunningBuild, workingDir: Path, worktree: Path, - ): BranchConfig { - val config = configLoader.loadForWorktree(workingDir, worktree) - val branchConfig = config.branches[runningBuild.branch] ?: config.branches["default"] ?: BranchConfig() - val definition = config.effectiveBuildDefinitions()[runningBuild.build] ?: return branchConfig - return definition.applyTo(branchConfig) - } + ): BranchConfig = + configLoader + .loadForWorktree(workingDir, worktree) + .buildSettings(runningBuild.branch, runningBuild.build) private class ActiveBuild( val runningBuild: RunningBuild, diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index d439c3a..23fbea3 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -21,6 +21,22 @@ data class GitTallyConfig( /** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */ fun effectiveBuildDefinitions(): Map = mapOf(BuildDefinition.DEFAULT to BuildDefinition(onPush = true)) + buildDefinitions + + /** + * The settings one build of [build] on [branch] runs with: the branch entry (falling + * back to `branches.default`) with the build definition's overrides applied last. + * A build name without a definition — a job removed from the config since the run — + * falls back to the plain branch settings. + * This must stay the single answer to "what does this build run", used by the + * executor and by everything that displays it. + */ + fun buildSettings( + branch: String, + build: String, + ): BranchConfig { + val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig() + return effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig + } } data class ServerConfig( diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index cd58082..0bf1161 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -6,7 +6,9 @@ import de.hoennig.gittally.build.BuildResult import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.gittally.git.GitService import de.hoennig.gittally.metrics.SystemMetricsCollector +import de.hoennig.gittally.watcher.Watcher import jakarta.servlet.http.HttpServletRequest import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.info.BuildProperties @@ -36,6 +38,7 @@ class UiController( private val buildExecutor: BuildExecutor, private val artifactStore: ArtifactStore, private val configLoader: ConfigLoader, + private val gitService: GitService, private val metricsCollector: SystemMetricsCollector, private val branchListing: BranchListing, private val branchPermalinks: BranchPermalinks, @@ -189,7 +192,7 @@ class UiController( model.addAttribute("filesBase", filesBase) model.addAttribute("result", result?.let { BuildRowView.from(it, links) }) model.addAttribute("hasArtifacts", artifactDir != null) - model.addAttribute("buildCommand", result?.let { branchBuildCommand(it.branch) }) + model.addAttribute("buildCommand", result?.let { buildCommandOf(it) }) model.addAttribute( "logs", artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) } @@ -220,10 +223,24 @@ class UiController( return links } - /** The currently configured build command — the command actually used at build time is not persisted. */ - private fun branchBuildCommand(branch: String): String { - val branches = configLoader.load(workingDir).branches - return (branches[branch] ?: branches["default"])?.buildCommand ?: "" + /** + * The command this build runs, resolved exactly like the executor resolves it: the + * branch layer committed at the build's own commit, plus the overrides of the build + * definition it belongs to. Reading only the primary config would show a command no + * build of this pool ever ran — the branch and its job usually override it. + * The command used by a past run is not persisted, so this is the current answer. + */ + private fun buildCommandOf(result: BuildResult): String { + val config = + try { + configLoader.loadWithBranchLayer( + workingDir, + gitService.showFileAtCommit(result.commit, Watcher.CONFIG_FILE, workingDir), + ) + } catch (_: Exception) { + configLoader.load(workingDir) + } + return config.buildSettings(result.branch, result.build).buildCommand } /** diff --git a/src/main/resources/templates/artifact.html b/src/main/resources/templates/artifact.html index 08b7b64..7c19351 100644 --- a/src/main/resources/templates/artifact.html +++ b/src/main/resources/templates/artifact.html @@ -39,7 +39,7 @@
  • Started: 2026-07-07 12:00 — Duration: 1:23
  • -
  • Build command (as currently configured):
    +
  • Build command (as configured for this build):
    ./gradlew test
  • diff --git a/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt b/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt index 07225cc..6e90965 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt @@ -8,6 +8,7 @@ import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.GitTallyConfig +import de.hoennig.gittally.git.GitService import de.hoennig.gittally.metrics.SystemMetricsCollector import io.kotest.core.spec.style.FunSpec import io.mockk.clearMocks @@ -52,6 +53,9 @@ class PermanentBranchRoutesTest : FunSpec() { @MockkBean lateinit var configLoader: ConfigLoader + @MockkBean + lateinit var gitService: GitService + @MockkBean lateinit var metricsCollector: SystemMetricsCollector @@ -81,11 +85,14 @@ class PermanentBranchRoutesTest : FunSpec() { artifactStore, controlTokens, configLoader, + gitService, metricsCollector, branchListing, branchPermalinks, ) every { configLoader.load(any()) } returns GitTallyConfig() + every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns GitTallyConfig() + every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" every { branchListing.branches(any()) } returns emptyList() every { branchPermalinks.latestGreenBuild("main") } returns greenBuild diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt index 725f8f2..3c6b7b8 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -7,10 +7,13 @@ import de.hoennig.gittally.build.BuildResult import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.RunningBuild +import de.hoennig.gittally.config.BranchConfig +import de.hoennig.gittally.config.BuildDefinition import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.GiteaConfig import de.hoennig.gittally.config.ServerConfig +import de.hoennig.gittally.git.GitService import de.hoennig.gittally.metrics.MetricAggregate import de.hoennig.gittally.metrics.SystemMetrics import de.hoennig.gittally.metrics.SystemMetricsCollector @@ -58,6 +61,9 @@ class UiControllerTest : FunSpec() { @MockkBean lateinit var configLoader: ConfigLoader + @MockkBean + lateinit var gitService: GitService + @MockkBean lateinit var metricsCollector: SystemMetricsCollector @@ -103,6 +109,7 @@ class UiControllerTest : FunSpec() { artifactStore, controlTokens, configLoader, + gitService, metricsCollector, branchListing, branchPermalinks, @@ -112,6 +119,8 @@ class UiControllerTest : FunSpec() { server = ServerConfig(impressumUrl = "https://example.org/imprint"), gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), ) + every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns GitTallyConfig() + every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" every { repository.latestGreenFor(any()) } returns null } @@ -264,6 +273,28 @@ class UiControllerTest : FunSpec() { ).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) } + test("the artifact page shows the command of the build's own definition, not the plain branch command") { + val pitestResult = + successResult.copy( + build = "pitest", + name = "main@pitest", + artifactKey = "main-pitest-key", + ) + every { repository.history() } returns listOf(pitestResult) + every { artifactStore.artifactDir("main-pitest-key") } returns null + every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns + GitTallyConfig( + branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")), + buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")), + ) + + mockMvc + .perform(get("/builds/main-pitest-key")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("./gradlew pitestFull"))) + .andExpect(content().string(not(containsString("./gradlew quick-check")))) + } + test("artifact index links a single index-less report page as a directory, keeping the URL stable") { val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) Files.createDirectories(artifactDir.resolve("reports/profile"))