mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
451 lines
19 KiB
Go
451 lines
19 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func validateWebhookURL(raw string) error {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("webhook URL must be an absolute HTTP(S) URL without user information or fragment"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// webhookEventGroups are the events that issues and pull_request select; tables show a complete
|
|
// group as one entry.
|
|
var webhookEventGroups = map[string][]string{
|
|
"issues": {"issue_assign", "issue_comment", "issue_label", "issue_milestone", "issues"},
|
|
"pull_request": {"pull_request", "pull_request_assign", "pull_request_comment", "pull_request_label", "pull_request_milestone", "pull_request_review", "pull_request_review_request", "pull_request_sync"},
|
|
}
|
|
|
|
// collapsedEvents lists events with each complete group as one entry, such as "pull_request (8)".
|
|
func collapsedEvents(events []string) string {
|
|
grouped := map[string]bool{}
|
|
for _, group := range webhookEventGroups {
|
|
if !slices.ContainsFunc(group, func(event string) bool { return !slices.Contains(events, event) }) {
|
|
for _, event := range group {
|
|
grouped[event] = true
|
|
}
|
|
}
|
|
}
|
|
var shown []string
|
|
for _, event := range events {
|
|
switch group, head := webhookEventGroups[event]; {
|
|
case head && grouped[event]:
|
|
shown = append(shown, fmt.Sprintf("%s (%d)", event, len(group)))
|
|
case !grouped[event]:
|
|
shown = append(shown, event)
|
|
}
|
|
}
|
|
return strings.Join(shown, ", ")
|
|
}
|
|
|
|
type webhookScope struct {
|
|
org string
|
|
account, global bool
|
|
}
|
|
|
|
func (s *webhookScope) service(a *application, cmd *cobra.Command) (target, services.Webhooks, error) {
|
|
scopes := 0
|
|
for _, selected := range []bool{s.org != "", s.account, s.global, a.options.repo != ""} {
|
|
if selected {
|
|
scopes++
|
|
}
|
|
}
|
|
if scopes > 1 {
|
|
return target{}, services.Webhooks{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, --global, and --repo select mutually exclusive scopes"))
|
|
}
|
|
t, err := a.resolve(cmd.Context(), s.org == "" && !s.account && !s.global)
|
|
return t, services.Webhooks{Client: t.client, Scope: services.WebhookScope{Repository: t.Repo, Organization: s.org, Account: s.account, Global: s.global}}, err
|
|
}
|
|
|
|
// webhookScopeName names a scope in messages: alice/x, organization acme, your account, the instance.
|
|
func webhookScopeName(scope services.WebhookScope) string {
|
|
switch {
|
|
case scope.Global:
|
|
return "the instance"
|
|
case scope.Account:
|
|
return "your account"
|
|
case scope.Organization != "":
|
|
return "organization " + scope.Organization
|
|
}
|
|
return scope.Repository
|
|
}
|
|
|
|
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
|
|
cmd := &cobra.Command{
|
|
Use: "list", Short: "List webhooks", Args: argsExact(0),
|
|
Example: `$ tea webhook list
|
|
$ tea webhook list --org acme
|
|
# print the delivery URLs
|
|
$ tea webhook list --jq '.[].url'`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
target, service, err := scope.service(a, cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hooks, err := service.List(cmd.Context(), limit, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
where := webhookScopeName(service.Scope)
|
|
return writeTable(a, cmd, hooks.Items, listSpec[services.Webhook]{
|
|
Columns: []terminal.Column{{Header: "ID"}, {Header: "Type"}, {Header: "Active"}, {Header: "URL", Flex: true}, {Header: "Events", Flex: true, TTYOnly: true}, {Header: "Events", PipedOnly: true}},
|
|
Row: func(hook services.Webhook) []terminal.Cell {
|
|
active := terminal.Cell{Text: yesNo(hook.Active), Color: "green"}
|
|
if !hook.Active {
|
|
active = terminal.Cell{Text: "no", Muted: true}
|
|
}
|
|
return []terminal.Cell{
|
|
{Text: strconv.FormatInt(hook.ID, 10), Link: webhookSettingsURL(target, service.Scope, hook.ID)},
|
|
{Text: hook.Type},
|
|
active,
|
|
{Text: hook.URL},
|
|
{Text: collapsedEvents(hook.Events)},
|
|
{Text: strings.Join(hook.Events, ",")},
|
|
}
|
|
},
|
|
Context: "webhooks of " + where, Empty: "no webhooks for " + where,
|
|
}, servicePage(hooks, page))
|
|
},
|
|
}
|
|
pageFlags(cmd, &limit, &page)
|
|
return cmd
|
|
}
|
|
|
|
func (a *application) webhookViewCommand(scope *webhookScope) *cobra.Command {
|
|
var reveal bool
|
|
cmd := &cobra.Command{
|
|
Use: "view <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 events []string
|
|
var config map[string]string
|
|
var active, clearAuthorization bool
|
|
command := &cobra.Command{
|
|
Use: "edit <id>", Short: "Edit a webhook", Args: argsExact(1), ValidArgsFunction: a.completeWebhooks(scope),
|
|
Long: `Edit a webhook. Only the settings you pass change.
|
|
|
|
Gitea drops the stored authorization header of a webhook on every edit and never shows it, so
|
|
pass it again with --authorization-header-file. On a terminal, tea asks for it instead; leave
|
|
the answer empty for a webhook without one. Signing secrets cannot be changed: recreate the
|
|
webhook to rotate one.`,
|
|
Example: `# pause deliveries of a webhook without an authorization header
|
|
$ tea webhook edit 3 --active=false --clear-authorization
|
|
# keep the authorization header while changing the events
|
|
$ tea webhook edit 3 --events push,release --authorization-header-file header.txt`,
|
|
}
|
|
eventsUsage := "Events that trigger the webhook, comma-separated"
|
|
if create {
|
|
command.Use, command.Short, command.ValidArgsFunction = "create [<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: ` + strings.Join(services.WebhookEvents, ", ") + `; issues and pull_request
|
|
include every issue or pull request event.
|
|
|
|
Types: ` + strings.Join(services.WebhookTypes, ", ") + `. Slack webhooks need
|
|
--config channel=#name (username, icon_url and color are optional). Gitea reads no other
|
|
settings from --config, so set up discord, matrix and packagist webhooks in the web UI.`
|
|
command.Example = `$ tea webhook create https://ci.example.com/hooks/gitea --events push,pull_request
|
|
# sign deliveries with a secret read from a file
|
|
$ tea webhook create https://ci.example.com/hooks/gitea --secret-file secret.txt
|
|
# notify a Slack channel about releases of an organization's repositories
|
|
$ tea webhook create https://hooks.slack.com/services/T0/B0/X --org acme --type slack --config channel=#releases --events release`
|
|
enumFlag(command, &kind, "type", "", "gitea", "Webhook adapter type", services.WebhookTypes...)
|
|
command.Flags().StringSliceVar(&events, "events", []string{"push"}, eventsUsage)
|
|
command.Flags().BoolVar(&active, "active", true, "Deliver events (use --active=false to create the webhook paused)")
|
|
} else {
|
|
command.Flags().StringSliceVar(&events, "events", nil, eventsUsage+", replacing the current ones (see tea webhook create --help)")
|
|
command.Flags().BoolVar(&active, "active", false, "Deliver events, or pause the webhook with --active=false")
|
|
}
|
|
enumValues(command, "events", services.WebhookEvents...)
|
|
command.Flags().StringVar(&endpoint, "url", "", "Delivery URL")
|
|
command.Flags().StringVar(&secret, "secret", "", "Signing secret (prefer --secret-file)")
|
|
command.Flags().StringVar(&secretFile, "secret-file", "", "Read signing secret from file or -")
|
|
command.Flags().StringVar(&filter, "branch-filter", "", "Branch glob for push events")
|
|
command.Flags().StringVar(&authorization, "authorization-header", "", "Authorization value (prefer file input)")
|
|
command.Flags().StringVar(&authorizationFile, "authorization-header-file", "", "Read authorization value from file or -")
|
|
command.Flags().BoolVar(&clearAuthorization, "clear-authorization", false, "Send no authorization header; on edit, this removes the stored one")
|
|
command.Flags().StringToStringVar(&config, "config", nil, "Adapter-specific configuration, for example channel=alerts")
|
|
if !create {
|
|
// Gitea cannot change signing secrets; the flags stay to explain that.
|
|
_ = command.Flags().MarkHidden("secret")
|
|
_ = command.Flags().MarkHidden("secret-file")
|
|
}
|
|
command.RunE = func(cmd *cobra.Command, args []string) error {
|
|
var id int64
|
|
var err error
|
|
opts := services.WebhookOptions{}
|
|
if !create {
|
|
if id, err = number(args[0]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("secret") && cmd.Flags().Changed("secret-file") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--secret and --secret-file are mutually exclusive"))
|
|
}
|
|
authSources := 0
|
|
for _, set := range []bool{cmd.Flags().Changed("authorization-header"), cmd.Flags().Changed("authorization-header-file"), clearAuthorization} {
|
|
if set {
|
|
authSources++
|
|
}
|
|
}
|
|
if authSources > 1 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("choose one authorization header source or --clear-authorization"))
|
|
}
|
|
if secretFile == "-" && authorizationFile == "-" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one webhook credential"))
|
|
}
|
|
if !create && (cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file")) {
|
|
return clierr.Wrap(clierr.Usage, &services.CapabilityError{Feature: "webhook secret editing", Reason: "Gitea does not update signing secrets through its edit API; recreate the hook to rotate it"})
|
|
}
|
|
if len(args) > 0 && create {
|
|
if cmd.Flags().Changed("url") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("supply the URL as an argument or --url, not both"))
|
|
}
|
|
endpoint = args[0]
|
|
}
|
|
if create || cmd.Flags().Changed("events") {
|
|
trimNames(events)
|
|
if len(events) == 0 || slices.Contains(events, "") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--events needs at least one trigger"))
|
|
}
|
|
opts.Events = &events
|
|
}
|
|
opts.Config = make(map[string]string, len(config)+4)
|
|
for key, value := range config {
|
|
if key == "url" || key == "secret" || key == "is_system_webhook" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--config cannot override the URL, secret, or webhook scope"))
|
|
}
|
|
if key == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--config keys cannot be empty"))
|
|
}
|
|
opts.Config[key] = value
|
|
}
|
|
if create && kind == "slack" && config["channel"] == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--type slack needs a channel: --config channel=#name"))
|
|
}
|
|
if endpoint != "" || cmd.Flags().Changed("url") {
|
|
if err := validateWebhookURL(endpoint); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if create {
|
|
if _, err := a.requireInput(cmd, &endpoint, "url", "Payload URL", validateWebhookURL); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if secret, err = a.readSecret(cmd, secret, secretFile, "secret", "secret-file"); err != nil {
|
|
return err
|
|
}
|
|
if authorization, err = a.readSecret(cmd, authorization, authorizationFile, "authorization-header", "authorization-header-file"); err != nil {
|
|
return err
|
|
}
|
|
askAuthorization := !create && authSources == 0
|
|
if askAuthorization && !a.canPrompt() {
|
|
return clierr.Wrap(clierr.Usage, errors.New("editing a webhook drops its stored authorization header unless it is sent again, and Gitea never returns it: pass --authorization-header-file or --authorization-header (use --clear-authorization only for a hook without one)"))
|
|
}
|
|
if strings.ContainsAny(authorization, "\r\n") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("authorization header must be a single line"))
|
|
}
|
|
if endpoint != "" {
|
|
opts.Config["url"] = endpoint
|
|
}
|
|
if create {
|
|
opts.Type = kind
|
|
opts.Active = &active
|
|
opts.BranchFilter = &filter
|
|
opts.Config["http_method"] = "post"
|
|
if _, ok := opts.Config["content_type"]; !ok {
|
|
opts.Config["content_type"] = "json"
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("secret") || cmd.Flags().Changed("secret-file") {
|
|
opts.Config["secret"] = secret
|
|
}
|
|
if cmd.Flags().Changed("active") {
|
|
opts.Active = &active
|
|
}
|
|
if cmd.Flags().Changed("branch-filter") {
|
|
opts.BranchFilter = &filter
|
|
}
|
|
if authSources > 0 {
|
|
opts.Authorization = &authorization
|
|
}
|
|
if len(opts.Config) == 0 {
|
|
opts.Config = nil
|
|
}
|
|
target, service, err := scope.service(a, cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if askAuthorization {
|
|
hook, err := service.View(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
title := fmt.Sprintf("Re-enter the authorization header of webhook %d (empty if it has none)", hook.ID)
|
|
if authorization, err = a.deps.Prompter.SecretInput(cmd.Context(), title); err != nil {
|
|
return err
|
|
}
|
|
opts.Authorization = &authorization
|
|
}
|
|
if !create {
|
|
hook, err := service.Edit(cmd.Context(), id, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Edited %s in %s", webhookLabel(hook), webhookScopeName(service.Scope))
|
|
return a.jsonResult(cmd, hook)
|
|
}
|
|
hook, err := service.Create(cmd.Context(), opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Created %s in %s for %s", webhookLabel(hook), webhookScopeName(service.Scope), collapsedEvents(hook.Events))
|
|
return a.created(cmd, hook, webhookSettingsURL(target, service.Scope, hook.ID))
|
|
}
|
|
return command
|
|
}
|
|
|
|
func (a *application) webhookDeleteCommand(scope *webhookScope) *cobra.Command {
|
|
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
|
|
}
|
|
return a.deleteConfirmed(cmd, webhookLabel(hook)+" from "+webhookScopeName(service.Scope), func() error { return service.Delete(cmd.Context(), id) })
|
|
},
|
|
}
|
|
}
|