internal/control/refsupdated.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/refsupdated.go history · blame · raw

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}