Skip to content

Commit

Permalink
Repository webhooks (#375)
Browse files Browse the repository at this point in the history
* 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: #148
Fixes: #56
Fixes: #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
  • Loading branch information
aymanbagabas authored Oct 25, 2023
1 parent 02e1617 commit 0846323
Show file tree
Hide file tree
Showing 42 changed files with 2,191 additions and 26 deletions.
5 changes: 5 additions & 0 deletions cmd/soft/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
9 changes: 9 additions & 0 deletions cmd/soft/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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",
Expand Down Expand Up @@ -61,6 +66,10 @@ func init() {
}
}
rootCmd.Version = Version

version.Version = Version
version.CommitSHA = CommitSHA
version.CommitDate = CommitDate
}

func main() {
Expand Down
8 changes: 8 additions & 0 deletions git/commit.go
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
43 changes: 39 additions & 4 deletions server/backend/collab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
56 changes: 55 additions & 1 deletion server/backend/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
83 changes: 73 additions & 10 deletions server/backend/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 0846323

Please sign in to comment.