mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(secret,variable): read values like gh and report stored names
secret set and variable set read the value from -b/--body, -F/--body-file or standard input when it is not a terminal (one trailing line break removed), and otherwise prompt for it; --env-file sets every NAME=value of a dotenv file. --value and --value-file keep working. The scope flags are -o/--org and --account on each command, and secret list no longer offers --account, which Gitea cannot list. Commands report what they did, naming the scope and the name Gitea stores: "Set Actions secret LOWER_NAME for alice/x (names are upper-cased)"; --json returns the stored name and omits an unknown creation time. Deleting asks "Delete secret X from alice/x?". Lists show NAME, CREATED and DESCRIPTION for secrets and NAME, VALUE and DESCRIPTION for variables; variable view shows a record and the new variable get prints the raw value. A missing secret or variable is reported by name and scope. The global --user selects the account tea acts as, unlike gh's --user, so secret and variable add a note on a terminal pointing to --account when it is given, as run list does for --actor. Help explains the scopes. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
5 files changed
+641
-185
No files matched your search
+414
-123
@@ -4,8 +4,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,138 +20,426 @@ import (
|
|||||||
|
|
||||||
var actionValueName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
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 {
|
func (a *application) actionValueCommand(secret bool) *cobra.Command {
|
||||||
kind := "variable"
|
kind, use := "variable", "read with ${{ vars.NAME }}"
|
||||||
if secret {
|
if secret {
|
||||||
kind = "secret"
|
kind, use = "secret", "read with ${{ secrets.NAME }}"
|
||||||
}
|
}
|
||||||
group := &cobra.Command{Use: kind, Short: "Manage scoped Actions " + kind + "s"}
|
group := &cobra.Command{
|
||||||
var org string
|
Use: kind,
|
||||||
var account bool
|
Short: fmt.Sprintf("Manage Actions %ss for a repository, organization, or your account", kind),
|
||||||
group.PersistentFlags().StringVar(&org, "org", "", "Organization scope")
|
Long: actionValueLong(kind, use),
|
||||||
group.PersistentFlags().BoolVar(&account, "account", false, "Current account scope across repositories")
|
|
||||||
scope := func(cmd *cobra.Command) (services.ActionScope, target, error) {
|
|
||||||
if (org != "" && account) || ((org != "" || account) && cmd.Flags().Changed("repo")) {
|
|
||||||
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, and --repo select mutually exclusive scopes"))
|
|
||||||
}
|
|
||||||
target, err := a.resolve(cmd.Context(), org == "" && !account)
|
|
||||||
return services.ActionScope{Repository: target.Repo, Organization: org, Account: account}, target, err
|
|
||||||
}
|
}
|
||||||
var limit, page int
|
group.AddCommand(a.actionValueListCommand(secret, kind), a.actionValueSetCommand(secret, kind), a.actionValueDeleteCommand(secret, kind))
|
||||||
list := &cobra.Command{Use: "list", Short: "List " + kind + "s in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
|
|
||||||
selected, target, err := scope(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if secret {
|
|
||||||
items, err := (services.ActionSecrets{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeList(a, cmd, items, secretRow)
|
|
||||||
}
|
|
||||||
items, err := (services.ActionVariables{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeList(a, cmd, items, variableRow)
|
|
||||||
}}
|
|
||||||
pageFlags(list, &limit, &page)
|
|
||||||
group.AddCommand(list)
|
|
||||||
verbs := []string{"set", "delete"}
|
|
||||||
if !secret {
|
if !secret {
|
||||||
verbs = append(verbs, "view")
|
group.AddCommand(a.variableViewCommand(), a.variableGetCommand())
|
||||||
}
|
|
||||||
for _, verb := range verbs {
|
|
||||||
var value, file, description string
|
|
||||||
command := &cobra.Command{Use: verb + " <name>", Short: titleCase(verb) + " an Actions " + kind, Args: argsExact(1)}
|
|
||||||
if verb == "set" {
|
|
||||||
command.Flags().StringVar(&value, "value", "", "Value to store (use a file for secrets)")
|
|
||||||
command.Flags().StringVar(&file, "value-file", "", "Read value from file, or - for stdin")
|
|
||||||
command.Flags().StringVar(&description, "description", "", "Description")
|
|
||||||
}
|
|
||||||
if verb == "delete" {
|
|
||||||
command.Annotations = textOutput(kind + " deletion does not return a resource")
|
|
||||||
}
|
|
||||||
command.RunE = func(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
if !actionValueName.MatchString(name) {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("name must contain letters, numbers, and underscores, and cannot start with a number"))
|
|
||||||
}
|
|
||||||
if strings.EqualFold(name, "CI") || strings.HasPrefix(strings.ToUpper(name), "GITEA_") || strings.HasPrefix(strings.ToUpper(name), "GITHUB_") {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("CI and names beginning GITEA_ or GITHUB_ are reserved"))
|
|
||||||
}
|
|
||||||
if verb == "set" {
|
|
||||||
var err error
|
|
||||||
if value, err = a.readSecret(cmd, value, file, "value", "value-file"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !cmd.Flags().Changed("value") && !cmd.Flags().Changed("value-file") {
|
|
||||||
snapshot, err := a.deps.Settings.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("missing value: supply --value or --value-file"))
|
|
||||||
}
|
|
||||||
if secret {
|
|
||||||
value, err = a.deps.Prompter.SecretInput(cmd.Context(), "Secret value")
|
|
||||||
} else {
|
|
||||||
value, err = a.deps.Prompter.Input(cmd.Context(), "Variable value")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if value == "" {
|
|
||||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s value cannot be empty", kind))
|
|
||||||
}
|
|
||||||
if !secret && len(value) > 65536 {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("value cannot exceed 64 KiB"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selected, target, err := scope(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if verb == "delete" {
|
|
||||||
if err := a.confirm(cmd, verb+" Actions "+kind+" "+name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if secret {
|
|
||||||
service := services.ActionSecrets{Client: target.client, Scope: selected}
|
|
||||||
if verb == "delete" {
|
|
||||||
return service.Delete(cmd.Context(), name)
|
|
||||||
}
|
|
||||||
item, err := service.Set(cmd.Context(), name, value, description)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeItem(a, cmd, item, secretRow)
|
|
||||||
}
|
|
||||||
service := services.ActionVariables{Client: target.client, Scope: selected}
|
|
||||||
if verb == "delete" {
|
|
||||||
return service.Delete(cmd.Context(), name)
|
|
||||||
}
|
|
||||||
var item services.ActionVariable
|
|
||||||
if verb == "view" {
|
|
||||||
item, err = service.View(cmd.Context(), name)
|
|
||||||
} else {
|
|
||||||
item, err = service.Set(cmd.Context(), name, value, description)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeItem(a, cmd, item, variableRow)
|
|
||||||
}
|
|
||||||
group.AddCommand(command)
|
|
||||||
}
|
}
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
func secretRow(item services.ActionSecret) []terminal.Cell { return terminal.Row(item.Name) }
|
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 {
|
func variableRow(item services.ActionVariable) []terminal.Cell {
|
||||||
return terminal.Row(item.Name, item.Value)
|
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
|
||||||
|
}
|
||||||
|
s, err := a.deps.Settings.Load()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !a.policy(s).CanPrompt(a.deps.Streams) {
|
||||||
|
return "", missing
|
||||||
|
}
|
||||||
|
if secret {
|
||||||
|
return a.deps.Prompter.SecretInput(cmd.Context(), "Paste the value of "+strings.ToUpper(name)+":")
|
||||||
|
}
|
||||||
|
return a.deps.Prompter.Input(cmd.Context(), "Value of "+strings.ToUpper(name)+":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
NonTTY: 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 func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return names(cmd, args, toComplete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/clierr"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,3 +35,144 @@ func TestSecretValueFileDropsTrailingLineEnding(t *testing.T) {
|
|||||||
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "set", "BUILD_TOKEN", "-R", "alice/project", "--value-file", "-", "--yes"}))
|
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "set", "BUILD_TOKEN", "-R", "alice/project", "--value-file", "-", "--yes"}))
|
||||||
require.Equal(t, "sensitive", stored)
|
require.Equal(t, "sensitive", stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionValueSetReadsValuesAndReportsStoredNames(t *testing.T) {
|
||||||
|
envFile := filepath.Join(t.TempDir(), ".env")
|
||||||
|
require.NoError(t, os.WriteFile(envFile, []byte("# deploy\nAPI_KEY=abc\nexport region=\"eu\\nwest\"\nMODE=fast # inline\n"), 0o600))
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
stdin string
|
||||||
|
requests []string
|
||||||
|
out, err string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"lower-case name from --body",
|
||||||
|
[]string{"secret", "set", "lower_name", "-b", "x"},
|
||||||
|
"",
|
||||||
|
[]string{`PUT actions/secrets/lower_name {"data":"x","description":""}`},
|
||||||
|
"",
|
||||||
|
"✓ Set Actions secret LOWER_NAME for alice/project (names are upper-cased)\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"standard input",
|
||||||
|
[]string{"secret", "set", "TOKEN"},
|
||||||
|
"piped\n",
|
||||||
|
[]string{`PUT actions/secrets/TOKEN {"data":"piped","description":""}`},
|
||||||
|
"", "✓ Set Actions secret TOKEN for alice/project\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"JSON names the stored secret",
|
||||||
|
[]string{"secret", "set", "token", "--body", "x", "--json"},
|
||||||
|
"",
|
||||||
|
[]string{`PUT actions/secrets/token {"data":"x","description":""}`},
|
||||||
|
`{"name":"TOKEN","description":""}`, "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"organization",
|
||||||
|
[]string{"secret", "set", "TOKEN", "-o", "acme", "-b", "x"},
|
||||||
|
"",
|
||||||
|
[]string{`PUT api/v1/orgs/acme/actions/secrets/TOKEN {"data":"x","description":""}`},
|
||||||
|
"", "✓ Set Actions secret TOKEN for organization acme\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account",
|
||||||
|
[]string{"secret", "set", "TOKEN", "--account", "-b", "x"},
|
||||||
|
"",
|
||||||
|
[]string{`PUT api/v1/user/actions/secrets/TOKEN {"data":"x","description":""}`},
|
||||||
|
"", "✓ Set Actions secret TOKEN for your account\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dotenv file",
|
||||||
|
[]string{"variable", "set", "--env-file", envFile, "-o", "acme"},
|
||||||
|
"",
|
||||||
|
[]string{
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/API_KEY", `POST api/v1/orgs/acme/actions/variables/API_KEY {"value":"abc","description":""}`,
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/region", `POST api/v1/orgs/acme/actions/variables/region {"value":"eu\nwest","description":""}`,
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/MODE", `POST api/v1/orgs/acme/actions/variables/MODE {"value":"fast","description":""}`,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
"✓ Set Actions variable API_KEY for organization acme\n✓ Set Actions variable REGION for organization acme (names are upper-cased)\n✓ Set Actions variable MODE for organization acme\n",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server, requests := actionsAPI(t, map[string]string{
|
||||||
|
"PUT actions/secrets/lower_name": "204 ", "PUT actions/secrets/TOKEN": "204 ", "PUT actions/secrets/token": "204 ",
|
||||||
|
"PUT api/v1/orgs/acme/actions/secrets/TOKEN": "204 ", "PUT api/v1/user/actions/secrets/TOKEN": "204 ",
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/API_KEY": `404 {}`, "POST api/v1/orgs/acme/actions/variables/API_KEY": "201 ",
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/region": `404 {}`, "POST api/v1/orgs/acme/actions/variables/region": "201 ",
|
||||||
|
"GET api/v1/orgs/acme/actions/variables/MODE": `404 {}`, "POST api/v1/orgs/acme/actions/variables/MODE": "201 ",
|
||||||
|
})
|
||||||
|
deps, out, diagnostics := actionsDeps(t, server.URL, true)
|
||||||
|
deps.Streams.In = strings.NewReader(tc.stdin)
|
||||||
|
args := tc.args
|
||||||
|
if !strings.Contains(strings.Join(args, " "), "-o acme") && !strings.Contains(strings.Join(args, " "), "--account") {
|
||||||
|
args = append(args, "-R", "alice/project")
|
||||||
|
}
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, args))
|
||||||
|
require.Equal(t, tc.requests, *requests)
|
||||||
|
if tc.out != "" {
|
||||||
|
require.JSONEq(t, tc.out, out.String())
|
||||||
|
} else {
|
||||||
|
require.Empty(t, out.String())
|
||||||
|
}
|
||||||
|
require.Equal(t, tc.err, diagnostics.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionValueListsShowScopeAndExplainTheAccountFlags(t *testing.T) {
|
||||||
|
server, _ := actionsAPI(t, map[string]string{
|
||||||
|
"GET actions/secrets": `[{"name":"DEPLOY_TOKEN","description":"Deploy token","created_at":"2028-01-01T10:00:00Z"}]`,
|
||||||
|
"GET actions/variables": `[{"name":"ENVIRONMENT","data":"staging","description":"Target environment"}]`,
|
||||||
|
})
|
||||||
|
deps, out, diagnostics := actionsDeps(t, server.URL, true)
|
||||||
|
deps.Streams.ErrTTY = true
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "list", "-R", "alice/project", "--user", "alice"}))
|
||||||
|
require.Equal(t, "Showing 1 of 1 secrets in alice/project\n\nNAME CREATED DESCRIPTION\nDEPLOY_TOKEN about 2 hours ago Deploy token\n", out.String())
|
||||||
|
require.Equal(t, "note: --user selects the account tea acts as (alice); Gitea cannot list your account's secrets; set or delete them with --account\n", diagnostics.String())
|
||||||
|
out.Reset()
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"variable", "list", "-R", "alice/project"}))
|
||||||
|
require.Equal(t, "Showing 1 of 1 variables in alice/project\n\nNAME VALUE DESCRIPTION\nENVIRONMENT staging Target environment\n", out.String())
|
||||||
|
err := Execute(context.Background(), deps, []string{"secret", "list", "--account"})
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||||
|
require.ErrorContains(t, err, "unknown flag: --account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariableGetAndView(t *testing.T) {
|
||||||
|
server, _ := actionsAPI(t, map[string]string{"GET actions/variables/region": `{"name":"REGION","data":"eu\nwest","description":"Where to deploy"}`})
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
tty bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"variable", "get", "region"}, false, "eu\nwest\n"},
|
||||||
|
{[]string{"variable", "view", "region"}, true, "REGION\nScope: alice/project\nDescription: Where to deploy\n\neu\nwest\n"},
|
||||||
|
{[]string{"variable", "view", "region"}, false, "title:\tREGION\nscope:\talice/project\ndescription:\tWhere to deploy\n--\neu\nwest\n"},
|
||||||
|
} {
|
||||||
|
deps, out, _ := actionsDeps(t, server.URL, tc.tty)
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project")))
|
||||||
|
require.Equal(t, tc.want, out.String(), "%v tty=%t", tc.args, tc.tty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionValueDeleteConfirmsWithTheScope(t *testing.T) {
|
||||||
|
server, requests := actionsAPI(t, map[string]string{"DELETE actions/secrets/deploy_token": "204 "})
|
||||||
|
for _, accept := range []bool{false, true} {
|
||||||
|
*requests = nil
|
||||||
|
deps, _, diagnostics := actionsDeps(t, server.URL, true)
|
||||||
|
prompter := &scriptedPrompter{accept: accept}
|
||||||
|
deps.Prompter = prompter
|
||||||
|
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||||
|
err := Execute(context.Background(), deps, []string{"secret", "delete", "deploy_token", "-R", "alice/project"})
|
||||||
|
require.Equal(t, []string{"Delete secret DEPLOY_TOKEN from alice/project?"}, prompter.confirmations)
|
||||||
|
if !accept {
|
||||||
|
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
|
||||||
|
require.Empty(t, *requests)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"DELETE actions/secrets/deploy_token"}, *requests)
|
||||||
|
require.Equal(t, "✓ Deleted Actions secret DEPLOY_TOKEN from alice/project\n", diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -544,18 +544,11 @@ const (
|
|||||||
search issue
|
search issue
|
||||||
search pr
|
search pr
|
||||||
search repo
|
search repo
|
||||||
secret delete
|
|
||||||
secret list
|
|
||||||
secret set
|
|
||||||
status
|
status
|
||||||
time create
|
time create
|
||||||
time delete
|
time delete
|
||||||
time list
|
time list
|
||||||
time reset
|
time reset
|
||||||
variable delete
|
|
||||||
variable list
|
|
||||||
variable set
|
|
||||||
variable view
|
|
||||||
wiki create
|
wiki create
|
||||||
wiki delete
|
wiki delete
|
||||||
wiki edit
|
wiki edit
|
||||||
@@ -581,9 +574,7 @@ const (
|
|||||||
release asset
|
release asset
|
||||||
repo
|
repo
|
||||||
search
|
search
|
||||||
secret
|
|
||||||
time
|
time
|
||||||
variable
|
|
||||||
wiki
|
wiki
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
@@ -198,10 +198,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"search", "issue", "--since="}, "invalid --since: use a date"},
|
{[]string{"search", "issue", "--since="}, "invalid --since: use a date"},
|
||||||
{[]string{"search", "repo", "query", "--sort", "invalid"}, "--sort must be alpha, created, updated, size, or id"},
|
{[]string{"search", "repo", "query", "--sort", "invalid"}, "--sort must be alpha, created, updated, size, or id"},
|
||||||
{[]string{"search", "repo", "query", "--order", "invalid"}, "--order must be asc or desc"},
|
{[]string{"search", "repo", "query", "--order", "invalid"}, "--order must be asc or desc"},
|
||||||
{[]string{"secret", "set", "BUILD_TOKEN", "--value", "text", "--value-file", "-"}, "--value and --value-file are mutually exclusive"},
|
{[]string{"secret", "set", "BUILD_TOKEN", "--value", "text", "--value-file", "-"}, "--body and --body-file are mutually exclusive"},
|
||||||
{[]string{"secret", "set", "BUILD_TOKEN", "--value= ", "--json=value"}, "unknown JSON field \"value\""},
|
{[]string{"secret", "set", "BUILD_TOKEN", "--value= ", "--json=value"}, "unknown JSON field \"value\""},
|
||||||
{[]string{"secret", "set", "BUILD_TOKEN", "--no-input"}, "missing value: supply --value or --value-file"},
|
{[]string{"secret", "set", "BUILD_TOKEN", "--no-input"}, "missing value: pass --body, --body-file or standard input"},
|
||||||
{[]string{"secret", "set", "CI", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"},
|
{[]string{"secret", "set", "CI", "--value", "text"}, "CI and names starting with GITEA_ or GITHUB_ are reserved"},
|
||||||
{[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`},
|
{[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`},
|
||||||
{[]string{"status", "--json=token"}, "unknown JSON field \"token\""},
|
{[]string{"status", "--json=token"}, "unknown JSON field \"token\""},
|
||||||
{[]string{"team", "create", "org", "--name", "Developers", "--permission", "owner"}, "--permission must be read, write, or admin"},
|
{[]string{"team", "create", "org", "--name", "Developers", "--permission", "owner"}, "--permission must be read, write, or admin"},
|
||||||
@@ -221,10 +221,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"time", "list", "--from", "2026-10-02", "--until", "2026-10-01"}, "--from must not be later than --until"},
|
{[]string{"time", "list", "--from", "2026-10-02", "--until", "2026-10-01"}, "--from must not be later than --until"},
|
||||||
{[]string{"time", "list", "--from", "1h", "--until", "2h"}, "--from must not be later than --until"},
|
{[]string{"time", "list", "--from", "1h", "--until", "2h"}, "--from must not be later than --until"},
|
||||||
{[]string{"time", "list", "--total", "--json"}, "--total is a human-output summary"},
|
{[]string{"time", "list", "--total", "--json"}, "--total is a human-output summary"},
|
||||||
{[]string{"variable", "set", "BUILD_MODE", "--value="}, "variable value cannot be empty"},
|
{[]string{"variable", "set", "BUILD_MODE", "--value="}, "the value of BUILD_MODE is empty"},
|
||||||
{[]string{"variable", "set", "GITEA_RESERVED", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"},
|
{[]string{"variable", "set", "GITEA_RESERVED", "--value", "text"}, "CI and names starting with GITEA_ or GITHUB_ are reserved"},
|
||||||
{[]string{"variable", "list", "--org", "team", "--account"}, "--org, --account, and --repo select mutually exclusive scopes"},
|
{[]string{"variable", "list", "--org", "team", "--account"}, "--org, --account and --repo select different scopes; pass one"},
|
||||||
{[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account, and --repo select mutually exclusive scopes"},
|
{[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account and --repo select different scopes; pass one"},
|
||||||
{[]string{"version", "--json"}, "version prints text"},
|
{[]string{"version", "--json"}, "version prints text"},
|
||||||
{[]string{"webhook", "edit", "1", "--secret", "private", "--clear-authorization"}, "webhook secret editing is unsupported"},
|
{[]string{"webhook", "edit", "1", "--secret", "private", "--clear-authorization"}, "webhook secret editing is unsupported"},
|
||||||
{[]string{"webhook", "create", "ftp://host/path"}, "webhook URL must be an absolute HTTP(S) URL without user information or fragment"},
|
{[]string{"webhook", "create", "ftp://host/path"}, "webhook URL must be an absolute HTTP(S) URL without user information or fragment"},
|
||||||
@@ -289,7 +289,7 @@ func TestPromptedCreateSubmitsWithoutConfirmation(t *testing.T) {
|
|||||||
secret, write string
|
secret, write string
|
||||||
}{
|
}{
|
||||||
{[]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{"Value of MODE:": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"},
|
||||||
{[]string{"webhook", "create"}, map[string]string{"Payload 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"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
"gitea.dev/tea/internal/transport"
|
"gitea.dev/tea/internal/transport"
|
||||||
@@ -20,13 +20,28 @@ type ActionScope struct {
|
|||||||
Account bool
|
Account bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionSecret describes a secret without containing its value.
|
// String names the scope for people: "alice/project", "organization acme" or "your account".
|
||||||
|
func (s ActionScope) String() string {
|
||||||
|
switch {
|
||||||
|
case s.Account:
|
||||||
|
return "your account"
|
||||||
|
case s.Organization != "":
|
||||||
|
return "organization " + s.Organization
|
||||||
|
}
|
||||||
|
return s.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionSecret describes a secret without containing its value. CreatedAt is empty for a secret
|
||||||
|
// that was just set, as Gitea does not return it.
|
||||||
type ActionSecret struct {
|
type ActionSecret struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionValueName is the name Gitea stores a secret or variable under: names are upper-cased.
|
||||||
|
func ActionValueName(name string) string { return strings.ToUpper(name) }
|
||||||
|
|
||||||
func secretFrom(secret *gitea.Secret) ActionSecret {
|
func secretFrom(secret *gitea.Secret) ActionSecret {
|
||||||
return ActionSecret{Name: secret.Name, Description: secret.Description, CreatedAt: timestamp(secret.Created)}
|
return ActionSecret{Name: secret.Name, Description: secret.Description, CreatedAt: timestamp(secret.Created)}
|
||||||
}
|
}
|
||||||
@@ -50,27 +65,34 @@ type ActionSecrets struct {
|
|||||||
|
|
||||||
// List returns secret metadata only.
|
// List returns secret metadata only.
|
||||||
func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) {
|
func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) {
|
||||||
if s.Scope.Account {
|
window, err := s.ListPage(ctx, limit, page)
|
||||||
return nil, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"}
|
return window.Items, err
|
||||||
}
|
|
||||||
options := listOptions(limit, page)
|
|
||||||
secrets, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Secret, *gitea.Response, error) {
|
|
||||||
if s.Scope.Organization != "" {
|
|
||||||
return c.Actions.ListOrgSecrets(ctx, s.Scope.Organization, gitea.ListOrgActionsSecretOption{ListOptions: options})
|
|
||||||
}
|
|
||||||
owner, repo, err := splitRepository(s.Scope.Repository)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return c.Actions.ListRepoSecrets(ctx, owner, repo, gitea.ListRepoActionsSecretOption{ListOptions: options})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return convertAll(secrets, secretFrom), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set creates or replaces a secret; the API's PUT operation defines upsert semantics.
|
// ListPage is List with what the server reports about the other pages.
|
||||||
|
func (s ActionSecrets) ListPage(ctx context.Context, limit, page int) (Page[ActionSecret], error) {
|
||||||
|
if s.Scope.Account {
|
||||||
|
return Page[ActionSecret]{}, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"}
|
||||||
|
}
|
||||||
|
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Secret, http.Header, error) {
|
||||||
|
options := listOptions(limit, page)
|
||||||
|
secrets, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Secret, *gitea.Response, error) {
|
||||||
|
if s.Scope.Organization != "" {
|
||||||
|
return c.Actions.ListOrgSecrets(ctx, s.Scope.Organization, gitea.ListOrgActionsSecretOption{ListOptions: options})
|
||||||
|
}
|
||||||
|
owner, repo, err := splitRepository(s.Scope.Repository)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.Actions.ListRepoSecrets(ctx, owner, repo, gitea.ListRepoActionsSecretOption{ListOptions: options})
|
||||||
|
})
|
||||||
|
return secrets, headerOf(resp), err
|
||||||
|
})
|
||||||
|
return Page[ActionSecret]{Items: convertAll(window.Items, secretFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set creates or replaces a secret; the API's PUT operation defines upsert semantics. The result
|
||||||
|
// names the secret as Gitea stores it.
|
||||||
func (s ActionSecrets) Set(ctx context.Context, name, value, description string) (ActionSecret, error) {
|
func (s ActionSecrets) Set(ctx context.Context, name, value, description string) (ActionSecret, error) {
|
||||||
option := gitea.CreateOrUpdateSecretOption{Data: value, Description: description}
|
option := gitea.CreateOrUpdateSecretOption{Data: value, Description: description}
|
||||||
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||||
@@ -86,12 +108,12 @@ func (s ActionSecrets) Set(ctx context.Context, name, value, description string)
|
|||||||
}
|
}
|
||||||
return c.Actions.CreateRepoSecret(ctx, owner, repo, name, option)
|
return c.Actions.CreateRepoSecret(ctx, owner, repo, name, option)
|
||||||
})
|
})
|
||||||
return ActionSecret{Name: name, Description: description}, err
|
return ActionSecret{Name: ActionValueName(name), Description: description}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes only the named secret in the selected scope.
|
// Delete removes only the named secret in the selected scope.
|
||||||
func (s ActionSecrets) Delete(ctx context.Context, name string) error {
|
func (s ActionSecrets) Delete(ctx context.Context, name string) error {
|
||||||
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||||
switch {
|
switch {
|
||||||
case s.Scope.Account:
|
case s.Scope.Account:
|
||||||
return c.Actions.DeleteUserSecret(ctx, name)
|
return c.Actions.DeleteUserSecret(ctx, name)
|
||||||
@@ -105,6 +127,7 @@ func (s ActionSecrets) Delete(ctx context.Context, name string) error {
|
|||||||
// TODO(sdk): DeleteRepoSecret does not escape the secret name.
|
// TODO(sdk): DeleteRepoSecret does not escape the secret name.
|
||||||
return c.Actions.DeleteRepoSecret(ctx, owner, repo, url.PathEscape(name))
|
return c.Actions.DeleteRepoSecret(ctx, owner, repo, url.PathEscape(name))
|
||||||
})
|
})
|
||||||
|
return Classify(err, "secret", ActionValueName(name), s.Scope.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionVariables manages scoped non-secret Actions variables.
|
// ActionVariables manages scoped non-secret Actions variables.
|
||||||
@@ -126,24 +149,28 @@ func (s ActionVariables) repoVariables(name string) string {
|
|||||||
|
|
||||||
// List returns a non-nil page of variables.
|
// List returns a non-nil page of variables.
|
||||||
func (s ActionVariables) List(ctx context.Context, limit, page int) ([]ActionVariable, error) {
|
func (s ActionVariables) List(ctx context.Context, limit, page int) ([]ActionVariable, error) {
|
||||||
var variables []*gitea.ActionsVariable
|
window, err := s.ListPage(ctx, limit, page)
|
||||||
|
return window.Items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPage is List with what the server reports about the other pages.
|
||||||
|
func (s ActionVariables) ListPage(ctx context.Context, limit, page int) (Page[ActionVariable], error) {
|
||||||
|
var window Page[*gitea.ActionsVariable]
|
||||||
var err error
|
var err error
|
||||||
switch {
|
if s.Scope.Account || s.Scope.Organization != "" {
|
||||||
case s.Scope.Account:
|
window, err = listPage(limit, page, func(limit, page int) ([]*gitea.ActionsVariable, http.Header, error) {
|
||||||
variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
|
variables, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
|
||||||
return c.Actions.ListUserVariables(ctx, listOptions(limit, page))
|
if s.Scope.Account {
|
||||||
|
return c.Actions.ListUserVariables(ctx, listOptions(limit, page))
|
||||||
|
}
|
||||||
|
return c.Actions.ListOrgVariables(ctx, s.Scope.Organization, gitea.ListOrgActionsVariableOption{ListOptions: listOptions(limit, page)})
|
||||||
|
})
|
||||||
|
return variables, headerOf(resp), err
|
||||||
})
|
})
|
||||||
case s.Scope.Organization != "":
|
} else {
|
||||||
variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
|
window, err = rawPage[*gitea.ActionsVariable](ctx, s.Client, s.repoVariables(""), nil, limit, page)
|
||||||
return c.Actions.ListOrgVariables(ctx, s.Scope.Organization, gitea.ListOrgActionsVariableOption{ListOptions: listOptions(limit, page)})
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
err = getJSON(ctx, s.Client, fmt.Sprintf("%s?limit=%d&page=%d", s.repoVariables(""), limit, page), &variables)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
return Page[ActionVariable]{Items: convertAll(window.Items, variableFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return convertAll(variables, variableFrom), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View retrieves one variable.
|
// View retrieves one variable.
|
||||||
@@ -163,7 +190,7 @@ func (s ActionVariables) View(ctx context.Context, name string) (ActionVariable,
|
|||||||
err = getJSON(ctx, s.Client, s.repoVariables(name), variable)
|
err = getJSON(ctx, s.Client, s.repoVariables(name), variable)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ActionVariable{}, err
|
return ActionVariable{}, Classify(err, "variable", ActionValueName(name), s.Scope.String())
|
||||||
}
|
}
|
||||||
return variableFrom(variable), nil
|
return variableFrom(variable), nil
|
||||||
}
|
}
|
||||||
@@ -172,7 +199,7 @@ func (s ActionVariables) View(ctx context.Context, name string) (ActionVariable,
|
|||||||
func (s ActionVariables) Set(ctx context.Context, name, value, description string) (ActionVariable, error) {
|
func (s ActionVariables) Set(ctx context.Context, name, value, description string) (ActionVariable, error) {
|
||||||
exists := true
|
exists := true
|
||||||
if _, err := s.View(ctx, name); err != nil {
|
if _, err := s.View(ctx, name); err != nil {
|
||||||
if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound {
|
if !errors.As(err, new(*NotFoundError)) {
|
||||||
return ActionVariable{}, err
|
return ActionVariable{}, err
|
||||||
}
|
}
|
||||||
exists = false
|
exists = false
|
||||||
@@ -202,20 +229,23 @@ func (s ActionVariables) Set(ctx context.Context, name, value, description strin
|
|||||||
}
|
}
|
||||||
err = sendJSON(ctx, s.Client, method, s.repoVariables(name), update, nil)
|
err = sendJSON(ctx, s.Client, method, s.repoVariables(name), update, nil)
|
||||||
}
|
}
|
||||||
return ActionVariable{Name: name, Value: value, Description: description}, err
|
return ActionVariable{Name: ActionValueName(name), Value: value, Description: description}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes only the named variable in the selected scope.
|
// Delete removes only the named variable in the selected scope.
|
||||||
func (s ActionVariables) Delete(ctx context.Context, name string) error {
|
func (s ActionVariables) Delete(ctx context.Context, name string) error {
|
||||||
|
var err error
|
||||||
switch {
|
switch {
|
||||||
case s.Scope.Account:
|
case s.Scope.Account:
|
||||||
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||||
return c.Actions.DeleteUserVariable(ctx, name)
|
return c.Actions.DeleteUserVariable(ctx, name)
|
||||||
})
|
})
|
||||||
case s.Scope.Organization != "":
|
case s.Scope.Organization != "":
|
||||||
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||||
return c.Actions.DeleteOrgVariable(ctx, s.Scope.Organization, name)
|
return c.Actions.DeleteOrgVariable(ctx, s.Scope.Organization, name)
|
||||||
})
|
})
|
||||||
|
default:
|
||||||
|
err = sendJSON(ctx, s.Client, http.MethodDelete, s.repoVariables(name), nil, nil)
|
||||||
}
|
}
|
||||||
return sendJSON(ctx, s.Client, http.MethodDelete, s.repoVariables(name), nil, nil)
|
return Classify(err, "variable", ActionValueName(name), s.Scope.String())
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user