diff --git a/CHANGELOG.md b/CHANGELOG.md index 2150c5ea62..c62c1eedff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Fix LSP hover and highlighting of CEL expressions whose proto strings contain escape sequences, which placed the ranges of string literals, and of tokens after a `\u` or `\U` escape, wrongly. +- Add `buf registry webhook` commands to create, list, update, delete, and ping BSR webhooks + scoped to an instance, organization, or module. ## [v1.75.0] - 2026-10-21 diff --git a/cmd/buf/buf.go b/cmd/buf/buf.go index 9ea4401305..8f76880c49 100644 --- a/cmd/buf/buf.go +++ b/cmd/buf/buf.go @@ -115,6 +115,12 @@ import ( "github.com/bufbuild/buf/cmd/buf/internal/command/registry/registrylogout" "github.com/bufbuild/buf/cmd/buf/internal/command/registry/sdk/sdkinfo" "github.com/bufbuild/buf/cmd/buf/internal/command/registry/sdk/version" + registrywebhookcreate "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhookcreate" + registrywebhookdelete "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhookdelete" + registrywebhookinfo "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhookinfo" + registrywebhooklist "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhooklist" + registrywebhookping "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhookping" + registrywebhookupdate "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/webhookupdate" "github.com/bufbuild/buf/cmd/buf/internal/command/registry/whoami" "github.com/bufbuild/buf/cmd/buf/internal/command/source/sourceedit/sourceeditdeprecate" "github.com/bufbuild/buf/cmd/buf/internal/command/stats" @@ -399,6 +405,18 @@ func newRootCommand(name string) *appcmd.Command { policydelete.NewCommand("delete", builder), }, }, + { + Use: "webhook", + Short: "Manage BSR webhooks", + SubCommands: []*appcmd.Command{ + registrywebhookcreate.NewCommand("create", builder), + registrywebhookdelete.NewCommand("delete", builder), + registrywebhookinfo.NewCommand("info", builder), + registrywebhooklist.NewCommand("list", builder), + registrywebhookping.NewCommand("ping", builder), + registrywebhookupdate.NewCommand("update", builder), + }, + }, }, }, { diff --git a/cmd/buf/internal/command/registry/webhook/internal/internal.go b/cmd/buf/internal/command/registry/webhook/internal/internal.go new file mode 100644 index 0000000000..a024490a90 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/internal/internal.go @@ -0,0 +1,248 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package internal contains shared functionality for the "buf registry webhook" commands. +package internal + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "slices" + "strings" + + modulev1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/module/v1" + ownerv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/owner/v1" + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufprint" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapimodule" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiowner" + "github.com/bufbuild/buf/private/pkg/connectclient" + "github.com/bufbuild/buf/private/pkg/syserror" +) + +const ( + // ScopeArgUsage is the usage of the scope argument shared by all webhook commands. + ScopeArgUsage = "" + // ScopeArgLong describes the scope argument shared by all webhook commands. + ScopeArgLong = `The first argument is the scope of the webhook, one of: + + the BSR instance, such as buf.build + / an organization, such as buf.build/acme + // a module, such as buf.build/acme/petapis` +) + +// EventFlagUsage returns the usage of an event type flag. +func EventFlagUsage(prefix string) string { + return fmt.Sprintf( + "%s. May be repeated. Must be one of %s", + prefix, + strings.Join(bufcli.WebhookEventTypeStrings(), ", "), + ) +} + +// ParseWebhookEventTypes parses webhook event types from their enum value names. +func ParseWebhookEventTypes(webhookEventTypeStrings []string) ([]webhookv1.EventType, error) { + webhookEventTypes := make([]webhookv1.EventType, 0, len(webhookEventTypeStrings)) + for _, webhookEventTypeString := range webhookEventTypeStrings { + webhookEventType, err := bufcli.ParseWebhookEventType(webhookEventTypeString) + if err != nil { + return nil, err + } + webhookEventTypes = append(webhookEventTypes, webhookEventType) + } + return webhookEventTypes, nil +} + +// ReadSigningSecretFile reads a signing secret from a file path, or from stdin +// if the path is "-". Trailing newlines are removed. +func ReadSigningSecretFile(container app.StdinContainer, path string) ([]byte, error) { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(container.Stdin()) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, fmt.Errorf("read signing secret: %w", err) + } + signingSecret := bytes.TrimRight(data, "\r\n") + if len(signingSecret) == 0 { + return nil, fmt.Errorf("signing secret from %q is empty", path) + } + return signingSecret, nil +} + +// NewWebhookEntities returns entities to print for the webhooks. +// +// Webhooks only reference their scope by id, so this looks up the names of +// the organizations and modules the webhooks are scoped to. +func NewWebhookEntities( + ctx context.Context, + clientConfig *connectclient.Config, + registry string, + webhooks []*webhookv1.Webhook, +) ([]bufprint.Entity, error) { + var organizationIDs []string + var moduleIDs []string + for _, webhook := range webhooks { + switch value := webhook.GetScope().GetValue().(type) { + case *webhookv1.WebhookScope_Instance_: + case *webhookv1.WebhookScope_Organization_: + organizationIDs = appendUnique(organizationIDs, value.Organization.GetOrganizationId()) + case *webhookv1.WebhookScope_Module_: + moduleIDs = appendUnique(moduleIDs, value.Module.GetModuleId()) + default: + return nil, syserror.Newf("unknown webhook scope: %T", value) + } + } + ownerClientProvider := bufregistryapiowner.NewClientProvider(clientConfig) + organizationIDToName, err := getOrganizationIDToName(ctx, ownerClientProvider, registry, organizationIDs) + if err != nil { + return nil, err + } + moduleIDToFullName, err := getModuleIDToFullName( + ctx, + bufregistryapimodule.NewClientProvider(clientConfig), + ownerClientProvider, + registry, + moduleIDs, + ) + if err != nil { + return nil, err + } + entities := make([]bufprint.Entity, 0, len(webhooks)) + for _, webhook := range webhooks { + var scope string + switch value := webhook.GetScope().GetValue().(type) { + case *webhookv1.WebhookScope_Instance_: + scope = registry + case *webhookv1.WebhookScope_Organization_: + scope = registry + "/" + organizationIDToName[value.Organization.GetOrganizationId()] + case *webhookv1.WebhookScope_Module_: + scope = registry + "/" + moduleIDToFullName[value.Module.GetModuleId()] + } + entities = append(entities, bufprint.NewWebhookEntity(webhook, scope)) + } + return entities, nil +} + +// *** PRIVATE *** + +func getOrganizationIDToName( + ctx context.Context, + ownerClientProvider bufregistryapiowner.ClientProvider, + registry string, + organizationIDs []string, +) (map[string]string, error) { + organizationIDToName := make(map[string]string, len(organizationIDs)) + if len(organizationIDs) == 0 { + return organizationIDToName, nil + } + organizationRefs := make([]*ownerv1.OrganizationRef, 0, len(organizationIDs)) + for _, organizationID := range organizationIDs { + organizationRefs = append(organizationRefs, &ownerv1.OrganizationRef{ + Value: &ownerv1.OrganizationRef_Id{Id: organizationID}, + }) + } + resp, err := ownerClientProvider.V1OrganizationServiceClient(registry).GetOrganizations( + ctx, + &ownerv1.GetOrganizationsRequest{ + OrganizationRefs: organizationRefs, + }, + ) + if err != nil { + return nil, err + } + for _, organization := range resp.GetOrganizations() { + organizationIDToName[organization.GetId()] = organization.GetName() + } + return organizationIDToName, nil +} + +// getModuleIDToFullName returns a map from module id to "owner/module". +func getModuleIDToFullName( + ctx context.Context, + moduleClientProvider bufregistryapimodule.ClientProvider, + ownerClientProvider bufregistryapiowner.ClientProvider, + registry string, + moduleIDs []string, +) (map[string]string, error) { + moduleIDToFullName := make(map[string]string, len(moduleIDs)) + if len(moduleIDs) == 0 { + return moduleIDToFullName, nil + } + moduleRefs := make([]*modulev1.ModuleRef, 0, len(moduleIDs)) + for _, moduleID := range moduleIDs { + moduleRefs = append(moduleRefs, &modulev1.ModuleRef{ + Value: &modulev1.ModuleRef_Id{Id: moduleID}, + }) + } + modulesResp, err := moduleClientProvider.V1ModuleServiceClient(registry).GetModules( + ctx, + &modulev1.GetModulesRequest{ + ModuleRefs: moduleRefs, + }, + ) + if err != nil { + return nil, err + } + modules := modulesResp.GetModules() + var ownerIDs []string + for _, module := range modules { + ownerIDs = appendUnique(ownerIDs, module.GetOwnerId()) + } + ownerRefs := make([]*ownerv1.OwnerRef, 0, len(ownerIDs)) + for _, ownerID := range ownerIDs { + ownerRefs = append(ownerRefs, &ownerv1.OwnerRef{ + Value: &ownerv1.OwnerRef_Id{Id: ownerID}, + }) + } + ownersResp, err := ownerClientProvider.V1OwnerServiceClient(registry).GetOwners( + ctx, + &ownerv1.GetOwnersRequest{ + OwnerRefs: ownerRefs, + }, + ) + if err != nil { + return nil, err + } + ownerIDToName := make(map[string]string, len(ownerIDs)) + for _, owner := range ownersResp.GetOwners() { + switch value := owner.GetValue().(type) { + case *ownerv1.Owner_User: + ownerIDToName[value.User.GetId()] = value.User.GetName() + case *ownerv1.Owner_Organization: + ownerIDToName[value.Organization.GetId()] = value.Organization.GetName() + default: + return nil, syserror.Newf("unknown owner: %T", value) + } + } + for _, module := range modules { + moduleIDToFullName[module.GetId()] = ownerIDToName[module.GetOwnerId()] + "/" + module.GetName() + } + return moduleIDToFullName, nil +} + +func appendUnique(values []string, value string) []string { + if slices.Contains(values, value) { + return values + } + return append(values, value) +} diff --git a/cmd/buf/internal/command/registry/webhook/webhookcreate/webhookcreate.go b/cmd/buf/internal/command/registry/webhook/webhookcreate/webhookcreate.go new file mode 100644 index 0000000000..8f50146aab --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhookcreate/webhookcreate.go @@ -0,0 +1,177 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhookcreate + +import ( + "context" + "fmt" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "connectrpc.com/connect/v2" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufprint" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + nameFlagName = "name" + urlFlagName = "url" + eventFlagName = "event" + signingSecretFileFlagName = "signing-secret-file" + formatFlagName = "format" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "Create a BSR webhook", + Long: internal.ScopeArgLong, + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + ModifyCobra: func(cmd *cobra.Command) error { + return bufcli.RegisterFlagCompletionOutputFormat(cmd, formatFlagName) + }, + } +} + +type flags struct { + Name string + URL string + Events []string + SigningSecretFile string + Format string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &f.Name, + nameFlagName, + "", + "The name of the webhook, unique within the scope", + ) + _ = appcmd.MarkFlagRequired(flagSet, nameFlagName) + flagSet.StringVar( + &f.URL, + urlFlagName, + "", + "The URL that events are delivered to", + ) + _ = appcmd.MarkFlagRequired(flagSet, urlFlagName) + flagSet.StringSliceVar( + &f.Events, + eventFlagName, + nil, + internal.EventFlagUsage("The event type to subscribe to"), + ) + _ = appcmd.MarkFlagRequired(flagSet, eventFlagName) + flagSet.StringVar( + &f.SigningSecretFile, + signingSecretFileFlagName, + "", + `The path to a file containing the secret used to sign deliveries, or "-" to read from stdin. Trailing newlines are removed. If not set, deliveries are not signed`, + ) + flagSet.StringVar( + &f.Format, + formatFlagName, + bufprint.FormatText.String(), + fmt.Sprintf(`The output format to use. Must be one of %s`, bufprint.AllFormatsString), + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookEventTypes, err := internal.ParseWebhookEventTypes(flags.Events) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + format, err := bufprint.ParseFormat(flags.Format) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + var signingSecret []byte + if flags.SigningSecretFile != "" { + signingSecret, err = internal.ReadSigningSecretFile(container, flags.SigningSecretFile) + if err != nil { + return err + } + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + resp, err := webhookServiceClient.CreateWebhooks( + ctx, + &webhookv1.CreateWebhooksRequest{ + Values: []*webhookv1.CreateWebhooksRequest_Value{ + { + Name: flags.Name, + ScopeRef: webhookScopeRef, + EventTypes: webhookEventTypes, + Url: flags.URL, + SigningSecret: signingSecret, + }, + }, + }, + ) + if err != nil { + if connect.CodeOf(err) == connect.CodeAlreadyExists { + return bufcli.NewWebhookNameAlreadyExistsError(flags.Name, container.Arg(0)) + } + return err + } + webhooks := resp.GetWebhooks() + if len(webhooks) != 1 { + return syserror.Newf("unexpected number of webhooks created by server: %d", len(webhooks)) + } + if format == bufprint.FormatText { + if _, err := fmt.Fprintf(container.Stdout(), "Created webhook %s in %s.\n", flags.Name, container.Arg(0)); err != nil { + return syserror.Wrap(err) + } + return nil + } + return bufprint.PrintNames( + container.Stdout(), + format, + bufprint.NewWebhookEntity(webhooks[0], container.Arg(0)), + ) +} diff --git a/cmd/buf/internal/command/registry/webhook/webhookdelete/webhookdelete.go b/cmd/buf/internal/command/registry/webhook/webhookdelete/webhookdelete.go new file mode 100644 index 0000000000..b911eca1a9 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhookdelete/webhookdelete.go @@ -0,0 +1,129 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhookdelete + +import ( + "cmp" + "context" + "fmt" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "connectrpc.com/connect/v2" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/pflag" +) + +const ( + nameFlagName = "name" + idFlagName = "id" + forceFlagName = "force" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "Delete a BSR webhook", + Long: internal.ScopeArgLong, + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + } +} + +type flags struct { + Name string + ID string + Force bool +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &f.Name, + nameFlagName, + "", + fmt.Sprintf("The name of the webhook within the scope. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + flagSet.StringVar( + &f.ID, + idFlagName, + "", + fmt.Sprintf("The id of the webhook. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + flagSet.BoolVar( + &f.Force, + forceFlagName, + false, + "Force deletion without confirming. Use with caution", + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookRef, err := bufcli.NewWebhookRef(webhookScopeRef, flags.Name, flags.ID) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + nameOrID := cmp.Or(flags.Name, flags.ID) + if !flags.Force { + if err := bufcli.PromptUserForDelete(container, "webhook", nameOrID); err != nil { + return err + } + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + if _, err := webhookServiceClient.DeleteWebhooks( + ctx, + &webhookv1.DeleteWebhooksRequest{ + WebhookRefs: []*webhookv1.WebhookRef{webhookRef}, + }, + ); err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return bufcli.NewWebhookNotFoundError(nameOrID, container.Arg(0)) + } + return err + } + if _, err := fmt.Fprintln(container.Stdout(), "Webhook deleted."); err != nil { + return syserror.Wrap(err) + } + return nil +} diff --git a/cmd/buf/internal/command/registry/webhook/webhookinfo/webhookinfo.go b/cmd/buf/internal/command/registry/webhook/webhookinfo/webhookinfo.go new file mode 100644 index 0000000000..e2ae7149e6 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhookinfo/webhookinfo.go @@ -0,0 +1,138 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhookinfo + +import ( + "cmp" + "context" + "fmt" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "connectrpc.com/connect/v2" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufprint" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + nameFlagName = "name" + idFlagName = "id" + formatFlagName = "format" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "Get information about a BSR webhook", + Long: internal.ScopeArgLong, + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + ModifyCobra: func(cmd *cobra.Command) error { + return bufcli.RegisterFlagCompletionOutputFormat(cmd, formatFlagName) + }, + } +} + +type flags struct { + Name string + ID string + Format string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &f.Name, + nameFlagName, + "", + fmt.Sprintf("The name of the webhook within the scope. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + flagSet.StringVar( + &f.ID, + idFlagName, + "", + fmt.Sprintf("The id of the webhook. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + flagSet.StringVar( + &f.Format, + formatFlagName, + bufprint.FormatText.String(), + fmt.Sprintf(`The output format to use. Must be one of %s`, bufprint.AllFormatsString), + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookRef, err := bufcli.NewWebhookRef(webhookScopeRef, flags.Name, flags.ID) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + format, err := bufprint.ParseFormat(flags.Format) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + resp, err := webhookServiceClient.GetWebhooks( + ctx, + &webhookv1.GetWebhooksRequest{ + WebhookRefs: []*webhookv1.WebhookRef{webhookRef}, + }, + ) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return bufcli.NewWebhookNotFoundError(cmp.Or(flags.Name, flags.ID), container.Arg(0)) + } + return err + } + webhooks := resp.GetWebhooks() + if len(webhooks) != 1 { + return syserror.Newf("unexpected number of webhooks returned from server: %d", len(webhooks)) + } + entities, err := internal.NewWebhookEntities(ctx, clientConfig, registry, webhooks) + if err != nil { + return err + } + return bufprint.PrintEntity(container.Stdout(), format, entities[0]) +} diff --git a/cmd/buf/internal/command/registry/webhook/webhooklist/webhooklist.go b/cmd/buf/internal/command/registry/webhook/webhooklist/webhooklist.go new file mode 100644 index 0000000000..b441087d10 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhooklist/webhooklist.go @@ -0,0 +1,225 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhooklist + +import ( + "context" + "fmt" + "strings" + + modulev1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/module/v1" + ownerv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/owner/v1" + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufprint" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + scopeTypeFlagName = "scope-type" + pageSizeFlagName = "page-size" + pageTokenFlagName = "page-token" + reverseFlagName = "reverse" + formatFlagName = "format" + + defaultPageSize = 10 +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "List BSR webhooks", + Long: internal.ScopeArgLong + ` + +Listing an organization includes the webhooks on the modules it owns. Listing +the instance includes all webhooks visible to the caller.`, + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + ModifyCobra: func(cmd *cobra.Command) error { + return bufcli.RegisterFlagCompletionOutputFormat(cmd, formatFlagName) + }, + } +} + +type flags struct { + ScopeTypes []string + PageSize uint32 + PageToken string + Reverse bool + Format string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringSliceVar( + &f.ScopeTypes, + scopeTypeFlagName, + nil, + fmt.Sprintf( + "Only list webhooks with this scope type. May be repeated. Must be one of %s", + strings.Join(allWebhookScopeTypeStrings(), ", "), + ), + ) + flagSet.Uint32Var( + &f.PageSize, + pageSizeFlagName, + defaultPageSize, + `The page size.`, + ) + flagSet.StringVar( + &f.PageToken, + pageTokenFlagName, + "", + `The page token. If more results are available, a "next_page" key is present in the --format=json output`, + ) + flagSet.BoolVar( + &f.Reverse, + reverseFlagName, + false, + `Reverse the results`, + ) + flagSet.StringVar( + &f.Format, + formatFlagName, + bufprint.FormatText.String(), + fmt.Sprintf(`The output format to use. Must be one of %s`, bufprint.AllFormatsString), + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookScopeTypes, err := parseWebhookScopeTypes(flags.ScopeTypes) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + format, err := bufprint.ParseFormat(flags.Format) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + order := webhookv1.ListWebhooksRequest_ORDER_CREATE_TIME_DESC + if flags.Reverse { + order = webhookv1.ListWebhooksRequest_ORDER_CREATE_TIME_ASC + } + request := &webhookv1.ListWebhooksRequest{ + PageSize: flags.PageSize, + PageToken: flags.PageToken, + Order: order, + HasScopeTypes: webhookScopeTypes, + } + switch value := webhookScopeRef.GetValue().(type) { + case *webhookv1.WebhookScopeRef_Instance_: + case *webhookv1.WebhookScopeRef_OrganizationRef: + request.OrganizationRefs = []*ownerv1.OrganizationRef{value.OrganizationRef} + case *webhookv1.WebhookScopeRef_ModuleRef: + request.ModuleRefs = []*modulev1.ModuleRef{value.ModuleRef} + default: + return syserror.Newf("unknown webhook scope ref: %T", value) + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + resp, err := webhookServiceClient.ListWebhooks(ctx, request) + if err != nil { + return err + } + entities, err := internal.NewWebhookEntities(ctx, clientConfig, registry, resp.GetWebhooks()) + if err != nil { + return err + } + return bufprint.PrintPage( + container.Stdout(), + format, + resp.GetNextPageToken(), + nextPageCommand(container, flags, resp.GetNextPageToken()), + entities, + ) +} + +func parseWebhookScopeTypes(webhookScopeTypeStrings []string) ([]webhookv1.WebhookScopeType, error) { + webhookScopeTypes := make([]webhookv1.WebhookScopeType, 0, len(webhookScopeTypeStrings)) + for _, webhookScopeTypeString := range webhookScopeTypeStrings { + value, ok := webhookv1.WebhookScopeType_value[webhookScopeTypeString] + if !ok || value == int32(webhookv1.WebhookScopeType_WEBHOOK_SCOPE_TYPE_UNSPECIFIED) { + return nil, fmt.Errorf( + "unknown webhook scope type %q: must be one of %s", + webhookScopeTypeString, + strings.Join(allWebhookScopeTypeStrings(), ", "), + ) + } + webhookScopeTypes = append(webhookScopeTypes, webhookv1.WebhookScopeType(value)) + } + return webhookScopeTypes, nil +} + +func allWebhookScopeTypeStrings() []string { + enumValueDescriptors := webhookv1.WebhookScopeType(0).Descriptor().Values() + var webhookScopeTypeStrings []string + for i := range enumValueDescriptors.Len() { + enumValueDescriptor := enumValueDescriptors.Get(i) + if enumValueDescriptor.Number() == 0 { + continue + } + webhookScopeTypeStrings = append(webhookScopeTypeStrings, string(enumValueDescriptor.Name())) + } + return webhookScopeTypeStrings +} + +func nextPageCommand(container appext.Container, flags *flags, nextPageToken string) string { + if nextPageToken == "" { + return "" + } + command := fmt.Sprintf("buf registry webhook list %s", container.Arg(0)) + for _, scopeType := range flags.ScopeTypes { + command = fmt.Sprintf("%s --%s %s", command, scopeTypeFlagName, scopeType) + } + if flags.PageSize != defaultPageSize { + command = fmt.Sprintf("%s --%s %d", command, pageSizeFlagName, flags.PageSize) + } + if flags.Reverse { + command = fmt.Sprintf("%s --%s", command, reverseFlagName) + } + if flags.Format != bufprint.FormatText.String() { + command = fmt.Sprintf("%s --%s %s", command, formatFlagName, flags.Format) + } + return fmt.Sprintf("%s --%s %s", command, pageTokenFlagName, nextPageToken) +} diff --git a/cmd/buf/internal/command/registry/webhook/webhookping/webhookping.go b/cmd/buf/internal/command/registry/webhook/webhookping/webhookping.go new file mode 100644 index 0000000000..764010db13 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhookping/webhookping.go @@ -0,0 +1,237 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhookping + +import ( + "cmp" + "context" + "fmt" + "time" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "connectrpc.com/connect/v2" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/buf/bufprint" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/protoencoding" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + nameFlagName = "name" + idFlagName = "id" + urlFlagName = "url" + signingSecretFileFlagName = "signing-secret-file" + formatFlagName = "format" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "Send a ping event to a BSR webhook", + Long: internal.ScopeArgLong + ` + +To ping an existing webhook, set --name or --id. Set --url to deliver the ping +to a different URL using the webhook's signing secret. + +To ping a URL before creating a webhook, set --url without --name or --id. Set +--signing-secret-file to sign the ping. + +The command fails if the ping is not delivered successfully.`, + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + ModifyCobra: func(cmd *cobra.Command) error { + return bufcli.RegisterFlagCompletionOutputFormat(cmd, formatFlagName) + }, + } +} + +type flags struct { + Name string + ID string + URL *string + SigningSecretFile string + Format string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &f.Name, + nameFlagName, + "", + "The name of an existing webhook within the scope", + ) + flagSet.StringVar( + &f.ID, + idFlagName, + "", + "The id of an existing webhook", + ) + bufcli.BindStringPointer( + flagSet, + urlFlagName, + &f.URL, + "The URL to deliver the ping to. Required if neither --name nor --id is set", + ) + flagSet.StringVar( + &f.SigningSecretFile, + signingSecretFileFlagName, + "", + `The path to a file containing the secret used to sign the ping, or "-" to read from stdin. Trailing newlines are removed. Only used if neither --name nor --id is set`, + ) + flagSet.StringVar( + &f.Format, + formatFlagName, + bufprint.FormatText.String(), + fmt.Sprintf(`The output format to use. Must be one of %s`, bufprint.AllFormatsString), + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + format, err := bufprint.ParseFormat(flags.Format) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + request, err := newPingWebhookRequest(container, flags, webhookScopeRef) + if err != nil { + return err + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + resp, err := webhookServiceClient.PingWebhook(ctx, request) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound && request.GetExisting() != nil { + return bufcli.NewWebhookNotFoundError(cmp.Or(flags.Name, flags.ID), container.Arg(0)) + } + return err + } + duration := resp.GetDuration().AsDuration().Round(time.Millisecond) + switch format { + case bufprint.FormatText: + if resp.GetErrorMessage() == "" { + if _, err := fmt.Fprintf( + container.Stdout(), + "Ping delivered: HTTP %d in %s.\n", + resp.GetHttpStatusCode(), + duration, + ); err != nil { + return syserror.Wrap(err) + } + } + case bufprint.FormatJSON: + data, err := protoencoding.NewJSONMarshaler(nil, protoencoding.JSONMarshalerWithIndent()).Marshal(resp) + if err != nil { + return err + } + if _, err := container.Stdout().Write(append(data, '\n')); err != nil { + return syserror.Wrap(err) + } + default: + return syserror.Newf("unknown format: %s", format) + } + if resp.GetErrorMessage() != "" { + return fmt.Errorf( + "ping failed after %s with HTTP status %d: %s", + duration, + resp.GetHttpStatusCode(), + resp.GetErrorMessage(), + ) + } + return nil +} + +func newPingWebhookRequest( + container appext.Container, + flags *flags, + webhookScopeRef *webhookv1.WebhookScopeRef, +) (*webhookv1.PingWebhookRequest, error) { + if flags.Name != "" || flags.ID != "" { + if flags.SigningSecretFile != "" { + return nil, appcmd.NewInvalidArgumentErrorf( + "--%s cannot be set when pinging an existing webhook with --%s or --%s", + signingSecretFileFlagName, + nameFlagName, + idFlagName, + ) + } + webhookRef, err := bufcli.NewWebhookRef(webhookScopeRef, flags.Name, flags.ID) + if err != nil { + return nil, appcmd.WrapInvalidArgumentError(err) + } + return &webhookv1.PingWebhookRequest{ + Value: &webhookv1.PingWebhookRequest_Existing_{ + Existing: &webhookv1.PingWebhookRequest_Existing{ + WebhookRef: webhookRef, + Url: flags.URL, + }, + }, + }, nil + } + if flags.URL == nil { + return nil, appcmd.NewInvalidArgumentErrorf( + "one of --%s, --%s, or --%s is required", + nameFlagName, + idFlagName, + urlFlagName, + ) + } + var signingSecret []byte + if flags.SigningSecretFile != "" { + var err error + signingSecret, err = internal.ReadSigningSecretFile(container, flags.SigningSecretFile) + if err != nil { + return nil, err + } + } + return &webhookv1.PingWebhookRequest{ + Value: &webhookv1.PingWebhookRequest_Draft_{ + Draft: &webhookv1.PingWebhookRequest_Draft{ + ScopeRef: webhookScopeRef, + Url: *flags.URL, + SigningSecret: signingSecret, + }, + }, + }, nil +} diff --git a/cmd/buf/internal/command/registry/webhook/webhookupdate/webhookupdate.go b/cmd/buf/internal/command/registry/webhook/webhookupdate/webhookupdate.go new file mode 100644 index 0000000000..04793822e5 --- /dev/null +++ b/cmd/buf/internal/command/registry/webhook/webhookupdate/webhookupdate.go @@ -0,0 +1,200 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhookupdate + +import ( + "cmp" + "context" + "fmt" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "connectrpc.com/connect/v2" + "github.com/bufbuild/buf/cmd/buf/internal/command/registry/webhook/internal" + "github.com/bufbuild/buf/private/buf/bufcli" + "github.com/bufbuild/buf/private/bufpkg/bufregistryapi/bufregistryapiwebhook" + "github.com/bufbuild/buf/private/pkg/syserror" + "github.com/spf13/pflag" +) + +const ( + nameFlagName = "name" + idFlagName = "id" + newNameFlagName = "new-name" + urlFlagName = "url" + eventFlagName = "event" + signingSecretFileFlagName = "signing-secret-file" + removeSigningSecretFlagName = "remove-signing-secret" +) + +// NewCommand returns a new Command. +func NewCommand( + name string, + builder appext.SubCommandBuilder, +) *appcmd.Command { + flags := newFlags() + return &appcmd.Command{ + Use: name + " " + internal.ScopeArgUsage, + Short: "Update a BSR webhook", + Long: internal.ScopeArgLong + "\n\nThe scope of a webhook cannot be changed.", + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + BindFlags: flags.Bind, + } +} + +type flags struct { + Name string + ID string + NewName *string + URL *string + Events []string + SigningSecretFile string + RemoveSigningSecret bool +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &f.Name, + nameFlagName, + "", + fmt.Sprintf("The name of the webhook within the scope. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + flagSet.StringVar( + &f.ID, + idFlagName, + "", + fmt.Sprintf("The id of the webhook. Exactly one of --%s or --%s is required", nameFlagName, idFlagName), + ) + bufcli.BindStringPointer( + flagSet, + newNameFlagName, + &f.NewName, + "The new name for the webhook, unique within the scope", + ) + bufcli.BindStringPointer( + flagSet, + urlFlagName, + &f.URL, + "The new URL that events are delivered to", + ) + flagSet.StringSliceVar( + &f.Events, + eventFlagName, + nil, + internal.EventFlagUsage("The event type to subscribe to, replacing the existing event types"), + ) + flagSet.StringVar( + &f.SigningSecretFile, + signingSecretFileFlagName, + "", + `The path to a file containing the new secret used to sign deliveries, or "-" to read from stdin. Trailing newlines are removed`, + ) + flagSet.BoolVar( + &f.RemoveSigningSecret, + removeSigningSecretFlagName, + false, + "Remove the signing secret, so that deliveries are not signed", + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + registry, webhookScopeRef, err := bufcli.ParseWebhookScope(container.Arg(0)) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookRef, err := bufcli.NewWebhookRef(webhookScopeRef, flags.Name, flags.ID) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + webhookEventTypes, err := internal.ParseWebhookEventTypes(flags.Events) + if err != nil { + return appcmd.WrapInvalidArgumentError(err) + } + if flags.SigningSecretFile != "" && flags.RemoveSigningSecret { + return appcmd.NewInvalidArgumentErrorf( + "cannot set both --%s and --%s", + signingSecretFileFlagName, + removeSigningSecretFlagName, + ) + } + var signingSecret []byte + switch { + case flags.SigningSecretFile != "": + signingSecret, err = internal.ReadSigningSecretFile(container, flags.SigningSecretFile) + if err != nil { + return err + } + case flags.RemoveSigningSecret: + // A set but empty secret removes the existing secret. + signingSecret = []byte{} + } + if flags.NewName == nil && flags.URL == nil && len(webhookEventTypes) == 0 && signingSecret == nil { + return appcmd.NewInvalidArgumentErrorf( + "at least one of --%s, --%s, --%s, --%s, or --%s is required", + newNameFlagName, + urlFlagName, + eventFlagName, + signingSecretFileFlagName, + removeSigningSecretFlagName, + ) + } + clientConfig, err := bufcli.NewConnectClientConfig(container) + if err != nil { + return err + } + webhookServiceClient := bufregistryapiwebhook.NewClientProvider(clientConfig).V1WebhookServiceClient(registry) + if _, err := webhookServiceClient.UpdateWebhooks( + ctx, + &webhookv1.UpdateWebhooksRequest{ + Values: []*webhookv1.UpdateWebhooksRequest_Value{ + { + WebhookRef: webhookRef, + Url: flags.URL, + EventTypes: webhookEventTypes, + Name: flags.NewName, + SigningSecret: signingSecret, + }, + }, + }, + ); err != nil { + switch connect.CodeOf(err) { + case connect.CodeNotFound: + return bufcli.NewWebhookNotFoundError(cmp.Or(flags.Name, flags.ID), container.Arg(0)) + case connect.CodeAlreadyExists: + if flags.NewName != nil { + return bufcli.NewWebhookNameAlreadyExistsError(*flags.NewName, container.Arg(0)) + } + } + return err + } + if _, err := fmt.Fprintln(container.Stdout(), "Webhook updated."); err != nil { + return syserror.Wrap(err) + } + return nil +} diff --git a/private/buf/bufcli/errors.go b/private/buf/bufcli/errors.go index 23c63430e2..bff5c3539a 100644 --- a/private/buf/bufcli/errors.go +++ b/private/buf/bufcli/errors.go @@ -66,6 +66,12 @@ func NewPolicyNameAlreadyExistsError(name string) error { return fmt.Errorf("a policy named %q already exists", name) } +// NewWebhookNameAlreadyExistsError informs the user that a webhook +// with that name already exists within the scope. +func NewWebhookNameAlreadyExistsError(name string, scope string) error { + return fmt.Errorf("a webhook named %q already exists in %q", name, scope) +} + // NewOrganizationNotFoundError informs the user that an organization with // that name does not exist. func NewOrganizationNotFoundError(name string) error { @@ -112,6 +118,12 @@ func NewPolicyNotFoundError(name string) error { return fmt.Errorf("a plugin named %q does not exist", name) } +// NewWebhookNotFoundError informs the user that a webhook with +// that name or id does not exist within the scope. +func NewWebhookNotFoundError(nameOrID string, scope string) error { + return fmt.Errorf("a webhook %q does not exist in %q", nameOrID, scope) +} + // NewInvalidRemoteError informs the user that the given remote is invalid. func NewInvalidRemoteError(err error, remote string, moduleFullName string) error { var connectErr *connect.Error diff --git a/private/buf/bufcli/webhook.go b/private/buf/bufcli/webhook.go new file mode 100644 index 0000000000..0bf52b2758 --- /dev/null +++ b/private/buf/bufcli/webhook.go @@ -0,0 +1,166 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bufcli + +import ( + "errors" + "fmt" + "strings" + + modulev1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/module/v1" + ownerv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/owner/v1" + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "github.com/bufbuild/buf/private/bufpkg/bufparse" + "github.com/bufbuild/buf/private/pkg/netext" +) + +// ParseWebhookScope parses a webhook scope from a string in the form "registry", +// "registry/organization", or "registry/organization/module". +// +// It returns the registry and a WebhookScopeRef for the instance, organization, +// or module respectively. +func ParseWebhookScope(webhookScopeString string) (string, *webhookv1.WebhookScopeRef, error) { + switch strings.Count(webhookScopeString, "/") { + case 0: + registry := strings.TrimSpace(webhookScopeString) + if registry == "" { + return "", nil, errors.New("registry is empty") + } + if _, err := netext.ValidateHostname(registry); err != nil { + return "", nil, fmt.Errorf("registry %q is not a valid hostname: %w", registry, err) + } + return registry, &webhookv1.WebhookScopeRef{ + Value: &webhookv1.WebhookScopeRef_Instance_{ + Instance: &webhookv1.WebhookScopeRef_Instance{}, + }, + }, nil + case 1: + moduleOwner, err := ParseModuleOwner(webhookScopeString) + if err != nil { + return "", nil, err + } + return moduleOwner.Registry(), &webhookv1.WebhookScopeRef{ + Value: &webhookv1.WebhookScopeRef_OrganizationRef{ + OrganizationRef: &ownerv1.OrganizationRef{ + Value: &ownerv1.OrganizationRef_Name{ + Name: moduleOwner.Owner(), + }, + }, + }, + }, nil + case 2: + moduleFullName, err := bufparse.ParseFullName(webhookScopeString) + if err != nil { + return "", nil, err + } + return moduleFullName.Registry(), &webhookv1.WebhookScopeRef{ + Value: &webhookv1.WebhookScopeRef_ModuleRef{ + ModuleRef: &modulev1.ModuleRef{ + Value: &modulev1.ModuleRef_Name_{ + Name: &modulev1.ModuleRef_Name{ + Owner: moduleFullName.Owner(), + Module: moduleFullName.Name(), + }, + }, + }, + }, + }, nil + default: + return "", nil, fmt.Errorf( + "invalid webhook scope %q: must be in the form registry, registry/organization, or registry/organization/module", + webhookScopeString, + ) + } +} + +// NewWebhookRef returns a WebhookRef for either a webhook name within the +// scope, or a webhook id. Exactly one of webhookName and webhookID must be set. +func NewWebhookRef( + webhookScopeRef *webhookv1.WebhookScopeRef, + webhookName string, + webhookID string, +) (*webhookv1.WebhookRef, error) { + switch { + case webhookName != "" && webhookID != "": + return nil, errors.New("only one of webhook name or id may be set") + case webhookName != "": + return &webhookv1.WebhookRef{ + Value: &webhookv1.WebhookRef_Name_{ + Name: &webhookv1.WebhookRef_Name{ + ScopeRef: webhookScopeRef, + Name: webhookName, + }, + }, + }, nil + case webhookID != "": + return &webhookv1.WebhookRef{ + Value: &webhookv1.WebhookRef_Id{ + Id: webhookID, + }, + }, nil + default: + return nil, errors.New("one of webhook name or id must be set") + } +} + +// ParseWebhookEventType parses a webhook event type from its enum value name, +// such as "EVENT_TYPE_MODULE_COMMITS_PUSHED". +// +// Only event types that a webhook can subscribe to are accepted. +func ParseWebhookEventType(webhookEventTypeString string) (webhookv1.EventType, error) { + value, ok := webhookv1.EventType_value[webhookEventTypeString] + if !ok { + return 0, fmt.Errorf( + "unknown webhook event type %q: must be one of %s", + webhookEventTypeString, + strings.Join(WebhookEventTypeStrings(), ", "), + ) + } + webhookEventType := webhookv1.EventType(value) + if !isSubscribableWebhookEventType(webhookEventType) { + return 0, fmt.Errorf( + "webhook event type %q cannot be subscribed to: must be one of %s", + webhookEventTypeString, + strings.Join(WebhookEventTypeStrings(), ", "), + ) + } + return webhookEventType, nil +} + +// WebhookEventTypeStrings returns the enum value names of the webhook event +// types that a webhook can subscribe to. +func WebhookEventTypeStrings() []string { + enumValueDescriptors := webhookv1.EventType(0).Descriptor().Values() + var webhookEventTypeStrings []string + for i := range enumValueDescriptors.Len() { + enumValueDescriptor := enumValueDescriptors.Get(i) + if !isSubscribableWebhookEventType(webhookv1.EventType(enumValueDescriptor.Number())) { + continue + } + webhookEventTypeStrings = append(webhookEventTypeStrings, string(enumValueDescriptor.Name())) + } + return webhookEventTypeStrings +} + +// *** PRIVATE *** + +func isSubscribableWebhookEventType(webhookEventType webhookv1.EventType) bool { + switch webhookEventType { + case webhookv1.EventType_EVENT_TYPE_UNSPECIFIED, webhookv1.EventType_EVENT_TYPE_PING: + return false + default: + return true + } +} diff --git a/private/buf/bufcli/webhook_test.go b/private/buf/bufcli/webhook_test.go new file mode 100644 index 0000000000..bdaefdab82 --- /dev/null +++ b/private/buf/bufcli/webhook_test.go @@ -0,0 +1,94 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bufcli + +import ( + "testing" + + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseWebhookScope(t *testing.T) { + t.Parallel() + + registry, scopeRef, err := ParseWebhookScope("buf.build") + require.NoError(t, err) + assert.Equal(t, "buf.build", registry) + assert.NotNil(t, scopeRef.GetInstance()) + + registry, scopeRef, err = ParseWebhookScope("buf.build/acme") + require.NoError(t, err) + assert.Equal(t, "buf.build", registry) + assert.Equal(t, "acme", scopeRef.GetOrganizationRef().GetName()) + + registry, scopeRef, err = ParseWebhookScope("buf.build/acme/petapis") + require.NoError(t, err) + assert.Equal(t, "buf.build", registry) + assert.Equal(t, "acme", scopeRef.GetModuleRef().GetName().GetOwner()) + assert.Equal(t, "petapis", scopeRef.GetModuleRef().GetName().GetModule()) + + for _, invalid := range []string{ + "", + "buf.build/", + "/acme", + "buf.build/acme/", + "buf.build/acme/petapis/extra", + "not a hostname", + } { + _, _, err := ParseWebhookScope(invalid) + assert.Error(t, err, "input %q", invalid) + } +} + +func TestNewWebhookRef(t *testing.T) { + t.Parallel() + _, scopeRef, err := ParseWebhookScope("buf.build/acme") + require.NoError(t, err) + + webhookRef, err := NewWebhookRef(scopeRef, "ci", "") + require.NoError(t, err) + assert.Equal(t, "ci", webhookRef.GetName().GetName()) + assert.Equal(t, scopeRef, webhookRef.GetName().GetScopeRef()) + + webhookRef, err = NewWebhookRef(scopeRef, "", "abc") + require.NoError(t, err) + assert.Equal(t, "abc", webhookRef.GetId()) + + _, err = NewWebhookRef(scopeRef, "ci", "abc") + assert.Error(t, err) + _, err = NewWebhookRef(scopeRef, "", "") + assert.Error(t, err) +} + +func TestParseWebhookEventType(t *testing.T) { + t.Parallel() + + eventType, err := ParseWebhookEventType("EVENT_TYPE_MODULE_COMMITS_PUSHED") + require.NoError(t, err) + assert.Equal(t, webhookv1.EventType_EVENT_TYPE_MODULE_COMMITS_PUSHED, eventType) + + for _, invalid := range []string{ + "", + "EVENT_TYPE_UNSPECIFIED", + "EVENT_TYPE_PING", + "MODULE_COMMITS_PUSHED", + "event_type_module_commits_pushed", + } { + _, err := ParseWebhookEventType(invalid) + assert.Error(t, err, "input %q", invalid) + } +} diff --git a/private/buf/bufprint/bufprint.go b/private/buf/bufprint/bufprint.go index 2be7f464a2..4bdb02c296 100644 --- a/private/buf/bufprint/bufprint.go +++ b/private/buf/bufprint/bufprint.go @@ -29,6 +29,7 @@ import ( ownerv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/owner/v1" pluginv1beta1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/plugin/v1beta1" policyv1beta1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/policy/v1beta1" + webhookv1 "buf.build/gen/go/bufbuild/registry/protocolbuffers/go/buf/registry/webhook/v1" "buf.build/go/standard/xstrings" "github.com/bufbuild/buf/private/bufpkg/bufparse" registryv1alpha1 "github.com/bufbuild/buf/private/gen/proto/go/buf/alpha/registry/v1alpha1" @@ -140,6 +141,8 @@ func PrintPage( currentEntitiesName = "modules" case outputOrganization: currentEntitiesName = "organizations" + case outputWebhook: + currentEntitiesName = "webhooks" default: return syserror.Newf("unknown implementation of Entity: %T", entity) } @@ -302,6 +305,30 @@ func NewUserEntity(user *ownerv1.User, remote string) Entity { } } +// NewWebhookEntity returns a new webhook entity to print. +// +// The webhook only references its scope by id, so the caller provides the +// scope in the form "registry", "registry/organization", or +// "registry/organization/module". +func NewWebhookEntity(webhook *webhookv1.Webhook, scope string) Entity { + eventTypes := make([]string, 0, len(webhook.GetEventTypes())) + for _, eventType := range webhook.GetEventTypes() { + eventTypes = append(eventTypes, eventType.String()) + } + return outputWebhook{ + ID: webhook.GetId(), + Name: webhook.GetName(), + Scope: scope, + URL: webhook.GetUrl(), + EventTypes: eventTypes, + EventTypesText: strings.Join(eventTypes, ","), + HasSigningSecret: webhook.GetHasSigningSecret(), + HasSigningSecretText: strconv.FormatBool(webhook.GetHasSigningSecret()), + CreateTime: webhook.GetCreateTime().AsTime(), + UpdateTime: webhook.GetUpdateTime().AsTime(), + } +} + // CuratedPluginPrinter is a printer for curated plugins. type CuratedPluginPrinter interface { PrintCuratedPlugin(ctx context.Context, format Format, plugin *registryv1alpha1.CuratedPlugin) error @@ -561,3 +588,20 @@ type outputUser struct { func (o outputUser) fullName() string { return o.FullName } + +type outputWebhook struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty" bufprint:"Name"` + Scope string `json:"scope,omitempty" bufprint:"Scope"` + URL string `json:"url,omitempty" bufprint:"URL"` + EventTypes []string `json:"event_types,omitempty"` + EventTypesText string `json:"-" bufprint:"Event Types"` + HasSigningSecret bool `json:"has_signing_secret"` + HasSigningSecretText string `json:"-" bufprint:"Signing Secret"` + CreateTime time.Time `json:"create_time" bufprint:"Create Time"` + UpdateTime time.Time `json:"update_time" bufprint:"Update Time"` +} + +func (w outputWebhook) fullName() string { + return w.Name +} diff --git a/private/bufpkg/bufregistryapi/bufregistryapiwebhook/bufregistryapiwebhook.go b/private/bufpkg/bufregistryapi/bufregistryapiwebhook/bufregistryapiwebhook.go new file mode 100644 index 0000000000..50d8329ef0 --- /dev/null +++ b/private/bufpkg/bufregistryapi/bufregistryapiwebhook/bufregistryapiwebhook.go @@ -0,0 +1,68 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bufregistryapiwebhook + +import ( + "buf.build/gen/go/bufbuild/registry/connectrpc/go/v2/buf/registry/webhook/v1/webhookv1connect" + "github.com/bufbuild/buf/private/pkg/connectclient" +) + +var ( + // NopV1WebhookServiceClientProvider is a V1WebhookServiceClientProvider that provides unimplemented services for testing. + NopV1WebhookServiceClientProvider V1WebhookServiceClientProvider = nopClientProvider{} + // NopClientProvider is a ClientProvider that provides unimplemented services for testing. + NopClientProvider ClientProvider = nopClientProvider{} +) + +// V1WebhookServiceClientProvider provides WebhookServiceClients. +type V1WebhookServiceClientProvider interface { + V1WebhookServiceClient(registry string) webhookv1connect.WebhookServiceClient +} + +// ClientProvider provides API clients for BSR services. +type ClientProvider interface { + V1WebhookServiceClientProvider +} + +// NewClientProvider returns a new ClientProvider. +func NewClientProvider(clientConfig *connectclient.Config) ClientProvider { + return newClientProvider(clientConfig) +} + +// *** PRIVATE *** + +type clientProvider struct { + clientConfig *connectclient.Config +} + +func newClientProvider(clientConfig *connectclient.Config) *clientProvider { + return &clientProvider{ + clientConfig: clientConfig, + } +} + +func (c *clientProvider) V1WebhookServiceClient(registry string) webhookv1connect.WebhookServiceClient { + return connectclient.Make( + c.clientConfig, + registry, + webhookv1connect.NewWebhookServiceClient, + ) +} + +type nopClientProvider struct{} + +func (nopClientProvider) V1WebhookServiceClient(registry string) webhookv1connect.WebhookServiceClient { + return webhookv1connect.UnimplementedWebhookServiceHandler{} +} diff --git a/private/bufpkg/bufregistryapi/bufregistryapiwebhook/usage.gen.go b/private/bufpkg/bufregistryapi/bufregistryapiwebhook/usage.gen.go new file mode 100644 index 0000000000..c2c7b58533 --- /dev/null +++ b/private/bufpkg/bufregistryapi/bufregistryapiwebhook/usage.gen.go @@ -0,0 +1,19 @@ +// Copyright 2020-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Generated. DO NOT EDIT. + +package bufregistryapiwebhook + +import _ "buf.build/go/bufprivateusage"