From 2152d99f2d9cdc473c89890a27988c6385c1b81a Mon Sep 17 00:00:00 2001 From: yousfi saad Date: Wed, 11 Feb 2026 00:40:06 +0000 Subject: [PATCH] Add tea actions runs and workflows commands (#880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive workflow execution tracking for Gitea Actions using tea CLI ## Features ### tea actions runs list - List workflow runs with filtering (status, branch, event, actor, time) - Time filters: relative (24h, 7d) and absolute dates - Status symbols: ✓ success, ✘ failure, ⭮ pending, ⊘ skipped/cancelled, ⚠ blocked - Multiple output formats: table, json, yaml, csv, tsv ### tea actions runs view - View run details with metadata (ID, status, workflow, branch, event, trigger info) - Shows jobs table with status, runner, duration - Optional --jobs flag to toggle jobs display ### tea actions runs delete - Delete/cancel workflow runs with confirmation prompt - Supports --confirm/-y to skip prompt ### tea actions runs logs - View job logs for all jobs or specific job (--job ) - **New: --follow/-f flag for real-time log following** (like tail -f) - Polls API every 2 seconds, only shows new content - Auto-detects completion and exits ### tea actions workflows list - List workflow files (.yml and .yaml) in repository - Searches in .gitea/workflows and .github/workflows - Shows active (✓) or inactive (✗) status based on recent runs - Displays workflow name, path, and file size ## Commands `tea actions runs list --status success --since 24h` `tea actions runs view 123` `tea actions runs delete 123 --confirm` `tea actions runs logs 123 --job 456 --follow` `tea actions workflows list` ## Tests - 19 unit tests across all commands - Full test suite passing - Manual testing successful --------- Co-authored-by: Lunny Xiao Co-authored-by: techknowlogick Reviewed-on: https://gitea.com/gitea/tea/pulls/880 Reviewed-by: Lunny Xiao Co-authored-by: yousfi saad Co-committed-by: yousfi saad --- cmd/actions.go | 9 +- cmd/actions/runs.go | 31 +++++ cmd/actions/runs/delete.go | 65 ++++++++++ cmd/actions/runs/list.go | 144 ++++++++++++++++++++++ cmd/actions/runs/list_test.go | 77 ++++++++++++ cmd/actions/runs/logs.go | 169 ++++++++++++++++++++++++++ cmd/actions/runs/view.go | 75 ++++++++++++ cmd/actions/workflows.go | 28 +++++ cmd/actions/workflows/list.go | 86 +++++++++++++ docs/CLI.md | 96 +++++++++++++++ modules/print/actions_runs.go | 188 +++++++++++++++++++++++++++++ modules/print/actions_runs_test.go | 174 ++++++++++++++++++++++++++ 12 files changed, 1138 insertions(+), 4 deletions(-) create mode 100644 cmd/actions/runs.go create mode 100644 cmd/actions/runs/delete.go create mode 100644 cmd/actions/runs/list.go create mode 100644 cmd/actions/runs/list_test.go create mode 100644 cmd/actions/runs/logs.go create mode 100644 cmd/actions/runs/view.go create mode 100644 cmd/actions/workflows.go create mode 100644 cmd/actions/workflows/list.go create mode 100644 modules/print/actions_runs.go create mode 100644 modules/print/actions_runs_test.go diff --git a/cmd/actions.go b/cmd/actions.go index c6aeb0d..df5b283 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -17,11 +17,13 @@ var CmdActions = cli.Command{ Aliases: []string{"action"}, Category: catEntities, Usage: "Manage repository actions", - Description: "Manage repository actions including secrets, variables, and workflows", + Description: "Manage repository actions including secrets, variables, and workflow runs", Action: runActionsDefault, Commands: []*cli.Command{ &actions.CmdActionsSecrets, &actions.CmdActionsVariables, + &actions.CmdActionsRuns, + &actions.CmdActionsWorkflows, }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -40,7 +42,6 @@ var CmdActions = cli.Command{ }, } -func runActionsDefault(ctx stdctx.Context, cmd *cli.Command) error { - // Default to showing help - return cli.ShowCommandHelp(ctx, cmd, "actions") +func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) } diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go new file mode 100644 index 0000000..10130cf --- /dev/null +++ b/cmd/actions/runs.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/actions/runs" + + "github.com/urfave/cli/v3" +) + +// CmdActionsRuns represents the actions runs command +var CmdActionsRuns = cli.Command{ + Name: "runs", + Aliases: []string{"run"}, + Usage: "Manage workflow runs", + Description: "List, view, and manage workflow runs for repository actions", + Action: runRunsDefault, + Commands: []*cli.Command{ + &runs.CmdRunsList, + &runs.CmdRunsView, + &runs.CmdRunsDelete, + &runs.CmdRunsLogs, + }, +} + +func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return runs.RunRunsList(ctx, cmd) +} diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go new file mode 100644 index 0000000..5e7d9eb --- /dev/null +++ b/cmd/actions/runs/delete.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdRunsDelete represents a sub command to delete/cancel workflow runs +var CmdRunsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"remove", "rm", "cancel"}, + Usage: "Delete or cancel a workflow run", + Description: "Delete (cancel) a workflow run from the repository", + ArgsUsage: "", + Action: runRunsDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete run %d? [y/N] ", runID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.DeleteRepoActionRun(c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to delete run: %w", err) + } + + fmt.Printf("Run %d deleted successfully\n", runID) + return nil +} diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go new file mode 100644 index 0000000..8e3cdb0 --- /dev/null +++ b/cmd/actions/runs/list.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "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" +) + +// CmdRunsList represents a sub command to list workflow runs +var CmdRunsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List workflow runs", + Description: "List workflow runs for repository actions with optional filtering", + Action: RunRunsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + &cli.StringFlag{ + Name: "status", + Usage: "Filter by status (success, failure, pending, queued, in_progress, skipped, canceled)", + }, + &cli.StringFlag{ + Name: "branch", + Usage: "Filter by branch name", + }, + &cli.StringFlag{ + Name: "event", + Usage: "Filter by event type (push, pull_request, etc.)", + }, + &cli.StringFlag{ + Name: "actor", + Usage: "Filter by actor username (who triggered the run)", + }, + &cli.StringFlag{ + Name: "since", + Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')", + }, + &cli.StringFlag{ + Name: "until", + Usage: "Show runs started before this time (e.g., '2024-01-01')", + }, + }, flags.AllDefaultFlags...), +} + +// parseTimeFlag parses time flags like "24h" or "2024-01-01" +func parseTimeFlag(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + + // Try parsing as duration (e.g., "24h", "168h") + if duration, err := time.ParseDuration(value); err == nil { + return time.Now().Add(-duration), nil + } + + // Try parsing as date + formats := []string{ + "2006-01-02", + "2006-01-02 15:04", + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time: %s", value) +} + +// RunRunsList lists workflow runs +func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + client := c.Login.Client() + + // Parse time filters + since, err := parseTimeFlag(cmd.String("since")) + if err != nil { + return fmt.Errorf("invalid --since value: %w", err) + } + + until, err := parseTimeFlag(cmd.String("until")) + if err != nil { + return fmt.Errorf("invalid --until value: %w", err) + } + + // Build list options + listOpts := flags.GetListOptions() + + runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ + ListOptions: listOpts, + Status: cmd.String("status"), + Branch: cmd.String("branch"), + Event: cmd.String("event"), + Actor: cmd.String("actor"), + }) + if err != nil { + return err + } + + if runs == nil { + print.ActionRunsList(nil, c.Output) + return nil + } + + // Filter by time if specified + filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) + + print.ActionRunsList(filteredRuns, c.Output) + return nil +} + +// filterRunsByTime filters runs based on time range +func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun { + if since.IsZero() && until.IsZero() { + return runs + } + + var filtered []*gitea.ActionWorkflowRun + for _, run := range runs { + if !since.IsZero() && run.StartedAt.Before(since) { + continue + } + if !until.IsZero() && run.StartedAt.After(until) { + continue + } + filtered = append(filtered, run) + } + + return filtered +} diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go new file mode 100644 index 0000000..176eef7 --- /dev/null +++ b/cmd/actions/runs/list_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + "testing" + "time" + + "code.gitea.io/sdk/gitea" +) + +func TestFilterRunsByTime(t *testing.T) { + now := time.Now() + runs := []*gitea.ActionWorkflowRun{ + {ID: 1, StartedAt: now.Add(-1 * time.Hour)}, + {ID: 2, StartedAt: now.Add(-2 * time.Hour)}, + {ID: 3, StartedAt: now.Add(-3 * time.Hour)}, + {ID: 4, StartedAt: now.Add(-4 * time.Hour)}, + {ID: 5, StartedAt: now.Add(-5 * time.Hour)}, + } + + tests := []struct { + name string + since time.Time + until time.Time + expected []int64 + }{ + { + name: "no filter", + since: time.Time{}, + until: time.Time{}, + expected: []int64{1, 2, 3, 4, 5}, + }, + { + name: "since 2.5 hours ago", + since: now.Add(-150 * time.Minute), + until: time.Time{}, + expected: []int64{1, 2}, + }, + { + name: "until 2.5 hours ago", + since: time.Time{}, + until: now.Add(-150 * time.Minute), + expected: []int64{3, 4, 5}, + }, + { + name: "between 2 and 4 hours ago", + since: now.Add(-4 * time.Hour), + until: now.Add(-2 * time.Hour), + expected: []int64{2, 3, 4}, + }, + { + name: "filter excludes all", + since: now.Add(-30 * time.Minute), + until: time.Time{}, + expected: []int64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterRunsByTime(runs, tt.since, tt.until) + + if len(result) != len(tt.expected) { + t.Errorf("filterRunsByTime() returned %d runs, want %d", len(result), len(tt.expected)) + return + } + + for i, run := range result { + if run.ID != tt.expected[i] { + t.Errorf("filterRunsByTime()[%d].ID = %d, want %d", i, run.ID, tt.expected[i]) + } + } + }) + } +} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go new file mode 100644 index 0000000..7a2637e --- /dev/null +++ b/cmd/actions/runs/logs.go @@ -0,0 +1,169 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + "time" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdRunsLogs represents a sub command to view workflow run logs +var CmdRunsLogs = cli.Command{ + Name: "logs", + Aliases: []string{"log"}, + Usage: "View workflow run logs", + Description: "View logs for a workflow run or specific job", + ArgsUsage: "", + Action: runRunsLogs, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "job", + Usage: "specific job ID to view logs for (if omitted, shows all jobs)", + }, + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "follow log output (like tail -f), requires job to be in progress", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Check if follow mode is enabled + follow := cmd.Bool("follow") + + // If specific job ID provided, fetch only that job's logs + jobIDStr := cmd.String("job") + if jobIDStr != "" { + jobID, err := strconv.ParseInt(jobIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobIDStr) + } + + if follow { + return followJobLogs(client, c, jobID, "") + } + + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs for job %d: %w", jobID, err) + } + + fmt.Printf("Logs for job %d:\n", jobID) + fmt.Printf("---\n%s\n", string(logs)) + return nil + } + + // Otherwise, fetch all jobs and their logs + jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if len(jobs.Jobs) == 0 { + fmt.Printf("No jobs found for run %d\n", runID) + return nil + } + + // If following and multiple jobs, require --job flag + if follow && len(jobs.Jobs) > 1 { + return fmt.Errorf("--follow requires --job when run has multiple jobs (found %d jobs)", len(jobs.Jobs)) + } + + // If following with single job, follow it + if follow && len(jobs.Jobs) == 1 { + return followJobLogs(client, c, jobs.Jobs[0].ID, jobs.Jobs[0].Name) + } + + // Fetch logs for each job + for i, job := range jobs.Jobs { + if i > 0 { + fmt.Println() + } + + fmt.Printf("Job: %s (ID: %d)\n", job.Name, job.ID) + fmt.Printf("Status: %s\n", job.Status) + fmt.Println("---") + + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, job.ID) + if err != nil { + fmt.Printf("Error fetching logs: %v\n", err) + continue + } + + fmt.Println(string(logs)) + } + + return nil +} + +// followJobLogs continuously fetches and displays logs for a running job +func followJobLogs(client *gitea.Client, c *context.TeaContext, jobID int64, jobName string) error { + var lastLogLength int + + if jobName != "" { + fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID) + } else { + fmt.Printf("Following logs for job %d (press Ctrl+C to stop)...\n", jobID) + } + fmt.Println("---") + + for { + // Fetch job status + job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + // Check if job is still running + isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending" + + // Fetch logs + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs: %w", err) + } + + // Display new content only + if len(logs) > lastLogLength { + newLogs := string(logs)[lastLogLength:] + fmt.Print(newLogs) + lastLogLength = len(logs) + } + + // If job is complete, exit + if !isRunning { + fmt.Printf("\n---\nJob completed with status: %s\n", job.Status) + break + } + + // Wait before next poll + time.Sleep(2 * time.Second) + } + + return nil +} diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go new file mode 100644 index 0000000..621ef67 --- /dev/null +++ b/cmd/actions/runs/view.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + "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" +) + +// CmdRunsView represents a sub command to view workflow run details +var CmdRunsView = cli.Command{ + Name: "view", + Aliases: []string{"show", "get"}, + Usage: "View workflow run details", + Description: "View details of a specific workflow run including jobs", + ArgsUsage: "", + Action: runRunsView, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "jobs", + Usage: "show jobs table", + Value: true, + }, + }, flags.AllDefaultFlags...), +} + +func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Fetch run details + run, _, err := client.GetRepoActionRun(c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + // Print run details + print.ActionRunDetails(run) + + // Fetch and print jobs if requested + if cmd.Bool("jobs") { + jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if jobs != nil && len(jobs.Jobs) > 0 { + fmt.Printf("\nJobs:\n\n") + print.ActionWorkflowJobsList(jobs.Jobs, c.Output) + } + } + + return nil +} diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go new file mode 100644 index 0000000..440783e --- /dev/null +++ b/cmd/actions/workflows.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/actions/workflows" + + "github.com/urfave/cli/v3" +) + +// CmdActionsWorkflows represents the actions workflows command +var CmdActionsWorkflows = cli.Command{ + Name: "workflows", + Aliases: []string{"workflow"}, + Usage: "Manage repository workflows", + Description: "List and manage repository action workflows", + Action: runWorkflowsDefault, + Commands: []*cli.Command{ + &workflows.CmdWorkflowsList, + }, +} + +func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return workflows.RunWorkflowsList(ctx, cmd) +} diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go new file mode 100644 index 0000000..d40be22 --- /dev/null +++ b/cmd/actions/workflows/list.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + "path/filepath" + "strings" + + "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" +) + +// CmdWorkflowsList represents a sub command to list workflows +var CmdWorkflowsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List repository workflows", + Description: "List workflow files in the repository with active/inactive status", + Action: RunWorkflowsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunWorkflowsList lists workflow files in the repository +func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + 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) + 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 + } + } + + // 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) + } + } + } + + 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(), + }) + if err == nil && runs != nil { + for _, run := range runs.WorkflowRuns { + // Extract workflow file name from path + workflowFile := filepath.Base(run.Path) + workflowStatus[workflowFile] = true + } + } + + print.WorkflowsList(workflows, workflowStatus, c.Output) + return nil +} diff --git a/docs/CLI.md b/docs/CLI.md index 2bca4e0..c5ab769 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1389,6 +1389,102 @@ Delete an action variable **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### runs, run + +Manage workflow runs + +#### list, ls + +List workflow runs + +**--actor**="": Filter by actor username (who triggered the run) + +**--branch**="": Filter by branch name + +**--event**="": Filter by event type (push, pull_request, etc.) + +**--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) + +**--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 + +**--since**="": Show runs started after this time (e.g., '24h', '2024-01-01') + +**--status**="": Filter by status (success, failure, pending, queued, in_progress, skipped, canceled) + +**--until**="": Show runs started before this time (e.g., '2024-01-01') + +#### view, show, get + +View workflow run details + +**--jobs**: show jobs table + +**--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 + +#### delete, remove, rm, cancel + +Delete or cancel a workflow run + +**--confirm, -y**: confirm deletion 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 + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### logs, log + +View workflow run logs + +**--follow, -f**: follow log output (like tail -f), requires job to be in progress + +**--job**="": specific job ID to view logs for (if omitted, shows all jobs) + +**--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 + +### workflows, workflow + +Manage repository workflows + +#### list, ls + +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) + +**--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 + ## webhooks, webhook, hooks, hook Manage webhooks diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go new file mode 100644 index 0000000..c2e2a40 --- /dev/null +++ b/modules/print/actions_runs.go @@ -0,0 +1,188 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + "time" + + "code.gitea.io/sdk/gitea" +) + +// formatDurationMinutes formats duration in a human-readable way +func formatDurationMinutes(started, completed time.Time) string { + if started.IsZero() { + return "" + } + + end := completed + if end.IsZero() { + end = time.Now() + } + + duration := end.Sub(started) + if duration < time.Minute { + return fmt.Sprintf("%ds", int(duration.Seconds())) + } + if duration < time.Hour { + return fmt.Sprintf("%dm", int(duration.Minutes())) + } + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, minutes) +} + +// getWorkflowDisplayName returns the display title or falls back to path +func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string { + if run.DisplayTitle != "" { + return run.DisplayTitle + } + return run.Path +} + +// ActionRunsList prints a list of workflow runs +func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) { + t := table{ + headers: []string{ + "ID", + "Status", + "Workflow", + "Branch", + "Event", + "Started", + "Duration", + }, + } + + machineReadable := isMachineReadable(output) + + for _, run := range runs { + workflowName := getWorkflowDisplayName(run) + duration := formatDurationMinutes(run.StartedAt, run.CompletedAt) + + t.addRow( + fmt.Sprintf("%d", run.ID), + run.Status, + workflowName, + run.HeadBranch, + run.Event, + FormatTime(run.StartedAt, machineReadable), + duration, + ) + } + + if len(runs) == 0 { + fmt.Printf("No workflow runs found\n") + return + } + + t.sort(0, true) + t.print(output) +} + +// ActionRunDetails prints detailed information about a workflow run +func ActionRunDetails(run *gitea.ActionWorkflowRun) { + workflowName := getWorkflowDisplayName(run) + + fmt.Printf("Run ID: %d\n", run.ID) + fmt.Printf("Run Number: %d\n", run.RunNumber) + fmt.Printf("Status: %s\n", run.Status) + if run.Conclusion != "" { + fmt.Printf("Conclusion: %s\n", run.Conclusion) + } + fmt.Printf("Workflow: %s\n", workflowName) + fmt.Printf("Path: %s\n", run.Path) + fmt.Printf("Branch: %s\n", run.HeadBranch) + fmt.Printf("Event: %s\n", run.Event) + fmt.Printf("Head SHA: %s\n", run.HeadSha) + fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false)) + if !run.CompletedAt.IsZero() { + fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false)) + duration := formatDurationMinutes(run.StartedAt, run.CompletedAt) + fmt.Printf("Duration: %s\n", duration) + } + if run.RunAttempt > 1 { + fmt.Printf("Attempt: %d\n", run.RunAttempt) + } + if run.Actor != nil { + fmt.Printf("Triggered by: %s\n", run.Actor.UserName) + } + if run.HTMLURL != "" { + fmt.Printf("URL: %s\n", run.HTMLURL) + } +} + +// ActionWorkflowJobsList prints a list of workflow jobs +func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) { + t := table{ + headers: []string{ + "ID", + "Name", + "Status", + "Runner", + "Started", + "Duration", + }, + } + + machineReadable := isMachineReadable(output) + + for _, job := range jobs { + duration := formatDurationMinutes(job.StartedAt, job.CompletedAt) + runner := job.RunnerName + if runner == "" { + runner = "-" + } + + t.addRow( + fmt.Sprintf("%d", job.ID), + job.Name, + job.Status, + runner, + FormatTime(job.StartedAt, machineReadable), + duration, + ) + } + + if len(jobs) == 0 { + fmt.Printf("No jobs found\n") + return + } + + t.sort(0, true) + t.print(output) +} + +// WorkflowsList prints a list of workflow files with active status +func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) { + t := table{ + headers: []string{ + "Active", + "Name", + "Path", + }, + } + + machineReadable := isMachineReadable(output) + + for _, workflow := range workflows { + // Check if this workflow file is active (has runs) + isActive := activeStatus[workflow.Name] + activeIndicator := formatBoolean(isActive, !machineReadable) + + t.addRow( + activeIndicator, + workflow.Name, + workflow.Path, + ) + } + + if len(workflows) == 0 { + fmt.Printf("No workflows found\n") + return + } + + t.sort(1, true) // Sort by name column + t.print(output) +} diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go new file mode 100644 index 0000000..cf4283e --- /dev/null +++ b/modules/print/actions_runs_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "testing" + "time" + + "code.gitea.io/sdk/gitea" +) + +func TestActionRunsListEmpty(t *testing.T) { + // Test with empty runs - should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunsList panicked with empty list: %v", r) + } + }() + + ActionRunsList([]*gitea.ActionWorkflowRun{}, "") +} + +func TestActionRunsListWithData(t *testing.T) { + runs := []*gitea.ActionWorkflowRun{ + { + ID: 1, + Status: "success", + DisplayTitle: "Test Workflow", + HeadBranch: "main", + Event: "push", + StartedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: time.Now().Add(-30 * time.Minute), + }, + { + ID: 2, + Status: "in_progress", + Path: ".gitea/workflows/test.yml", + HeadBranch: "feature", + Event: "pull_request", + StartedAt: time.Now().Add(-10 * time.Minute), + }, + } + + // Test that it doesn't panic with real data + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunsList panicked with data: %v", r) + } + }() + + ActionRunsList(runs, "") +} + +func TestActionRunDetails(t *testing.T) { + run := &gitea.ActionWorkflowRun{ + ID: 123, + RunNumber: 42, + Status: "success", + Conclusion: "success", + DisplayTitle: "Build and Test", + Path: ".gitea/workflows/ci.yml", + HeadBranch: "main", + Event: "push", + HeadSha: "abc123def456", + StartedAt: time.Now().Add(-2 * time.Hour), + CompletedAt: time.Now().Add(-1 * time.Hour), + RunAttempt: 1, + Actor: &gitea.User{ + UserName: "testuser", + }, + HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123", + } + + // Test that it doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunDetails panicked: %v", r) + } + }() + + ActionRunDetails(run) +} + +func TestActionWorkflowJobsListEmpty(t *testing.T) { + // Test with empty jobs - should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r) + } + }() + + ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "") +} + +func TestActionWorkflowJobsListWithData(t *testing.T) { + jobs := []*gitea.ActionWorkflowJob{ + { + ID: 1, + Name: "build", + Status: "success", + RunnerName: "runner-1", + StartedAt: time.Now().Add(-30 * time.Minute), + CompletedAt: time.Now().Add(-20 * time.Minute), + }, + { + ID: 2, + Name: "test", + Status: "in_progress", + RunnerName: "runner-2", + StartedAt: time.Now().Add(-5 * time.Minute), + }, + } + + // Test that it doesn't panic with real data + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowJobsList panicked with data: %v", r) + } + }() + + ActionWorkflowJobsList(jobs, "") +} + +func TestFormatDurationMinutes(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + started time.Time + completed time.Time + expected string + }{ + { + name: "zero started", + started: time.Time{}, + completed: now, + expected: "", + }, + { + name: "30 seconds", + started: now.Add(-30 * time.Second), + completed: now, + expected: "30s", + }, + { + name: "5 minutes", + started: now.Add(-5 * time.Minute), + completed: now, + expected: "5m", + }, + { + name: "in progress (no completed)", + started: now.Add(-1 * time.Hour), + completed: time.Time{}, + expected: "1h0m", + }, + { + name: "2 hours 30 minutes", + started: now.Add(-150 * time.Minute), + completed: now, + expected: "2h30m", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := formatDurationMinutes(test.started, test.completed) + if result != test.expected { + t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected) + } + }) + } +}