mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
4 files changed
+702
-108
No files matched your search
+472
-66
@@ -4,21 +4,49 @@
|
||||
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 {
|
||||
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
|
||||
@@ -27,50 +55,177 @@ func (a *application) workflowCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, items, workflowRow)
|
||||
}})
|
||||
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
|
||||
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})
|
||||
},
|
||||
}
|
||||
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 fields, inputs []string
|
||||
var follow bool
|
||||
dispatch := &cobra.Command{Use: "run <id>", 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"))
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <workflow>",
|
||||
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 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]
|
||||
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}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -78,53 +233,304 @@ func (a *application) workflowCommand() *cobra.Command {
|
||||
return a.writeJSON(cmd, result)
|
||||
}
|
||||
if result.ID == 0 {
|
||||
a.done(cmd, "Dispatched %s on %s", workflow.Label(), ref)
|
||||
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
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for run %d jobs...\n", result.ID)
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Minute)
|
||||
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, 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 {
|
||||
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)
|
||||
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=<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
|
||||
})
|
||||
}}
|
||||
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 + " <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 {
|
||||
}
|
||||
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 + " <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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if verb == "disable" {
|
||||
if err := a.confirm(cmd, "Disable workflow "+args[0]); err != nil {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return terminal.Row(item.ID, item.Name, item.State)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
+229
-35
@@ -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=<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)
|
||||
@@ -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
|
||||
`
|
||||
)
|
||||
@@ -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...)
|
||||
|
||||
Reference in new issue
Block a user