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
@@ -84,3 +84,7 @@ On polling pages it triggers an immediate data refresh via the page's poller; pa
|
|||||||
Addendum (2026-07-07): all links that leave the GitTally UI open in a new tab (`target="_blank" rel="noopener noreferrer"`).
|
Addendum (2026-07-07): all links that leave the GitTally UI open in a new tab (`target="_blank" rel="noopener noreferrer"`).
|
||||||
This already held for Gitea branch/commit links and the footer; it was added for the artifact page's log and report links, whose targets have no navigation.
|
This already held for Gitea branch/commit links and the footer; it was added for the artifact page's log and report links, whose targets have no navigation.
|
||||||
Links between GitTally pages (nav, artifact index) stay in the same tab.
|
Links between GitTally pages (nav, artifact index) stay in the same tab.
|
||||||
|
|
||||||
|
Addendum (2026-08-10): the artifacts column carries the whole build-reachability logic, and the nav lost its `Current` entry.
|
||||||
|
The permanent `🔗` link is rendered on the build it resolves to — the branch's latest green build — on every build table, instead of on each row of a branch with any green build.
|
||||||
|
A `📡` link to `/current` appears while a build runs; `/current` itself stays a routable page, it just has no tab of its own anymore.
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ data class BuildResultDto(
|
|||||||
val runningSince: Instant? = null,
|
val runningSince: Instant? = null,
|
||||||
val durationSeconds: Long?,
|
val durationSeconds: Long?,
|
||||||
val artifactKey: String,
|
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 {
|
companion object {
|
||||||
fun from(result: BuildResult) =
|
fun from(
|
||||||
BuildResultDto(
|
result: BuildResult,
|
||||||
|
isLatestGreen: Boolean = false,
|
||||||
|
) = BuildResultDto(
|
||||||
branch = result.branch,
|
branch = result.branch,
|
||||||
commit = result.commit,
|
commit = result.commit,
|
||||||
status = result.status.jsonName,
|
status = result.status.jsonName,
|
||||||
@@ -28,6 +32,7 @@ data class BuildResultDto(
|
|||||||
runningSince = result.runningSince,
|
runningSince = result.runningSince,
|
||||||
durationSeconds = result.duration?.seconds,
|
durationSeconds = result.duration?.seconds,
|
||||||
artifactKey = result.artifactKey,
|
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
|
* 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.
|
* branch with its latest build, or an `unknown` placeholder when never built.
|
||||||
* [latestGreenUrl] is the permanent artifact URL of the branch's latest green
|
* [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(
|
data class BranchDto(
|
||||||
val branch: String,
|
val branch: String,
|
||||||
@@ -53,7 +59,7 @@ data class BranchDto(
|
|||||||
branch: String,
|
branch: String,
|
||||||
headCommit: String,
|
headCommit: String,
|
||||||
latest: BuildResult?,
|
latest: BuildResult?,
|
||||||
hasGreenBuild: Boolean = false,
|
isLatestGreen: Boolean = false,
|
||||||
) = if (latest == null) {
|
) = if (latest == null) {
|
||||||
BranchDto(
|
BranchDto(
|
||||||
branch = branch,
|
branch = branch,
|
||||||
@@ -72,7 +78,7 @@ data class BranchDto(
|
|||||||
runningSince = latest.runningSince,
|
runningSince = latest.runningSince,
|
||||||
durationSeconds = latest.duration?.seconds,
|
durationSeconds = latest.duration?.seconds,
|
||||||
artifactKey = latest.artifactKey,
|
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.Files
|
||||||
import java.nio.file.LinkOption
|
import java.nio.file.LinkOption
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import kotlin.streams.asSequence
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams stored build artifacts. Status pages, JSON, and logs are served with
|
* Streams stored build artifacts. Status pages, JSON, and logs are served with
|
||||||
@@ -31,12 +32,15 @@ class ArtifactFileController(
|
|||||||
fun serve(
|
fun serve(
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(artifactKey)
|
artifactStore.artifactDir(artifactKey)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
|
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||||
|
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||||
val file =
|
val file =
|
||||||
resolveFile(artifactDir, path.removePrefix("/"))
|
resolveFile(artifactDir, relativePath)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS)
|
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
|
// the bare permanent URL is the artifact-index page rendered by the UI controller
|
||||||
return redirect(request.requestURI.trimEnd('/'))
|
return redirect(request.requestURI.trimEnd('/'))
|
||||||
}
|
}
|
||||||
val target = artifactDir.resolve(relativePath).normalize()
|
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||||
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 + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val file =
|
val file =
|
||||||
resolveFile(artifactDir, relativePath)
|
resolveFile(artifactDir, relativePath)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
return respond(file, noStore = true)
|
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. */
|
/** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */
|
||||||
private fun resolveFile(
|
private fun resolveFile(
|
||||||
artifactDir: Path,
|
artifactDir: Path,
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ class BranchListing(
|
|||||||
.entries
|
.entries
|
||||||
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
|
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
|
||||||
.map { (branch, headCommit) ->
|
.map { (branch, headCommit) ->
|
||||||
|
val latest = repository.latestFor(branch)
|
||||||
BranchDto.from(
|
BranchDto.from(
|
||||||
branch,
|
branch,
|
||||||
headCommit,
|
headCommit,
|
||||||
repository.latestFor(branch),
|
latest,
|
||||||
hasGreenBuild = repository.latestGreenFor(branch) != null,
|
// 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.ArtifactStore
|
||||||
import de.hoennig.gittally.build.BuildExecutor
|
import de.hoennig.gittally.build.BuildExecutor
|
||||||
|
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.git.GitService
|
import de.hoennig.gittally.git.GitService
|
||||||
@@ -38,14 +39,16 @@ class BuildsApiController(
|
|||||||
var workingDir: Path = Paths.get(".")
|
var workingDir: Path = Paths.get(".")
|
||||||
|
|
||||||
@GetMapping("/api/builds/latest")
|
@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`. */
|
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||||
@GetMapping("/api/branches")
|
@GetMapping("/api/branches")
|
||||||
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
|
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
|
||||||
|
|
||||||
@GetMapping("/api/builds/history")
|
@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`. */
|
/** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */
|
||||||
@GetMapping("/api/builds/current")
|
@GetMapping("/api/builds/current")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class UiController(
|
|||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
fun latest(model: Model): String {
|
fun latest(model: Model): String {
|
||||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
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("apiPath", "/api/builds/latest")
|
||||||
model.addAttribute("allowRestart", true)
|
model.addAttribute("allowRestart", true)
|
||||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
||||||
@@ -77,13 +77,21 @@ class UiController(
|
|||||||
@GetMapping("/history")
|
@GetMapping("/history")
|
||||||
fun history(model: Model): String {
|
fun history(model: Model): String {
|
||||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
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("apiPath", "/api/builds/history")
|
||||||
model.addAttribute("allowRestart", false)
|
model.addAttribute("allowRestart", false)
|
||||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
model.addAttribute("emptyMessage", "No builds archived yet.")
|
||||||
return "builds"
|
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")
|
@GetMapping("/current")
|
||||||
fun current(model: Model): String {
|
fun current(model: Model): String {
|
||||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
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/") }
|
knownDirs.any { known -> known.isEmpty() || this == known || this.startsWith("$known/") }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report pages of directories without an `index.html`, such as Gradle's `--profile` report
|
* 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
|
* A directory holding a single page is linked as a directory, so that a timestamped file name
|
||||||
* scanned, so that a report tree cannot flood the artifact index with its inner pages.
|
* 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(
|
private fun indexLessReportPages(
|
||||||
reportsDir: Path,
|
reportsDir: Path,
|
||||||
@@ -280,13 +289,16 @@ class UiController(
|
|||||||
return candidateDirs
|
return candidateDirs
|
||||||
.filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) }
|
.filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) }
|
||||||
.flatMap { dir ->
|
.flatMap { dir ->
|
||||||
Files.list(dir).use { pages ->
|
val pages =
|
||||||
pages
|
Files.list(dir).use { entries ->
|
||||||
|
entries
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { Files.isRegularFile(it) && it.name.endsWith(".html") }
|
.filter { Files.isRegularFile(it) && it.name.endsWith(".html") }
|
||||||
.map { reportsDir.relativize(it).toString() }
|
.map { reportsDir.relativize(it).toString() }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
val dirPath = reportsDir.relativize(dir).toString()
|
||||||
|
if (pages.size == 1 && dirPath.isNotEmpty()) listOf("$dirPath/") else pages
|
||||||
}.sorted()
|
}.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ object UiFormats {
|
|||||||
private const val UTILIZATION_CRIT = 0.90
|
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(
|
data class BuildRowView(
|
||||||
val branch: String,
|
val branch: String,
|
||||||
val commit: String,
|
val commit: String,
|
||||||
@@ -112,6 +112,7 @@ data class BuildRowView(
|
|||||||
fun from(
|
fun from(
|
||||||
result: BuildResult,
|
result: BuildResult,
|
||||||
links: GiteaWebLinks,
|
links: GiteaWebLinks,
|
||||||
|
latestGreenUrl: String? = null,
|
||||||
) = BuildRowView(
|
) = BuildRowView(
|
||||||
branch = result.branch,
|
branch = result.branch,
|
||||||
commit = result.commit,
|
commit = result.commit,
|
||||||
@@ -124,6 +125,7 @@ data class BuildRowView(
|
|||||||
artifactKey = result.artifactKey,
|
artifactKey = result.artifactKey,
|
||||||
branchUrl = links.branchUrl(result.branch),
|
branchUrl = links.branchUrl(result.branch),
|
||||||
commitUrl = links.commitUrl(result.commit),
|
commitUrl = links.commitUrl(result.commit),
|
||||||
|
latestGreenUrl = latestGreenUrl,
|
||||||
inProgress = !result.status.isTerminal,
|
inProgress = !result.status.isTerminal,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -250,8 +250,8 @@ function renderBuildRow(build, allowRestart) {
|
|||||||
|
|
||||||
const artifactsCell = elem("td");
|
const artifactsCell = elem("td");
|
||||||
artifactsCell.dataset.label = "Artifacts";
|
artifactsCell.dataset.label = "Artifacts";
|
||||||
if (build.artifactKey) {
|
|
||||||
const inProgress = build.status === "running" || build.status === "pending";
|
const inProgress = build.status === "running" || build.status === "pending";
|
||||||
|
if (build.artifactKey) {
|
||||||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
||||||
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
||||||
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||||||
@@ -263,7 +263,13 @@ function renderBuildRow(build, allowRestart) {
|
|||||||
permanentLink.title = "Permanent link: artifacts of the latest green build";
|
permanentLink.title = "Permanent link: artifacts of the latest green build";
|
||||||
artifactsCell.appendChild(permanentLink);
|
artifactsCell.appendChild(permanentLink);
|
||||||
}
|
}
|
||||||
if (!build.artifactKey && !build.latestGreenUrl) {
|
if (inProgress) {
|
||||||
|
const liveLink = elem("a", "artifact-link", "📡");
|
||||||
|
liveLink.href = "/current";
|
||||||
|
liveLink.title = "Watch this build live";
|
||||||
|
artifactsCell.appendChild(liveLink);
|
||||||
|
}
|
||||||
|
if (!build.artifactKey && !build.latestGreenUrl && !inProgress) {
|
||||||
artifactsCell.textContent = "n/a";
|
artifactsCell.textContent = "n/a";
|
||||||
}
|
}
|
||||||
row.appendChild(artifactsCell);
|
row.appendChild(artifactsCell);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
||||||
th:href="${row.latestGreenUrl}"
|
th:href="${row.latestGreenUrl}"
|
||||||
title="Permanent link: artifacts of the latest green build">🔗</a>
|
title="Permanent link: artifacts of the latest green build">🔗</a>
|
||||||
|
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
|
||||||
|
title="Watch this build live">📡</a>
|
||||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
<span th:if="${view == 'history'}">History</span>
|
<span th:if="${view == 'history'}">History</span>
|
||||||
<a th:unless="${view == 'history'}" href="/history">History</a>
|
<a th:unless="${view == 'history'}" href="/history">History</a>
|
||||||
<span th:if="${view == 'current'}">Current</span>
|
<span th:if="${view == 'current'}">Current</span>
|
||||||
<a th:unless="${view == 'current'}" href="/current">Current</a>
|
|
||||||
<span th:if="${view == 'system'}">System</span>
|
<span th:if="${view == 'system'}">System</span>
|
||||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -9,8 +9,13 @@
|
|||||||
|
|
||||||
<h2>v0.9.8 <span class="muted">— 2026-08-10</span></h2>
|
<h2>v0.9.8 <span class="muted">— 2026-08-10</span></h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The artifact index also links report pages of directories without an <code>index.html</code>,
|
<li>The artifact index also links report pages of directories without an <code>index.html</code>.
|
||||||
such as Gradle's <code>--profile</code> report with its timestamped file name.</li>
|
A directory holding a single page is linked as a directory, so Gradle's <code>--profile</code>
|
||||||
|
report keeps a stable URL although its file name carries the build timestamp.</li>
|
||||||
|
<li>The permanent <code>🔗</code> link now appears on the build it resolves to — the branch's
|
||||||
|
latest green build — instead of on every build of that branch, and on all build tables.</li>
|
||||||
|
<li>The <em>Current</em> tab gave way to a <code>📡</code> link in the artifacts column,
|
||||||
|
shown while a build runs.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>v0.9.7 <span class="muted">— 2026-08-10</span></h2>
|
<h2>v0.9.7 <span class="muted">— 2026-08-10</span></h2>
|
||||||
|
|||||||
@@ -150,6 +150,30 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("directory URL serves the single page of an index-less report directory") {
|
||||||
|
val profileDir = Files.createDirectories(artifactDir.resolve("reports/profile"))
|
||||||
|
Files.writeString(profileDir.resolve("profile-2026-08-10-18-36-12.html"), "<html>profile</html>")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/branches/main/reports/profile/"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string("<html>profile</html>"))
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/artifacts/known-key/reports/profile/"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().string("<html>profile</html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("directory URL of an index-less report directory holding several pages answers 404") {
|
||||||
|
val pmdDir = Files.createDirectories(artifactDir.resolve("reports/pmd"))
|
||||||
|
Files.writeString(pmdDir.resolve("main.html"), "<html>main</html>")
|
||||||
|
Files.writeString(pmdDir.resolve("test.html"), "<html>test</html>")
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/branches/main/reports/pmd/"))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
test("permanent URL with a bare trailing slash redirects to the artifact index page") {
|
test("permanent URL with a bare trailing slash redirects to the artifact index page") {
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(get("/branches/main/"))
|
.perform(get("/branches/main/"))
|
||||||
|
|||||||
@@ -66,16 +66,17 @@ class BranchListingTest : FunSpec() {
|
|||||||
branches[1].latestGreenUrl shouldBe null
|
branches[1].latestGreenUrl shouldBe null
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a failed latest build still links the older green build's permanent URL") {
|
test("a failed latest build carries no permanent URL — it belongs to the older green build") {
|
||||||
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
|
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
|
||||||
every { repository.latestFor("feature/x") } returns
|
every { repository.latestFor("feature/x") } returns
|
||||||
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED)
|
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
|
||||||
every { repository.latestGreenFor("feature/x") } returns mainResult.copy(branch = "feature/x")
|
every { repository.latestGreenFor("feature/x") } returns
|
||||||
|
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
|
||||||
|
|
||||||
val branches = listing.branches()
|
val branches = listing.branches()
|
||||||
|
|
||||||
branches[0].status shouldBe "failed"
|
branches[0].status shouldBe "failed"
|
||||||
branches[0].latestGreenUrl shouldBe "/branches/feature_x"
|
branches[0].latestGreenUrl shouldBe null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
|
||||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||||
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
}
|
}
|
||||||
|
|
||||||
test("latest answers one entry per branch with lowercase status and duration in seconds") {
|
test("latest answers one entry per branch with lowercase status and duration in seconds") {
|
||||||
@@ -100,6 +101,18 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
.andExpect(jsonPath("$[1].status").value("failed"))
|
.andExpect(jsonPath("$[1].status").value("failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("history carries the permanent URL on the branch's latest green build only") {
|
||||||
|
val older = successResult.copy(artifactKey = "older-key")
|
||||||
|
every { repository.history() } returns listOf(successResult, older)
|
||||||
|
every { repository.latestGreenFor("main") } returns successResult
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/builds/history"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$[0].latestGreenUrl").value("/branches/main"))
|
||||||
|
.andExpect(jsonPath("$[1].latestGreenUrl").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
test("current answers the running builds with live status and log size") {
|
test("current answers the running builds with live status and log size") {
|
||||||
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
|
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
|
||||||
val build = runningBuild(liveLogFile)
|
val build = runningBuild(liveLogFile)
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ class UiControllerTest : FunSpec() {
|
|||||||
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
|
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
|
||||||
)
|
)
|
||||||
every { controlTokens.token() } returns "test-token"
|
every { controlTokens.token() } returns "test-token"
|
||||||
|
every { repository.latestGreenFor(any()) } returns null
|
||||||
}
|
}
|
||||||
|
|
||||||
test("latest view renders the empty state") {
|
test("latest view renders the empty state, and the nav no longer offers the current view") {
|
||||||
every { repository.latestPerBranch() } returns emptyList()
|
every { repository.latestPerBranch() } returns emptyList()
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -124,6 +125,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(containsString("No builds recorded yet.")))
|
.andExpect(content().string(containsString("No builds recorded yet.")))
|
||||||
.andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
|
.andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
|
||||||
.andExpect(content().string(containsString("""id="reload-button"""")))
|
.andExpect(content().string(containsString("""id="reload-button"""")))
|
||||||
|
.andExpect(content().string(not(containsString("""href="/current""""))))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||||
@@ -147,7 +149,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
test("branches view renders built and never-built branches with restart actions") {
|
test("branches view renders built and never-built branches with restart actions") {
|
||||||
every { branchListing.branches(any()) } returns
|
every { branchListing.branches(any()) } returns
|
||||||
listOf(
|
listOf(
|
||||||
BranchDto.from("main", "ignored-head", successResult, hasGreenBuild = true),
|
BranchDto.from("main", "ignored-head", successResult, isLatestGreen = true),
|
||||||
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
|
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +166,23 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(containsString("Permanent link")))
|
.andExpect(content().string(containsString("Permanent link")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the permanent link shows on the branch's latest green build only, the live link while it runs") {
|
||||||
|
val running = successResult.copy(status = BuildStatus.RUNNING, duration = null, artifactKey = "running-key")
|
||||||
|
every { repository.history() } returns listOf(running, successResult)
|
||||||
|
every { repository.latestGreenFor("main") } returns successResult
|
||||||
|
|
||||||
|
val page =
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/history"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
.response.contentAsString
|
||||||
|
|
||||||
|
Regex("""href="/branches/main"""").findAll(page).count() shouldBe 1
|
||||||
|
Regex("""href="/current"""").findAll(page).count() shouldBe 1
|
||||||
|
page shouldContain "Watch this build live"
|
||||||
|
}
|
||||||
|
|
||||||
test("history view renders mixed history without restart actions") {
|
test("history view renders mixed history without restart actions") {
|
||||||
every { repository.history() } returns
|
every { repository.history() } returns
|
||||||
listOf(
|
listOf(
|
||||||
@@ -237,7 +256,7 @@ 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("artifact index links report pages of directories without an index, but not their inner pages") {
|
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"))
|
||||||
Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "<html></html>")
|
Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "<html></html>")
|
||||||
@@ -255,11 +274,31 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andReturn()
|
.andReturn()
|
||||||
.response.contentAsString
|
.response.contentAsString
|
||||||
|
|
||||||
page shouldContain "reports/profile/profile-2026-08-10-18-36-12.html"
|
page shouldContain "reports/profile/"
|
||||||
|
page shouldNotContain "profile-2026-08-10-18-36-12.html"
|
||||||
page shouldContain "reports/tests/test/index.html"
|
page shouldContain "reports/tests/test/index.html"
|
||||||
page shouldNotContain "SomeTest.html"
|
page shouldNotContain "SomeTest.html"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("artifact index links the pages of an index-less report directory holding several") {
|
||||||
|
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
|
||||||
|
Files.createDirectories(artifactDir.resolve("reports/pmd"))
|
||||||
|
Files.writeString(artifactDir.resolve("reports/pmd/main.html"), "<html></html>")
|
||||||
|
Files.writeString(artifactDir.resolve("reports/pmd/test.html"), "<html></html>")
|
||||||
|
every { repository.history() } returns listOf(successResult)
|
||||||
|
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||||
|
|
||||||
|
val page =
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/builds/main-abc123-key"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
.response.contentAsString
|
||||||
|
|
||||||
|
page shouldContain "reports/pmd/main.html"
|
||||||
|
page shouldContain "reports/pmd/test.html"
|
||||||
|
}
|
||||||
|
|
||||||
test("report links carry a failed-badge from the report's failures counter") {
|
test("report links carry a failed-badge from the report's failures counter") {
|
||||||
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
|
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
|
||||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||||
|
|||||||
Reference in New Issue
Block a user