Commit 68ae423bf6

68ae423bf66445f448ecb0968823369731fab716

parent: 7670a30c1b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28 17:34 UTC

mrs: closing keywords work in the description too

A "Closes #N" in a merge request body closed nothing — only commit
messages were scanned — so the intent had to be written twice to take
effect once.

The description is scanned after the landed commits and shares their
dedup key, so an issue named in both is closed once, attributed to the
commit.

Layout: unified · split

internal/control/commitrefs.go +46 −4
@@ -35,10 +35,8 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
35 } 35 }
36 for _, m := range msgs { 36 for _, m := range msgs {
37 closes := map[int64]bool{} 37 closes := map[int64]bool{}
38 for _, g := range closePat.FindAllStringSubmatch(m.Message, -1) { 38 for _, n := range closingRefs(m.Message) {
39 if n, err := strconv.ParseInt(g[1], 10, 64); err == nil { 39 closes[n] = true
40 closes[n] = true
41 }
42 } 40 }
43 refs := map[int64]bool{} 41 refs := map[int64]bool{}
44 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) { 42 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
@@ -57,6 +55,50 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
57 } 55 }
58} 56}
59 57
58// ProcessMRDescription acts on closing keywords in a merged merge
59// request's title and body. Commit messages remain the primary record —
60// they are what lands — but the intent is written in the merge request
61// just as often, and a "Closes #N" there used to close nothing.
62//
63// The dedup key is the merged sha, shared with ProcessCommitMessages, so
64// an issue named in both a commit and the description is acted on once.
65func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64, sha string) {
66 for _, n := range closingRefs(mr.Title + "\n" + mr.Body) {
67 issue, err := st.IssueByNumber(repo.ID, n)
68 if err != nil || issue.State != "open" {
69 continue // no such issue, or a commit already closed it
70 }
71 fresh, err := st.TryRecordCommitRef(issue.ID, sha)
72 if err != nil || !fresh {
73 continue
74 }
75 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
76 slog.Error("mr refs: closing issue", "issue", n, "err", err)
77 continue
78 }
79 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
80 st.AddIssueSystemComment(issue.ID, actorID,
81 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
82 st.RecordEvent(repo.ID, actorID, "issue.closed",
83 fmt.Sprintf(`{"number":%d,"mr":%d}`, n, mr.Number))
84 }
85}
86
87// closingRefs returns the issue numbers a text closes, in no order.
88func closingRefs(text string) []int64 {
89 seen := map[int64]bool{}
90 var out []int64
91 for _, g := range closePat.FindAllStringSubmatch(text, -1) {
92 n, err := strconv.ParseInt(g[1], 10, 64)
93 if err != nil || seen[n] {
94 continue
95 }
96 seen[n] = true
97 out = append(out, n)
98 }
99 return out
100}
101
60// RecordLandedCommits attributes commits that just landed on the default 102// RecordLandedCommits attributes commits that just landed on the default
61// branch to accounts by verified author email, for the activity graph. 103// branch to accounts by verified author email, for the activity graph.
62// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable 104// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
internal/control/commitrefs_test.go added +33
@@ -0,0 +1,33 @@
1package control
2
3import (
4 "slices"
5 "testing"
6)
7
8// The same keyword set has to work wherever the intent is written: a
9// commit message, or a merge request title or body.
10func TestClosingRefs(t *testing.T) {
11 for _, tc := range []struct {
12 name string
13 text string
14 want []int64
15 }{
16 {"closes", "Closes #50", []int64{50}},
17 {"lowercase and fix", "fixes #7", []int64{7}},
18 {"resolved", "resolved: #12", []int64{12}},
19 {"several", "Closes #1\n\nAlso fixes #2 and resolves #3", []int64{1, 2, 3}},
20 {"repeats collapse", "closes #4, closes #4", []int64{4}},
21 {"bare references do not close", "see #9 for context", nil},
22 {"cross-repo stays display-only", "closes krz/other#3", nil},
23 {"keyword must be its own word", "unclosed #5", nil},
24 } {
25 t.Run(tc.name, func(t *testing.T) {
26 got := closingRefs(tc.text)
27 slices.Sort(got)
28 if !slices.Equal(got, tc.want) {
29 t.Errorf("closingRefs(%q) = %v, want %v", tc.text, got, tc.want)
30 }
31 })
32 }
33}
internal/control/identity.go +4 −4
@@ -13,14 +13,14 @@ import (
13 13
14func init() { 14func init() {
15 register(Command{ 15 register(Command{
16 Path: []string{"whoami"}, 16 Path: []string{"whoami"},
17 Summary: "show the authenticated account", 17 Summary: "show the authenticated account",
18 ReadOnly: true, 18 ReadOnly: true,
19 Run: runWhoami, 19 Run: runWhoami,
20 }) 20 })
21 register(Command{ 21 register(Command{
22 Path: []string{"keys", "list"}, 22 Path: []string{"keys", "list"},
23 Summary: "list registered SSH keys", 23 Summary: "list registered SSH keys",
24 ReadOnly: true, 24 ReadOnly: true,
25 Run: runKeysList, 25 Run: runKeysList,
26 }) 26 })
internal/control/mr.go +4 −1
@@ -889,9 +889,12 @@ func runMRMerge(c *Ctx, args []string) int {
889 } 889 }
890 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) 890 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
891 // Merges bypass receive-pack, so the commit-message issue actions 891 // Merges bypass receive-pack, so the commit-message issue actions
892 // (closes #N, references) run here for the newly landed commits. 892 // (closes #N, references) run here for the newly landed commits. The
893 // description is scanned after them, so a commit wins the attribution
894 // when both name the same issue.
893 if mr.TargetRef == repo.DefaultBranch { 895 if mr.TargetRef == repo.DefaultBranch {
894 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) 896 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
897 ProcessMRDescription(c.Store, repo, mr, c.User.ID, newSHA)
895 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) 898 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
896 } 899 }
897 c.Store.MarkMirrorsDirty(repo.ID, "push") 900 c.Store.MarkMirrorsDirty(repo.ID, "push")
internal/control/register.go −2
@@ -184,5 +184,3 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user
184 return "", "registration is closed on this instance", protocol.ExitDenied 184 return "", "registration is closed on this instance", protocol.ExitDenied
185 } 185 }
186} 186}
187
188
internal/control/release.go +4 −4
@@ -19,10 +19,10 @@ import (
19 19
20func init() { 20func init() {
21 register(Command{Path: []string{"release", "create"}, 21 register(Command{Path: []string{"release", "create"},
22 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", 22 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
23 ReadsStdin: true, Run: runReleaseCreate}) 23 ReadsStdin: true, Run: runReleaseCreate})
24 register(Command{Path: []string{"release", "edit"}, 24 register(Command{Path: []string{"release", "edit"},
25 Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", 25 Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
26 ReadsStdin: true, Run: runReleaseEdit}) 26 ReadsStdin: true, Run: runReleaseEdit})
27 register(Command{Path: []string{"release", "list"}, 27 register(Command{Path: []string{"release", "list"},
28 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) 28 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
@@ -31,10 +31,10 @@ func init() {
31 register(Command{Path: []string{"release", "delete"}, 31 register(Command{Path: []string{"release", "delete"},
32 Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete}) 32 Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete})
33 register(Command{Path: []string{"release", "asset", "add"}, 33 register(Command{Path: []string{"release", "asset", "add"},
34 Summary: "upload an asset from stdin: release asset add <owner/name> <tag> <filename> < file", 34 Summary: "upload an asset from stdin: release asset add <owner/name> <tag> <filename> < file",
35 ReadsStdin: true, Run: runAssetAdd}) 35 ReadsStdin: true, Run: runAssetAdd})
36 register(Command{Path: []string{"release", "asset", "get"}, 36 register(Command{Path: []string{"release", "asset", "get"},
37 Summary: "write an asset to stdout: release asset get <owner/name> <tag> <filename> > file", 37 Summary: "write an asset to stdout: release asset get <owner/name> <tag> <filename> > file",
38 ReadOnly: true, Run: runAssetGet}) 38 ReadOnly: true, Run: runAssetGet})
39 register(Command{Path: []string{"release", "asset", "remove"}, 39 register(Command{Path: []string{"release", "asset", "remove"},
40 Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove}) 40 Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove})