Repository webhooks (#375)

Ayman Bagabas created

* feat: export server version

* fix: move db driver imports to db package

* feat: implement server webhooks

- branch/tag events
- collaborators events
- push events
- repository events

- [x] Implement database logic
- [x] Add database migrations
- [x] Implement webhooks logic
- [x] Integrate webhooks with backend
- [x] Implement repository webhooks SSH command interface
- [x] Implement webhook deliveries listing

Fixes: https://github.com/charmbracelet/soft-serve/issues/148
Fixes: https://github.com/charmbracelet/soft-serve/pull/56
Fixes: https://github.com/charmbracelet/soft-serve/issues/49

* wip

* fix: remove unnecessary webhook events

* fix(db): postgres migration script

* fix(db): use returning instead of LastInsertId

* fix(webhook): limit the number of push commits to 20

* fix(webhook): rename html_url to http_url

* fix(http): return 404 when repository on go-get not found

Change summary

cmd/soft/browse.go                                |   5 
cmd/soft/root.go                                  |   9 
git/commit.go                                     |   8 
go.mod                                            |   3 
go.sum                                            |   3 
server/backend/collab.go                          |  43 +
server/backend/hooks.go                           |  56 ++
server/backend/repo.go                            |  83 +++
server/backend/webhooks.go                        | 279 +++++++++++
server/config/config.go                           |   5 
server/config/file.go                             |   4 
server/db/errors.go                               |   8 
server/db/migrate/0002_webhooks.go                |  23 
server/db/migrate/0002_webhooks_postgres.down.sql |   0 
server/db/migrate/0002_webhooks_postgres.up.sql   |  46 +
server/db/migrate/0002_webhooks_sqlite.down.sql   |   0 
server/db/migrate/0002_webhooks_sqlite.up.sql     |  46 +
server/db/migrate/migrations.go                   |   1 
server/db/models/webhook.go                       |  44 +
server/proto/errors.go                            |   2 
server/proto/repo.go                              |  17 
server/ssh/cmd/branch.go                          |  27 +
server/ssh/cmd/cmd.go                             |  20 
server/ssh/cmd/repo.go                            |   1 
server/ssh/cmd/tag.go                             |  39 +
server/ssh/cmd/webhooks.go                        | 406 +++++++++++++++++
server/store/database/database.go                 |   1 
server/store/database/webhooks.go                 | 165 ++++++
server/store/store.go                             |   1 
server/store/webhooks.go                          |  48 ++
server/version/version.go                         |  14 
server/web/goget.go                               |   1 
server/webhook/branch_tag.go                      |  86 +++
server/webhook/collaborator.go                    |  83 +++
server/webhook/common.go                          |  95 +++
server/webhook/content_type.go                    |  70 ++
server/webhook/event.go                           | 101 ++++
server/webhook/push.go                            | 117 ++++
server/webhook/repository.go                      |  82 +++
server/webhook/webhook.go                         | 144 ++++++
testscript/testdata/http.txtar                    |   4 
testscript/testdata/repo-webhooks.txtar           |  27 +
42 files changed, 2,191 insertions(+), 26 deletions(-)

Detailed changes

cmd/soft/browse.go 🔗

@@ -299,3 +299,8 @@ func (r repository) UpdatedAt() time.Time {
 func (r repository) UserID() int64 {
 	return 0
 }
+
+// CreatedAt implements proto.Repository.
+func (r repository) CreatedAt() time.Time {
+	return time.Time{}
+}

cmd/soft/root.go 🔗

@@ -15,6 +15,7 @@ import (
 	logr "github.com/charmbracelet/soft-serve/server/log"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/soft-serve/server/store/database"
+	"github.com/charmbracelet/soft-serve/server/version"
 	"github.com/spf13/cobra"
 	"go.uber.org/automaxprocs/maxprocs"
 )
@@ -28,6 +29,10 @@ var (
 	// against. It's set via ldflags when building.
 	CommitSHA = ""
 
+	// CommitDate contains the date of the commit that this application was
+	// built against. It's set via ldflags when building.
+	CommitDate = ""
+
 	rootCmd = &cobra.Command{
 		Use:          "soft",
 		Short:        "A self-hostable Git server for the command line",
@@ -61,6 +66,10 @@ func init() {
 		}
 	}
 	rootCmd.Version = Version
+
+	version.Version = Version
+	version.CommitSHA = CommitSHA
+	version.CommitDate = CommitDate
 }
 
 func main() {

git/commit.go 🔗

@@ -1,12 +1,20 @@
 package git
 
 import (
+	"regexp"
+
 	"github.com/gogs/git-module"
 )
 
 // ZeroID is the zero hash.
 const ZeroID = git.EmptyID
 
+// IsZeroHash returns whether the hash is a zero hash.
+func IsZeroHash(h string) bool {
+	pattern := regexp.MustCompile(`^0{40,}$`)
+	return pattern.MatchString(h)
+}
+
 // Commit is a wrapper around git.Commit with helper methods.
 type Commit = git.Commit
 

go.mod 🔗

@@ -31,6 +31,8 @@ require (
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.8.3
 	github.com/golang-jwt/jwt/v5 v5.0.0
+	github.com/google/go-querystring v1.1.0
+	github.com/google/uuid v1.3.0
 	github.com/gorilla/handlers v1.5.1
 	github.com/gorilla/mux v1.8.0
 	github.com/hashicorp/golang-lru/v2 v2.0.7
@@ -65,7 +67,6 @@ require (
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
-	github.com/google/uuid v1.3.0 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect

go.sum 🔗

@@ -72,8 +72,11 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

server/backend/collab.go 🔗

@@ -2,12 +2,15 @@ package backend
 
 import (
 	"context"
+	"errors"
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/charmbracelet/soft-serve/server/webhook"
 )
 
 // AddCollaborator adds a collaborator to a repository.
@@ -20,11 +23,25 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
 	}
 
 	repo = utils.SanitizeRepo(repo)
-	return db.WrapError(
+	r, err := d.Repository(ctx, repo)
+	if err != nil {
+		return err
+	}
+
+	if err := db.WrapError(
 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 			return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
 		}),
-	)
+	); err != nil {
+		return err
+	}
+
+	wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded)
+	if err != nil {
+		return err
+	}
+
+	return webhook.SendEvent(ctx, wh)
 }
 
 // Collaborators returns a list of collaborators for a repository.
@@ -75,9 +92,27 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
 // It implements backend.Backend.
 func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {
 	repo = utils.SanitizeRepo(repo)
-	return db.WrapError(
+	r, err := d.Repository(ctx, repo)
+	if err != nil {
+		return err
+	}
+
+	wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved)
+	if err != nil {
+		return err
+	}
+
+	if err := db.WrapError(
 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 			return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)
 		}),
-	)
+	); err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return proto.ErrCollaboratorNotFound
+		}
+
+		return err
+	}
+
+	return webhook.SendEvent(ctx, wh)
 }

server/backend/hooks.go 🔗

@@ -3,10 +3,14 @@ package backend
 import (
 	"context"
 	"io"
+	"os"
 	"sync"
 
+	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/hooks"
 	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/sshutils"
+	"github.com/charmbracelet/soft-serve/server/webhook"
 )
 
 var _ hooks.Hooks = (*Backend)(nil)
@@ -28,8 +32,58 @@ func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo s
 // Update is called by the git update hook.
 //
 // It implements Hooks.
-func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
+func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
 	d.logger.Debug("update hook called", "repo", repo, "arg", arg)
+
+	// Find user
+	var user proto.User
+	if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" {
+		pk, _, err := sshutils.ParseAuthorizedKey(pubkey)
+		if err != nil {
+			d.logger.Error("error parsing public key", "err", err)
+			return
+		}
+
+		user, err = d.UserByPublicKey(ctx, pk)
+		if err != nil {
+			d.logger.Error("error finding user from public key", "key", pubkey, "err", err)
+			return
+		}
+	} else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" {
+		var err error
+		user, err = d.User(ctx, username)
+		if err != nil {
+			d.logger.Error("error finding user from username", "username", username, "err", err)
+			return
+		}
+	} else {
+		d.logger.Error("error finding user")
+		return
+	}
+
+	// Get repo
+	r, err := d.Repository(ctx, repo)
+	if err != nil {
+		d.logger.Error("error finding repository", "repo", repo, "err", err)
+		return
+	}
+
+	// TODO: run this async
+	// This would probably need something like an RPC server to communicate with the hook process.
+	if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {
+		wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
+		if err != nil {
+			d.logger.Error("error creating branch_tag webhook", "err", err)
+		} else if err := webhook.SendEvent(ctx, wh); err != nil {
+			d.logger.Error("error sending branch_tag webhook", "err", err)
+		}
+	}
+	wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
+	if err != nil {
+		d.logger.Error("error creating push webhook", "err", err)
+	} else if err := webhook.SendEvent(ctx, wh); err != nil {
+		d.logger.Error("error sending push webhook", "err", err)
+	}
 }
 
 // PostUpdate is called by the git post-update hook.

