System messages for commit references (#10) !22

merged merged by cmc on 2026-08-24 23:07 UTC · krz/gitbay:system-comments into main

10 files changed, +63 −15

Layout: unified · split

e2e/commitrefs_test.go +8
@@ -42,6 +42,14 @@ func TestCommitMessageIssueActions(t *testing.T) {
42 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { 42 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") {
43 t.Fatalf("issue 1 not closed by commit: %s", out) 43 t.Fatalf("issue 1 not closed by commit: %s", out)
44 } 44 }
45 // The entry is a system message with a linked sha, not a user comment.
46 if !strings.Contains(out, `"author":"system"`) || !strings.Contains(out, "](/alice/app/commit/") {
47 t.Fatalf("close entry not a linked system message: %s", out)
48 }
49 if status, body := inst.get(t, "/alice/app/issues/1"); status != 200 ||
50 !strings.Contains(body, `class="syscomment"`) || !strings.Contains(body, `/alice/app/commit/`) {
51 t.Fatalf("web system message: %d", status)
52 }
45 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") 53 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json")
46 if !strings.Contains(out, `"state":"open"`) || !strings.Contains(out, "referenced in commit") || 54 if !strings.Contains(out, `"state":"open"`) || !strings.Contains(out, "referenced in commit") ||
47 !strings.Contains(out, "repair the widget") { 55 !strings.Contains(out, "repair the widget") {
internal/control/commitrefs.go +5 −2
@@ -69,14 +69,17 @@ func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, num
69 if len(short) > 10 { 69 if len(short) > 10 {
70 short = short[:10] 70 short = short[:10]
71 } 71 }
72 // Informational system entries, not comments from the pusher; the
73 // linked sha renders clickable on the web.
74 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, repo.Path(), sha)
72 if close && issue.State == "open" { 75 if close && issue.State == "open" {
73 if err := st.SetIssueState(issue.ID, "closed"); err != nil { 76 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
74 slog.Error("commit refs: closing issue", "issue", number, "err", err) 77 slog.Error("commit refs: closing issue", "issue", number, "err", err)
75 return 78 return
76 } 79 }
77 st.AddIssueComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", short, subject)) 80 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", link, subject))
78 st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) 81 st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
79 return 82 return
80 } 83 }
81 st.AddIssueComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", short, subject)) 84 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", link, subject))
82} 85}
internal/httpd/web.go +4 −3
@@ -815,13 +815,14 @@ func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.
815type renderedComment struct { 815type renderedComment struct {
816 Author string 816 Author string
817 CreatedAt string 817 CreatedAt string
818 Kind string
818 BodyHTML template.HTML 819 BodyHTML template.HTML
819} 820}
820 821
821func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment { 822func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
822 var out []renderedComment 823 var out []renderedComment
823 for _, c := range cs { 824 for _, c := range cs {
824 out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)}) 825 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
825 } 826 }
826 return out 827 return out
827} 828}
@@ -930,11 +931,11 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin
930 for _, cm := range comments { 931 for _, cm := range comments {
931 if cm.ReplyTo == 0 { 932 if cm.ReplyTo == 0 {
932 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA, 933 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
933 Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}} 934 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
934 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} 935 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
935 order = append(order, cm.ID) 936 order = append(order, cm.ID)
936 } else if th, ok := threads[cm.ReplyTo]; ok { 937 } else if th, ok := threads[cm.ReplyTo]; ok {
937 th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)}) 938 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
938 } 939 }
939 } 940 }
940 placed := map[int64]bool{} 941 placed := map[int64]bool{}
internal/store/issues.go +14 −2
@@ -25,6 +25,7 @@ type IssueComment struct {
25 Author string 25 Author string
26 Body string 26 Body string
27 CreatedAt string 27 CreatedAt string
28 Kind string // comment | system
28} 29}
29 30
30// CreateIssue allocates the per-repo number from the repo counter inside the 31// CreateIssue allocates the per-repo number from the repo counter inside the
@@ -155,7 +156,8 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
155 156
156func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { 157func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
157 rows, err := s.DB.Query(` 158 rows, err := s.DB.Query(`
158 SELECT u.username, c.body, c.created_at 159 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
160 c.body, c.created_at, c.kind
159 FROM issue_comments c JOIN users u ON u.id = c.author_id 161 FROM issue_comments c JOIN users u ON u.id = c.author_id
160 WHERE c.issue_id = ? ORDER BY c.id`, issueID) 162 WHERE c.issue_id = ? ORDER BY c.id`, issueID)
161 if err != nil { 163 if err != nil {
@@ -165,7 +167,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
165 var out []IssueComment 167 var out []IssueComment
166 for rows.Next() { 168 for rows.Next() {
167 var c IssueComment 169 var c IssueComment
168 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil { 170 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
169 return nil, err 171 return nil, err
170 } 172 }
171 out = append(out, c) 173 out = append(out, c)
@@ -173,6 +175,16 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
173 return out, rows.Err() 175 return out, rows.Err()
174} 176}
175 177
178// AddIssueSystemComment records an informational entry (commit references,
179// automated closes). The actor is kept for provenance but the entry
180// displays as coming from the system, not the user.
181func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error {
182 _, err := s.DB.Exec(
183 "INSERT INTO issue_comments (issue_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
184 issueID, actorID, body)
185 return err
186}
187
176// ListIssueLabels returns the label names attached to each issue of a 188// ListIssueLabels returns the label names attached to each issue of a
177// repo, keyed by issue id. Used by the web issue listing; ListIssues 189// repo, keyed by issue id. Used by the web issue listing; ListIssues
178// itself stays label-free for the CLI's lean list output. 190// itself stays label-free for the CLI's lean list output.
internal/store/migrations/0019_comment_kind.down.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE issue_comments DROP COLUMN kind;
2ALTER TABLE mr_comments DROP COLUMN kind;
internal/store/migrations/0019_comment_kind.up.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE issue_comments ADD COLUMN kind TEXT NOT NULL DEFAULT 'comment';
2ALTER TABLE mr_comments ADD COLUMN kind TEXT NOT NULL DEFAULT 'comment';
internal/store/mrs.go +11 −2
@@ -185,9 +185,18 @@ func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
185 return err 185 return err
186} 186}
187 187
188// AddMRSystemComment is the informational counterpart of AddMRComment.
189func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
190 _, err := s.DB.Exec(
191 "INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
192 mrID, actorID, body)
193 return err
194}
195
188func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { 196func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
189 rows, err := s.DB.Query(` 197 rows, err := s.DB.Query(`
190 SELECT u.username, c.body, c.created_at 198 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
199 c.body, c.created_at, c.kind
191 FROM mr_comments c JOIN users u ON u.id = c.author_id 200 FROM mr_comments c JOIN users u ON u.id = c.author_id
192 WHERE c.mr_id = ? ORDER BY c.id`, mrID) 201 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
193 if err != nil { 202 if err != nil {
@@ -197,7 +206,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
197 var out []IssueComment 206 var out []IssueComment
198 for rows.Next() { 207 for rows.Next() {
199 var c IssueComment 208 var c IssueComment
200 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil { 209 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
201 return nil, err 210 return nil, err
202 } 211 }
203 out = append(out, c) 212 out = append(out, c)
internal/web/static/style.css +9
@@ -616,6 +616,15 @@ article.comment .rendered { padding: 0 var(--sp-4); }
616article.comment .rendered > :first-child { margin-top: var(--sp-3); } 616article.comment .rendered > :first-child { margin-top: var(--sp-3); }
617article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } 617article.comment .rendered > :last-child { margin-bottom: var(--sp-3); }
618form.commentform { margin: var(--sp-4) 0; } 618form.commentform { margin: var(--sp-4) 0; }
619.syscomment {
620 max-width: 48rem;
621 color: var(--muted);
622 font-size: var(--fs-2);
623 margin: var(--sp-3) 0;
624 padding-left: var(--sp-4);
625 border-left: 3px solid var(--faint);
626}
627.syscomment p { display: inline; margin: 0; }
619 628
620/* review threads under diff lines */ 629/* review threads under diff lines */
621.thread { 630.thread {
internal/web/templates/issue.html +4 −3
@@ -12,10 +12,11 @@
12 <div class="rendered">{{.BodyHTML}}</div> 12 <div class="rendered">{{.BodyHTML}}</div>
13</article>{{end}} 13</article>{{end}}
14{{range .Comments}} 14{{range .Comments}}
15<article class="comment"> 15{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
16 <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> 16{{else}}<article class="comment">
17 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
17 <div class="rendered">{{.BodyHTML}}</div> 18 <div class="rendered">{{.BodyHTML}}</div>
18</article> 19</article>{{end}}
19{{end}} 20{{end}}
20{{if .Viewer}} 21{{if .Viewer}}
21<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> 22<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
internal/web/templates/mr.html +4 −3
@@ -15,10 +15,11 @@
15</div>{{end}} 15</div>{{end}}
16{{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}} 16{{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}}
17{{range .Comments}} 17{{range .Comments}}
18<article class="comment"> 18{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
19 <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> 19{{else}}<article class="comment">
20 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
20 <div class="rendered">{{.BodyHTML}}</div> 21 <div class="rendered">{{.BodyHTML}}</div>
21</article> 22</article>{{end}}
22{{end}} 23{{end}}
23{{if .Viewer}} 24{{if .Viewer}}
24<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> 25<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform">