A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit c90a10435b

c90a10435ba3187b80e54031d1ef7219d0b436ee

parent: 027a72496b

Verified · cmc

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

Link cross-references and mentions in web rendering (#6)

Issue and MR bodies, comments, and diff threads pass through
autolink.Rewrite after markdown: #N, !N, owner/name#N, owner/name!N,
and @user become links when the target exists. Resolution respects the
viewer: cross-repo references to repositories the viewer cannot read
stay plain text, keeping private repos non-enumerable. Rendering-side
only; backlinks and mention notifications remain future work.
e2e/autolink_test.go added +92
@@ -0,0 +1,92 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestWebAutolinks(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
16 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
17
18 // Public repo with an issue, an MR, and a private repo with an issue.
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
23 t.Fatal("private repo create failed")
24 }
25 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", "--title", "'hidden'"); code != 0 {
26 t.Fatal("private issue create failed")
27 }
28 work := t.TempDir()
29 env := inst.gitEnv(aliceKey)
30 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
31 dir := filepath.Join(work, "w")
32 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
33 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
34 mustGit(t, dir, env, "add", ".")
35 mustGit(t, dir, env, "commit", "-q", "-m", "base")
36 mustGit(t, dir, env, "push", "-q", "origin", "main")
37 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
38 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
39 mustGit(t, dir, env, "add", ".")
40 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
41 mustGit(t, dir, env, "push", "-q", "origin", "feat")
42 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
43 "--source", "feat", "--target", "main", "--title", "'feat'"); code != 0 {
44 t.Fatalf("mr create: %s", errOut)
45 }
46 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'first'"); code != 0 {
47 t.Fatal("issue 1 create failed")
48 }
49 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app",
50 "--title", "'refs'", "--body",
51 "'see #1 and !1 and alice/app#1 and @bob, not #99, not alice/secret#1, not `#1` in code'"); code != 0 {
52 t.Fatal("issue 2 create failed")
53 }
54
55 status, body := inst.get(t, "/alice/app/issues/2")
56 if status != 200 {
57 t.Fatalf("issue page: %d", status)
58 }
59 for _, want := range []string{
60 `<a href="/alice/app/issues/1" class="xref">#1</a>`,
61 `<a href="/alice/app/mrs/1" class="xref">!1</a>`,
62 `<a href="/alice/app/issues/1" class="xref">alice/app#1</a>`,
63 `<a href="/bob" class="xref">@bob</a>`,
64 } {
65 if !strings.Contains(body, want) {
66 t.Errorf("missing %q on issue page", want)
67 }
68 }
69 if strings.Contains(body, "issues/99") {
70 t.Error("nonexistent issue got linked")
71 }
72 // The private repo must stay plain text for anonymous viewers: a link
73 // would confirm it exists.
74 if strings.Contains(body, `href="/alice/secret`) {
75 t.Error("private repo reference leaked as a link")
76 }
77 if !strings.Contains(body, "alice/secret#1") {
78 t.Error("private repo reference text missing entirely")
79 }
80 if !strings.Contains(body, "<code>#1</code>") {
81 t.Error("code-span reference was rewritten")
82 }
83
84 // Comments on MR pages get the same treatment.
85 if _, _, code := inst.ssh(t, bobKey, "", "mr", "comment", "alice/app", "1", "--message", "'closes #2'"); code != 0 {
86 t.Fatal("mr comment failed")
87 }
88 _, body = inst.get(t, "/alice/app/mrs/1")
89 if !strings.Contains(body, `<a href="/alice/app/issues/2" class="xref">#2</a>`) {
90 t.Error("MR comment reference not linked")
91 }
92}
internal/httpd/web.go +70 −8
@@ -21,6 +21,7 @@ import (
2121 "github.com/niklasfasching/go-org/org"
2222 "github.com/yuin/goldmark"
2323
24 "gitbay.org/gitbay/internal/autolink"
2425 "gitbay.org/gitbay/internal/control"
2526 "gitbay.org/gitbay/internal/gitutil"
2627 "gitbay.org/gitbay/internal/sig"
@@ -403,6 +404,65 @@ func mdHTML(raw string) template.HTML {
403404 return template.HTML(buf.String())
404405 }
405406
407// webResolver answers autolink lookups for one viewer. Cross-repo
408// references to repositories the viewer cannot read stay plain text, per
409// the enumeration rule: a link would confirm the repo exists.
410type webResolver struct {
411 s *Server
412 viewer store.User
413}
414
415func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
416 repo, err := r.s.st.RepoByPath(owner + "/" + name)
417 if err != nil {
418 return ""
419 }
420 grant := ""
421 if r.viewer.ID != 0 {
422 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
423 }
424 if !policy.CanRead(r.viewer, repo, grant) {
425 return ""
426 }
427 if kind == '#' {
428 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
429 return ""
430 }
431 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
432 }
433 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
434 return ""
435 }
436 return autolink.MRURL(repo.OwnerName, repo.Name, n)
437}
438
439func (r webResolver) UserURL(name string) string {
440 if _, err := r.s.st.UserByUsername(name); err == nil {
441 return "/" + name
442 }
443 if _, err := r.s.st.OrgByName(name); err == nil {
444 return "/" + name
445 }
446 return ""
447}
448
449// ugcFor returns a renderer for user-authored markdown on one repo's pages:
450// mdHTML plus cross-reference and mention autolinking for this viewer.
451func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
452 viewer := store.User{}
453 if s.cfg.Web.Mode == "accounts" {
454 viewer = s.viewer(r)
455 }
456 res := webResolver{s, viewer}
457 return func(raw string) template.HTML {
458 h := mdHTML(raw)
459 if h == "" {
460 return h
461 }
462 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
463 }
464}
465
406466 // renderedComment pairs a comment with its rendered body for templates.
407467 type renderedComment struct {
408468 Author string
@@ -410,10 +470,10 @@ type renderedComment struct {
410470 BodyHTML template.HTML
411471 }
412472
413func renderComments(cs []store.IssueComment) []renderedComment {
473func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
414474 var out []renderedComment
415475 for _, c := range cs {
416 out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
476 out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)})
417477 }
418478 return out
419479 }
@@ -510,7 +570,7 @@ type diffThread struct {
510570 // attachThreads injects review threads under their anchored diff lines;
511571 // threads whose anchor no longer appears (stale after force-push, or on a
512572 // context line outside the current diff) are returned separately.
513func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string) ([]diffLine, []diffThread) {
573func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
514574 type anchor struct {
515575 path string
516576 side string
@@ -522,11 +582,11 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin
522582 for _, cm := range comments {
523583 if cm.ReplyTo == 0 {
524584 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
525 Comments: []renderedComment{{cm.Author, cm.CreatedAt, mdHTML(cm.Body)}}}
585 Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}}
526586 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
527587 order = append(order, cm.ID)
528588 } else if th, ok := threads[cm.ReplyTo]; ok {
529 th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, mdHTML(cm.Body)})
589 th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)})
530590 }
531591 }
532592 placed := map[int64]bool{}
@@ -737,13 +797,14 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
737797 http.Error(w, "internal error", http.StatusInternalServerError)
738798 return
739799 }
800 md := s.ugcFor(r, p.Repo)
740801 s.render(w, "issue.html", struct {
741802 repoPage
742803 Issue store.Issue
743804 BodyHTML template.HTML
744805 Comments []renderedComment
745806 LabelColors map[string]template.CSS
746 }{p, iss, mdHTML(iss.Body), renderComments(comments), s.labelColors(p.Repo.ID)})
807 }{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)})
747808 }
748809
749810 func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
@@ -806,8 +867,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
806867 lines = classifyDiff(patch)
807868 }
808869 }
870 md := s.ugcFor(r, p.Repo)
809871 var detachedThreads []diffThread
810 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA)
872 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
811873 s.render(w, "mr.html", struct {
812874 repoPage
813875 MR store.MR
@@ -818,7 +880,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
818880 Reviews []store.MRReview
819881 DiffLines []diffLine
820882 DetachedThreads []diffThread
821 }{p, m, mdHTML(m.Body), checks, store.CombinedStatus(checks), renderComments(comments), reviews, lines, detachedThreads})
883 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, detachedThreads})
822884 }
823885
824886 func (s *Server) refs(w http.ResponseWriter, r *http.Request) {