mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01: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
+737
-143
No files matched your search
+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)
|
||||
Reference in new issue
Block a user