internal/control/commitrefs.go
248 lines · 8737 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, and scope is the key they used, which a
38// cross-repo close is checked against too; failures are logged, never
39// fatal, because this runs after the push or merge already succeeded.
40func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, scope, old, new string) {
41 msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits)
42 if err != nil {
43 slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err)
44 return
45 }
46 // Commits in one push name the same repositories over and over, and
47 // each resolution is three queries; keep the answers, refusals too.
48 resolved := map[string]struct {
49 repo store.Repo
50 ok bool
51 }{}
52 target := func(path string) (store.Repo, bool) {
53 if r, seen := resolved[path]; seen {
54 return r.repo, r.ok
55 }
56 t, ok := closeTarget(st, repo, actorID, scope, path)
57 resolved[path] = struct {
58 repo store.Repo
59 ok bool
60 }{t, ok}
61 return t, ok
62 }
63 for _, m := range msgs {
64 closes := closingRefs(m.Message)
65 local := map[int64]bool{}
66 for _, ref := range closes {
67 if ref.Path == "" {
68 local[ref.N] = true
69 }
70 }
71 refs := map[int64]bool{}
72 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
73 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !local[n] {
74 refs[n] = true
75 }
76 }
77 subject, _, _ := strings.Cut(m.Message, "\n")
78 author := authorLink(st, m.AuthorName, m.AuthorEmail)
79 for _, ref := range closes {
80 t, ok := target(ref.Path)
81 if !ok {
82 continue
83 }
84 actOnIssue(st, repo, t, actorID, m.SHA, ref.N, true, subject, author)
85 }
86 for n := range refs {
87 actOnIssue(st, repo, repo, actorID, m.SHA, n, false, subject, author)
88 }
89 }
90}
91
92// ProcessMRDescription acts on closing keywords in a merged merge
93// request's title and body. Commit messages remain the primary record —
94// they are what lands — but the intent is written in the merge request
95// just as often, and a "Closes #N" there used to close nothing.
96//
97// Acting once is guaranteed by the state check, not by the dedup key: a
98// commit that closed the issue leaves it closed, and this skips it. The
99// key is per merge request rather than the merged sha, because sharing
100// the sha let a bare "#N" in a commit message claim it first and silently
101// suppress the close.
102func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64, scope string) {
103 for _, ref := range closingRefs(mr.Title + "\n" + mr.Body) {
104 target, ok := closeTarget(st, repo, actorID, scope, ref.Path)
105 if !ok {
106 continue
107 }
108 issue, err := st.IssueByNumber(target.ID, ref.N)
109 if err != nil || issue.State != "open" {
110 continue // no such issue, or a commit already closed it
111 }
112 fresh, err := st.TryRecordCommitRef(issue.ID, mrRefKey(mr.Number))
113 if err != nil || !fresh {
114 continue // this merge request already acted on this issue
115 }
116 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
117 slog.Error("mr refs: closing issue", "issue", ref.N, "err", err)
118 continue
119 }
120 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
121 st.AddIssueSystemComment(issue.ID, actorID,
122 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
123 st.RecordEvent(target.ID, actorID, "issue.closed",
124 fmt.Sprintf(`{"number":%d,"mr":%d}`, ref.N, mr.Number))
125 }
126}
127
128// mrRefKey namespaces a merge request's dedup record so it cannot
129// collide with a commit sha.
130func mrRefKey(number int64) string {
131 return fmt.Sprintf("mr-%d", number)
132}
133
134// closingRefs returns the references a text closes, in no order.
135func closingRefs(text string) []closeRef {
136 seen := map[closeRef]bool{}
137 var out []closeRef
138 for _, g := range closePat.FindAllStringSubmatch(text, -1) {
139 n, err := strconv.ParseInt(g[2], 10, 64)
140 if err != nil {
141 continue
142 }
143 ref := closeRef{Path: strings.ToLower(g[1]), N: n}
144 if seen[ref] {
145 continue
146 }
147 seen[ref] = true
148 out = append(out, ref)
149 }
150 return out
151}
152
153// closeTarget resolves where a closing reference acts: the source
154// repository for a bare #N, or the named repository when the actor holds
155// write there with a key whose scope reaches it. false means the
156// reference stays text; nothing is logged above debug, since a refusal
157// must not confirm the target exists.
158func closeTarget(st *store.Store, source store.Repo, actorID int64, scope, path string) (store.Repo, bool) {
159 if path == "" {
160 return source, true
161 }
162 target, err := st.RepoByPath(path)
163 if err != nil {
164 return store.Repo{}, false
165 }
166 // The source repository named by its full path is the bare form
167 // spelled out, so a deploy key bound to it closes there as a bare #N
168 // would (#213).
169 if target.ID == source.ID {
170 return source, true
171 }
172 actor, err := st.UserByID(actorID)
173 if err != nil {
174 return store.Repo{}, false
175 }
176 grant, err := st.AccessRole(target.ID, actorID)
177 if err != nil {
178 return store.Repo{}, false
179 }
180 // The account's access and the key's reach both have to hold: a deploy
181 // key is bound to one repository and inherits nothing from whoever
182 // registered it, so its scope allows no write anywhere else.
183 if !policy.CanWrite(actor, target, grant) || !policy.ScopeAllowsGit(scope, target.Path(), true) {
184 slog.Debug("commit refs: cross-repo close refused", "source", source.Path(), "target", path)
185 return store.Repo{}, false
186 }
187 if target.Settings.Archived {
188 return store.Repo{}, false
189 }
190 return target, true
191}
192
193// RecordLandedCommits attributes commits that just landed on the default
194// branch to accounts by verified author email, for the activity graph.
195// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
196// authors are simply not activity.
197func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
198 authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
199 if err != nil {
200 return
201 }
202 for _, a := range authors {
203 if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
204 st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
205 }
206 }
207}
208
209// authorLink renders the commit's author for a system comment: a link to
210// their profile when the author email is verified on an account here, and
211// the name git recorded otherwise.
212func authorLink(st *store.Store, name, email string) string {
213 if user, ok := st.UsernameByVerifiedEmail(email); ok {
214 return fmt.Sprintf("[%s](/%s)", user, user)
215 }
216 if name == "" {
217 return email
218 }
219 return name
220}
221
222func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
223 issue, err := st.IssueByNumber(target.ID, number)
224 if err != nil {
225 return // no such issue: the reference is just text
226 }
227 fresh, err := st.TryRecordCommitRef(issue.ID, sha)
228 if err != nil || !fresh {
229 return
230 }
231 short := sha
232 if len(short) > 10 {
233 short = short[:10]
234 }
235 // Informational system entries, not comments from the pusher; the
236 // linked sha renders clickable on the web.
237 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, source.Path(), sha)
238 if close && issue.State == "open" {
239 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
240 slog.Error("commit refs: closing issue", "issue", number, "err", err)
241 return
242 }
243 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by %s in commit %s: %s", author, link, subject))
244 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
245 return
246 }
247 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
248}