A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

mrs: closing keywords work in the description too !105

merged cmc wants to merge krz/gitbay:mr-body-closes into main

6 files changed, +91 −15

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})