A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 57c622974b

57c622974bf94408ba5b9267a77724221528ed2b

parent: 89b532774d

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T20:21:51Z

Act on issue references in commit messages

Closes #31

Commits landing on the default branch (push post-receive, or the MR
merge path since merges bypass receive-pack) are scanned: closing
keywords (close/fix/resolve variants + #N) close the issue with a
linking comment; bare #N leaves a reference comment. Each (issue, sha)
pair acts at most once via issue_commit_refs (migration 0014); the
pusher or merger is the acting identity. Same-repo references only;
capped at 100 commits per update.
docs/users.org +5
@@ -146,6 +146,11 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the
146146 first argument. Long text: =--body= inline, =--file -= from stdin, or
147147 neither on a terminal and =$EDITOR= opens.
148148
149Commit messages act on issues when the commits land on the default
150branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
151issue with a linking comment, and a bare =#4= leaves a reference
152comment. Each issue/commit pair acts once, ever. Same repository only.
153
149154 Milestones group issues and MRs toward a release (write access to
150155 manage, attach with =issue milestone= / =mr milestone=; progress shows
151156 on the web at =/owner/name/milestones=):
e2e/commitrefs_test.go added +88
@@ -0,0 +1,88 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func 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 @@
1package control
2
3import (
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.
17var (
18 closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+#(\d+)\b`)
19 refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`)
20)
21
22const 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.
30func 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
59func 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 {
817817 return c.fail(protocol.ExitFailure, "%v", err)
818818 }
819819 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 }
820825 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
821826 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
822827 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 @@
1package gitutil
2
3import (
4 "fmt"
5 "os/exec"
6 "strings"
7)
8
9const zeroSHA = "0000000000000000000000000000000000000000"
10
11type 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.
19func 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) {
166166 // the target owns the objects, so the MR outlives the fork. This is the only
167167 // place a hook writes outside its own repository.
168168 func (s *Server) postReceive(req Request) {
169 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
169170 for _, u := range req.Updates {
170171 // Every ref update is an event webhooks can subscribe to.
171172 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
@@ -176,6 +177,12 @@ func (s *Server) postReceive(req Request) {
176177 if !ok {
177178 continue
178179 }
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 }
179186 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
180187 if err != nil {
181188 slog.Error("post-receive: listing MRs", "err", err)
internal/store/commitrefs.go added +15
@@ -0,0 +1,15 @@
1package 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.
6func (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 @@
1DROP TABLE issue_commit_refs;
internal/store/migrations/0014_commit_refs.up.sql added +5
@@ -0,0 +1,5 @@
1CREATE 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);