Skip to content

Commit 0846323

Browse files
authored
Repository webhooks (#375)
* 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
1 parent 02e1617 commit 0846323

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2191
-26
lines changed

cmd/soft/browse.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,8 @@ func (r repository) UpdatedAt() time.Time {
299299
func (r repository) UserID() int64 {
300300
return 0
301301
}
302+
303+
// CreatedAt implements proto.Repository.
304+
func (r repository) CreatedAt() time.Time {
305+
return time.Time{}
306+
}

cmd/soft/root.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
logr "github.com/charmbracelet/soft-serve/server/log"
1616
"github.com/charmbracelet/soft-serve/server/store"
1717
"github.com/charmbracelet/soft-serve/server/store/database"
18+
"github.com/charmbracelet/soft-serve/server/version"
1819
"github.com/spf13/cobra"
1920
"go.uber.org/automaxprocs/maxprocs"
2021
)
@@ -28,6 +29,10 @@ var (
2829
// against. It's set via ldflags when building.
2930
CommitSHA = ""
3031

32+
// CommitDate contains the date of the commit that this application was
33+
// built against. It's set via ldflags when building.
34+
CommitDate = ""
35+
3136
rootCmd = &cobra.Command{
3237
Use: "soft",
3338
Short: "A self-hostable Git server for the command line",
@@ -61,6 +66,10 @@ func init() {
6166
}
6267
}
6368
rootCmd.Version = Version
69+
70+
version.Version = Version
71+
version.CommitSHA = CommitSHA
72+
version.CommitDate = CommitDate
6473
}
6574

6675
func main() {

git/commit.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
package git
22

33
import (
4+
"regexp"
5+
46
"github.com/gogs/git-module"
57
)
68

79
// ZeroID is the zero hash.
810
const ZeroID = git.EmptyID
911

12+
// IsZeroHash returns whether the hash is a zero hash.
13+
func IsZeroHash(h string) bool {
14+
pattern := regexp.MustCompile(`^0{40,}$`)
15+
return pattern.MatchString(h)
16+
}
17+
1018
// Commit is a wrapper around git.Commit with helper methods.
1119
type Commit = git.Commit
1220

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ require (
3131
github.com/gobwas/glob v0.2.3
3232
github.com/gogs/git-module v1.8.3
3333
github.com/golang-jwt/jwt/v5 v5.0.0
34+
github.com/google/go-querystring v1.1.0
35+
github.com/google/uuid v1.3.0
3436
github.com/gorilla/handlers v1.5.1
3537
github.com/gorilla/mux v1.8.0
3638
github.com/hashicorp/golang-lru/v2 v2.0.7
@@ -65,7 +67,6 @@ require (
6567
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
6668
github.com/go-logfmt/logfmt v0.6.0 // indirect
6769
github.com/golang/protobuf v1.5.3 // indirect
68-
github.com/google/uuid v1.3.0 // indirect
6970
github.com/gorilla/css v1.0.0 // indirect
7071
github.com/inconshreveable/mousetrap v1.1.0 // indirect
7172
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
7272
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
7373
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
7474
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
75+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
7576
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
7677
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
78+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
79+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
7780
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
7881
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
7982
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

server/backend/collab.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package backend
22

33
import (
44
"context"
5+
"errors"
56
"strings"
67

78
"github.com/charmbracelet/soft-serve/server/access"
89
"github.com/charmbracelet/soft-serve/server/db"
910
"github.com/charmbracelet/soft-serve/server/db/models"
11+
"github.com/charmbracelet/soft-serve/server/proto"
1012
"github.com/charmbracelet/soft-serve/server/utils"
13+
"github.com/charmbracelet/soft-serve/server/webhook"
1114
)
1215

1316
// AddCollaborator adds a collaborator to a repository.
@@ -20,11 +23,25 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
2023
}
2124

2225
repo = utils.SanitizeRepo(repo)
23-
return db.WrapError(
26+
r, err := d.Repository(ctx, repo)
27+
if err != nil {
28+
return err
29+
}
30+
31+
if err := db.WrapError(
2432
d.db.TransactionContext(ctx, func(tx *db.Tx) error {
2533
return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
2634
}),
27-
)
35+
); err != nil {
36+
return err
37+
}
38+
39+
wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded)
40+
if err != nil {
41+
return err
42+
}
43+
44+
return webhook.SendEvent(ctx, wh)
2845
}
2946

3047
// Collaborators returns a list of collaborators for a repository.
@@ -75,9 +92,27 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
7592
// It implements backend.Backend.
7693
func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {
7794
repo = utils.SanitizeRepo(repo)
78-
return db.WrapError(
95+
r, err := d.Repository(ctx, repo)
96+
if err != nil {
97+
return err
98+
}
99+
100+
wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved)
101+
if err != nil {
102+
return err
103+
}
104+
105+
if err := db.WrapError(
79106
d.db.TransactionContext(ctx, func(tx *db.Tx) error {
80107
return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)
81108
}),
82-
)
109+
); err != nil {
110+
if errors.Is(err, db.ErrRecordNotFound) {
111+
return proto.ErrCollaboratorNotFound
112+
}
113+
114+
return err
115+
}
116+
117+
return webhook.SendEvent(ctx, wh)
83118
}

