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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 63 | 63 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 64 | 64 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 65 | 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 | 67 | return true | |
| 68 | 68 | } | |
| 69 | 69 | return false | |
| @@ -210,6 +210,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 210 | 210 | return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload") | |
| 211 | 211 | case domain.IssueCommented: | |
| 212 | 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 | 215 | case domain.PushEvent: | |
| 214 | 216 | switch l.view { | |
| 215 | 217 | 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 true | 67 | return true | ||
| 68 | } | 68 | } | ||
| 69 | return false | 69 | 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 { | |||
| 83 | 83 | Form *CommentForm | |
| 84 | 84 | Transition *IssueActions | |
| 85 | 85 | 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 | 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 | 294 | var err error | |
| 244 | 295 | if i.External { | |
| 245 | 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 | 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 | 301 | if err != nil { | |
| 251 | 302 | return nil, 0, err | |
| 252 | 303 | } | |
| 253 | 304 | 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 | 315 | p.Opened.Name = s.who(i.Author) | |
| 256 | 316 | if i.External { | |
| 257 | 317 | // The label is the visitor's, not the owner's: shown as plain text. | |
| @@ -83,6 +83,57 @@ type IssuePage struct { | |||||
| 83 | Form *CommentForm | 83 | Form *CommentForm | ||
| 84 | Transition *IssueActions | 84 | Transition *IssueActions | ||
| 85 | Live bool | 85 | 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 error | 294 | 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, err | 302 | return nil, 0, err | ||
| 252 | } | 303 | } | ||
| 253 | p.Desc = h | 304 | 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) { | |||
| 149 | 149 | { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time> | |
| 150 | 150 | } | |
| 151 | 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 | 156 | if p.UpdatedAt != "" { | |
| 153 | 157 | <dt>updated</dt> | |
| 154 | 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 | 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 | 202 | if p.Transition != nil { | |
| 182 | 203 | <section aria-labelledby="r-act"> | |
| 183 | 204 | <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 { | |||
| 712 | 712 | if templ_7745c5c3_Err != nil { | |
| 713 | 713 | return templ_7745c5c3_Err | |
| 714 | 714 | } | |
| 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=\"") | |
| 717 | 717 | if templ_7745c5c3_Err != nil { | |
| 718 | 718 | return templ_7745c5c3_Err | |
| 719 | 719 | } | |
| 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)) | |
| 722 | 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 | 726 | if templ_7745c5c3_Err != nil { | |
| 727 | 727 | return templ_7745c5c3_Err | |
| 728 | 728 | } | |
| @@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 731 | 731 | return templ_7745c5c3_Err | |
| 732 | 732 | } | |
| 733 | 733 | 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 | 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 | 738 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 739 | 739 | if templ_7745c5c3_Err != nil { | |
| 740 | 740 | 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 | 775 | if templ_7745c5c3_Err != nil { | |
| 744 | 776 | 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 | 780 | if templ_7745c5c3_Err != nil { | |
| 749 | 781 | return templ_7745c5c3_Err | |
| 750 | 782 | } | |
| 751 | 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 | 785 | if templ_7745c5c3_Err != nil { | |
| 754 | 786 | return templ_7745c5c3_Err | |
| 755 | 787 | } | |
| @@ -757,12 +789,12 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 757 | 789 | if templ_7745c5c3_Err != nil { | |
| 758 | 790 | 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 | 793 | if templ_7745c5c3_Err != nil { | |
| 762 | 794 | 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 | 798 | if templ_7745c5c3_Err != nil { | |
| 767 | 799 | return templ_7745c5c3_Err | |
| 768 | 800 | } | |
| @@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 772 | 804 | 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 | 808 | if templ_7745c5c3_Err != nil { | |
| 777 | 809 | return templ_7745c5c3_Err | |
| 778 | 810 | } | |
| 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))) | |
| 781 | 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 | 817 | if templ_7745c5c3_Err != nil { | |
| 786 | 818 | 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 | 821 | if templ_7745c5c3_Err != nil { | |
| 790 | 822 | return templ_7745c5c3_Err | |
| 791 | 823 | } | |
| @@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||
| 795 | 827 | 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 | 831 | if templ_7745c5c3_Err != nil { | |
| 800 | 832 | return templ_7745c5c3_Err | |
| 801 | 833 | } | |
| 802 | 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 | 836 | if templ_7745c5c3_Err != nil { | |
| 805 | 837 | 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 | 841 | if templ_7745c5c3_Err != nil { | |
| 810 | 842 | return templ_7745c5c3_Err | |
| 811 | 843 | } | |
| @@ -841,39 +873,135 @@ func issueRail(p IssuePage) templ.Component { | |||
| 841 | 873 | }() | |
| 842 | 874 | } | |
| 843 | 875 | 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 | |
| 847 | 879 | } | |
| 848 | 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 | 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 | 979 | if templ_7745c5c3_Err != nil { | |
| 852 | 980 | return templ_7745c5c3_Err | |
| 853 | 981 | } | |
| 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)) | |
| 856 | 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 | 988 | if templ_7745c5c3_Err != nil { | |
| 861 | 989 | 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 | 992 | if templ_7745c5c3_Err != nil { | |
| 865 | 993 | return templ_7745c5c3_Err | |
| 866 | 994 | } | |
| 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) | |
| 869 | 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 | 1001 | if templ_7745c5c3_Err != nil { | |
| 874 | 1002 | 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 | 1005 | if templ_7745c5c3_Err != nil { | |
| 878 | 1006 | return templ_7745c5c3_Err | |
| 879 | 1007 | } | |
| @@ -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_Err | 713 | 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_Err | 718 | return templ_7745c5c3_Err | ||
| 719 | } | 719 | } | ||
| 720 | - | var templ_7745c5c3_Var36 string | 720 | + | 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_Err | 727 | return templ_7745c5c3_Err | ||
| 728 | } | 728 | } | ||
| @@ -731,25 +731,57 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 731 | return templ_7745c5c3_Err | 731 | return templ_7745c5c3_Err | ||
| 732 | } | 732 | } | ||
| 733 | var templ_7745c5c3_Var37 string | 733 | 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_Err | 740 | 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_Err | 776 | 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_Err | 781 | 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_Err | 786 | 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_Err | 790 | 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_Err | 794 | 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_Err | 799 | return templ_7745c5c3_Err | ||
| 768 | } | 800 | } | ||
| @@ -772,20 +804,20 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 772 | return templ_7745c5c3_Err | 804 | 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_Err | 809 | return templ_7745c5c3_Err | ||
| 778 | } | 810 | } | ||
| 779 | - | var templ_7745c5c3_Var38 string | 811 | + | 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_Err | 818 | 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_Err | 822 | return templ_7745c5c3_Err | ||
| 791 | } | 823 | } | ||
| @@ -795,17 +827,17 @@ func IssueShow(l Layout, p IssuePage) templ.Component { | |||||
| 795 | return templ_7745c5c3_Err | 827 | 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_Err | 832 | 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_Err | 837 | 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_Err | 842 | 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.NopComponent | 878 | + | 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_Err | 980 | return templ_7745c5c3_Err | ||
| 853 | } | 981 | } | ||
| 854 | - | var templ_7745c5c3_Var40 templ.SafeURL | 982 | + | 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_Err | 989 | 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_Err | 993 | return templ_7745c5c3_Err | ||
| 866 | } | 994 | } | ||
| 867 | - | var templ_7745c5c3_Var41 string | 995 | + | 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_Err | 1002 | 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_Err | 1006 | return templ_7745c5c3_Err | ||
| 879 | } | 1007 | } | ||
M internal/adapters/web/issues_test.go +45 -0
| @@ -309,6 +309,8 @@ func TestIssueLiveNotices(t *testing.T) { | |||
| 309 | 309 | {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"}, | |
| 310 | 310 | {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"}, | |
| 311 | 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 | 314 | {domain.IssueClosed{Issue: issue}, "mrs", "", ""}, | |
| 313 | 315 | {domain.IssueClosed{Issue: issue}, "summary", "", ""}, | |
| 314 | 316 | } | |
| @@ -330,3 +332,46 @@ func TestIssueLiveNotices(t *testing.T) { | |||
| 330 | 332 | require.NoError(t, err) | |
| 331 | 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 <b>it</b>") | |
| 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 <b>it</b>") | |||
| 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, | |||
| 64 | 64 | // #fragment links chosen, so that several documents on one page (the summaries | |
| 65 | 65 | // of a run) do not share ids. | |
| 66 | 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. | |
| 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) | |
| 73 | 74 | 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 | 93 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | |
| 77 | 94 | // approved from the public form). On top of the policy of render, which already | |
| 78 | 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 | 97 | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | |
| 81 | 98 | // every other scheme lose their link and keep the text; | |
| 82 | 99 | // - 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}) | |
| 85 | 102 | 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 | 106 | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()})) | |
| 90 | 107 | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |
| 91 | 108 | pol.source = source | |
| 92 | 109 | // Collect eligible text before the policy unwraps rejected links: their | |
| 93 | 110 | // labels must not accidentally become references either. | |
| 94 | 111 | var refs []*ast.Text | |
| 95 | - | if notes != nil { | |
| 112 | + | if res.notes != nil || res.numbers != nil { | |
| 96 | 113 | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 97 | 114 | if !entering { | |
| 98 | 115 | return ast.WalkContinue, nil | |
| @@ -108,7 +125,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) | |||
| 108 | 125 | } | |
| 109 | 126 | pol.apply(doc) | |
| 110 | 127 | for _, t := range refs { | |
| 111 | - | noteReferences(t, source, notes) | |
| 128 | + | references(t, source, res) | |
| 112 | 129 | } | |
| 113 | 130 | ||
| 114 | 131 | 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 ' 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 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) { | |
| 398 | 418 | parent := t.Parent() | |
| 399 | 419 | if parent == nil { | |
| 400 | 420 | return | |
| 401 | 421 | } | |
| 402 | 422 | value := t.Value(source) | |
| 403 | 423 | 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]) | |
| 406 | 436 | n, err := strconv.ParseInt(id, 10, 64) | |
| 407 | - | if err != nil || !exists(n) { | |
| 437 | + | if err != nil { | |
| 408 | 438 | continue | |
| 409 | 439 | } | |
| 410 | - | start := match[4] - 1 | |
| 440 | + | start := from - 1 | |
| 411 | 441 | // Markdown punctuation may split adjacent text into separate AST nodes. | |
| 412 | 442 | // Check the original preceding byte as well as the segment-local regex. | |
| 413 | 443 | if at := t.Segment.Start + start; at > 0 { | |
| 414 | 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 | 449 | 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 | 471 | if start > offset { | |
| 420 | 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 | 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 | 476 | parent.InsertBefore(parent, t, link) | |
| 428 | - | offset = match[5] | |
| 477 | + | offset = to | |
| 429 | 478 | } | |
| 430 | 479 | if offset == 0 { | |
| 431 | 480 | 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 summaries | 64 | // #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, err | 74 | 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 issue | 93 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | ||
| 77 | // approved from the public form). On top of the policy of render, which already | 94 | // 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 and | 97 | // 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, err | 102 | 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 = source | 108 | pol.source = source | ||
| 92 | // Collect eligible text before the policy unwraps rejected links: their | 109 | // 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.Text | 111 | 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, nil | 115 | 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.Buffer | 131 | 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 ' 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 enter | 415 | + | // 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 | return | 420 | return | ||
| 401 | } | 421 | } | ||
| 402 | value := t.Value(source) | 422 | value := t.Value(source) | ||
| 403 | offset := 0 | 423 | 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 | continue | 438 | continue | ||
| 409 | } | 439 | } | ||
| 410 | - | start := match[4] - 1 | 440 | + | 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 | continue | 449 | 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 | return | 480 | return | ||
M internal/adapters/web/markdown_test.go +79 -3
| @@ -6,7 +6,10 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 9 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 10 | 13 | "github.com/stretchr/testify/require" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| @@ -139,7 +142,7 @@ func TestMarkdownGFM(t *testing.T) { | |||
| 139 | 142 | ||
| 140 | 143 | func renderUntrustedMD(t *testing.T, src string) string { | |
| 141 | 144 | t.Helper() | |
| 142 | - | h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-") | |
| 145 | + | h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil) | |
| 143 | 146 | require.NoError(t, err) | |
| 144 | 147 | return string(h) | |
| 145 | 148 | } | |
| @@ -218,17 +221,90 @@ func TestNoteMarkdown(t *testing.T) { | |||
| 218 | 221 | for _, tt := range tests { | |
| 219 | 222 | t.Run(tt.source, func(t *testing.T) { | |
| 220 | 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 | 225 | require.NoError(t, err) | |
| 223 | 226 | assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h)) | |
| 224 | 227 | assert.NotContains(t, string(h), `href="#c99"`) | |
| 225 | 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 | 232 | require.NoError(t, err) | |
| 230 | 233 | assert.Contains(t, string(h), `id="c1-heading"`) | |
| 231 | 234 | assert.Contains(t, string(h), "<strong>bold</strong>") | |
| 232 | 235 | assert.NotContains(t, string(h), "<script") | |
| 233 | 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 | + | {"' ", 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 | + | {"' ", 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 | |||
| 616 | 616 | if canWrite { | |
| 617 | 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 | 620 | lines, invisible := diffLines(p.Files) | |
| 621 | 621 | p.Invisible = invisible | |
| 622 | 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 | 680 | // Relative links point into the source branch as it is now (its head | |
| 681 | 681 | // commit); the heading ids carry their own prefix, apart from the | |
| 682 | 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 | 684 | if err != nil { | |
| 685 | 685 | return err | |
| 686 | 686 | } | |
| @@ -905,7 +905,7 @@ func parseOn(s string) *lineRef { | |||
| 905 | 905 | // as links to the form of their line; on designates the line whose form shows. | |
| 906 | 906 | // | |
| 907 | 907 | //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 | 909 | now := s.cfg.Now() | |
| 910 | 910 | self := mrHref(repo, n) | |
| 911 | 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 | 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 | 957 | children := map[int64][]domain.Comment{} | |
| 958 | 958 | for _, c := range comments { | |
| 959 | 959 | 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 = invisible | 621 | 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 head | 680 | // Relative links point into the source branch as it is now (its head | ||
| 681 | // commit); the heading ids carry their own prefix, apart from the | 681 | // 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 err | 685 | 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 only | 907 | //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, | |||
| 227 | 227 | } else { | |
| 228 | 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 | 231 | window := p.Files[from:min(from+mrWindow, len(p.Files))] | |
| 232 | 232 | if one { | |
| 233 | 233 | 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) { | |||
| 43 | 43 | {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true}, | |
| 44 | 44 | } | |
| 45 | 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 | 47 | require.Len(t, p.General, 3) | |
| 48 | 48 | assert.Equal(t, 4, p.NComments) | |
| 49 | 49 | assert.Equal(t, 1, p.NUnresolved) | |
| @@ -117,7 +117,7 @@ func TestDeletedNoteRendering(t *testing.T) { | |||
| 117 | 117 | require.NoError(t, err) | |
| 118 | 118 | s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()} | |
| 119 | 119 | p := MRPage{} | |
| 120 | - | s.placeComments(&p, "demo", 1, []domain.Comment{ | |
| 120 | + | s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{ | |
| 121 | 121 | {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove}, | |
| 122 | 122 | {ID: 2, Deleted: true}, | |
| 123 | 123 | {ID: 3, ReplyTo: 2, Deleted: true}, | |
| @@ -147,7 +147,7 @@ func TestInlineNoteHeadingIDs(t *testing.T) { | |||
| 147 | 147 | require.NoError(t, err) | |
| 148 | 148 | s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()} | |
| 149 | 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 | 151 | notes := p.Files[0].Hunks[0].Lines[0].Notes | |
| 152 | 152 | require.Len(t, notes, 1) | |
| 153 | 153 | 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].Notes | 151 | 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.Buffer | 153 | var b bytes.Buffer | ||