internal/control/commitrefs.go
217 lines · 7616 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/policy"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// closePat matches closing keywords, with an optional owner/name before
16// the number for an issue in another repository; refPat matches any bare
17// same-repo reference. A cross-repo close acts only when the actor holds
18// write on the target (closeTarget); a bare cross-repo reference stays
19// display-only.
20var (
21 closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+(?:([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*))?#(\d+)\b`)
22 refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`)
23)
24
25// closeRef is one closing reference: Path is "" for the same repository.
26type closeRef struct {
27 Path string
28 N int64
29}
30
31const maxMessageCommits = 100
32
33// ProcessCommitMessages acts on issue references in commits that just
34// landed on the default branch (old..new): closing keywords close the
35// issue, bare #N leaves a reference comment. Each (issue, sha) pair acts
36// at most once, ever. actorID — the pusher or merger — authorizes and
37// signs the resulting comments; failures are logged, never fatal, because
38// this runs after the push or merge already succeeded.
39func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, old, new string) {
40 msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits)
41 if err != nil {
42 slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err)
43 return
44 }
45 for _, m := range msgs {
46 closes := closingRefs(m.Message)
47 local := map[int64]bool{}
48 for _, ref := range closes {
49 if ref.Path == "" {
50 local[ref.N] = true
51 }
52 }
53 refs := map[int64]bool{}
54 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
55 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !local[n] {
56 refs[n] = true
57 }
58 }
59 subject, _, _ := strings.Cut(m.Message, "\n")
60 author := authorLink(st, m.AuthorName, m.AuthorEmail)
61 for _, ref := range closes {
62 target, ok := closeTarget(st, repo, actorID, ref.Path)
63 if !ok {
64 continue
65 }
66 actOnIssue(st, repo, target, actorID, m.SHA, ref.N, true, subject, author)
67 }
68 for n := range refs {
69 actOnIssue(st, repo, repo, actorID, m.SHA, n, false, subject, author)
70 }
71 }
72}
73
74// ProcessMRDescription acts on closing keywords in a merged merge
75// request's title and body. Commit messages remain the primary record —
76// they are what lands — but the intent is written in the merge request
77// just as often, and a "Closes #N" there used to close nothing.
78//
79// Acting once is guaranteed by the state check, not by the dedup key: a
80// commit that closed the issue leaves it closed, and this skips it. The
81// key is per merge request rather than the merged sha, because sharing
82// the sha let a bare "#N" in a commit message claim it first and silently
83// suppress the close.
84func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64) {
85 for _, ref := range closingRefs(mr.Title + "\n" + mr.Body) {
86 target, ok := closeTarget(st, repo, actorID, ref.Path)
87 if !ok {
88 continue
89 }
90 issue, err := st.IssueByNumber(target.ID, ref.N)
91 if err != nil || issue.State != "open" {
92 continue // no such issue, or a commit already closed it
93 }
94 fresh, err := st.TryRecordCommitRef(issue.ID, mrRefKey(mr.Number))
95 if err != nil || !fresh {
96 continue // this merge request already acted on this issue
97 }
98 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
99 slog.Error("mr refs: closing issue", "issue", ref.N, "err", err)
100 continue
101 }
102 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
103 st.AddIssueSystemComment(issue.ID, actorID,
104 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
105 st.RecordEvent(target.ID, actorID, "issue.closed",
106 fmt.Sprintf(`{"number":%d,"mr":%d}`, ref.N, mr.Number))
107 }
108}
109
110// mrRefKey namespaces a merge request's dedup record so it cannot
111// collide with a commit sha.
112func mrRefKey(number int64) string {
113 return fmt.Sprintf("mr-%d", number)
114}
115
116// closingRefs returns the references a text closes, in no order.
117func closingRefs(text string) []closeRef {
118 seen := map[closeRef]bool{}
119 var out []closeRef
120 for _, g := range closePat.FindAllStringSubmatch(text, -1) {
121 n, err := strconv.ParseInt(g[2], 10, 64)
122 if err != nil {
123 continue
124 }
125 ref := closeRef{Path: strings.ToLower(g[1]), N: n}
126 if seen[ref] {
127 continue
128 }
129 seen[ref] = true
130 out = append(out, ref)
131 }
132 return out
133}
134
135// closeTarget resolves where a closing reference acts: the source
136// repository for a bare #N, or the named repository when the actor holds
137// write there. false means the reference stays text; nothing is logged
138// above debug, since a refusal must not confirm the target exists.
139func closeTarget(st *store.Store, source store.Repo, actorID int64, path string) (store.Repo, bool) {
140 if path == "" {
141 return source, true
142 }
143 target, err := st.RepoByPath(path)
144 if err != nil {
145 return store.Repo{}, false
146 }
147 actor, err := st.UserByID(actorID)
148 if err != nil {
149 return store.Repo{}, false
150 }
151 grant, err := st.AccessRole(target.ID, actorID)
152 if err != nil {
153 return store.Repo{}, false
154 }
155 if !policy.CanWrite(actor, target, grant) {
156 slog.Debug("commit refs: cross-repo close refused", "source", source.Path(), "target", path)
157 return store.Repo{}, false
158 }
159 return target, true
160}
161
162// RecordLandedCommits attributes commits that just landed on the default
163// branch to accounts by verified author email, for the activity graph.
164// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
165// authors are simply not activity.
166func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
167 authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
168 if err != nil {
169 return
170 }
171 for _, a := range authors {
172 if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
173 st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
174 }
175 }
176}
177
178// authorLink renders the commit's author for a system comment: a link to
179// their profile when the author email is verified on an account here, and
180// the name git recorded otherwise.
181func authorLink(st *store.Store, name, email string) string {
182 if user, ok := st.UsernameByVerifiedEmail(email); ok {
183 return fmt.Sprintf("[%s](/%s)", user, user)
184 }
185 if name == "" {
186 return email
187 }
188 return name
189}
190
191func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
192 issue, err := st.IssueByNumber(target.ID, number)
193 if err != nil {
194 return // no such issue: the reference is just text
195 }
196 fresh, err := st.TryRecordCommitRef(issue.ID, sha)
197 if err != nil || !fresh {
198 return
199 }
200 short := sha
201 if len(short) > 10 {
202 short = short[:10]
203 }
204 // Informational system entries, not comments from the pusher; the
205 // linked sha renders clickable on the web.
206 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, source.Path(), sha)
207 if close && issue.State == "open" {
208 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
209 slog.Error("commit refs: closing issue", "issue", number, "err", err)
210 return
211 }
212 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
213 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
214 return
215 }
216 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
217}