package cmd import ( "fmt" "strconv" "strings" "time" "rickub.com/rickub/cli/internal/api" "github.com/spf13/cobra" ) var ( runRepo string runDispatchRef string runWatchInterval string runWatchTimeout string runWatchLogs bool ) func init() { runCmd := &cobra.Command{ Use: "run", Short: "Work with CI (Actions) workflow runs", } runCmd.PersistentFlags().StringVarP(&runRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") listCmd := &cobra.Command{ Use: "list", Short: "List workflow runs", Args: cobra.NoArgs, RunE: runRunList, } addPaging(listCmd) viewCmd := &cobra.Command{ Use: "view ", Short: "Show a run with its jobs and steps", Args: cobra.ExactArgs(1), RunE: runRunView, } logsCmd := &cobra.Command{ Use: "logs ", Short: "Print a run's accumulated logs", Args: cobra.ExactArgs(1), RunE: runRunLogs, } rerunCmd := &cobra.Command{ Use: "rerun ", Short: "Re-run a finished run at the same commit", Args: cobra.ExactArgs(1), RunE: runRunRerun, } cancelCmd := &cobra.Command{ Use: "cancel ", Short: "Cancel an in-flight run", Args: cobra.ExactArgs(1), RunE: runRunCancel, } dispatchCmd := &cobra.Command{ Use: "dispatch", Short: "Trigger workflow_dispatch workflows", Args: cobra.NoArgs, RunE: runRunDispatch, } dispatchCmd.Flags().StringVar(&runDispatchRef, "ref", "", "branch to dispatch on (default: default branch)") watchCmd := &cobra.Command{ Use: "watch ", Short: "Follow a run until it finishes (exit 0 on success, 1 otherwise)", Args: cobra.ExactArgs(1), RunE: runRunWatch, } watchCmd.Flags().StringVar(&runWatchInterval, "interval", "2s", "poll interval (e.g. 2s, 5s)") watchCmd.Flags().StringVar(&runWatchTimeout, "timeout", "30m", "give up after this duration") watchCmd.Flags().BoolVar(&runWatchLogs, "logs", false, "print the accumulated logs when the run finishes") runCmd.AddCommand(listCmd, viewCmd, logsCmd, rerunCmd, cancelCmd, dispatchCmd, watchCmd) rootCmd.AddCommand(runCmd) } func runNumber(arg string) (int, error) { n, err := strconv.Atoi(arg) if err != nil || n <= 0 { return 0, fmt.Errorf("invalid run number %q", arg) } return n, nil } func runRunList(cmd *cobra.Command, _ []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } page, err := client.ListRuns(cmd.Context(), owner, repo, pageFlag, perPageFlag) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), page) } if len(page.Items) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No workflow runs.") return nil } tw := newTabw(cmd.OutOrStdout()) fmt.Fprintln(tw, "#\tWORKFLOW\tEVENT\tBRANCH\tSTATUS\tCOMMIT") for _, r := range page.Items { fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) } tw.Flush() printPageFooter(cmd, page.Page) return nil } func short(sha string) string { if len(sha) > 8 { return sha[:8] } return dash(sha) } func runRunView(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } n, err := runNumber(args[0]) if err != nil { return err } r, err := client.GetRun(cmd.Context(), owner, repo, n) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), r) } out := cmd.OutOrStdout() fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) if r.SecretsWithheld { fmt.Fprintln(out, "note: secrets were withheld from this run") } for _, j := range r.Jobs { fmt.Fprintf(out, "\nJob %s [%s]\n", j.Name, j.Status) for _, s := range j.Steps { fmt.Fprintf(out, " %2d. %-30s %s\n", s.Ordinal, s.Name, s.Status) } } return nil } func runRunLogs(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } n, err := runNumber(args[0]) if err != nil { return err } logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), logs) } out := cmd.OutOrStdout() fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status) for _, j := range logs.Jobs { fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status) fmt.Fprintln(out, j.Log) } return nil } func runRunRerun(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } n, err := runNumber(args[0]) if err != nil { return err } r, err := client.RerunRun(cmd.Context(), owner, repo, n) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), r) } fmt.Fprintf(cmd.OutOrStdout(), "Re-ran; new run #%d [%s]\n", r.Number, r.Status) return nil } func runRunCancel(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } n, err := runNumber(args[0]) if err != nil { return err } r, err := client.CancelRun(cmd.Context(), owner, repo, n) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), r) } fmt.Fprintf(cmd.OutOrStdout(), "Cancelled run #%d [%s]\n", r.Number, r.Status) return nil } func runRunDispatch(cmd *cobra.Command, _ []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } res, err := client.Dispatch(cmd.Context(), owner, repo, runDispatchRef) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), res) } fmt.Fprintf(cmd.OutOrStdout(), "Dispatched %d run(s).\n", res.Dispatched) for _, r := range res.Runs { fmt.Fprintf(cmd.OutOrStdout(), " #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) } return nil } // ---- watch ------------------------------------------------------------------- // runTerminalStatuses are the statuses a run never leaves. var runTerminalStatuses = map[string]bool{"success": true, "failure": true, "cancelled": true} // runWatchExitError is returned (non-zero exit) when the watched run ends in a // state other than success — CI-friendly for scripting. type runWatchExitError struct{ status string } func (e *runWatchExitError) Error() string { return "run finished with status " + e.status } func runRunWatch(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(runRepo) if err != nil { return err } n, err := runNumber(args[0]) if err != nil { return err } interval, err := time.ParseDuration(runWatchInterval) if err != nil || interval <= 0 { return fmt.Errorf("invalid --interval %q", runWatchInterval) } timeout, err := time.ParseDuration(runWatchTimeout) if err != nil || timeout <= 0 { return fmt.Errorf("invalid --timeout %q", runWatchTimeout) } out := cmd.OutOrStdout() deadline := time.Now().Add(timeout) var last string tick := time.NewTicker(interval) defer tick.Stop() for { r, err := client.GetRun(cmd.Context(), owner, repo, n) if err != nil { return err } if snap := runWatchSnapshot(r); snap != last { last = snap fmt.Fprintln(out, snap) } if runTerminalStatuses[r.Status] { if runWatchLogs { logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) if err != nil { return err } for _, j := range logs.Jobs { fmt.Fprintf(out, "\n===== job: %s [%s] =====\n%s\n", j.Name, j.Status, j.Log) } } if flagJSON { return printJSON(out, r) } fmt.Fprintf(out, "Run #%d finished: %s\n", r.Number, r.Status) if r.Status != "success" { return &runWatchExitError{status: r.Status} } return nil } if time.Now().After(deadline) { return fmt.Errorf("timed out after %s; run #%d is still %s", runWatchTimeout, r.Number, r.Status) } select { case <-cmd.Context().Done(): return cmd.Context().Err() case <-tick.C: } } } // runWatchSnapshot renders one compact status line per poll, only when // something changed: "#12 CI [running] · build:success · test:running". func runWatchSnapshot(r *api.Run) string { parts := make([]string, 0, len(r.Jobs)) for _, j := range r.Jobs { parts = append(parts, j.Name+":"+j.Status) } snap := fmt.Sprintf("#%d %s [%s]", r.Number, dash(r.Workflow), r.Status) if len(parts) > 0 { snap += " · " + strings.Join(parts, " · ") } return snap }