diff --git a/spindle/server.go b/spindle/server.go index f3136d87..f42d9a87 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -41,6 +41,7 @@ import ( "tangled.org/core/spindle/git" "tangled.org/core/spindle/models" "tangled.org/core/spindle/secrets" + "tangled.org/core/spindle/webhook" "tangled.org/core/spindle/xrpc" "tangled.org/core/tid" "tangled.org/core/workflow" @@ -62,6 +63,7 @@ type Spindle struct { e *rbac.Enforcer l *slog.Logger n *notifier.Notifier + wh *webhook.Service engs map[string]models.Engine cfg *config.Config ks *eventconsumer.Consumer @@ -158,6 +160,7 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m db: d, l: logger, n: &n, + wh: webhook.New(d, cfg.Server.Dev), engs: engines, cfg: cfg, res: resolver, @@ -398,6 +401,7 @@ func (s *Spindle) XrpcRouter() http.Handler { Resolver: s.res, Vault: s.vault, Notifier: s.Notifier(), + Webhooks: s.wh, ServiceAuth: serviceAuth, Trigger: s, } @@ -430,6 +434,10 @@ func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Sourc return fmt.Errorf("repo knot does not match event source: %s != %s", src.Host, repo.Knot) } + // deliver push webhooks independently of CI; a skip-ci push option + // should not suppress webhook notifications + s.wh.FirePush(ctx, repo, event.CommitterDid, event.Ref, event.OldSha, event.NewSha) + if kgit.HasSkipCIPushOption(event.PushOptions) { l.Info("push event requested ci skip, skipping the event") return nil diff --git a/spindle/tapclient.go b/spindle/tapclient.go index bfe9d90f..81ec0810 100644 --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -162,6 +162,22 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error return fmt.Errorf("lookup existing repo by DID: %w", err) } + // a rename changes the record key while the repoDid stays constant, so + // the repoDid already existing under a different rkey signals a rename. + repoName := rkey.String() + if record.Name != nil && *record.Name != "" { + repoName = *record.Name + } + renamed := false + oldName := "" + if !knownRepo && existingRepo != nil && existingRepo.Owner == ownerDid && existingRepo.Rkey != rkey { + renamed = true + oldName = existingRepo.Name + if oldName == "" { + oldName = existingRepo.Rkey.String() + } + } + if err := t.spindle.e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()); err != nil { l.Error("failed to add repo policy", "err", err) return fmt.Errorf("add repo policy: %w", err) @@ -175,6 +191,7 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error Owner: ownerDid, Rkey: rkey, RepoDid: repoDid, + Name: repoName, CreatedAt: record.CreatedAt, } @@ -203,6 +220,11 @@ func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error l.Info("collapsed rename leftovers", "owner", ownerDid, "repo_did", repoDid, "removed", removed) } + if renamed { + l.Info("repo renamed, firing webhook", "old", oldName, "new", repoName) + t.spindle.wh.FireRename(ctx, ownerDid, &repo, oldName, repoName) + } + if e := t.spindle.embedTap; e == nil || !e.closed.Load() { if err := t.tap.AddRepos(ctx, []syntax.DID{ownerDid}); err != nil { l.Warn("tap AddRepos rejected", "did", ownerDid, "err", err) @@ -377,6 +399,15 @@ func (s *Spindle) processPull(ctx context.Context, evt *tapc.RecordEventData) er // for open/synchronize the event author is the pull record author. pullAuthor := evt.Did.String() + + whEvent := models.WebhookEventPullRequestCreated + whAction := "created" + if evt.Action == tapc.RecordUpdateAction { + whEvent = models.WebhookEventPullRequestResubmitted + whAction = "resubmitted" + } + s.firePullWebhook(ctx, whEvent, whAction, evt.Did, evt.Did, &record) + return s.triggerPullRequestPipeline(ctx, l, pullAuthor, pullAuthor, evt.Rkey.String(), &record, action) case tapc.RecordDeleteAction: // no-op @@ -435,9 +466,42 @@ func (s *Spindle) processPullStatus(ctx context.Context, evt *tapc.RecordEventDa } l = l.With("pull", record.Pull, "action", action, "actor", actorDid) + + if whEvent, ok := pullStatusWebhookEvent(record.Status); ok { + s.firePullWebhook(ctx, whEvent, action, syntax.DID(pullDid), evt.Did, pull) + } + return s.triggerPullRequestPipeline(ctx, l, actorDid, pullDid, pullRkey, pull, action) } +// pullStatusWebhookEvent maps a pull.status variant to its webhook event. +func pullStatusWebhookEvent(status string) (models.WebhookEvent, bool) { + switch status { + case tangled.RepoPullStatusOpen: + return models.WebhookEventPullRequestReopened, true + case tangled.RepoPullStatusClosed: + return models.WebhookEventPullRequestClosed, true + case tangled.RepoPullStatusMerged: + return models.WebhookEventPullRequestMerged, true + default: + return "", false + } +} + +// firePullWebhook delivers a pull_request:* webhook for the target repo, if that +// repo is served by this spindle and has matching webhooks configured. +func (s *Spindle) firePullWebhook(ctx context.Context, event models.WebhookEvent, action string, pullAuthor, sender syntax.DID, record *tangled.RepoPull) { + if record.Target == nil { + return + } + repo, err := s.db.GetRepoByDid(syntax.DID(record.Target.Repo)) + if err != nil { + // target repo is not served by this spindle; nothing to deliver + return + } + s.wh.FirePullRequest(ctx, event, action, repo, record, pullAuthor, sender) +} + // pullStatusAction maps a sh.tangled.repo.pull.status variant to the // corresponding pull_request trigger action. A status.open record is only ever // written on reopen (initial creation emits no status record), so it maps to