diff --git a/build.gradle.kts b/build.gradle.kts index 050640c..ce7bbac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("info.picocli:picocli-spring-boot-starter:4.7.6") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..072b6d3 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,89 @@ +# GitTally Configuration Reference + +GitTally is configured via YAML files. Settings are merged from two sources in order — later layers override earlier ones. + +## Config File Locations + +| Layer | Path | Committed to Git | Purpose | +|--------------------------|----------------------------|------------------|----------------------------------------------| +| Project config | `.gittally.yml` | Yes | Shared team settings | +| Repo installation config | `.git/gittally/config.yml` | No | Machine- or user-specific overrides, secrets | + +The repo install config (`.git/gittally/config.yml`) wins on any key present in both files. Typically used to set `gitea.token` without committing it. + +## Inspect the Effective Config + +```bash +java -jar gittally.jar config:print # only explicitly set values +java -jar gittally.jar config:print --full # all values including defaults +``` + +## `.gittally.yml` + +Values shown are the defaults. + +```yaml +server: + # Public base URL of this GitTally installation — used for all links posted to Gitea. + publicBaseUrl: https://ci.example.org/ + +# Gitea integration for fetching commits and posting build statuses. +gitea: + baseUrl: https://git.example.org # base URL of the Gitea instance + owner: my-org # repository owner (user or organisation) + repo: my-repo # repository name + statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) + +# Build artifact retention. +artifacts: + # number of builds to keep per branch + retentionPerBranch: 3 + +# Controls the branch-polling loop. +watcher: + # max commit age for new origin branches to be pulled automatically + newBranchMaxAge: 5d + +# Per-branch build configuration. +# Use "default" as the fallback for all branches not listed explicitly. +# Each entry merges build settings and auto-build scheduling. +branches: + default: + # run before each build + cleanCommand: rm -rf build + # shell command for each build + buildCommand: ./gradlew --console=plain --no-daemon test + # directories copied as build artifacts + artifactDirs: + - build/reports + - build/doc + stdoutLog: build.stdout.log # filename for captured stdout + stderrLog: build.stderr.log # filename for captured stderr + autoBuild: + enabled: false # whether to rebuild on schedule + times: ["01:00"] # UTC times HH:MM for scheduled builds + + main: + autoBuild: + enabled: true + + master: + autoBuild: + enabled: true + + release: + buildCommand: ./gradlew --console=plain --no-daemon --no-build-cache test jacocoReport + autoBuild: + enabled: true + times: + - "04:00" +``` + +## `.git/gittally/config.yml` (not committed) + +```yaml +# Machine- or user-specific overrides. Keys here win over .gittally.yml. +gitea: + gitUsername: my-user # git username for HTTPS authentication + token: glpat-xxxxxxxxxxxxxxxxxxxx # Gitea API token — never commit this +``` diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt index b7f372c..da30278 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ConfigPrintCommand.kt @@ -1,5 +1,6 @@ package de.hoennig.gittally.commands +import de.hoennig.gittally.config.ConfigLoader import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.Option @@ -10,11 +11,22 @@ import picocli.CommandLine.Option description = ["Print the effective configuration"], mixinStandardHelpOptions = true, ) -class ConfigPrintCommand : Runnable { +class ConfigPrintCommand( + private val configLoader: ConfigLoader, +) : Runnable { @Option(names = ["--full"], description = ["Include all defaults"]) var full: Boolean = false override fun run() { - println("config:print${if (full) " --full" else ""} – not yet implemented") + if (full) { + print(configLoader.toYaml(configLoader.load())) + } else { + val raw = configLoader.loadRaw() + if (raw.isEmpty()) { + println("(no configuration files found)") + } else { + print(configLoader.toYaml(raw)) + } + } } } diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt new file mode 100644 index 0000000..7a306ec --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -0,0 +1,78 @@ +package de.hoennig.gittally.config + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.stereotype.Service +import java.io.File +import java.nio.file.Path +import java.nio.file.Paths + +@Service +class ConfigLoader { + private val yaml = + ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)) + .registerKotlinModule() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + + fun load(workingDir: Path = Paths.get(".")): GitTallyConfig { + val raw = loadRaw(workingDir) + return if (raw.isEmpty()) { + GitTallyConfig() + } else { + yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java) + } + } + + fun loadRaw(workingDir: Path = Paths.get(".")): Map { + val repoInstall = loadFile(workingDir.resolve(".git/gittally/config.yml").toFile()) + val project = loadFile(workingDir.resolve(".gittally.yml").toFile()) + return deepMerge(project, repoInstall) + } + + fun toYaml(value: Any): String = yaml.writeValueAsString(value) + + private fun loadFile(file: File): Map { + if (!file.exists()) return emptyMap() + @Suppress("UNCHECKED_CAST") + return yaml.readValue(file, Map::class.java) as Map + } + + @Suppress("UNCHECKED_CAST") + private fun mergeBranchDefaults(raw: Map): Map { + val branches = raw["branches"] as? Map ?: return raw + val default = branches["default"] as? Map ?: return raw + if (default.isEmpty()) return raw + val merged = + branches.mapValues { (name, value) -> + if (name == "default") { + value + } else { + deepMerge(default, value as? Map ?: emptyMap()) + } + } + return raw + ("branches" to merged) + } + + @Suppress("UNCHECKED_CAST") + private fun deepMerge( + base: Map, + overlay: Map, + ): Map { + val result = base.toMutableMap() + for ((key, value) in overlay) { + val existing = result[key] + result[key] = + if (existing is Map<*, *> && value is Map<*, *>) { + deepMerge(existing as Map, value as Map) + } else { + value + } + } + return result + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt new file mode 100644 index 0000000..dca09c7 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -0,0 +1,44 @@ +package de.hoennig.gittally.config + +data class GitTallyConfig( + val server: ServerConfig = ServerConfig(), + val gitea: GiteaConfig = GiteaConfig(), + val artifacts: ArtifactsConfig = ArtifactsConfig(), + val watcher: WatcherConfig = WatcherConfig(), + val branches: Map = mapOf("default" to BranchConfig()), +) + +data class ServerConfig( + val publicBaseUrl: String = "", +) + +data class GiteaConfig( + val baseUrl: String = "", + val owner: String = "", + val repo: String = "", + val gitUsername: String = "", + val token: String = "", + val statusContext: String = "GitTally", +) + +data class ArtifactsConfig( + val retentionPerBranch: Int = 3, +) + +data class WatcherConfig( + val newBranchMaxAge: String = "5d", +) + +data class BranchConfig( + val buildCommand: String = "./gradlew --console=plain --no-daemon test", + val cleanCommand: String = "rm -rf build", + val artifactDirs: List = listOf("build/reports"), + val stdoutLog: String = "build.stdout.log", + val stderrLog: String = "build.stderr.log", + val autoBuild: AutoBuildConfig = AutoBuildConfig(), +) + +data class AutoBuildConfig( + val enabled: Boolean = false, + val times: List = listOf("01:00"), +) diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt new file mode 100644 index 0000000..3bbaac2 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -0,0 +1,117 @@ +package de.hoennig.gittally.config + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.maps.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.nio.file.Files + +class ConfigLoaderTest : FunSpec() { + private val loader = ConfigLoader() + + init { + test("returns defaults when no config files exist") { + val dir = Files.createTempDirectory("gittally-test") + loader.load(dir) shouldBe GitTallyConfig() + } + + test("loadRaw returns empty map when no config files exist") { + val dir = Files.createTempDirectory("gittally-test") + loader.loadRaw(dir).shouldBeEmpty() + } + + test("reads gitea config from .gittally.yml") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + gitea: + owner: my-org + repo: my-repo + """.trimIndent(), + ) + val config = loader.load(dir) + config.gitea.owner shouldBe "my-org" + config.gitea.repo shouldBe "my-repo" + config.branches["default"]!!.buildCommand shouldBe BranchConfig().buildCommand + } + + test("repo install config overrides project config for same keys") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + gitea: + owner: my-org + token: from-project + """.trimIndent(), + ) + dir.resolve(".git/gittally").toFile().mkdirs() + dir.resolve(".git/gittally/config.yml").toFile().writeText( + """ + gitea: + token: from-repo-install + """.trimIndent(), + ) + val config = loader.load(dir) + config.gitea.owner shouldBe "my-org" + config.gitea.token shouldBe "from-repo-install" + } + + test("loadRaw only returns explicitly set values") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + branches: + default: + buildCommand: ./mvnw test + """.trimIndent(), + ) + val raw = loader.loadRaw(dir) + + @Suppress("UNCHECKED_CAST") + val default = (raw["branches"] as Map)["default"] as Map + default["buildCommand"] shouldBe "./mvnw test" + default.containsKey("cleanCommand") shouldBe false + } + + test("branch-specific config inherits from branches.default") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + branches: + default: + buildCommand: ./mvnw test + main: + autoBuild: + enabled: true + """.trimIndent(), + ) + val config = loader.load(dir) + config.branches["main"]!!.buildCommand shouldBe "./mvnw test" + config.branches["main"]!!.autoBuild.enabled shouldBe true + } + + test("branch-specific buildCommand overrides branches.default") { + val dir = Files.createTempDirectory("gittally-test") + dir.resolve(".gittally.yml").toFile().writeText( + """ + branches: + default: + buildCommand: ./mvnw test + release: + buildCommand: ./mvnw -P release test + """.trimIndent(), + ) + val config = loader.load(dir) + config.branches["release"]!!.buildCommand shouldBe "./mvnw -P release test" + } + + test("toYaml serializes GitTallyConfig with all sections") { + val yaml = loader.toYaml(GitTallyConfig()) + yaml shouldContain "server:" + yaml shouldContain "gitea:" + yaml shouldContain "artifacts:" + yaml shouldContain "watcher:" + yaml shouldContain "branches:" + } + } +}