server/backend/hooks.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ package backend
33
import (
44
"context"
55
"io"
6+
"os"
67
"sync"
78

9+
"github.com/charmbracelet/soft-serve/git"
810
"github.com/charmbracelet/soft-serve/server/hooks"
911
"github.com/charmbracelet/soft-serve/server/proto"
12+
"github.com/charmbracelet/soft-serve/server/sshutils"
13+
"github.com/charmbracelet/soft-serve/server/webhook"
1014
)
1115

1216
var _ hooks.Hooks = (*Backend)(nil)
@@ -28,8 +32,58 @@ func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo s
2832
// Update is called by the git update hook.
2933
//
3034
// It implements Hooks.
31-
func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
35+
func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
3236
d.logger.Debug("update hook called", "repo", repo, "arg", arg)
37+
38+
// Find user
39+
var user proto.User
40+
if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" {
41+
pk, _, err := sshutils.ParseAuthorizedKey(pubkey)
42+
if err != nil {
43+
d.logger.Error("error parsing public key", "err", err)
44+
return
45+
}
46+
47+
user, err = d.UserByPublicKey(ctx, pk)
48+
if err != nil {
49+
d.logger.Error("error finding user from public key", "key", pubkey, "err", err)
50+
return
51+
}
52+
} else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" {
53+
var err error
54+
user, err = d.User(ctx, username)
55+
if err != nil {
56+
d.logger.Error("error finding user from username", "username", username, "err", err)
57+
return
58+
}
59+
} else {
60+
d.logger.Error("error finding user")
61+
return
62+
}
63+
64+
// Get repo
65+
r, err := d.Repository(ctx, repo)
66+
if err != nil {
67+
d.logger.Error("error finding repository", "repo", repo, "err", err)
68+
return
69+
}
70+
71+
// TODO: run this async
72+
// This would probably need something like an RPC server to communicate with the hook process.
73+
if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {
74+
wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
75+
if err != nil {
76+
d.logger.Error("error creating branch_tag webhook", "err", err)
77+
} else if err := webhook.SendEvent(ctx, wh); err != nil {
78+
d.logger.Error("error sending branch_tag webhook", "err", err)
79+
}
80+
}
81+
wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
82+
if err != nil {
83+
d.logger.Error("error creating push webhook", "err", err)
84+
} else if err := webhook.SendEvent(ctx, wh); err != nil {
85+
d.logger.Error("error sending push webhook", "err", err)
86+
}
3387
}
3488

3589
// PostUpdate is called by the git post-update hook.

server/backend/repo.go

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/charmbracelet/soft-serve/server/storage"
2222
"github.com/charmbracelet/soft-serve/server/task"
2323
"github.com/charmbracelet/soft-serve/server/utils"
24+
"github.com/charmbracelet/soft-serve/server/webhook"
2425
)
2526

