0da914221d1f50107e06d1f25f1c706a449fbd08

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): show worker build statuses

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index ad73e578c92182580094fc426d9cf885d9226cd6..48fa8b907f907fcd53dea9b270a5da851c20b621 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -226,6 +226,9 @@ http:
  6   web_ui:
  7     enabled: false
  8 
  9+    # Internal worker URL used for current build status badges.
 10+    build_status_url: ""
 11+
 12   # The cross-origin request security options
 13   cors:
 14     # The allowed cross-origin headers
 15@@ -307,6 +310,7 @@ name all in uppercase. Here are some examples:
 16 - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address
 17 - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning
 18 - `SOFT_SERVE_HTTP_WEB_UI_ENABLED`: Enable the read-only public web UI
 19+- `SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL`: Internal worker URL for current build status badges
 20 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
 21 - `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
 22 - `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
 23@@ -320,7 +324,11 @@ Enable it in `config.yaml` with `http.web_ui.enabled: true`, or set
 24 `SOFT_SERVE_HTTP_WEB_UI_ENABLED=true`.
 25 
 26 The UI is public-only: it does not provide browser login, sessions, management
 27-forms, or write operations. It serves the homepage (`/`), nested repository
 28+forms, or write operations. Configure `http.web_ui.build_status_url` (or
 29+`SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL`) only with the internal worker URL,
 30+such as `http://builder:8080`; browsers never contact that worker directly.
 31+Build badges report the worker's current in-memory state for pushes to `main`.
 32+A worker restart clears all previously reported statuses. It serves the homepage (`/`), nested repository
 33 overviews (`/{repo}`), file trees and source (`/{repo}/@/tree?ref=&path=`),
 34 downloads (`/{repo}/@/raw?ref=&path=`), commit history
 35 (`/{repo}/@/commits?ref=&page=`), commit details (`/{repo}/@/commit?hash=`),
 36diff --git a/pkg/config/config.go b/pkg/config/config.go
 37index 04b271a75c3f885dc72df163c422204de5f02112..60c1719a84db8862f9d0c0871b0f4889bb004091 100644
 38--- a/pkg/config/config.go
 39+++ b/pkg/config/config.go
 40@@ -73,7 +73,8 @@ type CORSConfig struct {
 41 
 42 // WebUIConfig is the configuration for the public web UI.
 43 type WebUIConfig struct {
 44-	Enabled bool `env:"ENABLED" yaml:"enabled"`
 45+	Enabled        bool   `env:"ENABLED" yaml:"enabled"`
 46+	BuildStatusURL string `env:"BUILD_STATUS_URL" yaml:"build_status_url"`
 47 }
 48 
 49 // HTTPConfig is the HTTP configuration for the server.
 50@@ -245,6 +246,7 @@ func (c *Config) Environ() []string {
 51 		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
 52 		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
 53 		fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_ENABLED=%t", c.HTTP.WebUI.Enabled),
 54+		fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL=%s", c.HTTP.WebUI.BuildStatusURL),
 55 		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
 56 		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
 57 		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
 58@@ -416,7 +418,7 @@ func DefaultConfig() *Config {
 59 			Enabled:    true,
 60 			ListenAddr: ":23232",
 61 			PublicURL:  "http://localhost:23232",
 62-			WebUI:      WebUIConfig{Enabled: false},
 63+			WebUI:      WebUIConfig{Enabled: false, BuildStatusURL: ""},
 64 			CORS: CORSConfig{
 65 				AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
 66 				AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
 67diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
 68index a7b051eab95305398f42185d393e6a44f56c2ddf..ef27ca170487b78c8418df86c343708efe7d6754 100644
 69--- a/pkg/config/config_test.go
 70+++ b/pkg/config/config_test.go
 71@@ -2,6 +2,7 @@ package config
 72 
 73 import (
 74 	"os"
 75+	"strings"
 76 	"testing"
 77 
 78 	"github.com/charmbracelet/soft-serve/pkg/access"
 79@@ -96,28 +97,37 @@ func TestParseWebUIEnabled(t *testing.T) {
 80 
 81 	cfg := DefaultConfig()
 82 	path := t.TempDir() + "/config.yaml"
 83-	is.NoErr(os.WriteFile(path, []byte("http:\n  web_ui:\n    enabled: true\n"), 0o600))
 84+	is.NoErr(os.WriteFile(path, []byte("http:\n  web_ui:\n    enabled: true\n    build_status_url: http://builder:8080\n"), 0o600))
 85 	is.NoErr(parseFile(cfg, path))
 86 	is.Equal(cfg.HTTP.WebUI.Enabled, true)
 87+	is.Equal(cfg.HTTP.WebUI.BuildStatusURL, "http://builder:8080")
 88 
 89 	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED", "true"))
 90 	t.Cleanup(func() {
 91 		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED"))
 92+		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL"))
 93 	})
 94 	cfg = DefaultConfig()
 95 	is.NoErr(cfg.ParseEnv())
 96 	is.Equal(cfg.HTTP.WebUI.Enabled, true)
 97+
 98+	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL", "http://builder:8080"))
 99+	cfg = DefaultConfig()
100+	is.NoErr(cfg.ParseEnv())
101+	is.Equal(cfg.HTTP.WebUI.BuildStatusURL, "http://builder:8080")
102 }
103 
104 func TestWebUIEnviron(t *testing.T) {
105 	cfg := DefaultConfig()
106 	cfg.HTTP.WebUI.Enabled = true
107-	for _, env := range cfg.Environ() {
108-		if env == "SOFT_SERVE_HTTP_WEB_UI_ENABLED=true" {
109-			return
110-		}
111+	cfg.HTTP.WebUI.BuildStatusURL = "http://builder:8080"
112+	environment := strings.Join(cfg.Environ(), "\n")
113+	if !strings.Contains(environment, "SOFT_SERVE_HTTP_WEB_UI_ENABLED=true") {
114+		t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_ENABLED=true")
115+	}
116+	if !strings.Contains(environment, "SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL=http://builder:8080") {
117+		t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL")
118 	}
119-	t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_ENABLED=true")
120 }
121 
122 func TestParseDefaultRepo(t *testing.T) {
123diff --git a/pkg/config/file.go b/pkg/config/file.go
124index 5cd3698a9278804384b8c131f9dbdcaadae338fb..d3e3ca6dce4f7bc82b660c2b6f9e5689052732d5 100644
125--- a/pkg/config/file.go
126+++ b/pkg/config/file.go
127@@ -93,6 +93,9 @@ http:
128   web_ui:
129     enabled: {{ .HTTP.WebUI.Enabled }}
130 
131+    # Internal worker URL used for current build status badges.
132+    build_status_url: "{{ .HTTP.WebUI.BuildStatusURL }}"
133+
134   # The cross-origin request security options
135   cors:
136     # The allowed cross-origin headers
137diff --git a/pkg/config/file_test.go b/pkg/config/file_test.go
138index f067869c85ad8df18bcc0e26918999fe9572f944..62d4116c9de1d935eb9c998c5001d8dcb60e9f4a 100644
139--- a/pkg/config/file_test.go
140+++ b/pkg/config/file_test.go
141@@ -18,7 +18,7 @@ func TestNewConfigFile(t *testing.T) {
142 }
143 
144 func TestNewConfigFileIncludesWebUI(t *testing.T) {
145-	if s := newConfigFile(DefaultConfig()); !strings.Contains(s, "web_ui:\n    enabled: false") {
146-		t.Errorf("generated config does not include disabled web_ui: %q", s)
147+	if s := newConfigFile(DefaultConfig()); !strings.Contains(s, "web_ui:\n    enabled: false\n\n    # Internal worker URL used for current build status badges.\n    build_status_url: \"\"") {
148+		t.Errorf("generated config does not include web_ui settings: %q", s)
149 	}
150 }
151diff --git a/pkg/web/build_status_test.go b/pkg/web/build_status_test.go
152new file mode 100644
153index 0000000000000000000000000000000000000000..49d8451bffdeb672bb1c01f61b58a9d1c17d0510
154--- /dev/null
155+++ b/pkg/web/build_status_test.go
156@@ -0,0 +1,184 @@
157+package web
158+
159+import (
160+	"context"
161+	"encoding/json"
162+	"net/http"
163+	"net/http/httptest"
164+	"sort"
165+	"strings"
166+	"sync"
167+	"testing"
168+	"time"
169+
170+	"github.com/charmbracelet/soft-serve/pkg/access"
171+	"github.com/charmbracelet/soft-serve/pkg/config"
172+	"github.com/charmbracelet/soft-serve/pkg/proto"
173+)
174+
175+const buildStatusTestSHA = "0123456789abcdef0123456789abcdef01234567"
176+
177+type buildStatusTestResponse struct {
178+	Statuses map[string]buildStatusTestEntry `json:"statuses"`
179+}
180+
181+type buildStatusTestEntry struct {
182+	SHA      string `json:"sha"`
183+	State    string `json:"state"`
184+	QueuedAt string `json:"queued_at"`
185+}
186+
187+func TestHomepageRendersBuildStatusesOnlyForVisiblePublicRepositories(t *testing.T) {
188+	var requested []string
189+	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
190+		if request.Method != http.MethodPost || request.URL.Path != "/build-statuses" {
191+			t.Fatalf("worker request = %s %s", request.Method, request.URL.Path)
192+		}
193+		var payload struct {
194+			Repositories []string `json:"repositories"`
195+		}
196+		if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
197+			t.Fatal(err)
198+		}
199+		requested = append(requested, payload.Repositories...)
200+		response := buildStatusTestResponse{Statuses: map[string]buildStatusTestEntry{
201+			"queued":  buildStatusEntry("queued"),
202+			"running": buildStatusEntry("running"),
203+			"passed":  buildStatusEntry("passed"),
204+			"failed":  buildStatusEntry("failed"),
205+		}}
206+		writer.Header().Set("Content-Type", "application/json")
207+		if err := json.NewEncoder(writer).Encode(response); err != nil {
208+			t.Fatal(err)
209+		}
210+	}))
211+	defer server.Close()
212+
213+	ctx, be, _ := newLFSTestContext(t)
214+	configureBuildStatusTest(ctx, server.URL)
215+	owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
216+	if err != nil {
217+		t.Fatal(err)
218+	}
219+	for _, repository := range []string{"queued", "running", "passed", "failed", "missing"} {
220+		createHomepageRepository(t, be, ctx, repository, owner, proto.RepositoryOptions{})
221+	}
222+	createHomepageRepository(t, be, ctx, "private", owner, proto.RepositoryOptions{Private: true})
223+	createHomepageRepository(t, be, ctx, "hidden", owner, proto.RepositoryOptions{Hidden: true})
224+
225+	response := httptest.NewRecorder()
226+	NewRouter(ctx).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
227+	if response.Code != http.StatusOK {
228+		t.Fatalf("homepage = %d: %s", response.Code, response.Body.String())
229+	}
230+	for _, label := range []string{"Queued", "Building", "Passed", "Failed", "No build"} {
231+		if !strings.Contains(response.Body.String(), "Build status: <span") || !strings.Contains(response.Body.String(), ">"+label+"</span>") {
232+			t.Errorf("homepage did not render %q: %s", label, response.Body.String())
233+		}
234+	}
235+	sort.Strings(requested)
236+	if got, want := strings.Join(requested, ","), "failed,missing,passed,queued,running"; got != want {
237+		t.Errorf("worker status requested repositories = %q, want %q", got, want)
238+	}
239+}
240+
241+func TestRepositoryOverviewRendersBuildStatusAndChecksAccessBeforeWorker(t *testing.T) {
242+	var mutex sync.Mutex
243+	requests := 0
244+	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
245+		mutex.Lock()
246+		requests++
247+		mutex.Unlock()
248+		var payload struct {
249+			Repositories []string `json:"repositories"`
250+		}
251+		if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
252+			t.Fatal(err)
253+		}
254+		response := buildStatusTestResponse{Statuses: make(map[string]buildStatusTestEntry)}
255+		for _, repository := range payload.Repositories {
256diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
257index aaf969d0e9251327b95d3d4e8b04a5145cc942cf..d481303fcb59f4e8b371964873aff2ce24cc5310 100644
258--- a/pkg/web/pages/assets/site.css
259+++ b/pkg/web/pages/assets/site.css
260@@ -61,6 +61,25 @@ a:focus-visible {
261   font-size: 0.9rem;
262 }
263 
264+.build-status {
265+  font-size: 0.9rem;
266+}
267+
268+.status-badge {
269+  border-radius: 0.25rem;
270+  color: #111827;
271+  display: inline-block;
272+  font-weight: 600;
273+  padding: 0.1rem 0.4rem;
274+}
275+
276+.build-status-queued { background: #fef3c7; }
277+.build-status-running { background: #bfdbfe; }
278+.build-status-passed { background: #bbf7d0; }
279+.build-status-failed { background: #fecaca; }
280+.build-status-none,
281+.build-status-unavailable { background: #e5e7eb; }
282+
283 .markdown > :first-child {
284   margin-top: 0;
285 }
286diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
287index 6b03bc1a2ac5fa44c92c49c51d896021638dd9b3..ee35cabb9d69a681528e031f45e15879f2462734 100644
288--- a/pkg/web/pages/browser.go
289+++ b/pkg/web/pages/browser.go
290@@ -29,6 +29,7 @@ type repositoryPage struct {
291 	RefsURL      string
292 	Empty        bool
293 	Readme       template.HTML
294+	BuildStatus  repositoryBuildStatus
295 }
296 
297 type treePage struct {
298@@ -76,6 +77,7 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
299 	if title == "" {
300 		title = repo.Name()
301 	}
302+	status := LoadBuildStatuses(r.Context(), cfg.HTTP.WebUI.BuildStatusURL, []string{repo.Name()})
303 	page := repositoryPage{
304 		ServerName:   cfg.Name,
305 		Repository:   repo.Name(),
306@@ -84,6 +86,7 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
307 		Empty:        empty,
308 		SSHCloneURL:  common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
309 		HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
310+		BuildStatus:  status[repo.Name()],
311 	}
312 	if !empty {
313 		page.Ref = ref.Name().String()
314diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
315index 478456fd32041a50c15cbb5997ac075eabdf4125..0a9458456dd8040877882d1c1209c68fb5b31ff1 100644
316--- a/pkg/web/pages/controller.go
317+++ b/pkg/web/pages/controller.go
318@@ -46,6 +46,7 @@ type repositoryCard struct {
319 	Description string
320 	URL         string
321 	UpdatedAt   string
322+	BuildStatus repositoryBuildStatus
323 }
324 
325 func home(w http.ResponseWriter, r *http.Request) {
326@@ -73,15 +74,20 @@ func newHomePage(ctx context.Context) (homePage, error) {
327 		ServerName: cfg.Name,
328 		Cards:      make([]repositoryCard, 0, len(repos)),
329 	}
330+	names := make([]string, 0, len(repos))
331 	for _, repo := range repos {
332-		page.Cards = append(page.Cards, newRepositoryCard(repo))
333+		names = append(names, repo.Name())
334+	}
335+	statuses := LoadBuildStatuses(ctx, cfg.HTTP.WebUI.BuildStatusURL, names)
336+	for _, repo := range repos {
337+		page.Cards = append(page.Cards, newRepositoryCard(repo, statuses[repo.Name()]))
338 	}
339 
340 	page.Profile = loadProfile(ctx)
341 	return page, nil
342 }
343 
344-func newRepositoryCard(repo proto.Repository) repositoryCard {
345+func newRepositoryCard(repo proto.Repository, status repositoryBuildStatus) repositoryCard {
346 	title := repo.ProjectName()
347 	if title == "" {
348 		title = repo.Name()
349@@ -97,6 +103,7 @@ func newRepositoryCard(repo proto.Repository) repositoryCard {
350 		Description: repo.Description(),
351 		URL:         RepositoryURL(repo.Name()),
352 		UpdatedAt:   updatedAt,
353+		BuildStatus: status,
354 	}
355 }
356 
357diff --git a/pkg/web/pages/status.go b/pkg/web/pages/status.go
358new file mode 100644
359index 0000000000000000000000000000000000000000..f1f7603c1a615051e21a1f4236052445954883b7
360--- /dev/null
361+++ b/pkg/web/pages/status.go
362@@ -0,0 +1,155 @@
363+package pages
364+
365+import (
366+	"bytes"
367+	"context"
368+	"encoding/json"
369+	"io"
370+	"net/http"
371+	"net/url"
372+	"time"
373+)
374+
375+const (
376+	buildStatusRequestBatchSize = 100
377+	maxBuildStatusResponseSize  = 1 << 20
378+)
379+
380+var buildStatusHTTPClient = &http.Client{Timeout: 2 * time.Second}
381+
382+type repositoryBuildStatus struct {
383+	Label string
384+	Class string
385+}
386+
387+type workerBuildStatusRequest struct {
388+	Repositories []string `json:"repositories"`
389+}
390+
391+type workerBuildStatusResponse struct {
392+	Statuses map[string]workerBuildStatus `json:"statuses"`
393+}
394+
395+type workerBuildStatus struct {
396+	SHA      string    `json:"sha"`
397+	State    string    `json:"state"`
398+	QueuedAt time.Time `json:"queued_at"`
399+}
400+
401+// LoadBuildStatuses obtains current worker status without allowing an unavailable worker to fail a page.
402+func LoadBuildStatuses(ctx context.Context, workerURL string, repositories []string) map[string]repositoryBuildStatus {
403+	statuses := make(map[string]repositoryBuildStatus, len(repositories))
404+	for _, repository := range repositories {
405+		statuses[repository] = noBuildStatus()
406+	}
407+	if workerURL == "" || len(repositories) == 0 {
408+		return statuses
409+	}
410+
411+	endpoint, err := buildStatusEndpoint(workerURL)
412+	if err != nil {
413+		markBuildStatusUnavailable(statuses, repositories)
414+		return statuses
415+	}
416+	for start := 0; start < len(repositories); start += buildStatusRequestBatchSize {
417+		end := start + buildStatusRequestBatchSize
418+		if end > len(repositories) {
419+			end = len(repositories)
420+		}
421+		batch := repositories[start:end]
422+		workerStatuses, err := requestBuildStatuses(ctx, endpoint, batch)
423+		if err != nil {
424+			markBuildStatusUnavailable(statuses, batch)
425+			continue
426+		}
427+		for repository, status := range workerStatuses {
428+			statuses[repository] = status
429+		}
430+	}
431+	return statuses
432+}
433+
434+func buildStatusEndpoint(rawURL string) (string, error) {
435+	base, err := url.Parse(rawURL)
436+	if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
437+		return "", ErrNotFound
438+	}
439+	return base.ResolveReference(&url.URL{Path: "/build-statuses"}).String(), nil
440+}
441+
442+func requestBuildStatuses(ctx context.Context, endpoint string, repositories []string) (map[string]repositoryBuildStatus, error) {
443+	body, err := json.Marshal(workerBuildStatusRequest{Repositories: repositories})
444+	if err != nil {
445+		return nil, err
446+	}
447+	request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
448+	if err != nil {
449+		return nil, err
450+	}
451+	request.Header.Set("Content-Type", "application/json")
452+	response, err := buildStatusHTTPClient.Do(request)
453+	if err != nil {
454+		return nil, err
455+	}
456+	defer response.Body.Close()
457+	if response.StatusCode != http.StatusOK {
458+		return nil, ErrNotFound
459+	}
460+
461+	limitedBody := &io.LimitedReader{R: response.Body, N: maxBuildStatusResponseSize + 1}
462diff --git a/pkg/web/pages/templates/home.gohtml b/pkg/web/pages/templates/home.gohtml
463index 6aae21f17c903ba2cc03d61a11c800f8b47009dc..3cefb5f3c459c1c1d1329bcb50971dfc51a32c77 100644
464--- a/pkg/web/pages/templates/home.gohtml
465+++ b/pkg/web/pages/templates/home.gohtml
466@@ -38,6 +38,7 @@
467           <h3><a href="{{ .URL }}">{{ .Title }}</a></h3>
468           {{ if ne .Title .Name }}<p class="repository-name">{{ .Name }}</p>{{ end }}
469           {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
470+          <p class="build-status">Build status: <span class="status-badge {{ .BuildStatus.Class }}">{{ .BuildStatus.Label }}</span></p>
471           {{ if .UpdatedAt }}<p class="updated">Updated <time>{{ .UpdatedAt }}</time></p>{{ end }}
472         </article>
473       </li>
474diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
475index 23973ab9e013f942245242cb27a765a9aafbd107..89dd0949c68182b51bd66aaaabd16117b9e57b9d 100644
476--- a/pkg/web/pages/templates/repository.gohtml
477+++ b/pkg/web/pages/templates/repository.gohtml
478@@ -13,6 +13,7 @@
479       <h1>{{ .Title }}</h1>
480       {{ if ne .Title .Repository }}<p class="repository-name">{{ .Repository }}</p>{{ end }}
481       {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
482+      <p class="build-status">Build status: <span class="status-badge {{ .BuildStatus.Class }}">{{ .BuildStatus.Label }}</span></p>
483       {{ if .Empty }}
484       <p>This repository is empty.</p>
485       {{ else }}