step 22b: repo context (#11)
* Step 22 B: RepoContext over the current repository A RepoContext bundles a repository's primary checkout with the state that lives inside or is keyed by it (results, artifact store) and carries its name. Today there is exactly one, opened over the current working directory; the result and artifact-store beans now come from it, so nothing else changes yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Step 22 B: the watcher polls a RepoContext start/poll/recoverOnStartup take the context instead of a working directory and read results and artifacts from it; the per-repository poll memory (logged fetch error, deprecation warning, cached branch definitions) moves into a RepoWatch keyed by context, so the next session can iterate contexts without one repository's outage silencing another's. The shared WatcherState is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Step 22 B: the executor runs builds of a RepoContext startBuild takes the context first; builds serialize per (context, branch) and share the global maxConcurrent cap across repositories, results and artifacts go to the build's own context. ConsoleBuildRunner, the build/retry commands and the builds API restart pass the current repository's context along. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Step 22 B: the UI and the branch listing read their RepoContext UiController and BuildsApiController take the current repository's context instead of a settable working directory; BranchListing lists the branches of a context and reads the results from it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Step 22 B: document the RepoContext, PR-doc for PR #11 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
713edf77d6
commit
096cce3659
@@ -1,17 +1,13 @@
|
||||
package de.hoennig.werkator.artifacts
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class ArtifactsConfiguration {
|
||||
/**
|
||||
* Store relative to the working directory, matching how `ConfigLoader` and the
|
||||
* `BuildResultRepository` bean resolve their files. Nothing is touched until the
|
||||
* first build persists, so the bean is safe outside a git repository.
|
||||
*/
|
||||
/** The current repository's artifact store, for the code paths that still take the store bean. */
|
||||
@Bean
|
||||
fun artifactStore(configLoader: ConfigLoader): ArtifactStore = FileArtifactStore(configLoader)
|
||||
fun artifactStore(repo: RepoContext): ArtifactStore = repo.artifactStore
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package de.hoennig.werkator.build
|
||||
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Configuration
|
||||
class BuildConfiguration {
|
||||
/**
|
||||
* Results file relative to the working directory, matching how `ConfigLoader`
|
||||
* resolves the `.git/werkator/` override file. Nothing is touched until the
|
||||
* first build runs, so the bean is safe outside a git repository.
|
||||
*/
|
||||
/** The current repository's results, for the code paths that still take the repository bean. */
|
||||
@Bean
|
||||
fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/werkator/build-results.json"))
|
||||
fun buildResultRepository(repo: RepoContext): BuildResultRepository = repo.results
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.config.BranchConfig
|
||||
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 org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.event.ContextClosedEvent
|
||||
@@ -14,7 +15,6 @@ import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -26,31 +26,30 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Runs builds asynchronously: up to `executor.maxConcurrent` branches at the same time
|
||||
* (default 1), but never more than one build per branch. Each branch builds in its
|
||||
* own git worktree via [BranchWorkspaces], never in the primary checkout.
|
||||
* Every status transition is persisted via the [BuildResultRepository], published
|
||||
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent].
|
||||
* Runs builds asynchronously: up to `executor.maxConcurrent` builds at the same time
|
||||
* across all repositories (default 1), but never more than one build per branch of
|
||||
* a repository. Each branch builds in its own git worktree via [BranchWorkspaces],
|
||||
* never in the primary checkout. Every status transition is persisted in the
|
||||
* build's [RepoContext.results], published to Gitea (non-fatal), and emitted as a
|
||||
* [BuildStatusChangedEvent].
|
||||
*/
|
||||
@Service
|
||||
class BuildExecutor(
|
||||
private val repository: BuildResultRepository,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val giteaClient: GiteaClient,
|
||||
private val buildRunner: BuildRunner,
|
||||
private val workspaces: BranchWorkspaces,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
|
||||
|
||||
/** One serial worker per branch enforces at most one build per branch. */
|
||||
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>()
|
||||
/** One serial worker per (repository, branch) enforces at most one build per branch of a repository. */
|
||||
private val branchWorkers = ConcurrentHashMap<Pair<RepoContext, String>, ExecutorService>()
|
||||
|
||||
/** All accepted, not yet finished builds by artifact key — queued and running. */
|
||||
private val builds = ConcurrentHashMap<String, ActiveBuild>()
|
||||
|
||||
/** Global concurrency limit; sized from `executor.maxConcurrent` on first use. */
|
||||
/** Global concurrency limit across all repositories; sized from `executor.maxConcurrent` on first use. */
|
||||
@Volatile
|
||||
private var slots: Semaphore? = null
|
||||
|
||||
@@ -61,9 +60,10 @@ class BuildExecutor(
|
||||
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
|
||||
|
||||
/**
|
||||
* Persists a PENDING result and queues the build; returns immediately.
|
||||
* Persists a PENDING result in [repo] and queues the build; returns immediately.
|
||||
* A build of the same branch waits until the branch's previous build finished;
|
||||
* builds of other branches run concurrently while slots are free.
|
||||
* builds of other branches — of this or any other repository — run concurrently
|
||||
* while slots are free.
|
||||
* While a build of the same branch and commit is already queued or executing (and
|
||||
* not cancel-requested), that build is returned instead of stacking a duplicate —
|
||||
* a double-triggered UI restart must not queue the same commit twice. Re-running
|
||||
@@ -76,15 +76,16 @@ class BuildExecutor(
|
||||
* worktree, serialized with the branch's other builds.
|
||||
*/
|
||||
fun startBuild(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
build: String = BuildDefinition.DEFAULT,
|
||||
): RunningBuild {
|
||||
val name = BuildDefinition.poolName(branch, build)
|
||||
val duplicate =
|
||||
builds.values.firstOrNull {
|
||||
!it.cancelled.get() &&
|
||||
it.repo === repo &&
|
||||
it.runningBuild.name == name &&
|
||||
it.runningBuild.commit == commit
|
||||
}
|
||||
@@ -114,13 +115,13 @@ class BuildExecutor(
|
||||
duration = null,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
)
|
||||
repository.append(pending)
|
||||
repo.results.append(pending)
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
||||
val activeBuild = ActiveBuild(runningBuild, workingDir)
|
||||
val activeBuild = ActiveBuild(runningBuild, repo)
|
||||
builds[runningBuild.artifactKey] = activeBuild
|
||||
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
|
||||
branchWorkers
|
||||
.computeIfAbsent(branch) { serialWorker(it) }
|
||||
.computeIfAbsent(repo to branch) { serialWorker(branch) }
|
||||
.submit { execute(activeBuild) }
|
||||
return runningBuild
|
||||
}
|
||||
@@ -171,7 +172,7 @@ class BuildExecutor(
|
||||
var finalStatus: BuildStatus? = BuildStatus.FAILED
|
||||
var workspace: Path? = null
|
||||
try {
|
||||
slot = slotsFor(build.workingDir)
|
||||
slot = slotsFor(build.repo.workingDir)
|
||||
slot.acquire()
|
||||
if (build.cancelled.get()) {
|
||||
finalStatus = BuildStatus.CANCELLED
|
||||
@@ -188,7 +189,7 @@ class BuildExecutor(
|
||||
workspaces.prepare(
|
||||
branch = build.runningBuild.branch,
|
||||
commit = build.runningBuild.commit,
|
||||
repoDir = build.workingDir,
|
||||
repoDir = build.repo.workingDir,
|
||||
)
|
||||
workspace = preparedWorkspace
|
||||
val exitCode = runBuildCommands(build, preparedWorkspace)
|
||||
@@ -220,7 +221,7 @@ class BuildExecutor(
|
||||
val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) }
|
||||
val result = transition(build, finalStatus, duration)
|
||||
try {
|
||||
artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
||||
build.repo.artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
||||
} catch (e: Exception) {
|
||||
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
|
||||
}
|
||||
@@ -231,8 +232,9 @@ class BuildExecutor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The semaphore is sized once from the first build's config;
|
||||
* changing `executor.maxConcurrent` requires a restart.
|
||||
* The semaphore is sized once from the first build's config — the global cap is an
|
||||
* instance-level setting (ADR 0009) and does not vary by repository; changing
|
||||
* `executor.maxConcurrent` requires a restart.
|
||||
*/
|
||||
private fun slotsFor(workingDir: Path): Semaphore {
|
||||
slots?.let { return it }
|
||||
@@ -256,7 +258,7 @@ class BuildExecutor(
|
||||
build: ActiveBuild,
|
||||
workspace: Path,
|
||||
): Int {
|
||||
val branchConfig = buildConfig(build.runningBuild, build.workingDir, workspace)
|
||||
val branchConfig = buildConfig(build.runningBuild, build.repo.workingDir, workspace)
|
||||
val buildCommand = branchConfig.buildCommand
|
||||
val stagingDir = build.runningBuild.stagingDir
|
||||
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
|
||||
@@ -293,7 +295,7 @@ class BuildExecutor(
|
||||
command = command,
|
||||
workingDir = workspace,
|
||||
environment = mapOf("branch" to build.runningBuild.branch),
|
||||
repoDir = build.workingDir,
|
||||
repoDir = build.repo.workingDir,
|
||||
branchConfig = branchConfig,
|
||||
onAuxProcess = { aux ->
|
||||
// preparation phases (e.g. a Docker image build) must die on cancellation
|
||||
@@ -363,7 +365,7 @@ class BuildExecutor(
|
||||
): BuildResult {
|
||||
val runningBuild = build.runningBuild
|
||||
val updated =
|
||||
repository.updateByArtifactKey(runningBuild.artifactKey) {
|
||||
build.repo.results.updateByArtifactKey(runningBuild.artifactKey) {
|
||||
it.copy(
|
||||
status = status,
|
||||
runningSince = runningBuild.runningSince ?: it.runningSince,
|
||||
@@ -378,7 +380,7 @@ class BuildExecutor(
|
||||
runningSince = runningBuild.runningSince,
|
||||
duration = duration,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
).also { repository.append(it) }
|
||||
).also { build.repo.results.append(it) }
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
||||
publishGiteaStatus(build, status, duration)
|
||||
return updated
|
||||
@@ -395,7 +397,7 @@ class BuildExecutor(
|
||||
status = status,
|
||||
description = description(status, duration),
|
||||
targetUrl = null,
|
||||
workingDir = build.workingDir,
|
||||
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
|
||||
context = statusContextOf(build),
|
||||
@@ -409,7 +411,7 @@ class BuildExecutor(
|
||||
private fun statusContextOf(build: ActiveBuild): String =
|
||||
try {
|
||||
configLoader
|
||||
.load(build.workingDir)
|
||||
.load(build.repo.workingDir)
|
||||
.buildSettings(build.runningBuild.branch, build.runningBuild.build)
|
||||
.statusContext
|
||||
} catch (e: Exception) {
|
||||
@@ -496,7 +498,7 @@ class BuildExecutor(
|
||||
|
||||
private class ActiveBuild(
|
||||
val runningBuild: RunningBuild,
|
||||
val workingDir: Path,
|
||||
val repo: RepoContext,
|
||||
) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import picocli.CommandLine.Parameters
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,8 @@ import java.util.concurrent.Callable
|
||||
class BuildCommand(
|
||||
private val gitService: GitService,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
/** The repository to build: the current working directory (a repo selector comes with the registry). */
|
||||
var repo: RepoContext,
|
||||
) : Callable<Int> {
|
||||
@Parameters(
|
||||
index = "0",
|
||||
@@ -33,7 +35,8 @@ class BuildCommand(
|
||||
)
|
||||
var branchFragment: String? = null
|
||||
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
override fun call(): Int {
|
||||
val branch: String
|
||||
@@ -47,7 +50,7 @@ class BuildCommand(
|
||||
return ExitCode.USAGE
|
||||
}
|
||||
println("building branch $branch at commit ${commit.take(12)}")
|
||||
val status = consoleBuildRunner.buildAndStream(branch, commit, workingDir)
|
||||
val status = consoleBuildRunner.buildAndStream(repo, branch, commit)
|
||||
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildExecutor
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.build.RunningBuild
|
||||
import de.hoennig.werkator.config.BuildDefinition
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.server.UiFormats
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.IOException
|
||||
@@ -14,7 +13,6 @@ import java.nio.channels.Channels
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
|
||||
@@ -26,31 +24,29 @@ import java.time.Duration
|
||||
@Component
|
||||
class ConsoleBuildRunner(
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val repository: BuildResultRepository,
|
||||
private val artifactStore: ArtifactStore,
|
||||
) {
|
||||
var pollIntervalMillis = 200L
|
||||
|
||||
var persistTimeoutMillis = 30_000L
|
||||
|
||||
/** Builds [branch] at [commit], blocking until the build finished; returns the final status. */
|
||||
/** Builds [branch] of [repo] at [commit], blocking until the build finished; returns the final status. */
|
||||
fun buildAndStream(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
buildDefinition: String = BuildDefinition.DEFAULT,
|
||||
): BuildStatus {
|
||||
val build = buildExecutor.startBuild(branch, commit, workingDir, buildDefinition)
|
||||
val build = buildExecutor.startBuild(repo, branch, commit, buildDefinition)
|
||||
var printed = 0L
|
||||
var result: BuildResult? = null
|
||||
while (result?.status?.isTerminal != true) {
|
||||
printed += printNewLogBytes(build.liveLogFile, printed)
|
||||
result = repository.history().firstOrNull { it.artifactKey == build.artifactKey }
|
||||
result = repo.results.history().firstOrNull { it.artifactKey == build.artifactKey }
|
||||
if (result?.status?.isTerminal != true) {
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
drainAfterBuild(build, printed)
|
||||
drainAfterBuild(repo, build, printed)
|
||||
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
|
||||
println("build of branch $branch: ${result.status.name.lowercase()}$after")
|
||||
return result.status
|
||||
@@ -64,6 +60,7 @@ class ConsoleBuildRunner(
|
||||
* stored copy (which is byte-identical, so the offset carries over).
|
||||
*/
|
||||
private fun drainAfterBuild(
|
||||
repo: RepoContext,
|
||||
build: RunningBuild,
|
||||
alreadyPrinted: Long,
|
||||
) {
|
||||
@@ -79,7 +76,7 @@ class ConsoleBuildRunner(
|
||||
}
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||
repo.artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
@@ -25,16 +24,18 @@ import java.util.concurrent.Callable
|
||||
)
|
||||
class RetryCommand(
|
||||
private val gitService: GitService,
|
||||
private val repository: BuildResultRepository,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */
|
||||
var repo: RepoContext,
|
||||
) : Callable<Int> {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
override fun call(): Int {
|
||||
val failed: List<BuildResult>
|
||||
try {
|
||||
fetchBestEffort()
|
||||
failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||
} catch (e: Exception) {
|
||||
System.err.println("error: ${e.message}")
|
||||
return ExitCode.USAGE
|
||||
@@ -52,7 +53,7 @@ class RetryCommand(
|
||||
}
|
||||
println("retrying build ${result.name} at commit ${commit.take(12)}")
|
||||
// a failed build retries its recorded build definition (settings from the current config)
|
||||
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.build)
|
||||
val status = consoleBuildRunner.buildAndStream(repo, result.branch, commit, result.build)
|
||||
if (status != BuildStatus.SUCCESS) {
|
||||
anyFailed = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.hoennig.werkator.repo
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Configuration
|
||||
class RepoConfiguration {
|
||||
/**
|
||||
* The single-repository case: the current working directory, which is how every
|
||||
* CLI command and the server resolve their files. Only paths are computed here, so
|
||||
* the bean is safe outside a git repository.
|
||||
*/
|
||||
@Bean
|
||||
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get("."))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.hoennig.werkator.repo
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Everything Werkator needs to work on one repository (ADR 0009): its primary
|
||||
* checkout, and the state that already lives inside or is keyed by it — build
|
||||
* results in `.git/werkator/`, the artifact store keyed by the repository path.
|
||||
* Git access and config loading stay path-based services and take [workingDir].
|
||||
*
|
||||
* One instance exists per registered repository, and the instance itself is the
|
||||
* identity: the executor serializes builds per (context, branch), so two contexts
|
||||
* for the same directory would build it concurrently. Today there is exactly one,
|
||||
* the current working directory ([RepoConfiguration]); the registry of the next
|
||||
* session creates one per entry.
|
||||
*/
|
||||
class RepoContext(
|
||||
/** Short unique name for display and, once routes carry it, the route segment; defaults to the directory basename. */
|
||||
val name: String,
|
||||
/** The primary checkout; never built in, its `.git/werkator/` holds the repository's state. */
|
||||
val workingDir: Path,
|
||||
val results: BuildResultRepository,
|
||||
val artifactStore: ArtifactStore,
|
||||
) {
|
||||
override fun toString(): String = "RepoContext($name at $workingDir)"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.hoennig.werkator.repo
|
||||
|
||||
import de.hoennig.werkator.artifacts.FileArtifactStore
|
||||
import de.hoennig.werkator.build.FileBuildResultRepository
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */
|
||||
@Component
|
||||
class RepoContexts(
|
||||
private val configLoader: ConfigLoader,
|
||||
) {
|
||||
fun open(
|
||||
workingDir: Path,
|
||||
name: String = defaultName(workingDir),
|
||||
): RepoContext =
|
||||
RepoContext(
|
||||
name = name,
|
||||
workingDir = workingDir,
|
||||
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
||||
artifactStore = FileArtifactStore(configLoader, workingDir),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
||||
const val RESULTS_FILE = ".git/werkator/build-results.json"
|
||||
|
||||
/** The directory basename (ADR 0009); a filesystem root has none and falls back to a constant. */
|
||||
fun defaultName(workingDir: Path): String =
|
||||
workingDir
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.fileName
|
||||
?.toString()
|
||||
?: "repository"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* The branches-view data, shared by the JSON API and the server-rendered page:
|
||||
@@ -18,10 +16,10 @@ import java.nio.file.Paths
|
||||
@Component
|
||||
class BranchListing(
|
||||
private val gitService: GitService,
|
||||
private val repository: BuildResultRepository,
|
||||
) {
|
||||
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
fun branches(repo: RepoContext): List<BranchDto> {
|
||||
val repository = repo.results
|
||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||
val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
|
||||
val branchRows =
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.hoennig.werkator.build.BuildResultRepository
|
||||
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 org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
@@ -20,7 +21,6 @@ import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
/**
|
||||
@@ -38,15 +38,17 @@ class BuildsApiController(
|
||||
private val controlTokens: ControlTokenService,
|
||||
private val gitService: GitService,
|
||||
private val branchListing: BranchListing,
|
||||
private val repo: RepoContext,
|
||||
) {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
@GetMapping("/api/builds/latest")
|
||||
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
||||
|
||||
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||
@GetMapping("/api/branches")
|
||||
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
|
||||
fun branches(): List<BranchDto> = branchListing.branches(repo)
|
||||
|
||||
@GetMapping("/api/builds/history")
|
||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
||||
@@ -122,6 +124,7 @@ class BuildsApiController(
|
||||
// a restarted build re-runs its recorded build definition (settings from the current config)
|
||||
val running =
|
||||
buildExecutor.startBuild(
|
||||
repo = repo,
|
||||
branch = branchName,
|
||||
commit = commit,
|
||||
build = latest?.build ?: BuildDefinition.DEFAULT,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.watcher.Watcher
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
@@ -8,18 +9,19 @@ import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* Starts the watcher poll loop once the server context is ready and stops it on
|
||||
* shutdown. Only in the `server` profile — CLI commands and tests never start
|
||||
* the loop (see [Watcher]).
|
||||
* Starts the watcher poll loop over the served repository once the server context
|
||||
* is ready and stops it on shutdown. Only in the `server` profile — CLI commands
|
||||
* and tests never start the loop (see [Watcher]).
|
||||
*/
|
||||
@Component
|
||||
@Profile("server")
|
||||
class ServerWatcherLifecycle(
|
||||
private val watcher: Watcher,
|
||||
private val repo: RepoContext,
|
||||
) {
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
watcher.start()
|
||||
watcher.start(repo)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.hoennig.werkator.config.ConfigFiles
|
||||
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 jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.boot.info.BuildProperties
|
||||
@@ -22,7 +23,6 @@ import org.springframework.web.servlet.view.RedirectView
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.name
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
@@ -43,8 +43,10 @@ class UiController(
|
||||
private val branchListing: BranchListing,
|
||||
private val branchPermalinks: BranchPermalinks,
|
||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
||||
private val repo: RepoContext,
|
||||
) {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
/**
|
||||
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||
@@ -71,7 +73,7 @@ class UiController(
|
||||
@GetMapping("/branches")
|
||||
fun branches(model: Model): String {
|
||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
||||
model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("apiPath", "/api/branches")
|
||||
model.addAttribute("allowRestart", true)
|
||||
// a row here stands for a branch, not for a past run
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.hoennig.werkator.watcher
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactKeys
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildExecutor
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.build.GitWorktreeWorkspaces
|
||||
import de.hoennig.werkator.config.BuildDefinition
|
||||
@@ -12,11 +10,11 @@ import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.config.DurationParser
|
||||
import de.hoennig.werkator.config.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -39,8 +37,6 @@ import java.util.concurrent.TimeUnit
|
||||
class Watcher(
|
||||
private val gitService: GitService,
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val repository: BuildResultRepository,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@@ -51,39 +47,51 @@ class Watcher(
|
||||
@Volatile
|
||||
private var state = WatcherState()
|
||||
|
||||
/** The branches.*.autoBuild deprecation is logged once per watcher instance, not once per poll. */
|
||||
@Volatile
|
||||
private var warnedDeprecatedAutoBuild = false
|
||||
|
||||
/**
|
||||
* The fetch failure last written to the log, so a lasting outage does not repeat the
|
||||
* same warning on every poll — one wrong token produced 297 identical lines before
|
||||
* this. Null while the last fetch succeeded, which is also what makes the recovery
|
||||
* loggable.
|
||||
*/
|
||||
@Volatile
|
||||
private var loggedFetchError: String? = null
|
||||
|
||||
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
||||
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||
/** What the watcher remembers about a repository between polls, keyed by the context (identity). */
|
||||
private val watched = ConcurrentHashMap<RepoContext, RepoWatch>()
|
||||
|
||||
fun state(): WatcherState = state
|
||||
|
||||
private fun watchOf(repo: RepoContext): RepoWatch = watched.computeIfAbsent(repo) { RepoWatch() }
|
||||
|
||||
/**
|
||||
* The per-repository poll memory: what was logged already, and the cached branch
|
||||
* definitions. Kept apart from the shared [WatcherState] so that the next session
|
||||
* can iterate contexts without one repository's outage silencing another's.
|
||||
*/
|
||||
private class RepoWatch {
|
||||
/** The branches.*.autoBuild deprecation is logged once per repository, not once per poll. */
|
||||
@Volatile
|
||||
var warnedDeprecatedAutoBuild = false
|
||||
|
||||
/**
|
||||
* The fetch failure last written to the log, so a lasting outage does not repeat the
|
||||
* same warning on every poll — one wrong token produced 297 identical lines before
|
||||
* this. Null while the last fetch succeeded, which is also what makes the recovery
|
||||
* loggable.
|
||||
*/
|
||||
@Volatile
|
||||
var loggedFetchError: String? = null
|
||||
|
||||
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
||||
val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the startup recovery and schedules the poll loop with the fixed delay
|
||||
* `watcher.pollInterval`; the first poll runs immediately.
|
||||
*/
|
||||
@Synchronized
|
||||
fun start(workingDir: Path = Paths.get(".")) {
|
||||
fun start(repo: RepoContext) {
|
||||
check(scheduler == null) { "watcher is already running" }
|
||||
recoverOnStartup(workingDir)
|
||||
val interval = DurationParser.parse(configLoader.load(workingDir).watcher.pollInterval)
|
||||
recoverOnStartup(repo)
|
||||
val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval)
|
||||
scheduler =
|
||||
Executors
|
||||
.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "werkator-watcher").apply { isDaemon = true }
|
||||
}.also {
|
||||
it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||
it.scheduleWithFixedDelay({ pollSafely(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||
}
|
||||
state = state.copy(running = true)
|
||||
}
|
||||
@@ -100,7 +108,9 @@ class Watcher(
|
||||
* superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose
|
||||
* latest build never finished and which still exists on origin.
|
||||
*/
|
||||
fun recoverOnStartup(workingDir: Path = Paths.get(".")) {
|
||||
fun recoverOnStartup(repo: RepoContext) {
|
||||
val workingDir = repo.workingDir
|
||||
val repository = repo.results
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
@@ -130,7 +140,7 @@ class Watcher(
|
||||
}
|
||||
log.info("restarting unfinished build {} of branch {}", result.build, result.branch)
|
||||
// the re-run resolves its settings from the current config by the recorded build name
|
||||
buildExecutor.startBuild(result.branch, commit, workingDir, result.build)
|
||||
buildExecutor.startBuild(repo, result.branch, commit, result.build)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,46 +151,48 @@ class Watcher(
|
||||
* fast-forward the local branch refs, and finally prune results, artifacts, and
|
||||
* worktrees of branches gone from origin.
|
||||
*/
|
||||
fun poll(workingDir: Path = Paths.get(".")) {
|
||||
fun poll(repo: RepoContext) {
|
||||
val startedAt = clock.instant()
|
||||
val workingDir = repo.workingDir
|
||||
val watch = watchOf(repo)
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
if (loggedFetchError != null) {
|
||||
if (watch.loggedFetchError != null) {
|
||||
log.info("fetching origin succeeded again")
|
||||
loggedFetchError = null
|
||||
watch.loggedFetchError = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val failure = e.message ?: e.javaClass.simpleName
|
||||
if (loggedFetchError != failure) {
|
||||
if (watch.loggedFetchError != failure) {
|
||||
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure)
|
||||
loggedFetchError = failure
|
||||
watch.loggedFetchError = failure
|
||||
}
|
||||
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
|
||||
return
|
||||
}
|
||||
val config = configLoader.load(workingDir)
|
||||
val originBranches = gitService.originBranches(workingDir)
|
||||
enqueueDueBranches(config, originBranches.toSet(), workingDir)
|
||||
enqueueDueBranches(repo, config, originBranches.toSet())
|
||||
if (config.watcher.fastForwardLocalRefs) {
|
||||
fastForwardLocalRefs(workingDir)
|
||||
}
|
||||
prune(config, originBranches, workingDir)
|
||||
prune(repo, config, originBranches)
|
||||
state =
|
||||
state.copy(
|
||||
lastPollAt = startedAt,
|
||||
lastFetchError = null,
|
||||
lastPollError = null,
|
||||
queuedBranches =
|
||||
repository
|
||||
repo.results
|
||||
.latestPerName()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
private fun pollSafely(workingDir: Path) {
|
||||
private fun pollSafely(repo: RepoContext) {
|
||||
try {
|
||||
poll(workingDir)
|
||||
poll(repo)
|
||||
} catch (e: Exception) {
|
||||
log.error("poll cycle failed", e)
|
||||
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
||||
@@ -208,16 +220,17 @@ class Watcher(
|
||||
}
|
||||
|
||||
private fun enqueueDueBranches(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: Set<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val workingDir = repo.workingDir
|
||||
// one ls-remote per poll cycle at most, and only when a due branch requires a pull request
|
||||
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
|
||||
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
|
||||
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
branchDefinitions.keys.retainAll(originBranches)
|
||||
watchOf(repo).branchDefinitions.keys.retainAll(originBranches)
|
||||
val changedLocal =
|
||||
gitService
|
||||
.localBranches(workingDir)
|
||||
@@ -226,15 +239,15 @@ class Watcher(
|
||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||
val changed = (changedLocal + newOrigin).distinct()
|
||||
for (branch in changed) {
|
||||
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.trigger.onPush }
|
||||
val onPush = definitionsFor(repo, branch, heads[branch], config).filterValues { it.trigger.onPush }
|
||||
for ((buildName, definition) in onPush) {
|
||||
if (selects(definition, branch, headCommitTimes)) {
|
||||
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
|
||||
startBuildIfDue(repo, branch, allowSameCommit = false, config, pullRequestHeads, buildName)
|
||||
}
|
||||
}
|
||||
}
|
||||
enqueueScheduledBuilds(config, originBranches, heads, pullRequestHeads, headCommitTimes, workingDir)
|
||||
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
|
||||
enqueueScheduledBuilds(repo, config, originBranches, heads, pullRequestHeads, headCommitTimes)
|
||||
enqueueDeprecatedAutoBuilds(repo, config, originBranches, pullRequestHeads)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,11 +265,13 @@ class Watcher(
|
||||
* instead of failing the poll cycle.
|
||||
*/
|
||||
private fun definitionsFor(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
headCommit: String?,
|
||||
workingDir: Path,
|
||||
primary: WerkatorConfig,
|
||||
): Map<String, BuildDefinition> {
|
||||
val workingDir = repo.workingDir
|
||||
val branchDefinitions = watchOf(repo).branchDefinitions
|
||||
val commit = headCommit ?: return primary.effectiveBuildDefinitions()
|
||||
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
|
||||
val definitions =
|
||||
@@ -305,14 +320,15 @@ class Watcher(
|
||||
* without pull-request refs.
|
||||
*/
|
||||
private fun startBuildIfDue(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
allowSameCommit: Boolean,
|
||||
config: WerkatorConfig,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
workingDir: Path,
|
||||
build: String = BuildDefinition.DEFAULT,
|
||||
): Boolean {
|
||||
val latest = repository.latestFor(BuildDefinition.poolName(branch, build))
|
||||
val workingDir = repo.workingDir
|
||||
val latest = repo.results.latestFor(BuildDefinition.poolName(branch, build))
|
||||
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
|
||||
return false
|
||||
}
|
||||
@@ -328,7 +344,7 @@ class Watcher(
|
||||
return false
|
||||
}
|
||||
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
|
||||
buildExecutor.startBuild(branch, commit, workingDir, build)
|
||||
buildExecutor.startBuild(repo, branch, commit, build)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -338,20 +354,20 @@ class Watcher(
|
||||
* the point of a scheduled build.
|
||||
*/
|
||||
private fun enqueueScheduledBuilds(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: Set<String>,
|
||||
heads: Map<String, String>,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
headCommitTimes: Lazy<Map<String, Instant>>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val autoBuildState = lazy { FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE)) }
|
||||
val autoBuildState = lazy { FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE)) }
|
||||
val now = clock.instant()
|
||||
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||
for (branch in originBranches) {
|
||||
val scheduled =
|
||||
definitionsFor(branch, heads[branch], workingDir, config).filterValues {
|
||||
definitionsFor(repo, branch, heads[branch], config).filterValues {
|
||||
it.trigger.atTimes.isNotEmpty()
|
||||
}
|
||||
for ((buildName, definition) in scheduled) {
|
||||
@@ -363,7 +379,7 @@ class Watcher(
|
||||
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
||||
continue
|
||||
}
|
||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir, buildName)) {
|
||||
if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads, buildName)) {
|
||||
autoBuildState.value.markTriggered(pool, today, slot)
|
||||
}
|
||||
}
|
||||
@@ -376,10 +392,10 @@ class Watcher(
|
||||
* `builds` entry with `atTimes` and a single-branch selector would do.
|
||||
*/
|
||||
private fun enqueueDeprecatedAutoBuilds(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: Set<String>,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val autoBuildBranches =
|
||||
config.branches.filter { (branch, branchConfig) ->
|
||||
@@ -388,14 +404,15 @@ class Watcher(
|
||||
if (autoBuildBranches.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (!warnedDeprecatedAutoBuild) {
|
||||
warnedDeprecatedAutoBuild = true
|
||||
val watch = watchOf(repo)
|
||||
if (!watch.warnedDeprecatedAutoBuild) {
|
||||
watch.warnedDeprecatedAutoBuild = true
|
||||
log.warn(
|
||||
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
|
||||
autoBuildBranches.keys.joinToString(", "),
|
||||
)
|
||||
}
|
||||
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE))
|
||||
val autoBuildState = FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE))
|
||||
val now = clock.instant()
|
||||
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||
@@ -408,7 +425,7 @@ class Watcher(
|
||||
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
|
||||
continue
|
||||
}
|
||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) {
|
||||
if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads)) {
|
||||
autoBuildState.markTriggered(branch, today, slot)
|
||||
}
|
||||
}
|
||||
@@ -416,28 +433,29 @@ class Watcher(
|
||||
|
||||
/** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */
|
||||
private fun prune(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val retentionCutoff =
|
||||
config.artifacts.retentionMaxAge
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { clock.instant().minus(DurationParser.parse(it)) }
|
||||
repository.prune(
|
||||
repo.results.prune(
|
||||
originBranches,
|
||||
config.artifacts.retentionPerBranch,
|
||||
config.artifacts.keepLatestGreen,
|
||||
retentionCutoff,
|
||||
)
|
||||
artifactStore.prune(repository.history())
|
||||
pruneWorktrees(originBranches, workingDir)
|
||||
repo.artifactStore.prune(repo.results.history())
|
||||
pruneWorktrees(repo, originBranches)
|
||||
}
|
||||
|
||||
private fun pruneWorktrees(
|
||||
repo: RepoContext,
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val workingDir = repo.workingDir
|
||||
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
|
||||
if (!Files.isDirectory(worktreesDir)) {
|
||||
return
|
||||
@@ -445,7 +463,7 @@ class Watcher(
|
||||
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
||||
// never delete under a build that is still queued or executing
|
||||
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||
repository
|
||||
repo.results
|
||||
.latestPerName()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||
|
||||
Reference in New Issue
Block a user