internal/control/commitrefs.go

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

166 lines · 6054 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// Acting once is guaranteed by the state check, not by the dedup key: a
 64// commit that closed the issue leaves it closed, and this skips it. The
 65// key is per merge request rather than the merged sha, because sharing
 66// the sha let a bare "#N" in a commit message claim it first and silently
 67// suppress the close.
 68func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64) {
 69	for _, n := range closingRefs(mr.Title + "\n" + mr.Body) {
 70		issue, err := st.IssueByNumber(repo.ID, n)
 71		if err != nil || issue.State != "open" {
 72			continue // no such issue, or a commit already closed it
 73		}
 74		fresh, err := st.TryRecordCommitRef(issue.ID, mrRefKey(mr.Number))
 75		if err != nil || !fresh {
 76			continue // this merge request already acted on this issue
 77		}
 78		if err := st.SetIssueState(issue.ID, "closed"); err != nil {
 79			slog.Error("mr refs: closing issue", "issue", n, "err", err)
 80			continue
 81		}
 82		link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
 83		st.AddIssueSystemComment(issue.ID, actorID,
 84			fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
 85		st.RecordEvent(repo.ID, actorID, "issue.closed",
 86			fmt.Sprintf(`{"number":%d,"mr":%d}`, n, mr.Number))
 87	}
 88}
 89
 90// mrRefKey namespaces a merge request's dedup record so it cannot
 91// collide with a commit sha.
 92func mrRefKey(number int64) string {
 93	return fmt.Sprintf("mr-%d", number)
 94}
 95
 96// closingRefs returns the issue numbers a text closes, in no order.
 97func closingRefs(text string) []int64 {
 98	seen := map[int64]bool{}
 99	var out []int64
100	for _, g := range closePat.FindAllStringSubmatch(text, -1) {
101		n, err := strconv.ParseInt(g[1], 10, 64)
102		if err != nil || seen[n] {
103			continue
104		}
105		seen[n] = true
106		out = append(out, n)
107	}
108	return out
109}
110
111// RecordLandedCommits attributes commits that just landed on the default
112// branch to accounts by verified author email, for the activity graph.
113// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
114// authors are simply not activity.
115func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
116	authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
117	if err != nil {
118		return
119	}
120	for _, a := range authors {
121		if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
122			st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
123		}
124	}
125}
126
127// authorLink renders the commit's author for a system comment: a link to
128// their profile when the author email is verified on an account here, and
129// the name git recorded otherwise.
130func authorLink(st *store.Store, name, email string) string {
131	if user, ok := st.UsernameByVerifiedEmail(email); ok {
132		return fmt.Sprintf("[%s](/%s)", user, user)
133	}
134	if name == "" {
135		return email
136	}
137	return name
138}
139
140func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
141	issue, err := st.IssueByNumber(repo.ID, number)
142	if err != nil {
143		return // no such issue: the reference is just text
144	}
145	fresh, err := st.TryRecordCommitRef(issue.ID, sha)
146	if err != nil || !fresh {
147		return
148	}
149	short := sha
150	if len(short) > 10 {
151		short = short[:10]
152	}
153	// Informational system entries, not comments from the pusher; the
154	// linked sha renders clickable on the web.
155	link := fmt.Sprintf("[%s](/%s/commit/%s)", short, repo.Path(), sha)
156	if close && issue.State == "open" {
157		if err := st.SetIssueState(issue.ID, "closed"); err != nil {
158			slog.Error("commit refs: closing issue", "issue", number, "err", err)
159			return
160		}
161		st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
162		st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
163		return
164	}
165	st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
166}