feat(workflow): address workflows by name and check dispatch inputs

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 <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:46:26 +02:00
1 parent 9f69a89537
commit 4edb836416
4 files changed
+702 -108

No files matched your search

+472 -66
View File
@@ -4,21 +4,49 @@
package app package app
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"slices"
"strconv"
"strings" "strings"
"time" "time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra" "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 { func (a *application) workflowCommand() *cobra.Command {
group := &cobra.Command{Use: "workflow", Short: "Manage Actions workflows"} group := &cobra.Command{
group.AddCommand(&cobra.Command{Use: "list", Short: "List repository workflows", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { 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) t, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
@@ -27,50 +55,177 @@ func (a *application) workflowCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
return writeList(a, cmd, items, workflowRow) 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})
group.AddCommand(&cobra.Command{Use: "view <id>", 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)
}}) // 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.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 var ref string
var fields, inputs []string cmd := &cobra.Command{
var follow bool Use: "view <workflow>",
dispatch := &cobra.Command{Use: "run <id>", Short: "Dispatch a workflow", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { Short: "View a workflow and its recent runs",
if follow && a.options.json != "" { Example: `# view a workflow and its recent runs
return clierr.Wrap(clierr.Usage, errors.New("--follow cannot be combined with --json/--jq")) $ 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 ref == "" { if showYAML && (web || a.options.json != "") {
branch, err := a.currentBranch(cmd.Context()) return clierr.Wrap(clierr.Usage, errors.New("--yaml cannot be combined with --web, --json or --jq"))
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) t, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
service := services.Actions{Client: t.client, Repository: t.Repo} service := services.Actions{Client: t.client, Repository: t.Repo}
result, err := service.Dispatch(cmd.Context(), args[0], ref, values) 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 <workflow>",
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 { if err != nil {
return err return err
} }
@@ -78,53 +233,304 @@ func (a *application) workflowCommand() *cobra.Command {
return a.writeJSON(cmd, result) return a.writeJSON(cmd, result)
} }
if result.ID == 0 { if result.ID == 0 {
a.done(cmd, "Dispatched %s on %s", workflow.Label(), ref)
if follow { if follow {
return errors.New("server did not return a run ID; cannot follow logs") return errors.New("the server did not report the run it started; cannot follow its 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 return nil
} }
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for run %d jobs...\n", result.ID) a.done(cmd, "Dispatched %s on %s → run %d", workflow.Label(), ref, result.ID)
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Minute) 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() defer cancel()
job, err := service.WaitForFirstJob(ctx, result.ID, 2*time.Second) 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 { if err != nil {
return err return err
} }
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Following job %d (%s)\n", job.ID, job.Name) if len(jobs) == 0 {
return service.FollowJobLogs(ctx, result.ID, job.ID, 2*time.Second, func(chunk []byte) error { jobs = []services.Job{job}
_, err := cmd.OutOrStdout().Write(chunk) }
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=<value>", 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 return err
}) }
}} var answer string
dispatch.Flags().StringVarP(&ref, "ref", "r", "", "Branch or tag to run (default: current branch)") var err error
dispatch.Flags().StringArrayVarP(&fields, "field", "f", nil, "Workflow input key=value (repeatable)") if rich {
dispatch.Flags().StringArrayVarP(&inputs, "input", "i", nil, "Alias for --field") answer, err = asker.Ask(cmd.Context(), title, validate)
dispatch.Flags().BoolVar(&follow, "follow", false, "Follow the first job's logs after dispatch") } else if answer, err = a.deps.Prompter.Input(cmd.Context(), title); err == nil {
group.AddCommand(dispatch) err = validate(answer)
for _, verb := range []string{"enable", "disable"} { }
group.AddCommand(&cobra.Command{Use: verb + " <id>", 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 { 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 + " <workflow>",
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) t, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
if verb == "disable" { service := services.Actions{Client: t.client, Repository: t.Repo}
if err := a.confirm(cmd, "Disable workflow "+args[0]); err != nil { workflow, err := service.FindWorkflow(cmd.Context(), args[0])
if err != nil {
return err 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
} }
return (services.Actions{Client: t.client, Repository: t.Repo}).SetWorkflowEnabled(cmd.Context(), args[0], verb == "enable") 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
},
} }
return group
} }
func workflowRow(item services.Workflow) []terminal.Cell { // completeWorkflowArg completes the workflow argument with workflow files.
return terminal.Row(item.ID, item.Name, item.State) 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
}
} }
+229 -35
View File
@@ -5,46 +5,240 @@ package app
import ( import (
"context" "context"
"net/http" "encoding/base64"
"encoding/json"
"strings"
"testing" "testing"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) { const ciWorkflowFile = `name: CI
jobPolls := 0 on:
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { push:
switch { workflow_dispatch:
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/actions/workflows/ci.yml/dispatches": inputs:
require.Equal(t, "true", r.URL.Query().Get("return_run_details")) target:
_, _ = w.Write([]byte(`{"workflow_run_id":11,"html_url":"https://gitea.test/owner/repo/actions/runs/11"}`)) description: Deploy target
case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11/jobs": required: true
jobPolls++ default: staging
if r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1" { type: choice
_, _ = w.Write([]byte(`{"jobs":[]}`)) options: [staging, production]
return dry_run:
} type: boolean
_, _ = w.Write([]byte(`{"jobs":[{"id":21,"name":"build","status":"completed","conclusion":"success"}]}`)) default: true
case r.URL.Path == "/api/v1/repos/owner/repo/actions/jobs/21/logs": note:
_, _ = w.Write([]byte("hello from job\n")) description: Free-form note
case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11": required: true
_, _ = w.Write([]byte(`{"id":11,"status":"completed","conclusion":"success","display_title":"ci"}`)) jobs: {}
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)
err := Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "owner/repo", "--ref", "main", "--follow", "--json", "--no-input"}) // contentsOf is the contents API response for a file.
require.Error(t, err) func contentsOf(content string) string {
require.Equal(t, clierr.Usage, clierr.ExitCode(err)) data, _ := json.Marshal(map[string]string{"type": "file", "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content))})
require.Contains(t, err.Error(), "--follow cannot be combined with --json") 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=<value>"},
{nil, "missing required input note for CI (ci.yml); pass -f note=<value>"},
} {
*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)
-6
View File
@@ -562,11 +562,6 @@ const (
wiki list wiki list
wiki revisions wiki revisions
wiki view wiki view
workflow disable
workflow enable
workflow list
workflow run
workflow view
` `
longsToWrite = ` longsToWrite = `
alias alias
@@ -590,6 +585,5 @@ const (
time time
variable variable
wiki wiki
workflow
` `
) )
+1 -1
View File
@@ -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", "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", "edit", "Home", "--body="}, "wiki content cannot be empty"},
{[]string{"wiki", "revisions", "Home", "--page", "0"}, `"--page" flag: must be a positive integer`}, {[]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) { t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
requireUsageBeforeRequests(t, tc.want, tc.args...) requireUsageBeforeRequests(t, tc.want, tc.args...)