mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
run list prints a table with a status icon, the workflow name, title, branch, event, elapsed time and age, and the total from the server; runs carry workflow, workflow_path and run_number in JSON. run view shows the run's state and trigger, its jobs with their steps and durations, the next steps and the run's URL; --json includes the jobs, --log prints their logs and --web opens the run. Run commands accept #N and run URLs, and ask for a recent run in a terminal when the argument is left out. run watch redraws the run on the alternate screen, then prints it with a summary; an expired --timeout names the run and suspects a missing runner when no job started. run cancel no longer asks for confirmation, rerun refuses unfinished runs up front, reruns a single job with --job and reports the new attempt, and delete names the run in its prompt. run logs follows several jobs with --follow, prefixing lines with the job's name, selects jobs by name or with --failed, and downloads the archive with --output or --dir. run download extracts the artifacts of a run by name, and artifact list shows sizes and expiry. Signed-off-by: bircni <bircni@icloud.com>
767 lines
27 KiB
Go
767 lines
27 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// runArguments describes the run references that run commands accept.
|
|
const runArguments = `A run can be supplied as its ID ("34", "#34") or its URL
|
|
("https://gitea.example.com/OWNER/REPO/actions/runs/34"). Without an argument,
|
|
tea asks for one of the repository's recent runs in a terminal.`
|
|
|
|
func (a *application) runCommand() *cobra.Command {
|
|
group := &cobra.Command{
|
|
Use: "run",
|
|
Short: "View and manage Actions workflow runs",
|
|
Long: `List, view and watch the runs of a repository's Actions workflows, read their logs, download
|
|
their artifacts, and cancel, rerun or delete them.
|
|
|
|
Start a run with tea workflow run.`,
|
|
}
|
|
group.AddCommand(a.runListCommand(), a.runViewCommand(), a.runWatchCommand(), a.runCancelCommand(), a.runRerunCommand(), a.runDeleteCommand(),
|
|
a.runJobsCommand(), a.runLogsCommand(), a.runDownloadCommand(), a.artifactCommand())
|
|
return group
|
|
}
|
|
|
|
func (a *application) runListCommand() *cobra.Command {
|
|
var filters services.RunListOptions
|
|
var created timeRange
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List workflow runs",
|
|
Long: `List the workflow runs of a repository, newest first.
|
|
|
|
--since and --until compare the time a run was created; tea scans up to 5000 runs to apply them.`,
|
|
Example: `# list recent runs
|
|
$ tea run list
|
|
# list failed runs of the CI workflow on main
|
|
$ tea run list --workflow CI --branch main --status failure
|
|
# list the runs created in the last day as JSON
|
|
$ tea run list --since 24h --json id,status,conclusion,workflow`,
|
|
Args: argsExact(0),
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
var err error
|
|
if filters.Since, filters.Until, err = created.parse(a.deps.Now()); err != nil {
|
|
return err
|
|
}
|
|
if err := filters.Validate(); err != nil {
|
|
return clierr.Wrap(clierr.Usage, err)
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.accountNote(cmd, t, "to list the runs someone triggered, use --actor")
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
opts := filters
|
|
opts.Actor = t.expandMe(opts.Actor)
|
|
if opts.Workflow != "" {
|
|
workflow, err := service.FindWorkflow(cmd.Context(), opts.Workflow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Workflow = workflow.ID
|
|
}
|
|
page, err := service.RunsPage(cmd.Context(), opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
empty := "no runs in " + t.Repo
|
|
if filters.Workflow+filters.Branch+filters.Event+filters.Actor+filters.Commit != "" || len(filters.Statuses) > 0 || filters.Since != nil || filters.Until != nil {
|
|
empty = "no runs match your filters in " + t.Repo
|
|
}
|
|
spec := listSpec[services.Run]{Columns: runColumns, Row: runRow(a.deps.Now()), Empty: empty, Context: "runs in " + t.Repo}
|
|
return writeTable(a, cmd, page.Items, spec, listPage{Number: filters.Page, More: page.More, Total: page.Total, Counted: page.Counted})
|
|
},
|
|
}
|
|
pageFlags(cmd, &filters.Limit, &filters.Page)
|
|
created.register(cmd, "since", "Runs created at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs created at or before a date, RFC3339 timestamp, or duration ago")
|
|
cmd.Flags().StringVarP(&filters.Workflow, "workflow", "w", "", "Filter by workflow file or name")
|
|
cmd.Flags().StringVarP(&filters.Branch, "branch", "b", "", "Filter by branch")
|
|
cmd.Flags().StringVarP(&filters.Event, "event", "e", "", "Filter by the event that triggered the run, such as push")
|
|
cmd.Flags().StringVarP(&filters.Actor, "actor", "u", "", "Filter by the user who triggered the run (@me for yourself)")
|
|
cmd.Flags().StringVarP(&filters.Commit, "commit", "c", "", "Filter by the SHA of the commit that triggered the run")
|
|
cmd.Flags().StringSliceVarP(&filters.Statuses, "status", "s", nil, "Filter by status or conclusion")
|
|
enumValues(cmd, "status", services.RunStatuses...)
|
|
_ = cmd.RegisterFlagCompletionFunc("workflow", a.completeWorkflows())
|
|
_ = cmd.RegisterFlagCompletionFunc("branch", a.completeBranches())
|
|
_ = cmd.RegisterFlagCompletionFunc("event", completeValues(runEvents...))
|
|
_ = cmd.RegisterFlagCompletionFunc("actor", a.completeUsers())
|
|
return cmd
|
|
}
|
|
|
|
// runEvents are the events that commonly trigger runs, for completion.
|
|
var runEvents = []string{"push", "pull_request", "pull_request_target", "workflow_dispatch", "schedule", "release", "create", "delete", "issues", "issue_comment"}
|
|
|
|
// runColumns head runRow: an icon on a terminal, the status and conclusion words for scripts.
|
|
var runColumns = []terminal.Column{
|
|
{Header: "Status", TTYOnly: true},
|
|
{Header: "Status", PipedOnly: true},
|
|
{Header: "Conclusion", PipedOnly: true},
|
|
{Header: "ID"},
|
|
{Header: "Workflow"},
|
|
{Header: "Title", Flex: true},
|
|
{Header: "Branch", Flex: true},
|
|
{Header: "Event"},
|
|
{Header: "Elapsed"},
|
|
{Header: "Age"},
|
|
{Header: "URL", PipedOnly: true},
|
|
}
|
|
|
|
func runRow(now time.Time) func(services.Run) []terminal.Cell {
|
|
return func(run services.Run) []terminal.Cell {
|
|
icon := terminal.StatusIcon(run.Status, run.Conclusion)
|
|
icon.Link = run.URL
|
|
return []terminal.Cell{
|
|
icon,
|
|
{Text: run.Status},
|
|
{Text: run.Conclusion},
|
|
{Text: strconv.FormatInt(run.ID, 10)},
|
|
{Text: run.Workflow},
|
|
{Text: run.Title},
|
|
{Text: run.Branch},
|
|
{Text: run.Event},
|
|
{Text: runElapsedText(run.StartedAt, run.CompletedAt, now)},
|
|
{Time: terminal.ParseTime(run.CreatedAt)},
|
|
{Text: run.URL},
|
|
}
|
|
}
|
|
}
|
|
|
|
// runElapsed is how long a run, job or step has been running: until it completed, or until now. ok
|
|
// is false before it starts.
|
|
func runElapsed(started, completed *string, now time.Time) (d time.Duration, ok bool) {
|
|
if started == nil {
|
|
return 0, false
|
|
}
|
|
start := terminal.ParseTime(*started)
|
|
if start.IsZero() {
|
|
return 0, false
|
|
}
|
|
end := now
|
|
if completed != nil {
|
|
if t := terminal.ParseTime(*completed); !t.IsZero() {
|
|
end = t
|
|
}
|
|
}
|
|
return max(end.Sub(start), 0), true
|
|
}
|
|
|
|
func runElapsedText(started, completed *string, now time.Time) string {
|
|
if d, ok := runElapsed(started, completed, now); ok {
|
|
return terminal.Duration(d)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// runStateCell names the state of a run, job or step, its conclusion once it completed, colored
|
|
// like its status icon.
|
|
func runStateCell(status, conclusion string) terminal.Cell {
|
|
cell := terminal.StatusIcon(status, conclusion)
|
|
cell.Text = cmp.Or(conclusion, status)
|
|
return cell
|
|
}
|
|
|
|
func (a *application) runViewCommand() *cobra.Command {
|
|
var noJobs, web, log bool
|
|
cmd := &cobra.Command{
|
|
Use: "view [<run-id>]",
|
|
Short: "View a workflow run",
|
|
Long: "View a workflow run with its jobs and their steps.",
|
|
Example: `# view a run with its jobs and steps
|
|
$ tea run view 34
|
|
# view a run from its URL
|
|
$ tea run view https://gitea.example.com/alice/project/actions/runs/34
|
|
# print the logs of every job of a run
|
|
$ tea run view 34 --log
|
|
# get the conclusion of each job as JSON
|
|
$ tea run view 34 --json jobs --jq '.jobs[] | {name, conclusion}'`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if log && (web || a.options.json != "") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--log cannot be combined with --web, --json or --jq"))
|
|
}
|
|
t, id, err := a.runArg(cmd, args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if web {
|
|
return a.openBrowser(cmd, fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimSuffix(t.Host, "/"), t.Repo, id))
|
|
}
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
if log {
|
|
jobs, err := service.Jobs(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.printJobLogs(cmd, service, jobs)
|
|
}
|
|
run, err := service.RunDetails(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if noJobs {
|
|
run.Jobs = nil
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, run)
|
|
}
|
|
return a.writeHuman(cmd, renderRun(run, a.display(), a.runHints(t, run)))
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&noJobs, "no-jobs", false, "Leave out the jobs")
|
|
cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the run in the browser")
|
|
cmd.Flags().BoolVar(&log, "log", false, "Print the logs of every job instead, like tea run logs")
|
|
cmd.ValidArgsFunction = a.completeRunArg()
|
|
return cmd
|
|
}
|
|
|
|
// runHints are the next steps shown under a run: watching it while it runs, and reading the logs
|
|
// of its jobs once one started.
|
|
func (a *application) runHints(t target, run services.Run) []string {
|
|
repo := a.suggestedRepo(t)
|
|
var hints []string
|
|
if !run.Completed() {
|
|
hints = append(hints, fmt.Sprintf("Watch it: tea run watch %d%s", run.ID, repo))
|
|
}
|
|
if slices.ContainsFunc(run.Jobs, services.Job.Started) {
|
|
hints = append(hints, fmt.Sprintf("Logs of a job: tea run logs %d%s --job <id>", run.ID, repo))
|
|
}
|
|
return hints
|
|
}
|
|
|
|
// suggestedRepo repeats --repo for commands that tea suggests, unless the repository came from the
|
|
// working directory, where the suggestion finds it again.
|
|
func (a *application) suggestedRepo(t target) string {
|
|
if strings.HasPrefix(t.RepoSource, "remote ") || strings.HasPrefix(t.RepoSource, "git config") {
|
|
return ""
|
|
}
|
|
return " -R " + t.Repo
|
|
}
|
|
|
|
// renderRun shows a run with its jobs: on a terminal its state, trigger and jobs with their
|
|
// steps, followed by hints and the run's URL; for scripts "key:\tvalue" lines and the jobs.
|
|
func renderRun(run services.Run, d terminal.Display, hints []string) string {
|
|
now := cmp.Or(d.Now, time.Now())
|
|
if !d.TTY {
|
|
return runRecord(run, d, now)
|
|
}
|
|
style := func(cell terminal.Cell) string { return paintActionCell(cell, d.Color) }
|
|
muted := func(text string) string { return style(terminal.Cell{Text: text, Muted: true}) }
|
|
var b strings.Builder
|
|
heading := run.Workflow
|
|
if run.RunNumber > 0 {
|
|
heading += fmt.Sprintf(" #%d", run.RunNumber)
|
|
}
|
|
icon := terminal.StatusIcon(run.Status, run.Conclusion)
|
|
b.WriteString(style(icon) + " " + style(terminal.Cell{Text: run.Title, Bold: true}) + " " + muted(fmt.Sprintf("%s · run %d", heading, run.ID)) + "\n")
|
|
|
|
facts := []string{style(runStateCell(run.Status, run.Conclusion))}
|
|
if run.Branch != "" {
|
|
facts = append(facts, terminal.SafeLine(run.Branch))
|
|
}
|
|
if len(run.Commit) >= 7 {
|
|
facts = append(facts, run.Commit[:7])
|
|
}
|
|
trigger := "triggered"
|
|
if run.Actor != "" {
|
|
trigger = run.Actor + " triggered it"
|
|
}
|
|
if run.Event != "" {
|
|
trigger += " via " + run.Event
|
|
}
|
|
if created := terminal.ParseTime(run.CreatedAt); !created.IsZero() {
|
|
trigger += " " + terminal.RelativeTime(now, created)
|
|
}
|
|
facts = append(facts, muted(trigger))
|
|
if d, ok := runElapsed(run.StartedAt, run.CompletedAt, now); ok {
|
|
if run.Completed() {
|
|
facts = append(facts, muted("took "+terminal.Duration(d)))
|
|
} else {
|
|
facts = append(facts, muted("running for "+terminal.Duration(d)))
|
|
}
|
|
}
|
|
if run.Attempt > 1 {
|
|
facts = append(facts, muted(fmt.Sprintf("attempt %d", run.Attempt)))
|
|
}
|
|
b.WriteString(strings.Join(facts, " • ") + "\n")
|
|
|
|
if len(run.Jobs) > 0 {
|
|
b.WriteString("\n" + style(terminal.Cell{Text: "JOBS", Bold: true}) + "\n")
|
|
b.WriteString(terminal.Table(nil, jobLines(run.Jobs, now), d))
|
|
}
|
|
if waiting := waitingForRunner(run); waiting != "" {
|
|
b.WriteString("\n" + muted(waiting) + "\n")
|
|
}
|
|
if len(hints) > 0 || run.URL != "" {
|
|
b.WriteByte('\n')
|
|
}
|
|
for _, hint := range hints {
|
|
b.WriteString(muted(hint) + "\n")
|
|
}
|
|
if run.URL != "" {
|
|
b.WriteString(muted("View this run on Gitea: "+terminal.SafeLine(run.URL)) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// paintActionCell styles a cell's safe text outside a table: its color, muted or bold.
|
|
func paintActionCell(cell terminal.Cell, color bool) string {
|
|
name := cell.Color
|
|
if cell.Muted {
|
|
name = "muted"
|
|
}
|
|
return terminal.Emphasis(terminal.Colorize(name, terminal.SafeLine(cell.Text), color), cell.Bold && color)
|
|
}
|
|
|
|
// jobLines lists jobs, each followed by its steps, for the run view.
|
|
func jobLines(jobs []services.Job, now time.Time) [][]terminal.Cell {
|
|
var rows [][]terminal.Cell
|
|
for _, job := range jobs {
|
|
icon := terminal.StatusIcon(job.Status, job.Conclusion)
|
|
icon.Link = job.URL
|
|
rows = append(rows, []terminal.Cell{icon, {Text: job.Name, Bold: true}, runStateCell(job.Status, job.Conclusion), {Text: runElapsedText(job.StartedAt, job.CompletedAt, now)}, {Text: fmt.Sprintf("(ID %d)", job.ID), Muted: true}})
|
|
for _, step := range job.Steps {
|
|
stepIcon := terminal.StatusIcon(step.Status, step.Conclusion)
|
|
row := []terminal.Cell{{}, {Text: " " + stepIcon.Text + " " + step.Name, Muted: true}, {}, {Text: runElapsedText(step.StartedAt, step.CompletedAt, now), Muted: true}}
|
|
if step.Conclusion == "failure" {
|
|
row[1].Muted, row[1].Color = false, "red"
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// waitingForRunner explains a run that waits for a runner: none of its jobs started yet.
|
|
func waitingForRunner(run services.Run) string {
|
|
if run.Completed() || len(run.Jobs) == 0 || slices.ContainsFunc(run.Jobs, services.Job.Started) {
|
|
return ""
|
|
}
|
|
return "Waiting for a runner" + runnerLabels(run)
|
|
}
|
|
|
|
// runnerLabels names the runner labels that the run's jobs need: ` for "ubuntu-latest"`.
|
|
func runnerLabels(run services.Run) string {
|
|
var labels []string
|
|
for _, job := range run.Jobs {
|
|
for _, label := range job.Labels {
|
|
if label = strconv.Quote(terminal.SafeLine(label)); !slices.Contains(labels, label) {
|
|
labels = append(labels, label)
|
|
}
|
|
}
|
|
}
|
|
if len(labels) == 0 {
|
|
return ""
|
|
}
|
|
return " for " + strings.Join(labels, ", ")
|
|
}
|
|
|
|
// runRecord shows a run for scripts: "key:\tvalue" lines, then the jobs tab-separated.
|
|
func runRecord(run services.Run, d terminal.Display, now time.Time) string {
|
|
optional := func(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
fields := []terminal.Field{
|
|
{Label: "ID", Value: strconv.FormatInt(run.ID, 10)},
|
|
{Label: "Workflow", Value: run.Workflow},
|
|
{Label: "Workflow_path", Value: run.WorkflowPath},
|
|
{Label: "Run_number", Value: strconv.FormatInt(run.RunNumber, 10)},
|
|
{Label: "Status", Value: run.Status},
|
|
{Label: "Conclusion", Value: run.Conclusion},
|
|
{Label: "Branch", Value: run.Branch},
|
|
{Label: "Commit", Value: run.Commit},
|
|
{Label: "Event", Value: run.Event},
|
|
{Label: "Actor", Value: run.Actor},
|
|
{Label: "Attempt", Value: strconv.FormatInt(run.Attempt, 10)},
|
|
{Label: "Created_at", Value: run.CreatedAt},
|
|
{Label: "Started_at", Value: optional(run.StartedAt)},
|
|
{Label: "Completed_at", Value: optional(run.CompletedAt)},
|
|
{Label: "Elapsed", Value: runElapsedText(run.StartedAt, run.CompletedAt, now)},
|
|
}
|
|
rows := make([][]terminal.Cell, len(run.Jobs))
|
|
for i, job := range run.Jobs {
|
|
rows[i] = jobRow(now)(job)
|
|
}
|
|
return terminal.View{Title: run.Title, Fields: fields, URL: run.URL, Body: terminal.Table(jobColumns, rows, d)}.Render(d)
|
|
}
|
|
|
|
// runArg resolves the run that a command addresses: args[0], a run ID or URL, or else, in a
|
|
// terminal, one of the repository's recent runs that the user picks; active offers only runs
|
|
// that have not completed.
|
|
func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (target, int64, error) {
|
|
if len(args) > 0 {
|
|
return a.resolveRef(cmd, args[0], runRef)
|
|
}
|
|
missing := clierr.Wrap(clierr.Usage, &argumentError{problem: "missing run ID", cmd: cmd})
|
|
chooser, ok := a.runPicker()
|
|
if !ok {
|
|
return target{}, 0, missing
|
|
}
|
|
t, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return t, 0, err
|
|
}
|
|
page, err := services.Actions{Client: t.client, Repository: t.Repo}.RunsPage(cmd.Context(), services.RunListOptions{Limit: 30, Page: 1})
|
|
if err != nil {
|
|
return t, 0, err
|
|
}
|
|
runs := page.Items
|
|
if active {
|
|
runs = slices.DeleteFunc(runs, services.Run.Completed)
|
|
}
|
|
if len(runs) == 0 {
|
|
if active {
|
|
return t, 0, fmt.Errorf("no runs in progress in %s; pass a run ID", t.Repo)
|
|
}
|
|
return t, 0, fmt.Errorf("no runs in %s", t.Repo)
|
|
}
|
|
now := a.deps.Now()
|
|
choices := make([]terminal.Choice, 0, 10)
|
|
for _, run := range runs[:min(len(runs), 10)] {
|
|
label := fmt.Sprintf("%s %s · %s · %s (run %d, %s)", terminal.StatusIcon(run.Status, run.Conclusion).Text, run.Title, run.Workflow, run.Branch, run.ID, terminal.RelativeTime(now, terminal.ParseTime(run.CreatedAt)))
|
|
choices = append(choices, terminal.Choice{Label: label, Value: strconv.FormatInt(run.ID, 10)})
|
|
}
|
|
chosen, err := chooser.Choose(cmd.Context(), "Select a run", choices, "")
|
|
if err != nil {
|
|
return t, 0, err
|
|
}
|
|
id, err := strconv.ParseInt(chosen, 10, 64)
|
|
return t, id, err
|
|
}
|
|
|
|
// runPicker returns the prompter's selection prompt when prompts are allowed.
|
|
func (a *application) runPicker() (terminal.Chooser, bool) {
|
|
chooser, ok := a.deps.Prompter.(terminal.Chooser)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
s, err := a.deps.Settings.Load()
|
|
return chooser, err == nil && a.policy(s).CanPrompt(a.deps.Streams)
|
|
}
|
|
|
|
// completeRunArg completes the run argument of a run command with recent runs.
|
|
func (a *application) completeRunArg() cobra.CompletionFunc {
|
|
runs := a.completeRuns()
|
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
|
if len(args) > 0 {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
return runs(cmd, args, toComplete)
|
|
}
|
|
}
|
|
|
|
// accountNote reminds people who pass the global --user, which selects the account tea acts as,
|
|
// of the flag they may have meant, as gh's --user means something else.
|
|
func (a *application) accountNote(cmd *cobra.Command, t target, hint string) {
|
|
if flag := cmd.Flag("user"); flag != nil && flag.Changed && a.deps.Streams.ErrTTY {
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "note: --user selects the account tea acts as (%s); %s\n", terminal.SafeLine(t.User), hint)
|
|
}
|
|
}
|
|
|
|
func (a *application) runWatchCommand() *cobra.Command {
|
|
var interval, timeout time.Duration
|
|
cmd := &cobra.Command{
|
|
Use: "watch [<run-id>]",
|
|
Short: "Watch a run until it completes",
|
|
Long: `Watch a run until it completes, refreshing its jobs and steps on a terminal.
|
|
|
|
The command fails when the run completes without success.`,
|
|
Example: `# watch a run
|
|
$ tea run watch 34
|
|
# pick one of the runs in progress
|
|
$ tea run watch
|
|
# wait for a run in a script, for at most 10 minutes
|
|
$ tea run watch 34 --timeout 10m && echo deployed`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if interval <= 0 || timeout <= 0 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
|
}
|
|
t, id, err := a.runArg(cmd, args, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.watchRun(cmd, t, id, interval, timeout)
|
|
},
|
|
}
|
|
cmd.Flags().DurationVarP(&interval, "interval", "i", 3*time.Second, "Refresh interval")
|
|
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to wait")
|
|
cmd.ValidArgsFunction = a.completeRunArg()
|
|
return cmd
|
|
}
|
|
|
|
// watchRun polls run id until it completes. A terminal shows the run view, redrawn in place on
|
|
// the alternate screen, then the final view and a summary; otherwise each change of state is
|
|
// reported on stderr, and the completed run printed. It fails when the run does not succeed.
|
|
func (a *application) watchRun(cmd *cobra.Command, t target, id int64, interval, timeout time.Duration) error {
|
|
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
|
defer cancel()
|
|
out := cmd.OutOrStdout()
|
|
live := a.deps.Streams.OutTTY && a.options.json == ""
|
|
if live {
|
|
fmt.Fprint(out, "\x1b[?1049h")
|
|
}
|
|
leave := func() {
|
|
if live {
|
|
fmt.Fprint(out, "\x1b[?1049l")
|
|
live = false
|
|
}
|
|
}
|
|
defer leave()
|
|
last := ""
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
run, err := service.Watch(ctx, id, interval, func(run services.Run) error {
|
|
if live {
|
|
d := a.display()
|
|
_, err := fmt.Fprintf(out, "\x1b[H\x1b[J%s\n\n%s", terminal.Colorize("muted", fmt.Sprintf("Refreshing run status every %s. Press Ctrl+C to quit.", shortDuration(interval)), d.Color), renderRun(run, d, nil))
|
|
return err
|
|
}
|
|
if state := runProgress(run); state != last {
|
|
last = state
|
|
_, err := fmt.Fprintln(cmd.ErrOrStderr(), state)
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
leave()
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) && run.ID != 0 {
|
|
if a.options.json == "" {
|
|
_ = a.writeHuman(cmd, renderRun(run, a.display(), nil))
|
|
}
|
|
return timeoutError(err, timeout, "waiting for run %d (%s)", id, stillRunning(run))
|
|
}
|
|
return timeoutError(err, timeout, "waiting for run %d", id)
|
|
}
|
|
if a.options.json != "" {
|
|
if err := a.writeJSON(cmd, run); err != nil {
|
|
return err
|
|
}
|
|
} else if err := a.writeHuman(cmd, renderRun(run, a.display(), nil)); err != nil {
|
|
return err
|
|
}
|
|
conclusion := cmp.Or(run.Conclusion, run.Status)
|
|
if !services.SuccessfulRun(run) {
|
|
return fmt.Errorf("run %d (%s) completed with %s", run.ID, run.Workflow, conclusion)
|
|
}
|
|
took := ""
|
|
if d, ok := runElapsed(run.StartedAt, run.CompletedAt, a.deps.Now()); ok {
|
|
took = " in " + terminal.Duration(d)
|
|
}
|
|
a.done(cmd, "Run %d (%s) completed with %s%s", run.ID, run.Workflow, conclusion, took)
|
|
return nil
|
|
}
|
|
|
|
// runProgress is one line about the state of a run and its jobs, for watching without a
|
|
// terminal: "run 34: in_progress (build: success, test: in_progress)".
|
|
func runProgress(run services.Run) string {
|
|
jobs := make([]string, len(run.Jobs))
|
|
for i, job := range run.Jobs {
|
|
jobs[i] = terminal.SafeLine(job.Name) + ": " + cmp.Or(job.Conclusion, job.Status)
|
|
}
|
|
line := fmt.Sprintf("run %d: %s", run.ID, cmp.Or(run.Conclusion, run.Status))
|
|
if len(jobs) > 0 {
|
|
line += " (" + strings.Join(jobs, ", ") + ")"
|
|
}
|
|
return line
|
|
}
|
|
|
|
// stillRunning says how far a run got when a wait for it expired, suspecting a missing runner
|
|
// when no job started.
|
|
func stillRunning(run services.Run) string {
|
|
if waitingForRunner(run) != "" {
|
|
return "still " + run.Status + "; is a runner registered" + runnerLabels(run) + "?"
|
|
}
|
|
return "still " + run.Status
|
|
}
|
|
|
|
func (a *application) runCancelCommand() *cobra.Command {
|
|
var force bool
|
|
cmd := &cobra.Command{
|
|
Use: "cancel [<run-id>]",
|
|
Short: "Cancel a workflow run",
|
|
Long: "Cancel a workflow run. The run and its logs are kept; rerun it with tea run rerun.",
|
|
Example: `# cancel a run
|
|
$ tea run cancel 34
|
|
# cancel a run whose runner no longer responds
|
|
$ tea run cancel 34 --force`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t, id, err := a.runArg(cmd, args, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
run, err := services.Actions{Client: t.client, Repository: t.Repo}.CancelRun(cmd.Context(), id, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, run)
|
|
}
|
|
a.done(cmd, "Requested cancellation of run %d", id)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&force, "force", false, "Cancel without waiting for the runner to acknowledge")
|
|
cmd.ValidArgsFunction = a.completeRunArg()
|
|
return cmd
|
|
}
|
|
|
|
func (a *application) runRerunCommand() *cobra.Command {
|
|
var failed bool
|
|
var job string
|
|
cmd := &cobra.Command{
|
|
Use: "rerun [<run-id>]",
|
|
Short: "Rerun a workflow run",
|
|
Long: "Rerun all jobs of a completed workflow run, only its failed jobs, or a single job.",
|
|
Example: `# rerun a run
|
|
$ tea run rerun 34
|
|
# rerun only the jobs that failed
|
|
$ tea run rerun 34 --failed
|
|
# rerun one job, by name or ID
|
|
$ tea run rerun 34 --job build`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if failed && job != "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--failed and --job cannot be combined"))
|
|
}
|
|
t, id, err := a.runArg(cmd, args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
run, err := service.Run(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !run.Completed() {
|
|
return a.unfinished(t, run)
|
|
}
|
|
var jobID int64
|
|
if job != "" {
|
|
jobs, err := service.Jobs(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selected, err := findJob(jobs, job, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jobID = selected.ID
|
|
}
|
|
if err := service.Rerun(cmd.Context(), id, jobID, failed); err != nil {
|
|
return err
|
|
}
|
|
if run, err = service.Run(cmd.Context(), id); err != nil {
|
|
return err
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, run)
|
|
}
|
|
a.done(cmd, "Requested rerun of run %d (attempt %d); watch it with: tea run watch %d%s", id, run.Attempt, id, a.suggestedRepo(t))
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&failed, "failed", false, "Rerun only the failed jobs")
|
|
cmd.Flags().StringVarP(&job, "job", "j", "", "Rerun only this job, by `name or ID`")
|
|
cmd.ValidArgsFunction = a.completeRunArg()
|
|
_ = cmd.RegisterFlagCompletionFunc("job", a.completeJobs())
|
|
return cmd
|
|
}
|
|
|
|
// unfinished refuses to act on a run that has not completed, as Gitea does, before asking anything.
|
|
func (a *application) unfinished(t target, run services.Run) error {
|
|
return fmt.Errorf("run %d is still %s; cancel it first: tea run cancel %d%s", run.ID, run.Status, run.ID, a.suggestedRepo(t))
|
|
}
|
|
|
|
// findJob returns the job of run that raw names by ID or name.
|
|
func findJob(jobs []services.Job, raw string, run int64) (services.Job, error) {
|
|
return services.NameOrID[services.Job]{
|
|
Kind: "job", Scope: fmt.Sprintf("run %d", run),
|
|
ID: func(job services.Job) int64 { return job.ID }, Name: func(job services.Job) string { return job.Name },
|
|
}.Find(jobs, raw)
|
|
}
|
|
|
|
func (a *application) runDeleteCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "delete [<run-id>]",
|
|
Short: "Delete a workflow run",
|
|
Long: "Delete a completed workflow run with its logs and artifacts. Cancel a run before deleting it.",
|
|
Example: `# delete a run
|
|
$ tea run delete 34
|
|
# delete a run without confirmation
|
|
$ tea run delete 34 --yes`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments, "tea.confirm": "true", "tea.text": "run deletion does not return a resource"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t, id, err := a.runArg(cmd, args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
run, err := service.RunDetails(cmd.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !run.Completed() {
|
|
return a.unfinished(t, run)
|
|
}
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{
|
|
Prompt: fmt.Sprintf("Delete run %d (%s · %s) from %s?", id, run.Workflow, run.Title, t.Repo),
|
|
NonTTY: fmt.Sprintf("delete run %d from %s", id, t.Repo),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := service.DeleteRun(cmd.Context(), id); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Deleted run %d from %s", id, t.Repo)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.ValidArgsFunction = a.completeRunArg()
|
|
return cmd
|
|
}
|
|
|
|
// completeJobs completes the jobs of the run given as the first argument with their names.
|
|
func (a *application) completeJobs() cobra.CompletionFunc {
|
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
|
if len(args) == 0 {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
run, err := parseRef(args[0], runRef)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
|
jobs, err := services.Actions{Client: t.client, Repository: t.Repo}.Jobs(ctx, run.Number)
|
|
return describe(jobs, func(job services.Job) (string, string) {
|
|
return strconv.FormatInt(job.ID, 10), job.Name + " · " + cmp.Or(job.Conclusion, job.Status)
|
|
}), err
|
|
})(cmd, args, toComplete)
|
|
}
|
|
}
|