08463237cade5869673a6cded4700833cb4286f2
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go
2index 324e7cb469be682d73a1c3501d251f9f66214009..8cb760198a989cdf94a23e541d61bffe2ee775f4 100644
3--- a/cmd/soft/browse.go
4+++ b/cmd/soft/browse.go
5@@ -299,3 +299,8 @@ func (r repository) UpdatedAt() time.Time {
6 func (r repository) UserID() int64 {
7 return 0
8 }
9+
10+// CreatedAt implements proto.Repository.
11+func (r repository) CreatedAt() time.Time {
12+ return time.Time{}
13+}
14diff --git a/cmd/soft/root.go b/cmd/soft/root.go
15index e031a67a377a68c2f44a1277a74da17a5cc4d20c..c47a81f7e35abfcc05f65fd8fec25576d1ed4d19 100644
16--- a/cmd/soft/root.go
17+++ b/cmd/soft/root.go
18@@ -15,6 +15,7 @@ import (
19 logr "github.com/charmbracelet/soft-serve/server/log"
20 "github.com/charmbracelet/soft-serve/server/store"
21 "github.com/charmbracelet/soft-serve/server/store/database"
22+ "github.com/charmbracelet/soft-serve/server/version"
23 "github.com/spf13/cobra"
24 "go.uber.org/automaxprocs/maxprocs"
25 )
26@@ -28,6 +29,10 @@ var (
27 // against. It's set via ldflags when building.
28 CommitSHA = ""
29
30+ // CommitDate contains the date of the commit that this application was
31+ // built against. It's set via ldflags when building.
32+ CommitDate = ""
33+
34 rootCmd = &cobra.Command{
35 Use: "soft",
36 Short: "A self-hostable Git server for the command line",
37@@ -61,6 +66,10 @@ func init() {
38 }
39 }
40 rootCmd.Version = Version
41+
42+ version.Version = Version
43+ version.CommitSHA = CommitSHA
44+ version.CommitDate = CommitDate
45 }
46
47 func main() {
48diff --git a/git/commit.go b/git/commit.go
49index 1e955ff31b405a98faa7804e96fe4b1d93214002..20f2050d49475cd0d5d1258fd1f9e1f7f95d6785 100644
50--- a/git/commit.go
51+++ b/git/commit.go
52@@ -1,12 +1,20 @@
53 package git
54
55 import (
56+ "regexp"
57+
58 "github.com/gogs/git-module"
59 )
60
61 // ZeroID is the zero hash.
62 const ZeroID = git.EmptyID
63
64+// IsZeroHash returns whether the hash is a zero hash.
65+func IsZeroHash(h string) bool {
66+ pattern := regexp.MustCompile(`^0{40,}$`)
67+ return pattern.MatchString(h)
68+}
69+
70 // Commit is a wrapper around git.Commit with helper methods.
71 type Commit = git.Commit
72
73diff --git a/go.mod b/go.mod
74index 25682ab2cf20e14a46498bc18a094d8557d4eab4..a5422cf75eaaee7c4a954524dcc8c03b80bbb1b6 100644
75--- a/go.mod
76+++ b/go.mod
77@@ -31,6 +31,8 @@ require (
78 github.com/gobwas/glob v0.2.3
79 github.com/gogs/git-module v1.8.3
80 github.com/golang-jwt/jwt/v5 v5.0.0
81+ github.com/google/go-querystring v1.1.0
82+ github.com/google/uuid v1.3.0
83 github.com/gorilla/handlers v1.5.1
84 github.com/gorilla/mux v1.8.0
85 github.com/hashicorp/golang-lru/v2 v2.0.7
86@@ -65,7 +67,6 @@ require (
87 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
88 github.com/go-logfmt/logfmt v0.6.0 // indirect
89 github.com/golang/protobuf v1.5.3 // indirect
90- github.com/google/uuid v1.3.0 // indirect
91 github.com/gorilla/css v1.0.0 // indirect
92 github.com/inconshreveable/mousetrap v1.1.0 // indirect
93 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
94diff --git a/go.sum b/go.sum
95index 22b77cacd99bb1c500becd06bad4eb4031a1941b..dd7cca011d45ac3064f1b3321f577f1f91e2cd47 100644
96--- a/go.sum
97+++ b/go.sum
98@@ -72,8 +72,11 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
99 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
100 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
101 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
102+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
103 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
104 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
105+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
106+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
107 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
108 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
109 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
110diff --git a/server/backend/collab.go b/server/backend/collab.go
111index ffe4b5f6ee725cc6f0b47d7e68b9f7464b674e46..6b03c5d0e038ce03e37d838ec5f6b3a83c75f1a9 100644
112--- a/server/backend/collab.go
113+++ b/server/backend/collab.go
114@@ -2,12 +2,15 @@ package backend
115
116 import (
117 "context"
118+ "errors"
119 "strings"
120
121 "github.com/charmbracelet/soft-serve/server/access"
122 "github.com/charmbracelet/soft-serve/server/db"
123 "github.com/charmbracelet/soft-serve/server/db/models"
124+ "github.com/charmbracelet/soft-serve/server/proto"
125 "github.com/charmbracelet/soft-serve/server/utils"
126+ "github.com/charmbracelet/soft-serve/server/webhook"
127 )
128
129 // AddCollaborator adds a collaborator to a repository.
130@@ -20,11 +23,25 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
131 }
132
133 repo = utils.SanitizeRepo(repo)
134- return db.WrapError(
135+ r, err := d.Repository(ctx, repo)
136+ if err != nil {
137+ return err
138+ }
139+
140+ if err := db.WrapError(
141 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
142 return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
143 }),
144- )
145+ ); err != nil {
146+ return err
147+ }
148+
149+ wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded)
150+ if err != nil {
151+ return err
152+ }
153+
154+ return webhook.SendEvent(ctx, wh)
155 }
156
157 // Collaborators returns a list of collaborators for a repository.
158@@ -75,9 +92,27 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
159 // It implements backend.Backend.
160 func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {
161 repo = utils.SanitizeRepo(repo)
162- return db.WrapError(
163+ r, err := d.Repository(ctx, repo)
164+ if err != nil {
165+ return err
166+ }
167+
168+ wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved)
169+ if err != nil {
170+ return err
171+ }
172+
173+ if err := db.WrapError(
174 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
175 return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)
176 }),
177- )
178+ ); err != nil {
179+ if errors.Is(err, db.ErrRecordNotFound) {
180+ return proto.ErrCollaboratorNotFound
181+ }
182+
183+ return err
184+ }
185+
186+ return webhook.SendEvent(ctx, wh)
187 }
188diff --git a/server/backend/hooks.go b/server/backend/hooks.go
189index fe10432edb74abc72279afb6cdd02d1ef2700ea1..3e5297eb953e0ecab5d790e930254d0939905d25 100644
190--- a/server/backend/hooks.go
191+++ b/server/backend/hooks.go
192@@ -3,10 +3,14 @@ package backend
193 import (
194 "context"
195 "io"
196+ "os"
197 "sync"
198
199+ "github.com/charmbracelet/soft-serve/git"
200 "github.com/charmbracelet/soft-serve/server/hooks"
201 "github.com/charmbracelet/soft-serve/server/proto"
202+ "github.com/charmbracelet/soft-serve/server/sshutils"
203+ "github.com/charmbracelet/soft-serve/server/webhook"
204 )
205
206 var _ hooks.Hooks = (*Backend)(nil)
207@@ -28,8 +32,58 @@ func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo s
208 // Update is called by the git update hook.
209 //
210 // It implements Hooks.
211-func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
212+func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
213 d.logger.Debug("update hook called", "repo", repo, "arg", arg)
214+
215+ // Find user
216+ var user proto.User
217+ if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" {
218+ pk, _, err := sshutils.ParseAuthorizedKey(pubkey)
219+ if err != nil {
220+ d.logger.Error("error parsing public key", "err", err)
221+ return
222+ }
223+
224+ user, err = d.UserByPublicKey(ctx, pk)
225+ if err != nil {
226+ d.logger.Error("error finding user from public key", "key", pubkey, "err", err)
227+ return
228+ }
229+ } else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" {
230+ var err error
231+ user, err = d.User(ctx, username)
232+ if err != nil {
233+ d.logger.Error("error finding user from username", "username", username, "err", err)
234+ return
235+ }
236+ } else {
237+ d.logger.Error("error finding user")
238+ return
239+ }
240+
241+ // Get repo
242+ r, err := d.Repository(ctx, repo)
243+ if err != nil {
244+ d.logger.Error("error finding repository", "repo", repo, "err", err)
245+ return
246+ }
247+
248+ // TODO: run this async
249+ // This would probably need something like an RPC server to communicate with the hook process.
250+ if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {
251+ wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
252+ if err != nil {
253+ d.logger.Error("error creating branch_tag webhook", "err", err)
254+ } else if err := webhook.SendEvent(ctx, wh); err != nil {
255+ d.logger.Error("error sending branch_tag webhook", "err", err)
256+ }
257+ }
258+ wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
259+ if err != nil {
260+ d.logger.Error("error creating push webhook", "err", err)
261+ } else if err := webhook.SendEvent(ctx, wh); err != nil {
262+ d.logger.Error("error sending push webhook", "err", err)
263+ }
264 }
265
266 // PostUpdate is called by the git post-update hook.
267diff --git a/server/backend/repo.go b/server/backend/repo.go
268index 2d8633cc3961c6883cea0942eef2aea67cadaa78..2a8a8d1be6b13eb3646162255fcd6435f94afaf1 100644
269--- a/server/backend/repo.go
270+++ b/server/backend/repo.go
271@@ -21,6 +21,7 @@ import (
272 "github.com/charmbracelet/soft-serve/server/storage"
273 "github.com/charmbracelet/soft-serve/server/task"
274 "github.com/charmbracelet/soft-serve/server/utils"
275+ "github.com/charmbracelet/soft-serve/server/webhook"
276 )
277
278 func (d *Backend) reposPath() string {
279@@ -216,7 +217,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
280 repo := name + ".git"
281 rp := filepath.Join(d.reposPath(), repo)
282
283- err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
284+ user := proto.UserFromContext(ctx)
285+ r, err := d.Repository(ctx, name)
286+ if err != nil {
287+ return err
288+ }
289+
290+ // We create the webhook event before deleting the repository so we can
291+ // send the event after deleting the repository.
292+ wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
293+ if err != nil {
294+ return err
295+ }
296+
297+ if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
298 // Delete repo from cache
299 defer d.cache.Delete(name)
300
301@@ -257,17 +271,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
302 }
303
304 return os.RemoveAll(rp)
305- })
306- if errors.Is(err, db.ErrRecordNotFound) {
307- return proto.ErrRepoNotFound
308+ }); err != nil {
309+ if errors.Is(err, db.ErrRecordNotFound) {
310+ return proto.ErrRepoNotFound
311+ }
312+
313+ return db.WrapError(err)
314 }
315
316- return err
317+ return webhook.SendEvent(ctx, wh)
318 }
319
320 // DeleteUserRepositories deletes all user repositories.
321 func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
322- return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
323+ if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
324 user, err := d.store.FindUserByUsername(ctx, tx, username)
325 if err != nil {
326 return err
327@@ -285,7 +302,11 @@ func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) e
328 }
329
330 return nil
331- })
332+ }); err != nil {
333+ return db.WrapError(err)
334+ }
335+
336+ return nil
337 }
338
339 // RenameRepository renames a repository.
340@@ -301,6 +322,11 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
341 if err := utils.ValidateRepo(newName); err != nil {
342 return err
343 }
344+
345+ if oldName == newName {
346+ return nil
347+ }
348+
349 oldRepo := oldName + ".git"
350 newRepo := newName + ".git"
351 op := filepath.Join(d.reposPath(), oldRepo)
352@@ -331,7 +357,18 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
353 return db.WrapError(err)
354 }
355
356- return nil
357+ user := proto.UserFromContext(ctx)
358+ repo, err := d.Repository(ctx, newName)
359+ if err != nil {
360+ return err
361+ }
362+
363+ wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
364+ if err != nil {
365+ return err
366+ }
367+
368+ return webhook.SendEvent(ctx, wh)
369 }
370
371diff --git a/server/backend/webhooks.go b/server/backend/webhooks.go
372new file mode 100644
373index 0000000000000000000000000000000000000000..6d676d35655f62a69bc1497191b2eea4c3d59ef6
374--- /dev/null
375+++ b/server/backend/webhooks.go
376@@ -0,0 +1,279 @@
377+package backend
378+
379+import (
380+ "context"
381+ "encoding/json"
382+
383+ "github.com/charmbracelet/log"
384+ "github.com/charmbracelet/soft-serve/server/db"
385+ "github.com/charmbracelet/soft-serve/server/db/models"
386+ "github.com/charmbracelet/soft-serve/server/proto"
387+ "github.com/charmbracelet/soft-serve/server/store"
388+ "github.com/charmbracelet/soft-serve/server/webhook"
389+ "github.com/google/uuid"
390+)
391+
392+// CreateWebhook creates a webhook for a repository.
393+func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error {
394+ dbx := db.FromContext(ctx)
395+ datastore := store.FromContext(ctx)
396+
397+ return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
398+ lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)
399+ if err != nil {
400+ return db.WrapError(err)
401+ }
402+
403+ evs := make([]int, len(events))
404+ for i, e := range events {
405+ evs[i] = int(e)
406+ }
407+ if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil {
408+ return db.WrapError(err)
409+ }
410+
411+ return nil
412+ })
413+}
414+
415+// Webhook returns a webhook for a repository.
416+func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) {
417+ dbx := db.FromContext(ctx)
418+ datastore := store.FromContext(ctx)
419+
420+ var wh webhook.Hook
421+ if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
422+ h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
423+ if err != nil {
424+ return db.WrapError(err)
425+ }
426+ events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
427+ if err != nil {
428+ return db.WrapError(err)
429+ }
430+
431+ wh = webhook.Hook{
432+ Webhook: h,
433+ ContentType: webhook.ContentType(h.ContentType),
434+ Events: make([]webhook.Event, len(events)),
435+ }
436+ for i, e := range events {
437+ wh.Events[i] = webhook.Event(e.Event)
438+ }
439+
440+ return nil
441+ }); err != nil {
442+ return webhook.Hook{}, db.WrapError(err)
443+ }
444+
445+ return wh, nil
446+}
447+
448+// ListWebhooks lists webhooks for a repository.
449+func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) {
450+ dbx := db.FromContext(ctx)
451+ datastore := store.FromContext(ctx)
452+
453+ var webhooks []models.Webhook
454+ webhookEvents := map[int64][]models.WebhookEvent{}
455+ if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
456+ var err error
457+ webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID())
458+ if err != nil {
459+ return err
460+ }
461+
462+ for _, h := range webhooks {
463+ events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID)
464+ if err != nil {
465+ return err
466+ }
467+ webhookEvents[h.ID] = events
468+ }
469+
470+ return nil
471+ }); err != nil {
472+ return nil, db.WrapError(err)
473+ }
474+
475+ hooks := make([]webhook.Hook, len(webhooks))
476diff --git a/server/config/config.go b/server/config/config.go
477index aba8f508e3189c7058d2678018d42852f2177248..fa5e2de8ec8a2d62691a0aab7ecc76031acba694 100644
478--- a/server/config/config.go
479+++ b/server/config/config.go
480@@ -40,6 +40,9 @@ type GitConfig struct {
481 // ListenAddr is the address on which the Git daemon will listen.
482 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
483
484+ // PublicURL is the public URL of the Git daemon server.
485+ PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
486+
487 // MaxTimeout is the maximum number of seconds a connection can take.
488 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
489
490@@ -157,6 +160,7 @@ func (c *Config) Environ() []string {
491 fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
492 fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
493 fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
494+ fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
495 fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
496 fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
497 fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
498@@ -304,6 +308,7 @@ func DefaultConfig() *Config {
499 },
500 Git: GitConfig{
501 ListenAddr: ":9418",
502+ PublicURL: "git://localhost",
503 MaxTimeout: 0,
504 IdleTimeout: 3,
505 MaxConnections: 32,
506diff --git a/server/config/file.go b/server/config/file.go
507index 6560dd15ca752dd80bcc3b1cb0a50559ae65aa51..27a3dcdf1f43b097956a79c06fc2db9f3d167fc9 100644
508--- a/server/config/file.go
509+++ b/server/config/file.go
510@@ -50,6 +50,10 @@ git:
511 # The address on which the Git daemon will listen.
512 listen_addr: "{{ .Git.ListenAddr }}"
513
514+ # The public URL of the Git daemon server.
515+ # This is the address that will be used to clone repositories.
516+ public_url: "{{ .Git.PublicURL }}"
517+
518 # The maximum number of seconds a connection can take.
519 # A value of 0 means no timeout.
520 max_timeout: {{ .Git.MaxTimeout }}
521diff --git a/server/db/errors.go b/server/db/errors.go
522index 9c19b028261f699da9101ea0b6a7b72ce4164a87..752835f43d62611b9711e6dc67a96a61ea7990ab 100644
523--- a/server/db/errors.go
524+++ b/server/db/errors.go
525@@ -6,7 +6,7 @@ import (
526
527 "github.com/lib/pq"
528 sqlite "modernc.org/sqlite"
529- sqlite3 "modernc.org/sqlite/lib"
530+ sqlitelib "modernc.org/sqlite/lib"
531 )
532
533 var (
534@@ -28,9 +28,9 @@ func WrapError(err error) error {
535 // Handle sqlite constraint error.
536 if liteErr, ok := err.(*sqlite.Error); ok {
537 code := liteErr.Code()
538- if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
539- code == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY ||
540- code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
541+ if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY ||
542+ code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY ||
543+ code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE {
544 return ErrDuplicateKey
545 }
546 }
547diff --git a/server/db/migrate/0002_webhooks.go b/server/db/migrate/0002_webhooks.go
548new file mode 100644
549index 0000000000000000000000000000000000000000..7ad37be61cc2d396a9ba1c57637c6ae461e1a93f
550--- /dev/null
551+++ b/server/db/migrate/0002_webhooks.go
552@@ -0,0 +1,23 @@
553+package migrate
554+
555+import (
556+ "context"
557+
558+ "github.com/charmbracelet/soft-serve/server/db"
559+)
560+
561+const (
562+ webhooksName = "webhooks"
563+ webhooksVersion = 2
564+)
565+
566+var webhooks = Migration{
567+ Name: webhooksName,
568+ Version: webhooksVersion,
569+ Migrate: func(ctx context.Context, tx *db.Tx) error {
570+ return migrateUp(ctx, tx, webhooksVersion, webhooksName)
571+ },
572+ Rollback: func(ctx context.Context, tx *db.Tx) error {
573+ return migrateDown(ctx, tx, webhooksVersion, webhooksName)
574+ },
575+}
576diff --git a/server/db/migrate/0002_webhooks_postgres.down.sql b/server/db/migrate/0002_webhooks_postgres.down.sql
577new file mode 100644
578index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
579--- /dev/null
580+++ b/server/db/migrate/0002_webhooks_postgres.down.sql
581diff --git a/server/db/migrate/0002_webhooks_postgres.up.sql b/server/db/migrate/0002_webhooks_postgres.up.sql
582new file mode 100644
583index 0000000000000000000000000000000000000000..dee09f7037458012747492265a44b951718dbf34
584--- /dev/null
585+++ b/server/db/migrate/0002_webhooks_postgres.up.sql
586@@ -0,0 +1,46 @@
587+CREATE TABLE IF NOT EXISTS webhooks (
588+ id SERIAL PRIMARY KEY,
589+ repo_id INTEGER NOT NULL,
590+ url TEXT NOT NULL,
591+ secret TEXT NOT NULL,
592+ content_type INTEGER NOT NULL,
593+ active BOOLEAN NOT NULL,
594+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
595+ updated_at TIMESTAMP NOT NULL,
596+ UNIQUE (repo_id, url),
597+ CONSTRAINT repo_id_fk
598+ FOREIGN KEY(repo_id) REFERENCES repos(id)
599+ ON DELETE CASCADE
600+ ON UPDATE CASCADE
601+);
602+
603+CREATE TABLE IF NOT EXISTS webhook_events (
604+ id SERIAL PRIMARY KEY,
605+ webhook_id INTEGER NOT NULL,
606+ event INTEGER NOT NULL,
607+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
608+ UNIQUE (webhook_id, event),
609+ CONSTRAINT webhook_id_fk
610+ FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
611+ ON DELETE CASCADE
612+ ON UPDATE CASCADE
613+);
614+
615+CREATE TABLE IF NOT EXISTS webhook_deliveries (
616+ id TEXT PRIMARY KEY,
617+ webhook_id INTEGER NOT NULL,
618+ event INTEGER NOT NULL,
619+ request_url TEXT NOT NULL,
620+ request_method TEXT NOT NULL,
621+ request_error TEXT,
622+ request_headers TEXT NOT NULL,
623+ request_body TEXT NOT NULL,
624+ response_status INTEGER NOT NULL,
625+ response_headers TEXT NOT NULL,
626+ response_body TEXT NOT NULL,
627+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
628+ CONSTRAINT webhook_id_fk
629+ FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
630+ ON DELETE CASCADE
631+ ON UPDATE CASCADE
632+);
633diff --git a/server/db/migrate/0002_webhooks_sqlite.down.sql b/server/db/migrate/0002_webhooks_sqlite.down.sql
634new file mode 100644
635index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
636--- /dev/null
637+++ b/server/db/migrate/0002_webhooks_sqlite.down.sql
638diff --git a/server/db/migrate/0002_webhooks_sqlite.up.sql b/server/db/migrate/0002_webhooks_sqlite.up.sql
639new file mode 100644
640index 0000000000000000000000000000000000000000..5f2139c309e23d846d46ce6ea23fdea1d6646466
641--- /dev/null
642+++ b/server/db/migrate/0002_webhooks_sqlite.up.sql
643@@ -0,0 +1,46 @@
644+CREATE TABLE IF NOT EXISTS webhooks (
645+ id INTEGER PRIMARY KEY AUTOINCREMENT,
646+ repo_id INTEGER NOT NULL,
647+ url TEXT NOT NULL,
648+ secret TEXT NOT NULL,
649+ content_type INTEGER NOT NULL,
650+ active BOOLEAN NOT NULL,
651+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
652+ updated_at DATETIME NOT NULL,
653+ UNIQUE (repo_id, url),
654+ CONSTRAINT repo_id_fk
655+ FOREIGN KEY(repo_id) REFERENCES repos(id)
656+ ON DELETE CASCADE
657+ ON UPDATE CASCADE
658+);
659+
660+CREATE TABLE IF NOT EXISTS webhook_events (
661+ id INTEGER PRIMARY KEY AUTOINCREMENT,
662+ webhook_id INTEGER NOT NULL,
663+ event INTEGER NOT NULL,
664+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
665+ UNIQUE (webhook_id, event),
666+ CONSTRAINT webhook_id_fk
667+ FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
668+ ON DELETE CASCADE
669+ ON UPDATE CASCADE
670+);
671+
672+CREATE TABLE IF NOT EXISTS webhook_deliveries (
673+ id TEXT PRIMARY KEY,
674+ webhook_id INTEGER NOT NULL,
675+ event INTEGER NOT NULL,
676+ request_url TEXT NOT NULL,
677+ request_method TEXT NOT NULL,
678+ request_error TEXT,
679+ request_headers TEXT NOT NULL,
680+ request_body TEXT NOT NULL,
681+ response_status INTEGER NOT NULL,
682+ response_headers TEXT NOT NULL,
683+ response_body TEXT NOT NULL,
684+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
685+ CONSTRAINT webhook_id_fk
686+ FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
687+ ON DELETE CASCADE
688+ ON UPDATE CASCADE
689+);
690diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
691index 88a9e434696ecdfe5ccb58d844d1f244d3bbfce5..890454ab147d431f5a03a835164e6230909ff5ff 100644
692--- a/server/db/migrate/migrations.go
693+++ b/server/db/migrate/migrations.go
694@@ -16,6 +16,7 @@ var sqls embed.FS
695 // Keep this in order of execution, oldest to newest.
696 var migrations = []Migration{
697 createTables,
698+ webhooks,
699 }
700
701 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
702diff --git a/server/db/models/webhook.go b/server/db/models/webhook.go
703new file mode 100644
704index 0000000000000000000000000000000000000000..85667ceb88c38ca4b3e7433c633cd26947650d05
705--- /dev/null
706+++ b/server/db/models/webhook.go
707@@ -0,0 +1,44 @@
708+package models
709+
710+import (
711+ "database/sql"
712+ "time"
713+
714+ "github.com/google/uuid"
715+)
716+
717+// Webhook is a repository webhook.
718+type Webhook struct {
719+ ID int64 `db:"id"`
720+ RepoID int64 `db:"repo_id"`
721+ URL string `db:"url"`
722+ Secret string `db:"secret"`
723+ ContentType int `db:"content_type"`
724+ Active bool `db:"active"`
725+ CreatedAt time.Time `db:"created_at"`
726+ UpdatedAt time.Time `db:"updated_at"`
727+}
728+
729+// WebhookEvent is a webhook event.
730+type WebhookEvent struct {
731+ ID int64 `db:"id"`
732+ WebhookID int64 `db:"webhook_id"`
733+ Event int `db:"event"`
734+ CreatedAt time.Time `db:"created_at"`
735+}
736+
737+// WebhookDelivery is a webhook delivery.
738+type WebhookDelivery struct {
739+ ID uuid.UUID `db:"id"`
740+ WebhookID int64 `db:"webhook_id"`
741+ Event int `db:"event"`
742+ RequestURL string `db:"request_url"`
743+ RequestMethod string `db:"request_method"`
744+ RequestError sql.NullString `db:"request_error"`
745+ RequestHeaders string `db:"request_headers"`
746+ RequestBody string `db:"request_body"`
747+ ResponseStatus int `db:"response_status"`
748+ ResponseHeaders string `db:"response_headers"`
749+ ResponseBody string `db:"response_body"`
750+ CreatedAt time.Time `db:"created_at"`
751+}
752diff --git a/server/proto/errors.go b/server/proto/errors.go
753index cb453a8f06f5924b8f0ceec2b36bcfa206f6ccee..cc48b6f78741247d1eb48f4585490b21cf5f77c6 100644
754--- a/server/proto/errors.go
755+++ b/server/proto/errors.go
756@@ -19,4 +19,6 @@ var (
757 ErrTokenNotFound = errors.New("token not found")
758 // ErrTokenExpired is returned when a token is expired.
759 ErrTokenExpired = errors.New("token expired")
760+ // ErrCollaboratorNotFound is returned when a collaborator is not found.
761+ ErrCollaboratorNotFound = errors.New("collaborator not found")
762 )
763diff --git a/server/proto/repo.go b/server/proto/repo.go
764index ee6ccae29d174e00cbab536b7141073a80510618..a4b80db9864a66810a286ccbe558ead3361466b5 100644
765--- a/server/proto/repo.go
766+++ b/server/proto/repo.go
767@@ -25,6 +25,8 @@ type Repository interface {
768 // UserID returns the ID of the user who owns the repository.
769 // It returns 0 if the repository is not owned by a user.
770 UserID() int64
771+ // CreatedAt returns the time the repository was created.
772+ CreatedAt() time.Time
773 // UpdatedAt returns the time the repository was last updated.
774 // If the repository has never been updated, it returns the time it was created.
775 UpdatedAt() time.Time
776@@ -42,3 +44,18 @@ type RepositoryOptions struct {
777 LFS bool
778 LFSEndpoint string
779 }
780+
781+// RepositoryDefaultBranch returns the default branch of a repository.
782+func RepositoryDefaultBranch(repo Repository) (string, error) {
783+ r, err := repo.Open()
784+ if err != nil {
785+ return "", err
786+ }
787+
788+ ref, err := r.HEAD()
789+ if err != nil {
790+ return "", err
791+ }
792+
793+ return ref.Name().Short(), nil
794+}
795diff --git a/server/ssh/cmd/branch.go b/server/ssh/cmd/branch.go
796index 7ed4603dbb022c07fa2b47b81f8bfd7ad69e08ae..051bc9c16d7cd00cc0099f18756ffe39584c8eed 100644
797--- a/server/ssh/cmd/branch.go
798+++ b/server/ssh/cmd/branch.go
799@@ -6,6 +6,8 @@ import (
800
801 "github.com/charmbracelet/soft-serve/git"
802 "github.com/charmbracelet/soft-serve/server/backend"
803+ "github.com/charmbracelet/soft-serve/server/proto"
804+ "github.com/charmbracelet/soft-serve/server/webhook"
805 gitm "github.com/gogs/git-module"
806 "github.com/spf13/cobra"
807 )
808@@ -123,6 +125,15 @@ func branchDefaultCommand() *cobra.Command {
809 }); err != nil {
810 return err
811 }
812+
813+ // TODO: move this to backend?
814+ user := proto.UserFromContext(ctx)
815+ wh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange)
816+ if err != nil {
817+ return err
818+ }
819+
820+ return webhook.SendEvent(ctx, wh)
821 }
822
823 return nil
824@@ -175,7 +186,21 @@ func branchDeleteCommand() *cobra.Command {
825 return fmt.Errorf("cannot delete the default branch")
826 }
827
828- return r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true})
829+ branchCommit, err := r.BranchCommit(branch)
830+ if err != nil {
831+ return err
832+ }
833+
834+ if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil {
835+ return err
836+ }
837+
838+ wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID)
839+ if err != nil {
840+ return err
841+ }
842+
843+ return webhook.SendEvent(ctx, wh)
844 },
845 }
846
847diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
848index 08ac8f9b4312297fd704bc296117ba05c1e8091d..d543c3ad12f0ba6d76a4b97ac9bbb876cced7e51 100644
849--- a/server/ssh/cmd/cmd.go
850+++ b/server/ssh/cmd/cmd.go
851@@ -137,9 +137,16 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
852 return false
853 }
854
855-func checkIfAdmin(cmd *cobra.Command, _ []string) error {
856+func checkIfAdmin(cmd *cobra.Command, args []string) error {
857+ var repo string
858+ if len(args) > 0 {
859+ repo = args[0]
860+ }
861+
862 ctx := cmd.Context()
863 cfg := config.FromContext(ctx)
864+ be := backend.FromContext(ctx)
865+ rn := utils.SanitizeRepo(repo)
866 pk := sshutils.PublicKeyFromContext(ctx)
867 if IsPublicKeyAdmin(cfg, pk) {
868 return nil
869@@ -150,11 +157,16 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error {
870 return proto.ErrUnauthorized
871 }
872
873- if !user.IsAdmin() {
874- return proto.ErrUnauthorized
875+ if user.IsAdmin() {
876+ return nil
877 }
878
879- return nil
880+ auth := be.AccessLevelForUser(cmd.Context(), rn, user)
881+ if auth >= access.AdminAccess {
882+ return nil
883+ }
884+
885+ return proto.ErrUnauthorized
886 }
887
888 func checkIfCollab(cmd *cobra.Command, args []string) error {
889diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go
890index 0b5ff3e5db40e4f672349174a01ea05daa7fcae2..0579a12ac739a0abd7d3d44fa181824d00285cb3 100644
891--- a/server/ssh/cmd/repo.go
892+++ b/server/ssh/cmd/repo.go
893@@ -34,6 +34,7 @@ func RepoCommand() *cobra.Command {
894 renameCommand(),
895 tagCommand(),
896 treeCommand(),
897+ webhookCommand(),
898 )
899
900 cmd.AddCommand(
901diff --git a/server/ssh/cmd/tag.go b/server/ssh/cmd/tag.go
902index 6b72087627c14411f021ed38bb5003f8032a4412..c15ce09f631c791b82c7260c49598ce5f8a82c31 100644
903--- a/server/ssh/cmd/tag.go
904+++ b/server/ssh/cmd/tag.go
905@@ -3,7 +3,11 @@ package cmd
906 import (
907 "strings"
908
909+ "github.com/charmbracelet/log"
910+ "github.com/charmbracelet/soft-serve/git"
911 "github.com/charmbracelet/soft-serve/server/backend"
912+ "github.com/charmbracelet/soft-serve/server/proto"
913+ "github.com/charmbracelet/soft-serve/server/webhook"
914 "github.com/spf13/cobra"
915 )
916
917@@ -72,10 +76,43 @@ func tagDeleteCommand() *cobra.Command {
918
919 r, err := rr.Open()
920 if err != nil {
921+ log.Errorf("failed to open repo: %s", err)
922 return err
923 }
924
925- return r.DeleteTag(args[1])
926+ tag := args[1]
927+ tags, _ := r.Tags()
928+ var exists bool
929+ for _, t := range tags {
930+ if tag == t {
931+ exists = true
932+ break
933+ }
934+ }
935+
936+ if !exists {
937+ log.Errorf("failed to get tag: tag %s does not exist", tag)
938+ return git.ErrReferenceNotExist
939+ }
940+
941+ tagCommit, err := r.TagCommit(tag)
942+ if err != nil {
943+ log.Errorf("failed to get tag commit: %s", err)
944+ return err
945+ }
946+
947+ if err := r.DeleteTag(tag); err != nil {
948+ log.Errorf("failed to delete tag: %s", err)
949+ return err
950+ }
951+
952+ wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID)
953+ if err != nil {
954+ log.Error("failed to create branch_tag webhook", "err", err)
955+ return err
956+ }
957+
958+ return webhook.SendEvent(ctx, wh)
959 },
960 }
961
962diff --git a/server/ssh/cmd/webhooks.go b/server/ssh/cmd/webhooks.go
963new file mode 100644
964index 0000000000000000000000000000000000000000..53ee877f82d8f7872989a7f91dbd3574bee93faf
965--- /dev/null
966+++ b/server/ssh/cmd/webhooks.go
967@@ -0,0 +1,406 @@
968+package cmd
969+
970+import (
971+ "fmt"
972+ "strconv"
973+ "strings"
974+
975+ "github.com/caarlos0/tablewriter"
976+ "github.com/charmbracelet/soft-serve/server/backend"
977+ "github.com/charmbracelet/soft-serve/server/webhook"
978+ "github.com/dustin/go-humanize"
979+ "github.com/google/uuid"
980+ "github.com/spf13/cobra"
981+)
982+
983+func webhookCommand() *cobra.Command {
984+ cmd := &cobra.Command{
985+ Use: "webhook",
986+ Aliases: []string{"webhooks"},
987+ Short: "Manage repository webhooks",
988+ }
989+
990+ cmd.AddCommand(
991+ webhookListCommand(),
992+ webhookCreateCommand(),
993+ webhookDeleteCommand(),
994+ webhookUpdateCommand(),
995+ webhookDeliveriesCommand(),
996+ )
997+
998+ return cmd
999+}
1000+
1001+var webhookEvents []string
1002+
1003+func init() {
1004+ events := webhook.Events()
1005+ webhookEvents = make([]string, len(events))
1006+ for i, e := range events {
1007+ webhookEvents[i] = e.String()
1008+ }
1009+}
1010+
1011+func webhookListCommand() *cobra.Command {
1012+ cmd := &cobra.Command{
1013+ Use: "list REPOSITORY",
1014+ Short: "List repository webhooks",
1015+ Args: cobra.ExactArgs(1),
1016+ PersistentPreRunE: checkIfAdmin,
1017+ RunE: func(cmd *cobra.Command, args []string) error {
1018+ ctx := cmd.Context()
1019+ be := backend.FromContext(ctx)
1020+ repo, err := be.Repository(ctx, args[0])
1021+ if err != nil {
1022+ return err
1023+ }
1024+
1025+ webhooks, err := be.ListWebhooks(ctx, repo)
1026+ if err != nil {
1027+ return err
1028+ }
1029+
1030+ return tablewriter.Render(
1031+ cmd.OutOrStdout(),
1032+ webhooks,
1033+ []string{"ID", "URL", "Events", "Active", "Created At", "Updated At"},
1034+ func(h webhook.Hook) ([]string, error) {
1035+ events := make([]string, len(h.Events))
1036+ for i, e := range h.Events {
1037+ events[i] = e.String()
1038+ }
1039+
1040+ row := []string{
1041+ strconv.FormatInt(h.ID, 10),
1042+ h.URL,
1043+ strings.Join(events, ","),
1044+ strconv.FormatBool(h.Active),
1045+ humanize.Time(h.CreatedAt),
1046+ humanize.Time(h.UpdatedAt),
1047+ }
1048+
1049+ return row, nil
1050+ },
1051+ )
1052+ },
1053+ }
1054+
1055+ return cmd
1056+}
1057+
1058+func webhookCreateCommand() *cobra.Command {
1059+ var events []string
1060+ var secret string
1061+ var active bool
1062+ var contentType string
1063+ cmd := &cobra.Command{
1064+ Use: "create REPOSITORY URL",
1065+ Short: "Create a repository webhook",
1066+ Args: cobra.ExactArgs(2),
1067diff --git a/server/store/database/database.go b/server/store/database/database.go
1068index f05ee4fa079ae1a2a13fb750e8b6a48773ea1f12..ab6155e8e41175487d949e772b86dbdc38934669 100644
1069--- a/server/store/database/database.go
1070+++ b/server/store/database/database.go
1071@@ -21,6 +21,7 @@ type datastore struct {
1072 *collabStore
1073 *lfsStore
1074 *accessTokenStore
1075+ *webhookStore
1076 }
1077
1078 // New returns a new store.Store database.
1079diff --git a/server/store/database/webhooks.go b/server/store/database/webhooks.go
1080new file mode 100644
1081index 0000000000000000000000000000000000000000..abaa25851f67d2a1edc5b6050bf71683ccb54c27
1082--- /dev/null
1083+++ b/server/store/database/webhooks.go
1084@@ -0,0 +1,165 @@
1085+package database
1086+
1087+import (
1088+ "context"
1089+
1090+ "github.com/charmbracelet/soft-serve/server/db"
1091+ "github.com/charmbracelet/soft-serve/server/db/models"
1092+ "github.com/charmbracelet/soft-serve/server/store"
1093+ "github.com/google/uuid"
1094+ "github.com/jmoiron/sqlx"
1095+)
1096+
1097+type webhookStore struct{}
1098+
1099+var _ store.WebhookStore = (*webhookStore)(nil)
1100+
1101+// CreateWebhook implements store.WebhookStore.
1102+func (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) {
1103+ var id int64
1104+ query := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at)
1105+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`)
1106+ err := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active)
1107+ if err != nil {
1108+ return 0, err
1109+ }
1110+
1111+ return id, nil
1112+}
1113+
1114+// CreateWebhookDelivery implements store.WebhookStore.
1115diff --git a/server/store/store.go b/server/store/store.go
1116index 7862cb598b39702aa6b815524dc91a34da59e843..41490cbf8725d8b4939b7b0a583dd077dd73de23 100644
1117--- a/server/store/store.go
1118+++ b/server/store/store.go
1119@@ -8,4 +8,5 @@ type Store interface {
1120 SettingStore
1121 LFSStore
1122 AccessTokenStore
1123+ WebhookStore
1124 }
1125diff --git a/server/store/webhooks.go b/server/store/webhooks.go
1126new file mode 100644
1127index 0000000000000000000000000000000000000000..139753484141937345ef493c38f6e32a1653dc19
1128--- /dev/null
1129+++ b/server/store/webhooks.go
1130@@ -0,0 +1,48 @@
1131+package store
1132+
1133+import (
1134+ "context"
1135+
1136+ "github.com/charmbracelet/soft-serve/server/db"
1137+ "github.com/charmbracelet/soft-serve/server/db/models"
1138+ "github.com/google/uuid"
1139+)
1140+
1141+// WebhookStore is an interface for managing webhooks.
1142+type WebhookStore interface {
1143+ // GetWebhookByID returns a webhook by its ID.
1144+ GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error)
1145+ // GetWebhooksByRepoID returns all webhooks for a repository.
1146+ GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error)
1147+ // GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events.
1148+ GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error)
1149+ // CreateWebhook creates a webhook.
1150+ CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error)
1151+ // UpdateWebhookByID updates a webhook by its ID.
1152+ UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error
1153+ // DeleteWebhookByID deletes a webhook by its ID.
1154+ DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error
1155+ // DeleteWebhookForRepoByID deletes a webhook for a repository by its ID.
1156+ DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error
1157+
1158+ // GetWebhookEventByID returns a webhook event by its ID.
1159+ GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error)
1160+ // GetWebhookEventsByWebhookID returns all webhook events for a webhook.
1161+ GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error)
1162+ // CreateWebhookEvents creates webhook events for a webhook.
1163+ CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error
1164+ // DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook.
1165+ DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error
1166+
1167+ // GetWebhookDeliveryByID returns a webhook delivery by its ID.
1168+ GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error)
1169+ // GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
1170+ GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
1171+ // ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
1172+ // This only returns the delivery ID, response status, and event.
1173+ ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
1174+ // CreateWebhookDelivery creates a webhook delivery.
1175diff --git a/server/version/version.go b/server/version/version.go
1176new file mode 100644
1177index 0000000000000000000000000000000000000000..22db4c3e5de255c44dad7555a3f000cf7248c79c
1178--- /dev/null
1179+++ b/server/version/version.go
1180@@ -0,0 +1,14 @@
1181+// Package version is used to store the version of the server during runtime.
1182+// The values are set during runtime in the main package.
1183+package version
1184+
1185+var (
1186+ // Version is the version of the server.
1187+ Version = ""
1188+
1189+ // CommitSHA is the commit SHA of the server.
1190+ CommitSHA = ""
1191+
1192+ // CommitDate is the commit date of the server.
1193+ CommitDate = ""
1194+)
1195diff --git a/server/web/goget.go b/server/web/goget.go
1196index 3e56f9db8cbcc0f06439c78abef46d9ea9c49fd0..ac0e54c696cdc2f99242d2b6aa4f807346d56df9 100644
1197--- a/server/web/goget.go
1198+++ b/server/web/goget.go
1199@@ -70,6 +70,7 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1200 }
1201
1202 if repo == "" || repo == "." || repo == "/" {
1203+ renderNotFound(w, r)
1204 return
1205 }
1206
1207diff --git a/server/webhook/branch_tag.go b/server/webhook/branch_tag.go
1208new file mode 100644
1209index 0000000000000000000000000000000000000000..88f42bdde15cdeabc4e8f36971b64e55315bdfcb
1210--- /dev/null
1211+++ b/server/webhook/branch_tag.go
1212@@ -0,0 +1,86 @@
1213+package webhook
1214+
1215+import (
1216+ "context"
1217+ "fmt"
1218+
1219+ "github.com/charmbracelet/soft-serve/git"
1220+ "github.com/charmbracelet/soft-serve/server/config"
1221+ "github.com/charmbracelet/soft-serve/server/db"
1222+ "github.com/charmbracelet/soft-serve/server/proto"
1223+ "github.com/charmbracelet/soft-serve/server/store"
1224+)
1225+
1226+// BranchTagEvent is a branch or tag event.
1227+type BranchTagEvent struct {
1228+ Common
1229+
1230+ // Ref is the branch or tag name.
1231+ Ref string `json:"ref" url:"ref"`
1232+ // Before is the previous commit SHA.
1233+ Before string `json:"before" url:"before"`
1234+ // After is the current commit SHA.
1235+ After string `json:"after" url:"after"`
1236+ // Created is whether the branch or tag was created.
1237+ Created bool `json:"created" url:"created"`
1238+ // Deleted is whether the branch or tag was deleted.
1239+ Deleted bool `json:"deleted" url:"deleted"`
1240+}
1241+
1242+// NewBranchTagEvent sends a branch or tag event.
1243+func NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) {
1244+ var event Event
1245+ if git.IsZeroHash(before) {
1246+ event = EventBranchTagCreate
1247+ } else if git.IsZeroHash(after) {
1248+ event = EventBranchTagDelete
1249+ } else {
1250+ return BranchTagEvent{}, fmt.Errorf("invalid branch or tag event: before=%q after=%q", before, after)
1251+ }
1252+
1253+ payload := BranchTagEvent{
1254+ Ref: ref,
1255+ Before: before,
1256+ After: after,
1257+ Created: git.IsZeroHash(before),
1258+ Deleted: git.IsZeroHash(after),
1259+ Common: Common{
1260+ EventType: event,
1261+ Repository: Repository{
1262+ ID: repo.ID(),
1263+ Name: repo.Name(),
1264+ Description: repo.Description(),
1265+ ProjectName: repo.ProjectName(),
1266+ Private: repo.IsPrivate(),
1267+ CreatedAt: repo.CreatedAt(),
1268+ UpdatedAt: repo.UpdatedAt(),
1269+ },
1270+ Sender: User{
1271+ ID: user.ID(),
1272+ Username: user.Username(),
1273+ },
1274+ },
1275+ }
1276+
1277+ cfg := config.FromContext(ctx)
1278+ payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1279+ payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1280+ payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1281+
1282+ // Find repo owner.
1283+ dbx := db.FromContext(ctx)
1284+ datastore := store.FromContext(ctx)
1285+ owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1286+ if err != nil {
1287+ return BranchTagEvent{}, db.WrapError(err)
1288+ }
1289+
1290+ payload.Repository.Owner.ID = owner.ID
1291+ payload.Repository.Owner.Username = owner.Username
1292+ payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1293+ if err != nil {
1294+ return BranchTagEvent{}, err
1295+ }
1296+
1297+ return payload, nil
1298+}
1299diff --git a/server/webhook/collaborator.go b/server/webhook/collaborator.go
1300new file mode 100644
1301index 0000000000000000000000000000000000000000..86f3e86a1cd0ad24c34a7a8979b6fba3e354fc81
1302--- /dev/null
1303+++ b/server/webhook/collaborator.go
1304@@ -0,0 +1,83 @@
1305+package webhook
1306+
1307+import (
1308+ "context"
1309+
1310+ "github.com/charmbracelet/soft-serve/server/access"
1311+ "github.com/charmbracelet/soft-serve/server/db"
1312+ "github.com/charmbracelet/soft-serve/server/proto"
1313+ "github.com/charmbracelet/soft-serve/server/store"
1314+)
1315+
1316+// CollaboratorEvent is a collaborator event.
1317+type CollaboratorEvent struct {
1318+ Common
1319+
1320+ // Action is the collaborator event action.
1321+ Action CollaboratorEventAction `json:"action" url:"action"`
1322+ // AccessLevel is the collaborator access level.
1323+ AccessLevel access.AccessLevel `json:"access_level" url:"access_level"`
1324+ // Collaborator is the collaborator.
1325+ Collaborator User `json:"collaborator" url:"collaborator"`
1326+}
1327+
1328+// CollaboratorEventAction is a collaborator event action.
1329+type CollaboratorEventAction string
1330+
1331+const (
1332+ // CollaboratorEventAdded is a collaborator added event.
1333+ CollaboratorEventAdded CollaboratorEventAction = "added"
1334+ // CollaboratorEventRemoved is a collaborator removed event.
1335+ CollaboratorEventRemoved CollaboratorEventAction = "removed"
1336+)
1337+
1338+// NewCollaboratorEvent sends a collaborator event.
1339+func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) {
1340+ event := EventCollaborator
1341+
1342+ payload := CollaboratorEvent{
1343+ Action: action,
1344+ Common: Common{
1345+ EventType: event,
1346+ Repository: Repository{
1347+ ID: repo.ID(),
1348+ Name: repo.Name(),
1349+ Description: repo.Description(),
1350+ ProjectName: repo.ProjectName(),
1351+ Private: repo.IsPrivate(),
1352+ CreatedAt: repo.CreatedAt(),
1353+ UpdatedAt: repo.UpdatedAt(),
1354+ },
1355+ Sender: User{
1356+ ID: user.ID(),
1357+ Username: user.Username(),
1358+ },
1359+ },
1360+ }
1361+
1362+ // Find repo owner.
1363+ dbx := db.FromContext(ctx)
1364+ datastore := store.FromContext(ctx)
1365+ owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1366+ if err != nil {
1367+ return CollaboratorEvent{}, db.WrapError(err)
1368+ }
1369+
1370+ payload.Repository.Owner.ID = owner.ID
1371+ payload.Repository.Owner.Username = owner.Username
1372+ payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1373+ if err != nil {
1374+ return CollaboratorEvent{}, err
1375+ }
1376+
1377+ collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name())
1378+ if err != nil {
1379+ return CollaboratorEvent{}, err
1380+ }
1381+
1382+ payload.AccessLevel = collab.AccessLevel
1383+ payload.Collaborator.ID = collab.UserID
1384+ payload.Collaborator.Username = collabUsername
1385+
1386+ return payload, nil
1387+}
1388diff --git a/server/webhook/common.go b/server/webhook/common.go
1389new file mode 100644
1390index 0000000000000000000000000000000000000000..b805c24a85149ed762ed3002b04b77137006b23b
1391--- /dev/null
1392+++ b/server/webhook/common.go
1393@@ -0,0 +1,95 @@
1394+package webhook
1395+
1396+import "time"
1397+
1398+// EventPayload is a webhook event payload.
1399+type EventPayload interface {
1400+ // Event returns the event type.
1401+ Event() Event
1402+ // RepositoryID returns the repository ID.
1403+ RepositoryID() int64
1404+}
1405+
1406+// Common is a common payload.
1407+type Common struct {
1408+ // EventType is the event type.
1409+ EventType Event `json:"event" url:"event"`
1410+ // Repository is the repository payload.
1411+ Repository Repository `json:"repository" url:"repository"`
1412+ // Sender is the sender payload.
1413+ Sender User `json:"sender" url:"sender"`
1414+}
1415+
1416+// Event returns the event type.
1417+// Implements EventPayload.
1418+func (c Common) Event() Event {
1419+ return c.EventType
1420+}
1421+
1422+// RepositoryID returns the repository ID.
1423+// Implements EventPayload.
1424+func (c Common) RepositoryID() int64 {
1425+ return c.Repository.ID
1426+}
1427+
1428+// User represents a user in an event.
1429+type User struct {
1430+ // ID is the owner ID.
1431+ ID int64 `json:"id" url:"id"`
1432+ // Username is the owner username.
1433+ Username string `json:"username" url:"username"`
1434+}
1435+
1436+// Repository represents an event repository.
1437+type Repository struct {
1438+ // ID is the repository ID.
1439+ ID int64 `json:"id" url:"id"`
1440+ // Name is the repository name.
1441+ Name string `json:"name" url:"name"`
1442+ // ProjectName is the repository project name.
1443+ ProjectName string `json:"project_name" url:"project_name"`
1444+ // Description is the repository description.
1445+ Description string `json:"description" url:"description"`
1446+ // DefaultBranch is the repository default branch.
1447+ DefaultBranch string `json:"default_branch" url:"default_branch"`
1448+ // Private is whether the repository is private.
1449+ Private bool `json:"private" url:"private"`
1450+ // Owner is the repository owner.
1451+ Owner User `json:"owner" url:"owner"`
1452+ // HTTPURL is the repository HTTP URL.
1453+ HTTPURL string `json:"http_url" url:"http_url"`
1454+ // SSHURL is the repository SSH URL.
1455+ SSHURL string `json:"ssh_url" url:"ssh_url"`
1456+ // GitURL is the repository Git URL.
1457+ GitURL string `json:"git_url" url:"git_url"`
1458+ // CreatedAt is the repository creation time.
1459+ CreatedAt time.Time `json:"created_at" url:"created_at"`
1460+ // UpdatedAt is the repository last update time.
1461+ UpdatedAt time.Time `json:"updated_at" url:"updated_at"`
1462+}
1463+
1464+// Author is a commit author.
1465+type Author struct {
1466+ // Name is the author name.
1467+ Name string `json:"name" url:"name"`
1468+ // Email is the author email.
1469+ Email string `json:"email" url:"email"`
1470+ // Date is the author date.
1471+ Date time.Time `json:"date" url:"date"`
1472+}
1473+
1474+// Commit represents a Git commit.
1475+type Commit struct {
1476+ // ID is the commit ID.
1477+ ID string `json:"id" url:"id"`
1478+ // Message is the commit message.
1479+ Message string `json:"message" url:"message"`
1480+ // Title is the commit title.
1481+ Title string `json:"title" url:"title"`
1482+ // Author is the commit author.
1483+ Author Author `json:"author" url:"author"`
1484+ // Committer is the commit committer.
1485+ Committer Author `json:"committer" url:"committer"`
1486+ // Timestamp is the commit timestamp.
1487+ Timestamp time.Time `json:"timestamp" url:"timestamp"`
1488+}
1489diff --git a/server/webhook/content_type.go b/server/webhook/content_type.go
1490new file mode 100644
1491index 0000000000000000000000000000000000000000..31731d287bd008d2bf1c740b9f9d0f5d79497295
1492--- /dev/null
1493+++ b/server/webhook/content_type.go
1494@@ -0,0 +1,70 @@
1495+package webhook
1496+
1497+import (
1498+ "encoding"
1499+ "errors"
1500+ "strings"
1501+)
1502+
1503+// ContentType is the type of content that will be sent in a webhook request.
1504+type ContentType int8
1505+
1506+const (
1507+ // ContentTypeJSON is the JSON content type.
1508+ ContentTypeJSON ContentType = iota
1509+ // ContentTypeForm is the form content type.
1510+ ContentTypeForm
1511+)
1512+
1513+var contentTypeStrings = map[ContentType]string{
1514+ ContentTypeJSON: "application/json",
1515+ ContentTypeForm: "application/x-www-form-urlencoded",
1516+}
1517+
1518+// String returns the string representation of the content type.
1519+func (c ContentType) String() string {
1520+ return contentTypeStrings[c]
1521+}
1522+
1523+var stringContentType = map[string]ContentType{
1524+ "application/json": ContentTypeJSON,
1525+ "application/x-www-form-urlencoded": ContentTypeForm,
1526+}
1527+
1528+// ErrInvalidContentType is returned when the content type is invalid.
1529+var ErrInvalidContentType = errors.New("invalid content type")
1530+
1531+// ParseContentType parses a content type string and returns the content type.
1532+func ParseContentType(s string) (ContentType, error) {
1533+ for k, v := range stringContentType {
1534+ if strings.HasPrefix(s, k) {
1535+ return v, nil
1536+ }
1537+ }
1538+
1539+ return -1, ErrInvalidContentType
1540+}
1541+
1542+var _ encoding.TextMarshaler = ContentType(0)
1543+var _ encoding.TextUnmarshaler = (*ContentType)(nil)
1544+
1545+// UnmarshalText implements encoding.TextUnmarshaler.
1546+func (c *ContentType) UnmarshalText(text []byte) error {
1547+ ct, err := ParseContentType(string(text))
1548+ if err != nil {
1549+ return err
1550+ }
1551+
1552+ *c = ct
1553+ return nil
1554+}
1555+
1556+// MarshalText implements encoding.TextMarshaler.
1557+func (c ContentType) MarshalText() (text []byte, err error) {
1558+ ct := c.String()
1559+ if ct == "" {
1560+ return nil, ErrInvalidContentType
1561+ }
1562+
1563+ return []byte(ct), nil
1564+}
1565diff --git a/server/webhook/event.go b/server/webhook/event.go
1566new file mode 100644
1567index 0000000000000000000000000000000000000000..09edb9cab7b276d436ec46bde13774d157a1d51e
1568--- /dev/null
1569+++ b/server/webhook/event.go
1570@@ -0,0 +1,101 @@
1571+package webhook
1572+
1573+import (
1574+ "encoding"
1575+ "errors"
1576+)
1577+
1578+// Event is a webhook event.
1579+type Event int
1580+
1581+const (
1582+ // EventBranchTagCreate is a branch or tag create event.
1583+ EventBranchTagCreate Event = 1
1584+
1585+ // EventBranchTagDelete is a branch or tag delete event.
1586+ EventBranchTagDelete Event = 2
1587+
1588+ // EventCollaborator is a collaborator change event.
1589+ EventCollaborator Event = 3
1590+
1591+ // EventPush is a push event.
1592+ EventPush Event = 4
1593+
1594+ // EventRepository is a repository create, delete, rename event.
1595+ EventRepository Event = 5
1596+
1597+ // EventRepositoryVisibilityChange is a repository visibility change event.
1598+ EventRepositoryVisibilityChange Event = 6
1599+)
1600+
1601+// Events return all events.
1602+func Events() []Event {
1603+ return []Event{
1604+ EventBranchTagCreate,
1605+ EventBranchTagDelete,
1606+ EventCollaborator,
1607+ EventPush,
1608+ EventRepository,
1609+ EventRepositoryVisibilityChange,
1610+ }
1611+}
1612+
1613+var eventStrings = map[Event]string{
1614+ EventBranchTagCreate: "branch_tag_create",
1615+ EventBranchTagDelete: "branch_tag_delete",
1616+ EventCollaborator: "collaborator",
1617+ EventPush: "push",
1618+ EventRepository: "repository",
1619+ EventRepositoryVisibilityChange: "repository_visibility_change",
1620+}
1621+
1622+// String returns the string representation of the event.
1623+func (e Event) String() string {
1624+ return eventStrings[e]
1625+}
1626+
1627+var stringEvent = map[string]Event{
1628+ "branch_tag_create": EventBranchTagCreate,
1629+ "branch_tag_delete": EventBranchTagDelete,
1630+ "collaborator": EventCollaborator,
1631+ "push": EventPush,
1632+ "repository": EventRepository,
1633+ "repository_visibility_change": EventRepositoryVisibilityChange,
1634+}
1635+
1636+// ErrInvalidEvent is returned when the event is invalid.
1637+var ErrInvalidEvent = errors.New("invalid event")
1638+
1639+// ParseEvent parses an event string and returns the event.
1640+func ParseEvent(s string) (Event, error) {
1641+ e, ok := stringEvent[s]
1642+ if !ok {
1643+ return -1, ErrInvalidEvent
1644+ }
1645+
1646+ return e, nil
1647+}
1648+
1649+var _ encoding.TextMarshaler = Event(0)
1650+var _ encoding.TextUnmarshaler = (*Event)(nil)
1651+
1652+// UnmarshalText implements encoding.TextUnmarshaler.
1653+func (e *Event) UnmarshalText(text []byte) error {
1654+ ev, err := ParseEvent(string(text))
1655+ if err != nil {
1656+ return err
1657+ }
1658+
1659+ *e = ev
1660+ return nil
1661+}
1662+
1663+// MarshalText implements encoding.TextMarshaler.
1664+func (e Event) MarshalText() (text []byte, err error) {
1665+ ev := e.String()
1666+ if ev == "" {
1667+ return nil, ErrInvalidEvent
1668+ }
1669+
1670diff --git a/server/webhook/push.go b/server/webhook/push.go
1671new file mode 100644
1672index 0000000000000000000000000000000000000000..4642aeb810e7a1f522c36cfbf2eaf45c5bc3e205
1673--- /dev/null
1674+++ b/server/webhook/push.go
1675@@ -0,0 +1,117 @@
1676+package webhook
1677+
1678+import (
1679+ "context"
1680+ "fmt"
1681+
1682+ "github.com/charmbracelet/soft-serve/git"
1683+ "github.com/charmbracelet/soft-serve/server/config"
1684+ "github.com/charmbracelet/soft-serve/server/db"
1685+ "github.com/charmbracelet/soft-serve/server/proto"
1686+ "github.com/charmbracelet/soft-serve/server/store"
1687+ gitm "github.com/gogs/git-module"
1688+)
1689+
1690+// PushEvent is a push event.
1691+type PushEvent struct {
1692+ Common
1693+
1694+ // Ref is the branch or tag name.
1695+ Ref string `json:"ref" url:"ref"`
1696+ // Before is the previous commit SHA.
1697+ Before string `json:"before" url:"before"`
1698+ // After is the current commit SHA.
1699+ After string `json:"after" url:"after"`
1700+ // Commits is the list of commits.
1701+ Commits []Commit `json:"commits" url:"commits"`
1702+}
1703+
1704+// NewPushEvent sends a push event.
1705+func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) {
1706+ event := EventPush
1707+
1708+ payload := PushEvent{
1709+ Ref: ref,
1710+ Before: before,
1711+ After: after,
1712+ Common: Common{
1713+ EventType: event,
1714+ Repository: Repository{
1715+ ID: repo.ID(),
1716+ Name: repo.Name(),
1717+ Description: repo.Description(),
1718+ ProjectName: repo.ProjectName(),
1719+ Private: repo.IsPrivate(),
1720+ CreatedAt: repo.CreatedAt(),
1721+ UpdatedAt: repo.UpdatedAt(),
1722+ },
1723+ Sender: User{
1724+ ID: user.ID(),
1725+ Username: user.Username(),
1726+ },
1727+ },
1728+ }
1729+
1730+ cfg := config.FromContext(ctx)
1731+ payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1732+ payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1733+ payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1734+
1735+ // Find repo owner.
1736+ dbx := db.FromContext(ctx)
1737+ datastore := store.FromContext(ctx)
1738+ owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1739+ if err != nil {
1740+ return PushEvent{}, db.WrapError(err)
1741+ }
1742+
1743+ payload.Repository.Owner.ID = owner.ID
1744+ payload.Repository.Owner.Username = owner.Username
1745+
1746+ // Find commits.
1747+ r, err := repo.Open()
1748+ if err != nil {
1749+ return PushEvent{}, err
1750+ }
1751+
1752+ payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1753+ if err != nil {
1754+ return PushEvent{}, err
1755+ }
1756+
1757+ rev := after
1758+ if !git.IsZeroHash(before) {
1759+ rev = fmt.Sprintf("%s..%s", before, after)
1760+ }
1761+
1762+ commits, err := r.Log(rev, gitm.LogOptions{
1763+ // XXX: limit to 20 commits for now
1764+ // TODO: implement a commits api
1765+ MaxCount: 20,
1766+ })
1767+ if err != nil {
1768+ return PushEvent{}, err
1769+ }
1770+
1771+ payload.Commits = make([]Commit, len(commits))
1772+ for i, c := range commits {
1773+ payload.Commits[i] = Commit{
1774+ ID: c.ID.String(),
1775diff --git a/server/webhook/repository.go b/server/webhook/repository.go
1776new file mode 100644
1777index 0000000000000000000000000000000000000000..19cb7230405ea01d455839a766849099c7b69f1d
1778--- /dev/null
1779+++ b/server/webhook/repository.go
1780@@ -0,0 +1,82 @@
1781+package webhook
1782+
1783+import (
1784+ "context"
1785+
1786+ "github.com/charmbracelet/soft-serve/server/config"
1787+ "github.com/charmbracelet/soft-serve/server/db"
1788+ "github.com/charmbracelet/soft-serve/server/proto"
1789+ "github.com/charmbracelet/soft-serve/server/store"
1790+)
1791+
1792+// RepositoryEvent is a repository payload.
1793+type RepositoryEvent struct {
1794+ Common
1795+
1796+ // Action is the repository event action.
1797+ Action RepositoryEventAction `json:"action" url:"action"`
1798+}
1799+
1800+// RepositoryEventAction is a repository event action.
1801+type RepositoryEventAction string
1802+
1803+const (
1804+ // RepositoryEventActionDelete is a repository deleted event.
1805+ RepositoryEventActionDelete RepositoryEventAction = "delete"
1806+ // RepositoryEventActionRename is a repository renamed event.
1807+ RepositoryEventActionRename RepositoryEventAction = "rename"
1808+ // RepositoryEventActionVisibilityChange is a repository visibility changed event.
1809+ RepositoryEventActionVisibilityChange RepositoryEventAction = "visibility_change"
1810+ // RepositoryEventActionDefaultBranchChange is a repository default branch changed event.
1811+ RepositoryEventActionDefaultBranchChange RepositoryEventAction = "default_branch_change"
1812+)
1813+
1814+// NewRepositoryEvent sends a repository event.
1815+func NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) {
1816+ var event Event
1817+ switch action {
1818+ case RepositoryEventActionVisibilityChange:
1819+ event = EventRepositoryVisibilityChange
1820+ default:
1821+ event = EventRepository
1822+ }
1823+
1824+ payload := RepositoryEvent{
1825+ Action: action,
1826+ Common: Common{
1827+ EventType: event,
1828+ Repository: Repository{
1829+ ID: repo.ID(),
1830+ Name: repo.Name(),
1831+ Description: repo.Description(),
1832+ ProjectName: repo.ProjectName(),
1833+ Private: repo.IsPrivate(),
1834+ CreatedAt: repo.CreatedAt(),
1835+ UpdatedAt: repo.UpdatedAt(),
1836+ },
1837+ Sender: User{
1838+ ID: user.ID(),
1839+ Username: user.Username(),
1840+ },
1841+ },
1842+ }
1843+
1844+ cfg := config.FromContext(ctx)
1845+ payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1846+ payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1847+ payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1848+
1849+ // Find repo owner.
1850+ dbx := db.FromContext(ctx)
1851+ datastore := store.FromContext(ctx)
1852+ owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1853+ if err != nil {
1854+ return RepositoryEvent{}, db.WrapError(err)
1855+ }
1856+
1857+ payload.Repository.Owner.ID = owner.ID
1858+ payload.Repository.Owner.Username = owner.Username
1859+ payload.Repository.DefaultBranch, _ = proto.RepositoryDefaultBranch(repo)
1860+
1861+ return payload, nil
1862+}
1863diff --git a/server/webhook/webhook.go b/server/webhook/webhook.go
1864new file mode 100644
1865index 0000000000000000000000000000000000000000..0429056c2745b145473ac8730cd51bcabb6b9e1d
1866--- /dev/null
1867+++ b/server/webhook/webhook.go
1868@@ -0,0 +1,144 @@
1869+package webhook
1870+
1871+import (
1872+ "bytes"
1873+ "context"
1874+ "crypto/hmac"
1875+ "crypto/sha256"
1876+ "encoding/hex"
1877+ "encoding/json"
1878+ "fmt"
1879+ "io"
1880+ "net/http"
1881+
1882+ "github.com/charmbracelet/soft-serve/server/db"
1883+ "github.com/charmbracelet/soft-serve/server/db/models"
1884+ "github.com/charmbracelet/soft-serve/server/store"
1885+ "github.com/charmbracelet/soft-serve/server/utils"
1886+ "github.com/charmbracelet/soft-serve/server/version"
1887+ "github.com/google/go-querystring/query"
1888+ "github.com/google/uuid"
1889+)
1890+
1891+// Hook is a repository webhook.
1892+type Hook struct {
1893+ models.Webhook
1894+ ContentType ContentType
1895+ Events []Event
1896+}
1897+
1898+// Delivery is a webhook delivery.
1899+type Delivery struct {
1900+ models.WebhookDelivery
1901+ Event Event
1902+}
1903+
1904+// do sends a webhook.
1905+// Caller must close the returned body.
1906+func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {
1907+ req, err := http.NewRequestWithContext(ctx, method, url, body)
1908+ if err != nil {
1909+ return nil, err
1910+ }
1911+
1912+ req.Header = headers
1913+ res, err := http.DefaultClient.Do(req)
1914+ if err != nil {
1915+ return nil, err
1916+ }
1917+
1918+ return res, nil
1919+}
1920+
1921+// SendWebhook sends a webhook event.
1922+func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {
1923+ var buf bytes.Buffer
1924+ dbx := db.FromContext(ctx)
1925+ datastore := store.FromContext(ctx)
1926+
1927+ contentType := ContentType(w.ContentType)
1928+ switch contentType {
1929+ case ContentTypeJSON:
1930+ if err := json.NewEncoder(&buf).Encode(payload); err != nil {
1931+ return err
1932+ }
1933+ case ContentTypeForm:
1934+ v, err := query.Values(payload)
1935+ if err != nil {
1936+ return err
1937+ }
1938+ buf.WriteString(v.Encode()) // nolint: errcheck
1939+ default:
1940+ return ErrInvalidContentType
1941+ }
1942+
1943+ headers := http.Header{}
1944+ headers.Add("Content-Type", contentType.String())
1945+ headers.Add("User-Agent", "SoftServe/"+version.Version)
1946+ headers.Add("X-SoftServe-Event", event.String())
1947+
1948+ id, err := uuid.NewUUID()
1949+ if err != nil {
1950+ return err
1951+ }
1952+
1953+ headers.Add("X-SoftServe-Delivery", id.String())
1954+
1955+ reqBody := buf.String()
1956+ if w.Secret != "" {
1957+ sig := hmac.New(sha256.New, []byte(w.Secret))
1958+ sig.Write([]byte(reqBody)) // nolint: errcheck
1959+ headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil)))
1960+ }
1961+
1962+ res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)
1963+ var reqHeaders string
1964+ for k, v := range headers {
1965+ reqHeaders += k + ": " + v[0] + "\n"
1966+ }
1967+
1968diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
1969index 68bec65d584f0e032d1a78f6e8a3f128ff5447a0..f2af369af97c6658c0bc9cffc02983ef61ee4ce1 100644
1970--- a/testscript/testdata/http.txtar
1971+++ b/testscript/testdata/http.txtar
1972@@ -84,6 +84,10 @@ cmpenv stdout goget.txt
1973 curl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
1974 stdout '404.*'
1975
1976+# go-get not found (invalid repo)
1977+curl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1
1978+stdout '404.*'
1979+
1980 # set private
1981 soft repo private repo2 true
1982
1983diff --git a/testscript/testdata/repo-webhooks.txtar b/testscript/testdata/repo-webhooks.txtar
1984new file mode 100644
1985index 0000000000000000000000000000000000000000..bee002e5ca9298d2a30b4b61cb7c224f98410b53
1986--- /dev/null
1987+++ b/testscript/testdata/repo-webhooks.txtar
1988@@ -0,0 +1,27 @@
1989+# vi: set ft=conf
1990+
1991+# create a repo
1992+soft repo create repo-123
1993+stderr 'Created repository repo-123.*'
1994+stdout ssh://localhost:$SSH_PORT/repo-123.git
1995+
1996+# create webhook
1997+soft repo webhook create repo-123 https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change
1998+
1999+# list webhooks
2000+soft repo webhook list repo-123
2001+stdout '1.*https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17.*'
2002+
2003+# clone repo
2004+git clone ssh://localhost:$SSH_PORT/repo-123 repo-123
2005+
2006+# create files
2007+mkfile ./repo-123/README.md 'foobar'
2008+git -C repo-123 add -A
2009+git -C repo-123 commit -m 'first'
2010+git -C repo-123 push origin HEAD
2011+
2012+# list webhook deliveries
2013+# TODO: enable this test when githooks tests are fixed
2014+# soft repo webhook deliver list repo-123 1
2015+# stdout '.*https://webhook.site/.*'