internal/control/commitrefs.go

32a583340a8db2b242ed452180dd239fd118bc26
gitbay/internal/control/commitrefs.go history · blame · raw

101 lines · 3589 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 _, g := range closePat.FindAllStringSubmatch(m.Message, -1) {
 39			if n, err := strconv.ParseInt(g[1], 10, 64); err == nil {
 40				closes[n] = true
 41			}
 42		}
 43		refs := map[int64]bool{}
 44		for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
 45			if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !closes[n] {
 46				refs[n] = true
 47			}
 48		}
 49		subject, _, _ := strings.Cut(m.Message, "\n")
 50		for n := range closes {
 51			actOnIssue(st, repo, actorID, m.SHA, n, true, subject)
 52		}
 53		for n := range refs {
 54			actOnIssue(st, repo, actorID, m.SHA, n, false, subject)
 55		}
 56	}
 57}
 58
 59// RecordLandedCommits attributes commits that just landed on the default
 60// branch to accounts by verified author email, for the activity graph.
 61// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
 62// authors are simply not activity.
 63func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new string) {
 64	authors, err := gitutil.RevListAuthors(dir, old, new, maxMessageCommits)
 65	if err != nil {
 66		return
 67	}
 68	for _, a := range authors {
 69		if uid, ok := st.UserIDByVerifiedEmail(a.Email); ok {
 70			st.RecordCommitActivity(repo.ID, a.SHA, uid, a.Day)
 71		}
 72	}
 73}
 74
 75func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) {
 76	issue, err := st.IssueByNumber(repo.ID, number)
 77	if err != nil {
 78		return // no such issue: the reference is just text
 79	}
 80	fresh, err := st.TryRecordCommitRef(issue.ID, sha)
 81	if err != nil || !fresh {
 82		return
 83	}
 84	short := sha
 85	if len(short) > 10 {
 86		short = short[:10]
 87	}
 88	// Informational system entries, not comments from the pusher; the
 89	// linked sha renders clickable on the web.
 90	link := fmt.Sprintf("[%s](/%s/commit/%s)", short, repo.Path(), sha)
 91	if close && issue.State == "open" {
 92		if err := st.SetIssueState(issue.ID, "closed"); err != nil {
 93			slog.Error("commit refs: closing issue", "issue", number, "err", err)
 94			return
 95		}
 96		st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", link, subject))
 97		st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
 98		return
 99	}
100	st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", link, subject))
101}