internal/control/commitrefs.go
157 lines · 5657 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 _, n := range closingRefs(m.Message) {
39 closes[n] = true
40 }
41 refs := map[int64]bool{}
42 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
43 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !closes[n] {
44 refs[n] = true
45 }
46 }
47 subject, _, _ := strings.Cut(m.Message, "\n")
48 author := authorLink(st, m.AuthorName, m.AuthorEmail)
49 for n := range closes {
50 actOnIssue(st, repo, actorID, m.SHA, n, true, subject, author)
51 }
52 for n := range refs {
53 actOnIssue(st, repo, actorID, m.SHA, n, false, subject, author)
54 }
55 }
56}
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.
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
102// RecordLandedCommits attributes commits that just landed on the default
103// branch to accounts by verified author email, for the activity graph.
104// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
105// authors are simply not activity.
106func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
107 authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
108 if err != nil {
109 return
110 }
111 for _, a := range authors {
112 if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
113 st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
114 }
115 }
116}
117
118// authorLink renders the commit's author for a system comment: a link to
119// their profile when the author email is verified on an account here, and
120// the name git recorded otherwise.
121func authorLink(st *store.Store, name, email string) string {
122 if user, ok := st.UsernameByVerifiedEmail(email); ok {
123 return fmt.Sprintf("[%s](/%s)", user, user)
124 }
125 if name == "" {
126 return email
127 }
128 return name
129}
130
131func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
132 issue, err := st.IssueByNumber(repo.ID, number)
133 if err != nil {
134 return // no such issue: the reference is just text
135 }
136 fresh, err := st.TryRecordCommitRef(issue.ID, sha)
137 if err != nil || !fresh {
138 return
139 }
140 short := sha
141 if len(short) > 10 {
142 short = short[:10]
143 }
144 // Informational system entries, not comments from the pusher; the
145 // linked sha renders clickable on the web.
146 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, repo.Path(), sha)
147 if close && issue.State == "open" {
148 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
149 slog.Error("commit refs: closing issue", "issue", number, "err", err)
150 return
151 }
152 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
153 st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
154 return
155 }
156 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
157}