Detailed changes
@@ -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{}
+}
@@ -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() {
@@ -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
@@ -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
@@ -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=
@@ -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)
}
@@ -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.
@@ -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.
@@ -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
+}
@@ -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,
@@ -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 }}
@@ -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
}
}
@@ -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)
+ },
+}
@@ -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
+);
@@ -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
+);
@@ -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 {
@@ -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"`
+}
@@ -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")
)
@@ -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
+}
@@ -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)
},
}
@@ -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 {
@@ -34,6 +34,7 @@ func RepoCommand() *cobra.Command {
renameCommand(),
tagCommand(),
treeCommand(),
+ webhookCommand(),
)
cmd.AddCommand(
@@ -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)
},
}
@@ -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
+}
@@ -21,6 +21,7 @@ type datastore struct {
*collabStore
*lfsStore
*accessTokenStore
+ *webhookStore
}
// New returns a new store.Store database.
@@ -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
+}
@@ -8,4 +8,5 @@ type Store interface {
SettingStore
LFSStore
AccessTokenStore
+ WebhookStore
}
@@ -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
+}
@@ -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 = ""
+)
@@ -70,6 +70,7 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if repo == "" || repo == "." || repo == "/" {
+ renderNotFound(w, r)
return
}
@@ -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
+}
@@ -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
+}
@@ -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"`
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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))
+}
@@ -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
@@ -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/.*'