diff --git a/internal/app/action_jobs.go b/internal/app/action_jobs.go index 310a0b33..368c846d 100644 --- a/internal/app/action_jobs.go +++ b/internal/app/action_jobs.go @@ -4,7 +4,9 @@ package app import ( + "fmt" "strconv" + "time" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" @@ -13,25 +15,56 @@ import ( func (a *application) runJobsCommand() *cobra.Command { var limit, page int - cmd := &cobra.Command{Use: "jobs ", Short: "List jobs belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - run, err := number(args[0]) - if err != nil { - return err - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - items, err := (services.Actions{Client: target.client, Repository: target.Repo}).JobsPage(cmd.Context(), run, limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, jobRow) - }} + cmd := &cobra.Command{ + Use: "jobs []", + Short: "List the jobs of a workflow run", + Example: `# list the jobs of a run +$ tea run jobs 34 +# list the IDs of the jobs that failed +$ tea run jobs 34 --json id,conclusion --jq '.[] | select(.conclusion == "failure") | .id'`, + Args: usageArgs(cobra.MaximumNArgs(1)), + Annotations: map[string]string{"tea.arguments": runArguments}, + RunE: func(cmd *cobra.Command, args []string) error { + t, run, err := a.runArg(cmd, args, false) + if err != nil { + return err + } + jobs, err := (services.Actions{Client: t.client, Repository: t.Repo}).JobsPage(cmd.Context(), run, limit, page) + if err != nil { + return err + } + spec := listSpec[services.Job]{Columns: jobColumns, Row: jobRow(a.deps.Now()), Empty: fmt.Sprintf("no jobs in run %d", run), Context: fmt.Sprintf("jobs in run %d", run)} + return writeTable(a, cmd, jobs.Items, spec, listPage{Number: page, More: jobs.More, Total: jobs.Total, Counted: jobs.Counted}) + }, + } pageFlags(cmd, &limit, &page) + cmd.ValidArgsFunction = a.completeRunArg() return cmd } -func jobRow(item services.Job) []terminal.Cell { - return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, item.Status, item.Conclusion) +// jobColumns head jobRow: an icon on a terminal, the status and conclusion words for scripts. +var jobColumns = []terminal.Column{ + {Header: "Status", TTYOnly: true}, + {Header: "Status", PipedOnly: true}, + {Header: "Conclusion", PipedOnly: true}, + {Header: "ID"}, + {Header: "Name", Flex: true}, + {Header: "Elapsed"}, + {Header: "URL", PipedOnly: true}, +} + +func jobRow(now time.Time) func(services.Job) []terminal.Cell { + return func(job services.Job) []terminal.Cell { + icon := terminal.StatusIcon(job.Status, job.Conclusion) + icon.Link = job.URL + return []terminal.Cell{ + icon, + {Text: job.Status}, + {Text: job.Conclusion}, + {Text: strconv.FormatInt(job.ID, 10)}, + {Text: job.Name}, + {Text: runElapsedText(job.StartedAt, job.CompletedAt, now)}, + {Text: job.URL}, + } + } } diff --git a/internal/app/action_logs.go b/internal/app/action_logs.go index 786e279a..3c50cb27 100644 --- a/internal/app/action_logs.go +++ b/internal/app/action_logs.go @@ -4,11 +4,13 @@ package app import ( + "bytes" "cmp" "context" "errors" "fmt" "io" + "slices" "time" "gitea.dev/tea/internal/clierr" @@ -18,112 +20,196 @@ import ( ) func (a *application) runLogsCommand() *cobra.Command { - var output string - var selected int64 - var follow bool + var archive downloadFlags + var job string + var follow, failed bool var interval, timeout time.Duration - cmd := &cobra.Command{Use: "logs ", Short: "Stream logs for a run or a selected job", Annotations: textOutput("run logs emits text"), Args: argsExact(1), 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")) - } - run, err := number(args[0]) - if err != nil { - return err - } - if cmd.Flags().Changed("job") && selected < 1 { - return clierr.Wrap(clierr.Usage, errors.New("--job must be a positive ID")) - } - if output != "" && (follow || cmd.Flags().Changed("job")) { - return clierr.Wrap(clierr.Usage, errors.New("--output-file downloads a run archive and cannot be combined with --job or --follow")) - } - if output != "" { - if err := validateDownloadDestination(output); err != nil { - return err + cmd := &cobra.Command{ + Use: "logs []", + Short: "Print the logs of a run's jobs", + Long: `Print the logs of a run's jobs, one after another; the name of each job goes to stderr. + +With --follow, tea waits for jobs to start and prints new log lines until they complete, +prefixing each line with its job's name when it follows several jobs. With --output or --dir, +it downloads the logs of the whole run as a ZIP archive instead.`, + Example: `# print the logs of every job of a run +$ tea run logs 34 +# print the logs of the failed jobs +$ tea run logs 34 --failed +# follow the logs of one job while it runs +$ tea run logs 34 --job build --follow +# download the logs as a ZIP archive +$ tea run logs 34 --output logs.zip`, + Args: usageArgs(cobra.MaximumNArgs(1)), + Annotations: map[string]string{"tea.arguments": runArguments, "tea.text": "run logs emits text"}, + 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")) } - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - service := services.Actions{Client: target.client, Repository: target.Repo} - if output != "" { - body, err := service.RunLogsArchive(cmd.Context(), run) + download := archive.output != "" || archive.dir != "" + if download && (follow || failed || job != "") { + return clierr.Wrap(clierr.Usage, errors.New("--output and --dir download the whole run and cannot be combined with --job, --failed or --follow")) + } + if follow && failed { + return clierr.Wrap(clierr.Usage, errors.New("--follow and --failed cannot be combined")) + } + t, run, err := a.runArg(cmd, args, false) if err != nil { return err } - defer body.Close() - return writeDownload(cmd, body, output) - } - jobs, err := service.Jobs(cmd.Context(), run) - if err != nil { - return err - } - if selected != 0 { - found := false - for _, job := range jobs { - if job.ID == selected { - jobs = []services.Job{job} - found = true - break + service := services.Actions{Client: t.client, Repository: t.Repo} + if download { + path, skip, err := archive.destination(fmt.Sprintf("run-%d-logs.zip", run)) + if err != nil || skip { + return err + } + body, err := service.RunLogsArchive(cmd.Context(), run) + if err != nil { + return err + } + defer body.Close() + if err := a.download(cmd, archive, path, body); err != nil { + return err + } + if path != "-" { + a.done(cmd, "Downloaded the logs of run %d to %s", run, path) + } + return nil + } + jobs, err := service.Jobs(cmd.Context(), run) + if err != nil { + return err + } + switch { + case job != "": + selected, err := findJob(jobs, job, run) + if err != nil { + return err + } + jobs = []services.Job{selected} + case failed: + jobs = slices.DeleteFunc(jobs, func(job services.Job) bool { return job.Conclusion != "failure" }) + if len(jobs) == 0 { + return fmt.Errorf("run %d has no failed jobs", run) } } - if !found { - return fmt.Errorf("job %d does not belong to run %d", selected, run) - } - } - out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true) - if follow { - if len(jobs) != 1 { - return clierr.Wrap(clierr.Usage, errors.New("--follow requires exactly one job; select --job")) + if !follow { + return a.printJobLogs(cmd, service, jobs) } ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() - return service.FollowJobLogs(ctx, run, jobs[0].ID, interval, func(chunk []byte) error { - n, err := out.Write(chunk) - if err == nil && n != len(chunk) { - return io.ErrShortWrite - } - return err - }) - } - for _, job := range jobs { - label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID) - var logs io.ReadCloser - if job.Started() { - logs, err = service.JobLogs(cmd.Context(), job.ID) - } - if !job.Started() || errors.Is(err, services.ErrJobNotStarted) { - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", label, unstarted(job)); err != nil { - return err - } - continue - } - if err != nil { - return err - } - if _, err := fmt.Fprintln(cmd.ErrOrStderr(), label); err != nil { - _ = logs.Close() - return err - } - _, copyErr := io.Copy(out, logs) - closeErr := logs.Close() - if copyErr != nil { - return copyErr - } - if closeErr != nil { - return closeErr - } - } - return nil - }} - cmd.Flags().StringVar(&output, "output-file", "", "Download run logs as ZIP to a new file, or - for stdout") - cmd.Flags().Int64Var(&selected, "job", 0, "Only stream this job's logs (must belong to the run)") - cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow newly appended logs until the selected job completes") - cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Log polling interval") - cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum follow duration") + return timeoutError(a.followJobLogs(ctx, cmd, service, run, jobs, interval), timeout, "following the logs of run %d", run) + }, + } + archive.register(cmd) + cmd.Flags().StringVarP(&job, "job", "j", "", "Only the logs of this job, by `name or ID`") + cmd.Flags().BoolVar(&failed, "failed", false, "Only the logs of failed jobs") + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Wait for jobs to start and print new lines until they complete") + cmd.Flags().DurationVarP(&interval, "interval", "i", 5*time.Second, "Polling interval with --follow") + cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to follow") + cmd.ValidArgsFunction = a.completeRunArg() + _ = cmd.RegisterFlagCompletionFunc("job", a.completeJobs()) return cmd } +// printJobLogs prints the logs of jobs on stdout, each headed by its name on stderr. Jobs that +// have not started are reported on stderr and skipped. A terminal keeps only the colors of logs. +func (a *application) printJobLogs(cmd *cobra.Command, service services.Actions, jobs []services.Job) error { + out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true) + for _, job := range jobs { + label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID) + var logs io.ReadCloser + var err error + if job.Started() { + logs, err = service.JobLogs(cmd.Context(), job.ID) + } + if !job.Started() || errors.Is(err, services.ErrJobNotStarted) { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", label, unstarted(job)); err != nil { + return err + } + continue + } + if err != nil { + return err + } + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), label); err != nil { + _ = logs.Close() + return err + } + _, copyErr := io.Copy(out, logs) + closeErr := logs.Close() + if err := cmp.Or(copyErr, closeErr); err != nil { + return err + } + } + return nil +} + +// followJobLogs follows the logs of jobs one after another until each completes, waiting for +// those that have not started. Following several jobs prefixes each line with its job's name. +func (a *application) followJobLogs(ctx context.Context, cmd *cobra.Command, service services.Actions, run int64, jobs []services.Job, interval time.Duration) error { + out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true) + for _, job := range jobs { + label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID) + if job.Started() { + fmt.Fprintln(cmd.ErrOrStderr(), label) + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "%s: waiting for it to start…\n", label) + } + lines := &prefixWriter{w: out} + if len(jobs) > 1 { + lines.prefix = []byte(terminal.SafeLine(job.Name) + "\t") + } + err := service.FollowJobLogs(ctx, run, job.ID, interval, func(chunk []byte) error { + _, err := lines.Write(chunk) + return err + }) + if errors.Is(err, services.ErrJobNotStarted) { + fmt.Fprintf(cmd.ErrOrStderr(), "%s: never started\n", label) + continue + } + if err := cmp.Or(err, lines.Flush()); err != nil { + return err + } + } + return nil +} + +// prefixWriter writes prefix before every line it passes to w; Flush ends a last unterminated line. +type prefixWriter struct { + w io.Writer + prefix []byte + partial bool +} + +func (p *prefixWriter) Write(data []byte) (int, error) { + if len(p.prefix) == 0 { + return p.w.Write(data) + } + var b bytes.Buffer + for line := range bytes.Lines(data) { + if !p.partial { + b.Write(p.prefix) + } + b.Write(line) + p.partial = !bytes.HasSuffix(line, []byte("\n")) + } + if _, err := p.w.Write(b.Bytes()); err != nil { + return 0, err + } + return len(data), nil +} + +// Flush terminates a last line that has no line break. +func (p *prefixWriter) Flush() error { + if !p.partial { + return nil + } + p.partial = false + _, err := p.w.Write([]byte("\n")) + return err +} + // unstarted explains why a job has no logs: it is waiting for a runner, or it completed, such // as by cancellation, before one started it. func unstarted(job services.Job) string { diff --git a/internal/app/action_logs_test.go b/internal/app/action_logs_test.go index 6cde8b04..28eb9fd2 100644 --- a/internal/app/action_logs_test.go +++ b/internal/app/action_logs_test.go @@ -53,7 +53,7 @@ func TestRunLogsStreamWithoutMixingDiagnosticsAndVerifyJobMembership(t *testing. diagnostics.Reset() args[5] = "--job=10" err := Execute(context.Background(), deps, args) - require.ErrorContains(t, err, "does not belong") + require.EqualError(t, err, "job 10 not found in run 7") require.Equal(t, 1, downloads) require.Empty(t, out.String()) path := filepath.Join(t.TempDir(), "logs.zip") @@ -121,3 +121,34 @@ func TestRunLogsContinuePastJobsThatHaveNotStarted(t *testing.T) { require.Equal(t, "test output\n", out.String()) require.Equal(t, "lint (job 8): never started (skipped)\nbuild (job 9): not started yet\ntest (job 10)\ndeploy (job 11): not started yet\n", diagnostics.String()) } + +func TestRunLogsSelectJobsAndFollowSeveral(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/runs/29/jobs": failedJobsFixture, + "GET actions/jobs/30/logs": "compiled\nlinked", + "GET actions/jobs/31/logs": "boom\n", + }) + for _, tc := range []struct { + name string + args []string + out, progress string + err string + }{ + {"failed jobs", []string{"--failed"}, "boom\n", "test (job 31)\n", ""}, + {"a job by name", []string{"--job", "Build"}, "compiled\nlinked", "build (job 30)\n", ""}, + {"follow several jobs", []string{"--follow", "--interval=1ms"}, "build\tcompiled\nbuild\tlinked\ntest\tboom\n", "build (job 30)\ntest (job 31)\n", ""}, + {"unknown job", []string{"--job", "deploy"}, "", "", `job "deploy" not found in run 29`}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, diagnostics := actionsDeps(t, server.URL, false) + err := Execute(context.Background(), deps, append([]string{"run", "logs", "29", "-R", "alice/project"}, tc.args...)) + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.out, out.String()) + require.Equal(t, tc.progress, diagnostics.String()) + }) + } +} diff --git a/internal/app/action_runs.go b/internal/app/action_runs.go new file mode 100644 index 00000000..0b3a588e --- /dev/null +++ b/internal/app/action_runs.go @@ -0,0 +1,766 @@ +// 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 []", + 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 ", 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 []", + 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 []", + 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 []", + 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 []", + 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) + } +} diff --git a/internal/app/action_runs_test.go b/internal/app/action_runs_test.go new file mode 100644 index 00000000..458555e5 --- /dev/null +++ b/internal/app/action_runs_test.go @@ -0,0 +1,397 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/terminal" + "github.com/stretchr/testify/require" +) + +// actionsNow is the clock of the Actions fixtures. +var actionsNow = time.Date(2028, 1, 1, 12, 0, 0, 0, time.UTC) + +const ( + workflowsFixture = `{"total_count":2,"workflows":[ + {"id":"ci.yml","name":"CI","path":".gitea/workflows/ci.yml","state":"active","html_url":"https://gitea.test/alice/project/src/branch/main/.gitea/workflows/ci.yml"}, + {"id":"nightly.yaml","name":"Nightly","path":".gitea/workflows/nightly.yaml","state":"disabled_manually"}]}` + queuedRunFixture = `{"id":47,"run_number":10,"display_title":"Add workflows","path":"ci.yml@refs/heads/main","event":"workflow_dispatch", + "head_branch":"main","head_sha":"f0c151dde0c0ef6c","status":"queued","run_attempt":1,"actor":{"login":"alice"}, + "created_at":"2028-01-01T11:58:00Z","started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z", + "html_url":"https://gitea.test/alice/project/actions/runs/47"}` + failedRunFixture = `{"id":29,"run_number":9,"display_title":"Refactor the deployment pipeline","path":"nightly.yaml@refs/heads/feature/x", + "event":"push","head_branch":"feature/x","head_sha":"209d545ac554","status":"completed","conclusion":"failure","run_attempt":2, + "actor":{"login":"bob"},"created_at":"2028-01-01T11:00:00Z","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:01:17Z", + "html_url":"https://gitea.test/alice/project/actions/runs/29"}` + queuedJobsFixture = `{"total_count":2,"jobs":[ + {"id":48,"name":"build","status":"queued","labels":["ubuntu-latest"],"html_url":"https://gitea.test/alice/project/actions/runs/47/jobs/48","started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z"}, + {"id":49,"name":"test","status":"waiting","labels":["ubuntu-latest"],"started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z"}]}` + failedJobsFixture = `{"total_count":2,"jobs":[ + {"id":30,"name":"build","status":"completed","conclusion":"success","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:00:50Z", + "steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:00:07Z"}]}, + {"id":31,"name":"test","status":"completed","conclusion":"failure","started_at":"2028-01-01T11:00:50Z","completed_at":"2028-01-01T11:01:17Z", + "steps":[{"number":1,"name":"Run tests","status":"completed","conclusion":"failure","started_at":"2028-01-01T11:00:51Z","completed_at":"2028-01-01T11:01:16Z"}]}]}` +) + +// actionsAPI serves canned responses for alice/project, keyed by method and path below the +// repository ("GET actions/runs"). A response that starts with a status code and a space, such as +// "404 {...}", is sent with that status. It records the keys of all requests, followed by their +// bodies, and fails the test on requests without a response. +func actionsAPI(t *testing.T, responses map[string]string) (*httptest.Server, *[]string) { + t.Helper() + var requests []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project"), "/") + if payload, _ := io.ReadAll(r.Body); len(payload) > 0 { + requests = append(requests, key+" "+string(payload)) + } else { + requests = append(requests, key) + } + body, ok := responses[key] + if !ok { + t.Errorf("unexpected request %s", key) + w.WriteHeader(http.StatusTeapot) + return + } + if status, rest, found := strings.Cut(body, " "); found && len(status) == 3 { + if code, err := strconv.Atoi(status); err == nil { + w.WriteHeader(code) + body = rest + } + } + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return server, &requests +} + +// actionsDeps returns dependencies for an actionsAPI server at the fixtures' clock, on a terminal +// without colors or links when tty is set. +func actionsDeps(t *testing.T, serverURL string, tty bool) (Dependencies, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + deps, out, diagnostics := serverDeps(t, serverURL) + deps.Getenv = serverEnv(serverURL, map[string]string{"TERM": "dumb"}) + deps.Now = func() time.Time { return actionsNow } + deps.Streams.OutTTY = tty + return deps, out, diagnostics +} + +func TestRunTimeBoundsUseOneClockReading(t *testing.T) { + deps, _, _ := testDeps(t) + reads := 0 + deps.Now = func() time.Time { reads++; return actionsNow } + err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"}) + require.ErrorContains(t, err, "--since must not be later than --until") + require.Equal(t, 1, reads) +} + +func TestRunListShowsStatusWorkflowAndTiming(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/runs": `{"total_count":2,"workflow_runs":[` + queuedRunFixture + `,` + failedRunFixture + `]}`, + "GET actions/workflows": workflowsFixture, + }) + for _, tc := range []struct { + name string + tty bool + args []string + want string + }{ + {"terminal", true, nil, `Showing 2 of 2 runs in alice/project + +STATUS ID WORKFLOW TITLE BRANCH EVENT ELAPSED AGE +* 47 CI Add workflows main workflow_dispatch about 2 minutes ago +X 29 Nightly Refactor the deployment pipeline feature/x push 1m 12s about 1 hour ago +`}, + {"piped", false, nil, "queued\t\t47\tCI\tAdd workflows\tmain\tworkflow_dispatch\t\t2028-01-01T11:58:00Z\thttps://gitea.test/alice/project/actions/runs/47\n" + + "completed\tfailure\t29\tNightly\tRefactor the deployment pipeline\tfeature/x\tpush\t1m 12s\t2028-01-01T11:00:00Z\thttps://gitea.test/alice/project/actions/runs/29\n"}, + {"json", false, []string{"--json", "id,workflow,workflow_path,run_number,started_at"}, `[{"id":47,"workflow":"CI","workflow_path":"ci.yml","run_number":10,"started_at":null},` + + `{"id":29,"workflow":"Nightly","workflow_path":"nightly.yaml","run_number":9,"started_at":"2028-01-01T11:00:05Z"}]` + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, diagnostics := actionsDeps(t, server.URL, tc.tty) + require.NoError(t, Execute(context.Background(), deps, append([]string{"run", "list", "-R", "alice/project"}, tc.args...))) + if strings.HasPrefix(tc.want, "[") { + require.JSONEq(t, tc.want, out.String()) + } else { + require.Equal(t, tc.want, out.String()) + } + require.Empty(t, diagnostics.String()) + }) + } +} + +func TestRunListResolvesWorkflowNamesAndReportsEmptyFilters(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET actions/workflows/ci.yml/runs": `{"total_count":0,"workflow_runs":[]}`, + }) + deps, out, diagnostics := actionsDeps(t, server.URL, true) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "list", "-R", "alice/project", "-w", "ci", "-s", "FAILURE"})) + require.Empty(t, out.String()) + require.Equal(t, "no runs match your filters in alice/project\n", diagnostics.String()) + require.Contains(t, *requests, "GET actions/workflows/ci.yml/runs") + requireUsageBeforeRequests(t, "--status must be queued, in_progress, waiting", "run", "list", "--status", "nope") +} + +func TestRunListExplainsTheAccountFlag(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{"GET actions/runs": `{"workflow_runs":[]}`}) + deps, _, diagnostics := actionsDeps(t, server.URL, true) + deps.Streams.ErrTTY = true + require.NoError(t, Execute(context.Background(), deps, []string{"run", "list", "-R", "alice/project", "--user", "alice"})) + require.Equal(t, "note: --user selects the account tea acts as (alice); to list the runs someone triggered, use --actor\nno runs in alice/project\n", diagnostics.String()) +} + +func TestRunViewShowsJobsStepsAndNextSteps(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET actions/runs/47": queuedRunFixture, + "GET actions/runs/47/jobs": queuedJobsFixture, + "GET actions/runs/29": failedRunFixture, + "GET actions/runs/29/jobs": failedJobsFixture, + }) + for _, tc := range []struct { + name string + tty bool + args []string + want string + }{ + {"queued", true, []string{"47"}, `* Add workflows CI #10 · run 47 +queued • main • f0c151d • alice triggered it via workflow_dispatch about 2 minutes ago + +JOBS +* build queued (ID 48) +* test waiting (ID 49) + +Waiting for a runner for "ubuntu-latest" + +Watch it: tea run watch 47 -R alice/project +View this run on Gitea: https://gitea.test/alice/project/actions/runs/47 +`}, + {"completed from its URL", true, []string{"{server}/alice/project/actions/runs/29"}, `X Refactor the deployment pipeline Nightly #9 · run 29 +failure • feature/x • 209d545 • bob triggered it via push about 1 hour ago • took 1m 12s • attempt 2 + +JOBS +✓ build success 45s (ID 30) + ✓ Set up job 2s +X test failure 27s (ID 31) + X Run tests 25s + +Logs of a job: tea run logs 29 -R alice/project --job +View this run on Gitea: https://gitea.test/alice/project/actions/runs/29 +`}, + {"piped", false, []string{"47"}, "title:\tAdd workflows\nid:\t47\nworkflow:\tCI\nworkflow_path:\tci.yml\nrun_number:\t10\nstatus:\tqueued\nconclusion:\t\n" + + "branch:\tmain\ncommit:\tf0c151dde0c0ef6c\nevent:\tworkflow_dispatch\nactor:\talice\nattempt:\t1\ncreated_at:\t2028-01-01T11:58:00Z\nstarted_at:\t\n" + + "completed_at:\t\nelapsed:\t\nurl:\thttps://gitea.test/alice/project/actions/runs/47\n--\n" + + "queued\t\t48\tbuild\t\thttps://gitea.test/alice/project/actions/runs/47/jobs/48\nwaiting\t\t49\ttest\t\t\n"}, + {"json with jobs", false, []string{"29", "--json", "id,jobs", "--jq", "[.id, [.jobs[] | .name, .conclusion, .steps[0].name]]"}, `[29,["build","success","Set up job","test","failure","Run tests"]]` + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, _ := actionsDeps(t, server.URL, tc.tty) + args := append([]string{"run", "view", strings.ReplaceAll(tc.args[0], "{server}", server.URL)}, tc.args[1:]...) + if !strings.Contains(tc.args[0], "{server}") { + args = append(args, "-R", "alice/project") + } + require.NoError(t, Execute(context.Background(), deps, args)) + require.Equal(t, tc.want, out.String()) + }) + } +} + +// choosingPrompter answers selections with the choice at index choose. +type choosingPrompter struct { + scriptedPrompter + choose int + choices []terminal.Choice + titles []string +} + +func (p *choosingPrompter) Choose(_ context.Context, title string, choices []terminal.Choice, _ string) (string, error) { + p.titles = append(p.titles, title) + p.choices = choices + if p.choose >= len(choices) { + return "", errors.New("no such choice") + } + return choices[p.choose].Value, nil +} + +func TestRunCommandsAskForARunWithoutAnArgument(t *testing.T) { + server, requests := actionsAPI(t, map[string]string{ + "GET actions/runs": `{"workflow_runs":[` + failedRunFixture + `,` + queuedRunFixture + `]}`, + "GET actions/workflows": workflowsFixture, + "GET actions/runs/29/jobs": failedJobsFixture, + }) + deps, out, _ := actionsDeps(t, server.URL, false) + prompter := &choosingPrompter{} + deps.Prompter = prompter + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "-R", "alice/project", "--json", "id", "--interactive"})) + require.JSONEq(t, `[{"id":30},{"id":31}]`, out.String()) + require.Equal(t, []string{"Select a run"}, prompter.titles) + require.Equal(t, []terminal.Choice{ + {Label: "X Refactor the deployment pipeline · Nightly · feature/x (run 29, about 1 hour ago)", Value: "29"}, + {Label: "* Add workflows · CI · main (run 47, about 2 minutes ago)", Value: "47"}, + }, prompter.choices) + + *requests = nil + prompter.choose = 1 + require.ErrorContains(t, Execute(context.Background(), deps, []string{"run", "watch", "-R", "alice/project"}), "no such choice") + require.Equal(t, []string{"GET actions/runs", "GET actions/workflows"}, *requests) + require.Equal(t, []terminal.Choice{{Label: "* Add workflows · CI · main (run 47, about 2 minutes ago)", Value: "47"}}, prompter.choices, "watch offers runs in progress") + + deps.Streams.InTTY = false + err := Execute(context.Background(), deps, []string{"run", "view", "-R", "alice/project"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.ErrorContains(t, err, "missing run ID\nUsage: tea run view [] [flags]") +} + +func TestRunWatchExitStatusAndTimeout(t *testing.T) { + succeeded := `{"id":7,"path":"ci.yml@refs/heads/main","status":"completed","conclusion":"success","created_at":"2028-01-01T11:59:00Z", + "started_at":"2028-01-01T11:59:00Z","completed_at":"2028-01-01T11:59:30Z"}` + for _, tc := range []struct { + name, run, jobs, want, err, progress string + code int + }{ + {"success", succeeded, `{"jobs":[]}`, `{"id":7,"conclusion":"success","workflow":"CI"}`, "", "run 7: success\n", 0}, + {"failure", strings.Replace(succeeded, `"success"`, `"failure"`, 1), `{"jobs":[]}`, `{"id":7,"conclusion":"failure","workflow":"CI"}`, "run 7 (CI) completed with failure", "run 7: failure\n", clierr.Failure}, + { + "timeout", strings.Replace(queuedRunFixture, `"id":47`, `"id":7`, 1), queuedJobsFixture, "", + `timed out after 100ms waiting for run 7 (still queued; is a runner registered for "ubuntu-latest"?)`, "run 7: queued (build: queued, test: waiting)\n", clierr.Canceled, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/7": tc.run, "GET actions/runs/7/jobs": tc.jobs}) + deps, out, diagnostics := actionsDeps(t, server.URL, false) + err := Execute(context.Background(), deps, []string{"run", "watch", "7", "-R", "alice/project", "--json=id,conclusion,workflow", "--interval=1ms", "--timeout=100ms"}) + require.Equal(t, tc.code, clierr.ExitCode(err), "%v", err) + if tc.err == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.err) + } + if tc.want == "" { + require.Empty(t, out.String()) + } else { + require.JSONEq(t, tc.want, out.String()) + } + require.Equal(t, tc.progress, diagnostics.String()) + }) + } +} + +func TestRunWatchRedrawsOnATerminalAndSummarizes(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/workflows": workflowsFixture, + "GET actions/runs/7": `{"id":7,"display_title":"Ship it","path":"ci.yml@refs/heads/main","status":"completed","conclusion":"success", + "created_at":"2028-01-01T11:59:00Z","started_at":"2028-01-01T11:59:00Z","completed_at":"2028-01-01T11:59:30Z"}`, + "GET actions/runs/7/jobs": `{"jobs":[]}`, + }) + deps, out, diagnostics := actionsDeps(t, server.URL, true) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "watch", "7", "-R", "alice/project", "--interval=2s"})) + frame := "✓ Ship it CI · run 7\nsuccess • triggered about 1 minute ago • took 30s\n" + require.Equal(t, "\x1b[?1049h\x1b[H\x1b[JRefreshing run status every 2s. Press Ctrl+C to quit.\n\n"+frame+"\x1b[?1049l"+frame, out.String()) + require.Equal(t, "✓ Run 7 (CI) completed with success in 30s\n", diagnostics.String()) +} + +func TestRunMutationsReportWhatHappened(t *testing.T) { + completedRun := failedRunFixture + for _, tc := range []struct { + name string + args []string + responses map[string]string + requests []string + out, err string + prompts []string + }{ + { + "cancel without confirmation", + []string{"run", "cancel", "47"}, + map[string]string{"POST actions/runs/47/cancel": queuedRunFixture}, + []string{"POST actions/runs/47/cancel"}, + "", "✓ Requested cancellation of run 47\n", nil, + }, + { + "cancel returns the run", + []string{"run", "cancel", "47", "--json", "id,status"}, + map[string]string{"POST actions/runs/47/cancel": queuedRunFixture}, + []string{"POST actions/runs/47/cancel"}, + `{"id":47,"status":"queued"}`, "", nil, + }, + { + "rerun", + []string{"run", "rerun", "29"}, + map[string]string{"GET actions/runs/29": completedRun, "POST actions/runs/29/rerun": completedRun}, + []string{"GET actions/runs/29", "POST actions/runs/29/rerun", "GET actions/runs/29"}, + "", "✓ Requested rerun of run 29 (attempt 2); watch it with: tea run watch 29 -R alice/project\n", nil, + }, + { + "rerun a job by name", + []string{"run", "rerun", "29", "--job", "TEST"}, + map[string]string{"GET actions/runs/29": completedRun, "GET actions/runs/29/jobs": failedJobsFixture, "POST actions/runs/29/jobs/31/rerun": `{"id":31}`}, + []string{"GET actions/runs/29", "GET actions/runs/29/jobs", "POST actions/runs/29/jobs/31/rerun", "GET actions/runs/29"}, + "", "✓ Requested rerun of run 29 (attempt 2); watch it with: tea run watch 29 -R alice/project\n", nil, + }, + { + "rerun refuses an unfinished run", + []string{"run", "rerun", "47"}, + map[string]string{"GET actions/runs/47": queuedRunFixture}, + []string{"GET actions/runs/47"}, + "", "", nil, + }, + { + "delete after confirmation", + []string{"run", "delete", "29"}, + map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/29": completedRun, "GET actions/runs/29/jobs": failedJobsFixture, "DELETE actions/runs/29": "204 "}, + []string{"GET actions/workflows", "GET actions/runs/29", "GET actions/runs/29/jobs", "DELETE actions/runs/29"}, + "", "✓ Deleted run 29 from alice/project\n", + []string{"Delete run 29 (Nightly · Refactor the deployment pipeline) from alice/project?"}, + }, + { + "delete refuses an unfinished run before asking", + []string{"run", "delete", "47"}, + map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/47": queuedRunFixture, "GET actions/runs/47/jobs": queuedJobsFixture}, + []string{"GET actions/workflows", "GET actions/runs/47", "GET actions/runs/47/jobs"}, + "", "", nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server, requests := actionsAPI(t, tc.responses) + deps, out, diagnostics := actionsDeps(t, server.URL, true) + prompter := &scriptedPrompter{accept: true} + deps.Prompter = prompter + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + err := Execute(context.Background(), deps, append(tc.args, "-R", "alice/project")) + if strings.Contains(tc.name, "unfinished") { + require.EqualError(t, err, "run 47 is still queued; cancel it first: tea run cancel 47 -R alice/project") + } else { + require.NoError(t, err) + } + require.Equal(t, tc.requests, *requests) + if strings.HasPrefix(tc.out, "{") { + require.JSONEq(t, tc.out, out.String()) + } else { + require.Equal(t, tc.out, out.String()) + } + require.Equal(t, tc.err, diagnostics.String()) + require.Equal(t, tc.prompts, prompter.confirmations) + }) + } +} + +func TestRunJobsListsStatusAndElapsedTime(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{"GET actions/runs/29/jobs": failedJobsFixture}) + deps, out, _ := actionsDeps(t, server.URL, true) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "#29", "-R", "alice/project"})) + require.Equal(t, "Showing 2 of 2 jobs in run 29\n\nSTATUS ID NAME ELAPSED\n✓ 30 build 45s\nX 31 test 27s\n", out.String()) +} diff --git a/internal/app/actions.go b/internal/app/actions.go index 09fe0132..edf5224e 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "strconv" "strings" "time" @@ -129,161 +128,3 @@ func (a *application) workflowCommand() *cobra.Command { func workflowRow(item services.Workflow) []terminal.Cell { return terminal.Row(item.ID, item.Name, item.State) } - -func runRow(item services.Run) []terminal.Cell { - return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Title}, {Text: item.Status}, {Text: item.Conclusion}, {Text: item.Branch}, {Text: item.Event}} -} - -func (a *application) runCommand() *cobra.Command { - group := &cobra.Command{Use: "run", Short: "Inspect and watch Actions runs"} - group.AddCommand(a.runLogsCommand(), a.artifactCommand(), a.runJobsCommand()) - var filters services.RunListOptions - var started timeRange - list := &cobra.Command{Use: "list", Short: "List workflow runs", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - var err error - if filters.Since, filters.Until, err = started.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 - } - items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Runs(cmd.Context(), filters) - if err != nil { - return err - } - return writeList(a, cmd, items, runRow) - }} - pageFlags(list, &filters.Limit, &filters.Page) - started.register(list, "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") - list.Flags().StringVar(&filters.Branch, "branch", "", "Filter by branch name") - list.Flags().StringVar(&filters.Event, "event", "", "Filter by triggering event") - list.Flags().StringVar(&filters.Actor, "actor", "", "Filter by triggering username") - list.Flags().StringVar(&filters.Commit, "commit", "", "Filter by triggering commit SHA") - list.Flags().StringVar(&filters.Workflow, "workflow", "", "Filter by workflow file name") - list.Flags().StringSliceVar(&filters.Statuses, "status", nil, "Filter status: pending, waiting, requested, action_required, queued, in_progress, completed, success, failure, skipped, neutral, cancelled, timed_out") //nolint:misspell // Gitea API spelling. - group.AddCommand(list) - var includeJobs bool - viewRun := &cobra.Command{Use: "view ", Short: "View a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - service := services.Actions{Client: t.client, Repository: t.Repo} - item, err := service.Run(cmd.Context(), id) - if err != nil { - return err - } - if !includeJobs || a.options.json != "" { - return writeItem(a, cmd, item, runRow) - } - jobs, err := service.Jobs(cmd.Context(), id) - if err != nil { - return err - } - jobLine := func(job services.Job) []terminal.Cell { return append(terminal.Row("job"), jobRow(job)...) } - color := a.colorEnabled() - return a.writeHuman(cmd, table([]services.Run{item}, runRow, color)+table(jobs, jobLine, color)) - }} - viewRun.Flags().BoolVar(&includeJobs, "jobs", true, "Include job summaries after the run details") - group.AddCommand(viewRun) - var interval, timeout time.Duration - watch := &cobra.Command{Use: "watch ", Short: "Wait for completion; fail if the run fails", Args: argsExact(1), 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")) - } - id, err := number(args[0]) - if err != nil { - return err - } - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(cmd.Context(), timeout) - defer cancel() - result, err := (services.Actions{Client: t.client, Repository: t.Repo}).Watch(ctx, id, interval, func(item services.Run) error { - if a.options.json != "" { - _, err := fmt.Fprintf(cmd.ErrOrStderr(), "run %d: %s %s\n", item.ID, item.Status, item.Conclusion) - return err - } - return writeItem(a, cmd, item, runRow) - }) - if err != nil { - return err - } - if a.options.json != "" { - if err := writeItem(a, cmd, result, runRow); err != nil { - return err - } - } - if !services.SuccessfulRun(result) { - return errors.New("workflow run did not succeed") - } - return nil - }} - watch.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval") - watch.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to wait") - group.AddCommand(watch) - var forceCancel bool - cancelRun := &cobra.Command{Use: "cancel ", Short: "Cancel a workflow run while preserving its history", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - message := fmt.Sprintf("Cancel run %d", id) - if forceCancel { - message = fmt.Sprintf("Force-cancel run %d without waiting for runners", id) - } - if err := a.confirm(cmd, message); err != nil { - return err - } - item, err := (services.Actions{Client: target.client, Repository: target.Repo}).CancelRun(cmd.Context(), id, forceCancel) - if err != nil { - return err - } - return writeItem(a, cmd, item, runRow) - }} - cancelRun.Flags().BoolVar(&forceCancel, "force", false, "Cancel without waiting for runner acknowledgment") - group.AddCommand(cancelRun) - var failedOnly bool - rerun := &cobra.Command{Use: "rerun ", Short: "Rerun a workflow run", Annotations: textOutput("rerun does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - return (services.Actions{Client: t.client, Repository: t.Repo}).Rerun(cmd.Context(), id, failedOnly) - }} - rerun.Flags().BoolVar(&failedOnly, "failed", false, "Rerun failed jobs only") - group.AddCommand(rerun) - group.AddCommand(&cobra.Command{Use: "delete ", Short: "Delete a workflow run", Annotations: textOutput("run deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - t, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - if err := a.confirm(cmd, fmt.Sprintf("Delete run %d", id)); err != nil { - return err - } - return (services.Actions{Client: t.client, Repository: t.Repo}).DeleteRun(cmd.Context(), id) - }}) - return group -} diff --git a/internal/app/actions_test.go b/internal/app/actions_test.go index f6d7a7f8..56eb8a30 100644 --- a/internal/app/actions_test.go +++ b/internal/app/actions_test.go @@ -7,51 +7,11 @@ import ( "context" "net/http" "testing" - "time" "gitea.dev/tea/internal/clierr" "github.com/stretchr/testify/require" ) -func TestRunTimeBoundsUseOneClockReading(t *testing.T) { - now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC) - deps, _, _ := testDeps(t) - reads := 0 - deps.Now = func() time.Time { reads++; return now } - err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"}) - require.ErrorContains(t, err, "--since must not be later than --until") - require.Equal(t, 1, reads) -} - -func TestRunWatchExitAndOutputContracts(t *testing.T) { - for _, test := range []struct { - name, status, conclusion string - code int - }{ - {"success", "completed", "success", 0}, - {"failed run", "completed", "failure", clierr.Failure}, - {"timeout", "in_progress", "", clierr.Canceled}, - } { - t.Run(test.name, func(t *testing.T) { - server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/api/v1/repos/alice/project/actions/runs/7", r.URL.Path) - _, _ = w.Write([]byte(`{"id":7,"status":"` + test.status + `","conclusion":"` + test.conclusion + `"}`)) - })) - defer server.Close() - deps, out, diagnostics := serverDeps(t, server.URL) - err := Execute(context.Background(), deps, []string{"run", "watch", "7", "--repo", "alice/project", "--json=id,status,conclusion", "--interval=1ms", "--timeout=100ms", "--no-input"}) - require.Equal(t, test.code, clierr.ExitCode(err)) - if test.code == clierr.Canceled { - require.ErrorIs(t, err, context.DeadlineExceeded) - require.Empty(t, out.String()) - } else { - require.JSONEq(t, `{"id":7,"status":"completed","conclusion":"`+test.conclusion+`"}`, out.String()) - } - require.Contains(t, diagnostics.String(), "run 7:") - }) - } -} - func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) { jobPolls := 0 server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 879ddbf5..8167d059 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -161,7 +161,7 @@ func TestSudoSendsHeaderAfterIdentityCheck(t *testing.T) { func TestJSONFieldErrorsListAvailableFields(t *testing.T) { requireUsageBeforeRequests(t, "unknown JSON field \"titel\"\navailable fields: assignees, author, body", "issue", "list", "--json", "number,titel") - requireUsageBeforeRequests(t, "unknown JSON field \"workflow\"\navailable fields: actor, attempt, branch", "run", "list", "--json", "workflow") + requireUsageBeforeRequests(t, "unknown JSON field \"workflw\"\navailable fields: actor, attempt, branch", "run", "list", "--json", "workflw") requireUsageBeforeRequests(t, "--json= needs field names; use --json alone for every field", "pr", "list", "--json=") requireUsageBeforeRequests(t, "version prints text; --jq is not supported", "version", "--jq", ".") } @@ -541,16 +541,6 @@ const ( repo set-default repo sync repo view - run artifact download - run artifact list - run cancel - run delete - run jobs - run list - run logs - run rerun - run view - run watch search issue search pr search repo @@ -595,8 +585,6 @@ const ( release release asset repo - run - run artifact search secret time diff --git a/internal/app/args_test.go b/internal/app/args_test.go index 0c591820..b62cef56 100644 --- a/internal/app/args_test.go +++ b/internal/app/args_test.go @@ -89,7 +89,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) { {[]string{"issue", "view", "1", "2"}, "too many arguments\nUsage: tea issue view [flags]"}, {[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"}, {[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout [flags]"}, - {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [flags]"}, + {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [] [flags]"}, {[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download [flags]"}, {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone [directory] [flags]"}, } { diff --git a/internal/app/artifacts.go b/internal/app/artifacts.go index 2e3c9fa0..0b1ed319 100644 --- a/internal/app/artifacts.go +++ b/internal/app/artifacts.go @@ -4,58 +4,350 @@ package app import ( + "archive/zip" + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" "strconv" + "strings" + "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) func (a *application) artifactCommand() *cobra.Command { - group := &cobra.Command{Use: "artifact", Short: "Inspect and download Actions artifacts"} + group := &cobra.Command{ + Use: "artifact", + Short: "List and download the artifacts of runs", + Long: `List the artifacts that a run uploaded and download one as a ZIP archive. + +To download and extract the artifacts of a run by name, use tea run download.`, + } var limit, page int - list := &cobra.Command{Use: "list ", Short: "List artifacts belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - run, err := number(args[0]) - if err != nil { - return err - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - items, err := (services.Actions{Client: target.client, Repository: target.Repo}).Artifacts(cmd.Context(), run, limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, artifactRow) - }} + list := &cobra.Command{ + Use: "list []", + Short: "List the artifacts of a run", + Example: `# list the artifacts of a run +$ tea run artifact list 34`, + Args: usageArgs(cobra.MaximumNArgs(1)), + Annotations: map[string]string{"tea.arguments": runArguments}, + RunE: func(cmd *cobra.Command, args []string) error { + t, run, err := a.runArg(cmd, args, false) + if err != nil { + return err + } + items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Artifacts(cmd.Context(), run, limit, page) + if err != nil { + return err + } + spec := listSpec[services.Artifact]{Columns: artifactColumns, Row: artifactRow, Empty: fmt.Sprintf("no artifacts in run %d", run), Context: fmt.Sprintf("artifacts of run %d", run)} + return writeTable(a, cmd, items.Items, spec, listPage{Number: page, More: items.More, Total: items.Total, Counted: items.Counted}) + }, + } pageFlags(list, &limit, &page) + list.ValidArgsFunction = a.completeRunArg() group.AddCommand(list) - var output string - download := &cobra.Command{Use: "download ", Short: "Download an artifact ZIP without extracting it", Annotations: textOutput("artifact downloads are binary"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := number(args[0]) - if err != nil { - return err - } - if err := validateDownloadDestination(output); err != nil { - return err - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - body, err := (services.Actions{Client: target.client, Repository: target.Repo}).DownloadArtifact(cmd.Context(), id) - if err != nil { - return err - } - defer body.Close() - return writeDownload(cmd, body, output) - }} - download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout") + + var destination downloadFlags + download := &cobra.Command{ + Use: "download ", + Short: "Download an artifact as a ZIP archive", + Long: "Download an artifact as a ZIP archive without extracting it, named after the artifact by default.", + Example: `# download artifact 7 to .zip +$ tea run artifact download 7 +# write it to standard output +$ tea run artifact download 7 --output - > build.zip`, + Annotations: textOutput("artifact downloads are binary"), + Args: argsExact(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + service := services.Actions{Client: t.client, Repository: t.Repo} + name := "" + if destination.output == "" || isDirectory(destination.output) { + artifact, err := service.Artifact(cmd.Context(), id) + if err != nil { + return err + } + name = artifact.Name + ".zip" + } + path, skip, err := destination.destination(name) + if err != nil || skip { + return err + } + body, err := service.DownloadArtifact(cmd.Context(), id) + if err != nil { + return err + } + defer body.Close() + if err := a.download(cmd, destination, path, body); err != nil { + return err + } + if path != "-" { + a.done(cmd, "Downloaded artifact %d to %s", id, path) + } + return nil + }, + } + destination.register(download) group.AddCommand(download) return group } -func artifactRow(item services.Artifact) []terminal.Cell { - return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), strconv.FormatBool(item.Expired)) +// artifactColumns head artifactRow: sizes and expiry for people on a terminal, bytes and times +// for scripts. +var artifactColumns = []terminal.Column{ + {Header: "ID"}, + {Header: "Name", Flex: true}, + {Header: "Size", TTYOnly: true}, + {Header: "Size", PipedOnly: true}, + {Header: "Expires", TTYOnly: true}, + {Header: "Expires", PipedOnly: true}, + {Header: "Expired", PipedOnly: true}, +} + +func artifactRow(item services.Artifact) []terminal.Cell { + expires := terminal.Cell{} + expiresAt := "" + if item.ExpiresAt != nil { + expiresAt = *item.ExpiresAt + expires.Time = terminal.ParseTime(expiresAt) + } + if item.Expired { + expires = terminal.Cell{Text: "expired", Color: "red"} + } + return []terminal.Cell{ + {Text: strconv.FormatInt(item.ID, 10)}, + {Text: item.Name}, + {Text: terminal.Bytes(item.Size)}, + {Text: strconv.FormatInt(item.Size, 10)}, + expires, + {Text: expiresAt}, + {Text: strconv.FormatBool(item.Expired)}, + } +} + +func (a *application) runDownloadCommand() *cobra.Command { + var names []string + var asZip bool + var destination downloadFlags + cmd := &cobra.Command{ + Use: "download []", + Short: "Download the artifacts of a run", + Long: `Download the artifacts of a run and extract them. + +Each artifact is extracted into a directory named after it, except a single artifact selected +with --name, which is extracted into the destination directory itself. With --zip, the +artifacts are saved as ZIP archives instead.`, + Example: `# download every artifact of a run into .// +$ tea run download 34 +# extract one artifact into dist/ +$ tea run download 34 --name build --dir dist +# save the artifacts as ZIP archives +$ tea run download 34 --zip`, + Args: usageArgs(cobra.MaximumNArgs(1)), + Annotations: map[string]string{"tea.arguments": runArguments, "tea.text": "run download writes files"}, + RunE: func(cmd *cobra.Command, args []string) error { + if destination.output != "" && (!asZip || len(names) != 1) { + return clierr.Wrap(clierr.Usage, errors.New("--output saves one archive; combine it with --zip and a single --name, or use --dir")) + } + t, run, err := a.runArg(cmd, args, false) + if err != nil { + return err + } + service := services.Actions{Client: t.client, Repository: t.Repo} + artifacts, err := service.AllArtifacts(cmd.Context(), run) + if err != nil { + return err + } + selected, err := selectArtifacts(artifacts, names, run) + if err != nil { + return err + } + if !slices.ContainsFunc(selected, func(artifact services.Artifact) bool { return !artifact.Expired }) { + return fmt.Errorf("the artifacts of run %d have expired", run) + } + for _, artifact := range selected { + if artifact.Expired { + a.warn(cmd, "Artifact %s has expired; skipped", artifact.Name) + continue + } + path, err := a.downloadArtifact(cmd, service, artifact, destination, asZip, len(names) != 1) + if err != nil { + return fmt.Errorf("artifact %s: %w", terminal.SafeLine(artifact.Name), err) + } + if path != "" { + a.done(cmd, "Downloaded artifact %s to %s", artifact.Name, path) + } + } + return nil + }, + } + destination.register(cmd) + cmd.Flags().StringSliceVarP(&names, "name", "n", nil, "Only download the artifacts with these names") + cmd.Flags().BoolVar(&asZip, "zip", false, "Save each artifact as a ZIP archive instead of extracting it") + cmd.ValidArgsFunction = a.completeRunArg() + _ = cmd.RegisterFlagCompletionFunc("name", commaSeparated(a.completeArtifactNames())) + return cmd +} + +// selectArtifacts returns the artifacts of a run with the given names, or all of them. +func selectArtifacts(artifacts []services.Artifact, names []string, run int64) ([]services.Artifact, error) { + if len(artifacts) == 0 { + return nil, fmt.Errorf("run %d has no artifacts", run) + } + if len(names) == 0 { + return artifacts, nil + } + selected := make([]services.Artifact, 0, len(names)) + for _, name := range names { + index := slices.IndexFunc(artifacts, func(artifact services.Artifact) bool { return artifact.Name == name }) + if index < 0 { + available := make([]string, len(artifacts)) + for i, artifact := range artifacts { + available[i] = artifact.Name + } + return nil, fmt.Errorf("artifact %q not found in run %d (artifacts: %s)", name, run, strings.Join(available, ", ")) + } + selected = append(selected, artifacts[index]) + } + return selected, nil +} + +// downloadArtifact saves an artifact as a ZIP archive, or extracts it into the destination +// directory, or into a directory named after it inside, with isolate. It returns where the +// artifact went, or "" when --skip-existing kept an existing archive. +func (a *application) downloadArtifact(cmd *cobra.Command, service services.Actions, artifact services.Artifact, f downloadFlags, asZip, isolate bool) (string, error) { + if asZip { + path, skip, err := f.destination(artifact.Name + ".zip") + if err != nil || skip { + return "", err + } + body, err := service.DownloadArtifact(cmd.Context(), artifact.ID) + if err != nil { + return "", err + } + defer body.Close() + return path, a.download(cmd, f, path, body) + } + if f.clobber && f.skipExisting { + return "", clierr.Wrap(clierr.Usage, errors.New("--clobber and --skip-existing cannot be combined")) + } + dir := cmp.Or(f.dir, ".") + if isolate { + if !filepath.IsLocal(artifact.Name) || strings.ContainsAny(artifact.Name, `/\`) { + return "", fmt.Errorf("cannot use %q as a directory name", artifact.Name) + } + dir = filepath.Join(dir, artifact.Name) + } + body, err := service.DownloadArtifact(cmd.Context(), artifact.ID) + if err != nil { + return "", err + } + defer body.Close() + return dir, extractZip(cmd.Context(), body, dir, f.clobber, f.skipExisting) +} + +// extractZip extracts the ZIP archive in body into dir. Entries outside dir, links and other +// special files are refused, and an existing file stops the extraction before anything is +// written, unless clobber replaces or skipExisting keeps it. +func extractZip(ctx context.Context, body io.Reader, dir string, clobber, skipExisting bool) error { + archive, err := os.CreateTemp("", "tea-artifact-*.zip") + if err != nil { + return err + } + defer os.Remove(archive.Name()) + defer archive.Close() + size, err := io.Copy(archive, body) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + reader, err := zip.NewReader(archive, size) + if err != nil { + return fmt.Errorf("invalid ZIP archive: %w", err) + } + for _, entry := range reader.File { + name := filepath.FromSlash(entry.Name) + mode := entry.Mode() + switch { + case !filepath.IsLocal(name): + return fmt.Errorf("refusing to extract %q outside %s", entry.Name, dir) + case !mode.IsRegular() && !mode.IsDir(): + return fmt.Errorf("refusing to extract %q: not a regular file", entry.Name) + } + if _, err := os.Lstat(filepath.Join(dir, name)); err == nil && !mode.IsDir() && !clobber && !skipExisting { + return clierr.Wrap(clierr.Usage, fmt.Errorf("%s already exists; pass --clobber to replace it or --skip-existing to keep it", filepath.Join(dir, name))) + } + } + for _, entry := range reader.File { + path := filepath.Join(dir, filepath.FromSlash(entry.Name)) + if entry.Mode().IsDir() { + if err := os.MkdirAll(path, 0o777); err != nil { + return err + } + continue + } + if err := extractFile(entry, path, clobber, skipExisting); err != nil { + return err + } + } + return nil +} + +// extractFile writes one archive entry to path with the permissions the umask allows. +func extractFile(entry *zip.File, path string, clobber, skipExisting bool) error { + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + return err + } + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if clobber { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + file, err := os.OpenFile(path, flags, 0o666) + if errors.Is(err, os.ErrExist) && skipExisting { + return nil + } + if err != nil { + return err + } + content, err := entry.Open() + if err != nil { + _ = file.Close() + return err + } + _, copyErr := io.Copy(file, content) + return cmp.Or(copyErr, content.Close(), file.Close()) +} + +// completeArtifactNames completes the names of the artifacts of the run given as the first argument. +func (a *application) completeArtifactNames() 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) { + artifacts, err := services.Actions{Client: t.client, Repository: t.Repo}.AllArtifacts(ctx, run.Number) + return describe(artifacts, func(artifact services.Artifact) (string, string) { return artifact.Name, terminal.Bytes(artifact.Size) }), err + })(cmd, args, toComplete) + } } diff --git a/internal/app/artifacts_test.go b/internal/app/artifacts_test.go index 2f6b0551..d64cde84 100644 --- a/internal/app/artifacts_test.go +++ b/internal/app/artifacts_test.go @@ -4,12 +4,15 @@ package app import ( + "archive/zip" + "bytes" "context" "net/http" "os" "path/filepath" "testing" + "gitea.dev/tea/internal/clierr" "github.com/stretchr/testify/require" ) @@ -18,6 +21,8 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ switch r.URL.Path { + case "/api/v1/repos/owner/repo/actions/artifacts/9": + _, _ = w.Write([]byte(`{"id":9,"name":"build"}`)) case "/api/v1/repos/owner/repo/actions/artifacts/9/zip": require.Equal(t, "token token", r.Header.Get("Authorization")) http.Redirect(w, r, "/signed?signature=private", http.StatusFound) @@ -31,24 +36,130 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing })) defer server.Close() deps, out, diagnostics := serverDeps(t, server.URL) - args := []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"} - require.NoError(t, Execute(context.Background(), deps, args)) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"})) require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes()) require.Empty(t, diagnostics.String()) - output := filepath.Join(t.TempDir(), "artifact.zip") - args[len(args)-1] = output + + dir := t.TempDir() out.Reset() + args := []string{"run", "artifact", "download", "#9", "-R", "owner/repo", "--dir", dir} require.NoError(t, Execute(context.Background(), deps, args)) - data, err := os.ReadFile(output) + data, err := os.ReadFile(filepath.Join(dir, "build.zip")) require.NoError(t, err) require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data) require.Empty(t, out.String()) before := requests - err = Execute(context.Background(), deps, args) - require.ErrorContains(t, err, "already exists") + err = Execute(context.Background(), deps, append(args, "--output", filepath.Join(dir, "build.zip"))) + require.ErrorContains(t, err, "--output and --dir cannot be combined") + err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output", filepath.Join(dir, "build.zip")}) + require.ErrorContains(t, err, "already exists; pass --clobber") require.Equal(t, before, requests) - args[len(args)-1] = "-" - err = Execute(context.Background(), deps, append(args, "--json")) + err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "-O", "-", "--json"}) require.ErrorContains(t, err, "binary") require.Equal(t, before, requests) } + +// zipOf returns a ZIP archive of files, by name. +func zipOf(t *testing.T, files map[string]string) string { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for name, content := range files { + file, err := writer.Create(name) + require.NoError(t, err) + _, err = file.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + return archive.String() +} + +func TestRunDownloadExtractsArtifactsByName(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/runs/29/artifacts": `{"total_count":3,"artifacts":[{"id":1,"name":"build","size_in_bytes":3},{"id":2,"name":"coverage"},{"id":3,"name":"old","expired":true}]}`, + "GET actions/artifacts/1/zip": zipOf(t, map[string]string{"bin/tea": "tea", "README": "read me"}), + "GET actions/artifacts/2/zip": zipOf(t, map[string]string{"cover.out": "100%"}), + "GET actions/artifacts/4/zip": zipOf(t, map[string]string{"../escape": "x"}), + }) + for _, tc := range []struct { + name string + args []string + files map[string]string + err string + out string + }{ + { + "every artifact in its own directory", nil, + map[string]string{"build/bin/tea": "tea", "build/README": "read me", "coverage/cover.out": "100%"}, + "", + "✓ Downloaded artifact build to {dir}/build\n✓ Downloaded artifact coverage to {dir}/coverage\n! Artifact old has expired; skipped\n", + }, + {"one artifact into the directory", []string{"-n", "coverage"}, map[string]string{"cover.out": "100%"}, "", "✓ Downloaded artifact coverage to {dir}\n"}, + { + "archives", + []string{"--zip", "--name", "build,coverage"}, + map[string]string{"build.zip": "", "coverage.zip": ""}, + "", + "✓ Downloaded artifact build to {dir}/build.zip\n✓ Downloaded artifact coverage to {dir}/coverage.zip\n", + }, + {"unknown name", []string{"-n", "docs"}, nil, `artifact "docs" not found in run 29 (artifacts: build, coverage, old)`, ""}, + {"expired", []string{"-n", "old"}, nil, "the artifacts of run 29 have expired", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + deps, _, diagnostics := actionsDeps(t, server.URL, true) + err := Execute(context.Background(), deps, append([]string{"run", "download", "29", "-R", "alice/project", "-D", dir}, tc.args...)) + if tc.err != "" { + require.EqualError(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, filepathReplace(tc.out, dir), diagnostics.String()) + for name, content := range tc.files { + data, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err, name) + if content != "" { + require.Equal(t, content, string(data), name) + } + } + }) + } + t.Run("existing files and unsafe paths", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.out"), []byte("mine"), 0o600)) + deps, _, _ := actionsDeps(t, server.URL, false) + err := Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.ErrorContains(t, err, "cover.out already exists; pass --clobber to replace it or --skip-existing to keep it") + require.NoError(t, Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage", "--skip-existing"})) + data, err := os.ReadFile(filepath.Join(dir, "cover.out")) + require.NoError(t, err) + require.Equal(t, "mine", string(data)) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage", "--clobber"})) + data, err = os.ReadFile(filepath.Join(dir, "cover.out")) + require.NoError(t, err) + require.Equal(t, "100%", string(data)) + require.ErrorContains(t, extractZip(context.Background(), bytes.NewReader([]byte(zipOf(t, map[string]string{"../escape": "x"}))), dir, false, false), `refusing to extract "../escape"`) + _, err = os.Stat(filepath.Join(filepath.Dir(dir), "escape")) + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + +// filepathReplace puts dir in place of {dir} in text. +func filepathReplace(text, dir string) string { + return string(bytes.ReplaceAll([]byte(text), []byte("{dir}"), []byte(dir))) +} + +func TestArtifactListShowsSizesAndExpiry(t *testing.T) { + server, _ := actionsAPI(t, map[string]string{ + "GET actions/runs/29/artifacts": `{"total_count":2,"artifacts":[{"id":1,"name":"build","size_in_bytes":3145728,"expires_at":"2028-03-01T12:00:00Z"},{"id":3,"name":"old","size_in_bytes":13,"expired":true,"expires_at":"2027-12-01T12:00:00Z"}]}`, + }) + for tty, want := range map[bool]string{ + true: "Showing 2 of 2 artifacts of run 29\n\nID NAME SIZE EXPIRES\n1 build 3.0 MiB in about 2 months\n3 old 13 B expired\n", + false: "1\tbuild\t3145728\t2028-03-01T12:00:00Z\tfalse\n3\told\t13\t2027-12-01T12:00:00Z\ttrue\n", + } { + deps, out, _ := actionsDeps(t, server.URL, tty) + require.NoError(t, Execute(context.Background(), deps, []string{"run", "artifact", "list", "29", "-R", "alice/project"})) + require.Equal(t, want, out.String(), "tty=%t", tty) + } +} diff --git a/internal/services/action_logs.go b/internal/services/action_logs.go index 414796ef..cab31796 100644 --- a/internal/services/action_logs.go +++ b/internal/services/action_logs.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" @@ -21,8 +22,22 @@ import ( // Job identifies one workflow job without exposing server-specific metadata. StartedAt and // CompletedAt are nil until the job starts and completes. type Job struct { - RunAttempt int64 `json:"run_attempt"` - ID int64 `json:"id"` + RunAttempt int64 `json:"run_attempt"` + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + Labels []string `json:"labels"` + CreatedAt string `json:"created_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + Steps []Step `json:"steps"` +} + +// Step is one step of a job. StartedAt and CompletedAt are nil until the step starts and completes. +type Step struct { + Number int64 `json:"number"` Name string `json:"name"` Status string `json:"status"` Conclusion string `json:"conclusion"` @@ -30,14 +45,44 @@ type Job struct { CompletedAt *string `json:"completed_at"` } -// Jobs lists every page of jobs in the selected run. +// Completed reports whether the job finished, whatever its conclusion. +func (j Job) Completed() bool { return completedActionStatus(j.Status) } + +// Jobs lists every job in the selected run. func (s Actions) Jobs(ctx context.Context, run int64) ([]Job, error) { - return listAll(func(page int) ([]Job, error) { return s.jobsPage(ctx, run, 100, page) }) + jobs := []Job{} + for page := 1; page <= maxListPages; page++ { + window, err := s.JobsPage(ctx, run, 100, page) + if err != nil { + return nil, err + } + jobs = append(jobs, window.Items...) + if !window.More || len(window.Items) == 0 { + return jobs, nil + } + } + return nil, fmt.Errorf("listing exceeded %d pages", maxListPages) } // JobsPage lists a page of jobs in the selected run. -func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) { - return s.jobsPage(ctx, run, limit, page) +func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page[Job], error) { + window, err := listPage(limit, page, func(limit, page int) ([]*gitea.ActionsWorkflowJob, http.Header, error) { + response, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, headerOf(resp), err + } + return response.Jobs, headerOf(resp), nil + }) + if err != nil { + return Page[Job]{}, err + } + return Page[Job]{Items: convertAll(window.Items, jobFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil } // WaitForFirstJob polls a freshly dispatched run until the server has created its first job. @@ -59,20 +104,18 @@ func (s Actions) WaitForFirstJob(ctx context.Context, run int64, interval time.D } } -func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) { - response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) { - return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)}) - }) - if err != nil { - return nil, err - } - return convertAll(response.Jobs, jobFrom), nil -} - func jobFrom(job *gitea.ActionsWorkflowJob) Job { return Job{ RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion, - StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt), + URL: job.HTMLURL, Labels: append([]string{}, job.Labels...), CreatedAt: timestamp(job.CreatedAt), + StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt), Steps: convertAll(job.Steps, stepFrom), + } +} + +func stepFrom(step *gitea.ActionsWorkflowStep) Step { + return Step{ + Number: step.Number, Name: step.Name, Status: step.Status, Conclusion: step.Conclusion, + StartedAt: actionTime(step.StartedAt), CompletedAt: actionTime(step.CompletedAt), } } @@ -187,9 +230,11 @@ func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time } } -// RunLogsArchive streams the server's ZIP archive without extracting its entries. +// RunLogsArchive streams the server's ZIP archive without extracting its entries. A run without +// logs, such as one that never started, is reported as not found. func (s Actions) RunLogsArchive(ctx context.Context, run int64) (io.ReadCloser, error) { - return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) { + body, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) { return c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run) }) + return body, Classify(err, "logs of run", strconv.FormatInt(run, 10), s.Repository) } diff --git a/internal/services/action_logs_test.go b/internal/services/action_logs_test.go index 90da8027..e157619f 100644 --- a/internal/services/action_logs_test.go +++ b/internal/services/action_logs_test.go @@ -110,11 +110,20 @@ func TestJobsPageMapsJobsAndSendsPagination(t *testing.T) { require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path) require.Equal(t, "20", r.URL.Query().Get("limit")) require.Equal(t, "3", r.URL.Query().Get("page")) - _, _ = w.Write([]byte(`{"total_count":1,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded"}]}`)) + w.Header().Set("X-Total-Count", "41") + _, _ = w.Write([]byte(`{"total_count":41,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded", + "labels":["ubuntu-latest"],"html_url":"https://gitea.test/owner/repo/actions/runs/7/jobs/9","created_at":"2028-01-01T00:00:00Z", + "started_at":"2028-01-01T00:00:01Z","completed_at":"1970-01-01T00:00:00Z", + "steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T00:00:01Z","completed_at":"2028-01-01T00:00:02Z"}]}]}`)) })) jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3) require.NoError(t, err) - require.Equal(t, []Job{{RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success"}}, jobs) + started, stepStarted, stepCompleted := "2028-01-01T00:00:01Z", "2028-01-01T00:00:01Z", "2028-01-01T00:00:02Z" + require.Equal(t, Page[Job]{Items: []Job{{ + RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success", URL: "https://gitea.test/owner/repo/actions/runs/7/jobs/9", + Labels: []string{"ubuntu-latest"}, CreatedAt: "2028-01-01T00:00:00Z", StartedAt: &started, + Steps: []Step{{Number: 1, Name: "Set up job", Status: "completed", Conclusion: "success", StartedAt: &stepStarted, CompletedAt: &stepCompleted}}, + }}, Total: 41, Counted: true}, jobs) } func TestRunLogsArchiveStreams(t *testing.T) { @@ -151,7 +160,8 @@ func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) { defer cancel() job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond) require.NoError(t, err) - require.Equal(t, Job{ID: 11, Name: "build"}, job) + require.Equal(t, int64(11), job.ID) + require.Equal(t, "build", job.Name) _, err = service.WaitForFirstJob(ctx, 7, 0) require.ErrorContains(t, err, "interval must be positive") } diff --git a/internal/services/action_runs.go b/internal/services/action_runs.go new file mode 100644 index 00000000..4ec822a6 --- /dev/null +++ b/internal/services/action_runs.go @@ -0,0 +1,347 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + gitea "gitea.dev/sdk" +) + +// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil +// until the run starts and completes. Workflow is the workflow's name and WorkflowPath its file +// name; Jobs is only filled where commands show a single run. +type Run struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + ID int64 `json:"id"` + RunNumber int64 `json:"run_number"` + Title string `json:"title"` + Workflow string `json:"workflow"` + WorkflowPath string `json:"workflow_path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + Branch string `json:"branch"` + Event string `json:"event,omitempty"` + Actor string `json:"actor,omitempty"` + Commit string `json:"commit,omitempty"` + Attempt int64 `json:"attempt,omitempty"` + Jobs []Job `json:"jobs,omitempty"` +} + +// Completed reports whether the run finished, whatever its conclusion. +func (r Run) Completed() bool { return completedActionStatus(r.Status) } + +type runResponse struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ID int64 `json:"id"` + RunNumber int64 `json:"run_number"` + DisplayTitle string `json:"display_title"` + Path string `json:"path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + HeadBranch string `json:"head_branch"` + Event string `json:"event"` + HeadSHA string `json:"head_sha"` + RunAttempt int64 `json:"run_attempt"` + Actor struct { + Login string `json:"login"` + } `json:"actor"` +} + +// result converts the response; until the run is named after its workflow, Workflow is the +// workflow's file name without extension. +func (r runResponse) result() Run { + file, _, _ := strings.Cut(r.Path, "@") + return Run{ + ID: r.ID, RunNumber: r.RunNumber, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, + Branch: r.HeadBranch, Workflow: strings.TrimSuffix(file, path.Ext(file)), WorkflowPath: file, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt), + Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt, + } +} + +// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports +// for a run or job that has not started or completed. +func runTime(raw string) *string { + t, err := time.Parse(time.RFC3339Nano, raw) + if raw == "" || (err == nil && t.Unix() <= 0) { + return nil + } + return &raw +} + +// RunListOptions supplies server-side run filters and pagination. Workflow is a workflow file +// name. +type RunListOptions struct { + Since, Until *time.Time + Limit, Page int + Branch, Event, Actor, Commit, Workflow string + Statuses []string +} + +// RunStatuses are the values Gitea accepts as run status filters. +var RunStatuses = []string{"queued", "in_progress", "waiting", "requested", "pending", "action_required", "completed", "success", "failure", "cancelled", "skipped", "timed_out", "neutral"} //nolint:misspell // Gitea API spelling. + +// Validate rejects unsupported status filters before performing API requests. +func (o RunListOptions) Validate() error { + if o.Limit < 1 || o.Limit > 1000 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit { + return errors.New("invalid pagination") + } + for _, status := range o.Statuses { + if !slices.Contains(RunStatuses, status) { + return fmt.Errorf("unsupported run status %q; see tea run list --help", status) + } + } + return nil +} + +const maxRunScanPages = 50 + +// Runs lists a page of runs, newest first. +func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) { + page, err := s.runs(ctx, opts) + return page.Items, err +} + +// RunsPage lists a page of runs like Runs, named after their workflows, with the number of runs +// the filters match when the server counts them. +func (s Actions) RunsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) { + page, err := s.runs(ctx, opts) + if err != nil { + return page, err + } + return page, s.nameRuns(ctx, page.Items) +} + +func (s Actions) runs(ctx context.Context, opts RunListOptions) (Page[Run], error) { + if err := opts.Validate(); err != nil { + return Page[Run]{}, err + } + if opts.Since == nil && opts.Until == nil { + return s.runsPage(ctx, opts) + } + first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit + result := []Run{} + matched := 0 + for page := 1; page <= maxRunScanPages; page++ { + scan := opts + scan.Limit = 100 + scan.Page = page + window, err := s.runsPage(ctx, scan) + if err != nil { + return Page[Run]{}, err + } + for _, item := range window.Items { + created, err := time.Parse(time.RFC3339Nano, item.CreatedAt) + if err != nil { + return Page[Run]{}, errors.New("server returned an invalid run creation timestamp") + } + // Runs are listed newest first, so the rest were created earlier still. + if opts.Since != nil && created.Before(*opts.Since) { + return Page[Run]{Items: result}, nil + } + if opts.Until != nil && created.After(*opts.Until) { + continue + } + if matched >= last { + return Page[Run]{Items: result, More: true}, nil + } + if matched >= first { + result = append(result, item) + } + matched++ + } + if len(window.Items) == 0 || !window.More { + return Page[Run]{Items: result}, nil + } + } + return Page[Run]{}, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100) +} + +func (s Actions) runsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) { + query := url.Values{} + for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} { + if value != "" { + query.Set(key, value) + } + } + for _, status := range opts.Statuses { + query.Add("status", status) + } + endpoint := s.endpoint() + "runs" + if opts.Workflow != "" { + endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs" + } + return listPage(opts.Limit, opts.Page, func(limit, page int) ([]Run, http.Header, error) { + query.Set("limit", strconv.Itoa(limit)) + query.Set("page", strconv.Itoa(page)) + var response struct { + Runs []runResponse `json:"workflow_runs"` + } + // TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at. + headers, err := rawJSON(ctx, s.Client, http.MethodGet, endpoint+"?"+query.Encode(), nil, &response) + runs := make([]Run, len(response.Runs)) + for i, item := range response.Runs { + runs[i] = item.result() + } + return runs, headers, err + }) +} + +// nameRuns names runs after their workflows. +func (s Actions) nameRuns(ctx context.Context, runs []Run) error { + if len(runs) == 0 { + return nil + } + names, err := s.workflowNames(ctx) + for i := range runs { + if name := names[runs[i].WorkflowPath]; name != "" { + runs[i].Workflow = name + } + } + return err +} + +func (s Actions) workflowNames(ctx context.Context) (map[string]string, error) { + workflows, err := s.Workflows(ctx) + names := make(map[string]string, len(workflows)) + for _, workflow := range workflows { + names[workflow.ID] = workflow.Name + } + return names, err +} + +// Run retrieves one Actions run. +func (s Actions) Run(ctx context.Context, id int64) (Run, error) { + var response runResponse + // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. + err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response) + return response.result(), err +} + +// RunDetails retrieves one run with its jobs, named after its workflow. +func (s Actions) RunDetails(ctx context.Context, id int64) (Run, error) { + names, err := s.workflowNames(ctx) + if err != nil { + return Run{}, err + } + return s.runDetails(ctx, id, names) +} + +func (s Actions) runDetails(ctx context.Context, id int64, names map[string]string) (Run, error) { + run, err := s.Run(ctx, id) + if err != nil { + return Run{}, err + } + if run.Jobs, err = s.Jobs(ctx, id); err != nil { + return Run{}, err + } + if name := names[run.WorkflowPath]; name != "" { + run.Workflow = name + } + return run, nil +} + +// Rerun schedules another attempt of a run, restricted to its failed jobs with failedOnly, or of +// one job when job is not 0. +func (s Actions) Rerun(ctx context.Context, id, job int64, failedOnly bool) error { + err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + switch { + case job != 0: + _, resp, err := c.Actions.RerunRepoRunJob(ctx, owner, repo, id, job) + return resp, err + case failedOnly: + return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id) + } + _, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id) + return resp, err + }) + return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository) +} + +// DeleteRun removes the selected run. +func (s Actions) DeleteRun(ctx context.Context, id int64) error { + err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Actions.DeleteRepoRun(ctx, owner, repo, id) + }) + return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository) +} + +func completedActionStatus(status string) bool { + switch status { + case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling. + return true + } + return false +} + +// SuccessfulRun recognizes a successful terminal run result. +func SuccessfulRun(run Run) bool { + return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success") +} + +// Watch polls a run with its jobs every interval until it completes, reporting each poll to +// update, and returns the completed run. When it fails, such as when ctx expires, it returns the +// run as last polled. +func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, update func(Run) error) (Run, error) { + if interval <= 0 { + return Run{}, errors.New("watch interval must be positive") + } + names, err := s.workflowNames(ctx) + if err != nil { + return Run{}, err + } + var last Run + for { + current, err := s.runDetails(ctx, id, names) + if err != nil { + return last, err + } + last = current + if update != nil { + if err := update(current); err != nil { + return last, err + } + } + if current.Completed() { + return current, nil + } + if err := sleepCtx(ctx, interval); err != nil { + return last, err + } + } +} + +// CancelRun requests cancellation without deleting the run or its history. +// Force cancellation is an explicit operation for unresponsive runners. +func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) { + action := "cancel" + if force { + action = "force-cancel" + } + var response runResponse + // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. + err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response) + if err != nil { + return Run{}, Classify(err, "run", strconv.FormatInt(id, 10), s.Repository) + } + return response.result(), nil +} diff --git a/internal/services/actions.go b/internal/services/actions.go index e9a6bcb1..ef79f747 100644 --- a/internal/services/actions.go +++ b/internal/services/actions.go @@ -5,82 +5,43 @@ package services import ( "context" + "encoding/base64" "errors" "fmt" "net/http" - "net/url" + "path" + "slices" "strconv" - "time" + "strings" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" + "gopkg.in/yaml.v3" ) -// Workflow is the stable command representation of an Actions workflow. +// Workflow is the stable command representation of an Actions workflow. ID is its file name, +// such as ci.yml. type Workflow struct { ID string `json:"id"` Name string `json:"name"` Path string `json:"path"` State string `json:"state"` + URL string `json:"url"` } func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow { - return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State} + return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State, URL: workflow.HTMLURL} } -// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil -// until the run starts and completes. -type Run struct { - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - ID int64 `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - URL string `json:"url"` - Branch string `json:"branch"` - Event string `json:"event,omitempty"` - Actor string `json:"actor,omitempty"` - Commit string `json:"commit,omitempty"` - Attempt int64 `json:"attempt,omitempty"` -} -type runResponse struct { - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at"` - ID int64 `json:"id"` - DisplayTitle string `json:"display_title"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - HTMLURL string `json:"html_url"` - HeadBranch string `json:"head_branch"` - Event string `json:"event"` - HeadSHA string `json:"head_sha"` - RunAttempt int64 `json:"run_attempt"` - Actor struct { - Login string `json:"login"` - } `json:"actor"` -} +// Enabled reports whether the workflow runs; Gitea reports a disabled one as disabled_manually. +func (w Workflow) Enabled() bool { return !strings.HasPrefix(w.State, "disabled") } -func (r runResponse) result() Run { - return Run{ - ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch, - CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt), - Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt, +// Label names the workflow for people: "CI (ci.yml)". +func (w Workflow) Label() string { + if w.Name == "" || w.Name == w.ID { + return w.ID } -} - -// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports -// for a run or job that has not started or completed. -func runTime(raw string) *string { - t, err := time.Parse(time.RFC3339Nano, raw) - if raw == "" || (err == nil && t.Unix() <= 0) { - return nil - } - return &raw + return w.Name + " (" + w.ID + ")" } // DispatchResult identifies a workflow run created by dispatch when the server returns details. @@ -108,20 +69,62 @@ func (s Actions) Workflows(ctx context.Context) ([]Workflow, error) { return convertAll(response.Workflows, workflowFrom), nil } -// Workflow retrieves one workflow definition. +// Workflow retrieves one workflow definition by its file name. func (s Actions) Workflow(ctx context.Context, id string) (Workflow, error) { return repoConvert(ctx, s.Client, s.Repository, workflowFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflow, *gitea.Response, error) { return c.Actions.GetRepoWorkflow(ctx, owner, repo, id) }) } +// FindWorkflow returns the workflow that raw names in the repository: its file name, path, file +// name without extension, or name, matched exactly and then case-insensitively. +func (s Actions) FindWorkflow(ctx context.Context, raw string) (Workflow, error) { + items, err := s.Workflows(ctx) + if err != nil { + return Workflow{}, err + } + return FindWorkflow(items, raw, s.Repository) +} + +// FindWorkflow returns the workflow among items that raw names, like Actions.FindWorkflow. It +// fails with a *NotFoundError when none matches and lists the files when several names match. +func FindWorkflow(items []Workflow, raw, scope string) (Workflow, error) { + raw = strings.TrimSpace(raw) + keys := func(w Workflow) []string { + return []string{w.ID, w.Path, strings.TrimSuffix(w.ID, path.Ext(w.ID)), w.Name} + } + for _, fold := range []bool{false, true} { + var matches []Workflow + for _, item := range items { + if slices.ContainsFunc(keys(item), func(key string) bool { return key == raw || (fold && strings.EqualFold(key, raw)) }) { + matches = append(matches, item) + } + } + switch len(matches) { + case 0: + continue + case 1: + return matches[0], nil + } + files := make([]string, len(matches)) + for i, match := range matches { + files[i] = match.ID + } + return Workflow{}, fmt.Errorf("workflow %q is ambiguous in %s (%s); pass the file name", raw, scope, strings.Join(files, ", ")) + } + return Workflow{}, NotFound("workflow", strconv.Quote(raw), scope) +} + // Dispatch triggers a workflow and reports the run it created. func (s Actions) Dispatch(ctx context.Context, id, ref string, inputs map[string]string) (DispatchResult, error) { details, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.RunDetails, *gitea.Response, error) { return c.Actions.DispatchRepoWorkflow(ctx, owner, repo, id, gitea.CreateActionsWorkflowDispatchOption{Ref: ref, Inputs: inputs}, true) }) + if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && strings.Contains(api.Message, "ref") { + return DispatchResult{}, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err} + } if err != nil { - return DispatchResult{}, err + return DispatchResult{}, Classify(err, "workflow", id, s.Repository) } return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil } @@ -136,181 +139,104 @@ func (s Actions) SetWorkflowEnabled(ctx context.Context, id string, enabled bool }) } -// RunListOptions supplies server-side run filters and pagination. -type RunListOptions struct { - Since, Until *time.Time - Limit, Page int - Branch, Event, Actor, Commit, Workflow string - Statuses []string +// WorkflowFile returns the workflow's YAML definition at ref, a branch, tag or commit, or on the +// default branch when ref is empty. +func (s Actions) WorkflowFile(ctx context.Context, workflow Workflow, ref string) ([]byte, error) { + contents, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ContentsResponse, *gitea.Response, error) { + return c.Repositories.GetContents(ctx, owner, repo, ref, workflow.Path) + }) + if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound { + // Gitea reports an unknown ref as "object does not exist [id: REF, rel_path: ]". + if ref != "" && strings.Contains(api.Message, "[id: "+ref+",") { + return nil, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err} + } + return nil, &NotFoundError{Kind: "workflow file", Key: workflow.Path, Scope: s.Repository, Err: err} + } + if err != nil { + return nil, err + } + if contents.Content == nil { + return nil, fmt.Errorf("%s is not a file in %s", workflow.Path, s.Repository) + } + data, err := base64.StdEncoding.DecodeString(*contents.Content) + if err != nil { + return nil, fmt.Errorf("invalid contents of %s: %w", workflow.Path, err) + } + return data, nil } -// Validate rejects unsupported status filters before performing API requests. -func (o RunListOptions) Validate() error { - if o.Limit < 1 || o.Limit > 100 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit { - return errors.New("invalid pagination") +// WorkflowInput is one input that a workflow's workflow_dispatch trigger declares. +type WorkflowInput struct { + Name, Description string + // Type is string, choice, boolean, number or environment; empty means string. + Type, Default string + Required bool + Options []string +} + +// ErrNoDispatch reports a workflow that has no workflow_dispatch trigger. +var ErrNoDispatch = errors.New("workflow has no workflow_dispatch trigger") + +// DispatchInputs parses the inputs that the workflow_dispatch trigger of a workflow definition +// declares, in their order. It fails with ErrNoDispatch when the workflow cannot be dispatched. +func DispatchInputs(definition []byte) ([]WorkflowInput, error) { + var workflow struct { + On yaml.Node `yaml:"on"` } - for _, status := range o.Statuses { - switch status { - case "pending", "waiting", "requested", "action_required", "queued", "in_progress", "completed", "failure", "success", "skipped", "neutral", "cancelled", "timed_out": //nolint:misspell // Gitea API spelling. - default: - return fmt.Errorf("unsupported run status %q; see tea run list --help", status) + if err := yaml.Unmarshal(definition, &workflow); err != nil { + return nil, fmt.Errorf("invalid workflow definition: %w", err) + } + on := workflow.On + switch on.Kind { + case yaml.ScalarNode: + if on.Value == "workflow_dispatch" { + return []WorkflowInput{}, nil + } + case yaml.SequenceNode: + if slices.ContainsFunc(on.Content, func(event *yaml.Node) bool { return event.Value == "workflow_dispatch" }) { + return []WorkflowInput{}, nil + } + case yaml.MappingNode: + if trigger := mappingValue(&on, "workflow_dispatch"); trigger != nil { + return dispatchInputs(mappingValue(trigger, "inputs")) + } + } + return nil, ErrNoDispatch +} + +func dispatchInputs(node *yaml.Node) ([]WorkflowInput, error) { + inputs := []WorkflowInput{} + if node == nil || node.Kind != yaml.MappingNode { + return inputs, nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + var spec struct { + Description string `yaml:"description"` + Type string `yaml:"type"` + Default yaml.Node `yaml:"default"` + Required bool `yaml:"required"` + Options []string `yaml:"options"` + } + if err := node.Content[i+1].Decode(&spec); err != nil { + return nil, fmt.Errorf("invalid workflow input %s: %w", node.Content[i].Value, err) + } + inputs = append(inputs, WorkflowInput{ + Name: node.Content[i].Value, Description: spec.Description, Type: spec.Type, + Default: spec.Default.Value, Required: spec.Required, Options: spec.Options, + }) + } + return inputs, nil +} + +// mappingValue returns the value of key in a YAML mapping, or nil. +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] } } return nil } - -const maxRunScanPages = 50 - -// Runs lists a page of runs. -func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) { - if err := opts.Validate(); err != nil { - return nil, err - } - if opts.Since != nil || opts.Until != nil { - first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit - result := []Run{} - matched := 0 - for page := 1; page <= maxRunScanPages; page++ { - scan := opts - scan.Limit = 100 - scan.Page = page - items, err := s.runsPage(ctx, scan) - if err != nil { - return nil, err - } - if len(items) == 0 { - return result, nil - } - for _, item := range items { - created, err := time.Parse(time.RFC3339Nano, item.CreatedAt) - if err != nil { - return nil, errors.New("server returned an invalid run creation timestamp") - } - // Runs are listed newest first, so the rest were created earlier still. - if opts.Since != nil && created.Before(*opts.Since) { - return result, nil - } - if opts.Until != nil && created.After(*opts.Until) { - continue - } - if matched >= first { - result = append(result, item) - } - matched++ - if matched >= last { - return result, nil - } - } - } - return nil, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100) - } - return s.runsPage(ctx, opts) -} - -func (s Actions) runsPage(ctx context.Context, opts RunListOptions) ([]Run, error) { - var response struct { - Runs []runResponse `json:"workflow_runs"` - } - query := url.Values{"limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}} - for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} { - if value != "" { - query.Set(key, value) - } - } - for _, status := range opts.Statuses { - query.Add("status", status) - } - endpoint := s.endpoint() + "runs" - if opts.Workflow != "" { - endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs" - } - // TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at. - if err := getJSON(ctx, s.Client, endpoint+"?"+query.Encode(), &response); err != nil { - return nil, err - } - result := make([]Run, 0, len(response.Runs)) - for _, item := range response.Runs { - result = append(result, item.result()) - } - return result, nil -} - -// Run retrieves one Actions run. -func (s Actions) Run(ctx context.Context, id int64) (Run, error) { - var response runResponse - // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. - err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response) - return response.result(), err -} - -// Rerun schedules another attempt, optionally restricted to failed jobs. -func (s Actions) Rerun(ctx context.Context, id int64, failedOnly bool) error { - return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { - if failedOnly { - return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id) - } - _, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id) - return resp, err - }) -} - -// DeleteRun removes the selected run. -func (s Actions) DeleteRun(ctx context.Context, id int64) error { - return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { - return c.Actions.DeleteRepoRun(ctx, owner, repo, id) - }) -} - -func completedActionStatus(status string) bool { - switch status { - case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling. - return true - } - return false -} - -// SuccessfulRun recognizes a successful terminal run result. -func SuccessfulRun(run Run) bool { - return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success") -} - -// Watch polls with cancellation and reports only changed run state. -func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, changed func(Run) error) (Run, error) { - if interval <= 0 { - return Run{}, errors.New("watch interval must be positive") - } - last := Run{} - for { - current, err := s.Run(ctx, id) - if err != nil { - return Run{}, err - } - if current != last { - if changed != nil { - if err := changed(current); err != nil { - return Run{}, err - } - } - last = current - } - if completedActionStatus(current.Status) { - return current, nil - } - if err := sleepCtx(ctx, interval); err != nil { - return Run{}, err - } - } -} - -// CancelRun requests cancellation without deleting the run or its history. -// Force cancellation is an explicit operation for unresponsive runners. -func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) { - action := "cancel" - if force { - action = "force-cancel" - } - var response runResponse - // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. - err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response) - return response.result(), err -} diff --git a/internal/services/actions_test.go b/internal/services/actions_test.go index 2a794ad3..a1ff24b1 100644 --- a/internal/services/actions_test.go +++ b/internal/services/actions_test.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -75,22 +76,34 @@ func TestWorkflowsMapDefinitions(t *testing.T) { require.Equal(t, "disabled_manually", item.State) } -func TestWatchChangesAndFailureConclusion(t *testing.T) { - calls := 0 +func TestWatchReportsEveryPollWithJobsUntilTheRunCompletes(t *testing.T) { + polls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - if calls <= 2 { - _, _ = w.Write([]byte(`{"id":1,"status":"in_progress"}`)) - } else { - _, _ = w.Write([]byte(`{"id":1,"status":"completed","conclusion":"failure"}`)) + switch r.URL.Path { + case "/api/v1/repos/owner/repo/actions/workflows": + _, _ = w.Write([]byte(`{"workflows":[{"id":"ci.yml","name":"CI"}]}`)) + case "/api/v1/repos/owner/repo/actions/runs/1": + polls++ + if polls < 3 { + _, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"in_progress"}`)) + return + } + _, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"completed","conclusion":"failure"}`)) + case "/api/v1/repos/owner/repo/actions/runs/1/jobs": + _, _ = w.Write([]byte(`{"jobs":[{"id":2,"name":"build","status":"in_progress"}]}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) } })) defer server.Close() - changes := []Run{} + var updates []string service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} - final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error { changes = append(changes, run); return nil }) + final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error { + updates = append(updates, run.Workflow+" "+run.Status+" "+run.Jobs[0].Name) + return nil + }) require.NoError(t, err) - require.Len(t, changes, 2) + require.Equal(t, []string{"CI in_progress build", "CI in_progress build", "CI completed build"}, updates) require.False(t, SuccessfulRun(final)) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -137,10 +150,10 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) { return s.SetWorkflowEnabled(context.Background(), "ci.yml", false) }}, {"rerun", "/api/v1/repos/owner/repo/actions/runs/7/rerun", http.MethodPost, func(s Actions) error { - return s.Rerun(context.Background(), 7, false) + return s.Rerun(context.Background(), 7, 0, false) }}, {"rerun-failed", "/api/v1/repos/owner/repo/actions/runs/7/rerun-failed-jobs", http.MethodPost, func(s Actions) error { - return s.Rerun(context.Background(), 7, true) + return s.Rerun(context.Background(), 7, 0, true) }}, {"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error { return s.DeleteRun(context.Background(), 7) @@ -209,6 +222,7 @@ func TestRunTimeFilteringPaginatesMatchingResults(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("page") { case "1": + w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`) _, _ = w.Write([]byte(`{"workflow_runs":[{"id":4,"created_at":"2028-01-03T00:00:00Z"},{"id":3,"created_at":"2028-01-01T12:00:00Z"}]}`)) case "2": _, _ = w.Write([]byte(`{"workflow_runs":[{"id":2,"created_at":"2028-01-01T00:00:00Z"}]}`)) @@ -254,7 +268,9 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { pages := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pages++ - _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"created_at":"2028-12-31T00:00:00Z"}]}`)) + w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`) + run := `{"id":1,"created_at":"2028-12-31T00:00:00Z"}` + _, _ = w.Write([]byte(`{"workflow_runs":[` + strings.Repeat(run+",", 99) + run + `]}`)) })) defer server.Close() service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} @@ -262,3 +278,78 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { require.ErrorContains(t, err, "narrow the search") require.Equal(t, maxRunScanPages, pages) } + +func TestFindWorkflowByFileStemPathOrName(t *testing.T) { + workflows := []Workflow{ + {ID: "ci.yml", Name: "CI", Path: ".gitea/workflows/ci.yml"}, + {ID: "nightly.yaml", Name: "Nightly build", Path: ".gitea/workflows/nightly.yaml"}, + {ID: "deploy.yml", Name: "Deploy", Path: ".gitea/workflows/deploy.yml"}, + {ID: "deploy.yaml", Name: "Deploy staging", Path: ".gitea/workflows/deploy.yaml"}, + } + for raw, want := range map[string]string{ + "ci.yml": "ci.yml", "ci": "ci.yml", "CI": "ci.yml", ".gitea/workflows/nightly.yaml": "nightly.yaml", + "nightly build": "nightly.yaml", "Deploy": "deploy.yml", "deploy.yaml": "deploy.yaml", + } { + workflow, err := FindWorkflow(workflows, raw, "alice/x") + require.NoError(t, err, raw) + require.Equal(t, want, workflow.ID, raw) + } + _, err := FindWorkflow(workflows, "deploy", "alice/x") + require.EqualError(t, err, `workflow "deploy" is ambiguous in alice/x (deploy.yml, deploy.yaml); pass the file name`) + _, err = FindWorkflow(workflows, "release", "alice/x") + require.EqualError(t, err, `workflow "release" not found in alice/x`) +} + +func TestDispatchInputsReadTheWorkflowDispatchTrigger(t *testing.T) { + for _, tc := range []struct { + name, definition string + want []WorkflowInput + err error + }{ + {"event name", "on: workflow_dispatch\n", []WorkflowInput{}, nil}, + {"event list", "on: [push, workflow_dispatch]\n", []WorkflowInput{}, nil}, + {"trigger without inputs", "on:\n workflow_dispatch:\n", []WorkflowInput{}, nil}, + {"inputs in order", `on: + workflow_dispatch: + inputs: + target: + description: Deploy target + required: true + default: staging + type: choice + options: [staging, production] + dry_run: + type: boolean + default: true +`, []WorkflowInput{ + {Name: "target", Description: "Deploy target", Type: "choice", Default: "staging", Required: true, Options: []string{"staging", "production"}}, + {Name: "dry_run", Type: "boolean", Default: "true"}, + }, nil}, + {"no trigger", "on:\n push:\n", nil, ErrNoDispatch}, + } { + t.Run(tc.name, func(t *testing.T) { + inputs, err := DispatchInputs([]byte(tc.definition)) + require.ErrorIs(t, err, tc.err) + require.Equal(t, tc.want, inputs) + }) + } +} + +func TestWorkflowFileTellsAMissingRefFromAMissingFile(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/owner/repo/contents/.gitea/workflows/ci.yml", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + ref := r.URL.Query().Get("ref") + if ref == "gone" { + _, _ = fmt.Fprintf(w, `{"message":"object does not exist [id: %s, rel_path: ]"}`, ref) + return + } + _, _ = w.Write([]byte(`{"message":"object does not exist [id: 0123abc, rel_path: .gitea/workflows/ci.yml]"}`)) + })) + service := Actions{Client: client, Repository: "owner/repo"} + workflow := Workflow{ID: "ci.yml", Path: ".gitea/workflows/ci.yml"} + _, err := service.WorkflowFile(context.Background(), workflow, "gone") + require.EqualError(t, err, `ref "gone" not found in owner/repo`) + _, err = service.WorkflowFile(context.Background(), workflow, "main") + require.EqualError(t, err, "workflow file .gitea/workflows/ci.yml not found in owner/repo") +} diff --git a/internal/services/artifacts.go b/internal/services/artifacts.go index 9267f777..bce7a549 100644 --- a/internal/services/artifacts.go +++ b/internal/services/artifacts.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "strconv" gitea "gitea.dev/sdk" ) @@ -30,15 +31,40 @@ func artifactFrom(artifact *gitea.ActionsArtifact) Artifact { } } -// Artifacts lists one page of artifacts belonging to a run. -func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) ([]Artifact, error) { +// Artifacts lists one page of artifacts belonging to a run, with their total number. +func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) (Page[Artifact], error) { response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifactsResponse, *gitea.Response, error) { return c.Actions.ListRepoRunArtifacts(ctx, owner, repo, run, gitea.ListActionsArtifactsOptions{ListOptions: listOptions(limit, page)}) }) if err != nil { - return nil, err + return Page[Artifact]{}, Classify(err, "run", strconv.FormatInt(run, 10), s.Repository) } - return convertAll(response.Artifacts, artifactFrom), nil + total := int(response.TotalCount) + return Page[Artifact]{Items: convertAll(response.Artifacts, artifactFrom), More: total > page*limit, Total: total, Counted: true}, nil +} + +// AllArtifacts lists every artifact belonging to a run. +func (s Actions) AllArtifacts(ctx context.Context, run int64) ([]Artifact, error) { + artifacts := []Artifact{} + for page := 1; page <= maxListPages; page++ { + window, err := s.Artifacts(ctx, run, 100, page) + if err != nil { + return nil, err + } + artifacts = append(artifacts, window.Items...) + if !window.More || len(window.Items) == 0 { + return artifacts, nil + } + } + return nil, fmt.Errorf("listing exceeded %d pages", maxListPages) +} + +// Artifact retrieves one artifact. +func (s Actions) Artifact(ctx context.Context, id int64) (Artifact, error) { + artifact, err := repoConvert(ctx, s.Client, s.Repository, artifactFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifact, *gitea.Response, error) { + return c.Actions.GetRepoArtifact(ctx, owner, repo, id) + }) + return artifact, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository) } // DownloadArtifact streams an artifact ZIP, accepting a scoped signed redirect. @@ -49,7 +75,7 @@ func (s Actions) DownloadArtifact(ctx context.Context, id int64) (io.ReadCloser, } response, err := s.Client.DownloadSigned(ctx, endpoint) if err != nil { - return nil, err + return nil, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository) } return response.Body, nil } diff --git a/internal/services/artifacts_test.go b/internal/services/artifacts_test.go index fe4856d2..d48a8ceb 100644 --- a/internal/services/artifacts_test.go +++ b/internal/services/artifacts_test.go @@ -25,16 +25,16 @@ func TestArtifactListingProjectsMetadataAndEmptyArrays(t *testing.T) { _, _ = w.Write([]byte(`{"artifacts":[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"expires_at":null,"workflow_run":{"head_sha":"excluded"},"archive_download_url":"excluded"}]}`)) })) service := Actions{Client: client, Repository: "owner/repo"} - items, err := service.Artifacts(context.Background(), 7, 20, 1) + page, err := service.Artifacts(context.Background(), 7, 20, 1) require.NoError(t, err) - require.Len(t, items, 1) - data, err := json.Marshal(items) + require.Len(t, page.Items, 1) + data, err := json.Marshal(page.Items) require.NoError(t, err) require.JSONEq(t, `[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"created_at":"","updated_at":"","expires_at":null}]`, string(data)) - items, err = service.Artifacts(context.Background(), 7, 20, 2) + page, err = service.Artifacts(context.Background(), 7, 20, 2) require.NoError(t, err) - require.Empty(t, items) - require.NotNil(t, items) + require.Empty(t, page.Items) + require.NotNil(t, page.Items) } func TestArtifactFromMapsServerFields(t *testing.T) { diff --git a/internal/terminal/choice.go b/internal/terminal/choice.go new file mode 100644 index 00000000..527c0ae5 --- /dev/null +++ b/internal/terminal/choice.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + + "charm.land/huh/v2" +) + +// Choice is one option of a selection prompt: Label is shown and Value returned. +type Choice struct { + Label, Value string +} + +// Chooser asks for one of several options. FormPrompter implements it; a Prompter without it +// cannot offer a selection. +type Chooser interface { + // Choose asks for one of choices, starting at the one whose value is initial, else the first. + Choose(ctx context.Context, title string, choices []Choice, initial string) (string, error) +} + +// Choose asks on stderr for one of choices and returns its value. +func (p *FormPrompter) Choose(ctx context.Context, title string, choices []Choice, initial string) (string, error) { + if len(choices) == 0 { + return "", errors.New("nothing to choose from") + } + options := make([]huh.Option[string], len(choices)) + value := choices[0].Value + for i, choice := range choices { + options[i] = huh.NewOption(SafeLine(choice.Label), choice.Value) + if choice.Value == initial { + value = initial + } + } + err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title(title).Options(options...).Height(min(len(choices), 10)+2).Value(&value)))) + return value, err +}