internal/control/commitrefs.go

9d96712a6988b5d376162e4f31c9ee3525e1ca9a
gitbay/internal/control/commitrefs.go history · blame · raw

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}