Commit 57c622974b
Verified · cmc
docs/users.org +5
| @@ -146,6 +146,11 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the | ||
| 146 | 146 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 147 | 147 | neither on a terminal and =$EDITOR= opens. |
| 148 | 148 | |
| 149 | Commit messages act on issues when the commits land on the default | |
| 150 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the | |
| 151 | issue with a linking comment, and a bare =#4= leaves a reference | |
| 152 | comment. Each issue/commit pair acts once, ever. Same repository only. | |
| 153 | ||
| 149 | 154 | Milestones group issues and MRs toward a release (write access to |
| 150 | 155 | manage, attach with =issue milestone= / =mr milestone=; progress shows |
| 151 | 156 | on the web at =/owner/name/milestones=): |
e2e/commitrefs_test.go added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func TestCommitMessageIssueActions(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | inst.admin(t, "admin", "user", "create", "alice", | |
| 14 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 15 | ||
| 16 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 17 | t.Fatalf("repo create: %s", errOut) | |
| 18 | } | |
| 19 | work := t.TempDir() | |
| 20 | env := inst.gitEnv(aliceKey) | |
| 21 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 22 | dir := filepath.Join(work, "w") | |
| 23 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 24 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 25 | mustGit(t, dir, env, "add", ".") | |
| 26 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 27 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 28 | for _, title := range []string{"'one'", "'two'", "'three'"} { | |
| 29 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", title); code != 0 { | |
| 30 | t.Fatal("issue create failed") | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | // A closing keyword on the default branch closes the issue with a | |
| 35 | // comment; a bare reference (and a nonexistent #99) only comments. | |
| 36 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | |
| 37 | mustGit(t, dir, env, "add", ".") | |
| 38 | mustGit(t, dir, env, "commit", "-q", "-m", "repair the widget\n\nFixes #1. Related to #2 but not #99.") | |
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 40 | ||
| 41 | out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | |
| 42 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { | |
| 43 | t.Fatalf("issue 1 not closed by commit: %s", out) | |
| 44 | } | |
| 45 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") | |
| 46 | if !strings.Contains(out, `"state":"open"`) || !strings.Contains(out, "referenced in commit") || | |
| 47 | !strings.Contains(out, "repair the widget") { | |
| 48 | t.Fatalf("issue 2 not referenced: %s", out) | |
| 49 | } | |
| 50 | ||
| 51 | // Commits on a feature branch do nothing until they land on the | |
| 52 | // default branch via a merge — then the merge path acts exactly once. | |
| 53 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 54 | os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644) | |
| 55 | mustGit(t, dir, env, "add", ".") | |
| 56 | mustGit(t, dir, env, "commit", "-q", "-m", "finish the gadget\n\nCloses #3") | |
| 57 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 58 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "3", "--json") | |
| 59 | if !strings.Contains(out, `"state":"open"`) { | |
| 60 | t.Fatalf("branch push acted early: %s", out) | |
| 61 | } | |
| 62 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | |
| 63 | "--source", "feat", "--target", "main", "--title", "'gadget'"); code != 0 { | |
| 64 | t.Fatal("mr create failed") | |
| 65 | } | |
| 66 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1"); code != 0 { | |
| 67 | t.Fatalf("merge: %s", errOut) | |
| 68 | } | |
| 69 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "3", "--json") | |
| 70 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { | |
| 71 | t.Fatalf("merge did not close issue 3: %s", out) | |
| 72 | } | |
| 73 | if strings.Count(out, "closed by commit") != 1 { | |
| 74 | t.Fatalf("duplicate close comments: %s", out) | |
| 75 | } | |
| 76 | ||
| 77 | // Pushing more commits does not re-act on already-processed shas. | |
| 78 | os.WriteFile(filepath.Join(dir, "d.txt"), []byte("d\n"), 0o644) | |
| 79 | mustGit(t, dir, env, "checkout", "-q", "main") | |
| 80 | mustGit(t, dir, env, "pull", "-q", "origin", "main") | |
| 81 | mustGit(t, dir, env, "add", ".") | |
| 82 | mustGit(t, dir, env, "commit", "-q", "-m", "unrelated") | |
| 83 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 84 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "2", "--json") | |
| 85 | if strings.Count(out, "referenced in commit") != 1 { | |
| 86 | t.Fatalf("reference duplicated: %s", out) | |
| 87 | } | |
| 88 | } | |
internal/control/commitrefs.go added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "log/slog" | |
| 6 | "regexp" | |
| 7 | "strconv" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/gitutil" | |
| 11 | "gitbay.org/gitbay/internal/store" | |
| 12 | ) | |
| 13 | ||
| 14 | // closePat matches closing keywords; refPat matches any same-repo issue | |
| 15 | // reference. Cross-repo references stay display-only (autolink) — acting | |
| 16 | // across repositories would need its own authorization story. | |
| 17 | var ( | |
| 18 | closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+#(\d+)\b`) | |
| 19 | refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`) | |
| 20 | ) | |
| 21 | ||
| 22 | const maxMessageCommits = 100 | |
| 23 | ||
| 24 | // ProcessCommitMessages acts on issue references in commits that just | |
| 25 | // landed on the default branch (old..new): closing keywords close the | |
| 26 | // issue, bare #N leaves a reference comment. Each (issue, sha) pair acts | |
| 27 | // at most once, ever. actorID — the pusher or merger — authorizes and | |
| 28 | // signs the resulting comments; failures are logged, never fatal, because | |
| 29 | // this runs after the push or merge already succeeded. | |
| 30 | func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, old, new string) { | |
| 31 | msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits) | |
| 32 | if err != nil { | |
| 33 | slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err) | |
| 34 | return | |
| 35 | } | |
| 36 | for _, m := range msgs { | |
| 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 | } | |
| 42 | } | |
| 43 | refs := map[int64]bool{} | |
| 44 | for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) { | |
| 45 | if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !closes[n] { | |
| 46 | refs[n] = true | |
| 47 | } | |
| 48 | } | |
| 49 | subject, _, _ := strings.Cut(m.Message, "\n") | |
| 50 | for n := range closes { | |
| 51 | actOnIssue(st, repo, actorID, m.SHA, n, true, subject) | |
| 52 | } | |
| 53 | for n := range refs { | |
| 54 | actOnIssue(st, repo, actorID, m.SHA, n, false, subject) | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) { | |
| 60 | issue, err := st.IssueByNumber(repo.ID, number) | |
| 61 | if err != nil { | |
| 62 | return // no such issue: the reference is just text | |
| 63 | } | |
| 64 | fresh, err := st.TryRecordCommitRef(issue.ID, sha) | |
| 65 | if err != nil || !fresh { | |
| 66 | return | |
| 67 | } | |
| 68 | short := sha | |
| 69 | if len(short) > 10 { | |
| 70 | short = short[:10] | |
| 71 | } | |
| 72 | if close && issue.State == "open" { | |
| 73 | if err := st.SetIssueState(issue.ID, "closed"); err != nil { | |
| 74 | slog.Error("commit refs: closing issue", "issue", number, "err", err) | |
| 75 | return | |
| 76 | } | |
| 77 | st.AddIssueComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", short, subject)) | |
| 78 | st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) | |
| 79 | return | |
| 80 | } | |
| 81 | st.AddIssueComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", short, subject)) | |
| 82 | } | |
internal/control/mr.go +5
| @@ -817,6 +817,11 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 817 | 817 | return c.fail(protocol.ExitFailure, "%v", err) |
| 818 | 818 | } |
| 819 | 819 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) |
| 820 | // Merges bypass receive-pack, so the commit-message issue actions | |
| 821 | // (closes #N, references) run here for the newly landed commits. | |
| 822 | if mr.TargetRef == repo.DefaultBranch { | |
| 823 | ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) | |
| 824 | } | |
| 820 | 825 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 821 | 826 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), |
| 822 | 827 | notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) |
internal/gitutil/messages.go added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os/exec" | |
| 6 | "strings" | |
| 7 | ) | |
| 8 | ||
| 9 | const zeroSHA = "0000000000000000000000000000000000000000" | |
| 10 | ||
| 11 | type CommitMsg struct { | |
| 12 | SHA string | |
| 13 | Message string | |
| 14 | } | |
| 15 | ||
| 16 | // RevListMessages returns sha and full message for commits reachable from | |
| 17 | // new but not old, newest first, capped at max. An empty or zero old (new | |
| 18 | // branch) lists from new alone, still capped. | |
| 19 | func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | |
| 20 | args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%B%x00", new} | |
| 21 | if old != "" && old != zeroSHA { | |
| 22 | args = append(args, "^"+old) | |
| 23 | } | |
| 24 | out, err := exec.Command("git", args...).Output() | |
| 25 | if err != nil { | |
| 26 | return nil, fmt.Errorf("rev-list messages: %w", err) | |
| 27 | } | |
| 28 | var msgs []CommitMsg | |
| 29 | for _, chunk := range strings.Split(string(out), "\x00") { | |
| 30 | chunk = strings.TrimLeft(chunk, "\n") | |
| 31 | if chunk == "" { | |
| 32 | continue | |
| 33 | } | |
| 34 | header, body, ok := strings.Cut(chunk, "\n") | |
| 35 | if !ok || !strings.HasPrefix(header, "commit ") { | |
| 36 | continue | |
| 37 | } | |
| 38 | msgs = append(msgs, CommitMsg{SHA: strings.TrimPrefix(header, "commit "), Message: strings.TrimSpace(body)}) | |
| 39 | } | |
| 40 | return msgs, nil | |
| 41 | } | |
internal/hookd/hookd.go +7
| @@ -166,6 +166,7 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | ||
| 166 | 166 | // the target owns the objects, so the MR outlives the fork. This is the only |
| 167 | 167 | // place a hook writes outside its own repository. |
| 168 | 168 | func (s *Server) postReceive(req Request) { |
| 169 | pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID) | |
| 169 | 170 | for _, u := range req.Updates { |
| 170 | 171 | // Every ref update is an event webhooks can subscribe to. |
| 171 | 172 | s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf( |
| @@ -176,6 +177,12 @@ func (s *Server) postReceive(req Request) { | ||
| 176 | 177 | if !ok { |
| 177 | 178 | continue |
| 178 | 179 | } |
| 180 | // Commits landing on the default branch act on issue references | |
| 181 | // in their messages (closes #N, plain #N). | |
| 182 | if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete { | |
| 183 | dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name) | |
| 184 | control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New) | |
| 185 | } | |
| 179 | 186 | mrs, err := s.st.OpenMRsBySource(req.RepoID, branch) |
| 180 | 187 | if err != nil { |
| 181 | 188 | slog.Error("post-receive: listing MRs", "err", err) |
internal/store/commitrefs.go added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | // TryRecordCommitRef marks a commit as having referenced an issue. It | |
| 4 | // reports whether this pair was new — false means the reference was | |
| 5 | // already processed and must not act again. | |
| 6 | func (s *Store) TryRecordCommitRef(issueID int64, sha string) (bool, error) { | |
| 7 | res, err := s.DB.Exec( | |
| 8 | "INSERT INTO issue_commit_refs (issue_id, sha) VALUES (?, ?) ON CONFLICT DO NOTHING", | |
| 9 | issueID, sha) | |
| 10 | if err != nil { | |
| 11 | return false, err | |
| 12 | } | |
| 13 | n, _ := res.RowsAffected() | |
| 14 | return n > 0, nil | |
| 15 | } | |
internal/store/migrations/0014_commit_refs.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP TABLE issue_commit_refs; | |
internal/store/migrations/0014_commit_refs.up.sql added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | CREATE TABLE issue_commit_refs ( | |
| 2 | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 3 | sha TEXT NOT NULL, | |
| 4 | PRIMARY KEY (issue_id, sha) | |
| 5 | ); | |