internal/control/refsupdated.go
214 lines · 7816 bytes
5 symbols in this file
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7 "path"
8 "strings"
9 "time"
10
11 "gitbay.org/gitbay/internal/ci"
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RefsUpdated is the work that follows a ref update in repoID by userID,
19// with a key or token of scope: post-receive runs it for every push, and
20// a server-side write to a branch (an applied suggestion) runs it after
21// its own ref update, so nothing a push triggers is skipped. A push to a
22// source branch refreshes refs/merge-requests/N/head in every target
23// repo, by fetching — the target owns the objects, so the MR outlives the
24// fork. This is the only place a push writes outside its own repository.
25func RefsUpdated(st *store.Store, cfg config.Config, repoID, userID int64, scope string, updates []policy.RefUpdate) {
26 pushedRepo, pushedRepoErr := st.RepoByID(repoID)
27 if pushedRepoErr == nil {
28 adoptDefaultBranch(st, cfg, &pushedRepo, updates)
29 }
30 for _, u := range updates {
31 // Every ref update is an event webhooks can subscribe to.
32 st.RecordEvent(repoID, userID, "push", fmt.Sprintf(
33 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
34 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
35
36 // Any ref update — branch or tag — schedules the push mirrors.
37 st.MarkMirrorsDirty(repoID, "push")
38
39 // Tag pushes run the tag-triggered CI jobs.
40 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
41 QueueTagBuilds(st, cfg, pushedRepo, userID, tag, u.New)
42 }
43
44 branch, ok := cutHeads(u.Ref)
45 if !ok {
46 continue
47 }
48 // Commits landing on the default branch act on issue references
49 // in their messages (closes #N, plain #N).
50 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
51 dir := RepoDir(cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
52 ProcessCommitMessages(st, dir, pushedRepo, userID, scope, u.Old, u.New)
53 RecordLandedCommits(st, dir, pushedRepo, u.Old, u.New)
54 // The symbol index is rebuilt by its worker, never here.
55 st.RequestSymbolIndex(pushedRepo.ID, false)
56 }
57 // A branch push with a .gitbay/ci.yml queues one build per job.
58 if pushedRepoErr == nil && !u.IsDelete {
59 QueueBranchBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
60 pushedRepo, userID, branch, u.Old, u.New, time.Now())
61 }
62 if u.IsForce {
63 st.Audit(userID, "push.forced", map[string]any{
64 "repo": repoID, "ref": u.Ref, "old": u.Old, "new": u.New})
65 }
66 mrs, err := st.OpenMRsBySource(repoID, branch)
67 if err != nil {
68 slog.Error("post-receive: listing MRs", "err", err)
69 continue
70 }
71 srcRepo, err := st.RepoByID(repoID)
72 if err != nil {
73 continue
74 }
75 srcDir := RepoDir(cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
76 for _, mr := range mrs {
77 target, err := st.RepoByID(mr.RepoID)
78 if err != nil {
79 continue
80 }
81 if u.IsDelete {
82 if mr.State == "open" {
83 st.SetMRState(mr.ID, "source_gone")
84 }
85 if mr.QueuedAt != "" {
86 TryQueuedMerge(st, cfg, mr.ID) // dequeues: the source is gone
87 }
88 continue // head ref retained: the diff stays viewable
89 }
90 dstDir := RepoDir(cfg.Server.Root, target.OwnerName, target.Name)
91 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
92 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
93 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
94 continue
95 }
96 // The merge base as it stands now, so a later range-diff
97 // compares each revision against the target it was written
98 // on rather than against today's. Best-effort: a base that
99 // cannot be worked out costs precision, not the record.
100 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
101 if err != nil {
102 base = ""
103 }
104 if err := st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
105 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
106 }
107 if srcRepo.ID != target.ID {
108 QueueMRBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
109 target, userID, mr.Number, u.New)
110 }
111 if mr.State == "source_gone" {
112 st.SetMRState(mr.ID, "open") // branch came back
113 }
114 // A queued merge stays queued across a push by someone who can
115 // merge it, and the new head has to pass the gates on its own.
116 if mr.QueuedAt != "" {
117 QueuedMergePushed(st, cfg, mr.ID, userID, scope)
118 }
119 }
120 }
121}
122
123// adoptDefaultBranch moves an unborn HEAD to the first branch a push
124// creates. A repository is initialised with HEAD at the stored default,
125// and a first push of master or trunk left HEAD naming a branch that did
126// not exist: clones checked out nothing and every surface asked git for
127// a branch that was not there (#189). A push that includes the default
128// branch itself needs nothing.
129func adoptDefaultBranch(st *store.Store, cfg config.Config, repo *store.Repo, updates []policy.RefUpdate) {
130 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
131 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
132 return
133 }
134 for _, u := range updates {
135 branch, ok := cutHeads(u.Ref)
136 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
137 continue
138 }
139 if err := gitutil.SetHead(dir, branch); err != nil {
140 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
141 return
142 }
143 if err := st.UpdateDefaultBranch(repo.ID, branch); err != nil {
144 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
145 return
146 }
147 repo.DefaultBranch = branch
148 return
149 }
150}
151
152// sameChange reports whether the new head proposes the diff the old one
153// did: the patch-id of each revision against its own merge base. A
154// rebase onto a moved target changes every sha and nothing about the
155// change, and the reviews of it should not go stale for that (#198).
156// Any doubt answers false, which is the old behaviour.
157func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
158 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
159 return false
160 }
161 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
162 if err != nil {
163 return false
164 }
165 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
166 if err != nil || oldID == "" {
167 return false
168 }
169 newID, err := gitutil.PatchID(dir, newBase, newHead)
170 return err == nil && newID == oldID
171}
172
173// QueueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
174// The build records the tag as its ref and the peeled commit as its sha,
175// so statuses land on the commit, not an annotated tag object.
176func QueueTagBuilds(st *store.Store, cfg config.Config, repo store.Repo, userID int64, tag, pushed string) {
177 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
178 sha, err := gitutil.PeelToCommit(dir, pushed)
179 if err != nil {
180 return
181 }
182 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
183 if err != nil {
184 return
185 }
186 jobs, err := ci.Parse(raw)
187 if err != nil {
188 return // the branch push already reported ci/config
189 }
190 for _, j := range jobs {
191 if j.Tags == "" {
192 continue
193 }
194 if ok, _ := path.Match(j.Tags, tag); !ok {
195 continue
196 }
197 steps, _ := json.Marshal(j.Steps)
198 n, err := st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
199 if err != nil {
200 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
201 continue
202 }
203 url := fmt.Sprintf("%s/%s/builds/%d", cfg.Server.SiteURL, repo.Path(), n)
204 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
205 }
206}
207
208func cutHeads(ref string) (string, bool) {
209 const p = "refs/heads/"
210 if len(ref) > len(p) && ref[:len(p)] == p {
211 return ref[len(p):], true
212 }
213 return "", false
214}