Stable per-branch report URLs and a reachable live view (v0.9.8)
A report directory holding a single page is now linked and served as a directory, so Gradle's --profile report has a stable permanent URL although its file name carries the build timestamp. The permanent link moves to the build it resolves to — the branch's latest green build — and appears on every build table instead of only the branches view. The Current tab gave way to a link in the artifacts column, shown while a build runs; /current itself stays routable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c1869ea427
commit
962836beaf
@@ -17,18 +17,23 @@ data class BuildResultDto(
|
||||
val runningSince: Instant? = null,
|
||||
val durationSeconds: Long?,
|
||||
val artifactKey: String,
|
||||
/** The permanent branch URL, set only on the build it resolves to — the branch's latest green build. */
|
||||
val latestGreenUrl: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: BuildResult) =
|
||||
BuildResultDto(
|
||||
branch = result.branch,
|
||||
commit = result.commit,
|
||||
status = result.status.jsonName,
|
||||
startedAt = result.startedAt,
|
||||
runningSince = result.runningSince,
|
||||
durationSeconds = result.duration?.seconds,
|
||||
artifactKey = result.artifactKey,
|
||||
)
|
||||
fun from(
|
||||
result: BuildResult,
|
||||
isLatestGreen: Boolean = false,
|
||||
) = BuildResultDto(
|
||||
branch = result.branch,
|
||||
commit = result.commit,
|
||||
status = result.status.jsonName,
|
||||
startedAt = result.startedAt,
|
||||
runningSince = result.runningSince,
|
||||
durationSeconds = result.duration?.seconds,
|
||||
artifactKey = result.artifactKey,
|
||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.branch) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +41,8 @@ data class BuildResultDto(
|
||||
* One entry of `GET /api/branches`, like a legacy branches-view row: an origin
|
||||
* branch with its latest build, or an `unknown` placeholder when never built.
|
||||
* [latestGreenUrl] is the permanent artifact URL of the branch's latest green
|
||||
* build; null while the branch has never built successfully.
|
||||
* build; set only when this row's build is that green build, so the link appears
|
||||
* where it resolves to.
|
||||
*/
|
||||
data class BranchDto(
|
||||
val branch: String,
|
||||
@@ -53,7 +59,7 @@ data class BranchDto(
|
||||
branch: String,
|
||||
headCommit: String,
|
||||
latest: BuildResult?,
|
||||
hasGreenBuild: Boolean = false,
|
||||
isLatestGreen: Boolean = false,
|
||||
) = if (latest == null) {
|
||||
BranchDto(
|
||||
branch = branch,
|
||||
@@ -72,7 +78,7 @@ data class BranchDto(
|
||||
runningSince = latest.runningSince,
|
||||
durationSeconds = latest.duration?.seconds,
|
||||
artifactKey = latest.artifactKey,
|
||||
latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null,
|
||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(branch) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption
|
||||
import java.nio.file.Path
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
/**
|
||||
* Streams stored build artifacts. Status pages, JSON, and logs are served with
|
||||
@@ -31,12 +32,15 @@ class ArtifactFileController(
|
||||
fun serve(
|
||||
@PathVariable artifactKey: String,
|
||||
@PathVariable path: String,
|
||||
request: HttpServletRequest,
|
||||
): ResponseEntity<Resource> {
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(artifactKey)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||
val file =
|
||||
resolveFile(artifactDir, path.removePrefix("/"))
|
||||
resolveFile(artifactDir, relativePath)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS)
|
||||
}
|
||||
@@ -66,24 +70,53 @@ class ArtifactFileController(
|
||||
// the bare permanent URL is the artifact-index page rendered by the UI controller
|
||||
return redirect(request.requestURI.trimEnd('/'))
|
||||
}
|
||||
val target = artifactDir.resolve(relativePath).normalize()
|
||||
if (target.startsWith(artifactDir) &&
|
||||
Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS) &&
|
||||
Files.isRegularFile(target.resolve(INDEX_FILE), LinkOption.NOFOLLOW_LINKS)
|
||||
) {
|
||||
// relative links inside a report only resolve correctly under a trailing-slash URL
|
||||
return if (request.requestURI.endsWith("/")) {
|
||||
respond(target.resolve(INDEX_FILE), noStore = true)
|
||||
} else {
|
||||
redirect(request.requestURI + "/")
|
||||
}
|
||||
}
|
||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||
val file =
|
||||
resolveFile(artifactDir, relativePath)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
return respond(file, noStore = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The response for a directory URL, or null when [relativePath] is no servable directory.
|
||||
* A directory serves its `index.html`, or the single HTML page of a report directory without
|
||||
* one — that keeps Gradle's `--profile` report, whose file name carries the build timestamp,
|
||||
* reachable under a stable URL.
|
||||
*/
|
||||
private fun directoryResponse(
|
||||
artifactDir: Path,
|
||||
relativePath: String,
|
||||
request: HttpServletRequest,
|
||||
noStore: Boolean,
|
||||
): ResponseEntity<Resource>? {
|
||||
val target = artifactDir.resolve(relativePath).normalize()
|
||||
if (relativePath.isBlank() || !target.startsWith(artifactDir) || !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null
|
||||
}
|
||||
val page = directoryPage(target) ?: return null
|
||||
// relative links inside a report only resolve correctly under a trailing-slash URL
|
||||
return if (request.requestURI.endsWith("/")) {
|
||||
respond(page, noStore = noStore)
|
||||
} else {
|
||||
redirect(request.requestURI + "/")
|
||||
}
|
||||
}
|
||||
|
||||
private fun directoryPage(dir: Path): Path? {
|
||||
val index = dir.resolve(INDEX_FILE)
|
||||
if (Files.isRegularFile(index, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return index
|
||||
}
|
||||
return Files
|
||||
.list(dir)
|
||||
.use { entries ->
|
||||
entries
|
||||
.asSequence()
|
||||
.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) && it.extension() == "html" }
|
||||
.toList()
|
||||
}.singleOrNull()
|
||||
}
|
||||
|
||||
/** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */
|
||||
private fun resolveFile(
|
||||
artifactDir: Path,
|
||||
|
||||
@@ -24,11 +24,13 @@ class BranchListing(
|
||||
.entries
|
||||
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
|
||||
.map { (branch, headCommit) ->
|
||||
val latest = repository.latestFor(branch)
|
||||
BranchDto.from(
|
||||
branch,
|
||||
headCommit,
|
||||
repository.latestFor(branch),
|
||||
hasGreenBuild = repository.latestGreenFor(branch) != null,
|
||||
latest,
|
||||
// the permanent link belongs to the build it resolves to, not to every build of the branch
|
||||
isLatestGreen = latest != null && latest.artifactKey == repository.latestGreenFor(branch)?.artifactKey,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.git.GitService
|
||||
@@ -38,14 +39,16 @@ class BuildsApiController(
|
||||
var workingDir: Path = Paths.get(".")
|
||||
|
||||
@GetMapping("/api/builds/latest")
|
||||
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) }
|
||||
fun latest(): List<BuildResultDto> = repository.latestPerBranch().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)
|
||||
|
||||
@GetMapping("/api/builds/history")
|
||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) }
|
||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
|
||||
|
||||
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(branch)?.artifactKey == artifactKey
|
||||
|
||||
/** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */
|
||||
@GetMapping("/api/builds/current")
|
||||
|
||||
@@ -56,7 +56,7 @@ class UiController(
|
||||
@GetMapping("/")
|
||||
fun latest(model: Model): String {
|
||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
||||
model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
||||
model.addAttribute("apiPath", "/api/builds/latest")
|
||||
model.addAttribute("allowRestart", true)
|
||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
||||
@@ -77,13 +77,21 @@ class UiController(
|
||||
@GetMapping("/history")
|
||||
fun history(model: Model): String {
|
||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
||||
model.addAttribute("apiPath", "/api/builds/history")
|
||||
model.addAttribute("allowRestart", false)
|
||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
||||
return "builds"
|
||||
}
|
||||
|
||||
/** The permanent branch URL belongs to the build it resolves to — the branch's latest green build. */
|
||||
private fun permanentUrlOf(result: BuildResult): String? =
|
||||
if (repository.latestGreenFor(result.branch)?.artifactKey == result.artifactKey) {
|
||||
BranchPermalinks.permanentUrl(result.branch)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@GetMapping("/current")
|
||||
fun current(model: Model): String {
|
||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
||||
@@ -262,9 +270,10 @@ class UiController(
|
||||
knownDirs.any { known -> known.isEmpty() || this == known || this.startsWith("$known/") }
|
||||
|
||||
/**
|
||||
* Report pages of directories without an `index.html`, such as Gradle's `--profile` report
|
||||
* with its timestamped file name. Only `reports/` itself and its direct sub-directories are
|
||||
* scanned, so that a report tree cannot flood the artifact index with its inner pages.
|
||||
* Report pages of directories without an `index.html`, such as Gradle's `--profile` report.
|
||||
* A directory holding a single page is linked as a directory, so that a timestamped file name
|
||||
* does not leak into the permanent `/branches/…` URLs. Only `reports/` itself and its direct
|
||||
* sub-directories are scanned, so that a report tree cannot flood the artifact index.
|
||||
*/
|
||||
private fun indexLessReportPages(
|
||||
reportsDir: Path,
|
||||
@@ -280,13 +289,16 @@ class UiController(
|
||||
return candidateDirs
|
||||
.filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) }
|
||||
.flatMap { dir ->
|
||||
Files.list(dir).use { pages ->
|
||||
pages
|
||||
.asSequence()
|
||||
.filter { Files.isRegularFile(it) && it.name.endsWith(".html") }
|
||||
.map { reportsDir.relativize(it).toString() }
|
||||
.toList()
|
||||
}
|
||||
val pages =
|
||||
Files.list(dir).use { entries ->
|
||||
entries
|
||||
.asSequence()
|
||||
.filter { Files.isRegularFile(it) && it.name.endsWith(".html") }
|
||||
.map { reportsDir.relativize(it).toString() }
|
||||
.toList()
|
||||
}
|
||||
val dirPath = reportsDir.relativize(dir).toString()
|
||||
if (pages.size == 1 && dirPath.isNotEmpty()) listOf("$dirPath/") else pages
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ object UiFormats {
|
||||
private const val UTILIZATION_CRIT = 0.90
|
||||
}
|
||||
|
||||
/** One row of the latest/history build tables; [latestGreenUrl] only on the branches view. */
|
||||
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
|
||||
data class BuildRowView(
|
||||
val branch: String,
|
||||
val commit: String,
|
||||
@@ -112,6 +112,7 @@ data class BuildRowView(
|
||||
fun from(
|
||||
result: BuildResult,
|
||||
links: GiteaWebLinks,
|
||||
latestGreenUrl: String? = null,
|
||||
) = BuildRowView(
|
||||
branch = result.branch,
|
||||
commit = result.commit,
|
||||
@@ -124,6 +125,7 @@ data class BuildRowView(
|
||||
artifactKey = result.artifactKey,
|
||||
branchUrl = links.branchUrl(result.branch),
|
||||
commitUrl = links.commitUrl(result.commit),
|
||||
latestGreenUrl = latestGreenUrl,
|
||||
inProgress = !result.status.isTerminal,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user