From 53e53e10676dba9650d50ba1aa99bdc5fe56220a Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 9 Apr 2026 20:03:33 +0000 Subject: [PATCH] feat(workflows): add dispatch, view, enable and disable subcommands (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 (new) ├── dispatch (new) ├── enable (new) └── disable (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 --ref main` triggers a run - [x] Manual test: `tea actions workflows view ` shows details --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/952 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/actions/workflows.go | 4 + cmd/actions/workflows/disable.go | 65 +++++++++++ cmd/actions/workflows/dispatch.go | 174 +++++++++++++++++++++++++++++ cmd/actions/workflows/enable.go | 48 ++++++++ cmd/actions/workflows/list.go | 59 ++-------- cmd/actions/workflows/view.go | 50 +++++++++ docs/CLI.md | 56 +++++++++- docs/example-workflows.md | 87 ++++++++++++++- modules/print/actions_runs.go | 53 ++++++--- modules/print/actions_runs_test.go | 81 ++++++++++++++ 10 files changed, 611 insertions(+), 66 deletions(-) create mode 100644 cmd/actions/workflows/disable.go create mode 100644 cmd/actions/workflows/dispatch.go create mode 100644 cmd/actions/workflows/enable.go create mode 100644 cmd/actions/workflows/view.go diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go index 440783e..1dc593a 100644 --- a/cmd/actions/workflows.go +++ b/cmd/actions/workflows.go @@ -20,6 +20,10 @@ var CmdActionsWorkflows = cli.Command{ Action: runWorkflowsDefault, Commands: []*cli.Command{ &workflows.CmdWorkflowsList, + &workflows.CmdWorkflowsView, + &workflows.CmdWorkflowsDispatch, + &workflows.CmdWorkflowsEnable, + &workflows.CmdWorkflowsDisable, }, } diff --git a/cmd/actions/workflows/disable.go b/cmd/actions/workflows/disable.go new file mode 100644 index 0000000..b707ca9 --- /dev/null +++ b/cmd/actions/workflows/disable.go @@ -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: "", + 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 +} diff --git a/cmd/actions/workflows/dispatch.go b/cmd/actions/workflows/dispatch.go new file mode 100644 index 0000000..2d24171 --- /dev/null +++ b/cmd/actions/workflows/dispatch.go @@ -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: "", + 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 +} diff --git a/cmd/actions/workflows/enable.go b/cmd/actions/workflows/enable.go new file mode 100644 index 0000000..7ca3af1 --- /dev/null +++ b/cmd/actions/workflows/enable.go @@ -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: "", + 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 +} diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go index 4cdf10c..a661cbc 100644 --- a/cmd/actions/workflows/list.go +++ b/cmd/actions/workflows/list.go @@ -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) } diff --git a/cmd/actions/workflows/view.go b/cmd/actions/workflows/view.go new file mode 100644 index 0000000..d05901f --- /dev/null +++ b/cmd/actions/workflows/view.go @@ -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: "", + 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 +} diff --git a/docs/CLI.md b/docs/CLI.md index bbfe584..e9e8621 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 diff --git a/docs/example-workflows.md b/docs/example-workflows.md index 018b692..c64fd59 100644 --- a/docs/example-workflows.md +++ b/docs/example-workflows.md @@ -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: diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go index ff9398f..aaf3799 100644 --- a/modules/print/actions_runs.go +++ b/modules/print/actions_runs.go @@ -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) + } + } +} diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go index 8cf5693..5a3a263 100644 --- a/modules/print/actions_runs_test.go +++ b/modules/print/actions_runs_test.go @@ -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()