1package backend
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11
12 "github.com/charmbracelet/log/v2"
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/pkg/config"
15 "github.com/charmbracelet/soft-serve/pkg/hooks"
16 "github.com/charmbracelet/soft-serve/pkg/proto"
17 "github.com/charmbracelet/soft-serve/pkg/sshutils"
18 "github.com/charmbracelet/soft-serve/pkg/webhook"
19 bugCache "github.com/git-bug/git-bug/cache"
20 "github.com/git-bug/git-bug/repository"
21)
22
23var _ hooks.Hooks = (*Backend)(nil)
24
25// PostReceive is called by the git post-receive hook.
26//
27// It implements Hooks.
28func (d *Backend) PostReceive(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {
29 d.logger.Debug("post-receive hook called", "repo", repo, "args", args)
30
31 hasGitBugRefs := false
32 for _, arg := range args {
33 if isGitBugRef(arg.RefName) {
34 hasGitBugRefs = true
35 d.logger.Info("git-bug ref detected, rebuilding cache", "repo", repo, "ref", arg.RefName)
36 if err := rebuildGitBugCache(d.cfg, repo, d.logger); err != nil {
37 d.logger.Error("failed to rebuild git-bug cache", "repo", repo, "err", err)
38 }
39 break
40 }
41 }
42
43 if !hasGitBugRefs {
44 d.logger.Debug("no git-bug refs detected in push", "repo", repo)
45 }
46}
47
48// PreReceive is called by the git pre-receive hook.
49//
50// It implements Hooks.
51func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {
52 d.logger.Debug("pre-receive hook called", "repo", repo, "args", args)
53}
54
55// Update is called by the git update hook.
56//
57// It implements Hooks.
58func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
59 d.logger.Debug("update hook called", "repo", repo, "arg", arg)
60
61 // Find user
62 var user proto.User
63 if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" {
64 pk, _, err := sshutils.ParseAuthorizedKey(pubkey)
65 if err != nil {
66 d.logger.Error("error parsing public key", "err", err)
67 return
68 }
69
70 user, err = d.UserByPublicKey(ctx, pk)
71 if err != nil {
72 d.logger.Error("error finding user from public key", "key", pubkey, "err", err)
73 return
74 }
75 } else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" {
76 var err error
77 user, err = d.User(ctx, username)
78 if err != nil {
79 d.logger.Error("error finding user from username", "username", username, "err", err)
80 return
81 }
82 } else {
83 d.logger.Error("error finding user")
84 return
85 }
86
87 // Get repo
88 r, err := d.Repository(ctx, repo)
89 if err != nil {
90 d.logger.Error("error finding repository", "repo", repo, "err", err)
91 return
92 }
93
94 // TODO: run this async
95 // This would probably need something like an RPC server to communicate with the hook process.
96 if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {
97 wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
98 if err != nil {
99 d.logger.Error("error creating branch_tag webhook", "err", err)
100 } else if err := webhook.SendEvent(ctx, wh); err != nil {
101 d.logger.Error("error sending branch_tag webhook", "err", err)
102 }
103 }
104 wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
105 if err != nil {
106 d.logger.Error("error creating push webhook", "err", err)
107 } else if err := webhook.SendEvent(ctx, wh); err != nil {
108 d.logger.Error("error sending push webhook", "err", err)
109 }
110}
111
112// PostUpdate is called by the git post-update hook.
113//
114// It implements Hooks.
115func (d *Backend) PostUpdate(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args ...string) {
116 d.logger.Debug("post-update hook called", "repo", repo, "args", args)
117
118 var wg sync.WaitGroup
119
120 // Populate last-modified file.
121 wg.Add(1)
122 go func() {
123 defer wg.Done()
124 if err := populateLastModified(ctx, d, repo); err != nil {
125 d.logger.Error("error populating last-modified", "repo", repo, "err", err)
126 return
127 }
128 }()
129
130 wg.Wait()
131}
132
133func populateLastModified(ctx context.Context, d *Backend, name string) error {
134 var rr *repo
135 _rr, err := d.Repository(ctx, name)
136 if err != nil {
137 return err
138 }
139
140 if r, ok := _rr.(*repo); ok {
141 rr = r
142 } else {
143 return proto.ErrRepoNotFound
144 }
145
146 r, err := rr.Open()
147 if err != nil {
148 return err
149 }
150
151 c, err := r.LatestCommitTime()
152 if err != nil {
153 return err
154 }
155
156 return rr.writeLastModified(c)
157}
158
159func isGitBugRef(refName string) bool {
160 return strings.HasPrefix(refName, "refs/bugs/") || strings.HasPrefix(refName, "refs/identities/")
161}
162
163func rebuildGitBugCache(cfg *config.Config, repoName string, logger *log.Logger) error {
164 logger.Info("starting git-bug cache rebuild", "repo", repoName)
165 repoPath := filepath.Join(cfg.DataPath, "repos", repoName+".git")
166
167 goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
168 if err != nil {
169 return fmt.Errorf("open go-git repo: %w", err)
170 }
171 defer goGitRepo.Close()
172
173 rc, err := bugCache.NewRepoCacheNoEvents(goGitRepo)
174 if err != nil {
175 return fmt.Errorf("create repo cache: %w", err)
176 }
177 defer func() {
178 if closeErr := rc.Close(); closeErr != nil {
179 logger.Error("failed to close git-bug cache", "repo", repoName, "err", closeErr)
180 }
181 }()
182
183 var identityTotal int64
184 logger.Debug("rebuilding identities cache", "repo", repoName)
185 for ev := range rc.Identities().Build() {
186 if ev.Err != nil {
187 return fmt.Errorf("rebuild identities cache: %w", ev.Err)
188 }
189 logger.Debug("identity build event",
190 "repo", repoName,
191 "event", ev.Event,
192 "typename", ev.Typename,
193 "total", ev.Total,
194 "progress", ev.Progress)
195
196 if ev.Total > 0 {
197 identityTotal = ev.Total
198 }
199 }
200 logger.Info("identities cache rebuilt", "repo", repoName, "count", identityTotal)
201
202 var bugTotal int64
203 logger.Debug("rebuilding bugs cache", "repo", repoName)
204 for ev := range rc.Bugs().Build() {
205 if ev.Err != nil {
206 return fmt.Errorf("rebuild bugs cache: %w", ev.Err)
207 }
208 logger.Debug("bug build event",
209 "repo", repoName,
210 "event", ev.Event,
211 "typename", ev.Typename,
212 "total", ev.Total,
213 "progress", ev.Progress)
214
215 if ev.Total > 0 {
216 bugTotal = ev.Total
217 }
218 }
219 logger.Info("bugs cache rebuilt", "repo", repoName, "count", bugTotal)
220
221 logger.Info("git-bug cache rebuild complete", "repo", repoName, "identities", identityTotal, "bugs", bugTotal)
222 return nil
223}