Commit 590c75d99c

590c75d99c31b5b7358d22561dcb0a134c1dbf57

parent: 9bf001e417

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 17:00 UTC

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.

Layout: unified · split

internal/httpd/web.go +41 −7
@@ -3,6 +3,7 @@ package httpd
3import ( 3import (
4 "bytes" 4 "bytes"
5 "fmt" 5 "fmt"
6 "hash/fnv"
6 7
7 "gitbay.org/gitbay/internal/policy" 8 "gitbay.org/gitbay/internal/policy"
8 "html/template" 9 "html/template"
@@ -645,6 +646,32 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
645 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines}) 646 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines})
646} 647}
647 648
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
648func (s *Server) issues(w http.ResponseWriter, r *http.Request) { 675func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
649 p, ok := s.repoFor(w, r, "") 676 p, ok := s.repoFor(w, r, "")
650 if !ok { 677 if !ok {
@@ -660,11 +687,17 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
660 http.Error(w, "internal error", http.StatusInternalServerError) 687 http.Error(w, "internal error", http.StatusInternalServerError)
661 return 688 return
662 } 689 }
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 }
663 s.render(w, "issues.html", struct { 695 s.render(w, "issues.html", struct {
664 repoPage 696 repoPage
665 State string 697 State string
666 Issues []store.Issue 698 Issues []store.Issue
667 }{p, state, issues}) 699 LabelColors map[string]template.CSS
700 }{p, state, issues, s.labelColors(p.Repo.ID)})
668} 701}
669 702
670func (s *Server) issue(w http.ResponseWriter, r *http.Request) { 703func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -690,10 +723,11 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
690 } 723 }
691 s.render(w, "issue.html", struct { 724 s.render(w, "issue.html", struct {
692 repoPage 725 repoPage
693 Issue store.Issue 726 Issue store.Issue
694 BodyHTML template.HTML 727 BodyHTML template.HTML
695 Comments []renderedComment 728 Comments []renderedComment
696 }{p, iss, mdHTML(iss.Body), renderComments(comments)}) 729 LabelColors map[string]template.CSS
730 }{p, iss, mdHTML(iss.Body), renderComments(comments), s.labelColors(p.Repo.ID)})
697} 731}
698 732
699func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { 733func (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) {
167 return out, rows.Err() 167 return out, rows.Err()
168} 168}
169 169
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
170// SetIssueLabel attaches (add) or detaches a label, creating the repo label 213// SetIssueLabel attaches (add) or detaches a label, creating the repo label
171// on first use. 214// on first use.
172func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { 215func (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); }
352} 352}
353.chip-open { --chip: var(--ok); } 353.chip-open { --chip: var(--ok); }
354.chip-closed { --chip: var(--bad); } 354.chip-closed { --chip: var(--bad); }
355.chip-merged { --chip: var(--done); } 355.chip-merged, .chip-done { --chip: var(--done); }
356.chip-neutral { --chip: var(--neutral); } 356.chip-neutral, .chip-source_gone { --chip: var(--neutral); }
357.chip-stale { --chip: var(--warn); }
358.chip.label { font-weight: 500; }
357.badge-verified { --chip: var(--ok); } 359.badge-verified { --chip: var(--ok); }
358.badge-unsigned { --chip: var(--neutral); } 360.badge-unsigned { --chip: var(--neutral); }
359.badge-signed_unknown_key { --chip: var(--warn); } 361.badge-signed_unknown_key { --chip: var(--warn); }
@@ -368,6 +370,63 @@ span.check-success { color: var(--ok); }
368span.check-pending { color: var(--warn); } 370span.check-pending { color: var(--warn); }
369span.check-failure, span.check-error { color: var(--bad); } 371span.check-failure, span.check-error { color: var(--bad); }
370 372
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
371/* review threads under diff lines */ 430/* review threads under diff lines */
372.thread { 431.thread {
373 border: 1px solid var(--faint); 432 border: 1px solid var(--faint);
@@ -378,6 +437,10 @@ span.check-failure, span.check-error { color: var(--bad); }
378} 437}
379.thread.resolved { border-left-color: var(--ok); opacity: 0.75; } 438.thread.resolved { border-left-color: var(--ok); opacity: 0.75; }
380.thread.stale { border-left-color: var(--warn); } 439.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; }
381 444
382/* forms */ 445/* forms */
383input, textarea, button, select { font: inherit; color: inherit; } 446input, textarea, button, select { font: inherit; color: inherit; }
internal/web/templates/index.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}{{.Site}}{{end}} 1{{define "title"}}{{.Site}}{{end}}
2{{define "content"}} 2{{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> ·
4<form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}} 4<form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}}
5<h1>repositories</h1> 5<h1>repositories</h1>
6<ul class="repolist"> 6<ul class="repolist">
internal/web/templates/issue.html +15 −8
@@ -1,17 +1,24 @@
1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<h2>#{{.Issue.Number}} {{.Issue.Title}} <span class="badge badge-unsigned">{{.Issue.State}}</span></h2> 4<h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2>
5<p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} 5<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
6{{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} 6{{.Issue.Author}} opened this on {{when .Issue.CreatedAt}}
7{{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> 7{{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}}
8{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{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}}
9{{range .Comments}} 13{{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>
11{{end}} 18{{end}}
12{{if .Viewer}} 19{{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> 20<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
14<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> 21<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p>
15<p><button type="submit">comment</button></p> 22<p><button type="submit">comment</button></p>
16</form> 23</form>
17{{end}} 24{{end}}
internal/web/templates/issues.html +19 −12
@@ -1,16 +1,23 @@
1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<h2>issues ({{.State}})</h2> 4<div class="listhead">
5<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p> 5 <h2>issues</h2>
6<table> 6 <nav class="filters">
7{{range .Issues}}<tr> 7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <td>#{{.Number}}</td> 8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
9 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a></td> 9 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
10 <td>{{.State}}</td> 10 </nav>
11 <td>{{.Author}}</td> 11</div>
12 <td>{{range .Labels}}<span class="badge badge-unsigned">{{.}}</span> {{end}}</td> 12<ul class="issuelist">
13</tr> 13{{range .Issues}}<li>
14{{else}}<tr><td>no issues</td></tr>{{end}} 14 <div class="issuemain">
15</table> 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>
16{{end}} 23{{end}}
internal/web/templates/mr.html +21 −12
@@ -1,25 +1,34 @@
1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> 4<h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2>
5<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} 5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6 @ <code>{{.MR.HeadSHA}}</code></p> 6{{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
7{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} 7 at <code>{{short .MR.HeadSHA}}</code></p>
8{{if .Checks}}<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span> 8{{if .BodyHTML}}<article class="comment">
9{{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>{{end}} 9 <header class="commenthead"><strong>{{.MR.Author}}</strong></header>
10{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} 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}}
11{{range .Comments}} 17{{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>
13{{end}} 22{{end}}
14{{if .Viewer}} 23{{if .Viewer}}
15<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment"> 24<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform">
16<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> 25<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p>
17<p><button type="submit">comment</button></p> 26<p><button type="submit">comment</button></p>
18</form> 27</form>
19{{end}} 28{{end}}
20<h3>diff</h3> 29<h3>diff</h3>
21<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> 30<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>
23{{if .DetachedThreads}}<h3>threads on earlier revisions</h3> 32{{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}}
25{{end}} 34{{end}}
internal/web/templates/mrs.html +19 −12
@@ -1,16 +1,23 @@
1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<h2>merge requests ({{.State}})</h2> 4<div class="listhead">
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> 5 <h2>merge requests</h2>
6<table> 6 <nav class="filters">
7{{range .MRs}}<tr> 7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <td>!{{.Number}}</td> 8 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a>
9 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td> 9 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
10 <td>{{.State}}</td> 10 <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a>
11 <td>{{.Author}}</td> 11 </nav>
12 <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td> 12</div>
13</tr> 13<ul class="issuelist">
14{{else}}<tr><td>no merge requests</td></tr>{{end}} 14{{range .MRs}}<li>
15</table> 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>
16{{end}} 23{{end}}
internal/web/web.go +10
@@ -8,6 +8,7 @@ import (
8 "io" 8 "io"
9 "runtime/debug" 9 "runtime/debug"
10 "sync" 10 "sync"
11 "time"
11) 12)
12 13
13//go:embed templates/*.html 14//go:embed templates/*.html
@@ -43,6 +44,15 @@ var funcs = template.FuncMap{
43 } 44 }
44 return s 45 return s
45 }, 46 },
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 },
46} 56}
47 57
48// Render executes the named page template with the shared layout. 58// Render executes the named page template with the shared layout.