feat(workflows): add dispatch, view, enable and disable subcommands (#952)

## Summary

- Add `tea actions workflows dispatch` to trigger `workflow_dispatch` events with `--ref`, `--input key=value`, and `--follow` for log tailing
- Add `tea actions workflows view` to show workflow details
- Add `tea actions workflows enable` and `disable` to toggle workflow state
- Rewrite `workflows list` to use the Workflow API instead of file listing
- Remove dead `WorkflowsList` print function that used `ContentsResponse`
- Update `CLI.md` and `example-workflows.md` with usage documentation and examples

## Motivation

Enable re-triggering specific workflows from the CLI, which is essential for AI-driven PR flows where a specific workflow needs to be re-run after pushing changes.

Leverages the 5 workflow API endpoints already supported by the Go SDK (v0.24.1) from go-gitea/gitea#33545:
- `ListRepoActionWorkflows`
- `GetRepoActionWorkflow`
- `DispatchRepoActionWorkflow` (with `returnRunDetails` support)
- `EnableRepoActionWorkflow`
- `DisableRepoActionWorkflow`

## New commands

\`\`\`
tea actions workflows
├── list          (rewritten to use Workflow API)
├── view <id>     (new)
├── dispatch <id> (new)
├── enable <id>   (new)
└── disable <id>  (new)
\`\`\`

### Usage examples

\`\`\`bash
# Dispatch workflow on current branch
tea actions workflows dispatch deploy.yml

# Dispatch with specific ref and inputs
tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3

# Dispatch and follow logs
tea actions workflows dispatch ci.yml --ref feature/my-pr --follow

# View workflow details
tea actions workflows view deploy.yml

# Enable/disable workflows
tea actions workflows enable deploy.yml
tea actions workflows disable deploy.yml --confirm
\`\`\`

## Test plan

- [x] `go build ./...` passes
- [x] `go test ./...` passes
- [x] `go vet ./...` passes
- [x] `make lint` — 0 issues
- [x] `make docs-check` — CLI.md is up to date
- [x] Manual test: `tea actions workflows list` shows workflows from API
- [x] Manual test: `tea actions workflows dispatch <workflow> --ref main` triggers a run
- [x] Manual test: `tea actions workflows view <workflow>` shows details

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/952
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
This commit is contained in:
Bo-Yi Wu
2026-04-09 20:03:33 +00:00
committed by Lunny Xiao
parent 0489d8c275
commit 53e53e1067
10 changed files with 611 additions and 66 deletions

View File

@@ -20,6 +20,10 @@ var CmdActionsWorkflows = cli.Command{
Action: runWorkflowsDefault,
Commands: []*cli.Command{
&workflows.CmdWorkflowsList,
&workflows.CmdWorkflowsView,
&workflows.CmdWorkflowsDispatch,
&workflows.CmdWorkflowsEnable,
&workflows.CmdWorkflowsDisable,
},
}

View File

@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsDisable represents a sub command to disable a workflow
var CmdWorkflowsDisable = cli.Command{
Name: "disable",
Usage: "Disable a workflow",
Description: "Disable a workflow in the repository",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsDisable,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm disable without prompting",
},
}, flags.AllDefaultFlags...),
}
func runWorkflowsDisable(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to disable workflow %s? [y/N] ", workflowID)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Disable canceled.")
return nil
}
}
_, err = client.DisableRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to disable workflow: %w", err)
}
fmt.Printf("Workflow %s disabled successfully\n", workflowID)
return nil
}

View File

@@ -0,0 +1,174 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"strings"
"time"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsDispatch represents a sub command to dispatch a workflow
var CmdWorkflowsDispatch = cli.Command{
Name: "dispatch",
Aliases: []string{"trigger", "run"},
Usage: "Dispatch a workflow run",
Description: "Trigger a workflow_dispatch event for a workflow",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsDispatch,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "ref",
Aliases: []string{"r"},
Usage: "branch or tag to dispatch on (default: current branch)",
},
&cli.StringSliceFlag{
Name: "input",
Aliases: []string{"i"},
Usage: "workflow input in key=value format (can be specified multiple times)",
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "follow log output after dispatching",
},
}, flags.AllDefaultFlags...),
}
func runWorkflowsDispatch(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
ref := cmd.String("ref")
if ref == "" {
if c.LocalRepo != nil {
branchName, _, localErr := c.LocalRepo.TeaGetCurrentBranchNameAndSHA()
if localErr == nil && branchName != "" {
ref = branchName
}
}
if ref == "" {
return fmt.Errorf("--ref is required (no local branch detected)")
}
}
inputs := make(map[string]string)
for _, input := range cmd.StringSlice("input") {
key, value, ok := strings.Cut(input, "=")
if !ok {
return fmt.Errorf("invalid input format %q, expected key=value", input)
}
inputs[key] = value
}
opt := gitea.CreateActionWorkflowDispatchOption{
Ref: ref,
Inputs: inputs,
}
details, _, err := client.DispatchRepoActionWorkflow(c.Owner, c.Repo, workflowID, opt, true)
if err != nil {
return fmt.Errorf("failed to dispatch workflow: %w", err)
}
print.ActionWorkflowDispatchResult(details)
if cmd.Bool("follow") && details != nil && details.WorkflowRunID > 0 {
return followDispatchedRun(client, c, details.WorkflowRunID)
}
return nil
}
const (
followPollInterval = 2 * time.Second
followMaxDuration = 30 * time.Minute
)
// followDispatchedRun waits for the dispatched run to start, then follows its logs
func followDispatchedRun(client *gitea.Client, c *context.TeaContext, runID int64) error {
fmt.Printf("\nWaiting for run %d to start...\n", runID)
var jobs *gitea.ActionWorkflowJobsResponse
for range 30 {
time.Sleep(followPollInterval)
var err error
jobs, _, err = client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{})
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if len(jobs.Jobs) > 0 {
break
}
}
if jobs == nil || len(jobs.Jobs) == 0 {
return fmt.Errorf("timed out waiting for jobs to appear")
}
jobID := jobs.Jobs[0].ID
jobName := jobs.Jobs[0].Name
fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID)
fmt.Println("---")
deadline := time.Now().Add(followMaxDuration)
var lastLogLength int
for time.Now().Before(deadline) {
job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending"
logs, _, logErr := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID)
if logErr != nil && isRunning {
time.Sleep(followPollInterval)
continue
}
if logErr == nil && len(logs) > lastLogLength {
fmt.Print(string(logs[lastLogLength:]))
lastLogLength = len(logs)
}
if !isRunning {
if logErr != nil {
fmt.Printf("\n---\nJob completed with status: %s (failed to fetch final logs: %v)\n", job.Status, logErr)
} else {
fmt.Printf("\n---\nJob completed with status: %s\n", job.Status)
}
break
}
time.Sleep(followPollInterval)
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s following logs", followMaxDuration)
}
return nil
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsEnable represents a sub command to enable a workflow
var CmdWorkflowsEnable = cli.Command{
Name: "enable",
Usage: "Enable a workflow",
Description: "Enable a disabled workflow in the repository",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsEnable,
Flags: flags.AllDefaultFlags,
}
func runWorkflowsEnable(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
_, err = client.EnableRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to enable workflow: %w", err)
}
fmt.Printf("Workflow %s enabled successfully\n", workflowID)
return nil
}

View File

@@ -6,8 +6,6 @@ package workflows
import (
stdctx "context"
"fmt"
"path/filepath"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
@@ -22,15 +20,12 @@ var CmdWorkflowsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List repository workflows",
Description: "List workflow files in the repository with active/inactive status",
Description: "List workflows in the repository with their status",
Action: RunWorkflowsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
Flags: flags.AllDefaultFlags,
}
// RunWorkflowsList lists workflow files in the repository
// RunWorkflowsList lists workflows in the repository using the workflow API
func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
@@ -41,51 +36,15 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
}
client := c.Login.Client()
// Try to list workflow files from .gitea/workflows directory
var workflows []*gitea.ContentsResponse
// Try .gitea/workflows first, then .github/workflows
workflowDir := ".gitea/workflows"
contents, _, err := client.ListContents(c.Owner, c.Repo, "", workflowDir)
resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo)
if err != nil {
workflowDir = ".github/workflows"
contents, _, err = client.ListContents(c.Owner, c.Repo, "", workflowDir)
if err != nil {
fmt.Printf("No workflow files found\n")
return nil
}
return fmt.Errorf("failed to list workflows: %w", err)
}
// Filter for workflow files (.yml and .yaml)
for _, content := range contents {
if content.Type == "file" {
ext := strings.ToLower(filepath.Ext(content.Name))
if ext == ".yml" || ext == ".yaml" {
content.Path = workflowDir + "/" + content.Name
workflows = append(workflows, content)
}
}
var workflows []*gitea.ActionWorkflow
if resp != nil {
workflows = resp.Workflows
}
if len(workflows) == 0 {
fmt.Printf("No workflow files found\n")
return nil
}
// Check which workflows have runs to determine active status
workflowStatus := make(map[string]bool)
// Get recent runs to check activity
runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err == nil && runs != nil {
for _, run := range runs.WorkflowRuns {
// Extract workflow file name from path
workflowFile := filepath.Base(run.Path)
workflowStatus[workflowFile] = true
}
}
return print.WorkflowsList(workflows, workflowStatus, c.Output)
return print.ActionWorkflowsList(workflows, c.Output)
}

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsView represents a sub command to view workflow details
var CmdWorkflowsView = cli.Command{
Name: "view",
Aliases: []string{"show", "get"},
Usage: "View workflow details",
Description: "View details of a specific workflow",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsView,
Flags: flags.AllDefaultFlags,
}
func runWorkflowsView(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
wf, _, err := client.GetRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to get workflow: %w", err)
}
print.ActionWorkflowDetails(wf)
return nil
}

View File

@@ -1573,13 +1573,65 @@ Manage repository workflows
List repository workflows
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### view, show, get
View workflow details
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--page, -p**="": specify page (default: 1)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### dispatch, trigger, run
Dispatch a workflow run
**--follow, -f**: follow log output after dispatching
**--input, -i**="": workflow input in key=value format (can be specified multiple times)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--ref, -r**="": branch or tag to dispatch on (default: current branch)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### enable
Enable a workflow
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### disable
Disable a workflow
**--confirm, -y**: confirm disable without prompting
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional

View File

@@ -1,8 +1,93 @@
# Gitea actions workflows
## Workflow management with tea
### List workflows
```bash
# List all workflows in the repository
tea actions workflows list
```
### View workflow details
```bash
# View details of a specific workflow by ID or filename
tea actions workflows view deploy.yml
```
### Dispatch (trigger) a workflow
```bash
# Dispatch a workflow on the current branch
tea actions workflows dispatch deploy.yml
# Dispatch on a specific branch
tea actions workflows dispatch deploy.yml --ref main
# Dispatch with workflow inputs
tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3
# Dispatch and follow log output
tea actions workflows dispatch ci.yml --ref feature/my-pr --follow
```
### Enable / disable workflows
```bash
# Disable a workflow
tea actions workflows disable deploy.yml --confirm
# Enable a workflow
tea actions workflows enable deploy.yml
```
## Example: Re-trigger CI from an AI-driven PR flow
Use `tea actions workflows dispatch` to re-run a specific workflow after
pushing changes in an automated PR workflow:
```bash
# Push changes to a feature branch, then re-trigger CI
git push origin feature/auto-fix
tea actions workflows dispatch check-and-test --ref feature/auto-fix --follow
```
## Example: Dispatch a workflow with `workflow_dispatch` trigger
```yaml
name: deploy
on:
workflow_dispatch:
inputs:
env:
description: "Target environment"
required: true
default: "staging"
version:
description: "Version to deploy"
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Deploy
run: |
echo "Deploying version ${{ gitea.event.inputs.version }} to ${{ gitea.event.inputs.env }}"
```
Trigger this workflow from the CLI:
```bash
tea actions workflows dispatch deploy.yml --ref main --input env=production --input version=2.0.0
```
## Merge Pull request on approval
``` Yaml
```yaml
---
name: Pull request
on:

View File

@@ -154,27 +154,23 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) erro
return t.print(output)
}
// WorkflowsList prints a list of workflow files with active status
func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error {
// ActionWorkflowsList prints a list of workflows from the workflow API
func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
t := table{
headers: []string{
"Active",
"ID",
"Name",
"Path",
"State",
},
}
machineReadable := isMachineReadable(output)
for _, workflow := range workflows {
// Check if this workflow file is active (has runs)
isActive := activeStatus[workflow.Name]
activeIndicator := formatBoolean(isActive, !machineReadable)
for _, wf := range workflows {
t.addRow(
activeIndicator,
workflow.Name,
workflow.Path,
wf.ID,
wf.Name,
wf.Path,
wf.State,
)
}
@@ -186,3 +182,34 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]
t.sort(1, true) // Sort by name column
return t.print(output)
}
// ActionWorkflowDetails prints detailed information about a workflow
func ActionWorkflowDetails(wf *gitea.ActionWorkflow) {
fmt.Printf("ID: %s\n", wf.ID)
fmt.Printf("Name: %s\n", wf.Name)
fmt.Printf("Path: %s\n", wf.Path)
fmt.Printf("State: %s\n", wf.State)
if wf.HTMLURL != "" {
fmt.Printf("URL: %s\n", wf.HTMLURL)
}
if wf.BadgeURL != "" {
fmt.Printf("Badge: %s\n", wf.BadgeURL)
}
if !wf.CreatedAt.IsZero() {
fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false))
}
if !wf.UpdatedAt.IsZero() {
fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false))
}
}
// ActionWorkflowDispatchResult prints the result of a workflow dispatch
func ActionWorkflowDispatchResult(details *gitea.RunDetails) {
fmt.Printf("Workflow dispatched successfully\n")
if details != nil {
fmt.Printf("Run ID: %d\n", details.WorkflowRunID)
if details.HTMLURL != "" {
fmt.Printf("URL: %s\n", details.HTMLURL)
}
}
}

View File

@@ -123,6 +123,87 @@ func TestActionWorkflowJobsListWithData(t *testing.T) {
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
}
func TestActionWorkflowsListEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, ""))
}
func TestActionWorkflowsListWithData(t *testing.T) {
workflows := []*gitea.ActionWorkflow{
{
ID: "1",
Name: "CI",
Path: ".gitea/workflows/ci.yml",
State: "active",
},
{
ID: "2",
Name: "Deploy",
Path: ".gitea/workflows/deploy.yml",
State: "disabled_manually",
},
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList(workflows, ""))
}
func TestActionWorkflowDetails(t *testing.T) {
wf := &gitea.ActionWorkflow{
ID: "1",
Name: "CI Pipeline",
Path: ".gitea/workflows/ci.yml",
State: "active",
HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml",
BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg",
CreatedAt: time.Now().Add(-24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDetails panicked: %v", r)
}
}()
ActionWorkflowDetails(wf)
}
func TestActionWorkflowDispatchResult(t *testing.T) {
details := &gitea.RunDetails{
WorkflowRunID: 42,
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42",
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked: %v", r)
}
}()
ActionWorkflowDispatchResult(details)
}
func TestActionWorkflowDispatchResultNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r)
}
}()
ActionWorkflowDispatchResult(nil)
}
func TestFormatDurationMinutes(t *testing.T) {
now := time.Now()