From 4edb8364169933f537b7db79641fd6302de4e96f Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:40:27 +0200 Subject: [PATCH] feat(workflow): address workflows by name and check dispatch inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflows are found by file name, file name without extension, path or name, case-insensitively, with the candidates listed when a name is ambiguous. workflow list shows a NAME, FILE and STATE table, and workflow view shows the workflow with its last runs, prints its file with --yaml (at --ref) or opens it with --web. workflow run reads the workflow_dispatch inputs of the workflow at the ref and refuses unknown inputs, missing required inputs and values a choice, boolean or number input does not accept, before dispatching. In a terminal it asks for missing inputs, or all of them when none is given. Without --ref it uses the checked-out branch, which must be on the server, or the default branch when the repository does not come from the working directory. It reports "Dispatched CI (ci.yml) on main → run 33" with the command to watch it, prints the run's URL, and --follow follows the logs of all jobs within --timeout. workflow enable and disable no longer ask for confirmation, report the change, warn when there is nothing to do, and return the workflow with --json. Signed-off-by: bircni --- internal/app/actions.go | 608 ++++++++++++++++++++++++++------ internal/app/actions_test.go | 264 ++++++++++++-- internal/app/app_test.go | 6 - internal/app/validation_test.go | 2 +- 4 files changed, 737 insertions(+), 143 deletions(-) diff --git a/internal/app/actions.go b/internal/app/actions.go index edf5224e..956a10e2 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -4,127 +4,533 @@ package app import ( + "bytes" "context" "errors" "fmt" + "net/http" + "slices" + "strconv" "strings" "time" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" + "gitea.dev/tea/internal/transport" "github.com/spf13/cobra" ) +// workflowArguments describes the workflow references that workflow commands accept. +const workflowArguments = `A workflow can be supplied as its file name ("ci.yml"), its file name without +extension ("ci"), its path (".gitea/workflows/ci.yml") or its name ("CI").` + func (a *application) workflowCommand() *cobra.Command { - group := &cobra.Command{Use: "workflow", Short: "Manage Actions workflows"} - group.AddCommand(&cobra.Command{Use: "list", Short: "List repository workflows", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Workflows(cmd.Context()) - if err != nil { - return err - } - return writeList(a, cmd, items, workflowRow) - }}) - group.AddCommand(&cobra.Command{Use: "view ", Short: "View a workflow", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - item, err := (services.Actions{Client: t.client, Repository: t.Repo}).Workflow(cmd.Context(), args[0]) - if err != nil { - return err - } - return writeItem(a, cmd, item, workflowRow) - }}) - var ref string - var fields, inputs []string - var follow bool - dispatch := &cobra.Command{Use: "run ", Short: "Dispatch a workflow", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - if follow && a.options.json != "" { - return clierr.Wrap(clierr.Usage, errors.New("--follow cannot be combined with --json/--jq")) - } - if ref == "" { - branch, err := a.currentBranch(cmd.Context()) - if err != nil || branch == "" { - return clierr.Wrap(clierr.Usage, errors.New("--ref is required outside a branch checkout")) - } - ref = branch - } - values := map[string]string{} - for _, field := range append(fields, inputs...) { - pieces := strings.SplitN(field, "=", 2) - if len(pieces) != 2 || pieces[0] == "" { - return clierr.Wrap(clierr.Usage, errors.New("--field/--input requires key=value")) - } - if _, exists := values[pieces[0]]; exists { - return clierr.Wrap(clierr.Usage, errors.New("duplicate workflow input")) - } - values[pieces[0]] = pieces[1] - } - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - service := services.Actions{Client: t.client, Repository: t.Repo} - result, err := service.Dispatch(cmd.Context(), args[0], ref, values) - if err != nil { - return err - } - if a.options.json != "" { - return a.writeJSON(cmd, result) - } - if result.ID == 0 { - if follow { - return errors.New("server did not return a run ID; cannot follow logs") - } - _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Workflow dispatched; the server did not return a run ID") - return err - } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\n", result.ID, result.URL); err != nil { - return err - } - if !follow { - return nil - } - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for run %d jobs...\n", result.ID) - ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Minute) - defer cancel() - job, err := service.WaitForFirstJob(ctx, result.ID, 2*time.Second) - if err != nil { - return err - } - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Following job %d (%s)\n", job.ID, job.Name) - return service.FollowJobLogs(ctx, result.ID, job.ID, 2*time.Second, func(chunk []byte) error { - _, err := cmd.OutOrStdout().Write(chunk) - return err - }) - }} - dispatch.Flags().StringVarP(&ref, "ref", "r", "", "Branch or tag to run (default: current branch)") - dispatch.Flags().StringArrayVarP(&fields, "field", "f", nil, "Workflow input key=value (repeatable)") - dispatch.Flags().StringArrayVarP(&inputs, "input", "i", nil, "Alias for --field") - dispatch.Flags().BoolVar(&follow, "follow", false, "Follow the first job's logs after dispatch") - group.AddCommand(dispatch) - for _, verb := range []string{"enable", "disable"} { - group.AddCommand(&cobra.Command{Use: verb + " ", Short: titleCase(verb) + " a workflow", Annotations: textOutput("workflow state changes do not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + group := &cobra.Command{ + Use: "workflow", + Short: "View, run, enable and disable Actions workflows", + Long: `View the Actions workflows of a repository, dispatch them, and enable or disable them. + +List and inspect the runs of workflows with tea run.`, + } + group.AddCommand(a.workflowListCommand(), a.workflowViewCommand(), a.workflowRunCommand(), a.workflowStateCommand(true), a.workflowStateCommand(false)) + return group +} + +func (a *application) workflowListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the workflows of a repository", + Example: `# list the workflows of the repository +$ tea workflow list +# list the files of disabled workflows +$ tea workflow list --json id,state --jq '.[] | select(.state != "active") | .id'`, + Args: argsExact(0), + RunE: func(cmd *cobra.Command, _ []string) error { t, err := a.resolve(cmd.Context(), true) if err != nil { return err } - if verb == "disable" { - if err := a.confirm(cmd, "Disable workflow "+args[0]); err != nil { - return err - } + items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Workflows(cmd.Context()) + if err != nil { + return err } - return (services.Actions{Client: t.client, Repository: t.Repo}).SetWorkflowEnabled(cmd.Context(), args[0], verb == "enable") - }}) + spec := listSpec[services.Workflow]{Columns: workflowColumns, Row: workflowRow, Empty: "no workflows in " + t.Repo, Context: "workflows in " + t.Repo} + return writeTable(a, cmd, items, spec, listPage{Number: 1, Total: len(items), Counted: true}) + }, } - return group } +// workflowColumns head workflowRow. +var workflowColumns = []terminal.Column{{Header: "Name", Flex: true}, {Header: "File"}, {Header: "State"}, {Header: "Path", PipedOnly: true}, {Header: "URL", PipedOnly: true}} + func workflowRow(item services.Workflow) []terminal.Cell { - return terminal.Row(item.ID, item.Name, item.State) + return []terminal.Cell{{Text: item.Name, Link: item.URL}, {Text: item.ID}, workflowState(item), {Text: item.Path}, {Text: item.URL}} +} + +// workflowState shows a workflow's state, "disabled" for Gitea's disabled_manually. +func workflowState(item services.Workflow) terminal.Cell { + if !item.Enabled() { + return terminal.Cell{Text: "disabled", Muted: true} + } + return terminal.Cell{Text: item.State, Color: "green"} +} + +func (a *application) workflowViewCommand() *cobra.Command { + var showYAML, web bool + var ref string + cmd := &cobra.Command{ + Use: "view ", + Short: "View a workflow and its recent runs", + Example: `# view a workflow and its recent runs +$ tea workflow view CI +# print the workflow file of a branch +$ tea workflow view ci.yml --yaml --ref feature/login +# open the workflow file in the browser +$ tea workflow view ci --web`, + Args: argsExact(1), + Annotations: map[string]string{"tea.arguments": workflowArguments}, + RunE: func(cmd *cobra.Command, args []string) error { + if ref != "" && !showYAML { + return clierr.Wrap(clierr.Usage, errors.New("--ref selects the version that --yaml prints")) + } + if showYAML && (web || a.options.json != "") { + return clierr.Wrap(clierr.Usage, errors.New("--yaml cannot be combined with --web, --json or --jq")) + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + service := services.Actions{Client: t.client, Repository: t.Repo} + workflow, err := service.FindWorkflow(cmd.Context(), args[0]) + if err != nil { + return err + } + switch { + case web: + return a.openBrowser(cmd, workflow.URL) + case showYAML: + definition, err := service.WorkflowFile(cmd.Context(), workflow, ref) + if err != nil { + return err + } + return writeTerminalSafe(cmd.OutOrStdout(), a.deps.Streams.OutTTY, bytes.NewReader(definition)) + case a.options.json != "": + return a.writeJSON(cmd, workflow) + } + runs, err := service.RunsPage(cmd.Context(), services.RunListOptions{Limit: 5, Page: 1, Workflow: workflow.ID}) + if err != nil { + return err + } + return a.writeHuman(cmd, a.renderWorkflow(t, workflow, runs.Items)) + }, + } + cmd.Flags().BoolVar(&showYAML, "yaml", false, "Print the workflow file") + cmd.Flags().StringVarP(&ref, "ref", "r", "", "Branch, tag or commit of the workflow file that --yaml prints (default: the default branch)") + cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the workflow file in the browser") + cmd.ValidArgsFunction = a.completeWorkflowArg() + _ = cmd.RegisterFlagCompletionFunc("ref", a.completeBranches()) + return cmd +} + +// renderWorkflow shows a workflow with its recent runs. +func (a *application) renderWorkflow(t target, workflow services.Workflow, runs []services.Run) string { + d := a.display() + rows := make([][]terminal.Cell, len(runs)) + for i, run := range runs { + rows[i] = runRow(a.deps.Now())(run) + } + if !d.TTY { + fields := []terminal.Field{{Label: "File", Value: workflow.ID}, {Label: "Path", Value: workflow.Path}, {Label: "State", Value: workflow.State}} + return terminal.View{Title: workflow.Name, Fields: fields, URL: workflow.URL, Body: terminal.Table(runColumns, rows, d)}.Render(d) + } + muted := func(text string) string { return paintActionCell(terminal.Cell{Text: text, Muted: true}, d.Color) } + var b strings.Builder + b.WriteString(paintActionCell(terminal.Cell{Text: workflow.Name, Bold: true}, d.Color) + " " + muted(workflow.ID) + "\n") + b.WriteString(paintActionCell(workflowState(workflow), d.Color) + " • " + muted(workflow.Path) + "\n\n") + b.WriteString(paintActionCell(terminal.Cell{Text: "RECENT RUNS", Bold: true}, d.Color) + "\n") + if len(runs) == 0 { + b.WriteString(muted("No runs yet") + "\n") + } else { + b.WriteString(terminal.Table(runColumns, rows, d)) + } + b.WriteString("\n" + muted(fmt.Sprintf("To see more runs: tea run list --workflow %s%s", workflow.ID, a.suggestedRepo(t))) + "\n") + if workflow.URL != "" { + b.WriteString(muted("View this workflow on Gitea: "+terminal.SafeLine(workflow.URL)) + "\n") + } + return b.String() +} + +func (a *application) workflowRunCommand() *cobra.Command { + var ref string + var fields, inputs []string + var follow bool + var interval, timeout time.Duration + cmd := &cobra.Command{ + Use: "run ", + Short: "Dispatch a workflow", + Long: `Start a run of a workflow that has a workflow_dispatch trigger. + +Inputs are checked against the inputs that the workflow declares: unknown inputs, missing +required inputs and values that a choice or boolean input does not accept are refused. In a +terminal, tea asks for missing required inputs, or for every input when none is given. + +Without --ref, the workflow runs on the checked-out branch, which must be on the server, or on +the default branch when the repository does not come from the working directory.`, + Example: `# run a workflow on the default or current branch +$ tea workflow run CI +# run it on a branch with inputs +$ tea workflow run ci.yml --ref main -f target=production -f dry_run=false +# run it and follow the logs of its jobs +$ tea workflow run nightly --follow`, + Args: argsExact(1), + Annotations: map[string]string{"tea.arguments": workflowArguments}, + RunE: func(cmd *cobra.Command, args []string) error { + if follow && a.options.json != "" { + return clierr.Wrap(clierr.Usage, errors.New("--follow cannot be combined with --json/--jq")) + } + if interval <= 0 || timeout <= 0 { + return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive")) + } + values, err := inputValues(append(fields, inputs...)) + if err != nil { + return err + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + service := services.Actions{Client: t.client, Repository: t.Repo} + workflow, err := service.FindWorkflow(cmd.Context(), args[0]) + if err != nil { + return err + } + if !workflow.Enabled() { + return fmt.Errorf("workflow %s is disabled; enable it with: tea workflow enable %s%s", workflow.ID, workflow.ID, a.suggestedRepo(t)) + } + if ref, err = a.dispatchRef(cmd.Context(), t, ref); err != nil { + return err + } + definition, err := service.WorkflowFile(cmd.Context(), workflow, ref) + if err != nil { + return err + } + declared, err := services.DispatchInputs(definition) + if errors.Is(err, services.ErrNoDispatch) { + return fmt.Errorf("%s cannot be dispatched: it has no workflow_dispatch trigger on %s", workflow.Label(), ref) + } + if err != nil { + return err + } + if values, err = a.dispatchValues(cmd, workflow, declared, values, len(fields)+len(inputs) == 0); err != nil { + return err + } + result, err := service.Dispatch(cmd.Context(), workflow.ID, ref, values) + if err != nil { + return err + } + if a.options.json != "" { + return a.writeJSON(cmd, result) + } + if result.ID == 0 { + a.done(cmd, "Dispatched %s on %s", workflow.Label(), ref) + if follow { + return errors.New("the server did not report the run it started; cannot follow its logs") + } + return nil + } + a.done(cmd, "Dispatched %s on %s → run %d", workflow.Label(), ref, result.ID) + if !follow { + a.nextStep(cmd, "Watch it: tea run watch %d%s", result.ID, a.suggestedRepo(t)) + } + if err := a.printURL(cmd, result.URL); err != nil || !follow { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + job, err := service.WaitForFirstJob(ctx, result.ID, interval) + if err != nil { + return timeoutError(err, timeout, "waiting for the jobs of run %d", result.ID) + } + jobs, err := service.Jobs(ctx, result.ID) + if err != nil { + return err + } + if len(jobs) == 0 { + jobs = []services.Job{job} + } + return timeoutError(a.followJobLogs(ctx, cmd, service, result.ID, jobs, interval), timeout, "following the logs of run %d", result.ID) + }, + } + cmd.Flags().StringVarP(&ref, "ref", "r", "", "Branch or tag to run the workflow on (default: the current or default branch)") + cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Workflow input as `key=value` (repeatable)") + cmd.Flags().StringArrayVarP(&inputs, "input", "i", nil, "Alias of --field") + _ = cmd.Flags().MarkHidden("input") + cmd.Flags().BoolVar(&follow, "follow", false, "Follow the logs of the run's jobs after dispatching it") + cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "Polling interval with --follow") + cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum time to follow") + cmd.ValidArgsFunction = a.completeWorkflowArg() + _ = cmd.RegisterFlagCompletionFunc("ref", a.completeBranches()) + _ = cmd.RegisterFlagCompletionFunc("field", a.completeWorkflowInputs()) + _ = cmd.RegisterFlagCompletionFunc("input", a.completeWorkflowInputs()) + return cmd +} + +// nextStep shows a next step under a done line, under the same conditions. +func (a *application) nextStep(cmd *cobra.Command, format string, args ...any) { + if a.deps.Streams.OutTTY && a.options.json == "" { + fmt.Fprintln(cmd.ErrOrStderr(), " "+terminal.Colorize("muted", terminal.SafeLine(fmt.Sprintf(format, args...)), a.colorEnabled())) + } +} + +// inputValues parses key=value workflow inputs. +func inputValues(fields []string) (map[string]string, error) { + values := map[string]string{} + for _, field := range fields { + key, value, ok := strings.Cut(field, "=") + if !ok || key == "" { + return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid input %q: use key=value", field)) + } + if _, exists := values[key]; exists { + return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("input %s is given more than once", key)) + } + values[key] = value + } + return values, nil +} + +// dispatchRef returns the ref to dispatch a workflow on: ref when set, else the checked-out +// branch when the repository comes from the working directory and the branch is on the server, +// else the repository's default branch. +func (a *application) dispatchRef(ctx context.Context, t target, ref string) (string, error) { + if ref != "" { + return ref, nil + } + defaultBranch := func() (string, error) { + repo, err := services.Repositories{Client: t.client}.View(ctx, t.Repo) + return repo.DefaultBranch, err + } + branch, err := a.currentBranch(ctx) + if err != nil || branch == "" || a.suggestedRepo(t) != "" { + return defaultBranch() + } + _, err = services.Branches{Client: t.client}.View(ctx, t.Repo, branch) + if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound { + fallback, err := defaultBranch() + if err != nil { + return "", err + } + return "", fmt.Errorf("branch %s is not on %s; push it or pass --ref %s", branch, t.Repo, fallback) + } + return branch, err +} + +// dispatchValues checks the inputs given for a workflow against those it declares, and asks +// for the missing required ones, or for all of them with askAll, when prompts are possible. +func (a *application) dispatchValues(cmd *cobra.Command, workflow services.Workflow, declared []services.WorkflowInput, values map[string]string, askAll bool) (map[string]string, error) { + names := make([]string, len(declared)) + for i, input := range declared { + names[i] = input.Name + } + for key := range values { + if slices.Contains(names, key) { + continue + } + if len(names) == 0 { + return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("%s takes no inputs", workflow.Label())) + } + return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("unknown input %q for %s; inputs: %s", key, workflow.Label(), strings.Join(names, ", "))) + } + s, err := a.deps.Settings.Load() + if err != nil { + return nil, err + } + canPrompt := a.policy(s).CanPrompt(a.deps.Streams) + for _, input := range declared { + value, given := values[input.Name] + missing := input.Required && input.Default == "" + switch { + case given: + normalized, err := inputValue(input, value) + if err != nil { + return nil, clierr.Wrap(clierr.Usage, err) + } + values[input.Name] = normalized + case canPrompt && (askAll || missing): + if values[input.Name], err = a.askInput(cmd, input); err != nil { + return nil, err + } + case missing: + return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input %s for %s; pass -f %s=", input.Name, workflow.Label(), input.Name)) + } + } + return values, nil +} + +// inputValue checks a value against its input's type: a choice must be one of its options, a +// boolean true or false, a number a number. It returns the value as the workflow expects it. +func inputValue(input services.WorkflowInput, value string) (string, error) { + switch input.Type { + case "choice": + if !slices.Contains(input.Options, value) { + return "", fmt.Errorf("invalid value %q for input %s: use %s", value, input.Name, strings.Join(input.Options, ", ")) + } + case "boolean": + parsed, err := strconv.ParseBool(value) + if err != nil { + return "", fmt.Errorf("invalid value %q for input %s: use true or false", value, input.Name) + } + return strconv.FormatBool(parsed), nil + case "number": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return "", fmt.Errorf("invalid value %q for input %s: use a number", value, input.Name) + } + } + if input.Required && value == "" { + return "", fmt.Errorf("input %s is required", input.Name) + } + return value, nil +} + +// askInput asks for one workflow input: a selection for a choice, yes or no for a boolean, and +// text otherwise, where an empty answer keeps the default. +func (a *application) askInput(cmd *cobra.Command, input services.WorkflowInput) (string, error) { + title := input.Name + if input.Description != "" { + title += " — " + input.Description + } + asker, rich := a.deps.Prompter.(terminal.Asker) + switch chooser, ok := a.deps.Prompter.(terminal.Chooser); { + case input.Type == "choice" && ok && len(input.Options) > 0: + choices := make([]terminal.Choice, len(input.Options)) + for i, option := range input.Options { + choices[i] = terminal.Choice{Label: option, Value: option} + } + return chooser.Choose(cmd.Context(), title, choices, input.Default) + case input.Type == "boolean" && rich: + yes, err := asker.ConfirmDefault(cmd.Context(), title, input.Default == "true") + return strconv.FormatBool(yes), err + } + if input.Default != "" { + title += " (default " + input.Default + ")" + } + validate := func(answer string) error { + if answer == "" { + if input.Required && input.Default == "" { + return errors.New("a value is required") + } + return nil + } + _, err := inputValue(input, answer) + return err + } + var answer string + var err error + if rich { + answer, err = asker.Ask(cmd.Context(), title, validate) + } else if answer, err = a.deps.Prompter.Input(cmd.Context(), title); err == nil { + err = validate(answer) + } + if err != nil { + return "", err + } + if answer == "" { + return input.Default, nil + } + return inputValue(input, answer) +} + +func (a *application) workflowStateCommand(enable bool) *cobra.Command { + verb, short, done := "disable", "Disable a workflow", "Disabled" + if enable { + verb, short, done = "enable", "Enable a workflow", "Enabled" + } + return &cobra.Command{ + Use: verb + " ", + Short: short, + Example: fmt.Sprintf(`# %s a workflow by name +$ tea workflow %s Nightly`, verb, verb), + Args: argsExact(1), + Annotations: map[string]string{"tea.arguments": workflowArguments}, + ValidArgsFunction: a.completeWorkflowArg(), + RunE: func(cmd *cobra.Command, args []string) error { + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + service := services.Actions{Client: t.client, Repository: t.Repo} + workflow, err := service.FindWorkflow(cmd.Context(), args[0]) + if err != nil { + return err + } + if workflow.Enabled() == enable { + a.warn(cmd, "%s is already %sd", workflow.Label(), verb) + } else { + if err := service.SetWorkflowEnabled(cmd.Context(), workflow.ID, enable); err != nil { + return err + } + if workflow, err = service.Workflow(cmd.Context(), workflow.ID); err != nil { + return err + } + a.done(cmd, "%s %s", done, workflow.Label()) + } + if a.options.json != "" { + return a.writeJSON(cmd, workflow) + } + return nil + }, + } +} + +// completeWorkflowArg completes the workflow argument with workflow files. +func (a *application) completeWorkflowArg() cobra.CompletionFunc { + workflows := a.completeWorkflows() + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return workflows(cmd, args, toComplete) + } +} + +// completeWorkflowInputs completes the inputs of the workflow given as the first argument as +// name=, then the values of a choice or boolean input. +func (a *application) completeWorkflowInputs() cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + key, _, value := strings.Cut(toComplete, "=") + values, directive := a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + service := services.Actions{Client: t.client, Repository: t.Repo} + workflow, err := service.FindWorkflow(ctx, args[0]) + if err != nil { + return nil, err + } + definition, err := service.WorkflowFile(ctx, workflow, "") + if err != nil { + return nil, err + } + declared, err := services.DispatchInputs(definition) + var candidates [][2]string + for _, input := range declared { + switch { + case !value: + candidates = append(candidates, [2]string{input.Name + "=", input.Description}) + case input.Name != key: + case input.Type == "boolean": + candidates = append(candidates, [2]string{key + "=true"}, [2]string{key + "=false"}) + default: + for _, option := range input.Options { + candidates = append(candidates, [2]string{key + "=" + option}) + } + } + } + return describe(candidates, func(candidate [2]string) (string, string) { return candidate[0], candidate[1] }), err + })(cmd, args, toComplete) + if !value { + directive |= cobra.ShellCompDirectiveNoSpace + } + return values, directive + } } diff --git a/internal/app/actions_test.go b/internal/app/actions_test.go index 56eb8a30..ea10e1db 100644 --- a/internal/app/actions_test.go +++ b/internal/app/actions_test.go @@ -5,46 +5,240 @@ package app import ( "context" - "net/http" + "encoding/base64" + "encoding/json" + "strings" "testing" "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/terminal" "github.com/stretchr/testify/require" ) -func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) { - jobPolls := 0 - server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/actions/workflows/ci.yml/dispatches": - require.Equal(t, "true", r.URL.Query().Get("return_run_details")) - _, _ = w.Write([]byte(`{"workflow_run_id":11,"html_url":"https://gitea.test/owner/repo/actions/runs/11"}`)) - case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11/jobs": - jobPolls++ - if r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1" { - _, _ = w.Write([]byte(`{"jobs":[]}`)) - return - } - _, _ = w.Write([]byte(`{"jobs":[{"id":21,"name":"build","status":"completed","conclusion":"success"}]}`)) - case r.URL.Path == "/api/v1/repos/owner/repo/actions/jobs/21/logs": - _, _ = w.Write([]byte("hello from job\n")) - case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11": - _, _ = w.Write([]byte(`{"id":11,"status":"completed","conclusion":"success","display_title":"ci"}`)) - default: - t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - deps, out, diagnostics := serverDeps(t, server.URL) - require.NoError(t, Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "owner/repo", "--ref", "main", "--follow", "--no-input"})) - require.Contains(t, out.String(), "11\thttps://gitea.test/owner/repo/actions/runs/11") - require.Contains(t, out.String(), "hello from job\n") - require.Contains(t, diagnostics.String(), "Waiting for run 11 jobs") - require.Contains(t, diagnostics.String(), "Following job 21 (build)") - require.GreaterOrEqual(t, jobPolls, 1) +const ciWorkflowFile = `name: CI +on: + push: + workflow_dispatch: + inputs: + target: + description: Deploy target + required: true + default: staging + type: choice + options: [staging, production] + dry_run: + type: boolean + default: true + note: + description: Free-form note + required: true +jobs: {} +` - err := Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "owner/repo", "--ref", "main", "--follow", "--json", "--no-input"}) - require.Error(t, err) - require.Equal(t, clierr.Usage, clierr.ExitCode(err)) - require.Contains(t, err.Error(), "--follow cannot be combined with --json") +// contentsOf is the contents API response for a file. +func contentsOf(content string) string { + data, _ := json.Marshal(map[string]string{"type": "file", "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content))}) + return string(data) } + +func TestWorkflowListAndView(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET actions/workflows/ci.yml/runs": `{"total_count":1,"workflow_runs":[` + queuedRunFixture + `]}`, + "GET contents/.gitea/workflows/ci.yml": contentsOf(ciWorkflowFile), + }) + for _, tc := range []struct { + name string + tty bool + args []string + want string + }{ + {"list", true, []string{"list"}, "Showing 2 of 2 workflows in alice/project\n\nNAME FILE STATE\nCI ci.yml active\nNightly nightly.yaml disabled\n"}, + {"list piped", false, []string{"list"}, "CI\tci.yml\tactive\t.gitea/workflows/ci.yml\thttps://gitea.test/alice/project/src/branch/main/.gitea/workflows/ci.yml\n" + + "Nightly\tnightly.yaml\tdisabled\t.gitea/workflows/nightly.yaml\t\n"}, + {"view by name", true, []string{"view", "ci"}, `CI ci.yml +active • .gitea/workflows/ci.yml + +RECENT RUNS +STATUS ID WORKFLOW TITLE BRANCH EVENT ELAPSED AGE +* 47 CI Add workflows main workflow_dispatch about 2 minutes ago + +To see more runs: tea run list --workflow ci.yml -R alice/project +View this workflow on Gitea: https://gitea.test/alice/project/src/branch/main/.gitea/workflows/ci.yml +`}, + {"view the file", false, []string{"view", ".gitea/workflows/ci.yml", "--yaml"}, ciWorkflowFile}, + {"view as JSON", false, []string{"view", "CI", "--json", "id,state"}, `{"id":"ci.yml","state":"active"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, _ := actionsDeps(t, server.URL, tc.tty) + require.NoError(t, Execute(context.Background(), deps, append(append([]string{"workflow"}, tc.args...), "-R", "alice/project"))) + if strings.HasPrefix(tc.want, "{") { + require.JSONEq(t, tc.want, out.String()) + } else { + require.Equal(t, tc.want, out.String()) + } + }) + } + deps, _, _ := actionsDeps(t, server.URL, false) + require.EqualError(t, Execute(context.Background(), deps, []string{"workflow", "view", "deploy", "-R", "alice/project"}), `workflow "deploy" not found in alice/project`) +} + +func TestWorkflowRunChecksInputsBeforeDispatching(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET contents/.gitea/workflows/ci.yml": contentsOf(ciWorkflowFile), + }) + for _, tc := range []struct { + args []string + err string + }{ + {[]string{"-f", "note=x", "-f", "bogus=1"}, `unknown input "bogus" for CI (ci.yml); inputs: target, dry_run, note`}, + {[]string{"-f", "note=x", "-f", "target=moon"}, `invalid value "moon" for input target: use staging, production`}, + {[]string{"-f", "note=x", "-f", "dry_run=maybe"}, `invalid value "maybe" for input dry_run: use true or false`}, + {[]string{"-f", "target=production"}, "missing required input note for CI (ci.yml); pass -f note="}, + {nil, "missing required input note for CI (ci.yml); pass -f note="}, + } { + *requests = nil + deps, _, _ := actionsDeps(t, server.URL, false) + err := Execute(context.Background(), deps, append([]string{"workflow", "run", "CI", "-R", "alice/project", "--ref", "main"}, tc.args...)) + require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", tc.args) + require.EqualError(t, err, tc.err) + require.Equal(t, []string{"GET actions/workflows", "GET contents/.gitea/workflows/ci.yml"}, *requests, "nothing is dispatched") + } + deps, _, _ := actionsDeps(t, server.URL, false) + require.EqualError(t, Execute(context.Background(), deps, []string{"workflow", "run", "nightly", "-R", "alice/project"}), + "workflow nightly.yaml is disabled; enable it with: tea workflow enable nightly.yaml -R alice/project") +} + +// dispatchingPrompter answers workflow inputs: selections with their first choice, confirmations +// with yes, and text by title. +type dispatchingPrompter struct { + choosingPrompter + asked []string +} + +func (p *dispatchingPrompter) Ask(_ context.Context, title string, validate func(string) error) (string, error) { + p.asked = append(p.asked, title) + answer := p.inputs[title] + return answer, validate(answer) +} + +func (p *dispatchingPrompter) AskText(context.Context, string, string) (string, error) { + return "", nil +} + +func (p *dispatchingPrompter) ConfirmDefault(_ context.Context, title string, defaultYes bool) (bool, error) { + p.asked = append(p.asked, title) + return !defaultYes, nil +} + +func TestWorkflowRunDispatchesOnTheDefaultBranch(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET ": `{"full_name":"alice/project","default_branch":"trunk"}`, + "GET actions/workflows": workflowsFixture, + "GET contents/.gitea/workflows/ci.yml": contentsOf(ciWorkflowFile), + "POST actions/workflows/ci.yml/dispatches": `{"workflow_run_id":51,"html_url":"https://gitea.test/alice/project/actions/runs/51"}`, + }) + for _, tc := range []struct { + name, out, err string + args []string + prompter *dispatchingPrompter + payload string + }{ + { + "flags", "https://gitea.test/alice/project/actions/runs/51\n", "✓ Dispatched CI (ci.yml) on trunk → run 51\n Watch it: tea run watch 51 -R alice/project\n", + []string{"-f", "note=ship", "-f", "dry_run=FALSE"}, + nil, `{"ref":"trunk","inputs":{"dry_run":"false","note":"ship"}}`, + }, + { + "prompts", "https://gitea.test/alice/project/actions/runs/51\n", "✓ Dispatched CI (ci.yml) on trunk → run 51\n Watch it: tea run watch 51 -R alice/project\n", + nil, &dispatchingPrompter{choosingPrompter: choosingPrompter{scriptedPrompter: scriptedPrompter{inputs: map[string]string{"note — Free-form note": "ship"}}}}, + `{"ref":"trunk","inputs":{"dry_run":"false","note":"ship","target":"staging"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + *requests = nil + deps, out, diagnostics := actionsDeps(t, server.URL, true) + if tc.prompter != nil { + deps.Prompter = tc.prompter + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + } + require.NoError(t, Execute(context.Background(), deps, append([]string{"workflow", "run", "ci.yml", "-R", "alice/project"}, tc.args...))) + require.Equal(t, tc.out, out.String()) + require.Equal(t, tc.err, diagnostics.String()) + require.Equal(t, []string{"GET actions/workflows", "GET ", "GET contents/.gitea/workflows/ci.yml", "POST actions/workflows/ci.yml/dispatches " + tc.payload}, *requests) + if tc.prompter != nil { + require.Equal(t, []string{"target — Deploy target"}, tc.prompter.titles) + require.Equal(t, []string{"dry_run", "note — Free-form note"}, tc.prompter.asked) + } + }) + } +} + +func TestWorkflowRunNeedsTheCheckedOutBranchOnTheServer(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET ": `{"full_name":"alice/project","default_branch":"main"}`, + "GET actions/workflows": workflowsFixture, + "GET branches/local-only": `404 {"message":"branch not found"}`, + }) + deps, _, _ := actionsDeps(t, server.URL, false) + deps.Git = &fakeGit{results: map[string]string{ + "remote": "origin", "remote get-url origin": server.URL + "/alice/project.git", + "symbolic-ref --short HEAD": "local-only", "config --get branch.local-only.remote": "origin", + }} + err := Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-f", "note=x"}) + require.EqualError(t, err, "branch local-only is not on alice/project; push it or pass --ref main") + require.Equal(t, []string{"GET actions/workflows", "GET branches/local-only", "GET "}, *requests) +} + +func TestWorkflowStateChangesNeedNoConfirmation(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "PUT actions/workflows/nightly.yaml/enable": "204 ", + "GET actions/workflows/nightly.yaml": `{"id":"nightly.yaml","name":"Nightly","path":".gitea/workflows/nightly.yaml","state":"active"}`, + }) + for _, tc := range []struct { + args []string + out, err string + requests []string + }{ + {[]string{"enable", "Nightly"}, "", "✓ Enabled Nightly (nightly.yaml)\n", []string{"GET actions/workflows", "PUT actions/workflows/nightly.yaml/enable", "GET actions/workflows/nightly.yaml"}}, + {[]string{"enable", "nightly", "--json", "state"}, `{"state":"active"}`, "", []string{"GET actions/workflows", "PUT actions/workflows/nightly.yaml/enable", "GET actions/workflows/nightly.yaml"}}, + {[]string{"disable", "NIGHTLY"}, "", "! Nightly (nightly.yaml) is already disabled\n", []string{"GET actions/workflows"}}, + } { + *requests = nil + deps, out, diagnostics := actionsDeps(t, server.URL, true) + prompter := &scriptedPrompter{} + deps.Prompter = prompter + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + require.NoError(t, Execute(context.Background(), deps, append(append([]string{"workflow"}, tc.args...), "-R", "alice/project"))) + if tc.out != "" { + require.JSONEq(t, tc.out, out.String()) + } else { + require.Empty(t, out.String()) + } + require.Equal(t, tc.err, diagnostics.String()) + require.Equal(t, tc.requests, *requests) + require.Empty(t, prompter.confirmations) + } +} + +func TestWorkflowRunFollowStreamsTheLogsOfItsJobs(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET contents/.gitea/workflows/ci.yml": contentsOf("on: workflow_dispatch\n"), + "POST actions/workflows/ci.yml/dispatches": `{"workflow_run_id":11,"html_url":"https://gitea.test/alice/project/actions/runs/11"}`, + "GET actions/runs/11/jobs": failedJobsFixture, + "GET actions/jobs/30/logs": "compiled\n", + "GET actions/jobs/31/logs": "boom\n", + }) + deps, out, diagnostics := actionsDeps(t, server.URL, false) + require.NoError(t, Execute(context.Background(), deps, []string{"workflow", "run", "ci", "-R", "alice/project", "--ref", "main", "--follow", "--interval=1ms"})) + require.Equal(t, "https://gitea.test/alice/project/actions/runs/11\nbuild\tcompiled\ntest\tboom\n", out.String()) + require.Equal(t, "build (job 30)\ntest (job 31)\n", diagnostics.String()) + err := Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "alice/project", "--ref", "main", "--follow", "--json"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.ErrorContains(t, err, "--follow cannot be combined with --json") +} + +var _ terminal.Asker = (*dispatchingPrompter)(nil) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 8167d059..2b478ed8 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -562,11 +562,6 @@ const ( wiki list wiki revisions wiki view - workflow disable - workflow enable - workflow list - workflow run - workflow view ` longsToWrite = ` alias @@ -590,6 +585,5 @@ const ( time variable wiki - workflow ` ) diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 373d8b4e..78419f78 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -245,7 +245,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"wiki", "create", "--title", "Home", "--body", "text", "--json=created_at"}, "unknown JSON field \"created_at\""}, {[]string{"wiki", "edit", "Home", "--body="}, "wiki content cannot be empty"}, {[]string{"wiki", "revisions", "Home", "--page", "0"}, `"--page" flag: must be a positive integer`}, - {[]string{"workflow", "run", "ci.yml", "-r", "main", "-f", "a=1", "-i", "a=2"}, "duplicate workflow input"}, + {[]string{"workflow", "run", "ci.yml", "-r", "main", "-f", "a=1", "-i", "a=2"}, "input a is given more than once"}, } { t.Run(strings.Join(tc.args, " "), func(t *testing.T) { requireUsageBeforeRequests(t, tc.want, tc.args...)