A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 68ae423bf6

68ae423bf66445f448ecb0968823369731fab716

parent: 7670a30c1b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T17:34:38Z

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.
internal/control/commitrefs.go +46 −4
@@ -35,10 +35,8 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
3535 }
3636 for _, m := range msgs {
3737 closes := map[int64]bool{}
38 for _, g := range closePat.FindAllStringSubmatch(m.Message, -1) {
39 if n, err := strconv.ParseInt(g[1], 10, 64); err == nil {
40 closes[n] = true
41 }
38 for _, n := range closingRefs(m.Message) {
39 closes[n] = true
4240 }
4341 refs := map[int64]bool{}
4442 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
@@ -57,6 +55,50 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
5755 }
5856 }
5957
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
60102 // RecordLandedCommits attributes commits that just landed on the default
61103 // branch to accounts by verified author email, for the activity graph.
62104 // 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 (
1313
1414 func init() {
1515 register(Command{
16 Path: []string{"whoami"},
17 Summary: "show the authenticated account",
16 Path: []string{"whoami"},
17 Summary: "show the authenticated account",
1818 ReadOnly: true,
1919 Run: runWhoami,
2020 })
2121 register(Command{
22 Path: []string{"keys", "list"},
23 Summary: "list registered SSH keys",
22 Path: []string{"keys", "list"},
23 Summary: "list registered SSH keys",
2424 ReadOnly: true,
2525 Run: runKeysList,
2626 })
internal/control/mr.go +4 −1
@@ -889,9 +889,12 @@ func runMRMerge(c *Ctx, args []string) int {
889889 }
890890 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
891891 // 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.
893895 if mr.TargetRef == repo.DefaultBranch {
894896 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
897 ProcessMRDescription(c.Store, repo, mr, c.User.ID, newSHA)
895898 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
896899 }
897900 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
184184 return "", "registration is closed on this instance", protocol.ExitDenied
185185 }
186186 }
187
188
internal/control/release.go +4 −4
@@ -19,10 +19,10 @@ import (
1919
2020 func init() {
2121 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 -]",
2323 ReadsStdin: true, Run: runReleaseCreate})
2424 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 -]",
2626 ReadsStdin: true, Run: runReleaseEdit})
2727 register(Command{Path: []string{"release", "list"},
2828 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
@@ -31,10 +31,10 @@ func init() {
3131 register(Command{Path: []string{"release", "delete"},
3232 Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete})
3333 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",
3535 ReadsStdin: true, Run: runAssetAdd})
3636 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",
3838 ReadOnly: true, Run: runAssetGet})
3939 register(Command{Path: []string{"release", "asset", "remove"},
4040 Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove})