internal/control/commitrefs.go

c327fbdf04d6735772a5d661b271d68fe9e39404
gitbay/internal/control/commitrefs.go history · blame · raw

82 lines · 2799 bytes

 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}