internal/control/commitrefs.go
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}