detached a8cc2eb

feat(web): show resolved issues and linked issue history

commit
a8cc2eb2e4df41b3fd7ca24493d3cbf47d20e8c4
author
brice · · 2026-10-22 18:14 UTC
committer
brice · · 2026-10-22 18:41 UTC
parent
38857c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1643 -915
M internal/adapters/web/fragments.go +5 -1
@@ -67,7 +67,7 @@ func liveEvent(we ports.WatchEvent) bool {
6767 domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6868 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6969 domain.RunUpdated, domain.JobUpdated,
70- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
70+ domain.IssueOpened, domain.IssueResolved, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
7171 return true
7272 }
7373 return false
@@ -129,6 +129,8 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
129129 name, fresh = ev.Repo.String(), true
130130 case domain.IssueOpened:
131131 name, fresh = ev.Issue.Repo.String(), true
132+ case domain.IssueResolved:
133+ name, fresh = ev.Issue.Repo.String(), true
132134 case domain.IssueClosed:
133135 name, fresh = ev.Issue.Repo.String(), true
134136 case domain.IssueReopened:
@@ -239,6 +241,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
239241 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
240242 case domain.IssueOpened:
241243 return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
244+ case domain.IssueResolved:
245+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was resolved — reload")
242246 case domain.IssueClosed:
243247 return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
244248 case domain.IssueReopened:
@@ -67,7 +67,7 @@ func liveEvent(we ports.WatchEvent) bool {
67 domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,67 domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
68 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,68 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
69 domain.RunUpdated, domain.JobUpdated,69 domain.RunUpdated, domain.JobUpdated,
70- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:70+ domain.IssueOpened, domain.IssueResolved, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
71 return true71 return true
72 }72 }
73 return false73 return false
@@ -129,6 +129,8 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
129 name, fresh = ev.Repo.String(), true129 name, fresh = ev.Repo.String(), true
130 case domain.IssueOpened:130 case domain.IssueOpened:
131 name, fresh = ev.Issue.Repo.String(), true131 name, fresh = ev.Issue.Repo.String(), true
132+ case domain.IssueResolved:
133+ name, fresh = ev.Issue.Repo.String(), true
132 case domain.IssueClosed:134 case domain.IssueClosed:
133 name, fresh = ev.Issue.Repo.String(), true135 name, fresh = ev.Issue.Repo.String(), true
134 case domain.IssueReopened:136 case domain.IssueReopened:
@@ -239,6 +241,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
239 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")241 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
240 case domain.IssueOpened:242 case domain.IssueOpened:
241 return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")243 return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
244+ case domain.IssueResolved:
245+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was resolved — reload")
242 case domain.IssueClosed:246 case domain.IssueClosed:
243 return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")247 return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
244 case domain.IssueReopened:248 case domain.IssueReopened:
M internal/adapters/web/home_test.go +1 -1
@@ -379,7 +379,7 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
379379 domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
380380 domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
381381 domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
382- domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
382+ domain.IssueResolved{Issue: issue}, domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
383383 } {
384384 t.Run(ev.EventKind(), func(t *testing.T) {
385385 t.Parallel()
@@ -379,7 +379,7 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
379 domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},379 domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
380 domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},380 domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
381 domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},381 domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
382- domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},382+ domain.IssueResolved{Issue: issue}, domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
383 } {383 } {
384 t.Run(ev.EventKind(), func(t *testing.T) {384 t.Run(ev.EventKind(), func(t *testing.T) {
385 t.Parallel()385 t.Parallel()
M internal/adapters/web/inbox.go +1 -1
@@ -287,7 +287,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
287287 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
288288 switch we.Event.(type) {
289289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
290+ domain.IssueOpened, domain.IssueResolved, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291291 domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
292292 default:
293293 return nil, nil
@@ -287,7 +287,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
287 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {287 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
288 switch we.Event.(type) {288 switch we.Event.(type) {
289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290- domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,290+ domain.IssueOpened, domain.IssueResolved, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291 domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:291 domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
292 default:292 default:
293 return nil, nil293 return nil, nil
M internal/adapters/web/inbox_test.go +5 -0
@@ -188,6 +188,11 @@ func TestInboxLiveFragments(t *testing.T) {
188188 assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189189 assert.Contains(t, frags[5].html, `id="inbox-issues"`)
190190
191+ resolved, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
192+ require.NoError(t, err)
193+ require.Len(t, resolved, 6)
194+ assert.Contains(t, resolved[5].html, `id="inbox-issues"`)
195+
191196 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192197 require.NoError(t, err)
193198 assert.Empty(t, none, "a push is not inbox news")
@@ -188,6 +188,11 @@ func TestInboxLiveFragments(t *testing.T) {
188 assert.Contains(t, frags[4].html, `id="inbox-mrs"`)188 assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189 assert.Contains(t, frags[5].html, `id="inbox-issues"`)189 assert.Contains(t, frags[5].html, `id="inbox-issues"`)
190 190
191+ resolved, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
192+ require.NoError(t, err)
193+ require.Len(t, resolved, 6)
194+ assert.Contains(t, resolved[5].html, `id="inbox-issues"`)
195+
191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})196 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192 require.NoError(t, err)197 require.NoError(t, err)
193 assert.Empty(t, none, "a push is not inbox news")198 assert.Empty(t, none, "a push is not inbox news")
A internal/adapters/web/issue_timeline.go +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "sort"
7+ "strconv"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// TimelineItem holds either a comment or a compact system event.
15+type TimelineItem struct {
16+ Comment *CommentView
17+ Event *IssueEventView
18+ at time.Time
19+}
20+
21+type IssueEventView struct {
22+ ID, Kind, Actor, Text, When, WhenAt, Abs string
23+ MR *Link
24+ Closes bool
25+ Icon string
26+}
27+
28+type IssueLinkView struct {
29+ Number, Title, State, Href string
30+ Closes bool
31+}
32+
33+// issueTimeline keeps comments ahead of events with the same timestamp.
34+func (s *Server) issueTimeline(repo string, comments []CommentView, events []ports.IssueEventDetail, mr bool) []TimelineItem {
35+ items := make([]TimelineItem, 0, len(comments)+len(events))
36+ for _, c := range comments {
37+ at := c.createdAt
38+ items = append(items, TimelineItem{Comment: &c, at: at})
39+ }
40+ for _, detail := range events {
41+ e := detail.Event
42+ v := IssueEventView{ID: strconv.FormatInt(e.ID, 10), Kind: string(e.Kind), Actor: s.who(e.Actor), Closes: e.Closes}
43+ v.When, v.WhenAt = whenOf(s.cfg.Now(), e.CreatedAt)
44+ if !e.CreatedAt.IsZero() {
45+ v.Abs = absolute(e.CreatedAt)
46+ }
47+ switch e.Kind {
48+ case domain.IssueEventReferenced:
49+ v.Text, v.Icon = "referenced this in", "↗"
50+ if e.Closes {
51+ v.Text = "will resolve this in"
52+ }
53+ case domain.IssueEventClosed:
54+ v.Text, v.Icon = "closed this", "✗"
55+ case domain.IssueEventReopened:
56+ v.Text, v.Icon = "reopened this", "↻"
57+ case domain.IssueEventResolved:
58+ v.Text, v.Icon = "resolved this", "✓"
59+ if e.MergeRequest != 0 {
60+ v.Text += " with"
61+ }
62+ default:
63+ continue
64+ }
65+ if mr {
66+ // IDs are per issue, so MR event anchors also include the issue number.
67+ v.ID = strconv.FormatInt(e.Issue, 10) + "-" + v.ID
68+ v.MR = &Link{Label: "#" + strconv.FormatInt(e.Issue, 10), Href: issueHref(repo, e.Issue)}
69+ switch e.Kind {
70+ case domain.IssueEventReferenced:
71+ v.Text = "referenced"
72+ if e.Closes {
73+ v.Text = "will resolve"
74+ }
75+ case domain.IssueEventResolved:
76+ v.Text = "resolved"
77+ case domain.IssueEventClosed:
78+ v.Text = "closed"
79+ case domain.IssueEventReopened:
80+ v.Text = "reopened"
81+ }
82+ } else if e.MergeRequest != 0 {
83+ v.MR = numberLink(repo, e.MergeRequest)
84+ if title := visibleText(detail.MergeRequestTitle.String()); title != "" {
85+ v.MR.Label += " " + title
86+ }
87+ }
88+ items = append(items, TimelineItem{Event: &v, at: e.CreatedAt})
89+ }
90+ sort.SliceStable(items, func(i, j int) bool { return items[i].at.Before(items[j].at) })
91+ return items
92+}
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "sort"
7+ "strconv"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// TimelineItem holds either a comment or a compact system event.
15+type TimelineItem struct {
16+ Comment *CommentView
17+ Event *IssueEventView
18+ at time.Time
19+}
20+
21+type IssueEventView struct {
22+ ID, Kind, Actor, Text, When, WhenAt, Abs string
23+ MR *Link
24+ Closes bool
25+ Icon string
26+}
27+
28+type IssueLinkView struct {
29+ Number, Title, State, Href string
30+ Closes bool
31+}
32+
33+// issueTimeline keeps comments ahead of events with the same timestamp.
34+func (s *Server) issueTimeline(repo string, comments []CommentView, events []ports.IssueEventDetail, mr bool) []TimelineItem {
35+ items := make([]TimelineItem, 0, len(comments)+len(events))
36+ for _, c := range comments {
37+ at := c.createdAt
38+ items = append(items, TimelineItem{Comment: &c, at: at})
39+ }
40+ for _, detail := range events {
41+ e := detail.Event
42+ v := IssueEventView{ID: strconv.FormatInt(e.ID, 10), Kind: string(e.Kind), Actor: s.who(e.Actor), Closes: e.Closes}
43+ v.When, v.WhenAt = whenOf(s.cfg.Now(), e.CreatedAt)
44+ if !e.CreatedAt.IsZero() {
45+ v.Abs = absolute(e.CreatedAt)
46+ }
47+ switch e.Kind {
48+ case domain.IssueEventReferenced:
49+ v.Text, v.Icon = "referenced this in", "↗"
50+ if e.Closes {
51+ v.Text = "will resolve this in"
52+ }
53+ case domain.IssueEventClosed:
54+ v.Text, v.Icon = "closed this", "✗"
55+ case domain.IssueEventReopened:
56+ v.Text, v.Icon = "reopened this", "↻"
57+ case domain.IssueEventResolved:
58+ v.Text, v.Icon = "resolved this", "✓"
59+ if e.MergeRequest != 0 {
60+ v.Text += " with"
61+ }
62+ default:
63+ continue
64+ }
65+ if mr {
66+ // IDs are per issue, so MR event anchors also include the issue number.
67+ v.ID = strconv.FormatInt(e.Issue, 10) + "-" + v.ID
68+ v.MR = &Link{Label: "#" + strconv.FormatInt(e.Issue, 10), Href: issueHref(repo, e.Issue)}
69+ switch e.Kind {
70+ case domain.IssueEventReferenced:
71+ v.Text = "referenced"
72+ if e.Closes {
73+ v.Text = "will resolve"
74+ }
75+ case domain.IssueEventResolved:
76+ v.Text = "resolved"
77+ case domain.IssueEventClosed:
78+ v.Text = "closed"
79+ case domain.IssueEventReopened:
80+ v.Text = "reopened"
81+ }
82+ } else if e.MergeRequest != 0 {
83+ v.MR = numberLink(repo, e.MergeRequest)
84+ if title := visibleText(detail.MergeRequestTitle.String()); title != "" {
85+ v.MR.Label += " " + title
86+ }
87+ }
88+ items = append(items, TimelineItem{Event: &v, at: e.CreatedAt})
89+ }
90+ sort.SliceStable(items, func(i, j int) bool { return items[i].at.Before(items[j].at) })
91+ return items
92+}
A internal/adapters/web/issue_timeline.templ +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+templ issueTimeline(items []TimelineItem) {
6+ if len(items) > 0 {
7+ <ol class="timeline" aria-label="Conversation history">
8+ for _, item := range items {
9+ <li>
10+ if item.Comment != nil {
11+ @commentView(*item.Comment, "")
12+ } else if item.Event != nil {
13+ @issueEventRow(*item.Event)
14+ }
15+ </li>
16+ }
17+ </ol>
18+ }
19+}
20+
21+templ issueEventRow(e IssueEventView) {
22+ <div class="sys" id={ "e" + e.ID } data-kind={ e.Kind }>
23+ <span class="sys-icon" aria-hidden="true">{ e.Icon }</span>
24+ <span>
25+ { e.Actor } { e.Text }
26+ if e.MR != nil {
27+ { " " }<a class="ref" href={ templ.SafeURL(e.MR.Href) }>{ e.MR.Label }</a>
28+ }
29+ </span>
30+ if e.WhenAt != "" {
31+ { " · " }<time datetime={ e.WhenAt } title={ e.Abs }>{ e.When }</time>
32+ }
33+ </div>
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+templ issueTimeline(items []TimelineItem) {
6+ if len(items) > 0 {
7+ <ol class="timeline" aria-label="Conversation history">
8+ for _, item := range items {
9+ <li>
10+ if item.Comment != nil {
11+ @commentView(*item.Comment, "")
12+ } else if item.Event != nil {
13+ @issueEventRow(*item.Event)
14+ }
15+ </li>
16+ }
17+ </ol>
18+ }
19+}
20+
21+templ issueEventRow(e IssueEventView) {
22+ <div class="sys" id={ "e" + e.ID } data-kind={ e.Kind }>
23+ <span class="sys-icon" aria-hidden="true">{ e.Icon }</span>
24+ <span>
25+ { e.Actor } { e.Text }
26+ if e.MR != nil {
27+ { " " }<a class="ref" href={ templ.SafeURL(e.MR.Href) }>{ e.MR.Label }</a>
28+ }
29+ </span>
30+ if e.WhenAt != "" {
31+ { " · " }<time datetime={ e.WhenAt } title={ e.Abs }>{ e.When }</time>
32+ }
33+ </div>
34+}
A internal/adapters/web/issue_timeline_templ.go +266 -0 generated file: collapsed
M internal/adapters/web/issues.go +18 -14
@@ -25,7 +25,7 @@ import (
2525 //
2626 // Routes:
2727 //
28-// GET /<repo>/issues the list, open or closed (?state=), paged (?page=)
28+// GET /<repo>/issues the list, open, closed or resolved (?state=), paged (?page=)
2929 // GET /<repo>/issues/new the form that opens one (owner)
3030 // GET /<repo>/issues/<n> the issue with its comments
3131 //
@@ -55,11 +55,11 @@ type IssueRow struct {
5555 type IssueListPage struct {
5656 Rows []IssueRow
5757 // Closed is true on the tab of the closed issues.
58- Closed bool
59- OpenHref, ClosedHref string
60- Older, Newest string
61- NewHref string // the form that opens an issue; "" unless the owner
62- Live bool
58+ Closed, Resolved bool
59+ OpenHref, ClosedHref, ResolvedHref string
60+ Older, Newest string
61+ NewHref string // the form that opens an issue; "" unless the owner
62+ Live bool
6363 }
6464
6565 // IssueActions are the buttons of the owner on an issue.
@@ -80,11 +80,12 @@ type IssuePage struct {
8080 // Desc is the body rendered as Markdown, "" when it is empty.
8181 Desc template.HTML
8282 Comments []CommentView
83+ Timeline []TimelineItem
8384 Form *CommentForm
8485 Transition *IssueActions
8586 Live bool
86- // ClosedBy is the merge request whose merge closed the issue.
87- ClosedBy *Link
87+ // ResolvedBy is the merge request whose merge resolved the issue.
88+ ResolvedBy *Link
8889 // References are the merge requests naming the issue.
8990 References []IssueRefView
9091 }
@@ -192,6 +193,8 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
192193 case "", "open":
193194 case "closed":
194195 state = domain.IssueStateClosed
196+ case "resolved":
197+ state = domain.IssueStateResolved
195198 default:
196199 return nil, 0, errNotFound
197200 }
@@ -200,8 +203,8 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
200203 closed := state == domain.IssueStateClosed
201204 link := func(page string) string {
202205 q := url.Values{}
203- if closed {
204- q.Set("state", "closed")
206+ if state != domain.IssueStateOpen {
207+ q.Set("state", string(state))
205208 }
206209 if page != "" {
207210 q.Set("page", page)
@@ -218,7 +221,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
218221 if err != nil {
219222 return nil, 0, err
220223 }
221- p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
224+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed", Resolved: state == domain.IssueStateResolved, ResolvedHref: base + "?state=resolved"}
222225 for _, i := range res.Issues {
223226 p.Rows = append(p.Rows, s.issueRow(d.name(), i))
224227 }
@@ -293,7 +296,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
293296 if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
294297 return nil, 0, err
295298 }
296- p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
299+ p.ResolvedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297300 p.References = issueRefViews(d.name(), res.References)
298301 p.Opened.Name = s.who(i.Author)
299302 if i.External {
@@ -307,7 +310,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
307310 p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
308311 for _, c := range res.Comments {
309312 v := CommentView{
310- ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
313+ createdAt: c.CreatedAt, ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
311314 }
312315 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
313316 if !c.CreatedAt.IsZero() {
@@ -315,12 +318,13 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
315318 }
316319 p.Comments = append(p.Comments, v)
317320 }
321+ p.Timeline = s.issueTimeline(d.name(), p.Comments, res.Events, false)
318322 if signed {
319323 p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
320324 switch i.State {
321325 case domain.IssueStateOpen:
322326 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
323- case domain.IssueStateClosed:
327+ case domain.IssueStateClosed, domain.IssueStateResolved:
324328 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
325329 }
326330 }
@@ -25,7 +25,7 @@ import (
25 //25 //
26 // Routes:26 // Routes:
27 //27 //
28-// GET /<repo>/issues the list, open or closed (?state=), paged (?page=)28+// GET /<repo>/issues the list, open, closed or resolved (?state=), paged (?page=)
29 // GET /<repo>/issues/new the form that opens one (owner)29 // GET /<repo>/issues/new the form that opens one (owner)
30 // GET /<repo>/issues/<n> the issue with its comments30 // GET /<repo>/issues/<n> the issue with its comments
31 //31 //
@@ -55,11 +55,11 @@ type IssueRow struct {
55 type IssueListPage struct {55 type IssueListPage struct {
56 Rows []IssueRow56 Rows []IssueRow
57 // Closed is true on the tab of the closed issues.57 // Closed is true on the tab of the closed issues.
58- Closed bool58+ Closed, Resolved bool
59- OpenHref, ClosedHref string59+ OpenHref, ClosedHref, ResolvedHref string
60- Older, Newest string60+ Older, Newest string
61- NewHref string // the form that opens an issue; "" unless the owner61+ NewHref string // the form that opens an issue; "" unless the owner
62- Live bool62+ Live bool
63 }63 }
64 64
65 // IssueActions are the buttons of the owner on an issue.65 // IssueActions are the buttons of the owner on an issue.
@@ -80,11 +80,12 @@ type IssuePage struct {
80 // Desc is the body rendered as Markdown, "" when it is empty.80 // Desc is the body rendered as Markdown, "" when it is empty.
81 Desc template.HTML81 Desc template.HTML
82 Comments []CommentView82 Comments []CommentView
83+ Timeline []TimelineItem
83 Form *CommentForm84 Form *CommentForm
84 Transition *IssueActions85 Transition *IssueActions
85 Live bool86 Live bool
86- // ClosedBy is the merge request whose merge closed the issue.87+ // ResolvedBy is the merge request whose merge resolved the issue.
87- ClosedBy *Link88+ ResolvedBy *Link
88 // References are the merge requests naming the issue.89 // References are the merge requests naming the issue.
89 References []IssueRefView90 References []IssueRefView
90 }91 }
@@ -192,6 +193,8 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
192 case "", "open":193 case "", "open":
193 case "closed":194 case "closed":
194 state = domain.IssueStateClosed195 state = domain.IssueStateClosed
196+ case "resolved":
197+ state = domain.IssueStateResolved
195 default:198 default:
196 return nil, 0, errNotFound199 return nil, 0, errNotFound
197 }200 }
@@ -200,8 +203,8 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
200 closed := state == domain.IssueStateClosed203 closed := state == domain.IssueStateClosed
201 link := func(page string) string {204 link := func(page string) string {
202 q := url.Values{}205 q := url.Values{}
203- if closed {206+ if state != domain.IssueStateOpen {
204- q.Set("state", "closed")207+ q.Set("state", string(state))
205 }208 }
206 if page != "" {209 if page != "" {
207 q.Set("page", page)210 q.Set("page", page)
@@ -218,7 +221,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
218 if err != nil {221 if err != nil {
219 return nil, 0, err222 return nil, 0, err
220 }223 }
221- p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}224+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed", Resolved: state == domain.IssueStateResolved, ResolvedHref: base + "?state=resolved"}
222 for _, i := range res.Issues {225 for _, i := range res.Issues {
223 p.Rows = append(p.Rows, s.issueRow(d.name(), i))226 p.Rows = append(p.Rows, s.issueRow(d.name(), i))
224 }227 }
@@ -293,7 +296,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
293 if p.Desc, err = s.issueBody(ctx, d, i); err != nil {296 if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
294 return nil, 0, err297 return nil, 0, err
295 }298 }
296- p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)299+ p.ResolvedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297 p.References = issueRefViews(d.name(), res.References)300 p.References = issueRefViews(d.name(), res.References)
298 p.Opened.Name = s.who(i.Author)301 p.Opened.Name = s.who(i.Author)
299 if i.External {302 if i.External {
@@ -307,7 +310,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
307 p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)310 p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
308 for _, c := range res.Comments {311 for _, c := range res.Comments {
309 v := CommentView{312 v := CommentView{
310- ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),313+ createdAt: c.CreatedAt, ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
311 }314 }
312 v.When, v.WhenAt = whenOf(now, c.CreatedAt)315 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
313 if !c.CreatedAt.IsZero() {316 if !c.CreatedAt.IsZero() {
@@ -315,12 +318,13 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
315 }318 }
316 p.Comments = append(p.Comments, v)319 p.Comments = append(p.Comments, v)
317 }320 }
321+ p.Timeline = s.issueTimeline(d.name(), p.Comments, res.Events, false)
318 if signed {322 if signed {
319 p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}323 p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
320 switch i.State {324 switch i.State {
321 case domain.IssueStateOpen:325 case domain.IssueStateOpen:
322 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}326 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
323- case domain.IssueStateClosed:327+ case domain.IssueStateClosed, domain.IssueStateResolved:
324 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}328 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
325 }329 }
326 }330 }
M internal/adapters/web/issues.templ +21 -11
@@ -11,7 +11,7 @@ templ IssueList(l Layout, p IssueListPage) {
1111 <nav class="since" aria-label="State">
1212 <a
1313 href={ templ.SafeURL(p.OpenHref) }
14- if !p.Closed {
14+ if !p.Closed && !p.Resolved {
1515 aria-current="page"
1616 }
1717 >open</a>
@@ -21,6 +21,12 @@ templ IssueList(l Layout, p IssueListPage) {
2121 aria-current="page"
2222 }
2323 >closed</a>
24+ <a
25+ href={ templ.SafeURL(p.ResolvedHref) }
26+ if p.Resolved {
27+ aria-current="page"
28+ }
29+ >resolved</a>
2430 </nav>
2531 <div class="filter">
2632 <label for="issue-filter" class="sr">filter</label>
@@ -34,7 +40,9 @@ templ IssueList(l Layout, p IssueListPage) {
3440 />
3541 </div>
3642 <h2 class="rule-h">
37- if p.Closed {
43+ if p.Resolved {
44+ <span>Resolved</span>
45+ } else if p.Closed {
3846 <span>Closed</span>
3947 } else {
4048 <span>Open</span>
@@ -43,7 +51,9 @@ templ IssueList(l Layout, p IssueListPage) {
4351 </h2>
4452 @issueRows(p.Rows)
4553 if len(p.Rows) == 0 {
46- if p.Closed {
54+ if p.Resolved {
55+ <p class="lede dim">no resolved issues.</p>
56+ } else if p.Closed {
4757 <p class="lede dim">no closed issues.</p>
4858 } else {
4959 <p class="lede dim">no open issues.</p>
@@ -95,7 +105,9 @@ templ issueListRail(p IssueListPage) {
95105 <dt>open</dt>
96106 <dd>being worked on</dd>
97107 <dt>closed</dt>
98- <dd>done with, can be reopened</dd>
108+ <dd>closed without resolution</dd>
109+ <dt>resolved</dt>
110+ <dd>fixed, can be reopened</dd>
99111 </dl>
100112 </section>
101113 if p.NewHref != "" {
@@ -149,9 +161,9 @@ templ IssueShow(l Layout, p IssuePage) {
149161 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150162 }
151163 </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>
164+ if p.ResolvedBy != nil {
165+ <dt>resolved by</dt>
166+ <dd><a class="ref" href={ templ.SafeURL(p.ResolvedBy.Href) }>{ p.ResolvedBy.Label }</a></dd>
155167 }
156168 if p.UpdatedAt != "" {
157169 <dt>updated</dt>
@@ -169,9 +181,7 @@ templ IssueShow(l Layout, p IssuePage) {
169181 @LiveSlot("mr-notice")
170182 }
171183 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
172- for _, c := range p.Comments {
173- @commentView(c, "")
174- }
184+ @issueTimeline(p.Timeline)
175185 if len(p.Comments) == 0 {
176186 <p class="dim">no comments yet.</p>
177187 }
@@ -192,7 +202,7 @@ templ issueRail(p IssuePage) {
192202 { " " + r.Title + " " }
193203 <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194204 if r.Closes {
195- { " · closes" }
205+ { " · resolves" }
196206 }
197207 </li>
198208 }
@@ -11,7 +11,7 @@ templ IssueList(l Layout, p IssueListPage) {
11 <nav class="since" aria-label="State">11 <nav class="since" aria-label="State">
12 <a12 <a
13 href={ templ.SafeURL(p.OpenHref) }13 href={ templ.SafeURL(p.OpenHref) }
14- if !p.Closed {14+ if !p.Closed && !p.Resolved {
15 aria-current="page"15 aria-current="page"
16 }16 }
17 >open</a>17 >open</a>
@@ -21,6 +21,12 @@ templ IssueList(l Layout, p IssueListPage) {
21 aria-current="page"21 aria-current="page"
22 }22 }
23 >closed</a>23 >closed</a>
24+ <a
25+ href={ templ.SafeURL(p.ResolvedHref) }
26+ if p.Resolved {
27+ aria-current="page"
28+ }
29+ >resolved</a>
24 </nav>30 </nav>
25 <div class="filter">31 <div class="filter">
26 <label for="issue-filter" class="sr">filter</label>32 <label for="issue-filter" class="sr">filter</label>
@@ -34,7 +40,9 @@ templ IssueList(l Layout, p IssueListPage) {
34 />40 />
35 </div>41 </div>
36 <h2 class="rule-h">42 <h2 class="rule-h">
37- if p.Closed {43+ if p.Resolved {
44+ <span>Resolved</span>
45+ } else if p.Closed {
38 <span>Closed</span>46 <span>Closed</span>
39 } else {47 } else {
40 <span>Open</span>48 <span>Open</span>
@@ -43,7 +51,9 @@ templ IssueList(l Layout, p IssueListPage) {
43 </h2>51 </h2>
44 @issueRows(p.Rows)52 @issueRows(p.Rows)
45 if len(p.Rows) == 0 {53 if len(p.Rows) == 0 {
46- if p.Closed {54+ if p.Resolved {
55+ <p class="lede dim">no resolved issues.</p>
56+ } else if p.Closed {
47 <p class="lede dim">no closed issues.</p>57 <p class="lede dim">no closed issues.</p>
48 } else {58 } else {
49 <p class="lede dim">no open issues.</p>59 <p class="lede dim">no open issues.</p>
@@ -95,7 +105,9 @@ templ issueListRail(p IssueListPage) {
95 <dt>open</dt>105 <dt>open</dt>
96 <dd>being worked on</dd>106 <dd>being worked on</dd>
97 <dt>closed</dt>107 <dt>closed</dt>
98- <dd>done with, can be reopened</dd>108+ <dd>closed without resolution</dd>
109+ <dt>resolved</dt>
110+ <dd>fixed, can be reopened</dd>
99 </dl>111 </dl>
100 </section>112 </section>
101 if p.NewHref != "" {113 if p.NewHref != "" {
@@ -149,9 +161,9 @@ templ IssueShow(l Layout, p IssuePage) {
149 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>161 { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150 }162 }
151 </dd>163 </dd>
152- if p.ClosedBy != nil {164+ if p.ResolvedBy != nil {
153- <dt>closed by</dt>165+ <dt>resolved by</dt>
154- <dd><a class="ref" href={ templ.SafeURL(p.ClosedBy.Href) }>{ p.ClosedBy.Label }</a></dd>166+ <dd><a class="ref" href={ templ.SafeURL(p.ResolvedBy.Href) }>{ p.ResolvedBy.Label }</a></dd>
155 }167 }
156 if p.UpdatedAt != "" {168 if p.UpdatedAt != "" {
157 <dt>updated</dt>169 <dt>updated</dt>
@@ -169,9 +181,7 @@ templ IssueShow(l Layout, p IssuePage) {
169 @LiveSlot("mr-notice")181 @LiveSlot("mr-notice")
170 }182 }
171 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>183 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
172- for _, c := range p.Comments {184+ @issueTimeline(p.Timeline)
173- @commentView(c, "")
174- }
175 if len(p.Comments) == 0 {185 if len(p.Comments) == 0 {
176 <p class="dim">no comments yet.</p>186 <p class="dim">no comments yet.</p>
177 }187 }
@@ -192,7 +202,7 @@ templ issueRail(p IssuePage) {
192 { " " + r.Title + " " }202 { " " + r.Title + " " }
193 <span class="mr-state" data-state={ r.State }>{ r.State }</span>203 <span class="mr-state" data-state={ r.State }>{ r.State }</span>
194 if r.Closes {204 if r.Closes {
195- { " · closes" }205+ { " · resolves" }
196 }206 }
197 </li>207 </li>
198 }208 }
M internal/adapters/web/issues_templ.go +288 -257 generated file: collapsed
M internal/adapters/web/issues_test.go +93 -4
@@ -304,6 +304,9 @@ func TestIssueLiveNotices(t *testing.T) {
304304 }{
305305 {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306306 {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueResolved{Issue: issue}, "issues", "", "issues changed"},
308+ {domain.IssueResolved{Issue: issue}, "issue", "5", "this issue was resolved"},
309+ {domain.IssueResolved{Issue: issue}, "issue", "6", ""},
307310 {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308311 {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309312 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
@@ -338,7 +341,7 @@ func TestIssueShowReferences(t *testing.T) {
338341 r := newIssueRig(t, domain.IssuesPublic, true)
339342 title, err := domain.ParseMRTitle("fix <b>it</b>")
340343 require.NoError(t, err)
341- issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
344+ issue := testIssue(t, 5, domain.IssueStateResolved, "bug", "see #7 and #8")
342345 issue.ClosedByMergeRequest = 2
343346 r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344347 Issue: issue,
@@ -353,12 +356,12 @@ func TestIssueShowReferences(t *testing.T) {
353356 rec := r.do("GET", "/proj/issues/5", false, nil)
354357 require.Equal(t, 200, rec.Code, rec.Body.String())
355358 body := rec.Body.String()
356- assert.Contains(t, body, "closed by")
359+ assert.Contains(t, body, "resolved by")
357360 assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358361 assert.Contains(t, body, ">!2<")
359362 assert.Contains(t, body, ">!3<")
360363 assert.Contains(t, body, `id="r-refs"`)
361- assert.Contains(t, body, "closes")
364+ assert.Contains(t, body, "resolves")
362365 assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363366 assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364367 assert.Contains(t, body, `title="merge request 7"`)
@@ -373,5 +376,91 @@ func TestIssueShowWithoutReferences(t *testing.T) {
373376 }, nil)
374377 body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375378 assert.NotContains(t, body, "r-refs")
376- assert.NotContains(t, body, "closed by")
379+ assert.NotContains(t, body, "resolved by")
380+}
381+
382+func TestIssueResolvedList(t *testing.T) {
383+ t.Parallel()
384+ r := newIssueRig(t, domain.IssuesOwner, false)
385+ for _, token := range []string{"", "next"} {
386+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateResolved, PageSize: pageSize, PageToken: token}).Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 5, domain.IssueStateResolved, "fixed", "")}, NextPageToken: "next"}, nil)
387+ path := "/proj/issues?state=resolved"
388+ if token != "" {
389+ path += "&page=" + token
390+ }
391+ rec := r.do("GET", path, false, nil)
392+ require.Equal(t, 200, rec.Code)
393+ body := rec.Body.String()
394+ assert.Contains(t, body, `href="/proj/issues?state=resolved" aria-current="page"`)
395+ assert.Contains(t, body, `data-state="resolved"`)
396+ assert.Contains(t, body, `href="/proj/issues?page=next&amp;state=resolved"`)
397+ }
398+}
399+
400+func TestIssueResolvedTimeline(t *testing.T) {
401+ t.Parallel()
402+ r := newIssueRig(t, domain.IssuesOwner, false)
403+ at := time.Date(2026, 1, 2, 12, 0, 0, 123, time.UTC)
404+ issue := testIssue(t, 5, domain.IssueStateResolved, "fixed", "")
405+ issue.ClosedByMergeRequest = 12
406+ text, err := domain.ParseIssueBody("comment between events")
407+ require.NoError(t, err)
408+ title, err := domain.ParseMRTitle("Fix login")
409+ require.NoError(t, err)
410+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
411+ Issue: issue,
412+ Comments: []domain.IssueComment{{ID: 7, Author: "owner", Text: text, CreatedAt: at}},
413+ Events: []ports.IssueEventDetail{
414+ {Event: domain.IssueEvent{ID: 1, Kind: domain.IssueEventReferenced, MergeRequest: 12, Actor: "owner", CreatedAt: at.Add(-time.Second)}, MergeRequestTitle: title},
415+ {Event: domain.IssueEvent{ID: 2, Kind: domain.IssueEventResolved, MergeRequest: 12, Actor: "owner", CreatedAt: at}, MergeRequestTitle: title},
416+ },
417+ }, nil)
418+ for _, owner := range []bool{false, true} {
419+ rec := r.do("GET", "/proj/issues/5", owner, nil)
420+ require.Equal(t, 200, rec.Code)
421+ body := rec.Body.String()
422+ assert.Contains(t, body, `data-state="resolved"`)
423+ assert.Contains(t, body, "resolved by")
424+ assert.Contains(t, body, "!12 Fix login")
425+ assert.Contains(t, body, `datetime="2026-01-02T12:00:00Z"`)
426+ assert.Less(t, strings.Index(body, `id="e1"`), strings.Index(body, `id="c7"`))
427+ assert.Less(t, strings.Index(body, `id="c7"`), strings.Index(body, `id="e2"`), "comment precedes event on exact timestamp tie")
428+ assert.NotContains(t, body, `/5/resolve`)
429+ assert.NotContains(t, body, `/5/close`)
430+ if owner {
431+ assert.Contains(t, body, `action="/proj/issues/5/reopen"`)
432+ } else {
433+ assert.NotContains(t, body, `/5/reopen`)
434+ }
435+ }
436+}
437+
438+func TestIssueHistoryEventKinds(t *testing.T) {
439+ t.Parallel()
440+ s := &Server{cfg: Config{Now: time.Now}}
441+ for _, tc := range []struct {
442+ kind domain.IssueEventKind
443+ mr int64
444+ closes bool
445+ text string
446+ }{
447+ {domain.IssueEventReferenced, 12, false, "referenced this in"},
448+ {domain.IssueEventReferenced, 12, true, "will resolve this in"},
449+ {domain.IssueEventClosed, 0, false, "closed this"},
450+ {domain.IssueEventReopened, 0, false, "reopened this"},
451+ {domain.IssueEventResolved, 0, false, "resolved this"},
452+ {domain.IssueEventResolved, 12, false, "resolved this with"},
453+ } {
454+ items := s.issueTimeline("proj", nil, []ports.IssueEventDetail{{Event: domain.IssueEvent{ID: 1, Kind: tc.kind, MergeRequest: tc.mr, Closes: tc.closes, Actor: "owner", CreatedAt: time.Now()}}}, false)
455+ require.Len(t, items, 1)
456+ body := renderToString(t, issueTimeline(items))
457+ assert.Contains(t, body, "owner "+tc.text)
458+ assert.Contains(t, body, `data-kind="`+string(tc.kind)+`"`)
459+ assert.Contains(t, body, `<ol class="timeline" aria-label="Conversation history">`)
460+ if tc.mr != 0 {
461+ assert.Contains(t, body, `href="/proj/merge-requests/12">!12</a>`)
462+ } else {
463+ assert.NotContains(t, body, `/merge-requests/`)
464+ }
465+ }
377466 }
@@ -304,6 +304,9 @@ func TestIssueLiveNotices(t *testing.T) {
304 }{304 }{
305 {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},305 {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306 {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},306 {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueResolved{Issue: issue}, "issues", "", "issues changed"},
308+ {domain.IssueResolved{Issue: issue}, "issue", "5", "this issue was resolved"},
309+ {domain.IssueResolved{Issue: issue}, "issue", "6", ""},
307 {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},310 {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308 {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},311 {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},312 {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
@@ -338,7 +341,7 @@ func TestIssueShowReferences(t *testing.T) {
338 r := newIssueRig(t, domain.IssuesPublic, true)341 r := newIssueRig(t, domain.IssuesPublic, true)
339 title, err := domain.ParseMRTitle("fix <b>it</b>")342 title, err := domain.ParseMRTitle("fix <b>it</b>")
340 require.NoError(t, err)343 require.NoError(t, err)
341- issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")344+ issue := testIssue(t, 5, domain.IssueStateResolved, "bug", "see #7 and #8")
342 issue.ClosedByMergeRequest = 2345 issue.ClosedByMergeRequest = 2
343 r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{346 r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344 Issue: issue,347 Issue: issue,
@@ -353,12 +356,12 @@ func TestIssueShowReferences(t *testing.T) {
353 rec := r.do("GET", "/proj/issues/5", false, nil)356 rec := r.do("GET", "/proj/issues/5", false, nil)
354 require.Equal(t, 200, rec.Code, rec.Body.String())357 require.Equal(t, 200, rec.Code, rec.Body.String())
355 body := rec.Body.String()358 body := rec.Body.String()
356- assert.Contains(t, body, "closed by")359+ assert.Contains(t, body, "resolved by")
357 assert.Contains(t, body, `href="/proj/merge-requests/2"`)360 assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358 assert.Contains(t, body, ">!2<")361 assert.Contains(t, body, ">!2<")
359 assert.Contains(t, body, ">!3<")362 assert.Contains(t, body, ">!3<")
360 assert.Contains(t, body, `id="r-refs"`)363 assert.Contains(t, body, `id="r-refs"`)
361- assert.Contains(t, body, "closes")364+ assert.Contains(t, body, "resolves")
362 assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")365 assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363 assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")366 assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364 assert.Contains(t, body, `title="merge request 7"`)367 assert.Contains(t, body, `title="merge request 7"`)
@@ -373,5 +376,91 @@ func TestIssueShowWithoutReferences(t *testing.T) {
373 }, nil)376 }, nil)
374 body := r.do("GET", "/proj/issues/5", false, nil).Body.String()377 body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375 assert.NotContains(t, body, "r-refs")378 assert.NotContains(t, body, "r-refs")
376- assert.NotContains(t, body, "closed by")379+ assert.NotContains(t, body, "resolved by")
380+}
381+
382+func TestIssueResolvedList(t *testing.T) {
383+ t.Parallel()
384+ r := newIssueRig(t, domain.IssuesOwner, false)
385+ for _, token := range []string{"", "next"} {
386+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateResolved, PageSize: pageSize, PageToken: token}).Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 5, domain.IssueStateResolved, "fixed", "")}, NextPageToken: "next"}, nil)
387+ path := "/proj/issues?state=resolved"
388+ if token != "" {
389+ path += "&page=" + token
390+ }
391+ rec := r.do("GET", path, false, nil)
392+ require.Equal(t, 200, rec.Code)
393+ body := rec.Body.String()
394+ assert.Contains(t, body, `href="/proj/issues?state=resolved" aria-current="page"`)
395+ assert.Contains(t, body, `data-state="resolved"`)
396+ assert.Contains(t, body, `href="/proj/issues?page=next&amp;state=resolved"`)
397+ }
398+}
399+
400+func TestIssueResolvedTimeline(t *testing.T) {
401+ t.Parallel()
402+ r := newIssueRig(t, domain.IssuesOwner, false)
403+ at := time.Date(2026, 1, 2, 12, 0, 0, 123, time.UTC)
404+ issue := testIssue(t, 5, domain.IssueStateResolved, "fixed", "")
405+ issue.ClosedByMergeRequest = 12
406+ text, err := domain.ParseIssueBody("comment between events")
407+ require.NoError(t, err)
408+ title, err := domain.ParseMRTitle("Fix login")
409+ require.NoError(t, err)
410+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
411+ Issue: issue,
412+ Comments: []domain.IssueComment{{ID: 7, Author: "owner", Text: text, CreatedAt: at}},
413+ Events: []ports.IssueEventDetail{
414+ {Event: domain.IssueEvent{ID: 1, Kind: domain.IssueEventReferenced, MergeRequest: 12, Actor: "owner", CreatedAt: at.Add(-time.Second)}, MergeRequestTitle: title},
415+ {Event: domain.IssueEvent{ID: 2, Kind: domain.IssueEventResolved, MergeRequest: 12, Actor: "owner", CreatedAt: at}, MergeRequestTitle: title},
416+ },
417+ }, nil)
418+ for _, owner := range []bool{false, true} {
419+ rec := r.do("GET", "/proj/issues/5", owner, nil)
420+ require.Equal(t, 200, rec.Code)
421+ body := rec.Body.String()
422+ assert.Contains(t, body, `data-state="resolved"`)
423+ assert.Contains(t, body, "resolved by")
424+ assert.Contains(t, body, "!12 Fix login")
425+ assert.Contains(t, body, `datetime="2026-01-02T12:00:00Z"`)
426+ assert.Less(t, strings.Index(body, `id="e1"`), strings.Index(body, `id="c7"`))
427+ assert.Less(t, strings.Index(body, `id="c7"`), strings.Index(body, `id="e2"`), "comment precedes event on exact timestamp tie")
428+ assert.NotContains(t, body, `/5/resolve`)
429+ assert.NotContains(t, body, `/5/close`)
430+ if owner {
431+ assert.Contains(t, body, `action="/proj/issues/5/reopen"`)
432+ } else {
433+ assert.NotContains(t, body, `/5/reopen`)
434+ }
435+ }
436+}
437+
438+func TestIssueHistoryEventKinds(t *testing.T) {
439+ t.Parallel()
440+ s := &Server{cfg: Config{Now: time.Now}}
441+ for _, tc := range []struct {
442+ kind domain.IssueEventKind
443+ mr int64
444+ closes bool
445+ text string
446+ }{
447+ {domain.IssueEventReferenced, 12, false, "referenced this in"},
448+ {domain.IssueEventReferenced, 12, true, "will resolve this in"},
449+ {domain.IssueEventClosed, 0, false, "closed this"},
450+ {domain.IssueEventReopened, 0, false, "reopened this"},
451+ {domain.IssueEventResolved, 0, false, "resolved this"},
452+ {domain.IssueEventResolved, 12, false, "resolved this with"},
453+ } {
454+ items := s.issueTimeline("proj", nil, []ports.IssueEventDetail{{Event: domain.IssueEvent{ID: 1, Kind: tc.kind, MergeRequest: tc.mr, Closes: tc.closes, Actor: "owner", CreatedAt: time.Now()}}}, false)
455+ require.Len(t, items, 1)
456+ body := renderToString(t, issueTimeline(items))
457+ assert.Contains(t, body, "owner "+tc.text)
458+ assert.Contains(t, body, `data-kind="`+string(tc.kind)+`"`)
459+ assert.Contains(t, body, `<ol class="timeline" aria-label="Conversation history">`)
460+ if tc.mr != 0 {
461+ assert.Contains(t, body, `href="/proj/merge-requests/12">!12</a>`)
462+ } else {
463+ assert.NotContains(t, body, `/merge-requests/`)
464+ }
465+ }
377 }466 }
M internal/adapters/web/mr.go +22 -1
@@ -99,6 +99,7 @@ type MRListPage struct {
9999
100100 // CommentView is a comment of a merge request, ready to show.
101101 type CommentView struct {
102+ createdAt time.Time
102103 ID string
103104 Author string
104105 When string
@@ -296,6 +297,8 @@ type MRPage struct {
296297 // loaded later) the loose comments of a listed file go under the file
297298 // instead (DiffFile.Loose).
298299 General, Loose []CommentView
300+ Timeline []TimelineItem
301+ Issues []IssueLinkView
299302 LooseInFiles bool
300303 NComments int
301304 NUnresolved int
@@ -634,6 +637,24 @@ func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragmen
634637 on = parseOn(r.URL.Query().Get("on"))
635638 }
636639 s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
640+ p.Timeline = s.issueTimeline(d.name(), p.General, res.IssueEvents, true)
641+ // Event DTOs carry MR titles, not issue titles. Enrich from current links
642+ // when available; removed references still retain a numbered history link.
643+ issueTitles := make(map[string]string, len(res.Issues))
644+ for _, issue := range res.Issues {
645+ issueTitles[issueHref(d.name(), issue.Number)] = visibleText(issue.Title.String())
646+ }
647+ for _, item := range p.Timeline {
648+ if item.Event != nil && item.Event.MR != nil {
649+ if title := issueTitles[item.Event.MR.Href]; title != "" {
650+ item.Event.MR.Label += " " + title
651+ }
652+ }
653+ }
654+ for _, issue := range res.Issues {
655+ p.Issues = append(p.Issues, IssueLinkView{Number: "#" + strconv.FormatInt(issue.Number, 10), Title: visibleText(issue.Title.String()), State: string(issue.State), Href: issueHref(d.name(), issue.Number), Closes: issue.Closes})
656+ }
657+
637658 lines, invisible := diffLines(p.Files)
638659 p.Invisible = invisible
639660 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -1010,7 +1031,7 @@ func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n in
10101031 if err != nil {
10111032 bodySplit = ""
10121033 }
1013- v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
1034+ v := CommentView{createdAt: c.CreatedAt, BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
10141035 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
10151036 if !c.CreatedAt.IsZero() {
10161037 v.Abs = absolute(c.CreatedAt)
@@ -99,6 +99,7 @@ type MRListPage struct {
99 99
100 // CommentView is a comment of a merge request, ready to show.100 // CommentView is a comment of a merge request, ready to show.
101 type CommentView struct {101 type CommentView struct {
102+ createdAt time.Time
102 ID string103 ID string
103 Author string104 Author string
104 When string105 When string
@@ -296,6 +297,8 @@ type MRPage struct {
296 // loaded later) the loose comments of a listed file go under the file297 // loaded later) the loose comments of a listed file go under the file
297 // instead (DiffFile.Loose).298 // instead (DiffFile.Loose).
298 General, Loose []CommentView299 General, Loose []CommentView
300+ Timeline []TimelineItem
301+ Issues []IssueLinkView
299 LooseInFiles bool302 LooseInFiles bool
300 NComments int303 NComments int
301 NUnresolved int304 NUnresolved int
@@ -634,6 +637,24 @@ func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragmen
634 on = parseOn(r.URL.Query().Get("on"))637 on = parseOn(r.URL.Query().Get("on"))
635 }638 }
636 s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)639 s.placeComments(ctx, &p, d.name(), n, res.Comments, head, !since, canWrite, on)
640+ p.Timeline = s.issueTimeline(d.name(), p.General, res.IssueEvents, true)
641+ // Event DTOs carry MR titles, not issue titles. Enrich from current links
642+ // when available; removed references still retain a numbered history link.
643+ issueTitles := make(map[string]string, len(res.Issues))
644+ for _, issue := range res.Issues {
645+ issueTitles[issueHref(d.name(), issue.Number)] = visibleText(issue.Title.String())
646+ }
647+ for _, item := range p.Timeline {
648+ if item.Event != nil && item.Event.MR != nil {
649+ if title := issueTitles[item.Event.MR.Href]; title != "" {
650+ item.Event.MR.Label += " " + title
651+ }
652+ }
653+ }
654+ for _, issue := range res.Issues {
655+ p.Issues = append(p.Issues, IssueLinkView{Number: "#" + strconv.FormatInt(issue.Number, 10), Title: visibleText(issue.Title.String()), State: string(issue.State), Href: issueHref(d.name(), issue.Number), Closes: issue.Closes})
656+ }
657+
637 lines, invisible := diffLines(p.Files)658 lines, invisible := diffLines(p.Files)
638 p.Invisible = invisible659 p.Invisible = invisible
639 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))660 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
@@ -1010,7 +1031,7 @@ func (s *Server) placeComments(ctx context.Context, p *MRPage, repo string, n in
1010 if err != nil {1031 if err != nil {
1011 bodySplit = ""1032 bodySplit = ""
1012 }1033 }
1013- v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}1034+ v := CommentView{createdAt: c.CreatedAt, BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
1014 v.When, v.WhenAt = whenOf(now, c.CreatedAt)1035 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
1015 if !c.CreatedAt.IsZero() {1036 if !c.CreatedAt.IsZero() {
1016 v.Abs = absolute(c.CreatedAt)1037 v.Abs = absolute(c.CreatedAt)
M internal/adapters/web/mr.templ +21 -7
@@ -214,9 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
214214 for _, c := range p.Loose {
215215 @commentView(c, "")
216216 }
217- for _, c := range p.General {
218- @commentView(c, "")
219- }
217+ @issueTimeline(p.Timeline)
220218 if p.NComments == 0 {
221219 <p class="dim">no comments yet.</p>
222220 }
@@ -267,7 +265,8 @@ templ commentView(c CommentView, suffix string) {
267265 }
268266 </p>
269267 if len(c.Parts) > 0 {
270- <p class="c-text">@userText(c.Parts)</p>
268+ <p class="c-text">@userText(c.Parts)
269+</p>
271270 } else if c.Deleted {
272271 if c.Verdict != "" {
273272 <p class="mute">text deleted</p>
@@ -428,6 +427,21 @@ templ mrFooter(p MRPage) {
428427 }
429428
430429 templ mrRail(p MRPage) {
430+ if len(p.Issues) > 0 {
431+ <section aria-labelledby="r-issues">
432+ <h2 id="r-issues">Issues</h2>
433+ <ul>
434+ for _, i := range p.Issues {
435+ <li>
436+ <a class="ref" href={ templ.SafeURL(i.Href) }>{ i.Number } { i.Title }</a>{ " " }<span class="mr-state" data-state={ i.State }>{ i.State }</span>
437+ if i.Closes {
438+ { " · resolves" }
439+ }
440+ </li>
441+ }
442+ </ul>
443+ </section>
444+ }
431445 <section aria-labelledby="r-files" class="r-files">
432446 <h2 id="r-files">
433447 Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd>
@@ -808,7 +822,7 @@ templ mrActions(p MRPage, fragment bool) {
808822
809823 // MRActionStatus remains visible even when the request detail cannot be read.
810824 templ MRActionStatus(message, href string) {
811- <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812- <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813- </div>
825+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
826+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
827+ </div>
814828 }
@@ -214,9 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
214 for _, c := range p.Loose {214 for _, c := range p.Loose {
215 @commentView(c, "")215 @commentView(c, "")
216 }216 }
217- for _, c := range p.General {217+ @issueTimeline(p.Timeline)
218- @commentView(c, "")
219- }
220 if p.NComments == 0 {218 if p.NComments == 0 {
221 <p class="dim">no comments yet.</p>219 <p class="dim">no comments yet.</p>
222 }220 }
@@ -267,7 +265,8 @@ templ commentView(c CommentView, suffix string) {
267 }265 }
268 </p>266 </p>
269 if len(c.Parts) > 0 {267 if len(c.Parts) > 0 {
270- <p class="c-text">@userText(c.Parts)</p>268+ <p class="c-text">@userText(c.Parts)
269+</p>
271 } else if c.Deleted {270 } else if c.Deleted {
272 if c.Verdict != "" {271 if c.Verdict != "" {
273 <p class="mute">text deleted</p>272 <p class="mute">text deleted</p>
@@ -428,6 +427,21 @@ templ mrFooter(p MRPage) {
428 }427 }
429 428
430 templ mrRail(p MRPage) {429 templ mrRail(p MRPage) {
430+ if len(p.Issues) > 0 {
431+ <section aria-labelledby="r-issues">
432+ <h2 id="r-issues">Issues</h2>
433+ <ul>
434+ for _, i := range p.Issues {
435+ <li>
436+ <a class="ref" href={ templ.SafeURL(i.Href) }>{ i.Number } { i.Title }</a>{ " " }<span class="mr-state" data-state={ i.State }>{ i.State }</span>
437+ if i.Closes {
438+ { " · resolves" }
439+ }
440+ </li>
441+ }
442+ </ul>
443+ </section>
444+ }
431 <section aria-labelledby="r-files" class="r-files">445 <section aria-labelledby="r-files" class="r-files">
432 <h2 id="r-files">446 <h2 id="r-files">
433 Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd>447 Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd>
@@ -808,7 +822,7 @@ templ mrActions(p MRPage, fragment bool) {
808 822
809 // MRActionStatus remains visible even when the request detail cannot be read.823 // MRActionStatus remains visible even when the request detail cannot be read.
810 templ MRActionStatus(message, href string) {824 templ MRActionStatus(message, href string) {
811- <div id="mr-actions" class="mr-acts" hx-swap-oob="true">825+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812- <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>826+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813- </div>827+ </div>
814 }828 }
M internal/adapters/web/mr_templ.go +725 -618 generated file: collapsed
M internal/adapters/web/mr_test.go +35 -0
@@ -332,3 +332,38 @@ func TestMergeIgnoreChecks(t *testing.T) {
332332 assert.Equal(t, mrHref("demo", 3), to)
333333 }
334334 }
335+
336+func TestMRLinkedIssuesAndHistory(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesOwner, false)
339+ at := time.Date(2026, 1, 2, 12, 0, 0, 123, time.UTC)
340+ title, err := domain.ParseIssueTitle("Fix <login>")
341+ require.NoError(t, err)
342+ text, err := domain.ParsePlainText("between issue events")
343+ require.NoError(t, err)
344+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(12), mock.Anything).Return(ports.MergeRequestDetail{
345+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "proj"), Number: 12, State: domain.MRStateMerged, Source: "feature", Target: "main"},
346+ Issues: []ports.MergeRequestIssue{{Number: 3, Title: title, State: domain.IssueStateResolved, Closes: true}, {Number: 4, Title: title, State: domain.IssueStateOpen}},
347+ Comments: []domain.Comment{{ID: 7, Author: "owner", Text: text, CreatedAt: at}},
348+ IssueEvents: []ports.IssueEventDetail{
349+ {Event: domain.IssueEvent{ID: 1, Issue: 3, Kind: domain.IssueEventReferenced, Actor: "owner", CreatedAt: at.Add(-time.Second)}},
350+ {Event: domain.IssueEvent{ID: 2, Issue: 3, Kind: domain.IssueEventReferenced, Closes: true, Actor: "owner", CreatedAt: at}},
351+ {Event: domain.IssueEvent{ID: 3, Issue: 3, Kind: domain.IssueEventResolved, Actor: "owner", CreatedAt: at.Add(time.Second)}},
352+ {Event: domain.IssueEvent{ID: 1, Issue: 9, Kind: domain.IssueEventReferenced, Actor: "owner", CreatedAt: at.Add(2 * time.Second)}},
353+ },
354+ }, nil)
355+ rec := r.do("GET", "/proj/merge-requests/12", false, nil)
356+ require.Equal(t, 200, rec.Code, rec.Body.String())
357+ body := rec.Body.String()
358+ assert.Contains(t, body, `id="r-issues">Issues`)
359+ assert.Contains(t, body, `href="/proj/issues/3">#3 Fix &lt;login&gt;</a>`)
360+ assert.Contains(t, body, `data-state="resolved"`)
361+ assert.Contains(t, body, " · resolves")
362+ assert.Contains(t, body, "owner referenced")
363+ assert.Contains(t, body, "owner will resolve")
364+ assert.Contains(t, body, "owner resolved")
365+ assert.Contains(t, body, `href="/proj/issues/9">#9</a>`, "removed links keep their history")
366+ assert.Less(t, strings.Index(body, `id="e3-1"`), strings.Index(body, `id="c7"`))
367+ assert.Less(t, strings.Index(body, `id="c7"`), strings.Index(body, `id="e3-2"`))
368+ assert.Less(t, strings.Index(body, `id="e3-2"`), strings.Index(body, `id="e3-3"`))
369+}
@@ -332,3 +332,38 @@ func TestMergeIgnoreChecks(t *testing.T) {
332 assert.Equal(t, mrHref("demo", 3), to)332 assert.Equal(t, mrHref("demo", 3), to)
333 }333 }
334 }334 }
335+
336+func TestMRLinkedIssuesAndHistory(t *testing.T) {
337+ t.Parallel()
338+ r := newIssueRig(t, domain.IssuesOwner, false)
339+ at := time.Date(2026, 1, 2, 12, 0, 0, 123, time.UTC)
340+ title, err := domain.ParseIssueTitle("Fix <login>")
341+ require.NoError(t, err)
342+ text, err := domain.ParsePlainText("between issue events")
343+ require.NoError(t, err)
344+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(12), mock.Anything).Return(ports.MergeRequestDetail{
345+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "proj"), Number: 12, State: domain.MRStateMerged, Source: "feature", Target: "main"},
346+ Issues: []ports.MergeRequestIssue{{Number: 3, Title: title, State: domain.IssueStateResolved, Closes: true}, {Number: 4, Title: title, State: domain.IssueStateOpen}},
347+ Comments: []domain.Comment{{ID: 7, Author: "owner", Text: text, CreatedAt: at}},
348+ IssueEvents: []ports.IssueEventDetail{
349+ {Event: domain.IssueEvent{ID: 1, Issue: 3, Kind: domain.IssueEventReferenced, Actor: "owner", CreatedAt: at.Add(-time.Second)}},
350+ {Event: domain.IssueEvent{ID: 2, Issue: 3, Kind: domain.IssueEventReferenced, Closes: true, Actor: "owner", CreatedAt: at}},
351+ {Event: domain.IssueEvent{ID: 3, Issue: 3, Kind: domain.IssueEventResolved, Actor: "owner", CreatedAt: at.Add(time.Second)}},
352+ {Event: domain.IssueEvent{ID: 1, Issue: 9, Kind: domain.IssueEventReferenced, Actor: "owner", CreatedAt: at.Add(2 * time.Second)}},
353+ },
354+ }, nil)
355+ rec := r.do("GET", "/proj/merge-requests/12", false, nil)
356+ require.Equal(t, 200, rec.Code, rec.Body.String())
357+ body := rec.Body.String()
358+ assert.Contains(t, body, `id="r-issues">Issues`)
359+ assert.Contains(t, body, `href="/proj/issues/3">#3 Fix &lt;login&gt;</a>`)
360+ assert.Contains(t, body, `data-state="resolved"`)
361+ assert.Contains(t, body, " · resolves")
362+ assert.Contains(t, body, "owner referenced")
363+ assert.Contains(t, body, "owner will resolve")
364+ assert.Contains(t, body, "owner resolved")
365+ assert.Contains(t, body, `href="/proj/issues/9">#9</a>`, "removed links keep their history")
366+ assert.Less(t, strings.Index(body, `id="e3-1"`), strings.Index(body, `id="c7"`))
367+ assert.Less(t, strings.Index(body, `id="c7"`), strings.Index(body, `id="e3-2"`))
368+ assert.Less(t, strings.Index(body, `id="e3-2"`), strings.Index(body, `id="e3-3"`))
369+}
M internal/adapters/web/static/css/60-mr.css +16 -0
@@ -1,4 +1,12 @@
11 @layer components {
2+ .timeline { list-style: none; padding: 0; margin: 0; }
3+ .sys { margin-block: var(--s3); padding: var(--s2) var(--s3); border-left: 2px solid var(--border); color: var(--ink-dim); font-size: var(--fs-s); overflow-wrap: anywhere; }
4+ .sys-icon { margin-inline-end: var(--s2); }
5+ .sys[data-kind="resolved"] .sys-icon { color: var(--teal); }
6+ .sys[data-kind="closed"] .sys-icon { color: var(--red); }
7+ .mr-state[data-state="resolved"]::before { content: "✓ "; color: var(--teal); }
8+ .mrs tr[data-state="resolved"] .c-state::before { content: "✓ "; color: var(--teal); }
9+
210 /* merge requests: the review page. Hue only where it carries a verdict. */
311 .mr-h .show-kv { margin-top: 0; }
412 #merge-menu .htmx-indicator { display: none; }
@@ -281,6 +289,14 @@
281289 }
282290
283291 @layer components {
292+ .timeline { list-style: none; padding: 0; margin: 0; }
293+ .sys { margin-block: var(--s3); padding: var(--s2) var(--s3); border-left: 2px solid var(--border); color: var(--ink-dim); font-size: var(--fs-s); overflow-wrap: anywhere; }
294+ .sys-icon { margin-inline-end: var(--s2); }
295+ .sys[data-kind="resolved"] .sys-icon { color: var(--teal); }
296+ .sys[data-kind="closed"] .sys-icon { color: var(--red); }
297+ .mr-state[data-state="resolved"]::before { content: "✓ "; color: var(--teal); }
298+ .mrs tr[data-state="resolved"] .c-state::before { content: "✓ "; color: var(--teal); }
299+
284300 .c-body { white-space: normal; overflow-wrap: anywhere; }
285301 .c-body.prose > * { margin-block: .5em; }
286302 .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
@@ -1,4 +1,12 @@
1 @layer components {1 @layer components {
2+ .timeline { list-style: none; padding: 0; margin: 0; }
3+ .sys { margin-block: var(--s3); padding: var(--s2) var(--s3); border-left: 2px solid var(--border); color: var(--ink-dim); font-size: var(--fs-s); overflow-wrap: anywhere; }
4+ .sys-icon { margin-inline-end: var(--s2); }
5+ .sys[data-kind="resolved"] .sys-icon { color: var(--teal); }
6+ .sys[data-kind="closed"] .sys-icon { color: var(--red); }
7+ .mr-state[data-state="resolved"]::before { content: "✓ "; color: var(--teal); }
8+ .mrs tr[data-state="resolved"] .c-state::before { content: "✓ "; color: var(--teal); }
9+
2 /* merge requests: the review page. Hue only where it carries a verdict. */10 /* merge requests: the review page. Hue only where it carries a verdict. */
3 .mr-h .show-kv { margin-top: 0; }11 .mr-h .show-kv { margin-top: 0; }
4 #merge-menu .htmx-indicator { display: none; }12 #merge-menu .htmx-indicator { display: none; }
@@ -281,6 +289,14 @@
281 }289 }
282 290
283 @layer components {291 @layer components {
292+ .timeline { list-style: none; padding: 0; margin: 0; }
293+ .sys { margin-block: var(--s3); padding: var(--s2) var(--s3); border-left: 2px solid var(--border); color: var(--ink-dim); font-size: var(--fs-s); overflow-wrap: anywhere; }
294+ .sys-icon { margin-inline-end: var(--s2); }
295+ .sys[data-kind="resolved"] .sys-icon { color: var(--teal); }
296+ .sys[data-kind="closed"] .sys-icon { color: var(--red); }
297+ .mr-state[data-state="resolved"]::before { content: "✓ "; color: var(--teal); }
298+ .mrs tr[data-state="resolved"] .c-state::before { content: "✓ "; color: var(--teal); }
299+
284 .c-body { white-space: normal; overflow-wrap: anywhere; }300 .c-body { white-space: normal; overflow-wrap: anywhere; }
285 .c-body.prose > * { margin-block: .5em; }301 .c-body.prose > * { margin-block: .5em; }
286 .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }302 .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close