Commit 35cb93f6f1
35cb93f6f17f984193c93df7ff9278f5bb662ffa
parent: 542b37e732
Verified · cmc
cmc <hello@cleberg.net> · 2026-08-24T23:06:53Z
Commit-reference entries become system messages with linked shas
Per cmc's feedback on the #31 behavior seen in #10: automated
close/reference entries were rendering as comments from the pusher.
issue_comments and mr_comments gain a kind column (migration 0019);
commit-message actions now write kind=system entries whose author reads
'system' everywhere (the acting user is kept in the row and the audit
log for provenance), the sha is a markdown link to the commit page, and
the web renders them as muted informational lines instead of comment
cards. Comment author names on issue/MR pages are links now too.
e2e/commitrefs_test.go
+8
| @@ -42,6 +42,14 @@ func TestCommitMessageIssueActions(t *testing.T) { |
| 42 | 42 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { |
| 43 | 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 | 53 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") |
| 46 | 54 | if !strings.Contains(out, `"state":"open"`) || !strings.Contains(out, "referenced in commit") || |
| 47 | 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 | 69 | if len(short) > 10 { |
| 70 | 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 | 75 | if close && issue.State == "open" { |
| 73 | 76 | if err := st.SetIssueState(issue.ID, "closed"); err != nil { |
| 74 | 77 | slog.Error("commit refs: closing issue", "issue", number, "err", err) |
| 75 | 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 | 81 | st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) |
| 79 | 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. |
| 815 | 815 | type renderedComment struct { |
| 816 | 816 | Author string |
| 817 | 817 | CreatedAt string |
| 818 | Kind string |
| 818 | 819 | BodyHTML template.HTML |
| 819 | 820 | } |
| 820 | 821 | |
| 821 | 822 | func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment { |
| 822 | 823 | var out []renderedComment |
| 823 | 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 | 827 | return out |
| 827 | 828 | } |
| @@ -930,11 +931,11 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin |
| 930 | 931 | for _, cm := range comments { |
| 931 | 932 | if cm.ReplyTo == 0 { |
| 932 | 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 | 935 | anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} |
| 935 | 936 | order = append(order, cm.ID) |
| 936 | 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 | 941 | placed := map[int64]bool{} |
internal/store/issues.go
+14 −2
| @@ -25,6 +25,7 @@ type IssueComment struct { |
| 25 | 25 | Author string |
| 26 | 26 | Body string |
| 27 | 27 | CreatedAt string |
| 28 | Kind string // comment | system |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | 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 | |
| 156 | 157 | func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { |
| 157 | 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 | 161 | FROM issue_comments c JOIN users u ON u.id = c.author_id |
| 160 | 162 | WHERE c.issue_id = ? ORDER BY c.id`, issueID) |
| 161 | 163 | if err != nil { |
| @@ -165,7 +167,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { |
| 165 | 167 | var out []IssueComment |
| 166 | 168 | for rows.Next() { |
| 167 | 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 | 171 | return nil, err |
| 170 | 172 | } |
| 171 | 173 | out = append(out, c) |
| @@ -173,6 +175,16 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { |
| 173 | 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. |
| 181 | func (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 | 188 | // ListIssueLabels returns the label names attached to each issue of a |
| 177 | 189 | // repo, keyed by issue id. Used by the web issue listing; ListIssues |
| 178 | 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 @@ |
| 1 | ALTER TABLE issue_comments DROP COLUMN kind; |
| 2 | ALTER TABLE mr_comments DROP COLUMN kind; |
internal/store/migrations/0019_comment_kind.up.sql
added
+2
| @@ -0,0 +1,2 @@ |
| 1 | ALTER TABLE issue_comments ADD COLUMN kind TEXT NOT NULL DEFAULT 'comment'; |
| 2 | ALTER 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 | 185 | return err |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | // AddMRSystemComment is the informational counterpart of AddMRComment. |
| 189 | func (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 | |
| 188 | 196 | func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { |
| 189 | 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 | 200 | FROM mr_comments c JOIN users u ON u.id = c.author_id |
| 192 | 201 | WHERE c.mr_id = ? ORDER BY c.id`, mrID) |
| 193 | 202 | if err != nil { |
| @@ -197,7 +206,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { |
| 197 | 206 | var out []IssueComment |
| 198 | 207 | for rows.Next() { |
| 199 | 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 | 210 | return nil, err |
| 202 | 211 | } |
| 203 | 212 | out = append(out, c) |
internal/web/static/style.css
+9
| @@ -616,6 +616,15 @@ article.comment .rendered { padding: 0 var(--sp-4); } |
| 616 | 616 | article.comment .rendered > :first-child { margin-top: var(--sp-3); } |
| 617 | 617 | article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } |
| 618 | 618 | form.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 | 629 | /* review threads under diff lines */ |
| 621 | 630 | .thread { |
internal/web/templates/issue.html
+4 −3
| @@ -12,10 +12,11 @@ |
| 12 | 12 | <div class="rendered">{{.BodyHTML}}</div> |
| 13 | 13 | </article>{{end}} |
| 14 | 14 | {{range .Comments}} |
| 15 | | <article class="comment"> |
| 16 | | <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 15 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 16 | {{else}}<article class="comment"> |
| 17 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 17 | 18 | <div class="rendered">{{.BodyHTML}}</div> |
| 18 | | </article> |
| 19 | </article>{{end}} |
| 19 | 20 | {{end}} |
| 20 | 21 | {{if .Viewer}} |
| 21 | 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 | 15 | </div>{{end}} |
| 16 | 16 | {{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}} |
| 17 | 17 | {{range .Comments}} |
| 18 | | <article class="comment"> |
| 19 | | <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 18 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 19 | {{else}}<article class="comment"> |
| 20 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 20 | 21 | <div class="rendered">{{.BodyHTML}}</div> |
| 21 | | </article> |
| 22 | </article>{{end}} |
| 22 | 23 | {{end}} |
| 23 | 24 | {{if .Viewer}} |
| 24 | 25 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> |