server/backend/repo.go 🔗

@@ -21,6 +21,7 @@ import (
 	"github.com/charmbracelet/soft-serve/server/storage"
 	"github.com/charmbracelet/soft-serve/server/task"
 	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/charmbracelet/soft-serve/server/webhook"
 )
 
 func (d *Backend) reposPath() string {
@@ -216,7 +217,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 	repo := name + ".git"
 	rp := filepath.Join(d.reposPath(), repo)
 
-	err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+	user := proto.UserFromContext(ctx)
+	r, err := d.Repository(ctx, name)
+	if err != nil {
+		return err
+	}
+
+	// We create the webhook event before deleting the repository so we can
+	// send the event after deleting the repository.
+	wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
+	if err != nil {
+		return err
+	}
+
+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		// Delete repo from cache
 		defer d.cache.Delete(name)
 
@@ -257,17 +271,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 		}
 
 		return os.RemoveAll(rp)
-	})
-	if errors.Is(err, db.ErrRecordNotFound) {
-		return proto.ErrRepoNotFound
+	}); err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return proto.ErrRepoNotFound
+		}
+
+		return db.WrapError(err)
 	}
 
-	return err
+	return webhook.SendEvent(ctx, wh)
 }
 
 // DeleteUserRepositories deletes all user repositories.
 func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
-	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		user, err := d.store.FindUserByUsername(ctx, tx, username)
 		if err != nil {
 			return err
@@ -285,7 +302,11 @@ func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) e
 		}
 
 		return nil
-	})
+	}); err != nil {
+		return db.WrapError(err)
+	}
+
+	return nil
 }
 
 // RenameRepository renames a repository.
@@ -301,6 +322,11 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 	if err := utils.ValidateRepo(newName); err != nil {
 		return err
 	}
+
+	if oldName == newName {
+		return nil
+	}
+
 	oldRepo := oldName + ".git"
 	newRepo := newName + ".git"
 	op := filepath.Join(d.reposPath(), oldRepo)
@@ -331,7 +357,18 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 		return db.WrapError(err)
 	}
 
-	return nil
+	user := proto.UserFromContext(ctx)
+	repo, err := d.Repository(ctx, newName)
+	if err != nil {
+		return err
+	}
+
+	wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
+	if err != nil {
+		return err
+	}
+
+	return webhook.SendEvent(ctx, wh)
 }
 
 // Repositories returns a list of repositories per page.
@@ -537,7 +574,7 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) err
 	// Delete cache
 	d.cache.Delete(name)
 
-	return db.WrapError(
+	if err := db.WrapError(
 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 			fp := filepath.Join(rp, "git-daemon-export-ok")
 			if !private {
@@ -556,7 +593,28 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) err
 
 			return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
 		}),
-	)
+	); err != nil {
+		return err
+	}
+
+	user := proto.UserFromContext(ctx)
+	repo, err := d.Repository(ctx, name)
+	if err != nil {
+		return err
+	}
+
+	if repo.IsPrivate() != !private {
+		wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)
+		if err != nil {
+			return err
+		}
+
+		if err := webhook.SendEvent(ctx, wh); err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
 // SetProjectName sets the project name of a repository.
@@ -651,6 +709,11 @@ func (r *repo) IsHidden() bool {
 	return r.repo.Hidden
 }
 
+// CreatedAt returns the repository's creation time.
+func (r *repo) CreatedAt() time.Time {
+	return r.repo.CreatedAt
+}
+
 // UpdatedAt returns the repository's last update time.
 func (r *repo) UpdatedAt() time.Time {
 	// Try to read the last modified time from the info directory.

server/backend/webhooks.go 🔗

@@ -0,0 +1,279 @@
+package backend
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/store"
+	"github.com/charmbracelet/soft-serve/server/webhook"
+	"github.com/google/uuid"
+)
+
+// CreateWebhook creates a webhook for a repository.
+func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		evs := make([]int, len(events))
+		for i, e := range events {
+			evs[i] = int(e)
+		}
+		if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	})
+}
+
+// Webhook returns a webhook for a repository.
+func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	var wh webhook.Hook
+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+		events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		wh = webhook.Hook{
+			Webhook:     h,
+			ContentType: webhook.ContentType(h.ContentType),
+			Events:      make([]webhook.Event, len(events)),
+		}
+		for i, e := range events {
+			wh.Events[i] = webhook.Event(e.Event)
+		}
+
+		return nil
+	}); err != nil {
+		return webhook.Hook{}, db.WrapError(err)
+	}
+
+	return wh, nil
+}
+
+// ListWebhooks lists webhooks for a repository.
+func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	var webhooks []models.Webhook
+	webhookEvents := map[int64][]models.WebhookEvent{}
+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		var err error
+		webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID())
+		if err != nil {
+			return err
+		}
+
+		for _, h := range webhooks {
+			events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID)
+			if err != nil {
+				return err
+			}
+			webhookEvents[h.ID] = events
+		}
+
+		return nil
+	}); err != nil {
+		return nil, db.WrapError(err)
+	}
+
+	hooks := make([]webhook.Hook, len(webhooks))
+	for i, h := range webhooks {
+		events := make([]webhook.Event, len(webhookEvents[h.ID]))
+		for i, e := range webhookEvents[h.ID] {
+			events[i] = webhook.Event(e.Event)
+		}
+
+		hooks[i] = webhook.Hook{
+			Webhook:     h,
+			ContentType: webhook.ContentType(h.ContentType),
+			Events:      events,
+		}
+	}
+
+	return hooks, nil
+}
+
+// UpdateWebhook updates a webhook.
+func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id int64, url string, contentType webhook.ContentType, secret string, updatedEvents []webhook.Event, active bool) error {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil {
+			return db.WrapError(err)
+		}
+
+		currentEvents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		// Delete events that are no longer in the list.
+		toBeDeleted := make([]int64, 0)
+		for _, e := range currentEvents {
+			found := false
+			for _, ne := range updatedEvents {
+				if int(ne) == e.Event {
+					found = true
+					break
+				}
+			}
+			if !found {
+				toBeDeleted = append(toBeDeleted, e.ID)
+			}
+		}
+
+		if err := datastore.DeleteWebhookEventsByID(ctx, tx, toBeDeleted); err != nil {
+			return db.WrapError(err)
+		}
+
+		// Prune events that are already in the list.
+		newEvents := make([]int, 0)
+		for _, e := range updatedEvents {
+			found := false
+			for _, ne := range currentEvents {
+				if int(e) == ne.Event {
+					found = true
+					break
+				}
+			}
+			if !found {
+				newEvents = append(newEvents, int(e))
+			}
+		}
+
+		if err := datastore.CreateWebhookEvents(ctx, tx, id, newEvents); err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	})
+}
+
+// DeleteWebhook deletes a webhook for a repository.
+func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id int64) error {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		_, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+		if err := datastore.DeleteWebhookForRepoByID(ctx, tx, repo.ID(), id); err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	})
+}
+
+// ListWebhookDeliveries lists webhook deliveries for a webhook.
+func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	var deliveries []models.WebhookDelivery
+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		var err error
+		deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	}); err != nil {
+		return nil, db.WrapError(err)
+	}
+
+	ds := make([]webhook.Delivery, len(deliveries))
+	for i, d := range deliveries {
+		ds[i] = webhook.Delivery{
+			WebhookDelivery: d,
+			Event:           webhook.Event(d.Event),
+		}
+	}
+
+	return ds, nil
+}
+
+// RedeliverWebhookDelivery redelivers a webhook delivery.
+func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	var delivery models.WebhookDelivery
+	var wh models.Webhook
+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		var err error
+		wh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
+		if err != nil {
+			log.Errorf("error getting webhook: %v", err)
+			return db.WrapError(err)
+		}
+
+		delivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	}); err != nil {
+		return db.WrapError(err)
+	}
+
+	log.Infof("redelivering webhook delivery %s for webhook %d\n\n%s\n\n", delID, id, delivery.RequestBody)
+
+	var payload json.RawMessage
+	if err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil {
+		log.Errorf("error unmarshaling webhook payload: %v", err)
+		return err
+	}
+
+	return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)
+}
+
+// WebhookDelivery returns a webhook delivery.
+func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	var delivery webhook.Delivery
+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
+		d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		delivery = webhook.Delivery{
+			WebhookDelivery: d,
+			Event:           webhook.Event(d.Event),
+		}
+
+		return nil
+	}); err != nil {
+		return webhook.Delivery{}, db.WrapError(err)
+	}
+
+	return delivery, nil
+}

