Files
gitea-tea/internal/app/action_values.go
T
bircni 29f62da006 refactor: load settings once per run and reuse shared helpers
The application caches the settings snapshot and reloads it only after
an update through the store, so output and prompt helpers no longer
read and parse config.yml and hosts.yml several times per command.
canPrompt and chooser replace the inline prompt-policy checks.

Checks are tallied by one services.Tally, terminal.Plural replaces the
two app copies, refKind.article/noun replace the comment group's own
words, list commands use servicePage, and web URLs are built by
repositoryURL and webURL in browser.go.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:57:39 +02:00

434 lines
16 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
var actionValueName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// checkActionValueName rejects names that Gitea refuses for secrets and variables.
func checkActionValueName(name string) error {
if !actionValueName.MatchString(name) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: use letters, numbers and underscores, not starting with a number", name))
}
if upper := strings.ToUpper(name); upper == "CI" || strings.HasPrefix(upper, "GITEA_") || strings.HasPrefix(upper, "GITHUB_") {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: CI and names starting with GITEA_ or GITHUB_ are reserved", name))
}
return nil
}
// actionScopeFlags select where secrets and variables live: a repository by default, an
// organization with --org, or the user's account with --account.
type actionScopeFlags struct {
org string
account bool
}
// register adds the scope flags to cmd; account adds --account, which secret list lacks.
func (f *actionScopeFlags) register(a *application, cmd *cobra.Command, kind string, account bool) {
cmd.Flags().StringVarP(&f.org, "org", "o", "", "Use the "+kind+"s of an organization instead of a repository")
_ = cmd.RegisterFlagCompletionFunc("org", a.completeOrgs())
if account {
cmd.Flags().BoolVar(&f.account, "account", false, "Use the "+kind+"s of your user account instead of a repository or organization")
}
}
func (f *actionScopeFlags) scope() services.ActionScope {
return services.ActionScope{Organization: f.org, Account: f.account}
}
// resolve selects the scope and the account that manages it.
func (f *actionScopeFlags) resolve(a *application, cmd *cobra.Command) (services.ActionScope, target, error) {
if (f.org != "" && f.account) || ((f.org != "" || f.account) && a.options.repo != "") {
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account and --repo select different scopes; pass one"))
}
t, err := a.resolve(cmd.Context(), f.org == "" && !f.account)
if err != nil {
return services.ActionScope{}, t, err
}
scope := f.scope()
if f.org == "" && !f.account {
scope.Repository = t.Repo
}
return scope, t, nil
}
// actionValueLong explains the scopes of secrets or variables.
func actionValueLong(kind, use string) string {
return fmt.Sprintf(`Manage the Actions %[1]ss that workflows %[2]s.
A %[1]s belongs to a repository, to an organization (--org), whose repositories all see it, or
to your user account (--account), which all your repositories see. Without --org or --account,
tea uses the repository of the working directory or --repo. Names are stored upper-cased.
Note that the global --user flag selects the account tea acts as, not a scope.`, kind, use)
}
func (a *application) actionValueCommand(secret bool) *cobra.Command {
kind, use := "variable", "read with ${{ vars.NAME }}"
if secret {
kind, use = "secret", "read with ${{ secrets.NAME }}"
}
group := &cobra.Command{
Use: kind,
Short: fmt.Sprintf("Manage Actions %ss for a repository, organization, or your account", kind),
Long: actionValueLong(kind, use),
}
group.AddCommand(a.actionValueListCommand(secret, kind), a.actionValueSetCommand(secret, kind), a.actionValueDeleteCommand(secret, kind))
if !secret {
group.AddCommand(a.variableViewCommand(), a.variableGetCommand())
}
return group
}
func (a *application) actionValueListCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var limit, page int
example := `# list the variables of the repository
$ tea variable list
# list the variables of an organization
$ tea variable list --org acme`
if secret {
example = `# list the secrets of the repository
$ tea secret list
# list the secrets of an organization
$ tea secret list --org acme`
}
cmd := &cobra.Command{
Use: "list",
Short: "List " + kind + "s",
Example: example,
Args: argsExact(0),
RunE: func(cmd *cobra.Command, _ []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if secret {
a.accountNote(cmd, t, "Gitea cannot list your account's secrets; set or delete them with --account")
} else {
a.accountNote(cmd, t, "for your account's variables, use --account")
}
where := " of " + selected.String()
if selected.Repository != "" {
where = " in " + selected.Repository
}
pages := listPage{Number: page}
if secret {
items, err := (services.ActionSecrets{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionSecret]{Columns: secretColumns, Row: secretRow, Empty: "no secrets" + where, Context: "secrets" + where}
return writeTable(a, cmd, items.Items, spec, pages)
}
items, err := (services.ActionVariables{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionVariable]{Columns: variableColumns, Row: variableRow, Empty: "no variables" + where, Context: "variables" + where}
return writeTable(a, cmd, items.Items, spec, pages)
},
}
pageFlags(cmd, &limit, &page)
scope.register(a, cmd, kind, !secret)
return cmd
}
var (
secretColumns = []terminal.Column{{Header: "Name"}, {Header: "Created"}, {Header: "Description", Flex: true}}
variableColumns = []terminal.Column{{Header: "Name"}, {Header: "Value", Flex: true}, {Header: "Description", Flex: true}}
)
func secretRow(item services.ActionSecret) []terminal.Cell {
return []terminal.Cell{{Text: item.Name}, {Time: terminal.ParseTime(item.CreatedAt)}, {Text: item.Description}}
}
func variableRow(item services.ActionVariable) []terminal.Cell {
return []terminal.Cell{{Text: item.Name}, {Text: item.Value}, {Text: item.Description}}
}
func (a *application) actionValueSetCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var body, bodyFile, envFile, description string
example := `# set a repository variable
$ tea variable set ENVIRONMENT --body staging
# set an organization variable from a file
$ tea variable set CONFIG --org acme < config.json
# set the variables of a dotenv file
$ tea variable set --env-file .env`
if secret {
example = `# paste the value of a repository secret at a prompt
$ tea secret set DEPLOY_TOKEN
# set an organization secret from standard input
$ echo -n "$TOKEN" | tea secret set NPM_TOKEN --org acme
# set a secret of your account from a file
$ tea secret set SSH_KEY --account --body-file key.pem
# set the secrets of a dotenv file
$ tea secret set --env-file .env`
}
cmd := &cobra.Command{
Use: "set [<name>]",
Short: "Create or update a " + kind,
Long: fmt.Sprintf(`Create or update a %[1]s.
The value comes from --body, --body-file or standard input when it is not a terminal (one
trailing line break is removed), or else from a prompt. --env-file sets every NAME=value of a
dotenv file instead.`, kind),
Example: example,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if (len(args) == 1) == (envFile != "") {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "pass a " + kind + " name or --env-file", cmd: cmd})
}
if envFile != "" && (cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file")) {
return clierr.Wrap(clierr.Usage, errors.New("--env-file cannot be combined with --body or --body-file"))
}
var entries []envEntry
if envFile != "" {
data, err := readFileOrStdin(a.deps.Streams.In, envFile)
if err != nil {
return fmt.Errorf("read --env-file: %w", err)
}
if entries, err = parseEnvFile(data); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s: %w", envFile, err))
}
} else {
value, err := a.actionValue(cmd, secret, args[0], body, bodyFile)
if err != nil {
return err
}
entries = []envEntry{{args[0], value}}
}
for _, entry := range entries {
if err := checkActionValueName(entry.name); err != nil {
return err
}
if entry.value == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s is empty", entry.name))
}
if !secret && len(entry.value) > 65536 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s exceeds 64 KiB", entry.name))
}
}
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
a.accountNote(cmd, t, "for a "+kind+" of your account, use --account")
given := map[string]string{}
for _, entry := range entries {
given[services.ActionValueName(entry.name)] = entry.name
}
label := func(entry envEntry) string { return entry.name }
report := func(name string) string {
text := fmt.Sprintf("Set Actions %s %s for %s", kind, name, selected)
if given[name] != name {
text += " (names are upper-cased)"
}
return text
}
if secret {
service := services.ActionSecrets{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionSecret, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionSecret) string { return report(item.Name) })
}
service := services.ActionVariables{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionVariable, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionVariable) string { return report(item.Name) })
},
}
cmd.Flags().StringVarP(&body, "body", "b", "", "The value")
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read the value from a file, or - for standard input")
cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "Set every NAME=value of a dotenv file, or - for standard input")
cmd.Flags().StringVarP(&description, "description", "d", "", "Description")
flagAliases(cmd, map[string]string{"value": "body", "value-file": "body-file"})
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
// actionValue returns the value to set: --body, --body-file, standard input when it is not a
// terminal, or else what the user types at a prompt.
func (a *application) actionValue(cmd *cobra.Command, secret bool, name, body, bodyFile string) (string, error) {
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
return a.readSecret(cmd, body, bodyFile, "body", "body-file")
}
missing := clierr.Wrap(clierr.Usage, errors.New("missing value: pass --body, --body-file or standard input"))
if !a.deps.Streams.InTTY {
data, err := io.ReadAll(a.deps.Streams.In)
if err != nil {
return "", fmt.Errorf("read standard input: %w", err)
}
if len(data) == 0 {
return "", missing
}
return strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r"), nil
}
if !a.canPrompt() {
return "", missing
}
if secret {
return a.deps.Prompter.SecretInput(cmd.Context(), "Paste the value of "+strings.ToUpper(name)+":")
}
return a.deps.Prompter.Ask(cmd.Context(), "Value of "+strings.ToUpper(name)+":", nil)
}
// envEntry is one NAME=value line of a dotenv file.
type envEntry struct{ name, value string }
// parseEnvFile reads NAME=value lines, skipping blank lines and # comments. An optional export
// prefix is ignored; values may be quoted, and double-quoted ones understand \n.
func parseEnvFile(data []byte) ([]envEntry, error) {
var entries []envEntry
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(nil, 1<<20)
for number := 1; scanner.Scan(); number++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if !ok || name == "" {
return nil, fmt.Errorf("line %d: use NAME=value", number)
}
switch {
case len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"':
value = strings.ReplaceAll(value[1:len(value)-1], `\n`, "\n")
case len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'':
value = value[1 : len(value)-1]
default:
value, _, _ = strings.Cut(value, " #")
}
entries = append(entries, envEntry{name, value})
}
if len(entries) == 0 {
return nil, errors.New("no NAME=value lines")
}
return entries, scanner.Err()
}
func (a *application) actionValueDeleteCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "delete <name>",
Short: "Delete a " + kind,
Example: fmt.Sprintf(`# delete a repository %[1]s
$ tea %[1]s delete DEPLOY_TOKEN
# delete an organization %[1]s without confirmation
$ tea %[1]s delete DEPLOY_TOKEN --org acme --yes`, kind),
Args: argsExact(1),
Annotations: map[string]string{"tea.confirm": "true", "tea.text": kind + " deletion does not return a resource"},
RunE: func(cmd *cobra.Command, args []string) error {
name := services.ActionValueName(args[0])
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Delete %s %s from %s?", kind, name, selected)}); err != nil {
return err
}
if secret {
err = services.ActionSecrets{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
} else {
err = services.ActionVariables{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
}
if err != nil {
return err
}
a.done(cmd, "Deleted Actions %s %s from %s", kind, name, selected)
return nil
},
}
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
func (a *application) variableViewCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "view <name>",
Short: "View a variable",
Example: `# view a variable with its description
$ tea variable view ENVIRONMENT`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
d := a.display()
d.Markdown = false
view := terminal.View{Title: item.Name, Fields: []terminal.Field{{Label: "Scope", Value: selected.String()}, {Label: "Description", Value: item.Description}}, Body: item.Value}
return a.writeHuman(cmd, view.Render(d))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
func (a *application) variableGetCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "get <name>",
Short: "Print the value of a variable",
Example: `# use the value of a variable in a script
$ region=$(tea variable get REGION --org acme)`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return writeTerminalSafe(cmd.OutOrStdout(), a.deps.Streams.OutTTY, strings.NewReader(item.Value+"\n"))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
// completeActionValues completes the secret or variable names of the scope the flags select.
func (a *application) completeActionValues(secret bool, scope *actionScopeFlags) cobra.CompletionFunc {
names := a.completeVariables(scope.scope)
if secret {
names = a.completeSecrets(scope.scope)
}
return firstArg(names)
}