From 0dfe7def9b840292d1412cedc1ec87c77443396f Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:41:05 +0200 Subject: [PATCH] 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 --- internal/app/action_values.go | 537 ++++++++++++++++++++++------- internal/app/action_values_test.go | 144 ++++++++ internal/app/app_test.go | 9 - internal/app/validation_test.go | 16 +- internal/services/action_values.go | 120 ++++--- 5 files changed, 641 insertions(+), 185 deletions(-) diff --git a/internal/app/action_values.go b/internal/app/action_values.go index 2a55da30..e80a7119 100644 --- a/internal/app/action_values.go +++ b/internal/app/action_values.go @@ -4,8 +4,11 @@ package app import ( + "bufio" + "bytes" "errors" "fmt" + "io" "regexp" "strings" @@ -17,138 +20,426 @@ import ( 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 := "variable" + kind, use := "variable", "read with ${{ vars.NAME }}" if secret { - kind = "secret" + kind, use = "secret", "read with ${{ secrets.NAME }}" } - group := &cobra.Command{Use: kind, Short: "Manage scoped Actions " + kind + "s"} - var org string - var account bool - group.PersistentFlags().StringVar(&org, "org", "", "Organization scope") - 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 + group := &cobra.Command{ + Use: kind, + Short: fmt.Sprintf("Manage Actions %ss for a repository, organization, or your account", kind), + Long: actionValueLong(kind, use), } - var limit, page int - 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"} + group.AddCommand(a.actionValueListCommand(secret, kind), a.actionValueSetCommand(secret, kind), a.actionValueDeleteCommand(secret, kind)) if !secret { - verbs = append(verbs, "view") - } - for _, verb := range verbs { - var value, file, description string - command := &cobra.Command{Use: verb + " ", 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) + group.AddCommand(a.variableViewCommand(), a.variableGetCommand()) } 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 { - 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 []", + 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 ", + 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 ", + 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 ", + 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) + } } diff --git a/internal/app/action_values_test.go b/internal/app/action_values_test.go index 0714adbc..db3b2e2d 100644 --- a/internal/app/action_values_test.go +++ b/internal/app/action_values_test.go @@ -7,9 +7,12 @@ import ( "context" "encoding/json" "net/http" + "os" + "path/filepath" "strings" "testing" + "gitea.dev/tea/internal/clierr" "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.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()) + } +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 2b478ed8..6f2b16af 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -544,18 +544,11 @@ const ( search issue search pr search repo - secret delete - secret list - secret set status time create time delete time list time reset - variable delete - variable list - variable set - variable view wiki create wiki delete wiki edit @@ -581,9 +574,7 @@ const ( release asset repo search - secret time - variable wiki ` ) diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 78419f78..4d4b0ee9 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -198,10 +198,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]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", "--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", "--no-input"}, "missing value: supply --value or --value-file"}, - {[]string{"secret", "set", "CI", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"}, + {[]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 starting with GITEA_ or GITHUB_ are reserved"}, {[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`}, {[]string{"status", "--json=token"}, "unknown JSON field \"token\""}, {[]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", "1h", "--until", "2h"}, "--from must not be later than --until"}, {[]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", "GITEA_RESERVED", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"}, - {[]string{"variable", "list", "--org", "team", "--account"}, "--org, --account, and --repo select mutually exclusive scopes"}, - {[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account, and --repo select mutually exclusive scopes"}, + {[]string{"variable", "set", "BUILD_MODE", "--value="}, "the value of BUILD_MODE is empty"}, + {[]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 different scopes; pass one"}, + {[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account and --repo select different scopes; pass one"}, {[]string{"version", "--json"}, "version prints text"}, {[]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"}, @@ -289,7 +289,7 @@ func TestPromptedCreateSubmitsWithoutConfirmation(t *testing.T) { secret, write string }{ {[]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{"team", "create", "acme"}, map[string]string{"Team name": "Developers"}, "", "POST /api/v1/orgs/acme/teams"}, } { diff --git a/internal/services/action_values.go b/internal/services/action_values.go index 7c67a29a..3d81888c 100644 --- a/internal/services/action_values.go +++ b/internal/services/action_values.go @@ -6,9 +6,9 @@ package services import ( "context" "errors" - "fmt" "net/http" "net/url" + "strings" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -20,13 +20,28 @@ type ActionScope struct { 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 { Name string `json:"name"` 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 { return ActionSecret{Name: secret.Name, Description: secret.Description, CreatedAt: timestamp(secret.Created)} } @@ -50,27 +65,34 @@ type ActionSecrets struct { // List returns secret metadata only. func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) { - if s.Scope.Account { - return nil, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"} - } - 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 + window, err := s.ListPage(ctx, limit, page) + return window.Items, err } -// 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) { option := gitea.CreateOrUpdateSecretOption{Data: value, Description: description} 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 ActionSecret{Name: name, Description: description}, err + return ActionSecret{Name: ActionValueName(name), Description: description}, err } // Delete removes only the named secret in the selected scope. 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 { case s.Scope.Account: 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. 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. @@ -126,24 +149,28 @@ func (s ActionVariables) repoVariables(name string) string { // List returns a non-nil page of variables. 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 - switch { - case s.Scope.Account: - variables, _, 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 || s.Scope.Organization != "" { + window, err = listPage(limit, page, func(limit, page int) ([]*gitea.ActionsVariable, http.Header, error) { + variables, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) { + 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 != "": - variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) { - 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) + } else { + window, err = rawPage[*gitea.ActionsVariable](ctx, s.Client, s.repoVariables(""), nil, limit, page) } - if err != nil { - return nil, err - } - return convertAll(variables, variableFrom), nil + return Page[ActionVariable]{Items: convertAll(window.Items, variableFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err } // 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) } if err != nil { - return ActionVariable{}, err + return ActionVariable{}, Classify(err, "variable", ActionValueName(name), s.Scope.String()) } 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) { exists := true 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 } 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) } - 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. func (s ActionVariables) Delete(ctx context.Context, name string) error { + var err error switch { 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) }) 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) }) + 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()) }