server/config/config.go 🔗

@@ -40,6 +40,9 @@ type GitConfig struct {
 	// ListenAddr is the address on which the Git daemon will listen.
 	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 
+	// PublicURL is the public URL of the Git daemon server.
+	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
+
 	// MaxTimeout is the maximum number of seconds a connection can take.
 	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 
@@ -157,6 +160,7 @@ func (c *Config) Environ() []string {
 		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
 		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
 		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
+		fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
 		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
 		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
 		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
@@ -304,6 +308,7 @@ func DefaultConfig() *Config {
 		},
 		Git: GitConfig{
 			ListenAddr:     ":9418",
+			PublicURL:      "git://localhost",
 			MaxTimeout:     0,
 			IdleTimeout:    3,
 			MaxConnections: 32,

server/config/file.go 🔗

@@ -50,6 +50,10 @@ git:
   # The address on which the Git daemon will listen.
   listen_addr: "{{ .Git.ListenAddr }}"
 
+  # The public URL of the Git daemon server.
+  # This is the address that will be used to clone repositories.
+  public_url: "{{ .Git.PublicURL }}"
+
   # The maximum number of seconds a connection can take.
   # A value of 0 means no timeout.
   max_timeout: {{ .Git.MaxTimeout }}

server/db/errors.go 🔗

@@ -6,7 +6,7 @@ import (
 
 	"github.com/lib/pq"
 	sqlite "modernc.org/sqlite"
-	sqlite3 "modernc.org/sqlite/lib"
+	sqlitelib "modernc.org/sqlite/lib"
 )
 
 var (
@@ -28,9 +28,9 @@ func WrapError(err error) error {
 		// Handle sqlite constraint error.
 		if liteErr, ok := err.(*sqlite.Error); ok {
 			code := liteErr.Code()
-			if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
-				code == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY ||
-				code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
+			if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY ||
+				code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY ||
+				code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE {
 				return ErrDuplicateKey
 			}
 		}

server/db/migrate/0002_webhooks.go 🔗

@@ -0,0 +1,23 @@
+package migrate
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+)
+
+const (
+	webhooksName    = "webhooks"
+	webhooksVersion = 2
+)
+
+var webhooks = Migration{
+	Name:    webhooksName,
+	Version: webhooksVersion,
+	Migrate: func(ctx context.Context, tx *db.Tx) error {
+		return migrateUp(ctx, tx, webhooksVersion, webhooksName)
+	},
+	Rollback: func(ctx context.Context, tx *db.Tx) error {
+		return migrateDown(ctx, tx, webhooksVersion, webhooksName)
+	},
+}

server/db/migrate/0002_webhooks_postgres.up.sql 🔗

@@ -0,0 +1,46 @@
+CREATE TABLE IF NOT EXISTS webhooks (
+  id SERIAL PRIMARY KEY,
+  repo_id INTEGER NOT NULL,
+  url TEXT NOT NULL,
+  secret TEXT NOT NULL,
+  content_type INTEGER NOT NULL,
+  active BOOLEAN NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP NOT NULL,
+  UNIQUE (repo_id, url),
+  CONSTRAINT repo_id_fk
+  FOREIGN KEY(repo_id) REFERENCES repos(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS webhook_events (
+  id SERIAL PRIMARY KEY,
+  webhook_id INTEGER NOT NULL,
+  event INTEGER NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE (webhook_id, event),
+  CONSTRAINT webhook_id_fk
+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS webhook_deliveries (
+  id TEXT PRIMARY KEY,
+  webhook_id INTEGER NOT NULL,
+  event INTEGER NOT NULL,
+  request_url TEXT NOT NULL,
+  request_method TEXT NOT NULL,
+  request_error TEXT,
+  request_headers TEXT NOT NULL,
+  request_body TEXT NOT NULL,
+  response_status INTEGER NOT NULL,
+  response_headers TEXT NOT NULL,
+  response_body TEXT NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  CONSTRAINT webhook_id_fk
+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);

server/db/migrate/0002_webhooks_sqlite.up.sql 🔗

@@ -0,0 +1,46 @@
+CREATE TABLE IF NOT EXISTS webhooks (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  repo_id INTEGER NOT NULL,
+  url TEXT NOT NULL,
+  secret TEXT NOT NULL,
+  content_type INTEGER NOT NULL,
+  active BOOLEAN NOT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME NOT NULL,
+  UNIQUE (repo_id, url),
+  CONSTRAINT repo_id_fk
+  FOREIGN KEY(repo_id) REFERENCES repos(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS webhook_events (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  webhook_id INTEGER NOT NULL,
+  event INTEGER NOT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE (webhook_id, event),
+  CONSTRAINT webhook_id_fk
+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS webhook_deliveries (
+  id TEXT PRIMARY KEY,
+  webhook_id INTEGER NOT NULL,
+  event INTEGER NOT NULL,
+  request_url TEXT NOT NULL,
+  request_method TEXT NOT NULL,
+  request_error TEXT,
+  request_headers TEXT NOT NULL,
+  request_body TEXT NOT NULL,
+  response_status INTEGER NOT NULL,
+  response_headers TEXT NOT NULL,
+  response_body TEXT NOT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  CONSTRAINT webhook_id_fk
+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);

server/db/migrate/migrations.go 🔗

@@ -16,6 +16,7 @@ var sqls embed.FS
 // Keep this in order of execution, oldest to newest.
 var migrations = []Migration{
 	createTables,
+	webhooks,
 }
 
 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {

server/db/models/webhook.go 🔗

@@ -0,0 +1,44 @@
+package models
+
+import (
+	"database/sql"
+	"time"
+
+	"github.com/google/uuid"
+)
+
+// Webhook is a repository webhook.
+type Webhook struct {
+	ID          int64     `db:"id"`
+	RepoID      int64     `db:"repo_id"`
+	URL         string    `db:"url"`
+	Secret      string    `db:"secret"`
+	ContentType int       `db:"content_type"`
+	Active      bool      `db:"active"`
+	CreatedAt   time.Time `db:"created_at"`
+	UpdatedAt   time.Time `db:"updated_at"`
+}
+
+// WebhookEvent is a webhook event.
+type WebhookEvent struct {
+	ID        int64     `db:"id"`
+	WebhookID int64     `db:"webhook_id"`
+	Event     int       `db:"event"`
+	CreatedAt time.Time `db:"created_at"`
+}
+
+// WebhookDelivery is a webhook delivery.
+type WebhookDelivery struct {
+	ID              uuid.UUID      `db:"id"`
+	WebhookID       int64          `db:"webhook_id"`
+	Event           int            `db:"event"`
+	RequestURL      string         `db:"request_url"`
+	RequestMethod   string         `db:"request_method"`
+	RequestError    sql.NullString `db:"request_error"`
+	RequestHeaders  string         `db:"request_headers"`
+	RequestBody     string         `db:"request_body"`
+	ResponseStatus  int            `db:"response_status"`
+	ResponseHeaders string         `db:"response_headers"`
+	ResponseBody    string         `db:"response_body"`
+	CreatedAt       time.Time      `db:"created_at"`
+}

server/proto/errors.go 🔗

@@ -19,4 +19,6 @@ var (
 	ErrTokenNotFound = errors.New("token not found")
 	// ErrTokenExpired is returned when a token is expired.
 	ErrTokenExpired = errors.New("token expired")
+	// ErrCollaboratorNotFound is returned when a collaborator is not found.
+	ErrCollaboratorNotFound = errors.New("collaborator not found")
 )

server/proto/repo.go 🔗

@@ -25,6 +25,8 @@ type Repository interface {
 	// UserID returns the ID of the user who owns the repository.
 	// It returns 0 if the repository is not owned by a user.
 	UserID() int64
+	// CreatedAt returns the time the repository was created.
+	CreatedAt() time.Time
 	// UpdatedAt returns the time the repository was last updated.
 	// If the repository has never been updated, it returns the time it was created.
 	UpdatedAt() time.Time
@@ -42,3 +44,18 @@ type RepositoryOptions struct {
 	LFS         bool
 	LFSEndpoint string
 }
+
+// RepositoryDefaultBranch returns the default branch of a repository.
+func RepositoryDefaultBranch(repo Repository) (string, error) {
+	r, err := repo.Open()
+	if err != nil {
+		return "", err
+	}
+
+	ref, err := r.HEAD()
+	if err != nil {
+		return "", err
+	}
+
+	return ref.Name().Short(), nil
+}

server/ssh/cmd/branch.go 🔗

@@ -6,6 +6,8 @@ import (
 
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/webhook"
 	gitm "github.com/gogs/git-module"
 	"github.com/spf13/cobra"
 )
@@ -123,6 +125,15 @@ func branchDefaultCommand() *cobra.Command {
 				}); err != nil {
 					return err
 				}
+
+				// TODO: move this to backend?
+				user := proto.UserFromContext(ctx)
+				wh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange)
+				if err != nil {
+					return err
+				}
+
+				return webhook.SendEvent(ctx, wh)
 			}
 
 			return nil
@@ -175,7 +186,21 @@ func branchDeleteCommand() *cobra.Command {
 				return fmt.Errorf("cannot delete the default branch")
 			}
 
-			return r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true})
+			branchCommit, err := r.BranchCommit(branch)
+			if err != nil {
+				return err
+			}
+
+			if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil {
+				return err
+			}
+
+			wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID)
+			if err != nil {
+				return err
+			}
+
+			return webhook.SendEvent(ctx, wh)
 		},
 	}
 

server/ssh/cmd/cmd.go 🔗

@@ -137,9 +137,16 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
 	return false
 }
 
-func checkIfAdmin(cmd *cobra.Command, _ []string) error {
+func checkIfAdmin(cmd *cobra.Command, args []string) error {
+	var repo string
+	if len(args) > 0 {
+		repo = args[0]
+	}
+
 	ctx := cmd.Context()
 	cfg := config.FromContext(ctx)
+	be := backend.FromContext(ctx)
+	rn := utils.SanitizeRepo(repo)
 	pk := sshutils.PublicKeyFromContext(ctx)
 	if IsPublicKeyAdmin(cfg, pk) {
 		return nil
@@ -150,11 +157,16 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 		return proto.ErrUnauthorized
 	}
 
-	if !user.IsAdmin() {
-		return proto.ErrUnauthorized
+	if user.IsAdmin() {
+		return nil
 	}
 
-	return nil
+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
+	if auth >= access.AdminAccess {
+		return nil
+	}
+
+	return proto.ErrUnauthorized
 }
 
 func checkIfCollab(cmd *cobra.Command, args []string) error {

server/ssh/cmd/repo.go 🔗

@@ -34,6 +34,7 @@ func RepoCommand() *cobra.Command {
 		renameCommand(),
 		tagCommand(),
 		treeCommand(),
+		webhookCommand(),
 	)
 
 	cmd.AddCommand(

server/ssh/cmd/tag.go 🔗

@@ -3,7 +3,11 @@ package cmd
 import (
 	"strings"
 
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/webhook"
 	"github.com/spf13/cobra"
 )
 
@@ -72,10 +76,43 @@ func tagDeleteCommand() *cobra.Command {
 
 			r, err := rr.Open()
 			if err != nil {
+				log.Errorf("failed to open repo: %s", err)
 				return err
 			}
 
-			return r.DeleteTag(args[1])
+			tag := args[1]
+			tags, _ := r.Tags()
+			var exists bool
+			for _, t := range tags {
+				if tag == t {
+					exists = true
+					break
+				}
+			}
+
+			if !exists {
+				log.Errorf("failed to get tag: tag %s does not exist", tag)
+				return git.ErrReferenceNotExist
+			}
+
+			tagCommit, err := r.TagCommit(tag)
+			if err != nil {
+				log.Errorf("failed to get tag commit: %s", err)
+				return err
+			}
+
+			if err := r.DeleteTag(tag); err != nil {
+				log.Errorf("failed to delete tag: %s", err)
+				return err
+			}
+
+			wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID)
+			if err != nil {
+				log.Error("failed to create branch_tag webhook", "err", err)
+				return err
+			}
+
+			return webhook.SendEvent(ctx, wh)
 		},
 	}
 

server/ssh/cmd/webhooks.go 🔗

@@ -0,0 +1,406 @@
+package cmd
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/caarlos0/tablewriter"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/webhook"
+	"github.com/dustin/go-humanize"
+	"github.com/google/uuid"
+	"github.com/spf13/cobra"
+)
+
+func webhookCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "webhook",
+		Aliases: []string{"webhooks"},
+		Short:   "Manage repository webhooks",
+	}
+
+	cmd.AddCommand(
+		webhookListCommand(),
+		webhookCreateCommand(),
+		webhookDeleteCommand(),
+		webhookUpdateCommand(),
+		webhookDeliveriesCommand(),
+	)
+
+	return cmd
+}
+
+var webhookEvents []string
+
+func init() {
+	events := webhook.Events()
+	webhookEvents = make([]string, len(events))
+	for i, e := range events {
+		webhookEvents[i] = e.String()
+	}
+}
+
+func webhookListCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "list REPOSITORY",
+		Short:             "List repository webhooks",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			repo, err := be.Repository(ctx, args[0])
+			if err != nil {
+				return err
+			}
+
+			webhooks, err := be.ListWebhooks(ctx, repo)
+			if err != nil {
+				return err
+			}
+
+			return tablewriter.Render(
+				cmd.OutOrStdout(),
+				webhooks,
+				[]string{"ID", "URL", "Events", "Active", "Created At", "Updated At"},
+				func(h webhook.Hook) ([]string, error) {
+					events := make([]string, len(h.Events))
+					for i, e := range h.Events {
+						events[i] = e.String()
+					}
+
+					row := []string{
+						strconv.FormatInt(h.ID, 10),
+						h.URL,
+						strings.Join(events, ","),
+						strconv.FormatBool(h.Active),
+						humanize.Time(h.CreatedAt),
+						humanize.Time(h.UpdatedAt),
+					}
+
+					return row, nil
+				},
+			)
+		},
+	}
+
+	return cmd
+}
+
+func webhookCreateCommand() *cobra.Command {
+	var events []string
+	var secret string
+	var active bool
+	var contentType string
+	cmd := &cobra.Command{
+		Use:               "create REPOSITORY URL",
+		Short:             "Create a repository webhook",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			repo, err := be.Repository(ctx, args[0])
+			if err != nil {
+				return err
+			}
+
+			var evs []webhook.Event
+			for _, e := range events {
+				ev, err := webhook.ParseEvent(e)
+				if err != nil {
+					return fmt.Errorf("invalid event: %w", err)
+				}
+
+				evs = append(evs, ev)
+			}
+
+			var ct webhook.ContentType
+			switch strings.ToLower(strings.TrimSpace(contentType)) {
+			case "json":
+				ct = webhook.ContentTypeJSON
+			case "form":
+				ct = webhook.ContentTypeForm
+			default:
+				return webhook.ErrInvalidContentType
+			}
+
+			return be.CreateWebhook(ctx, repo, strings.TrimSpace(args[1]), ct, secret, evs, active)
+		},
+	}
+
+	cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", ")))
+	cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload")
+	cmd.Flags().BoolVarP(&active, "active", "a", true, "whether the webhook is active")
+	cmd.Flags().StringVarP(&contentType, "content-type", "c", "json", "content type of the webhook payload, can be either `json` or `form`")
+
+	return cmd
+}
+
+func webhookDeleteCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "delete REPOSITORY WEBHOOK_ID",
+		Short:             "Delete a repository webhook",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			repo, err := be.Repository(ctx, args[0])
+			if err != nil {
+				return err
+			}
+
+			id, err := strconv.ParseInt(args[1], 10, 64)
+			if err != nil {
+				return fmt.Errorf("invalid webhook ID: %w", err)
+			}
+
+			return be.DeleteWebhook(ctx, repo, id)
+		},
+	}
+
+	return cmd
+}
+
+func webhookUpdateCommand() *cobra.Command {
+	var events []string
+	var secret string
+	var active string
+	var contentType string
+	var url string
+	cmd := &cobra.Command{
+		Use:               "update REPOSITORY WEBHOOK_ID",
+		Short:             "Update a repository webhook",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			repo, err := be.Repository(ctx, args[0])
+			if err != nil {
+				return err
+			}
+
+			id, err := strconv.ParseInt(args[1], 10, 64)
+			if err != nil {
+				return fmt.Errorf("invalid webhook ID: %w", err)
+			}
+
+			wh, err := be.Webhook(ctx, repo, id)
+			if err != nil {
+				return err
+			}
+
+			newURL := wh.URL
+			if url != "" {
+				newURL = url
+			}
+
+			newSecret := wh.Secret
+			if secret != "" {
+				newSecret = secret
+			}
+
+			newActive := wh.Active
+			if active != "" {
+				active, err := strconv.ParseBool(active)
+				if err != nil {
+					return fmt.Errorf("invalid active value: %w", err)
+				}
+
+				newActive = active
+			}
+
+			newContentType := wh.ContentType
+			if contentType != "" {
+				var ct webhook.ContentType
+				switch strings.ToLower(strings.TrimSpace(contentType)) {
+				case "json":
+					ct = webhook.ContentTypeJSON
+				case "form":
+					ct = webhook.ContentTypeForm
+				default:
+					return webhook.ErrInvalidContentType
+				}
+				newContentType = ct
+			}
+
+			newEvents := wh.Events
+			if len(events) > 0 {
+				var evs []webhook.Event
+				for _, e := range events {
+					ev, err := webhook.ParseEvent(e)
+					if err != nil {
+						return fmt.Errorf("invalid event: %w", err)
+					}
+
+					evs = append(evs, ev)
+				}
+
+				newEvents = evs
+			}
+
+			return be.UpdateWebhook(ctx, repo, id, newURL, newContentType, newSecret, newEvents, newActive)
+		},
+	}
+
+	cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", ")))
+	cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload")
+	cmd.Flags().StringVarP(&active, "active", "a", "", "whether the webhook is active")
+	cmd.Flags().StringVarP(&contentType, "content-type", "c", "", "content type of the webhook payload, can be either `json` or `form`")
+	cmd.Flags().StringVarP(&url, "url", "u", "", "webhook URL")
+
+	return cmd
+}
+
+func webhookDeliveriesCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "deliveries",
+		Short:   "Manage webhook deliveries",
+		Aliases: []string{"delivery", "deliver"},
+	}
+
+	cmd.AddCommand(
+		webhookDeliveriesListCommand(),
+		webhookDeliveriesRedeliverCommand(),
+		webhookDeliveriesGetCommand(),
+	)
+
+	return cmd
+}
+
+func webhookDeliveriesListCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "list REPOSITORY WEBHOOK_ID",
+		Short:             "List webhook deliveries",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			id, err := strconv.ParseInt(args[1], 10, 64)
+			if err != nil {
+				return fmt.Errorf("invalid webhook ID: %w", err)
+			}
+
+			dels, err := be.ListWebhookDeliveries(ctx, id)
+			if err != nil {
+				return err
+			}
+
+			return tablewriter.Render(
+				cmd.OutOrStdout(),
+				dels,
+				[]string{"Status", "ID", "Event", "Created At"},
+				func(d webhook.Delivery) ([]string, error) {
+					status := "❌"
+					if d.ResponseStatus >= 200 && d.ResponseStatus < 300 {
+						status = "✅"
+					}
+
+					return []string{
+						status,
+						d.ID.String(),
+						d.Event.String(),
+						humanize.Time(d.CreatedAt),
+					}, nil
+				},
+			)
+		},
+	}
+
+	return cmd
+}
+
+func webhookDeliveriesRedeliverCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID",
+		Short:             "Redeliver a webhook delivery",
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			repo, err := be.Repository(ctx, args[0])
+			if err != nil {
+				return err
+			}
+
+			id, err := strconv.ParseInt(args[1], 10, 64)
+			if err != nil {
+				return fmt.Errorf("invalid webhook ID: %w", err)
+			}
+
+			delID, err := uuid.Parse(args[2])
+			if err != nil {
+				return fmt.Errorf("invalid delivery ID: %w", err)
+			}
+
+			return be.RedeliverWebhookDelivery(ctx, repo, id, delID)
+		},
+	}
+
+	return cmd
+}
+
+func webhookDeliveriesGetCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "get REPOSITORY WEBHOOK_ID DELIVERY_ID",
+		Short:             "Get a webhook delivery",
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			id, err := strconv.ParseInt(args[1], 10, 64)
+			if err != nil {
+				return fmt.Errorf("invalid webhook ID: %w", err)
+			}
+
+			delID, err := uuid.Parse(args[2])
+			if err != nil {
+				return fmt.Errorf("invalid delivery ID: %w", err)
+			}
+
+			del, err := be.WebhookDelivery(ctx, id, delID)
+			if err != nil {
+				return err
+			}
+
+			out := cmd.OutOrStdout()
+			fmt.Fprintf(out, "ID: %s\n", del.ID)
+			fmt.Fprintf(out, "Event: %s\n", del.Event)
+			fmt.Fprintf(out, "Request URL: %s\n", del.RequestURL)
+			fmt.Fprintf(out, "Request Method: %s\n", del.RequestMethod)
+			fmt.Fprintf(out, "Request Error: %s\n", del.RequestError.String)
+			fmt.Fprintf(out, "Request Headers:\n")
+			reqHeaders := strings.Split(del.RequestHeaders, "\n")
+			for _, h := range reqHeaders {
+				fmt.Fprintf(out, "  %s\n", h)
+			}
+
+			fmt.Fprintf(out, "Request Body:\n")
+			reqBody := strings.Split(del.RequestBody, "\n")
+			for _, b := range reqBody {
+				fmt.Fprintf(out, "  %s\n", b)
+			}
+
+			fmt.Fprintf(out, "Response Status: %d\n", del.ResponseStatus)
+			fmt.Fprintf(out, "Response Headers:\n")
+			resHeaders := strings.Split(del.ResponseHeaders, "\n")
+			for _, h := range resHeaders {
+				fmt.Fprintf(out, "  %s\n", h)
+			}
+
+			fmt.Fprintf(out, "Response Body:\n")
+			resBody := strings.Split(del.ResponseBody, "\n")
+			for _, b := range resBody {
+				fmt.Fprintf(out, "  %s\n", b)
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}

