A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 590c75d99c

590c75d99c31b5b7358d22561dcb0a134c1dbf57

parent: 9bf001e417

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T17:00:28Z

web: issues and merge requests

Listings become rows with title, labels, author meta, and a state chip
(open green, closed/merged purple, MR closed red); state filters render
as segmented pills. Detail pages get a title with muted number, a
state+meta line, and discussion as comment cards with author/timestamp
headers; timestamps format via a new 'when' template func.

Label colors wired: labels.color is read per repo (new store methods
LabelColors and ListIssueLabels — ListIssues stays label-free for the
CLI), invalid or empty colors fall back to a stable palette pick by
name hash, and chips carry the color as an inline CSS variable.

Review threads keep their classes; resolved/stale states and check
badges restyled from tokens.
internal/httpd/web.go +41 −7
@@ -3,6 +3,7 @@ package httpd
33 import (
44 "bytes"
55 "fmt"
6 "hash/fnv"
67
78 "gitbay.org/gitbay/internal/policy"
89 "html/template"
@@ -645,6 +646,32 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
645646 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines})
646647 }
647648
649// labelPalette provides default label chip colors: mid-tone hues that stay
650// legible on light and dark backgrounds.
651var labelPalette = []string{
652 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
653 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
654}
655
656var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
657
658// labelColors returns a complete label-name -> chip color map for a repo:
659// the stored labels.color when it is a valid hex color, otherwise a
660// stable default picked from the palette by name hash.
661func (s *Server) labelColors(repoID int64) map[string]template.CSS {
662 stored, _ := s.st.LabelColors(repoID)
663 out := make(map[string]template.CSS, len(stored))
664 for name, color := range stored {
665 if !hexColorPat.MatchString(color) {
666 h := fnv.New32a()
667 h.Write([]byte(name))
668 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
669 }
670 out[name] = template.CSS("--chip:" + color)
671 }
672 return out
673}
674
648675 func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
649676 p, ok := s.repoFor(w, r, "")
650677 if !ok {
@@ -660,11 +687,17 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
660687 http.Error(w, "internal error", http.StatusInternalServerError)
661688 return
662689 }
690 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
691 for i := range issues {
692 issues[i].Labels = labels[issues[i].ID]
693 }
694 }
663695 s.render(w, "issues.html", struct {
664696 repoPage
665 State string
666 Issues []store.Issue
667 }{p, state, issues})
697 State string
698 Issues []store.Issue
699 LabelColors map[string]template.CSS
700 }{p, state, issues, s.labelColors(p.Repo.ID)})
668701 }
669702
670703 func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -690,10 +723,11 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
690723 }
691724 s.render(w, "issue.html", struct {
692725 repoPage
693 Issue store.Issue
694 BodyHTML template.HTML
695 Comments []renderedComment
696 }{p, iss, mdHTML(iss.Body), renderComments(comments)})
726 Issue store.Issue
727 BodyHTML template.HTML
728 Comments []renderedComment
729 LabelColors map[string]template.CSS
730 }{p, iss, mdHTML(iss.Body), renderComments(comments), s.labelColors(p.Repo.ID)})
697731 }
698732
699733 func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
internal/store/issues.go +43
@@ -167,6 +167,49 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
167167 return out, rows.Err()
168168 }
169169
170// ListIssueLabels returns the label names attached to each issue of a
171// repo, keyed by issue id. Used by the web issue listing; ListIssues
172// itself stays label-free for the CLI's lean list output.
173func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
174 rows, err := s.DB.Query(`
175 SELECT il.issue_id, l.name FROM issue_labels il
176 JOIN labels l ON l.id = il.label_id
177 WHERE l.repo_id = ? ORDER BY l.name`, repoID)
178 if err != nil {
179 return nil, err
180 }
181 defer rows.Close()
182 out := map[int64][]string{}
183 for rows.Next() {
184 var id int64
185 var name string
186 if err := rows.Scan(&id, &name); err != nil {
187 return nil, err
188 }
189 out[id] = append(out[id], name)
190 }
191 return out, rows.Err()
192}
193
194// LabelColors returns the repo's label colors keyed by label name. Labels
195// with no stored color map to "".
196func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
197 rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID)
198 if err != nil {
199 return nil, err
200 }
201 defer rows.Close()
202 out := map[string]string{}
203 for rows.Next() {
204 var name, color string
205 if err := rows.Scan(&name, &color); err != nil {
206 return nil, err
207 }
208 out[name] = color
209 }
210 return out, rows.Err()
211}
212
170213 // SetIssueLabel attaches (add) or detaches a label, creating the repo label
171214 // on first use.
172215 func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error {
internal/web/static/style.css +65 −2
@@ -352,8 +352,10 @@ pre.diff .meta { color: var(--muted); }
352352 }
353353 .chip-open { --chip: var(--ok); }
354354 .chip-closed { --chip: var(--bad); }
355.chip-merged { --chip: var(--done); }
356.chip-neutral { --chip: var(--neutral); }
355.chip-merged, .chip-done { --chip: var(--done); }
356.chip-neutral, .chip-source_gone { --chip: var(--neutral); }
357.chip-stale { --chip: var(--warn); }
358.chip.label { font-weight: 500; }
357359 .badge-verified { --chip: var(--ok); }
358360 .badge-unsigned { --chip: var(--neutral); }
359361 .badge-signed_unknown_key { --chip: var(--warn); }
@@ -368,6 +370,63 @@ span.check-success { color: var(--ok); }
368370 span.check-pending { color: var(--warn); }
369371 span.check-failure, span.check-error { color: var(--bad); }
370372
373/* issue and MR listings */
374.listhead {
375 display: flex;
376 align-items: baseline;
377 gap: var(--sp-4);
378 flex-wrap: wrap;
379}
380.listhead h2 { margin-bottom: var(--sp-2); }
381nav.filters { display: flex; gap: var(--sp-1); }
382nav.filters a {
383 color: var(--muted);
384 font-size: var(--fs-2);
385 padding: 0.05rem var(--sp-2);
386 border-radius: var(--r-pill);
387}
388nav.filters a:hover { color: var(--fg); background: var(--surface); text-decoration: none; }
389nav.filters a.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
390
391ul.issuelist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
392ul.issuelist li {
393 display: flex;
394 align-items: baseline;
395 gap: var(--sp-3);
396 padding: var(--sp-3) 0;
397 border-bottom: 1px solid var(--faint);
398}
399ul.issuelist li.empty { display: block; color: var(--muted); }
400ul.issuelist .issuemain { flex: 1; min-width: 0; }
401ul.issuelist p { margin: 0; }
402ul.issuelist .title a { color: var(--fg); font-weight: 550; }
403ul.issuelist .title a:hover { color: var(--accent); }
404
405/* issue / MR detail */
406h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); }
407.issuenumber { color: var(--muted); font-weight: 400; }
408.issuemeta { color: var(--muted); font-size: var(--fs-2); margin-top: 0; }
409.review { font-size: var(--fs-2); }
410
411/* comment cards */
412article.comment {
413 border: 1px solid var(--faint);
414 border-radius: var(--r-lg);
415 margin: var(--sp-4) 0;
416}
417article.comment .commenthead {
418 background: var(--surface);
419 border-bottom: 1px solid var(--faint);
420 border-radius: var(--r-lg) var(--r-lg) 0 0;
421 padding: var(--sp-2) var(--sp-4);
422 font-size: var(--fs-2);
423}
424article.comment .commenthead .when { color: var(--muted); }
425article.comment .rendered { padding: 0 var(--sp-4); }
426article.comment .rendered > :first-child { margin-top: var(--sp-3); }
427article.comment .rendered > :last-child { margin-bottom: var(--sp-3); }
428form.commentform { margin: var(--sp-4) 0; }
429
371430 /* review threads under diff lines */
372431 .thread {
373432 border: 1px solid var(--faint);
@@ -378,6 +437,10 @@ span.check-failure, span.check-error { color: var(--bad); }
378437 }
379438 .thread.resolved { border-left-color: var(--ok); opacity: 0.75; }
380439 .thread.stale { border-left-color: var(--warn); }
440.thread p.commenthead { font-size: var(--fs-2); margin: var(--sp-2) 0 0; }
441.thread .when { color: var(--muted); }
442.thread .rendered p { margin: var(--sp-1) 0; }
443.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
381444
382445 /* forms */
383446 input, textarea, button, select { font: inherit; color: inherit; }
internal/web/templates/index.html +1 −1
@@ -1,6 +1,6 @@
11 {{define "title"}}{{.Site}}{{end}}
22 {{define "content"}}
3{{if .Viewer}}<p class="toolbar">logged in as <strong>{{.Viewer}}</strong> · <a href="/new">new repository</a> ·
3{{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> ·
44 <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}}
55 <h1>repositories</h1>
66 <ul class="repolist">
internal/web/templates/issue.html +15 −8
@@ -1,17 +1,24 @@
11 {{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 {{template "repoheader" .}}
4<h2>#{{.Issue.Number}} {{.Issue.Title}} <span class="badge badge-unsigned">{{.Issue.State}}</span></h2>
5<p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}}
6{{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}}
7{{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p>
8{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
4<h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2>
5<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
6{{.Issue.Author}} opened this on {{when .Issue.CreatedAt}}
7{{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}}
8{{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p>
9{{if .BodyHTML}}<article class="comment">
10 <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
11 <div class="rendered">{{.BodyHTML}}</div>
12</article>{{end}}
913 {{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
14<article class="comment">
15 <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header>
16 <div class="rendered">{{.BodyHTML}}</div>
17</article>
1118 {{end}}
1219 {{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment">
14<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
20<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
21<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p>
1522 <p><button type="submit">comment</button></p>
1623 </form>
1724 {{end}}
internal/web/templates/issues.html +19 −12
@@ -1,16 +1,23 @@
11 {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 {{template "repoheader" .}}
4<h2>issues ({{.State}})</h2>
5<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p>
6<table>
7{{range .Issues}}<tr>
8 <td>#{{.Number}}</td>
9 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a></td>
10 <td>{{.State}}</td>
11 <td>{{.Author}}</td>
12 <td>{{range .Labels}}<span class="badge badge-unsigned">{{.}}</span> {{end}}</td>
13</tr>
14{{else}}<tr><td>no issues</td></tr>{{end}}
15</table>
4<div class="listhead">
5 <h2>issues</h2>
6 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
9 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
10 </nav>
11</div>
12<ul class="issuelist">
13{{range .Issues}}<li>
14 <div class="issuemain">
15 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a>
16 {{range .Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}</p>
17 <p class="meta">#{{.Number}} opened by {{.Author}}</p>
18 </div>
19 <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>
20</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}
22</ul>
1623 {{end}}
internal/web/templates/mr.html +21 −12
@@ -1,25 +1,34 @@
11 {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 {{template "repoheader" .}}
4<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2>
5<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
6 @ <code>{{.MR.HeadSHA}}</code></p>
7{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}}
8{{if .Checks}}<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span>
9{{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>{{end}}
10{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
4<h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2>
5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6{{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
7 at <code>{{short .MR.HeadSHA}}</code></p>
8{{if .BodyHTML}}<article class="comment">
9 <header class="commenthead"><strong>{{.MR.Author}}</strong></header>
10 <div class="rendered">{{.BodyHTML}}</div>
11</article>{{end}}
12{{if .Checks}}<div class="checks">
13<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span>
14{{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>
15</div>{{end}}
16{{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}}
1117 {{range .Comments}}
12<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>
18<article class="comment">
19 <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header>
20 <div class="rendered">{{.BodyHTML}}</div>
21</article>
1322 {{end}}
1423 {{if .Viewer}}
15<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">
16<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
24<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform">
25<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p>
1726 <p><button type="submit">comment</button></p>
1827 </form>
1928 {{end}}
2029 <h3>diff</h3>
2130 <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
22{{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="crumbs">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre>
31{{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre>
2332 {{if .DetachedThreads}}<h3>threads on earlier revisions</h3>
24{{range .DetachedThreads}}<div class="thread stale"><p class="crumbs">{{if .Stale}}stale · {{end}}{{if .Resolved}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}}
33{{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}}
2534 {{end}}
internal/web/templates/mrs.html +19 −12
@@ -1,16 +1,23 @@
11 {{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 {{template "repoheader" .}}
4<h2>merge requests ({{.State}})</h2>
5<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=merged">merged</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p>
6<table>
7{{range .MRs}}<tr>
8 <td>!{{.Number}}</td>
9 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td>
10 <td>{{.State}}</td>
11 <td>{{.Author}}</td>
12 <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td>
13</tr>
14{{else}}<tr><td>no merge requests</td></tr>{{end}}
15</table>
4<div class="listhead">
5 <h2>merge requests</h2>
6 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a>
9 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
10 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
11 </nav>
12</div>
13<ul class="issuelist">
14{{range .MRs}}<li>
15 <div class="issuemain">
16 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p>
17 <p class="meta">!{{.Number}} by {{.Author}} · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p>
18 </div>
19 <span class="chip chip-{{.State}}">{{.State}}</span>
20</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}
22</ul>
1623 {{end}}
internal/web/web.go +10
@@ -8,6 +8,7 @@ import (
88 "io"
99 "runtime/debug"
1010 "sync"
11 "time"
1112 )
1213
1314 //go:embed templates/*.html
@@ -43,6 +44,15 @@ var funcs = template.FuncMap{
4344 }
4445 return s
4546 },
47 // when formats a stored RFC3339 timestamp for display; unparseable
48 // values pass through unchanged.
49 "when": func(s string) string {
50 t, err := time.Parse(time.RFC3339Nano, s)
51 if err != nil {
52 return s
53 }
54 return t.UTC().Format("2006-01-02 15:04")
55 },
4656 }
4757
4858 // Render executes the named page template with the shared layout.