gitmote

gitmote  ·  history  ·  branches

feat: CI status UI — runs, run detail, log viewer, commit badge

7471be891afe653778e7400575b067d4ee817ebd
Atanas Minev <atmin@protonmail.com> · 2026-07-03 21:31

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 51dd028..a8a35d0 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -347,7 +347,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, nil, noop, err
}
- ui, err := buildUI(md, materializer, guard, secretsSvc, logger)
+ ui, err := buildUI(md, materializer, objs, guard, secretsSvc, logger)
if err != nil {
_ = cleanup()
return nil, nil, nil, noop, err
@@ -358,13 +358,13 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
// buildUI constructs the management UI when GITMOTE_COOKIE_KEY is set; otherwise
// it returns nil (UI disabled) so a misconfigured key never yields an insecurely
// signed session.
-func buildUI(md *meta.Metadata, mz *repo.Materializer, guard *auth.Guard, secretsSvc *secrets.Service, logger *slog.Logger) (*webui.Handler, error) {
+func buildUI(md *meta.Metadata, mz *repo.Materializer, objs store.Store, guard *auth.Guard, secretsSvc *secrets.Service, logger *slog.Logger) (*webui.Handler, error) {
key := os.Getenv("GITMOTE_COOKIE_KEY")
if key == "" {
logger.Warn("GITMOTE_COOKIE_KEY unset; management UI disabled")
return nil, nil
}
- return webui.New(md, mz, guard, secretsSvc, []byte(key), logger)
+ return webui.New(md, mz, objs, guard, secretsSvc, []byte(key), logger)
}
// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
diff --git a/internal/meta/ci.go b/internal/meta/ci.go
index ed1a9b9..e9107fc 100644
--- a/internal/meta/ci.go
+++ b/internal/meta/ci.go
@@ -134,6 +134,31 @@ func (m *Metadata) ListRuns(ctx context.Context, repoID int64, limit int) ([]Run
return runs, rows.Err()
}
+// LatestRunForSHA returns the most recent run for a repo at an exact commit SHA,
+// or ErrNotFound. It backs the commit-status badge in the browse view.
+func (m *Metadata) LatestRunForSHA(ctx context.Context, repoID int64, sha string) (*Run, error) {
+ var (
+ r Run
+ status string
+ cts string
+ uts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT id, repo_id, ref, sha, status, created_at, updated_at
+ FROM ci_runs WHERE repo_id = ? AND sha = ? ORDER BY id DESC LIMIT 1`, repoID, sha).
+ Scan(&r.ID, &r.RepoID, &r.Ref, &r.SHA, &status, &cts, &uts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ r.Status = RunStatus(status)
+ r.CreatedAt = parseTime(cts)
+ r.UpdatedAt = parseTime(uts)
+ return &r, nil
+}
+
// Job is one unit of a run: a single workflow file. Name is the workflow file's
// base name (stage 2); LogKey is the ci/ object key, set on completion (stage 4).
type Job struct {
diff --git a/internal/render/ansi.go b/internal/render/ansi.go
new file mode 100644
index 0000000..4eb00fd
--- /dev/null
+++ b/internal/render/ansi.go
@@ -0,0 +1,109 @@
+package render
+
+import (
+ "html/template"
+ "strconv"
+ "strings"
+)
+
+// ansiFG maps SGR foreground color codes (standard 30–37, bright 90–97) to CSS
+// colors. Backgrounds and 256/truecolor are intentionally unsupported — CI logs
+// use the basic palette, and a fixed table keeps the output injection-proof (the
+// color never comes from the log).
+var ansiFG = map[int]string{
+ 30: "#555555", 31: "#c0392b", 32: "#27ae60", 33: "#b7950b",
+ 34: "#2980b9", 35: "#8e44ad", 36: "#17a2a2", 37: "#aaaaaa",
+ 90: "#7f8c8d", 91: "#e74c3c", 92: "#2ecc71", 93: "#f1c40f",
+ 94: "#3498db", 95: "#9b59b6", 96: "#1abc9c", 97: "#ecf0f1",
+}
+
+// ANSI converts a byte stream carrying ANSI SGR escapes into HTML safe to embed
+// in a <pre>. All text is HTML-escaped; SGR color/bold escapes become <span>
+// wrappers with styles drawn only from the fixed palette; every other escape
+// sequence (cursor moves, etc.) is dropped. Because the styles are ours and the
+// text is escaped, the untrusted log can't inject markup.
+func ANSI(b []byte) template.HTML {
+ var out strings.Builder
+ color := ""
+ bold := false
+
+ flush := func(s []byte) {
+ if len(s) == 0 {
+ return
+ }
+ esc := template.HTMLEscapeString(string(s))
+ if color == "" && !bold {
+ out.WriteString(esc)
+ return
+ }
+ out.WriteString(`<span style="`)
+ if bold {
+ out.WriteString("font-weight:bold;")
+ }
+ if color != "" {
+ out.WriteString("color:")
+ out.WriteString(color)
+ out.WriteString(";")
+ }
+ out.WriteString(`">`)
+ out.WriteString(esc)
+ out.WriteString("</span>")
+ }
+
+ i, n, start := 0, len(b), 0
+ for i < n {
+ if b[i] != 0x1b { // ESC
+ i++
+ continue
+ }
+ flush(b[start:i])
+ if i+1 < n && b[i+1] == '[' { // CSI: ESC [ params <final>
+ j := i + 2
+ for j < n && (b[j] < 0x40 || b[j] > 0x7e) {
+ j++
+ }
+ if j >= n { // unterminated — drop the rest
+ i = n
+ } else {
+ if b[j] == 'm' {
+ applySGR(string(b[i+2:j]), &color, &bold)
+ }
+ i = j + 1
+ }
+ } else {
+ i++ // lone ESC — skip it
+ }
+ start = i
+ }
+ flush(b[start:n])
+ return template.HTML(out.String())
+}
+
+// applySGR updates the current color/bold from a semicolon-separated SGR
+// parameter list. Unknown codes are ignored.
+func applySGR(params string, color *string, bold *bool) {
+ if params == "" { // ESC[m is a reset
+ *color, *bold = "", false
+ return
+ }
+ for _, p := range strings.Split(params, ";") {
+ code, err := strconv.Atoi(p)
+ if err != nil {
+ continue
+ }
+ switch code {
+ case 0:
+ *color, *bold = "", false
+ case 1:
+ *bold = true
+ case 22:
+ *bold = false
+ case 39:
+ *color = ""
+ default:
+ if c, ok := ansiFG[code]; ok {
+ *color = c
+ }
+ }
+ }
+}
diff --git a/internal/render/ansi_test.go b/internal/render/ansi_test.go
new file mode 100644
index 0000000..934c317
--- /dev/null
+++ b/internal/render/ansi_test.go
@@ -0,0 +1,70 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestANSIPlainText(t *testing.T) {
+ got := string(ANSI([]byte("hello world\n")))
+ if got != "hello world\n" {
+ t.Errorf("plain text = %q, want unchanged", got)
+ }
+}
+
+func TestANSIColorBecomesSpan(t *testing.T) {
+ // red "err" then reset.
+ got := string(ANSI([]byte("\x1b[31merr\x1b[0m ok")))
+ if !strings.Contains(got, "<span style=\"color:#c0392b;\">err</span>") {
+ t.Errorf("colored run not wrapped: %q", got)
+ }
+ if !strings.HasSuffix(got, " ok") {
+ t.Errorf("text after reset not plain: %q", got)
+ }
+}
+
+func TestANSIBold(t *testing.T) {
+ got := string(ANSI([]byte("\x1b[1mB\x1b[0m")))
+ if !strings.Contains(got, "font-weight:bold;") || !strings.Contains(got, ">B</span>") {
+ t.Errorf("bold not applied: %q", got)
+ }
+}
+
+func TestANSIEscapesHTML(t *testing.T) {
+ // Untrusted log content must be escaped — no raw tags reach the output.
+ got := string(ANSI([]byte("<script>alert(1)</script>")))
+ if strings.Contains(got, "<script>") {
+ t.Errorf("HTML not escaped (injection): %q", got)
+ }
+ if !strings.Contains(got, "&lt;script&gt;") {
+ t.Errorf("expected escaped entities, got %q", got)
+ }
+}
+
+func TestANSIColoredHTMLStillEscaped(t *testing.T) {
+ // HTML inside a colored run is still escaped.
+ got := string(ANSI([]byte("\x1b[32m<b>\x1b[0m")))
+ if strings.Contains(got, "<b>") {
+ t.Errorf("HTML inside colored run not escaped: %q", got)
+ }
+ if !strings.Contains(got, "&lt;b&gt;") {
+ t.Errorf("want escaped entities inside span: %q", got)
+ }
+}
+
+func TestANSIDropsNonSGREscapes(t *testing.T) {
+ // A cursor-move CSI (ends in 'H') and an erase (ends in 'K') are dropped, not
+ // rendered as bytes.
+ got := string(ANSI([]byte("a\x1b[2Kb\x1b[1;1Hc")))
+ if got != "abc" {
+ t.Errorf("non-SGR escapes not dropped: %q", got)
+ }
+}
+
+func TestANSIUnterminatedEscape(t *testing.T) {
+ // A trailing, unterminated escape must not panic or leak raw bytes.
+ got := string(ANSI([]byte("ok\x1b[")))
+ if got != "ok" {
+ t.Errorf("unterminated escape = %q, want %q", got, "ok")
+ }
+}
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index 2170bce..b1f1bd1 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -41,6 +41,10 @@ func (h *Handler) browse(w http.ResponseWriter, r *http.Request) {
h.browseCommits(w, r, repoName)
case "commit":
h.browseCommit(w, r, repoName, arg)
+ case "runs":
+ h.ciRuns(w, r, repoName)
+ case "run":
+ h.ciRun(w, r, repoName, arg)
default:
http.NotFound(w, r)
}
@@ -259,10 +263,19 @@ func (h *Handler) browseCommit(w http.ResponseWriter, r *http.Request, repoName,
h.serverError(w, "show commit", err)
return
}
+ // Best-effort CI badge: the latest run for this exact commit, if any. A
+ // lookup failure is non-fatal — the commit still renders, just without a badge.
+ var run *meta.Run
+ if got, err := h.md.LatestRunForSHA(r.Context(), c.repo.ID, commit.SHA); err == nil {
+ run = got
+ } else if !errors.Is(err, meta.ErrNotFound) {
+ h.log.Error("latest run for sha", "repo", repoName, "sha", commit.SHA, "error", err)
+ }
h.render(w, "browse_commit.html", commitData{
browseBase: h.browseHeader(r, c),
Commit: commit,
Diff: diff,
+ Run: run,
})
}
diff --git a/internal/webui/ci.go b/internal/webui/ci.go
new file mode 100644
index 0000000..c73d8a0
--- /dev/null
+++ b/internal/webui/ci.go
@@ -0,0 +1,165 @@
+package webui
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/render"
+ "github.com/atmin/gitmote/internal/store"
+)
+
+// ciRunsLimit caps the recent-runs page; a 101st run becomes the "more" marker
+// rather than being silently dropped.
+const ciRunsLimit = 100
+
+// ciRuns renders a repo's recent CI runs, newest first.
+func (h *Handler) ciRuns(w http.ResponseWriter, r *http.Request, repoName string) {
+ rp, ok := h.ciRepo(w, r, repoName)
+ if !ok {
+ return
+ }
+ runs, err := h.md.ListRuns(r.Context(), rp.ID, ciRunsLimit+1)
+ if err != nil {
+ h.serverError(w, "list runs", err)
+ return
+ }
+ more := false
+ if len(runs) > ciRunsLimit {
+ runs, more = runs[:ciRunsLimit], true
+ }
+ h.render(w, "ci_runs.html", ciRunsData{
+ base: h.base(r, "", ""),
+ Repo: rp.Name,
+ Runs: runs,
+ More: more,
+ })
+}
+
+// ciRun renders either a run's detail (arg = "{id}") or one job's log
+// (arg = "{id}/job/{jid}/log").
+func (h *Handler) ciRun(w http.ResponseWriter, r *http.Request, repoName, arg string) {
+ idStr, rest, hasRest := strings.Cut(arg, "/")
+ runID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ rp, ok := h.ciRepo(w, r, repoName)
+ if !ok {
+ return
+ }
+ run, err := h.md.GetRun(r.Context(), runID)
+ if errors.Is(err, meta.ErrNotFound) || (err == nil && run.RepoID != rp.ID) {
+ http.NotFound(w, r) // unknown run, or a run belonging to another repo
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get run", err)
+ return
+ }
+
+ if !hasRest {
+ h.ciRunDetail(w, r, rp, run)
+ return
+ }
+ // The only sub-resource is a job's log: "job/{jid}/log".
+ parts := strings.Split(rest, "/")
+ if len(parts) != 3 || parts[0] != "job" || parts[2] != "log" {
+ http.NotFound(w, r)
+ return
+ }
+ jobID, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ h.ciJobLog(w, r, rp, run, jobID)
+}
+
+func (h *Handler) ciRunDetail(w http.ResponseWriter, r *http.Request, rp *meta.Repo, run *meta.Run) {
+ jobs, err := h.md.ListJobs(r.Context(), run.ID)
+ if err != nil {
+ h.serverError(w, "list jobs", err)
+ return
+ }
+ h.render(w, "ci_run.html", ciRunData{
+ base: h.base(r, "", ""),
+ Repo: rp.Name,
+ Run: *run,
+ Jobs: jobs,
+ })
+}
+
+func (h *Handler) ciJobLog(w http.ResponseWriter, r *http.Request, rp *meta.Repo, run *meta.Run, jobID int64) {
+ job, err := h.md.GetJob(r.Context(), jobID)
+ if errors.Is(err, meta.ErrNotFound) || (err == nil && job.RunID != run.ID) {
+ http.NotFound(w, r) // unknown job, or a job under a different run
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get job", err)
+ return
+ }
+
+ data := ciLogData{
+ base: h.base(r, "", ""),
+ Repo: rp.Name,
+ Run: *run,
+ Job: *job,
+ }
+ if job.LogKey == "" {
+ data.Note = "No log yet — the job hasn't reported."
+ } else {
+ h.loadLog(r, job.LogKey, &data)
+ }
+ h.render(w, "ci_log.html", data)
+}
+
+// loadLog fetches a job's log blob and fills data with either the ANSI-rendered
+// HTML or, over the render cap, a plain-text prefix with a note. Read failures
+// degrade to a note — the run detail already showed the status.
+func (h *Handler) loadLog(r *http.Request, key string, data *ciLogData) {
+ rc, err := h.store.Get(r.Context(), key)
+ if errors.Is(err, store.ErrNotFound) {
+ data.Note = "Log not found in the object store."
+ return
+ }
+ if err != nil {
+ h.log.Error("get ci log", "key", key, "error", err)
+ data.Note = "Could not read the log."
+ return
+ }
+ defer func() { _ = rc.Close() }()
+
+ raw, err := io.ReadAll(io.LimitReader(rc, render.MaxSize+1))
+ if err != nil {
+ h.log.Error("read ci log", "key", key, "error", err)
+ data.Note = "Could not read the log."
+ return
+ }
+ if len(raw) > render.MaxSize {
+ data.Note = "Log is large; showing the first part as plain text."
+ data.Plain = string(raw[:render.MaxSize])
+ return
+ }
+ data.Log = render.ANSI(raw)
+}
+
+// ciRepo resolves the repo record for a CI page. CI reads meta + the object
+// store, not git, so it needs no materialization.
+func (h *Handler) ciRepo(w http.ResponseWriter, r *http.Request, repoName string) (*meta.Repo, bool) {
+ rp, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
+ return nil, false
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return nil, false
+ }
+ return rp, true
+}
diff --git a/internal/webui/ci_test.go b/internal/webui/ci_test.go
new file mode 100644
index 0000000..3a8a412
--- /dev/null
+++ b/internal/webui/ci_test.go
@@ -0,0 +1,150 @@
+package webui
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// seedRun creates a repo (if repoName is new), a run, and one job with a stored
+// log blob, returning the ids. The log carries ANSI + HTML to exercise rendering.
+func (x *harness) seedRun(repoName, sha, log string) (repoID, runID, jobID int64) {
+ x.t.Helper()
+ ctx := context.Background()
+ r, err := x.md.GetRepo(ctx, repoName)
+ if err != nil {
+ r, err = x.md.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ x.t.Fatalf("CreateRepo: %v", err)
+ }
+ }
+ run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", sha)
+ if err != nil {
+ x.t.Fatalf("CreateRun: %v", err)
+ }
+ job, err := x.md.CreateJob(ctx, run.ID, "ci.yml")
+ if err != nil {
+ x.t.Fatalf("CreateJob: %v", err)
+ }
+ key := "ci/1/" + itoa(run.ID) + "/" + itoa(job.ID) + ".log"
+ if err := x.store.Put(ctx, key, bytes.NewReader([]byte(log))); err != nil {
+ x.t.Fatalf("store log: %v", err)
+ }
+ if err := x.md.SetJobResult(ctx, job.ID, meta.RunPassed, key); err != nil {
+ x.t.Fatalf("SetJobResult: %v", err)
+ }
+ return r.ID, run.ID, job.ID
+}
+
+func TestCIRunsListAndDetail(t *testing.T) {
+ x := newHarness(t)
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ _, runID, jobID := x.seedRun("atmin/app", "deadbeefcafe1234", "ok\n")
+
+ // Runs list links to the run and shows the short SHA.
+ rec := x.do(http.MethodGet, "/browse/atmin/app/-/runs", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("runs list: %d (%s)", rec.Code, rec.Body)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "/browse/atmin/app/-/run/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
+ t.Errorf("runs list missing run link or short sha: %s", body)
+ }
+
+ // Run detail shows the job and links to its log.
+ rec = x.do(http.MethodGet, "/browse/atmin/app/-/run/"+itoa(runID), nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("run detail: %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), "ci.yml") ||
+ !strings.Contains(rec.Body.String(), "/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log") {
+ t.Errorf("run detail missing job or log link: %s", rec.Body)
+ }
+}
+
+func TestCIJobLogRendersSafely(t *testing.T) {
+ x := newHarness(t)
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ // Green "ok" via ANSI, then HTML that must be escaped.
+ _, runID, jobID := x.seedRun("atmin/app", "sha1", "\x1b[32mok\x1b[0m <b>x</b>\n")
+
+ rec := x.do(http.MethodGet, "/browse/atmin/app/-/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("log view: %d (%s)", rec.Code, rec.Body)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "ok</span>") {
+ t.Errorf("ANSI color not rendered as a span: %s", body)
+ }
+ if strings.Contains(body, "<b>x</b>") {
+ t.Errorf("log HTML not escaped (injection): %s", body)
+ }
+ if !strings.Contains(body, "&lt;b&gt;x&lt;/b&gt;") {
+ t.Errorf("expected escaped log HTML: %s", body)
+ }
+}
+
+func TestCIRunNotFound(t *testing.T) {
+ x := newHarness(t)
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ if _, err := x.md.CreateRepo(context.Background(), "atmin/app", "main"); err != nil {
+ t.Fatal(err)
+ }
+ // Unknown run id → 404.
+ if rec := x.do(http.MethodGet, "/browse/atmin/app/-/run/9999", nil, session); rec.Code != http.StatusNotFound {
+ t.Errorf("unknown run = %d, want 404", rec.Code)
+ }
+}
+
+func TestCIRunCrossRepoIsNotFound(t *testing.T) {
+ x := newHarness(t)
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ _, runID, _ := x.seedRun("atmin/app", "sha1", "x")
+ if _, err := x.md.CreateRepo(context.Background(), "atmin/other", "main"); err != nil {
+ t.Fatal(err)
+ }
+ // A run that belongs to atmin/app must not be reachable under atmin/other.
+ rec := x.do(http.MethodGet, "/browse/atmin/other/-/run/"+itoa(runID), nil, session)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("cross-repo run = %d, want 404", rec.Code)
+ }
+}
+
+func TestCIRunsRequireAdmin(t *testing.T) {
+ x := newHarness(t)
+ // Unauthenticated GET is redirected to login like the rest of /browse.
+ if rec := x.do(http.MethodGet, "/browse/atmin/app/-/runs", nil, nil); rec.Code != http.StatusSeeOther {
+ t.Errorf("unauth runs = %d, want 303 redirect", rec.Code)
+ }
+}
+
+func TestCICommitBadge(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ head, first := x.seedBrowseRepo("atmin/repo", "main")
+ r, _ := x.md.GetRepo(ctx, "atmin/repo")
+ run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", head)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The commit with a run shows a badge linking to it.
+ rec := x.do(http.MethodGet, "/browse/atmin/repo/-/commit/"+head, nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("commit page: %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), "/browse/atmin/repo/-/run/"+itoa(run.ID)) {
+ t.Errorf("commit with a run is missing the CI badge: %s", rec.Body)
+ }
+
+ // A commit without a run shows no badge.
+ rec = x.do(http.MethodGet, "/browse/atmin/repo/-/commit/"+first, nil, session)
+ if strings.Contains(rec.Body.String(), "/-/run/") {
+ t.Errorf("commit without a run should have no badge: %s", rec.Body)
+ }
+}
diff --git a/internal/webui/data.go b/internal/webui/data.go
index ec9c79d..4120d59 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -110,4 +110,31 @@ type commitData struct {
Crumbs []crumb // always nil; present so the shared browse_head renders
Commit repo.Commit
Diff string
+ Run *meta.Run // the latest CI run for this commit's SHA, if any (badge)
+}
+
+// --- ci status ---
+
+type ciRunsData struct {
+ base
+ Repo string
+ Runs []meta.Run
+ More bool // the list was capped; older runs exist
+}
+
+type ciRunData struct {
+ base
+ Repo string
+ Run meta.Run
+ Jobs []meta.Job
+}
+
+type ciLogData struct {
+ base
+ Repo string
+ Run meta.Run
+ Job meta.Job
+ Log template.HTML // ANSI-rendered log (safe HTML)
+ Plain string // plain-text fallback for an over-cap log
+ Note string // an explanation when there is no rendered log
}
diff --git a/internal/webui/templates/browse_commit.html b/internal/webui/templates/browse_commit.html
index 0a14978..3954d6d 100644
--- a/internal/webui/templates/browse_commit.html
+++ b/internal/webui/templates/browse_commit.html
@@ -4,6 +4,7 @@
<p>
<code>{{.Commit.SHA}}</code><br>
{{.Commit.Author}} &lt;{{.Commit.Email}}&gt; · {{.Commit.When.Format "2006-01-02 15:04"}}
+ {{if .Run}}· CI <a href="/browse/{{.Repo}}/-/run/{{.Run.ID}}" style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</a>{{end}}
</p>
{{if .Commit.Body}}<pre>{{.Commit.Body}}</pre>{{end}}
<pre>{{.Diff}}</pre>
diff --git a/internal/webui/templates/ci_log.html b/internal/webui/templates/ci_log.html
new file mode 100644
index 0000000..4cf7698
--- /dev/null
+++ b/internal/webui/templates/ci_log.html
@@ -0,0 +1,15 @@
+{{define "ci_log.html"}}{{template "top" .}}
+<h1>{{.Job.Name}} — run #{{.Run.ID}}</h1>
+<p>
+ <span style="color:{{statusColor .Job.Status}}">{{.Job.Status}}</span>
+ · <a href="/browse/{{.Repo}}/-/run/{{.Run.ID}}">run</a>
+ · {{.Repo}}
+</p>
+
+{{if .Note}}<div class="flash err">{{.Note}}</div>{{end}}
+{{if .Plain}}
+<pre>{{.Plain}}</pre>
+{{else if .Log}}
+<pre>{{.Log}}</pre>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/ci_run.html b/internal/webui/templates/ci_run.html
new file mode 100644
index 0000000..92ee8b9
--- /dev/null
+++ b/internal/webui/templates/ci_run.html
@@ -0,0 +1,26 @@
+{{define "ci_run.html"}}{{template "top" .}}
+<h1>Run #{{.Run.ID}} — {{.Repo}}</h1>
+<p>
+ <span style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</span>
+ · {{.Run.Ref}} · <code>{{short .Run.SHA}}</code>
+ · <a href="/browse/{{.Repo}}/-/commit/{{.Run.SHA}}">commit</a>
+ · <a href="/browse/{{.Repo}}/-/runs">all runs</a>
+</p>
+
+{{if .Jobs}}
+<table>
+ <thead><tr><th>Job</th><th>Status</th><th>Log</th></tr></thead>
+ <tbody>
+ {{range .Jobs}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td><span style="color:{{statusColor .Status}}">{{.Status}}</span></td>
+ <td><a href="/browse/{{$.Repo}}/-/run/{{$.Run.ID}}/job/{{.ID}}/log">log</a></td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>This run has no jobs.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/ci_runs.html b/internal/webui/templates/ci_runs.html
new file mode 100644
index 0000000..c35c3ff
--- /dev/null
+++ b/internal/webui/templates/ci_runs.html
@@ -0,0 +1,24 @@
+{{define "ci_runs.html"}}{{template "top" .}}
+<h1>CI runs — {{.Repo}}</h1>
+<p><a href="/browse/{{.Repo}}/-/tree/">files</a> · <a href="/browse/{{.Repo}}/-/commits">history</a></p>
+
+{{if .Runs}}
+<table>
+ <thead><tr><th>Run</th><th>Status</th><th>Ref</th><th>Commit</th><th>Updated</th></tr></thead>
+ <tbody>
+ {{range .Runs}}
+ <tr>
+ <td><a href="/browse/{{$.Repo}}/-/run/{{.ID}}">#{{.ID}}</a></td>
+ <td><span style="color:{{statusColor .Status}}">{{.Status}}</span></td>
+ <td>{{.Ref}}</td>
+ <td><code>{{short .SHA}}</code></td>
+ <td>{{.UpdatedAt.Format "2006-01-02 15:04"}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{if .More}}<p>Showing the most recent {{len .Runs}} runs; older runs exist.</p>{{end}}
+{{else}}
+<p>No CI runs yet for {{.Repo}}. Push a commit with <code>.github/workflows</code>.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/repos.html b/internal/webui/templates/repos.html
index ab32a44..25d80b0 100644
--- a/internal/webui/templates/repos.html
+++ b/internal/webui/templates/repos.html
@@ -7,7 +7,7 @@
<tbody>
{{range .Repos}}
<tr>
- <td><a href="/browse/{{.Name}}/-/tree/">{{.Name}}</a></td>
+ <td><a href="/browse/{{.Name}}/-/tree/">{{.Name}}</a> · <a href="/browse/{{.Name}}/-/runs">CI</a></td>
<td>{{.DefaultBranch}}</td>
<td>
<form class="inline" method="post" action="/ui/repos/default-branch">
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index b00205e..8d372bd 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -27,6 +27,7 @@ import (
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
)
//go:embed templates/*.html
@@ -61,6 +62,7 @@ var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
type Handler struct {
md *meta.Metadata
mz *repo.Materializer
+ store store.Store
auth Authenticator
secrets SecretsAdmin
sess *sessions
@@ -71,8 +73,9 @@ type Handler struct {
// New builds the UI handler. cookieKey signs session cookies and must be
// non-empty; the auth verifier backs the login form; mz materializes repos for
-// the read-only browse pages; secrets (may be nil) backs the CI secrets panel.
-func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, secrets SecretsAdmin, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
+// the read-only browse pages; objs reads CI log blobs for the run views; secrets
+// (may be nil) backs the CI secrets panel.
+func New(md *meta.Metadata, mz *repo.Materializer, objs store.Store, a Authenticator, secrets SecretsAdmin, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
if len(cookieKey) == 0 {
return nil, errors.New("webui: empty cookie key")
}
@@ -89,6 +92,26 @@ func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, secrets Secr
// highlightCSS is chroma's class stylesheet, included once in the head so
// highlighted blobs and README code blocks share one theme.
"highlightCSS": render.HighlightCSS,
+ // short abbreviates a commit SHA for display.
+ "short": func(s string) string {
+ if len(s) > 8 {
+ return s[:8]
+ }
+ return s
+ },
+ // statusColor maps a CI run/job status to a CSS color for its badge.
+ "statusColor": func(s meta.RunStatus) string {
+ switch s {
+ case meta.RunPassed:
+ return "#27ae60"
+ case meta.RunFailed, meta.RunError:
+ return "#c0392b"
+ case meta.RunRunning:
+ return "#b7950b"
+ default: // queued, superseded
+ return "#7f8c8d"
+ }
+ },
}).ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, err
@@ -99,6 +122,7 @@ func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, secrets Secr
return &Handler{
md: md,
mz: mz,
+ store: objs,
auth: a,
secrets: secrets,
sess: &sessions{key: cookieKey},
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
index 901a83b..b01cb76 100644
--- a/internal/webui/webui_test.go
+++ b/internal/webui/webui_test.go
@@ -51,7 +51,7 @@ func newHarness(t *testing.T) *harness {
}
svc := secrets.NewService(kr, md)
- h, err := New(md, mz, auth.NewGuard(md), svc, []byte("test-cookie-key"), nil)
+ h, err := New(md, mz, objs, auth.NewGuard(md), svc, []byte("test-cookie-key"), nil)
if err != nil {
t.Fatalf("New: %v", err)
}
diff --git a/tasks/23-ci-status-ui.md b/tasks/23-ci-status-ui.md
deleted file mode 100644
index 3097377..0000000
--- a/tasks/23-ci-status-ui.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# CI stage 6 — status UI + log viewer
-
-Part of the CI epic ([16-ci.md](16-ci.md)). Depends on runs/jobs/logs existing
-(stages 1–4, [17](17-ci-data-model.md)–[21](21-ci-runner.md)) and reuses the
-browse UI + rendering from the shipped tasks 14/15. Admin-gated like the rest of
-the UI. Additive — no write-path or protocol change.
-
-## Spec
-
-Surface CI in the web UI: a per-repo **runs list**, a **run detail** page (its
-jobs + statuses), and a **log viewer** for a job. Add a commit-status affordance
-to the existing browse commit view (a badge linking to the run for that SHA).
-
-## Current
-
-- `ci_runs` / `ci_jobs` with statuses (stages 1–2), and `log_key` pointing at a
- `ci/` object once a job completes (stage 4).
-- The webui browse handlers ([internal/webui/browse.go](../internal/webui/browse.go))
- already split a `/browse/{repo}/-/…` path manually and gate on `requireAdmin`;
- templates + the shared `browse_head` partial are the pattern.
-- [internal/render](../internal/render) sanitizes/renders HTML (markdown +
- chroma) with a size guard — the model for rendering a log safely. No ANSI→HTML
- converter yet.
-- `store.Get` reads a `ci/` log blob back.
-
-## Change
-
-**1. Routes** (behind `requireAdmin`, same manual `/-/` split as browse):
-- `GET /browse/{repo}/-/runs` — recent runs (`meta.ListRuns`), each linking to
- its detail; newest first, with a "more" marker if capped (no silent truncation).
-- `GET /browse/{repo}/-/run/{id}` — run metadata + its jobs (`meta.ListJobs`) and
- statuses, each job linking to its log.
-- `GET /browse/{repo}/-/run/{id}/job/{jid}/log` — the log view.
-
-**2. Log rendering.** Fetch the blob via `store.Get(log_key)`; render as text in a
-`<pre>` (reuse the blob template's isolated text block). Add a small **ANSI→HTML**
-step (a minimal escape-code→`<span class>` converter, or a vetted tiny dep) so
-`act`'s colored output is readable; **size-guard** it like blobs (over the cap →
-plain, note shown). All output stays sanitized — log bytes are untrusted.
-
-**3. Commit status in browse.** In `browse_commit.html` (task 14), look up the
-latest run for that SHA (`meta` helper `LatestRunForSHA(repoID, sha)`) and show a
-status badge linking to the run. Absent → nothing.
-
-**4. Templates** — `ci_runs.html`, `ci_run.html`, `ci_log.html`, reusing
-[layout.html](../internal/webui/templates/layout.html) and `browse_head`. A
-`ci` nav affordance from the repos list (like the browse link) is fine.
-
-## Verify
-
-- **webui route tests** (golden + failure, still behind `requireAdmin`, cf.
- [browse_test.go](../internal/webui/browse_test.go)): seed runs/jobs + a log blob;
- the runs list renders and links; run detail shows job statuses; the log view
- renders the stored bytes; an unknown run/job → 404; unauth → redirect/deny.
-- **ANSI + safety:** a log containing ANSI codes renders as spans, not raw escape
- bytes; a log with HTML-ish content is escaped (no injection); an over-cap log
- falls back to plain with the note.
-- **commit badge:** a commit with a run shows the badge linking to it; one without
- shows nothing.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green.
diff --git a/tasks/README.md b/tasks/README.md
index 7c6b820..ab5f4ef 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -21,7 +21,5 @@ dependency-ordered stages, each implementable from fresh context:
The runner, local trigger, scoped clone token, and reconcile ticker have landed
(CI runs locally via `make dev` + `act`); what remains is packaging the runner
for Scaleway (Dockerfile.runner + `scw jobs definition`), gated on a DinD spike.
-- [23-ci-status-ui.md](23-ci-status-ui.md) — runs list, run detail, log viewer,
- commit status badge (admin-gated).
- [24-ci-self-deploy.md](24-ci-self-deploy.md) — green master run redeploys
gitmote; latest-wins guard; GitHub mirror as break-glass.