internal/control/commitrefs.go

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

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}