diff --git a/internal/app/app_test.go b/internal/app/app_test.go index da9e8d08..73d1970c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -570,11 +570,6 @@ const ( variable list variable set variable view - webhook create - webhook delete - webhook edit - webhook list - webhook view wiki create wiki delete wiki edit @@ -611,7 +606,6 @@ const ( ssh-key time variable - webhook wiki workflow ` diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index edd1a1ac..373d8b4e 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -290,7 +290,7 @@ func TestPromptedCreateSubmitsWithoutConfirmation(t *testing.T) { }{ {[]string{"secret", "set", "TOKEN"}, nil, "private", "PUT /api/v1/repos/alice/project/actions/secrets/TOKEN"}, {[]string{"variable", "set", "MODE"}, map[string]string{"Variable value": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"}, - {[]string{"webhook", "create"}, map[string]string{"url": "https://hooks.test/path"}, "", "POST /api/v1/repos/alice/project/hooks"}, + {[]string{"webhook", "create"}, map[string]string{"Payload URL": "https://hooks.test/path"}, "", "POST /api/v1/repos/alice/project/hooks"}, {[]string{"team", "create", "acme"}, map[string]string{"Team name": "Developers"}, "", "POST /api/v1/orgs/acme/teams"}, } { t.Run(strings.Join(tc.args, " "), func(t *testing.T) { diff --git a/internal/app/webhooks.go b/internal/app/webhooks.go index 770545a0..d9806e4e 100644 --- a/internal/app/webhooks.go +++ b/internal/app/webhooks.go @@ -4,8 +4,11 @@ package app import ( + "context" "errors" + "fmt" "net/url" + "slices" "strconv" "strings" @@ -23,207 +26,450 @@ func validateWebhookURL(raw string) error { return nil } +// webhookEventGroups are the events that issues and pull_request select; tables show a complete +// group as one entry. +var webhookEventGroups = map[string][]string{ + "issues": {"issue_assign", "issue_comment", "issue_label", "issue_milestone", "issues"}, + "pull_request": {"pull_request", "pull_request_assign", "pull_request_comment", "pull_request_label", "pull_request_milestone", "pull_request_review", "pull_request_review_request", "pull_request_sync"}, +} + +// collapsedEvents lists events with each complete group as one entry, such as "pull_request (8)". +func collapsedEvents(events []string) string { + grouped := map[string]bool{} + for _, group := range webhookEventGroups { + if !slices.ContainsFunc(group, func(event string) bool { return !slices.Contains(events, event) }) { + for _, event := range group { + grouped[event] = true + } + } + } + var shown []string + for _, event := range events { + switch group, head := webhookEventGroups[event]; { + case head && grouped[event]: + shown = append(shown, fmt.Sprintf("%s (%d)", event, len(group))) + case !grouped[event]: + shown = append(shown, event) + } + } + return strings.Join(shown, ", ") +} + +// webhookScope holds the scope flags of the webhook commands. +type webhookScope struct { + org string + account, global bool +} + +// service resolves the target and the webhooks of the selected scope. +func (s *webhookScope) service(a *application, cmd *cobra.Command) (target, services.Webhooks, error) { + scopes := 0 + for _, selected := range []bool{s.org != "", s.account, s.global, a.options.repo != ""} { + if selected { + scopes++ + } + } + if scopes > 1 { + return target{}, services.Webhooks{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, --global, and --repo select mutually exclusive scopes")) + } + t, err := a.resolve(cmd.Context(), s.org == "" && !s.account && !s.global) + return t, services.Webhooks{Client: t.client, Scope: services.WebhookScope{Repository: t.Repo, Organization: s.org, Account: s.account, Global: s.global}}, err +} + +// webhookScopeName names a scope in messages: alice/x, organization acme, your account, the instance. +func webhookScopeName(scope services.WebhookScope) string { + switch { + case scope.Global: + return "the instance" + case scope.Account: + return "your account" + case scope.Organization != "": + return "organization " + scope.Organization + } + return scope.Repository +} + +// webhookSettingsURL is the settings page of a webhook. +func webhookSettingsURL(t target, scope services.WebhookScope, id int64) string { + hook := strconv.FormatInt(id, 10) + switch { + case scope.Global: + return webURL(t, "-", "admin", "hooks", hook) + case scope.Account: + return webURL(t, "user", "settings", "hooks", hook) + case scope.Organization != "": + return webURL(t, "org", scope.Organization, "settings", "hooks", hook) + } + owner, name, _ := strings.Cut(scope.Repository, "/") + return webURL(t, owner, name, "settings", "hooks", hook) +} + +// webhookLabel names a webhook in messages: "webhook 3 (https://hooks.example.com/…)". +func webhookLabel(hook services.Webhook) string { + return fmt.Sprintf("webhook %d (%s)", hook.ID, hook.URL) +} + func (a *application) webhookCommand() *cobra.Command { - group := &cobra.Command{Use: "webhook", Short: "Manage webhook delivery settings"} - var org string - var account, global bool - group.PersistentFlags().StringVar(&org, "org", "", "Organization scope") - group.PersistentFlags().BoolVar(&account, "account", false, "Current account scope") - group.PersistentFlags().BoolVar(&global, "global", false, "System webhook scope (administrator)") - scope := func(cmd *cobra.Command) (services.Webhooks, error) { - scopes := 0 - for _, selected := range []bool{org != "", account, global, a.options.repo != ""} { - if selected { - scopes++ - } - } - if scopes > 1 { - return services.Webhooks{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, --global, and --repo select mutually exclusive scopes")) - } - target, err := a.resolve(cmd.Context(), org == "" && !account && !global) - return services.Webhooks{Client: target.client, Scope: services.WebhookScope{Repository: target.Repo, Organization: org, Account: account, Global: global}}, err - } - var limit, page int - list := &cobra.Command{Use: "list", Short: "List webhook metadata (delivery credentials are omitted)", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - service, err := scope(cmd) - if err != nil { - return err - } - items, err := service.List(cmd.Context(), limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, webhookRow) - }} - pageFlags(list, &limit, &page) - group.AddCommand(list) - for _, verb := range []string{"view", "create", "edit", "delete"} { - var endpoint, kind, secret, secretFile, authorization, authorizationFile, filter string - var events []string - var config map[string]string - var active, clearAuthorization bool - command := &cobra.Command{Use: verb + " ", Short: titleCase(verb) + " a webhook", Args: argsExact(1)} - if verb == "create" { - command.Use = "create [url]" - command.Args = usageArgs(cobra.MaximumNArgs(1)) - } - if verb == "create" || verb == "edit" { - command.Flags().StringVar(&endpoint, "url", "", "Delivery URL") - command.Flags().StringVar(&secret, "secret", "", "Signing secret (prefer --secret-file)") - command.Flags().StringVar(&secretFile, "secret-file", "", "Read signing secret from file or -") - command.Flags().StringSliceVar(&events, "events", []string{"push"}, "Comma-separated trigger events") - command.Flags().BoolVar(&active, "active", true, "Enable delivery (use --active=false to disable)") - command.Flags().StringVar(&filter, "branch-filter", "", "Branch glob for push events") - command.Flags().StringVar(&authorization, "authorization-header", "", "Authorization value (prefer file input)") - command.Flags().StringVar(&authorizationFile, "authorization-header-file", "", "Read authorization value from file or -") - command.Flags().BoolVar(&clearAuthorization, "clear-authorization", false, "Send no authorization header; on edit, this removes the stored one") - command.Flags().StringToStringVar(&config, "config", nil, "Adapter-specific configuration, for example channel=alerts") - if verb == "create" { - command.Flags().StringVar(&kind, "type", "gitea", "Webhook adapter type") - } - } - if verb == "delete" { - command.Annotations = textOutput("webhook deletion does not return a resource") - } - command.RunE = func(cmd *cobra.Command, args []string) error { - var id int64 - var err error - opts := services.WebhookOptions{} - if verb != "create" { - id, err = number(args[0]) - if err != nil { - return err - } - } - if verb == "create" || verb == "edit" { - if cmd.Flags().Changed("secret") && cmd.Flags().Changed("secret-file") { - return clierr.Wrap(clierr.Usage, errors.New("--secret and --secret-file are mutually exclusive")) - } - authSources := 0 - for _, set := range []bool{cmd.Flags().Changed("authorization-header"), cmd.Flags().Changed("authorization-header-file"), clearAuthorization} { - if set { - authSources++ - } - } - if authSources > 1 { - return clierr.Wrap(clierr.Usage, errors.New("choose one authorization header source or --clear-authorization")) - } - if secretFile == "-" && authorizationFile == "-" { - return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one webhook credential")) - } - if verb == "edit" && (cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file")) { - return clierr.Wrap(clierr.Usage, &services.CapabilityError{Feature: "webhook secret editing", Reason: "Gitea does not update signing secrets through its edit API; recreate the hook to rotate it"}) - } - if verb == "edit" && authSources == 0 { - return clierr.Wrap(clierr.Usage, errors.New("editing a webhook drops its stored authorization header unless it is sent again, and Gitea never returns it: pass --authorization-header-file or --authorization-header (use --clear-authorization only for a hook without one)")) - } - if len(args) > 0 && verb == "create" { - if cmd.Flags().Changed("url") { - return clierr.Wrap(clierr.Usage, errors.New("supply the URL as an argument or --url, not both")) - } - endpoint = args[0] - } - if verb == "create" { - if _, err := a.require(cmd, &endpoint, "url"); err != nil { - return err - } - } - if endpoint != "" || cmd.Flags().Changed("url") { - if err := validateWebhookURL(endpoint); err != nil { - return err - } - } - if verb == "create" || cmd.Flags().Changed("events") { - if len(events) == 0 { - return clierr.Wrap(clierr.Usage, errors.New("--events needs at least one trigger")) - } - for i, event := range events { - events[i] = strings.TrimSpace(event) - if events[i] == "" { - return clierr.Wrap(clierr.Usage, errors.New("--events cannot contain empty triggers")) - } - } - opts.Events = &events - } - opts.Config = make(map[string]string, len(config)+4) - for key, value := range config { - if key == "url" || key == "secret" || key == "is_system_webhook" { - return clierr.Wrap(clierr.Usage, errors.New("--config cannot override the URL, secret, or webhook scope")) - } - if key == "" { - return clierr.Wrap(clierr.Usage, errors.New("--config keys cannot be empty")) - } - opts.Config[key] = value - } - secret, err = a.readSecret(cmd, secret, secretFile, "secret", "secret-file") - if err != nil { - return err - } - authorization, err = a.readSecret(cmd, authorization, authorizationFile, "authorization-header", "authorization-header-file") - if err != nil { - return err - } - if strings.ContainsAny(authorization, "\r\n") { - return clierr.Wrap(clierr.Usage, errors.New("authorization header must be a single line")) - } - if endpoint != "" { - opts.Config["url"] = endpoint - } - if verb == "create" { - opts.Type = kind - opts.Active = &active - opts.BranchFilter = &filter - opts.Config["http_method"] = "post" - if _, ok := opts.Config["content_type"]; !ok { - opts.Config["content_type"] = "json" - } - } - if cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file") { - opts.Config["secret"] = secret - } - if cmd.Flags().Changed("active") { - opts.Active = &active - } - if cmd.Flags().Changed("branch-filter") { - opts.BranchFilter = &filter - } - if authSources > 0 { - opts.Authorization = &authorization - } - if len(opts.Config) == 0 { - opts.Config = nil - } - } - service, err := scope(cmd) - if err != nil { - return err - } - if verb == "delete" { - if err := a.confirm(cmd, "delete webhook "+args[0]); err != nil { - return err - } - } - var item services.Webhook - switch verb { - case "view": - item, err = service.View(cmd.Context(), id) - case "create": - item, err = service.Create(cmd.Context(), opts) - case "edit": - item, err = service.Edit(cmd.Context(), id, opts) - case "delete": - return service.Delete(cmd.Context(), id) - } - if err != nil { - return err - } - return writeItem(a, cmd, item, webhookRow) - } - group.AddCommand(command) - } + group := &cobra.Command{Use: "webhook", Short: "Manage webhooks", Long: `Manage the webhooks of a repository, of an organization with --org, of your account with +--account, or of the instance with --global (administrators). Without these flags, webhooks +belong to the repository of the current directory or of --repo. + +Delivery URLs often carry credentials, so tea shows them without user information and query, +and without the path for chat services; tea webhook view --reveal-url shows a URL whole. +Signing secrets and authorization headers are never shown.`} + var scope webhookScope + group.PersistentFlags().StringVar(&scope.org, "org", "", "Manage the webhooks of an organization") + group.PersistentFlags().BoolVar(&scope.account, "account", false, "Manage the webhooks of your account") + group.PersistentFlags().BoolVar(&scope.global, "global", false, "Manage the system webhooks of the instance (administrators)") + _ = group.RegisterFlagCompletionFunc("org", a.completeOrgs()) + group.AddCommand(a.webhookListCommand(&scope), a.webhookViewCommand(&scope), a.webhookChangeCommand(&scope, true), a.webhookChangeCommand(&scope, false), a.webhookDeleteCommand(&scope)) return group } -func webhookRow(item services.Webhook) []terminal.Cell { - return terminal.Row(strconv.FormatInt(item.ID, 10), item.Type, strconv.FormatBool(item.Active), strings.Join(item.Events, ",")) +// completeWebhooks completes the webhook IDs of the scope that the parsed flags select, with their +// URLs and events. +func (a *application) completeWebhooks(scope *webhookScope) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + inRepository := scope.org == "" && !scope.account && !scope.global + return a.apiCompletion(inRepository, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + hooks, err := services.Webhooks{Client: t.client, Scope: services.WebhookScope{Repository: t.Repo, Organization: scope.org, Account: scope.account, Global: scope.global}}.List(ctx, completionLimit, 1) + return describe(hooks.Items, func(hook services.Webhook) (string, string) { + return strconv.FormatInt(hook.ID, 10), hook.URL + " (" + collapsedEvents(hook.Events) + ")" + }), err + })(cmd, args, toComplete) + } +} + +func (a *application) webhookListCommand(scope *webhookScope) *cobra.Command { + var limit, page int + cmd := &cobra.Command{ + Use: "list", Short: "List webhooks", Args: argsExact(0), + Example: `$ tea webhook list +$ tea webhook list --org acme +# print the delivery URLs +$ tea webhook list --jq '.[].url'`, + RunE: func(cmd *cobra.Command, _ []string) error { + target, service, err := scope.service(a, cmd) + if err != nil { + return err + } + hooks, err := service.List(cmd.Context(), limit, page) + if err != nil { + return err + } + where := webhookScopeName(service.Scope) + return writeTable(a, cmd, hooks.Items, listSpec[services.Webhook]{ + Columns: []terminal.Column{{Header: "ID"}, {Header: "Type"}, {Header: "Active"}, {Header: "URL", Flex: true}, {Header: "Events", Flex: true, TTYOnly: true}, {Header: "Events", PipedOnly: true}}, + Row: func(hook services.Webhook) []terminal.Cell { + active := terminal.Cell{Text: yesNo(hook.Active), Color: "green"} + if !hook.Active { + active = terminal.Cell{Text: "no", Muted: true} + } + return []terminal.Cell{ + {Text: strconv.FormatInt(hook.ID, 10), Link: webhookSettingsURL(target, service.Scope, hook.ID)}, + {Text: hook.Type}, + active, + {Text: hook.URL}, + {Text: collapsedEvents(hook.Events)}, + {Text: strings.Join(hook.Events, ",")}, + } + }, + Context: "webhooks of " + where, Empty: "no webhooks for " + where, + }, servicePage(hooks, page)) + }, + } + pageFlags(cmd, &limit, &page) + return cmd +} + +func (a *application) webhookViewCommand(scope *webhookScope) *cobra.Command { + var reveal bool + cmd := &cobra.Command{ + Use: "view ", Short: "View a webhook", Args: argsExact(1), ValidArgsFunction: a.completeWebhooks(scope), + Example: `$ tea webhook view 3 +# show the delivery URL with the credentials it carries +$ tea webhook view 3 --org acme --reveal-url`, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err + } + target, service, err := scope.service(a, cmd) + if err != nil { + return err + } + service.RevealURL = reveal + hook, err := service.View(cmd.Context(), id) + if err != nil { + return err + } + d := a.display() + return writeView(a, cmd, hook, func(hook services.Webhook) terminal.View { + active := terminal.Cell{Text: "active", Color: "green"} + if !hook.Active { + active = terminal.Cell{Text: "inactive", Muted: true} + } + updated := timeField("Updated", hook.UpdatedAt, d) + meta := "" + if updated.Value != "" { + meta = "updated " + updated.Value + } + updated.PipedOnly = true + return terminal.View{ + Title: hook.URL, ID: fmt.Sprintf("(webhook %d)", hook.ID), Badges: []terminal.Cell{active, {Text: hook.Type}}, Meta: meta, + Fields: []terminal.Field{ + {Label: "Active", Value: strconv.FormatBool(hook.Active), PipedOnly: true}, + {Label: "Type", Value: hook.Type, PipedOnly: true}, + {Label: "Scope", Value: webhookScopeName(service.Scope)}, + {Label: "Events", Value: strings.Join(hook.Events, ", ")}, + {Label: "Content type", Value: hook.ContentType}, + {Label: "Branch filter", Value: hook.BranchFilter}, + timeField("Created", hook.CreatedAt, d), updated, + }, + Bodyless: true, Kind: "webhook", URL: webhookSettingsURL(target, service.Scope, hook.ID), + } + }) + }, + } + cmd.Flags().BoolVar(&reveal, "reveal-url", false, "Show the delivery URL whole, with any credentials it carries") + return cmd +} + +// webhookChangeCommand creates or edits a webhook. +func (a *application) webhookChangeCommand(scope *webhookScope, create bool) *cobra.Command { + var endpoint, kind, secret, secretFile, authorization, authorizationFile, filter string + var events []string + var config map[string]string + var active, clearAuthorization bool + command := &cobra.Command{ + Use: "edit ", Short: "Edit a webhook", Args: argsExact(1), ValidArgsFunction: a.completeWebhooks(scope), + Long: `Edit a webhook. Only the settings you pass change. + +Gitea drops the stored authorization header of a webhook on every edit and never shows it, so +pass it again with --authorization-header-file. On a terminal, tea asks for it instead; leave +the answer empty for a webhook without one. Signing secrets cannot be changed: recreate the +webhook to rotate one.`, + Example: `# pause deliveries of a webhook without an authorization header +$ tea webhook edit 3 --active=false --clear-authorization +# keep the authorization header while changing the events +$ tea webhook edit 3 --events push,release --authorization-header-file header.txt`, + } + eventsUsage := "Events that trigger the webhook, comma-separated" + if create { + command.Use, command.Short, command.ValidArgsFunction = "create []", "Create a webhook", nil + command.Args = usageArgs(cobra.MaximumNArgs(1)) + command.Long = `Create a webhook that delivers events to a URL, by default as JSON for push events. + +Events: create, delete, fork, push, issues (every issue event), issues_only, issue_assign, +issue_label, issue_milestone, issue_comment, pull_request (every pull request event), +pull_request_only, pull_request_assign, pull_request_label, pull_request_milestone, +pull_request_comment, pull_request_review, pull_request_sync, pull_request_review_request, wiki, +repository, release, package, status, workflow_run, workflow_job. + +Types: ` + strings.Join(services.WebhookTypes, ", ") + `. Slack webhooks need +--config channel=#name; other types take their settings through --config too.` + command.Example = `$ tea webhook create https://ci.example.com/hooks/gitea --events push,pull_request +# sign deliveries with a secret read from a file +$ tea webhook create https://ci.example.com/hooks/gitea --secret-file secret.txt +# notify a Slack channel about releases of an organization's repositories +$ tea webhook create https://hooks.slack.com/services/T0/B0/X --org acme --type slack --config channel=#releases --events release` + enumFlag(command, &kind, "type", "", "gitea", "Webhook adapter type", services.WebhookTypes...) + command.Flags().StringSliceVar(&events, "events", []string{"push"}, eventsUsage) + command.Flags().BoolVar(&active, "active", true, "Deliver events (use --active=false to create the webhook paused)") + } else { + command.Flags().StringSliceVar(&events, "events", nil, eventsUsage+", replacing the current ones (see tea webhook create --help)") + command.Flags().BoolVar(&active, "active", false, "Deliver events, or pause the webhook with --active=false") + } + enumValues(command, "events", services.WebhookEvents...) + command.Flags().StringVar(&endpoint, "url", "", "Delivery URL") + command.Flags().StringVar(&secret, "secret", "", "Signing secret (prefer --secret-file)") + command.Flags().StringVar(&secretFile, "secret-file", "", "Read signing secret from file or -") + command.Flags().StringVar(&filter, "branch-filter", "", "Branch glob for push events") + command.Flags().StringVar(&authorization, "authorization-header", "", "Authorization value (prefer file input)") + command.Flags().StringVar(&authorizationFile, "authorization-header-file", "", "Read authorization value from file or -") + command.Flags().BoolVar(&clearAuthorization, "clear-authorization", false, "Send no authorization header; on edit, this removes the stored one") + command.Flags().StringToStringVar(&config, "config", nil, "Adapter-specific configuration, for example channel=alerts") + if !create { + // Gitea cannot change signing secrets; the flags stay to explain that. + _ = command.Flags().MarkHidden("secret") + _ = command.Flags().MarkHidden("secret-file") + } + command.RunE = func(cmd *cobra.Command, args []string) error { + var id int64 + var err error + opts := services.WebhookOptions{} + if !create { + if id, err = number(args[0]); err != nil { + return err + } + } + if cmd.Flags().Changed("secret") && cmd.Flags().Changed("secret-file") { + return clierr.Wrap(clierr.Usage, errors.New("--secret and --secret-file are mutually exclusive")) + } + authSources := 0 + for _, set := range []bool{cmd.Flags().Changed("authorization-header"), cmd.Flags().Changed("authorization-header-file"), clearAuthorization} { + if set { + authSources++ + } + } + if authSources > 1 { + return clierr.Wrap(clierr.Usage, errors.New("choose one authorization header source or --clear-authorization")) + } + if secretFile == "-" && authorizationFile == "-" { + return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one webhook credential")) + } + if !create && (cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file")) { + return clierr.Wrap(clierr.Usage, &services.CapabilityError{Feature: "webhook secret editing", Reason: "Gitea does not update signing secrets through its edit API; recreate the hook to rotate it"}) + } + if len(args) > 0 && create { + if cmd.Flags().Changed("url") { + return clierr.Wrap(clierr.Usage, errors.New("supply the URL as an argument or --url, not both")) + } + endpoint = args[0] + } + if create || cmd.Flags().Changed("events") { + trimNames(events) + if len(events) == 0 || slices.Contains(events, "") { + return clierr.Wrap(clierr.Usage, errors.New("--events needs at least one trigger")) + } + opts.Events = &events + } + opts.Config = make(map[string]string, len(config)+4) + for key, value := range config { + if key == "url" || key == "secret" || key == "is_system_webhook" { + return clierr.Wrap(clierr.Usage, errors.New("--config cannot override the URL, secret, or webhook scope")) + } + if key == "" { + return clierr.Wrap(clierr.Usage, errors.New("--config keys cannot be empty")) + } + opts.Config[key] = value + } + if create && kind == "slack" && config["channel"] == "" { + return clierr.Wrap(clierr.Usage, errors.New("--type slack needs a channel: --config channel=#name")) + } + if endpoint != "" || cmd.Flags().Changed("url") { + if err := validateWebhookURL(endpoint); err != nil { + return err + } + } + if create { + if _, err := a.requireInput(cmd, &endpoint, "url", "Payload URL", validateWebhookURL); err != nil { + return err + } + } + if secret, err = a.readSecret(cmd, secret, secretFile, "secret", "secret-file"); err != nil { + return err + } + if authorization, err = a.readSecret(cmd, authorization, authorizationFile, "authorization-header", "authorization-header-file"); err != nil { + return err + } + askAuthorization := !create && authSources == 0 + if askAuthorization { + snapshot, err := a.deps.Settings.Load() + if err != nil { + return err + } + if !a.policy(snapshot).CanPrompt(a.deps.Streams) { + return clierr.Wrap(clierr.Usage, errors.New("editing a webhook drops its stored authorization header unless it is sent again, and Gitea never returns it: pass --authorization-header-file or --authorization-header (use --clear-authorization only for a hook without one)")) + } + } + if strings.ContainsAny(authorization, "\r\n") { + return clierr.Wrap(clierr.Usage, errors.New("authorization header must be a single line")) + } + if endpoint != "" { + opts.Config["url"] = endpoint + } + if create { + opts.Type = kind + opts.Active = &active + opts.BranchFilter = &filter + opts.Config["http_method"] = "post" + if _, ok := opts.Config["content_type"]; !ok { + opts.Config["content_type"] = "json" + } + } + if cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file") { + opts.Config["secret"] = secret + } + if cmd.Flags().Changed("active") { + opts.Active = &active + } + if cmd.Flags().Changed("branch-filter") { + opts.BranchFilter = &filter + } + if authSources > 0 { + opts.Authorization = &authorization + } + if len(opts.Config) == 0 { + opts.Config = nil + } + target, service, err := scope.service(a, cmd) + if err != nil { + return err + } + if askAuthorization { + hook, err := service.View(cmd.Context(), id) + if err != nil { + return err + } + title := fmt.Sprintf("Re-enter the authorization header of webhook %d (empty if it has none)", hook.ID) + if authorization, err = a.deps.Prompter.SecretInput(cmd.Context(), title); err != nil { + return err + } + opts.Authorization = &authorization + } + if !create { + hook, err := service.Edit(cmd.Context(), id, opts) + if err != nil { + return err + } + a.done(cmd, "Edited %s in %s", webhookLabel(hook), webhookScopeName(service.Scope)) + return a.jsonResult(cmd, hook) + } + hook, err := service.Create(cmd.Context(), opts) + if err != nil { + return err + } + a.done(cmd, "Created %s in %s for %s", webhookLabel(hook), webhookScopeName(service.Scope), collapsedEvents(hook.Events)) + if err := a.printURL(cmd, webhookSettingsURL(target, service.Scope, hook.ID)); err != nil { + return err + } + return a.jsonResult(cmd, hook) + } + return command +} + +func (a *application) webhookDeleteCommand(scope *webhookScope) *cobra.Command { + return &cobra.Command{ + Use: "delete ", Short: "Delete a webhook", Args: argsExact(1), ValidArgsFunction: a.completeWebhooks(scope), + Example: `$ tea webhook delete 3 +$ tea webhook delete 4 --org acme --yes`, + Annotations: map[string]string{"tea.text": "webhook deletion does not return a resource", "tea.confirm": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err + } + _, service, err := scope.service(a, cmd) + if err != nil { + return err + } + hook, err := service.View(cmd.Context(), id) + if err != nil { + return err + } + where := webhookScopeName(service.Scope) + if err := a.confirmAction(cmd, terminal.Confirmation{ + Prompt: fmt.Sprintf("Delete %s from %s?", webhookLabel(hook), where), + NonTTY: fmt.Sprintf("delete %s from %s", webhookLabel(hook), where), + }); err != nil { + return err + } + if err := service.Delete(cmd.Context(), id); err != nil { + return err + } + a.done(cmd, "Deleted %s from %s", webhookLabel(hook), where) + return nil + }, + } } diff --git a/internal/app/webhooks_test.go b/internal/app/webhooks_test.go new file mode 100644 index 00000000..16eecbdd --- /dev/null +++ b/internal/app/webhooks_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWebhookCreateValidatesEventsAndTypes(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"--events", "pushh"}, "--events must be create, delete, fork, push, issues"}, + {[]string{"--events", "push,pull_request_review_approved"}, "--events must be"}, + {[]string{"--type", "slackk"}, "--type must be gitea, gogs, slack"}, + {[]string{"--type", "slack"}, "--type slack needs a channel: --config channel=#name"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + requireUsageBeforeRequests(t, tc.want, append([]string{"webhook", "create", "https://hooks.example.com/ci"}, tc.args...)...) + }) + } +} + +func TestWebhookListShowsDeliveryURLsAndSortedEvents(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/alice/project/hooks", r.URL.Path) + _, _ = w.Write([]byte(`[ + {"id":3,"type":"gitea","active":true,"events":["push","issues","issue_label"],"config":{"url":"https://ci.example.com/hook?token=private","content_type":"json"}}, + {"id":4,"type":"slack","active":false,"events":["release"],"config":{"url":"https://hooks.slack.com/services/T0/B0/private"}}]`)) + })) + defer server.Close() + deps, out, _ := testDeps(t) + deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"}) + require.NoError(t, Execute(context.Background(), deps, []string{"webhook", "list"})) + require.Equal(t, "3\tgitea\tyes\thttps://ci.example.com/hook?…\tissue_label,issues,push\n4\tslack\tno\thttps://hooks.slack.com/…\trelease\n", out.String()) + require.NotContains(t, out.String(), "private") +} + +func TestWebhookEventsCollapseCompleteGroups(t *testing.T) { + pulls := webhookEventGroups["pull_request"] + for _, tc := range []struct { + events []string + want string + }{ + {[]string{"push"}, "push"}, + {[]string{"issue_assign", "issue_comment", "issue_label", "issue_milestone", "issues", "push"}, "issues (5), push"}, + {[]string{"issue_label", "issues"}, "issue_label, issues"}, + {append([]string{"create"}, pulls...), "create, pull_request (8)"}, + } { + require.Equal(t, tc.want, collapsedEvents(tc.events)) + } +} + +func TestWebhookEditAsksForTheAuthorizationHeaderOnATerminal(t *testing.T) { + var edits []map[string]any + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/alice/project/hooks/3", r.URL.Path) + if r.Method == http.MethodPatch { + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + edits = append(edits, body) + } + _, _ = w.Write([]byte(`{"id":3,"type":"gitea","active":true,"events":["push"],"config":{"url":"https://ci.example.com/hook"}}`)) + })) + defer server.Close() + deps, _, _ := testDeps(t) + deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"}) + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + prompt := &scriptedPrompter{secret: "Bearer kept"} + deps.Prompter = prompt + require.NoError(t, Execute(context.Background(), deps, []string{"webhook", "edit", "3", "--active=false"})) + require.Equal(t, []string{"Re-enter the authorization header of webhook 3 (empty if it has none)"}, prompt.secrets) + require.Len(t, edits, 1) + require.Equal(t, "Bearer kept", edits[0]["authorization_header"]) + require.Equal(t, false, edits[0]["active"]) +} diff --git a/internal/services/webhooks.go b/internal/services/webhooks.go index ff5b03d9..02ca9eca 100644 --- a/internal/services/webhooks.go +++ b/internal/services/webhooks.go @@ -4,17 +4,25 @@ package services import ( + "cmp" "context" "maps" + "net/http" + "net/url" + "slices" + "strconv" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" ) -// Webhook is safe webhook metadata. Delivery URLs and credentials are omitted. +// Webhook is webhook metadata. Its delivery URL omits credentials unless revealed; signing secrets +// and authorization headers are never returned. type Webhook struct { ID int64 `json:"id"` Type string `json:"type"` + URL string `json:"url"` + ContentType string `json:"content_type"` Active bool `json:"active"` Events []string `json:"events"` BranchFilter string `json:"branch_filter"` @@ -22,14 +30,52 @@ type Webhook struct { UpdatedAt string `json:"updated_at"` } -func webhookFrom(hook *gitea.Hook) Webhook { - events := hook.Events +// WebhookEvents are the events that webhooks accept. issues and pull_request select every event of +// their group; issues_only and pull_request_only select the events of the issue or pull request itself. +var WebhookEvents = []string{ + "create", "delete", "fork", "push", + "issues", "issues_only", "issue_assign", "issue_label", "issue_milestone", "issue_comment", + "pull_request", "pull_request_only", "pull_request_assign", "pull_request_label", "pull_request_milestone", + "pull_request_comment", "pull_request_review", "pull_request_sync", "pull_request_review_request", + "wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job", +} + +// WebhookTypes are the adapters that deliver webhooks. +var WebhookTypes = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"} + +// tokenPaths are the adapters whose delivery URL paths carry access tokens. +var tokenPaths = []string{"slack", "discord", "telegram", "msteams", "feishu"} + +// redactURL drops the credentials a delivery URL may carry: user information, the query, and the +// path of adapters with a token in it. "…" marks what was dropped. +func redactURL(raw, adapter string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "" + } + path := parsed.EscapedPath() + if slices.Contains(tokenPaths, adapter) && path != "" && path != "/" { + path = "/…" + } + redacted := parsed.Scheme + "://" + parsed.Host + path + if parsed.RawQuery != "" { + redacted += "?…" + } + return redacted +} + +func webhookFrom(hook *gitea.Hook, reveal bool) Webhook { + events := slices.Sorted(slices.Values(hook.Events)) if events == nil { events = []string{} } + address := hook.Config["url"] + if !reveal { + address = redactURL(address, hook.Type) + } return Webhook{ - ID: hook.ID, Type: hook.Type, Active: hook.Active, Events: events, BranchFilter: hook.BranchFilter, - CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated), + ID: hook.ID, Type: hook.Type, URL: address, ContentType: hook.Config["content_type"], Active: hook.Active, + Events: events, BranchFilter: hook.BranchFilter, CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated), } } @@ -53,30 +99,36 @@ type WebhookOptions struct { type Webhooks struct { Client *transport.Client Scope WebhookScope + // RevealURL returns delivery URLs whole, with the credentials they may carry. + RevealURL bool +} + +func (s Webhooks) from(hook *gitea.Hook) Webhook { + return webhookFrom(hook, s.RevealURL) } // List returns a page of hook metadata. -func (s Webhooks) List(ctx context.Context, limit, page int) ([]Webhook, error) { - hooks, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, error) { - opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)} - switch { - case s.Scope.Global: - return c.Hooks.ListGlobalHooks(ctx, gitea.ListGlobalHooksOptions{ListOptions: opts.ListOptions}) - case s.Scope.Account: - return c.Hooks.ListMyHooks(ctx, opts) - case s.Scope.Organization != "": - return c.Hooks.ListOrgHooks(ctx, s.Scope.Organization, opts) - } - owner, name, err := splitRepository(s.Scope.Repository) - if err != nil { - return nil, nil, err - } - return c.Hooks.ListRepoHooks(ctx, owner, name, opts) +func (s Webhooks) List(ctx context.Context, limit, page int) (Page[Webhook], error) { + hooks, err := listPage(limit, page, func(limit, page int) ([]*gitea.Hook, http.Header, error) { + hooks, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, error) { + opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)} + switch { + case s.Scope.Global: + return c.Hooks.ListGlobalHooks(ctx, gitea.ListGlobalHooksOptions{ListOptions: opts.ListOptions}) + case s.Scope.Account: + return c.Hooks.ListMyHooks(ctx, opts) + case s.Scope.Organization != "": + return c.Hooks.ListOrgHooks(ctx, s.Scope.Organization, opts) + } + owner, name, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, nil, err + } + return c.Hooks.ListRepoHooks(ctx, owner, name, opts) + }) + return hooks, headerOf(resp), err }) - if err != nil { - return nil, err - } - return convertAll(hooks, webhookFrom), nil + return convertPage(hooks, s.from), err } func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) { @@ -95,7 +147,7 @@ func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) { } return c.Hooks.GetRepoHook(ctx, owner, name, id) }) - return hook, err + return hook, Classify(err, "webhook", strconv.FormatInt(id, 10), cmp.Or(s.Scope.Repository, s.Scope.Organization)) } // View retrieves hook metadata without exposing its delivery configuration. @@ -104,7 +156,7 @@ func (s Webhooks) View(ctx context.Context, id int64) (Webhook, error) { if err != nil { return Webhook{}, err } - return webhookFrom(hook), nil + return s.from(hook), nil } // Create registers a new webhook. @@ -136,7 +188,7 @@ func (s Webhooks) Create(ctx context.Context, opts WebhookOptions) (Webhook, err if err != nil { return Webhook{}, err } - return webhookFrom(hook), nil + return s.from(hook), nil } // Edit preserves settings that Gitea replaces rather than patching individually. @@ -189,7 +241,7 @@ func (s Webhooks) Edit(ctx context.Context, id int64, opts WebhookOptions) (Webh if err != nil { return Webhook{}, err } - return webhookFrom(hook), nil + return s.from(hook), nil } // Delete removes a webhook from the selected scope. diff --git a/internal/services/webhooks_test.go b/internal/services/webhooks_test.go index 8ab74399..7a983419 100644 --- a/internal/services/webhooks_test.go +++ b/internal/services/webhooks_test.go @@ -133,7 +133,7 @@ func TestWebhookScopesSelectEndpoints(t *testing.T) { service := Webhooks{Client: client, Scope: tc.scope} items, err := service.List(context.Background(), 10, 2) require.NoError(t, err) - require.Equal(t, []Webhook{{ID: 3, Type: "gitea", Events: []string{}}}, items) + require.Equal(t, []Webhook{{ID: 3, Type: "gitea", Events: []string{}}}, items.Items) require.NoError(t, service.Delete(context.Background(), 3)) require.Equal(t, []string{"GET " + tc.path + "?limit=10&page=2", "DELETE " + tc.path + "/3"}, requests) }) @@ -142,9 +142,32 @@ func TestWebhookScopesSelectEndpoints(t *testing.T) { func TestWebhookFromMapsTimestamps(t *testing.T) { hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","active":true,"events":["push"],"branch_filter":"main", - "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","config":{"url":"https://private.invalid"}}`) + "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","config":{"url":"https://hooks.invalid/ci","content_type":"form"}}`) require.Equal(t, Webhook{ - ID: 1, Type: "gitea", Active: true, Events: []string{"push"}, BranchFilter: "main", + ID: 1, Type: "gitea", URL: "https://hooks.invalid/ci", ContentType: "form", Active: true, Events: []string{"push"}, BranchFilter: "main", CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00", - }, webhookFrom(hook)) + }, webhookFrom(hook, false)) +} + +func TestWebhookFromSortsEvents(t *testing.T) { + hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","events":["push","issues","issue_label","create"]}`) + require.Equal(t, []string{"create", "issue_label", "issues", "push"}, webhookFrom(hook, false).Events) +} + +func TestWebhookFromRedactsDeliveryURLs(t *testing.T) { + for _, tc := range []struct{ adapter, raw, want string }{ + {"gitea", "https://ci.example.com/hooks/gitea", "https://ci.example.com/hooks/gitea"}, + {"gitea", "https://user:pass@ci.example.com/hook?token=private", "https://ci.example.com/hook?…"}, + {"packagist", "https://packagist.org/api/update-package?username=a&apiToken=private", "https://packagist.org/api/update-package?…"}, + {"slack", "https://hooks.slack.com/services/T0/B0/private", "https://hooks.slack.com/…"}, + {"discord", "https://discord.com/api/webhooks/1/private", "https://discord.com/…"}, + {"telegram", "https://api.telegram.org/botprivate/sendMessage?chat_id=1", "https://api.telegram.org/…?…"}, + {"matrix", "https://matrix.example.com/_matrix/client/v3/rooms/!room/send/m.room.message", "https://matrix.example.com/_matrix/client/v3/rooms/!room/send/m.room.message"}, + } { + t.Run(tc.adapter, func(t *testing.T) { + hook := &gitea.Hook{ID: 1, Type: tc.adapter, Config: map[string]string{"url": tc.raw}} + require.Equal(t, tc.want, webhookFrom(hook, false).URL) + require.Equal(t, tc.raw, webhookFrom(hook, true).URL) + }) + } } diff --git a/internal/terminal/view.go b/internal/terminal/view.go index e13f2527..eba183b5 100644 --- a/internal/terminal/view.go +++ b/internal/terminal/view.go @@ -49,6 +49,8 @@ type View struct { Meta string Fields []Field Body string + // Bodyless resources, such as webhooks, have no body: the view ends with the fields and URL. + Bodyless bool // Kind names the resource in the footer, such as "issue". Kind, URL string } @@ -68,6 +70,9 @@ func (v View) Render(d Display) string { if v.URL != "" { b.WriteString("url:\t" + SafeLine(v.URL) + "\n") } + if v.Bodyless { + return b.String() + } body := SafeText(v.Body) b.WriteString("--\n" + body) if !strings.HasSuffix(body, "\n") { @@ -106,16 +111,18 @@ func (v View) Render(d Display) string { b.WriteString(Cell{Bold: true}.style(label, d, false) + strings.Repeat(" ", labelWidth+2-runewidth.StringWidth(label)) + field.text(d) + "\n") } - body := strings.TrimSpace(v.Body) - switch { - case body == "": - body = muted("No description provided") - case d.Markdown: - body = Markdown(body, d) - default: - body = SafeText(body) + if !v.Bodyless { + body := strings.TrimSpace(v.Body) + switch { + case body == "": + body = muted("No description provided") + case d.Markdown: + body = Markdown(body, d) + default: + body = SafeText(body) + } + b.WriteString("\n" + body + "\n") } - b.WriteString("\n" + body + "\n") if v.URL != "" { kind := "View on Gitea: " if v.Kind != "" { diff --git a/internal/terminal/view_test.go b/internal/terminal/view_test.go index 18a8856c..264f20a8 100644 --- a/internal/terminal/view_test.go +++ b/internal/terminal/view_test.go @@ -62,6 +62,18 @@ func TestViewRender(t *testing.T) { Display{TTY: true}, "Home\n\nNo description provided\n", }, + { + "bodyless for people", + View{Title: "laptop", Fields: []Field{{Label: "Type", Value: "ssh-ed25519"}}, Bodyless: true, URL: "https://example.org/keys"}, + Display{TTY: true}, + "laptop\nType: ssh-ed25519\n\nView on Gitea: https://example.org/keys\n", + }, + { + "bodyless when piped", + View{Title: "laptop", Fields: []Field{{Label: "Type", Value: "ssh-ed25519"}}, Bodyless: true, URL: "https://example.org/keys"}, + Display{}, + "title:\tlaptop\ntype:\tssh-ed25519\nurl:\thttps://example.org/keys\n", + }, } { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, tc.view.Render(tc.d))