mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
8 files changed
+532
-114
No files matched your search
@@ -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
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in new issue
Block a user