detached 6859f89

feat(web): link #N references in Markdown and show merge request links on issue pages

commit
6859f892d4a1a5b893fd0688608bff47279dc3fc
author
brice · · 2026-10-20 15:15 UTC
parent
1386983
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +454 -73
M internal/adapters/web/fragments.go +3 -1
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6464 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
6767 return true
6868 }
6969 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211211 case domain.IssueCommented:
212212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213215 case domain.PushEvent:
214216 switch l.view {
215217 case "mr":
@@ -63,7 +63,7 @@ func liveEvent(we ports.WatchEvent) bool {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,64 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:66+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
67 return true67 return true
68 }68 }
69 return false69 return false
@@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")210 return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
211 case domain.IssueCommented:211 case domain.IssueCommented:
212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")212 return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
213+ case domain.IssueReferenced:
214+ return l.issueNotice(ctx, ev.Issue, "a merge request mentions this issue — reload")
213 case domain.PushEvent:215 case domain.PushEvent:
214 switch l.view {216 switch l.view {
215 case "mr":217 case "mr":
M internal/adapters/web/issues.go +62 -2
@@ -83,6 +83,57 @@ type IssuePage struct {
8383 Form *CommentForm
8484 Transition *IssueActions
8585 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ ok bool
115+ }
116+ seen := map[int64]answer{}
117+ return func(n int64) (string, bool) {
118+ if a, ok := seen[n]; ok {
119+ return a.href, a.ok
120+ }
121+ if len(seen) >= maxNumberLookups {
122+ return "", false
123+ }
124+ var a answer
125+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
126+ if err == nil {
127+ switch kind {
128+ case domain.NumberIssue:
129+ a = answer{issueHref(repo, n), true}
130+ case domain.NumberMergeRequest:
131+ a = answer{mrHref(repo, n), true}
132+ }
133+ }
134+ seen[n] = a
135+ return a.href, a.ok
136+ }
86137 }
87138
88139 // IssueNewPage is the form that opens an issue.
@@ -243,15 +294,24 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
243294 var err error
244295 if i.External {
245296 // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
297+ h, err = s.md.renderUntrusted([]byte(body), "issue-desc-", s.numberResolver(ctx, d.name()))
247298 } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
299+ h, err = s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", s.numberResolver(ctx, d.name()))
249300 }
250301 if err != nil {
251302 return nil, 0, err
252303 }
253304 p.Desc = h
254305 }
306+ if i.ClosedByMergeRequest > 0 {
307+ p.ClosedBy = &Link{Label: "!" + strconv.FormatInt(i.ClosedByMergeRequest, 10), Href: mrHref(d.name(), i.ClosedByMergeRequest)}
308+ }
309+ for _, ref := range res.References {
310+ p.References = append(p.References, IssueRefView{
311+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
312+ State: string(ref.State), Href: mrHref(d.name(), ref.Number), Closes: ref.Closes,
313+ })
314+ }
255315 p.Opened.Name = s.who(i.Author)
256316 if i.External {
257317 // The label is the visitor's, not the owner's: shown as plain text.
@@ -83,6 +83,57 @@ type IssuePage struct {
83 Form *CommentForm83 Form *CommentForm
84 Transition *IssueActions84 Transition *IssueActions
85 Live bool85 Live bool
86+ // ClosedBy is the merge request whose merge closed the issue.
87+ ClosedBy *Link
88+ // References are the merge requests naming the issue.
89+ References []IssueRefView
90+}
91+
92+// IssueRefView is a merge request naming an issue.
93+type IssueRefView struct {
94+ Number, Title, State, Href string
95+ Closes bool
96+}
97+
98+// maxNumberLookups bounds the lookups of #N references of one render.
99+const maxNumberLookups = 50
100+
101+// numberResolver resolves #N to the page of an issue or a merge request of
102+// the repository. It is meant for one render: answers are memoized and at most
103+// maxNumberLookups numbers are asked, the others do not resolve.
104+func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, bool) {
105+ if s.cfg.IssueNumbers == nil {
106+ return nil
107+ }
108+ name, err := domain.ParseRepoName(repo)
109+ if err != nil {
110+ return nil
111+ }
112+ type answer struct {
113+ href string
114+ ok bool
115+ }
116+ seen := map[int64]answer{}
117+ return func(n int64) (string, bool) {
118+ if a, ok := seen[n]; ok {
119+ return a.href, a.ok
120+ }
121+ if len(seen) >= maxNumberLookups {
122+ return "", false
123+ }
124+ var a answer
125+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
126+ if err == nil {
127+ switch kind {
128+ case domain.NumberIssue:
129+ a = answer{issueHref(repo, n), true}
130+ case domain.NumberMergeRequest:
131+ a = answer{mrHref(repo, n), true}
132+ }
133+ }
134+ seen[n] = a
135+ return a.href, a.ok
136+ }
86 }137 }
87 138
88 // IssueNewPage is the form that opens an issue.139 // IssueNewPage is the form that opens an issue.
@@ -243,15 +294,24 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
243 var err error294 var err error
244 if i.External {295 if i.External {
245 // Written by an anonymous visitor: the untrusted policy.296 // Written by an anonymous visitor: the untrusted policy.
246- h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")297+ h, err = s.md.renderUntrusted([]byte(body), "issue-desc-", s.numberResolver(ctx, d.name()))
247 } else {298 } else {
248- h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")299+ h, err = s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", s.numberResolver(ctx, d.name()))
249 }300 }
250 if err != nil {301 if err != nil {
251 return nil, 0, err302 return nil, 0, err
252 }303 }
253 p.Desc = h304 p.Desc = h
254 }305 }
306+ if i.ClosedByMergeRequest > 0 {
307+ p.ClosedBy = &Link{Label: "!" + strconv.FormatInt(i.ClosedByMergeRequest, 10), Href: mrHref(d.name(), i.ClosedByMergeRequest)}
308+ }
309+ for _, ref := range res.References {
310+ p.References = append(p.References, IssueRefView{
311+ Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
312+ State: string(ref.State), Href: mrHref(d.name(), ref.Number), Closes: ref.Closes,
313+ })
314+ }
255 p.Opened.Name = s.who(i.Author)315 p.Opened.Name = s.who(i.Author)
256 if i.External {316 if i.External {
257 // The label is the visitor's, not the owner's: shown as plain text.317 // The label is the visitor's, not the owner's: shown as plain text.
M internal/adapters/web/issues.templ +21 -0
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150150 }
151151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152156 if p.UpdatedAt != "" {
153157 <dt>updated</dt>
154158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178182 }
179183
180184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181202 if p.Transition != nil {
182203 <section aria-labelledby="r-act">
183204 <h2 id="r-act">Issue</h2>
@@ -149,6 +149,10 @@ templ IssueShow(l Layout, p IssuePage) {
149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150 }150 }
151 </dd>151 </dd>
152+ if p.ClosedBy != nil {
153+ <dt>closed by</dt>
154+ <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>
155+ }
152 if p.UpdatedAt != "" {156 if p.UpdatedAt != "" {
153 <dt>updated</dt>157 <dt>updated</dt>
154 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>158 <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
@@ -178,6 +182,23 @@ templ IssueShow(l Layout, p IssuePage) {
178 }182 }
179 183
180 templ issueRail(p IssuePage) {184 templ issueRail(p IssuePage) {
185+ if len(p.References) > 0 {
186+ <section aria-labelledby="r-refs">
187+ <h2 id="r-refs">Merge requests</h2>
188+ <ul>
189+ for _, r := range p.References {
190+ <li>
191+ <a class="ref" href={ templ.SafeURL(r.Href) }>{ r.Number }</a>
192+ { " " + r.Title + " " }
193+ <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194+ if r.Closes {
195+ { " · closes" }
196+ }
197+ </li>
198+ }
199+ </ul>
200+ </section>
201+ }
181 if p.Transition != nil {202 if p.Transition != nil {
182 <section aria-labelledby="r-act">203 <section aria-labelledby="r-act">
183 <h2 id="r-act">Issue</h2>204 <h2 id="r-act">Issue</h2>
M internal/adapters/web/issues_templ.go +164 -36
@@ -712,17 +712,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
712712 if templ_7745c5c3_Err != nil {
713713 return templ_7745c5c3_Err
714714 }
715- if p.UpdatedAt != "" {
716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>updated</dt><dd><time datetime=\"")
715+ if p.ClosedBy != nil {
716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>closed by</dt><dd><a class=\"ref\" href=\"")
717717 if templ_7745c5c3_Err != nil {
718718 return templ_7745c5c3_Err
719719 }
720- var templ_7745c5c3_Var36 string
721- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)
720+ var templ_7745c5c3_Var36 templ.SafeURL
721+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.ClosedBy.Href))
722722 if templ_7745c5c3_Err != nil {
723- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 38}
723+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 62}
724724 }
725- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
725+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
726726 if templ_7745c5c3_Err != nil {
727727 return templ_7745c5c3_Err
728728 }
@@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
731731 return templ_7745c5c3_Err
732732 }
733733 var templ_7745c5c3_Var37 string
734- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)
734+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.ClosedBy.Label)
735735 if templ_7745c5c3_Err != nil {
736- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 52}
736+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 83}
737737 }
738738 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
739739 if templ_7745c5c3_Err != nil {
740740 return templ_7745c5c3_Err
741741 }
742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time></dd>")
742+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a></dd>")
743+ if templ_7745c5c3_Err != nil {
744+ return templ_7745c5c3_Err
745+ }
746+ }
747+ if p.UpdatedAt != "" {
748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<dt>updated</dt><dd><time datetime=\"")
749+ if templ_7745c5c3_Err != nil {
750+ return templ_7745c5c3_Err
751+ }
752+ var templ_7745c5c3_Var38 string
753+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)
754+ if templ_7745c5c3_Err != nil {
755+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 38}
756+ }
757+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
758+ if templ_7745c5c3_Err != nil {
759+ return templ_7745c5c3_Err
760+ }
761+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
762+ if templ_7745c5c3_Err != nil {
763+ return templ_7745c5c3_Err
764+ }
765+ var templ_7745c5c3_Var39 string
766+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)
767+ if templ_7745c5c3_Err != nil {
768+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 52}
769+ }
770+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
771+ if templ_7745c5c3_Err != nil {
772+ return templ_7745c5c3_Err
773+ }
774+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</time></dd>")
743775 if templ_7745c5c3_Err != nil {
744776 return templ_7745c5c3_Err
745777 }
746778 }
747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</dl></header>")
779+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dl></header>")
748780 if templ_7745c5c3_Err != nil {
749781 return templ_7745c5c3_Err
750782 }
751783 if p.Desc != "" {
752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")
784+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")
753785 if templ_7745c5c3_Err != nil {
754786 return templ_7745c5c3_Err
755787 }
@@ -757,12 +789,12 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
757789 if templ_7745c5c3_Err != nil {
758790 return templ_7745c5c3_Err
759791 }
760- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</article>")
792+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")
761793 if templ_7745c5c3_Err != nil {
762794 return templ_7745c5c3_Err
763795 }
764796 }
765- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>")
797+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</section>")
766798 if templ_7745c5c3_Err != nil {
767799 return templ_7745c5c3_Err
768800 }
@@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
772804 return templ_7745c5c3_Err
773805 }
774806 }
775- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
776808 if templ_7745c5c3_Err != nil {
777809 return templ_7745c5c3_Err
778810 }
779- var templ_7745c5c3_Var38 string
780- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments)))
811+ var templ_7745c5c3_Var40 string
812+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments)))
781813 if templ_7745c5c3_Err != nil {
782- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 167, Col: 99}
814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 171, Col: 99}
783815 }
784- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
785817 if templ_7745c5c3_Err != nil {
786818 return templ_7745c5c3_Err
787819 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></h2>")
820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span></h2>")
789821 if templ_7745c5c3_Err != nil {
790822 return templ_7745c5c3_Err
791823 }
@@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
795827 return templ_7745c5c3_Err
796828 }
797829 }
798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ")
799831 if templ_7745c5c3_Err != nil {
800832 return templ_7745c5c3_Err
801833 }
802834 if len(p.Comments) == 0 {
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"dim\">no comments yet.</p>")
835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"dim\">no comments yet.</p>")
804836 if templ_7745c5c3_Err != nil {
805837 return templ_7745c5c3_Err
806838 }
807839 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ")
840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
809841 if templ_7745c5c3_Err != nil {
810842 return templ_7745c5c3_Err
811843 }
@@ -841,39 +873,135 @@ func issueRail(p IssuePage) templ.Component {
841873 }()
842874 }
843875 ctx = templ.InitializeContext(ctx)
844- templ_7745c5c3_Var39 := templ.GetChildren(ctx)
845- if templ_7745c5c3_Var39 == nil {
846- templ_7745c5c3_Var39 = templ.NopComponent
876+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
877+ if templ_7745c5c3_Var41 == nil {
878+ templ_7745c5c3_Var41 = templ.NopComponent
847879 }
848880 ctx = templ.ClearChildren(ctx)
881+ if len(p.References) > 0 {
882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Merge requests</h2><ul>")
883+ if templ_7745c5c3_Err != nil {
884+ return templ_7745c5c3_Err
885+ }
886+ for _, r := range p.References {
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a class=\"ref\" href=\"")
888+ if templ_7745c5c3_Err != nil {
889+ return templ_7745c5c3_Err
890+ }
891+ var templ_7745c5c3_Var42 templ.SafeURL
892+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
893+ if templ_7745c5c3_Err != nil {
894+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 49}
895+ }
896+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
897+ if templ_7745c5c3_Err != nil {
898+ return templ_7745c5c3_Err
899+ }
900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
901+ if templ_7745c5c3_Err != nil {
902+ return templ_7745c5c3_Err
903+ }
904+ var templ_7745c5c3_Var43 string
905+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number)
906+ if templ_7745c5c3_Err != nil {
907+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 62}
908+ }
909+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
910+ if templ_7745c5c3_Err != nil {
911+ return templ_7745c5c3_Err
912+ }
913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</a> ")
914+ if templ_7745c5c3_Err != nil {
915+ return templ_7745c5c3_Err
916+ }
917+ var templ_7745c5c3_Var44 string
918+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" " + r.Title + " ")
919+ if templ_7745c5c3_Err != nil {
920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 192, Col: 27}
921+ }
922+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
923+ if templ_7745c5c3_Err != nil {
924+ return templ_7745c5c3_Err
925+ }
926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " <span class=\"mr-state\" data-state=\"")
927+ if templ_7745c5c3_Err != nil {
928+ return templ_7745c5c3_Err
929+ }
930+ var templ_7745c5c3_Var45 string
931+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.State)
932+ if templ_7745c5c3_Err != nil {
933+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 49}
934+ }
935+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
936+ if templ_7745c5c3_Err != nil {
937+ return templ_7745c5c3_Err
938+ }
939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
940+ if templ_7745c5c3_Err != nil {
941+ return templ_7745c5c3_Err
942+ }
943+ var templ_7745c5c3_Var46 string
944+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
945+ if templ_7745c5c3_Err != nil {
946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 61}
947+ }
948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
949+ if templ_7745c5c3_Err != nil {
950+ return templ_7745c5c3_Err
951+ }
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ")
953+ if templ_7745c5c3_Err != nil {
954+ return templ_7745c5c3_Err
955+ }
956+ if r.Closes {
957+ var templ_7745c5c3_Var47 string
958+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · closes")
959+ if templ_7745c5c3_Err != nil {
960+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 195, Col: 21}
961+ }
962+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
963+ if templ_7745c5c3_Err != nil {
964+ return templ_7745c5c3_Err
965+ }
966+ }
967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</li>")
968+ if templ_7745c5c3_Err != nil {
969+ return templ_7745c5c3_Err
970+ }
971+ }
972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></section>")
973+ if templ_7745c5c3_Err != nil {
974+ return templ_7745c5c3_Err
975+ }
976+ }
849977 if p.Transition != nil {
850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"")
978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"")
851979 if templ_7745c5c3_Err != nil {
852980 return templ_7745c5c3_Err
853981 }
854- var templ_7745c5c3_Var40 templ.SafeURL
855- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action))
982+ var templ_7745c5c3_Var48 templ.SafeURL
983+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action))
856984 if templ_7745c5c3_Err != nil {
857- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 184, Col: 66}
985+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 205, Col: 66}
858986 }
859- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
987+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
860988 if templ_7745c5c3_Err != nil {
861989 return templ_7745c5c3_Err
862990 }
863- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"><button type=\"submit\" class=\"btn\">")
991+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"><button type=\"submit\" class=\"btn\">")
864992 if templ_7745c5c3_Err != nil {
865993 return templ_7745c5c3_Err
866994 }
867- var templ_7745c5c3_Var41 string
868- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label)
995+ var templ_7745c5c3_Var49 string
996+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label)
869997 if templ_7745c5c3_Err != nil {
870- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 185, Col: 58}
998+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 206, Col: 58}
871999 }
872- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
1000+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
8731001 if templ_7745c5c3_Err != nil {
8741002 return templ_7745c5c3_Err
8751003 }
876- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</button></form></section>")
1004+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form></section>")
8771005 if templ_7745c5c3_Err != nil {
8781006 return templ_7745c5c3_Err
8791007 }
@@ -712,17 +712,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
712 if templ_7745c5c3_Err != nil {712 if templ_7745c5c3_Err != nil {
713 return templ_7745c5c3_Err713 return templ_7745c5c3_Err
714 }714 }
715- if p.UpdatedAt != "" {715+ if p.ClosedBy != nil {
716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>updated</dt><dd><time datetime=\"")716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<dt>closed by</dt><dd><a class=\"ref\" href=\"")
717 if templ_7745c5c3_Err != nil {717 if templ_7745c5c3_Err != nil {
718 return templ_7745c5c3_Err718 return templ_7745c5c3_Err
719 }719 }
720- var templ_7745c5c3_Var36 string720+ var templ_7745c5c3_Var36 templ.SafeURL
721- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)721+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.ClosedBy.Href))
722 if templ_7745c5c3_Err != nil {722 if templ_7745c5c3_Err != nil {
723- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 38}723+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 62}
724 }724 }
725- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)725+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
726 if templ_7745c5c3_Err != nil {726 if templ_7745c5c3_Err != nil {
727 return templ_7745c5c3_Err727 return templ_7745c5c3_Err
728 }728 }
@@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
731 return templ_7745c5c3_Err731 return templ_7745c5c3_Err
732 }732 }
733 var templ_7745c5c3_Var37 string733 var templ_7745c5c3_Var37 string
734- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)734+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(p.ClosedBy.Label)
735 if templ_7745c5c3_Err != nil {735 if templ_7745c5c3_Err != nil {
736- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 52}736+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 154, Col: 83}
737 }737 }
738 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))738 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
739 if templ_7745c5c3_Err != nil {739 if templ_7745c5c3_Err != nil {
740 return templ_7745c5c3_Err740 return templ_7745c5c3_Err
741 }741 }
742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time></dd>")742+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a></dd>")
743+ if templ_7745c5c3_Err != nil {
744+ return templ_7745c5c3_Err
745+ }
746+ }
747+ if p.UpdatedAt != "" {
748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<dt>updated</dt><dd><time datetime=\"")
749+ if templ_7745c5c3_Err != nil {
750+ return templ_7745c5c3_Err
751+ }
752+ var templ_7745c5c3_Var38 string
753+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)
754+ if templ_7745c5c3_Err != nil {
755+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 38}
756+ }
757+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
758+ if templ_7745c5c3_Err != nil {
759+ return templ_7745c5c3_Err
760+ }
761+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
762+ if templ_7745c5c3_Err != nil {
763+ return templ_7745c5c3_Err
764+ }
765+ var templ_7745c5c3_Var39 string
766+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)
767+ if templ_7745c5c3_Err != nil {
768+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 158, Col: 52}
769+ }
770+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
771+ if templ_7745c5c3_Err != nil {
772+ return templ_7745c5c3_Err
773+ }
774+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</time></dd>")
743 if templ_7745c5c3_Err != nil {775 if templ_7745c5c3_Err != nil {
744 return templ_7745c5c3_Err776 return templ_7745c5c3_Err
745 }777 }
746 }778 }
747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</dl></header>")779+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dl></header>")
748 if templ_7745c5c3_Err != nil {780 if templ_7745c5c3_Err != nil {
749 return templ_7745c5c3_Err781 return templ_7745c5c3_Err
750 }782 }
751 if p.Desc != "" {783 if p.Desc != "" {
752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")784+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")
753 if templ_7745c5c3_Err != nil {785 if templ_7745c5c3_Err != nil {
754 return templ_7745c5c3_Err786 return templ_7745c5c3_Err
755 }787 }
@@ -757,12 +789,12 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
757 if templ_7745c5c3_Err != nil {789 if templ_7745c5c3_Err != nil {
758 return templ_7745c5c3_Err790 return templ_7745c5c3_Err
759 }791 }
760- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</article>")792+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")
761 if templ_7745c5c3_Err != nil {793 if templ_7745c5c3_Err != nil {
762 return templ_7745c5c3_Err794 return templ_7745c5c3_Err
763 }795 }
764 }796 }
765- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>")797+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</section>")
766 if templ_7745c5c3_Err != nil {798 if templ_7745c5c3_Err != nil {
767 return templ_7745c5c3_Err799 return templ_7745c5c3_Err
768 }800 }
@@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
772 return templ_7745c5c3_Err804 return templ_7745c5c3_Err
773 }805 }
774 }806 }
775- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " <h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
776 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
777 return templ_7745c5c3_Err809 return templ_7745c5c3_Err
778 }810 }
779- var templ_7745c5c3_Var38 string811+ var templ_7745c5c3_Var40 string
780- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments)))812+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Comments)))
781 if templ_7745c5c3_Err != nil {813 if templ_7745c5c3_Err != nil {
782- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 167, Col: 99}814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 171, Col: 99}
783 }815 }
784- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
785 if templ_7745c5c3_Err != nil {817 if templ_7745c5c3_Err != nil {
786 return templ_7745c5c3_Err818 return templ_7745c5c3_Err
787 }819 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></h2>")820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span></h2>")
789 if templ_7745c5c3_Err != nil {821 if templ_7745c5c3_Err != nil {
790 return templ_7745c5c3_Err822 return templ_7745c5c3_Err
791 }823 }
@@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component {
795 return templ_7745c5c3_Err827 return templ_7745c5c3_Err
796 }828 }
797 }829 }
798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ")
799 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
800 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
801 }833 }
802 if len(p.Comments) == 0 {834 if len(p.Comments) == 0 {
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"dim\">no comments yet.</p>")835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"dim\">no comments yet.</p>")
804 if templ_7745c5c3_Err != nil {836 if templ_7745c5c3_Err != nil {
805 return templ_7745c5c3_Err837 return templ_7745c5c3_Err
806 }838 }
807 }839 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ")840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
809 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
810 return templ_7745c5c3_Err842 return templ_7745c5c3_Err
811 }843 }
@@ -841,39 +873,135 @@ func issueRail(p IssuePage) templ.Component {
841 }()873 }()
842 }874 }
843 ctx = templ.InitializeContext(ctx)875 ctx = templ.InitializeContext(ctx)
844- templ_7745c5c3_Var39 := templ.GetChildren(ctx)876+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
845- if templ_7745c5c3_Var39 == nil {877+ if templ_7745c5c3_Var41 == nil {
846- templ_7745c5c3_Var39 = templ.NopComponent878+ templ_7745c5c3_Var41 = templ.NopComponent
847 }879 }
848 ctx = templ.ClearChildren(ctx)880 ctx = templ.ClearChildren(ctx)
881+ if len(p.References) > 0 {
882+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Merge requests</h2><ul>")
883+ if templ_7745c5c3_Err != nil {
884+ return templ_7745c5c3_Err
885+ }
886+ for _, r := range p.References {
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a class=\"ref\" href=\"")
888+ if templ_7745c5c3_Err != nil {
889+ return templ_7745c5c3_Err
890+ }
891+ var templ_7745c5c3_Var42 templ.SafeURL
892+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
893+ if templ_7745c5c3_Err != nil {
894+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 49}
895+ }
896+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
897+ if templ_7745c5c3_Err != nil {
898+ return templ_7745c5c3_Err
899+ }
900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
901+ if templ_7745c5c3_Err != nil {
902+ return templ_7745c5c3_Err
903+ }
904+ var templ_7745c5c3_Var43 string
905+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number)
906+ if templ_7745c5c3_Err != nil {
907+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 191, Col: 62}
908+ }
909+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
910+ if templ_7745c5c3_Err != nil {
911+ return templ_7745c5c3_Err
912+ }
913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</a> ")
914+ if templ_7745c5c3_Err != nil {
915+ return templ_7745c5c3_Err
916+ }
917+ var templ_7745c5c3_Var44 string
918+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" " + r.Title + " ")
919+ if templ_7745c5c3_Err != nil {
920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 192, Col: 27}
921+ }
922+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
923+ if templ_7745c5c3_Err != nil {
924+ return templ_7745c5c3_Err
925+ }
926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " <span class=\"mr-state\" data-state=\"")
927+ if templ_7745c5c3_Err != nil {
928+ return templ_7745c5c3_Err
929+ }
930+ var templ_7745c5c3_Var45 string
931+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.State)
932+ if templ_7745c5c3_Err != nil {
933+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 49}
934+ }
935+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
936+ if templ_7745c5c3_Err != nil {
937+ return templ_7745c5c3_Err
938+ }
939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
940+ if templ_7745c5c3_Err != nil {
941+ return templ_7745c5c3_Err
942+ }
943+ var templ_7745c5c3_Var46 string
944+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
945+ if templ_7745c5c3_Err != nil {
946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 193, Col: 61}
947+ }
948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
949+ if templ_7745c5c3_Err != nil {
950+ return templ_7745c5c3_Err
951+ }
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ")
953+ if templ_7745c5c3_Err != nil {
954+ return templ_7745c5c3_Err
955+ }
956+ if r.Closes {
957+ var templ_7745c5c3_Var47 string
958+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · closes")
959+ if templ_7745c5c3_Err != nil {
960+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 195, Col: 21}
961+ }
962+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
963+ if templ_7745c5c3_Err != nil {
964+ return templ_7745c5c3_Err
965+ }
966+ }
967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</li>")
968+ if templ_7745c5c3_Err != nil {
969+ return templ_7745c5c3_Err
970+ }
971+ }
972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></section>")
973+ if templ_7745c5c3_Err != nil {
974+ return templ_7745c5c3_Err
975+ }
976+ }
849 if p.Transition != nil {977 if p.Transition != nil {
850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"")978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\"><h2 id=\"r-act\">Issue</h2><form method=\"post\" action=\"")
851 if templ_7745c5c3_Err != nil {979 if templ_7745c5c3_Err != nil {
852 return templ_7745c5c3_Err980 return templ_7745c5c3_Err
853 }981 }
854- var templ_7745c5c3_Var40 templ.SafeURL982+ var templ_7745c5c3_Var48 templ.SafeURL
855- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action))983+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Transition.Action))
856 if templ_7745c5c3_Err != nil {984 if templ_7745c5c3_Err != nil {
857- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 184, Col: 66}985+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 205, Col: 66}
858 }986 }
859- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))987+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
860 if templ_7745c5c3_Err != nil {988 if templ_7745c5c3_Err != nil {
861 return templ_7745c5c3_Err989 return templ_7745c5c3_Err
862 }990 }
863- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"><button type=\"submit\" class=\"btn\">")991+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"><button type=\"submit\" class=\"btn\">")
864 if templ_7745c5c3_Err != nil {992 if templ_7745c5c3_Err != nil {
865 return templ_7745c5c3_Err993 return templ_7745c5c3_Err
866 }994 }
867- var templ_7745c5c3_Var41 string995+ var templ_7745c5c3_Var49 string
868- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label)996+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Transition.Label)
869 if templ_7745c5c3_Err != nil {997 if templ_7745c5c3_Err != nil {
870- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 185, Col: 58}998+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues.templ`, Line: 206, Col: 58}
871 }999 }
872- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))1000+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
873 if templ_7745c5c3_Err != nil {1001 if templ_7745c5c3_Err != nil {
874 return templ_7745c5c3_Err1002 return templ_7745c5c3_Err
875 }1003 }
876- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</button></form></section>")1004+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form></section>")
877 if templ_7745c5c3_Err != nil {1005 if templ_7745c5c3_Err != nil {
878 return templ_7745c5c3_Err1006 return templ_7745c5c3_Err
879 }1007 }
M internal/adapters/web/issues_test.go +45 -0
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330332 require.NoError(t, err)
331333 assert.Empty(t, got)
332334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
@@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) {
309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},310 {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},311 {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313+ {domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
312 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},314 {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313 {domain.IssueClosed{Issue: issue}, "summary", "", ""},315 {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314 }316 }
@@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) {
330 require.NoError(t, err)332 require.NoError(t, err)
331 assert.Empty(t, got)333 assert.Empty(t, got)
332 }334 }
335+
336+func TestIssueShowReferences(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesPublic, true)
339+ title, err := domain.ParseMRTitle("fix <b>it</b>")
340+ require.NoError(t, err)
341+ issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342+ issue.ClosedByMergeRequest = 2
343+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344+ Issue: issue,
345+ References: []ports.IssueReference{
346+ {Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347+ {Number: 3, Title: title, State: domain.MRStateOpen},
348+ },
349+ }, nil)
350+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352+
353+ rec := r.do("GET", "/proj/issues/5", false, nil)
354+ require.Equal(t, 200, rec.Code, rec.Body.String())
355+ body := rec.Body.String()
356+ assert.Contains(t, body, "closed by")
357+ assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358+ assert.Contains(t, body, ">!2<")
359+ assert.Contains(t, body, ">!3<")
360+ assert.Contains(t, body, `id="r-refs"`)
361+ assert.Contains(t, body, "closes")
362+ assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363+ assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364+ assert.Contains(t, body, `title="merge request 7"`)
365+ assert.NotContains(t, body, `/issues/8"`)
366+}
367+
368+func TestIssueShowWithoutReferences(t *testing.T) {
369+ t.Parallel()
370+ r := newIssueRig(t, domain.IssuesPublic, false)
371+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373+ }, nil)
374+ body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375+ assert.NotContains(t, body, "r-refs")
376+ assert.NotContains(t, body, "closed by")
377+}
M internal/adapters/web/markdown.go +72 -23
@@ -64,15 +64,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6464 // #fragment links chosen, so that several documents on one page (the summaries
6565 // of a run) do not share ids.
6666 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
6868 }
6969
70-// renderNote uses the repository Markdown policy, then adds local note links.
71-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
70+// renderNote uses the repository Markdown policy, then adds local note links
71+// and links to issues and merge requests.
72+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
73+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
7374 return h, err
7475 }
7576
77+// renderIssue renders the trusted body of an issue: the policy of render, plus
78+// links to issues and merge requests.
79+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, bool)) (template.HTML, error) {
80+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
81+ return h, err
82+}
83+
84+// refResolver tells which references of a text are links.
85+type refResolver struct {
86+ // notes tells whether ^N names a note of the page (nil: no ^N links).
87+ notes func(int64) bool
88+ // numbers gives the page of the issue or merge request #N, when it exists
89+ // (nil: no #N links). The href is built by the server, never from the text.
90+ numbers func(int64) (href string, ok bool)
91+}
92+
7693 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
7794 // approved from the public form). On top of the policy of render, which already
7895 // drops raw HTML and never loads an image:
@@ -80,19 +97,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
8097 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
8198 // every other scheme lose their link and keep the text;
8299 // - nothing points into the repository.
83-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
100+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, bool)) (template.HTML, error) {
101+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
85102 return h, err
86103 }
87104
88-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
105+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
89106 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
90107 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
91108 pol.source = source
92109 // Collect eligible text before the policy unwraps rejected links: their
93110 // labels must not accidentally become references either.
94111 var refs []*ast.Text
95- if notes != nil {
112+ if res.notes != nil || res.numbers != nil {
96113 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97114 if !entering {
98115 return ast.WalkContinue, nil
@@ -108,7 +125,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
108125 }
109126 pol.apply(doc)
110127 for _, t := range refs {
111- noteReferences(t, source, notes)
128+ references(t, source, res)
112129 }
113130
114131 var buf bytes.Buffer
@@ -390,42 +407,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
390407 })
391408 }
392409
393-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
410+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
411+// character is not part of a word; #N also rejects & and # so that numeric
412+// entities such as &#39; stay text.
413+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
394414
395-// noteReferences changes only text segments; code and link labels never enter
396-// this function. New fragment links deliberately bypass heading-prefix rewriting.
397-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
415+// references changes only text segments; code and link labels never enter
416+// this function. New links deliberately bypass heading-prefix rewriting.
417+func references(t *ast.Text, source []byte, res refResolver) {
398418 parent := t.Parent()
399419 if parent == nil {
400420 return
401421 }
402422 value := t.Value(source)
403423 offset := 0
404- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
405- id := string(value[match[4]:match[5]])
424+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
425+ isNote := match[4] >= 0
426+ var from, to int
427+ if isNote {
428+ from, to = match[4], match[5]
429+ } else {
430+ from, to = match[8], match[9]
431+ }
432+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
433+ continue
434+ }
435+ id := string(value[from:to])
406436 n, err := strconv.ParseInt(id, 10, 64)
407- if err != nil || !exists(n) {
437+ if err != nil {
408438 continue
409439 }
410- start := match[4] - 1
440+ start := from - 1
411441 // Markdown punctuation may split adjacent text into separate AST nodes.
412442 // Check the original preceding byte as well as the segment-local regex.
413443 if at := t.Segment.Start + start; at > 0 {
414444 prev := source[at-1]
415- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
445+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
446+ continue
447+ }
448+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
416449 continue
417450 }
418451 }
452+ link := ast.NewLink()
453+ if isNote {
454+ if !res.notes(n) {
455+ continue
456+ }
457+ link.Destination = []byte("#c" + id)
458+ link.Title = []byte("note " + id)
459+ } else {
460+ href, ok := res.numbers(n)
461+ if !ok {
462+ continue
463+ }
464+ link.Destination = []byte(href)
465+ title := "issue "
466+ if strings.Contains(href, "/merge-requests/") {
467+ title = "merge request "
468+ }
469+ link.Title = []byte(title + id)
470+ }
419471 if start > offset {
420472 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421473 }
422- link := ast.NewLink()
423- link.Destination = []byte("#c" + id)
424- link.Title = []byte("note " + id)
425474 link.SetAttributeString("class", []byte("ref"))
426- link.AppendChild(link, ast.NewString(value[start:match[5]]))
475+ link.AppendChild(link, ast.NewString(value[start:to]))
427476 parent.InsertBefore(parent, t, link)
428- offset = match[5]
477+ offset = to
429478 }
430479 if offset == 0 {
431480 return
@@ -64,15 +64,32 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
64 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
65 // of a run) do not share ids.65 // of a run) do not share ids.
66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
68 }68 }
69 69
70-// renderNote uses the repository Markdown policy, then adds local note links.70+// renderNote uses the repository Markdown policy, then adds local note links
71-func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {71+// and links to issues and merge requests.
72- h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)72+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
73+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
73 return h, err74 return h, err
74 }75 }
75 76
77+// renderIssue renders the trusted body of an issue: the policy of render, plus
78+// links to issues and merge requests.
79+func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, bool)) (template.HTML, error) {
80+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
81+ return h, err
82+}
83+
84+// refResolver tells which references of a text are links.
85+type refResolver struct {
86+ // notes tells whether ^N names a note of the page (nil: no ^N links).
87+ notes func(int64) bool
88+ // numbers gives the page of the issue or merge request #N, when it exists
89+ // (nil: no #N links). The href is built by the server, never from the text.
90+ numbers func(int64) (href string, ok bool)
91+}
92+
76 // renderUntrusted renders Markdown written by an anonymous visitor (an issue93 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
77 // approved from the public form). On top of the policy of render, which already94 // approved from the public form). On top of the policy of render, which already
78 // drops raw HTML and never loads an image:95 // drops raw HTML and never loads an image:
@@ -80,19 +97,19 @@ func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes f
80 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and97 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81 // every other scheme lose their link and keep the text;98 // every other scheme lose their link and keep the text;
82 // - nothing points into the repository.99 // - nothing points into the repository.
83-func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {100+func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, bool)) (template.HTML, error) {
84- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)101+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
85 return h, err102 return h, err
86 }103 }
87 104
88-func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {105+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
89 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))106 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))107 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
91 pol.source = source108 pol.source = source
92 // Collect eligible text before the policy unwraps rejected links: their109 // Collect eligible text before the policy unwraps rejected links: their
93 // labels must not accidentally become references either.110 // labels must not accidentally become references either.
94 var refs []*ast.Text111 var refs []*ast.Text
95- if notes != nil {112+ if res.notes != nil || res.numbers != nil {
96 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {113 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97 if !entering {114 if !entering {
98 return ast.WalkContinue, nil115 return ast.WalkContinue, nil
@@ -108,7 +125,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64)
108 }125 }
109 pol.apply(doc)126 pol.apply(doc)
110 for _, t := range refs {127 for _, t := range refs {
111- noteReferences(t, source, notes)128+ references(t, source, res)
112 }129 }
113 130
114 var buf bytes.Buffer131 var buf bytes.Buffer
@@ -390,42 +407,74 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
390 })407 })
391 }408 }
392 409
393-var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)410+// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
411+// character is not part of a word; #N also rejects & and # so that numeric
412+// entities such as &#39; stay text.
413+var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
394 414
395-// noteReferences changes only text segments; code and link labels never enter415+// references changes only text segments; code and link labels never enter
396-// this function. New fragment links deliberately bypass heading-prefix rewriting.416+// this function. New links deliberately bypass heading-prefix rewriting.
397-func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {417+func references(t *ast.Text, source []byte, res refResolver) {
398 parent := t.Parent()418 parent := t.Parent()
399 if parent == nil {419 if parent == nil {
400 return420 return
401 }421 }
402 value := t.Value(source)422 value := t.Value(source)
403 offset := 0423 offset := 0
404- for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {424+ for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
405- id := string(value[match[4]:match[5]])425+ isNote := match[4] >= 0
426+ var from, to int
427+ if isNote {
428+ from, to = match[4], match[5]
429+ } else {
430+ from, to = match[8], match[9]
431+ }
432+ if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
433+ continue
434+ }
435+ id := string(value[from:to])
406 n, err := strconv.ParseInt(id, 10, 64)436 n, err := strconv.ParseInt(id, 10, 64)
407- if err != nil || !exists(n) {437+ if err != nil {
408 continue438 continue
409 }439 }
410- start := match[4] - 1440+ start := from - 1
411 // Markdown punctuation may split adjacent text into separate AST nodes.441 // Markdown punctuation may split adjacent text into separate AST nodes.
412 // Check the original preceding byte as well as the segment-local regex.442 // Check the original preceding byte as well as the segment-local regex.
413 if at := t.Segment.Start + start; at > 0 {443 if at := t.Segment.Start + start; at > 0 {
414 prev := source[at-1]444 prev := source[at-1]
415- if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {445+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
446+ continue
447+ }
448+ if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
416 continue449 continue
417 }450 }
418 }451 }
452+ link := ast.NewLink()
453+ if isNote {
454+ if !res.notes(n) {
455+ continue
456+ }
457+ link.Destination = []byte("#c" + id)
458+ link.Title = []byte("note " + id)
459+ } else {
460+ href, ok := res.numbers(n)
461+ if !ok {
462+ continue
463+ }
464+ link.Destination = []byte(href)
465+ title := "issue "
466+ if strings.Contains(href, "/merge-requests/") {
467+ title = "merge request "
468+ }
469+ link.Title = []byte(title + id)
470+ }
419 if start > offset {471 if start > offset {
420 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))472 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421 }473 }
422- link := ast.NewLink()
423- link.Destination = []byte("#c" + id)
424- link.Title = []byte("note " + id)
425 link.SetAttributeString("class", []byte("ref"))474 link.SetAttributeString("class", []byte("ref"))
426- link.AppendChild(link, ast.NewString(value[start:match[5]]))475+ link.AppendChild(link, ast.NewString(value[start:to]))
427 parent.InsertBefore(parent, t, link)476 parent.InsertBefore(parent, t, link)
428- offset = match[5]477+ offset = to
429 }478 }
430 if offset == 0 {479 if offset == 0 {
431 return480 return
M internal/adapters/web/markdown_test.go +79 -3
@@ -6,7 +6,10 @@ import (
66 "strings"
77 "testing"
88
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
1013 "github.com/stretchr/testify/require"
1114 )
1215
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139142
140143 func renderUntrustedMD(t *testing.T, src string) string {
141144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143146 require.NoError(t, err)
144147 return string(h)
145148 }
@@ -218,17 +221,90 @@ func TestNoteMarkdown(t *testing.T) {
218221 for _, tt := range tests {
219222 t.Run(tt.source, func(t *testing.T) {
220223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222225 require.NoError(t, err)
223226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224227 assert.NotContains(t, string(h), `href="#c99"`)
225228 assert.NotContains(t, string(h), "javascript:")
226229 })
227230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229232 require.NoError(t, err)
230233 assert.Contains(t, string(h), `id="c1-heading"`)
231234 assert.Contains(t, string(h), "<strong>bold</strong>")
232235 assert.NotContains(t, string(h), "<script")
233236 assert.Contains(t, string(h), `title="note 2"`)
234237 }
238+
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", true
245+ case 4:
246+ return "/demo/merge-requests/4", true
247+ }
248+ return "", false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, bool) { return "/demo/issues/3", n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
@@ -6,7 +6,10 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
11 )14 )
12 15
@@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) {
139 142
140 func renderUntrustedMD(t *testing.T, src string) string {143 func renderUntrustedMD(t *testing.T, src string) string {
141 t.Helper()144 t.Helper()
142- h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")145+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
143 require.NoError(t, err)146 require.NoError(t, err)
144 return string(h)147 return string(h)
145 }148 }
@@ -218,17 +221,90 @@ func TestNoteMarkdown(t *testing.T) {
218 for _, tt := range tests {221 for _, tt := range tests {
219 t.Run(tt.source, func(t *testing.T) {222 t.Run(tt.source, func(t *testing.T) {
220 t.Parallel()223 t.Parallel()
221- h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })224+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
222 require.NoError(t, err)225 require.NoError(t, err)
223 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224 assert.NotContains(t, string(h), `href="#c99"`)227 assert.NotContains(t, string(h), `href="#c99"`)
225 assert.NotContains(t, string(h), "javascript:")228 assert.NotContains(t, string(h), "javascript:")
226 })229 })
227 }230 }
228- h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })231+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
229 require.NoError(t, err)232 require.NoError(t, err)
230 assert.Contains(t, string(h), `id="c1-heading"`)233 assert.Contains(t, string(h), `id="c1-heading"`)
231 assert.Contains(t, string(h), "<strong>bold</strong>")234 assert.Contains(t, string(h), "<strong>bold</strong>")
232 assert.NotContains(t, string(h), "<script")235 assert.NotContains(t, string(h), "<script")
233 assert.Contains(t, string(h), `title="note 2"`)236 assert.Contains(t, string(h), `title="note 2"`)
234 }237 }
238+
239+func TestNumberReferences(t *testing.T) {
240+ t.Parallel()
241+ numbers := func(n int64) (string, bool) {
242+ switch n {
243+ case 3:
244+ return "/demo/issues/3", true
245+ case 4:
246+ return "/demo/merge-requests/4", true
247+ }
248+ return "", false
249+ }
250+ tests := []struct {
251+ source string
252+ want []string
253+ not []string
254+ }{
255+ {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256+ {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257+ {"&#39; &#3;", nil, []string{"<a "}},
258+ {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259+ {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260+ {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261+ {"#03 #12345678901", nil, []string{"<a "}},
262+ }
263+ for _, tt := range tests {
264+ t.Run(tt.source, func(t *testing.T) {
265+ t.Parallel()
266+ h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267+ require.NoError(t, err)
268+ for _, w := range tt.want {
269+ assert.Contains(t, string(h), w)
270+ }
271+ for _, w := range tt.not {
272+ assert.NotContains(t, string(h), w)
273+ }
274+ u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275+ require.NoError(t, err)
276+ assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277+ })
278+ }
279+ // Without a resolver nothing links.
280+ h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281+ require.NoError(t, err)
282+ assert.NotContains(t, string(h), "<a ")
283+}
284+
285+func TestNumberAndNoteReferencesTogether(t *testing.T) {
286+ t.Parallel()
287+ h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288+ notes: func(n int64) bool { return n == 2 },
289+ numbers: func(n int64) (string, bool) { return "/demo/issues/3", n == 3 },
290+ })
291+ require.NoError(t, err)
292+ assert.Contains(t, string(h), `href="#c2"`)
293+ assert.Contains(t, string(h), `href="/demo/issues/3"`)
294+ assert.Contains(t, string(h), "and ")
295+}
296+
297+func TestNumberResolverCapsLookups(t *testing.T) {
298+ t.Parallel()
299+ nums := mocks.NewIssueNumberResolver(t)
300+ nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301+ srv := &Server{cfg: Config{IssueNumbers: nums}}
302+ resolve := srv.numberResolver(t.Context(), "demo")
303+ require.NotNil(t, resolve)
304+ for n := int64(1); n <= maxNumberLookups+10; n++ {
305+ _, ok := resolve(n)
306+ assert.Equal(t, n <= maxNumberLookups, ok, n)
307+ }
308+ _, ok := resolve(1) // memoized
309+ assert.True(t, ok)
310+}
M internal/adapters/web/mr.go +4 -4
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616616 if canWrite {
617617 on = parseOn(r.URL.Query().Get("on"))
618618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620620 lines, invisible := diffLines(p.Files)
621621 p.Invisible = invisible
622622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680680 // Relative links point into the source branch as it is now (its head
681681 // commit); the heading ids carry their own prefix, apart from the
682682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684684 if err != nil {
685685 return err
686686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905905 // as links to the form of their line; on designates the line whose form shows.
906906 //
907907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909909 now := s.cfg.Now()
910910 self := mrHref(repo, n)
911911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953953 return index[lineKey{path, side, line}]
954954 }
955955
956- exists := noteIDs(comments)
956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957957 children := map[int64][]domain.Comment{}
958958 for _, c := range comments {
959959 if c.ReplyTo != 0 && !c.Deleted {
@@ -616,7 +616,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
616 if canWrite {616 if canWrite {
617 on = parseOn(r.URL.Query().Get("on"))617 on = parseOn(r.URL.Query().Get("on"))
618 }618 }
619- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)619+ s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
620 lines, invisible := diffLines(p.Files)620 lines, invisible := diffLines(p.Files)
621 p.Invisible = invisible621 p.Invisible = invisible
622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))622 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -680,7 +680,7 @@ func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *M
680 // Relative links point into the source branch as it is now (its head680 // Relative links point into the source branch as it is now (its head
681 // commit); the heading ids carry their own prefix, apart from the681 // commit); the heading ids carry their own prefix, apart from the
682 // ids of the diff.682 // ids of the diff.
683- h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))683+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", refResolver{notes: noteIDs(res.Comments), numbers: s.numberResolver(ctx, d.name())})
684 if err != nil {684 if err != nil {
685 return err685 return err
686 }686 }
@@ -905,7 +905,7 @@ func parseOn(s string) *lineRef {
905 // as links to the form of their line; on designates the line whose form shows.905 // as links to the form of their line; on designates the line whose form shows.
906 //906 //
907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only907 //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
908-func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {908+func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
909 now := s.cfg.Now()909 now := s.cfg.Now()
910 self := mrHref(repo, n)910 self := mrHref(repo, n)
911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }911 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
@@ -953,7 +953,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
953 return index[lineKey{path, side, line}]953 return index[lineKey{path, side, line}]
954 }954 }
955 955
956- exists := noteIDs(comments)956+ exists := refResolver{notes: noteIDs(comments), numbers: s.numberResolver(ctx, repo)}
957 children := map[int64][]domain.Comment{}957 children := map[int64][]domain.Comment{}
958 for _, c := range comments {958 for _, c := range comments {
959 if c.ReplyTo != 0 && !c.Deleted {959 if c.ReplyTo != 0 && !c.Deleted {
M internal/adapters/web/mr_files.go +1 -1
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227227 } else {
228228 collapseFiles(p.Files, res.Diff.Files, links)
229229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232232 if one {
233233 window = p.Files[from : from+1]
@@ -227,7 +227,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
227 } else {227 } else {
228 collapseFiles(p.Files, res.Diff.Files, links)228 collapseFiles(p.Files, res.Diff.Files, links)
229 }229 }
230- s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)230+ s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231 window := p.Files[from:min(from+mrWindow, len(p.Files))]231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232 if one {232 if one {
233 window = p.Files[from : from+1]233 window = p.Files[from : from+1]
M internal/adapters/web/mr_notes_test.go +3 -3
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
4343 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
4444 }
4545 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
4747 require.Len(t, p.General, 3)
4848 assert.Equal(t, 4, p.NComments)
4949 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117117 require.NoError(t, err)
118118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{
120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122122 {ID: 2, Deleted: true},
123123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147147 require.NoError(t, err)
148148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152152 require.Len(t, notes, 1)
153153 var b bytes.Buffer
@@ -43,7 +43,7 @@ func TestNoteThreads(t *testing.T) {
43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44 }44 }
45 p := MRPage{NoteAuthor: "alice"}45 p := MRPage{NoteAuthor: "alice"}
46- s.placeComments(&p, "demo", 1, comments, "", true, true, nil)46+ s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
47 require.Len(t, p.General, 3)47 require.Len(t, p.General, 3)
48 assert.Equal(t, 4, p.NComments)48 assert.Equal(t, 4, p.NComments)
49 assert.Equal(t, 1, p.NUnresolved)49 assert.Equal(t, 1, p.NUnresolved)
@@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) {
117 require.NoError(t, err)117 require.NoError(t, err)
118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119 p := MRPage{}119 p := MRPage{}
120- s.placeComments(&p, "demo", 1, []domain.Comment{120+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122 {ID: 2, Deleted: true},122 {ID: 2, Deleted: true},
123 {ID: 3, ReplyTo: 2, Deleted: true},123 {ID: 3, ReplyTo: 2, Deleted: true},
@@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) {
147 require.NoError(t, err)147 require.NoError(t, err)
148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150- s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)150+ s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151 notes := p.Files[0].Hunks[0].Lines[0].Notes151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152 require.Len(t, notes, 1)152 require.Len(t, notes, 1)
153 var b bytes.Buffer153 var b bytes.Buffer

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close