server/store/database/webhooks.go 🔗

@@ -0,0 +1,165 @@
+package database
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/store"
+	"github.com/google/uuid"
+	"github.com/jmoiron/sqlx"
+)
+
+type webhookStore struct{}
+
+var _ store.WebhookStore = (*webhookStore)(nil)
+
+// CreateWebhook implements store.WebhookStore.
+func (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) {
+	var id int64
+	query := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at)
+			VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`)
+	err := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active)
+	if err != nil {
+		return 0, err
+	}
+
+	return id, nil
+}
+
+// CreateWebhookDelivery implements store.WebhookStore.
+func (*webhookStore) CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error {
+	query := h.Rebind(`INSERT INTO webhook_deliveries (id, webhook_id, event, request_url, request_method, request_error, request_headers, request_body, response_status, response_headers, response_body)
+			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`)
+	var reqErr string
+	if requestError != nil {
+		reqErr = requestError.Error()
+	}
+	_, err := h.ExecContext(ctx, query, id, webhookID, event, url, method, reqErr, requestHeaders, requestBody, responseStatus, responseHeaders, responseBody)
+	return err
+}
+
+// CreateWebhookEvents implements store.WebhookStore.
+func (*webhookStore) CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error {
+	query := h.Rebind(`INSERT INTO webhook_events (webhook_id, event)
+			VALUES (?, ?);`)
+	for _, event := range events {
+		_, err := h.ExecContext(ctx, query, webhookID, event)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// DeleteWebhookByID implements store.WebhookStore.
+func (*webhookStore) DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error {
+	query := h.Rebind(`DELETE FROM webhooks WHERE id = ?;`)
+	_, err := h.ExecContext(ctx, query, id)
+	return err
+}
+
+// DeleteWebhookForRepoByID implements store.WebhookStore.
+func (*webhookStore) DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error {
+	query := h.Rebind(`DELETE FROM webhooks WHERE repo_id = ? AND id = ?;`)
+	_, err := h.ExecContext(ctx, query, repoID, id)
+	return err
+}
+
+// DeleteWebhookDeliveryByID implements store.WebhookStore.
+func (*webhookStore) DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error {
+	query := h.Rebind(`DELETE FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`)
+	_, err := h.ExecContext(ctx, query, webhookID, id)
+	return err
+}
+
+// DeleteWebhookEventsByWebhookID implements store.WebhookStore.
+func (*webhookStore) DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error {
+	query, args, err := sqlx.In(`DELETE FROM webhook_events WHERE id IN (?);`, ids)
+	if err != nil {
+		return err
+	}
+
+	query = h.Rebind(query)
+	_, err = h.ExecContext(ctx, query, args...)
+	return err
+}
+
+// GetWebhookByID implements store.WebhookStore.
+func (*webhookStore) GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) {
+	query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ? AND id = ?;`)
+	var wh models.Webhook
+	err := h.GetContext(ctx, &wh, query, repoID, id)
+	return wh, err
+}
+
+// GetWebhookDeliveriesByWebhookID implements store.WebhookStore.
+func (*webhookStore) GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) {
+	query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ?;`)
+	var whds []models.WebhookDelivery
+	err := h.SelectContext(ctx, &whds, query, webhookID)
+	return whds, err
+}
+
+// GetWebhookDeliveryByID implements store.WebhookStore.
+func (*webhookStore) GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) {
+	query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`)
+	var whd models.WebhookDelivery
+	err := h.GetContext(ctx, &whd, query, webhookID, id)
+	return whd, err
+}
+
+// GetWebhookEventByID implements store.WebhookStore.
+func (*webhookStore) GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) {
+	query := h.Rebind(`SELECT * FROM webhook_events WHERE id = ?;`)
+	var whe models.WebhookEvent
+	err := h.GetContext(ctx, &whe, query, id)
+	return whe, err
+}
+
+// GetWebhookEventsByWebhookID implements store.WebhookStore.
+func (*webhookStore) GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) {
+	query := h.Rebind(`SELECT * FROM webhook_events WHERE webhook_id = ?;`)
+	var whes []models.WebhookEvent
+	err := h.SelectContext(ctx, &whes, query, webhookID)
+	return whes, err
+}
+
+// GetWebhooksByRepoID implements store.WebhookStore.
+func (*webhookStore) GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) {
+	query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ?;`)
+	var whs []models.Webhook
+	err := h.SelectContext(ctx, &whs, query, repoID)
+	return whs, err
+}
+
+// GetWebhooksByRepoIDWhereEvent implements store.WebhookStore.
+func (*webhookStore) GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) {
+	query, args, err := sqlx.In(`SELECT webhooks.*
+			FROM webhooks
+			INNER JOIN webhook_events ON webhooks.id = webhook_events.webhook_id
+			WHERE webhooks.repo_id = ? AND webhook_events.event IN (?);`, repoID, events)
+	if err != nil {
+		return nil, err
+	}
+
+	query = h.Rebind(query)
+	var whs []models.Webhook
+	err = h.SelectContext(ctx, &whs, query, args...)
+	return whs, err
+}
+
+// ListWebhookDeliveriesByWebhookID implements store.WebhookStore.
+func (*webhookStore) ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) {
+	query := h.Rebind(`SELECT id, response_status, event FROM webhook_deliveries WHERE webhook_id = ?;`)
+	var whds []models.WebhookDelivery
+	err := h.SelectContext(ctx, &whds, query, webhookID)
+	return whds, err
+}
+
+// UpdateWebhookByID implements store.WebhookStore.
+func (*webhookStore) UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error {
+	query := h.Rebind(`UPDATE webhooks SET url = ?, secret = ?, content_type = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE repo_id = ? AND id = ?;`)
+	_, err := h.ExecContext(ctx, query, url, secret, contentType, active, repoID, id)
+	return err
+}

server/store/webhooks.go 🔗

@@ -0,0 +1,48 @@
+package store
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/google/uuid"
+)
+
+// WebhookStore is an interface for managing webhooks.
+type WebhookStore interface {
+	// GetWebhookByID returns a webhook by its ID.
+	GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error)
+	// GetWebhooksByRepoID returns all webhooks for a repository.
+	GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error)
+	// GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events.
+	GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error)
+	// CreateWebhook creates a webhook.
+	CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error)
+	// UpdateWebhookByID updates a webhook by its ID.
+	UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error
+	// DeleteWebhookByID deletes a webhook by its ID.
+	DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error
+	// DeleteWebhookForRepoByID deletes a webhook for a repository by its ID.
+	DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error
+
+	// GetWebhookEventByID returns a webhook event by its ID.
+	GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error)
+	// GetWebhookEventsByWebhookID returns all webhook events for a webhook.
+	GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error)
+	// CreateWebhookEvents creates webhook events for a webhook.
+	CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error
+	// DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook.
+	DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error
+
+	// GetWebhookDeliveryByID returns a webhook delivery by its ID.
+	GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error)
+	// GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
+	GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
+	// ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
+	// This only returns the delivery ID, response status, and event.
+	ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
+	// CreateWebhookDelivery creates a webhook delivery.
+	CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error
+	// DeleteWebhookDeliveryByID deletes a webhook delivery by its ID.
+	DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error
+}

server/version/version.go 🔗

@@ -0,0 +1,14 @@
+// Package version is used to store the version of the server during runtime.
+// The values are set during runtime in the main package.
+package version
+
+var (
+	// Version is the version of the server.
+	Version = ""
+
+	// CommitSHA is the commit SHA of the server.
+	CommitSHA = ""
+
+	// CommitDate is the commit date of the server.
+	CommitDate = ""
+)

server/web/goget.go 🔗

@@ -70,6 +70,7 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			}
 
 			if repo == "" || repo == "." || repo == "/" {
+				renderNotFound(w, r)
 				return
 			}
 

server/webhook/branch_tag.go 🔗

@@ -0,0 +1,86 @@
+package webhook
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/store"
+)
+
+// BranchTagEvent is a branch or tag event.
+type BranchTagEvent struct {
+	Common
+
+	// Ref is the branch or tag name.
+	Ref string `json:"ref" url:"ref"`
+	// Before is the previous commit SHA.
+	Before string `json:"before" url:"before"`
+	// After is the current commit SHA.
+	After string `json:"after" url:"after"`
+	// Created is whether the branch or tag was created.
+	Created bool `json:"created" url:"created"`
+	// Deleted is whether the branch or tag was deleted.
+	Deleted bool `json:"deleted" url:"deleted"`
+}
+
+// NewBranchTagEvent sends a branch or tag event.
+func NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) {
+	var event Event
+	if git.IsZeroHash(before) {
+		event = EventBranchTagCreate
+	} else if git.IsZeroHash(after) {
+		event = EventBranchTagDelete
+	} else {
+		return BranchTagEvent{}, fmt.Errorf("invalid branch or tag event: before=%q after=%q", before, after)
+	}
+
+	payload := BranchTagEvent{
+		Ref:     ref,
+		Before:  before,
+		After:   after,
+		Created: git.IsZeroHash(before),
+		Deleted: git.IsZeroHash(after),
+		Common: Common{
+			EventType: event,
+			Repository: Repository{
+				ID:          repo.ID(),
+				Name:        repo.Name(),
+				Description: repo.Description(),
+				ProjectName: repo.ProjectName(),
+				Private:     repo.IsPrivate(),
+				CreatedAt:   repo.CreatedAt(),
+				UpdatedAt:   repo.UpdatedAt(),
+			},
+			Sender: User{
+				ID:       user.ID(),
+				Username: user.Username(),
+			},
+		},
+	}
+
+	cfg := config.FromContext(ctx)
+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
+
+	// Find repo owner.
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
+	if err != nil {
+		return BranchTagEvent{}, db.WrapError(err)
+	}
+
+	payload.Repository.Owner.ID = owner.ID
+	payload.Repository.Owner.Username = owner.Username
+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
+	if err != nil {
+		return BranchTagEvent{}, err
+	}
+
+	return payload, nil
+}

server/webhook/collaborator.go 🔗

@@ -0,0 +1,83 @@
+package webhook
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/access"
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/store"
+)
+
+// CollaboratorEvent is a collaborator event.
+type CollaboratorEvent struct {
+	Common
+
+	// Action is the collaborator event action.
+	Action CollaboratorEventAction `json:"action" url:"action"`
+	// AccessLevel is the collaborator access level.
+	AccessLevel access.AccessLevel `json:"access_level" url:"access_level"`
+	// Collaborator is the collaborator.
+	Collaborator User `json:"collaborator" url:"collaborator"`
+}
+
+// CollaboratorEventAction is a collaborator event action.
+type CollaboratorEventAction string
+
+const (
+	// CollaboratorEventAdded is a collaborator added event.
+	CollaboratorEventAdded CollaboratorEventAction = "added"
+	// CollaboratorEventRemoved is a collaborator removed event.
+	CollaboratorEventRemoved CollaboratorEventAction = "removed"
+)
+
+// NewCollaboratorEvent sends a collaborator event.
+func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) {
+	event := EventCollaborator
+
+	payload := CollaboratorEvent{
+		Action: action,
+		Common: Common{
+			EventType: event,
+			Repository: Repository{
+				ID:          repo.ID(),
+				Name:        repo.Name(),
+				Description: repo.Description(),
+				ProjectName: repo.ProjectName(),
+				Private:     repo.IsPrivate(),
+				CreatedAt:   repo.CreatedAt(),
+				UpdatedAt:   repo.UpdatedAt(),
+			},
+			Sender: User{
+				ID:       user.ID(),
+				Username: user.Username(),
+			},
+		},
+	}
+
+	// Find repo owner.
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
+	if err != nil {
+		return CollaboratorEvent{}, db.WrapError(err)
+	}
+
+	payload.Repository.Owner.ID = owner.ID
+	payload.Repository.Owner.Username = owner.Username
+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
+	if err != nil {
+		return CollaboratorEvent{}, err
+	}
+
+	collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name())
+	if err != nil {
+		return CollaboratorEvent{}, err
+	}
+
+	payload.AccessLevel = collab.AccessLevel
+	payload.Collaborator.ID = collab.UserID
+	payload.Collaborator.Username = collabUsername
+
+	return payload, nil
+}

server/webhook/common.go 🔗

@@ -0,0 +1,95 @@
+package webhook
+
+import "time"
+
+// EventPayload is a webhook event payload.
+type EventPayload interface {
+	// Event returns the event type.
+	Event() Event
+	// RepositoryID returns the repository ID.
+	RepositoryID() int64
+}
+
+// Common is a common payload.
+type Common struct {
+	// EventType is the event type.
+	EventType Event `json:"event" url:"event"`
+	// Repository is the repository payload.
+	Repository Repository `json:"repository" url:"repository"`
+	// Sender is the sender payload.
+	Sender User `json:"sender" url:"sender"`
+}
+
+// Event returns the event type.
+// Implements EventPayload.
+func (c Common) Event() Event {
+	return c.EventType
+}
+
+// RepositoryID returns the repository ID.
+// Implements EventPayload.
+func (c Common) RepositoryID() int64 {
+	return c.Repository.ID
+}
+
+// User represents a user in an event.
+type User struct {
+	// ID is the owner ID.
+	ID int64 `json:"id" url:"id"`
+	// Username is the owner username.
+	Username string `json:"username" url:"username"`
+}
+
+// Repository represents an event repository.
+type Repository struct {
+	// ID is the repository ID.
+	ID int64 `json:"id" url:"id"`
+	// Name is the repository name.
+	Name string `json:"name" url:"name"`
+	// ProjectName is the repository project name.
+	ProjectName string `json:"project_name" url:"project_name"`
+	// Description is the repository description.
+	Description string `json:"description" url:"description"`
+	// DefaultBranch is the repository default branch.
+	DefaultBranch string `json:"default_branch" url:"default_branch"`
+	// Private is whether the repository is private.
+	Private bool `json:"private" url:"private"`
+	// Owner is the repository owner.
+	Owner User `json:"owner" url:"owner"`
+	// HTTPURL is the repository HTTP URL.
+	HTTPURL string `json:"http_url" url:"http_url"`
+	// SSHURL is the repository SSH URL.
+	SSHURL string `json:"ssh_url" url:"ssh_url"`
+	// GitURL is the repository Git URL.
+	GitURL string `json:"git_url" url:"git_url"`
+	// CreatedAt is the repository creation time.
+	CreatedAt time.Time `json:"created_at" url:"created_at"`
+	// UpdatedAt is the repository last update time.
+	UpdatedAt time.Time `json:"updated_at" url:"updated_at"`
+}
+
+// Author is a commit author.
+type Author struct {
+	// Name is the author name.
+	Name string `json:"name" url:"name"`
+	// Email is the author email.
+	Email string `json:"email" url:"email"`
+	// Date is the author date.
+	Date time.Time `json:"date" url:"date"`
+}
+
+// Commit represents a Git commit.
+type Commit struct {
+	// ID is the commit ID.
+	ID string `json:"id" url:"id"`
+	// Message is the commit message.
+	Message string `json:"message" url:"message"`
+	// Title is the commit title.
+	Title string `json:"title" url:"title"`
+	// Author is the commit author.
+	Author Author `json:"author" url:"author"`
+	// Committer is the commit committer.
+	Committer Author `json:"committer" url:"committer"`
+	// Timestamp is the commit timestamp.
+	Timestamp time.Time `json:"timestamp" url:"timestamp"`
+}

server/webhook/content_type.go 🔗

@@ -0,0 +1,70 @@
+package webhook
+
+import (
+	"encoding"
+	"errors"
+	"strings"
+)
+
+// ContentType is the type of content that will be sent in a webhook request.
+type ContentType int8
+
+const (
+	// ContentTypeJSON is the JSON content type.
+	ContentTypeJSON ContentType = iota
+	// ContentTypeForm is the form content type.
+	ContentTypeForm
+)
+
+var contentTypeStrings = map[ContentType]string{
+	ContentTypeJSON: "application/json",
+	ContentTypeForm: "application/x-www-form-urlencoded",
+}
+
+// String returns the string representation of the content type.
+func (c ContentType) String() string {
+	return contentTypeStrings[c]
+}
+
+var stringContentType = map[string]ContentType{
+	"application/json":                  ContentTypeJSON,
+	"application/x-www-form-urlencoded": ContentTypeForm,
+}
+
+// ErrInvalidContentType is returned when the content type is invalid.
+var ErrInvalidContentType = errors.New("invalid content type")
+
+// ParseContentType parses a content type string and returns the content type.
+func ParseContentType(s string) (ContentType, error) {
+	for k, v := range stringContentType {
+		if strings.HasPrefix(s, k) {
+			return v, nil
+		}
+	}
+
+	return -1, ErrInvalidContentType
+}
+
+var _ encoding.TextMarshaler = ContentType(0)
+var _ encoding.TextUnmarshaler = (*ContentType)(nil)
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (c *ContentType) UnmarshalText(text []byte) error {
+	ct, err := ParseContentType(string(text))
+	if err != nil {
+		return err
+	}
+
+	*c = ct
+	return nil
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (c ContentType) MarshalText() (text []byte, err error) {
+	ct := c.String()
+	if ct == "" {
+		return nil, ErrInvalidContentType
+	}
+
+	return []byte(ct), nil
+}

server/webhook/event.go 🔗

@@ -0,0 +1,101 @@
+package webhook
+
+import (
+	"encoding"
+	"errors"
+)
+
+// Event is a webhook event.
+type Event int
+
+const (
+	// EventBranchTagCreate is a branch or tag create event.
+	EventBranchTagCreate Event = 1
+
+	// EventBranchTagDelete is a branch or tag delete event.
+	EventBranchTagDelete Event = 2
+
+	// EventCollaborator is a collaborator change event.
+	EventCollaborator Event = 3
+
+	// EventPush is a push event.
+	EventPush Event = 4
+
+	// EventRepository is a repository create, delete, rename event.
+	EventRepository Event = 5
+
+	// EventRepositoryVisibilityChange is a repository visibility change event.
+	EventRepositoryVisibilityChange Event = 6
+)
+
+// Events return all events.
+func Events() []Event {
+	return []Event{
+		EventBranchTagCreate,
+		EventBranchTagDelete,
+		EventCollaborator,
+		EventPush,
+		EventRepository,
+		EventRepositoryVisibilityChange,
+	}
+}
+
+var eventStrings = map[Event]string{
+	EventBranchTagCreate:            "branch_tag_create",
+	EventBranchTagDelete:            "branch_tag_delete",
+	EventCollaborator:               "collaborator",
+	EventPush:                       "push",
+	EventRepository:                 "repository",
+	EventRepositoryVisibilityChange: "repository_visibility_change",
+}
+
+// String returns the string representation of the event.
+func (e Event) String() string {
+	return eventStrings[e]
+}
+
+var stringEvent = map[string]Event{
+	"branch_tag_create":            EventBranchTagCreate,
+	"branch_tag_delete":            EventBranchTagDelete,
+	"collaborator":                 EventCollaborator,
+	"push":                         EventPush,
+	"repository":                   EventRepository,
+	"repository_visibility_change": EventRepositoryVisibilityChange,
+}
+
+// ErrInvalidEvent is returned when the event is invalid.
+var ErrInvalidEvent = errors.New("invalid event")
+
+// ParseEvent parses an event string and returns the event.
+func ParseEvent(s string) (Event, error) {
+	e, ok := stringEvent[s]
+	if !ok {
+		return -1, ErrInvalidEvent
+	}
+
+	return e, nil
+}
+
+var _ encoding.TextMarshaler = Event(0)
+var _ encoding.TextUnmarshaler = (*Event)(nil)
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (e *Event) UnmarshalText(text []byte) error {
+	ev, err := ParseEvent(string(text))
+	if err != nil {
+		return err
+	}
+
+	*e = ev
+	return nil
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (e Event) MarshalText() (text []byte, err error) {
+	ev := e.String()
+	if ev == "" {
+		return nil, ErrInvalidEvent
+	}
+
+	return []byte(ev), nil
+}

server/webhook/push.go 🔗

@@ -0,0 +1,117 @@
+package webhook
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/store"
+	gitm "github.com/gogs/git-module"
+)
+
+// PushEvent is a push event.
+type PushEvent struct {
+	Common
+
+	// Ref is the branch or tag name.
+	Ref string `json:"ref" url:"ref"`
+	// Before is the previous commit SHA.
+	Before string `json:"before" url:"before"`
+	// After is the current commit SHA.
+	After string `json:"after" url:"after"`
+	// Commits is the list of commits.
+	Commits []Commit `json:"commits" url:"commits"`
+}
+
+// NewPushEvent sends a push event.
+func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) {
+	event := EventPush
+
+	payload := PushEvent{
+		Ref:    ref,
+		Before: before,
+		After:  after,
+		Common: Common{
+			EventType: event,
+			Repository: Repository{
+				ID:          repo.ID(),
+				Name:        repo.Name(),
+				Description: repo.Description(),
+				ProjectName: repo.ProjectName(),
+				Private:     repo.IsPrivate(),
+				CreatedAt:   repo.CreatedAt(),
+				UpdatedAt:   repo.UpdatedAt(),
+			},
+			Sender: User{
+				ID:       user.ID(),
+				Username: user.Username(),
+			},
+		},
+	}
+
+	cfg := config.FromContext(ctx)
+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
+
+	// Find repo owner.
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
+	if err != nil {
+		return PushEvent{}, db.WrapError(err)
+	}
+
+	payload.Repository.Owner.ID = owner.ID
+	payload.Repository.Owner.Username = owner.Username
+
+	// Find commits.
+	r, err := repo.Open()
+	if err != nil {
+		return PushEvent{}, err
+	}
+
+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
+	if err != nil {
+		return PushEvent{}, err
+	}
+
+	rev := after
+	if !git.IsZeroHash(before) {
+		rev = fmt.Sprintf("%s..%s", before, after)
+	}
+
+	commits, err := r.Log(rev, gitm.LogOptions{
+		// XXX: limit to 20 commits for now
+		// TODO: implement a commits api
+		MaxCount: 20,
+	})
+	if err != nil {
+		return PushEvent{}, err
+	}
+
+	payload.Commits = make([]Commit, len(commits))
+	for i, c := range commits {
+		payload.Commits[i] = Commit{
+			ID:      c.ID.String(),
+			Message: c.Message,
+			Title:   c.Summary(),
+			Author: Author{
+				Name:  c.Author.Name,
+				Email: c.Author.Email,
+				Date:  c.Author.When,
+			},
+			Committer: Author{
+				Name:  c.Committer.Name,
+				Email: c.Committer.Email,
+				Date:  c.Committer.When,
+			},
+			Timestamp: c.Committer.When,
+		}
+	}
+
+	return payload, nil
+}

server/webhook/repository.go 🔗

@@ -0,0 +1,82 @@
+package webhook
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/store"
+)
+
+// RepositoryEvent is a repository payload.
+type RepositoryEvent struct {
+	Common
+
+	// Action is the repository event action.
+	Action RepositoryEventAction `json:"action" url:"action"`
+}
+
+// RepositoryEventAction is a repository event action.
+type RepositoryEventAction string
+
+const (
+	// RepositoryEventActionDelete is a repository deleted event.
+	RepositoryEventActionDelete RepositoryEventAction = "delete"
+	// RepositoryEventActionRename is a repository renamed event.
+	RepositoryEventActionRename RepositoryEventAction = "rename"
+	// RepositoryEventActionVisibilityChange is a repository visibility changed event.
+	RepositoryEventActionVisibilityChange RepositoryEventAction = "visibility_change"
+	// RepositoryEventActionDefaultBranchChange is a repository default branch changed event.
+	RepositoryEventActionDefaultBranchChange RepositoryEventAction = "default_branch_change"
+)
+
+// NewRepositoryEvent sends a repository event.
+func NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) {
+	var event Event
+	switch action {
+	case RepositoryEventActionVisibilityChange:
+		event = EventRepositoryVisibilityChange
+	default:
+		event = EventRepository
+	}
+
+	payload := RepositoryEvent{
+		Action: action,
+		Common: Common{
+			EventType: event,
+			Repository: Repository{
+				ID:          repo.ID(),
+				Name:        repo.Name(),
+				Description: repo.Description(),
+				ProjectName: repo.ProjectName(),
+				Private:     repo.IsPrivate(),
+				CreatedAt:   repo.CreatedAt(),
+				UpdatedAt:   repo.UpdatedAt(),
+			},
+			Sender: User{
+				ID:       user.ID(),
+				Username: user.Username(),
+			},
+		},
+	}
+
+	cfg := config.FromContext(ctx)
+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
+
+	// Find repo owner.
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
+	if err != nil {
+		return RepositoryEvent{}, db.WrapError(err)
+	}
+
+	payload.Repository.Owner.ID = owner.ID
+	payload.Repository.Owner.Username = owner.Username
+	payload.Repository.DefaultBranch, _ = proto.RepositoryDefaultBranch(repo)
+
+	return payload, nil
+}

server/webhook/webhook.go 🔗

@@ -0,0 +1,144 @@
+package webhook
+
+import (
+	"bytes"
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/store"
+	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/charmbracelet/soft-serve/server/version"
+	"github.com/google/go-querystring/query"
+	"github.com/google/uuid"
+)
+
+// Hook is a repository webhook.
+type Hook struct {
+	models.Webhook
+	ContentType ContentType
+	Events      []Event
+}
+
+// Delivery is a webhook delivery.
+type Delivery struct {
+	models.WebhookDelivery
+	Event Event
+}
+
+// do sends a webhook.
+// Caller must close the returned body.
+func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {
+	req, err := http.NewRequestWithContext(ctx, method, url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header = headers
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// SendWebhook sends a webhook event.
+func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {
+	var buf bytes.Buffer
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+
+	contentType := ContentType(w.ContentType)
+	switch contentType {
+	case ContentTypeJSON:
+		if err := json.NewEncoder(&buf).Encode(payload); err != nil {
+			return err
+		}
+	case ContentTypeForm:
+		v, err := query.Values(payload)
+		if err != nil {
+			return err
+		}
+		buf.WriteString(v.Encode()) // nolint: errcheck
+	default:
+		return ErrInvalidContentType
+	}
+
+	headers := http.Header{}
+	headers.Add("Content-Type", contentType.String())
+	headers.Add("User-Agent", "SoftServe/"+version.Version)
+	headers.Add("X-SoftServe-Event", event.String())
+
+	id, err := uuid.NewUUID()
+	if err != nil {
+		return err
+	}
+
+	headers.Add("X-SoftServe-Delivery", id.String())
+
+	reqBody := buf.String()
+	if w.Secret != "" {
+		sig := hmac.New(sha256.New, []byte(w.Secret))
+		sig.Write([]byte(reqBody)) // nolint: errcheck
+		headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil)))
+	}
+
+	res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)
+	var reqHeaders string
+	for k, v := range headers {
+		reqHeaders += k + ": " + v[0] + "\n"
+	}
+
+	resStatus := 0
+	resHeaders := ""
+	resBody := ""
+
+	if res != nil {
+		resStatus = res.StatusCode
+		for k, v := range res.Header {
+			resHeaders += k + ": " + v[0] + "\n"
+		}
+
+		if res.Body != nil {
+			defer res.Body.Close() // nolint: errcheck
+			b, err := io.ReadAll(res.Body)
+			if err != nil {
+				return err
+			}
+
+			resBody = string(b)
+		}
+	}
+
+	return db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody))
+}
+
+// SendEvent sends a webhook event.
+func SendEvent(ctx context.Context, payload EventPayload) error {
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	webhooks, err := datastore.GetWebhooksByRepoIDWhereEvent(ctx, dbx, payload.RepositoryID(), []int{int(payload.Event())})
+	if err != nil {
+		return db.WrapError(err)
+	}
+
+	for _, w := range webhooks {
+		if err := SendWebhook(ctx, w, payload.Event(), payload); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func repoURL(publicURL string, repo string) string {
+	return fmt.Sprintf("%s/%s.git", publicURL, utils.SanitizeRepo(repo))
+}

testscript/testdata/http.txtar 🔗

@@ -84,6 +84,10 @@ cmpenv stdout goget.txt
 curl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
 stdout '404.*'
 
+# go-get not found (invalid repo)
+curl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1
+stdout '404.*'
+
 # set private
 soft repo private repo2 true
 

testscript/testdata/repo-webhooks.txtar 🔗

@@ -0,0 +1,27 @@
+# vi: set ft=conf
+
+# create a repo
+soft repo create repo-123
+stderr 'Created repository repo-123.*'
+stdout ssh://localhost:$SSH_PORT/repo-123.git
+
+# create webhook
+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
+
+# list webhooks
+soft repo webhook list repo-123
+stdout '1.*https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17.*'
+
+# clone repo
+git clone ssh://localhost:$SSH_PORT/repo-123 repo-123
+
+# create files
+mkfile ./repo-123/README.md 'foobar'
+git -C repo-123 add -A
+git -C repo-123 commit -m 'first'
+git -C repo-123 push origin HEAD
+
+# list webhook deliveries
+# TODO: enable this test when githooks tests are fixed
+# soft repo webhook deliver list repo-123 1
+# stdout '.*https://webhook.site/.*'