Show the command a build actually runs on its artifact page

The artifact page read `branches.<branch>.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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-29 07:52:22 +02:00
co-authored by Claude Opus 5
parent 939d8eeb9c
commit 1a84b1fc1b
6 changed files with 81 additions and 12 deletions
@@ -474,12 +474,10 @@ class BuildExecutor(
runningBuild: RunningBuild, runningBuild: RunningBuild,
workingDir: Path, workingDir: Path,
worktree: Path, worktree: Path,
): BranchConfig { ): BranchConfig =
val config = configLoader.loadForWorktree(workingDir, worktree) configLoader
val branchConfig = config.branches[runningBuild.branch] ?: config.branches["default"] ?: BranchConfig() .loadForWorktree(workingDir, worktree)
val definition = config.effectiveBuildDefinitions()[runningBuild.build] ?: return branchConfig .buildSettings(runningBuild.branch, runningBuild.build)
return definition.applyTo(branchConfig)
}
private class ActiveBuild( private class ActiveBuild(
val runningBuild: RunningBuild, val runningBuild: RunningBuild,
@@ -21,6 +21,22 @@ data class GitTallyConfig(
/** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */ /** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */
fun effectiveBuildDefinitions(): Map<String, BuildDefinition> = fun effectiveBuildDefinitions(): Map<String, BuildDefinition> =
mapOf(BuildDefinition.DEFAULT to BuildDefinition(onPush = true)) + buildDefinitions 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( data class ServerConfig(
@@ -6,7 +6,9 @@ import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.git.GitService
import de.hoennig.gittally.metrics.SystemMetricsCollector import de.hoennig.gittally.metrics.SystemMetricsCollector
import de.hoennig.gittally.watcher.Watcher
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties import org.springframework.boot.info.BuildProperties
@@ -36,6 +38,7 @@ class UiController(
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val artifactStore: ArtifactStore, private val artifactStore: ArtifactStore,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val gitService: GitService,
private val metricsCollector: SystemMetricsCollector, private val metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing, private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks, private val branchPermalinks: BranchPermalinks,
@@ -189,7 +192,7 @@ class UiController(
model.addAttribute("filesBase", filesBase) model.addAttribute("filesBase", filesBase)
model.addAttribute("result", result?.let { BuildRowView.from(it, links) }) model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
model.addAttribute("hasArtifacts", artifactDir != null) model.addAttribute("hasArtifacts", artifactDir != null)
model.addAttribute("buildCommand", result?.let { branchBuildCommand(it.branch) }) model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
model.addAttribute( model.addAttribute(
"logs", "logs",
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) } artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
@@ -220,10 +223,24 @@ class UiController(
return links return links
} }
/** The currently configured build command — the command actually used at build time is not persisted. */ /**
private fun branchBuildCommand(branch: String): String { * The command this build runs, resolved exactly like the executor resolves it: the
val branches = configLoader.load(workingDir).branches * branch layer committed at the build's own commit, plus the overrides of the build
return (branches[branch] ?: branches["default"])?.buildCommand ?: "" * 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
} }
/** /**
+1 -1
View File
@@ -39,7 +39,7 @@
<li>Started: <span th:text="${result.startedAt}">2026-07-07 12:00</span> <li>Started: <span th:text="${result.startedAt}">2026-07-07 12:00</span>
<th:block th:if="${result.duration != ''}">— Duration: <span th:text="${result.duration}">1:23</span></th:block> <th:block th:if="${result.duration != ''}">— Duration: <span th:text="${result.duration}">1:23</span></th:block>
</li> </li>
<li th:if="${buildCommand != null}">Build command (as currently configured):<br> <li th:if="${buildCommand != null}">Build command (as configured for this build):<br>
<code th:text="${buildCommand}">./gradlew test</code> <code th:text="${buildCommand}">./gradlew test</code>
</li> </li>
</ul> </ul>
@@ -8,6 +8,7 @@ import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.git.GitService
import de.hoennig.gittally.metrics.SystemMetricsCollector import de.hoennig.gittally.metrics.SystemMetricsCollector
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
@@ -52,6 +53,9 @@ class PermanentBranchRoutesTest : FunSpec() {
@MockkBean @MockkBean
lateinit var configLoader: ConfigLoader lateinit var configLoader: ConfigLoader
@MockkBean
lateinit var gitService: GitService
@MockkBean @MockkBean
lateinit var metricsCollector: SystemMetricsCollector lateinit var metricsCollector: SystemMetricsCollector
@@ -81,11 +85,14 @@ class PermanentBranchRoutesTest : FunSpec() {
artifactStore, artifactStore,
controlTokens, controlTokens,
configLoader, configLoader,
gitService,
metricsCollector, metricsCollector,
branchListing, branchListing,
branchPermalinks, branchPermalinks,
) )
every { configLoader.load(any()) } returns GitTallyConfig() 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 { controlTokens.token() } returns "test-token"
every { branchListing.branches(any()) } returns emptyList() every { branchListing.branches(any()) } returns emptyList()
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
@@ -7,10 +7,13 @@ import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.RunningBuild 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.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.config.GiteaConfig import de.hoennig.gittally.config.GiteaConfig
import de.hoennig.gittally.config.ServerConfig import de.hoennig.gittally.config.ServerConfig
import de.hoennig.gittally.git.GitService
import de.hoennig.gittally.metrics.MetricAggregate import de.hoennig.gittally.metrics.MetricAggregate
import de.hoennig.gittally.metrics.SystemMetrics import de.hoennig.gittally.metrics.SystemMetrics
import de.hoennig.gittally.metrics.SystemMetricsCollector import de.hoennig.gittally.metrics.SystemMetricsCollector
@@ -58,6 +61,9 @@ class UiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var configLoader: ConfigLoader lateinit var configLoader: ConfigLoader
@MockkBean
lateinit var gitService: GitService
@MockkBean @MockkBean
lateinit var metricsCollector: SystemMetricsCollector lateinit var metricsCollector: SystemMetricsCollector
@@ -103,6 +109,7 @@ class UiControllerTest : FunSpec() {
artifactStore, artifactStore,
controlTokens, controlTokens,
configLoader, configLoader,
gitService,
metricsCollector, metricsCollector,
branchListing, branchListing,
branchPermalinks, branchPermalinks,
@@ -112,6 +119,8 @@ class UiControllerTest : FunSpec() {
server = ServerConfig(impressumUrl = "https://example.org/imprint"), server = ServerConfig(impressumUrl = "https://example.org/imprint"),
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), 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 { controlTokens.token() } returns "test-token"
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
} }
@@ -264,6 +273,28 @@ class UiControllerTest : FunSpec() {
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) ).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") { 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")) val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/profile")) Files.createDirectories(artifactDir.resolve("reports/profile"))