fix(webhook): validate events and types, sort events and show delivery URLs

webhook create --events pushh created a webhook that fired on nothing,
since Gitea drops unknown events, and unknown types or a Slack webhook
without a channel failed with raw HTTP 422 errors. --events and --type
now accept only what Gitea knows, list it in help and complete it, and a
Slack webhook needs --config channel=.

Events came back in a different order on every call; they are sorted
now. Webhooks can be told apart again: list, view and JSON show the
delivery URL without user information and query, and without the path
for chat services whose paths carry tokens; view --reveal-url shows it
whole. Tables collapse complete event groups such as pull_request (8).

On a terminal, webhook edit no longer needs an authorization flag: since
Gitea drops the stored header on every edit and never returns it, it asks
for the header again and keeps it, and an empty answer suits webhooks
without one. Without a terminal the edit still requires the flags. Edit
flags lose the create defaults, the unsupported secret flags are hidden
on edit, deletions name the webhook and its scope, and mutations print ✓
lines. terminal.View gains Bodyless for resources without a body.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:42:49 +02:00
1 parent 769e93f5fd
commit ab3e91acd1
8 files changed
+532 -114

No files matched your search

-6
View File
@@ -570,11 +570,6 @@ const (
variable list variable list
variable set variable set
variable view variable view
webhook create
webhook delete
webhook edit
webhook list
webhook view
wiki create wiki create
wiki delete wiki delete
wiki edit wiki edit
@@ -611,7 +606,6 @@ const (
ssh-key ssh-key
time time
variable variable
webhook
wiki wiki
workflow workflow
` `
+1 -1
View File
@@ -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{"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{"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"}, {[]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) { t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
+334 -88
View File
@@ -4,8 +4,11 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -23,79 +26,283 @@ func validateWebhookURL(raw string) error {
return nil return nil
} }
func (a *application) webhookCommand() *cobra.Command { // webhookEventGroups are the events that issues and pull_request select; tables show a complete
group := &cobra.Command{Use: "webhook", Short: "Manage webhook delivery settings"} // group as one entry.
var org string var webhookEventGroups = map[string][]string{
var account, global bool "issues": {"issue_assign", "issue_comment", "issue_label", "issue_milestone", "issues"},
group.PersistentFlags().StringVar(&org, "org", "", "Organization scope") "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"},
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) { // 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 scopes := 0
for _, selected := range []bool{org != "", account, global, a.options.repo != ""} { for _, selected := range []bool{s.org != "", s.account, s.global, a.options.repo != ""} {
if selected { if selected {
scopes++ scopes++
} }
} }
if scopes > 1 { if scopes > 1 {
return services.Webhooks{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, --global, and --repo select mutually exclusive scopes")) return target{}, 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) t, err := a.resolve(cmd.Context(), s.org == "" && !s.account && !s.global)
return services.Webhooks{Client: target.client, Scope: services.WebhookScope{Repository: target.Repo, Organization: org, Account: account, Global: global}}, err 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 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
}
// 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 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 { cmd := &cobra.Command{
service, err := scope(cmd) 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 { if err != nil {
return err return err
} }
items, err := service.List(cmd.Context(), limit, page) hooks, err := service.List(cmd.Context(), limit, page)
if err != nil { if err != nil {
return err return err
} }
return writeList(a, cmd, items, webhookRow) where := webhookScopeName(service.Scope)
}} return writeTable(a, cmd, hooks.Items, listSpec[services.Webhook]{
pageFlags(list, &limit, &page) Columns: []terminal.Column{{Header: "ID"}, {Header: "Type"}, {Header: "Active"}, {Header: "URL", Flex: true}, {Header: "Events", Flex: true, TTYOnly: true}, {Header: "Events", PipedOnly: true}},
group.AddCommand(list) Row: func(hook services.Webhook) []terminal.Cell {
for _, verb := range []string{"view", "create", "edit", "delete"} { 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 <id>", 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 endpoint, kind, secret, secretFile, authorization, authorizationFile, filter string
var events []string var events []string
var config map[string]string var config map[string]string
var active, clearAuthorization bool var active, clearAuthorization bool
command := &cobra.Command{Use: verb + " <id>", Short: titleCase(verb) + " a webhook", Args: argsExact(1)} command := &cobra.Command{
if verb == "create" { Use: "edit <id>", Short: "Edit a webhook", Args: argsExact(1), ValidArgsFunction: a.completeWebhooks(scope),
command.Use = "create [url]" Long: `Edit a webhook. Only the settings you pass change.
command.Args = usageArgs(cobra.MaximumNArgs(1))
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`,
} }
if verb == "create" || verb == "edit" { eventsUsage := "Events that trigger the webhook, comma-separated"
if create {
command.Use, command.Short, command.ValidArgsFunction = "create [<url>]", "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(&endpoint, "url", "", "Delivery URL")
command.Flags().StringVar(&secret, "secret", "", "Signing secret (prefer --secret-file)") 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(&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(&filter, "branch-filter", "", "Branch glob for push events")
command.Flags().StringVar(&authorization, "authorization-header", "", "Authorization value (prefer file input)") 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().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().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") command.Flags().StringToStringVar(&config, "config", nil, "Adapter-specific configuration, for example channel=alerts")
if verb == "create" { if !create {
command.Flags().StringVar(&kind, "type", "gitea", "Webhook adapter type") // Gitea cannot change signing secrets; the flags stay to explain that.
} _ = command.Flags().MarkHidden("secret")
} _ = command.Flags().MarkHidden("secret-file")
if verb == "delete" {
command.Annotations = textOutput("webhook deletion does not return a resource")
} }
command.RunE = func(cmd *cobra.Command, args []string) error { command.RunE = func(cmd *cobra.Command, args []string) error {
var id int64 var id int64
var err error var err error
opts := services.WebhookOptions{} opts := services.WebhookOptions{}
if verb != "create" { if !create {
id, err = number(args[0]) if id, err = number(args[0]); err != nil {
if err != nil {
return err return err
} }
} }
if verb == "create" || verb == "edit" {
if cmd.Flags().Changed("secret") && cmd.Flags().Changed("secret-file") { if cmd.Flags().Changed("secret") && cmd.Flags().Changed("secret-file") {
return clierr.Wrap(clierr.Usage, errors.New("--secret and --secret-file are mutually exclusive")) return clierr.Wrap(clierr.Usage, errors.New("--secret and --secret-file are mutually exclusive"))
} }
@@ -111,38 +318,20 @@ func (a *application) webhookCommand() *cobra.Command {
if secretFile == "-" && authorizationFile == "-" { if secretFile == "-" && authorizationFile == "-" {
return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one webhook credential")) 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")) { 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"}) 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 { if len(args) > 0 && create {
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") { if cmd.Flags().Changed("url") {
return clierr.Wrap(clierr.Usage, errors.New("supply the URL as an argument or --url, not both")) return clierr.Wrap(clierr.Usage, errors.New("supply the URL as an argument or --url, not both"))
} }
endpoint = args[0] endpoint = args[0]
} }
if verb == "create" { if create || cmd.Flags().Changed("events") {
if _, err := a.require(cmd, &endpoint, "url"); err != nil { trimNames(events)
return err if len(events) == 0 || slices.Contains(events, "") {
}
}
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")) 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.Events = &events
} }
opts.Config = make(map[string]string, len(config)+4) opts.Config = make(map[string]string, len(config)+4)
@@ -155,13 +344,34 @@ func (a *application) webhookCommand() *cobra.Command {
} }
opts.Config[key] = value opts.Config[key] = value
} }
secret, err = a.readSecret(cmd, secret, secretFile, "secret", "secret-file") 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 { if err != nil {
return err return err
} }
authorization, err = a.readSecret(cmd, authorization, authorizationFile, "authorization-header", "authorization-header-file") if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
if err != nil { 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)"))
return err }
} }
if strings.ContainsAny(authorization, "\r\n") { if strings.ContainsAny(authorization, "\r\n") {
return clierr.Wrap(clierr.Usage, errors.New("authorization header must be a single line")) return clierr.Wrap(clierr.Usage, errors.New("authorization header must be a single line"))
@@ -169,7 +379,7 @@ func (a *application) webhookCommand() *cobra.Command {
if endpoint != "" { if endpoint != "" {
opts.Config["url"] = endpoint opts.Config["url"] = endpoint
} }
if verb == "create" { if create {
opts.Type = kind opts.Type = kind
opts.Active = &active opts.Active = &active
opts.BranchFilter = &filter opts.BranchFilter = &filter
@@ -193,37 +403,73 @@ func (a *application) webhookCommand() *cobra.Command {
if len(opts.Config) == 0 { if len(opts.Config) == 0 {
opts.Config = nil opts.Config = nil
} }
} target, service, err := scope.service(a, cmd)
service, err := scope(cmd)
if err != nil { if err != nil {
return err return err
} }
if verb == "delete" { if askAuthorization {
if err := a.confirm(cmd, "delete webhook "+args[0]); err != nil { hook, err := service.View(cmd.Context(), id)
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 { if err != nil {
return err return err
} }
return writeItem(a, cmd, item, webhookRow) 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
} }
group.AddCommand(command) opts.Authorization = &authorization
} }
return group 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 webhookRow(item services.Webhook) []terminal.Cell { func (a *application) webhookDeleteCommand(scope *webhookScope) *cobra.Command {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Type, strconv.FormatBool(item.Active), strings.Join(item.Events, ",")) return &cobra.Command{
Use: "delete <id>", 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
},
}
} }
+84
View File
@@ -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"])
}
+67 -15
View File
@@ -4,17 +4,25 @@
package services package services
import ( import (
"cmp"
"context" "context"
"maps" "maps"
"net/http"
"net/url"
"slices"
"strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "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 { type Webhook struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type string `json:"type"`
URL string `json:"url"`
ContentType string `json:"content_type"`
Active bool `json:"active"` Active bool `json:"active"`
Events []string `json:"events"` Events []string `json:"events"`
BranchFilter string `json:"branch_filter"` BranchFilter string `json:"branch_filter"`
@@ -22,14 +30,52 @@ type Webhook struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
func webhookFrom(hook *gitea.Hook) Webhook { // WebhookEvents are the events that webhooks accept. issues and pull_request select every event of
events := hook.Events // 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 { if events == nil {
events = []string{} events = []string{}
} }
address := hook.Config["url"]
if !reveal {
address = redactURL(address, hook.Type)
}
return Webhook{ return Webhook{
ID: hook.ID, Type: hook.Type, Active: hook.Active, Events: events, BranchFilter: hook.BranchFilter, ID: hook.ID, Type: hook.Type, URL: address, ContentType: hook.Config["content_type"], Active: hook.Active,
CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated), Events: events, BranchFilter: hook.BranchFilter, CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated),
} }
} }
@@ -53,11 +99,18 @@ type WebhookOptions struct {
type Webhooks struct { type Webhooks struct {
Client *transport.Client Client *transport.Client
Scope WebhookScope 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. // List returns a page of hook metadata.
func (s Webhooks) List(ctx context.Context, limit, page int) ([]Webhook, error) { func (s Webhooks) List(ctx context.Context, limit, page int) (Page[Webhook], error) {
hooks, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, 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)} opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)}
switch { switch {
case s.Scope.Global: case s.Scope.Global:
@@ -73,10 +126,9 @@ func (s Webhooks) List(ctx context.Context, limit, page int) ([]Webhook, error)
} }
return c.Hooks.ListRepoHooks(ctx, owner, name, opts) return c.Hooks.ListRepoHooks(ctx, owner, name, opts)
}) })
if err != nil { return hooks, headerOf(resp), err
return nil, err })
} return convertPage(hooks, s.from), err
return convertAll(hooks, webhookFrom), nil
} }
func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) { 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 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. // 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 { if err != nil {
return Webhook{}, err return Webhook{}, err
} }
return webhookFrom(hook), nil return s.from(hook), nil
} }
// Create registers a new webhook. // Create registers a new webhook.
@@ -136,7 +188,7 @@ func (s Webhooks) Create(ctx context.Context, opts WebhookOptions) (Webhook, err
if err != nil { if err != nil {
return Webhook{}, err return Webhook{}, err
} }
return webhookFrom(hook), nil return s.from(hook), nil
} }
// Edit preserves settings that Gitea replaces rather than patching individually. // 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 { if err != nil {
return Webhook{}, err return Webhook{}, err
} }
return webhookFrom(hook), nil return s.from(hook), nil
} }
// Delete removes a webhook from the selected scope. // Delete removes a webhook from the selected scope.
+27 -4
View File
@@ -133,7 +133,7 @@ func TestWebhookScopesSelectEndpoints(t *testing.T) {
service := Webhooks{Client: client, Scope: tc.scope} service := Webhooks{Client: client, Scope: tc.scope}
items, err := service.List(context.Background(), 10, 2) items, err := service.List(context.Background(), 10, 2)
require.NoError(t, err) 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.NoError(t, service.Delete(context.Background(), 3))
require.Equal(t, []string{"GET " + tc.path + "?limit=10&page=2", "DELETE " + tc.path + "/3"}, requests) 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) { func TestWebhookFromMapsTimestamps(t *testing.T) {
hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","active":true,"events":["push"],"branch_filter":"main", 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{ 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", 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)
})
}
} }
+7
View File
@@ -49,6 +49,8 @@ type View struct {
Meta string Meta string
Fields []Field Fields []Field
Body string 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 names the resource in the footer, such as "issue".
Kind, URL string Kind, URL string
} }
@@ -68,6 +70,9 @@ func (v View) Render(d Display) string {
if v.URL != "" { if v.URL != "" {
b.WriteString("url:\t" + SafeLine(v.URL) + "\n") b.WriteString("url:\t" + SafeLine(v.URL) + "\n")
} }
if v.Bodyless {
return b.String()
}
body := SafeText(v.Body) body := SafeText(v.Body)
b.WriteString("--\n" + body) b.WriteString("--\n" + body)
if !strings.HasSuffix(body, "\n") { if !strings.HasSuffix(body, "\n") {
@@ -106,6 +111,7 @@ 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") b.WriteString(Cell{Bold: true}.style(label, d, false) + strings.Repeat(" ", labelWidth+2-runewidth.StringWidth(label)) + field.text(d) + "\n")
} }
if !v.Bodyless {
body := strings.TrimSpace(v.Body) body := strings.TrimSpace(v.Body)
switch { switch {
case body == "": case body == "":
@@ -116,6 +122,7 @@ func (v View) Render(d Display) string {
body = SafeText(body) body = SafeText(body)
} }
b.WriteString("\n" + body + "\n") b.WriteString("\n" + body + "\n")
}
if v.URL != "" { if v.URL != "" {
kind := "View on Gitea: " kind := "View on Gitea: "
if v.Kind != "" { if v.Kind != "" {
+12
View File
@@ -62,6 +62,18 @@ func TestViewRender(t *testing.T) {
Display{TTY: true}, Display{TTY: true},
"Home\n\nNo description provided\n", "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) { t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, tc.view.Render(tc.d)) require.Equal(t, tc.want, tc.view.Render(tc.d))