mrs: closing keywords work in the description too !105
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 | ||
| 35 | 35 | } |
| 36 | 36 | for _, m := range msgs { |
| 37 | 37 | 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 | |
| 42 | 40 | } |
| 43 | 41 | refs := map[int64]bool{} |
| 44 | 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. | |
| 65 | func 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. | |
| 88 | func 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 | 102 | // RecordLandedCommits attributes commits that just landed on the default |
| 61 | 103 | // branch to accounts by verified author email, for the activity graph. |
| 62 | 104 | // Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable |
internal/control/commitrefs_test.go added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 10 | func 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 | |
| 14 | 14 | func init() { |
| 15 | 15 | register(Command{ |
| 16 | Path: []string{"whoami"}, | |
| 17 | Summary: "show the authenticated account", | |
| 16 | Path: []string{"whoami"}, | |
| 17 | Summary: "show the authenticated account", | |
| 18 | 18 | ReadOnly: true, |
| 19 | 19 | Run: runWhoami, |
| 20 | 20 | }) |
| 21 | 21 | register(Command{ |
| 22 | Path: []string{"keys", "list"}, | |
| 23 | Summary: "list registered SSH keys", | |
| 22 | Path: []string{"keys", "list"}, | |
| 23 | Summary: "list registered SSH keys", | |
| 24 | 24 | ReadOnly: true, |
| 25 | 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 | 890 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) |
| 891 | 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 | 895 | if mr.TargetRef == repo.DefaultBranch { |
| 894 | 896 | ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) |
| 897 | ProcessMRDescription(c.Store, repo, mr, c.User.ID, newSHA) | |
| 895 | 898 | RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) |
| 896 | 899 | } |
| 897 | 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 | 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 | |
| 20 | 20 | func init() { |
| 21 | 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 | 23 | ReadsStdin: true, Run: runReleaseCreate}) |
| 24 | 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 | 26 | ReadsStdin: true, Run: runReleaseEdit}) |
| 27 | 27 | register(Command{Path: []string{"release", "list"}, |
| 28 | 28 | Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) |
| @@ -31,10 +31,10 @@ func init() { | ||
| 31 | 31 | register(Command{Path: []string{"release", "delete"}, |
| 32 | 32 | Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete}) |
| 33 | 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 | 35 | ReadsStdin: true, Run: runAssetAdd}) |
| 36 | 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 | 38 | ReadOnly: true, Run: runAssetGet}) |
| 39 | 39 | register(Command{Path: []string{"release", "asset", "remove"}, |
| 40 | 40 | Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove}) |