krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
4485496a075180586eab7210ec46a6b6f4285770
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T02:46:42Z
e2e/issue_test.go | 14 +++++++++++++- e2e/web_test.go | 5 +++++ internal/httpd/web.go | 38 ++++++++++++++++++++++++++++++++++---- internal/web/static/style.css | 6 +++++- internal/web/templates/issue.html | 4 ++-- internal/web/templates/layout.html | 2 ++ internal/web/templates/mr.html | 4 ++-- 7 files changed, 63 insertions(+), 10 deletions(-) @@ -132,7 +132,8 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) { t.Fatalf("missing repo: exit %d, want 3", code) } - // Web read views: list shows the issue, detail shows the comment. + // Web read views: list shows the issue, detail shows the comment, and + // bodies render as markdown (goldmark drops raw HTML). status, body := inst.get(t, "/alice/proj/issues") if status != 200 || !strings.Contains(body, "first bug") { t.Fatalf("issues page: %d", status) @@ -141,6 +142,17 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) { if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") { t.Fatalf("issue detail: %d\n%s", status, body) } + if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", + "--title", "'md body'", "--body", "'has **bold** and <script>x</script>'"); code != 0 { + t.Fatal("md issue create failed") + } + status, body = inst.get(t, "/alice/proj/issues/3") + if status != 200 || !strings.Contains(body, "<strong>bold</strong>") { + t.Fatalf("markdown body not rendered: %d\n%s", status, body) + } + if strings.Contains(body, "<script>x</script>") { + t.Fatal("raw HTML survived in issue body") + } // Private repos hide their issues from non-readers, as not-found. if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { @@ -83,6 +83,11 @@ func TestWebUI(t *testing.T) { if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { t.Fatalf("README not rendered:\n%s", body) } + for _, tab := range []string{">issues<", ">merge requests<"} { + if !strings.Contains(body, tab) { + t.Fatalf("repo header missing %s tab", tab) + } + } // Subdirectory tree and blob with highlighting. status, body = inst.get(t, "/alice/site/tree/main/src") @@ -342,6 +342,34 @@ func pickReadme(entries []gitutil.TreeEntry) string { return best } +// mdHTML renders user-authored markdown (issue and MR bodies, comments). +// goldmark's default renderer drops raw HTML, so this is safe as-is. +func mdHTML(raw string) template.HTML { + if strings.TrimSpace(raw) == "" { + return "" + } + var buf bytes.Buffer + if goldmark.Convert([]byte(raw), &buf) != nil { + return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") + } + return template.HTML(buf.String()) +} + +// renderedComment pairs a comment with its rendered body for templates. +type renderedComment struct { + Author string + CreatedAt string + BodyHTML template.HTML +} + +func renderComments(cs []store.IssueComment) []renderedComment { + var out []renderedComment + for _, c := range cs { + out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)}) + } + return out +} + // ugcPolicy sanitizes rendered repo content before it enters the forge's // origin: markdown is already safe (goldmark drops raw HTML), but org-mode // output and repo-authored HTML are not. @@ -548,8 +576,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { s.render(w, "issue.html", struct { repoPage Issue store.Issue - Comments []store.IssueComment - }{p, iss, comments}) + BodyHTML template.HTML + Comments []renderedComment + }{p, iss, mdHTML(iss.Body), renderComments(comments)}) } func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { @@ -605,10 +634,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { s.render(w, "mr.html", struct { repoPage MR store.MR - Comments []store.IssueComment + BodyHTML template.HTML + Comments []renderedComment Reviews []store.MRReview DiffLines []diffLine - }{p, m, comments, reviews, lines}) + }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines}) } func (s *Server) refs(w http.ResponseWriter, r *http.Request) { @@ -34,7 +34,11 @@ p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radiu colors in both schemes so unstyled tokens stay legible. */ .code { background: #f8f8f8; color: #1a1a1a; } .code pre { margin: 0; background: transparent !important; } -pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; } +pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; } +.rendered { max-width: 100%; overflow-wrap: anywhere; } +.rendered pre { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } +.rendered code { background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; } +.rendered blockquote { border-left: 3px solid var(--line); margin-left: 0; padding-left: 1rem; color: var(--muted); } pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; } pre.diff .add { color: var(--ok); } pre.diff .del { color: var(--bad); } @@ -5,9 +5,9 @@ <p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} {{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} {{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> -{{if .Issue.Body}}<pre class="message">{{.Issue.Body}}</pre>{{end}} +{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} {{range .Comments}} -<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> +<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> {{end}} {{if .Viewer}} <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> @@ -22,6 +22,8 @@ <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> + <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> </nav> <p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> @@ -4,10 +4,10 @@ <h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} @ <code>{{.MR.HeadSHA}}</code></p> -{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}} +{{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} {{range .Comments}} -<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> +<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> {{end}} {{if .Viewer}} <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">