2627
func (d *Backend) reposPath() string {
@@ -216,7 +217,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
216217
repo := name + ".git"
217218
rp := filepath.Join(d.reposPath(), repo)
218219

219-
err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
220+
user := proto.UserFromContext(ctx)
221+
r, err := d.Repository(ctx, name)
222+
if err != nil {
223+
return err
224+
}
225+
226+
// We create the webhook event before deleting the repository so we can
227+
// send the event after deleting the repository.
228+
wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
229+
if err != nil {
230+
return err
231+
}
232+
233+
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
220234
// Delete repo from cache
221235
defer d.cache.Delete(name)
222236

@@ -257,17 +271,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
257271
}
258272

259273
return os.RemoveAll(rp)
260-
})
261-
if errors.Is(err, db.ErrRecordNotFound) {
262-
return proto.ErrRepoNotFound
274+
}); err != nil {
275+
if errors.Is(err, db.ErrRecordNotFound) {
276+
return proto.ErrRepoNotFound
277+
}
278+
279+
return db.WrapError(err)
263280
}
264281

265-
return err
282+
return webhook.SendEvent(ctx, wh)
266283
}
267284

268285
// DeleteUserRepositories deletes all user repositories.
269286
func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
270-
return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
287+
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
271288
user, err := d.store.FindUserByUsername(ctx, tx, username)
272289
if err != nil {
273290
return err
@@ -285,7 +302,11 @@ func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) e
285302
}
286303

287304
return nil
288-
})
305+
}); err != nil {
306+
return db.WrapError(err)
307+
}
308+
309+
return nil
289310
}
290311

291312
// RenameRepository renames a repository.
@@ -301,6 +322,11 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
301322
if err := utils.ValidateRepo(newName); err != nil {
302323
return err
303324
}
325+
326+
if oldName == newName {
327+
return nil
328+
}
329+
304330
oldRepo := oldName + ".git"
305331
newRepo := newName + ".git"
306332
op := filepath.Join(d.reposPath(), oldRepo)
@@ -331,7 +357,18 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
331357
return db.WrapError(err)
332358
}
333359

334-
return nil
360+
user := proto.UserFromContext(ctx)
361+
repo, err := d.Repository(ctx, newName)
362+
if err != nil {
363+
return err
364+
}
365+
366+
wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
367+
if err != nil {
368+
return err
369+
}
370+
371+
return webhook.SendEvent(ctx, wh)
335372
}
336373

337374
// Repositories returns a list of repositories per page.
@@ -537,7 +574,7 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) err
537574
// Delete cache
538575
d.cache.Delete(name)
539576

540-
return db.WrapError(
577+
if err := db.WrapError(
541578
d.db.TransactionContext(ctx, func(tx *db.Tx) error {
542579
fp := filepath.Join(rp, "git-daemon-export-ok")
543580
if !private {
@@ -556,7 +593,28 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) err
556593

557594
return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
558595
}),
559-
)
596+
); err != nil {
597+
return err
598+
}
599+
600+
user := proto.UserFromContext(ctx)
601+
repo, err := d.Repository(ctx, name)
602+
if err != nil {
603+
return err
604+
}
605+
606+
if repo.IsPrivate() != !private {
607+
wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)
608+
if err != nil {
609+
return err
610+
}
611+
612+
if err := webhook.SendEvent(ctx, wh); err != nil {
613+
return err
614+
}
615+
}
616+
617+
return nil
560618
}
561619

562620
// SetProjectName sets the project name of a repository.
@@ -651,6 +709,11 @@ func (r *repo) IsHidden() bool {
651709
return r.repo.Hidden
652710
}
653711

712+
// CreatedAt returns the repository's creation time.
713+
func (r *repo) CreatedAt() time.Time {
714+
return r.repo.CreatedAt
715+
}
716+
654717
// UpdatedAt returns the repository's last update time.
655718
func (r *repo) UpdatedAt() time.Time {
656719
// Try to read the last modified time from the info directory.

0 commit comments

Comments
 (0)