| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/spf13/cobra" |
| 14 | |
| 15 | actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle" |
| 16 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 17 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 18 | "github.com/tenseleyFlow/shithub/internal/infra/config" |
| 19 | "github.com/tenseleyFlow/shithub/internal/infra/db" |
| 20 | ) |
| 21 | |
| 22 | // adminActionsCmd is the parent group for actions-related operator |
| 23 | // subcommands. Currently scoped to read-only inspection (`parse`); will |
| 24 | // grow with `runner register`, `runner list`, etc. in S41c. |
| 25 | var adminActionsCmd = &cobra.Command{ |
| 26 | Use: "actions", |
| 27 | Short: "Inspect and operate the Actions/CI subsystem", |
| 28 | } |
| 29 | |
| 30 | // adminActionsParseCmd reads a workflow YAML file from disk, runs it |
| 31 | // through the parser, and prints diagnostics + a canonical JSON |
| 32 | // rendering of the parsed AST. No DB or runner dependency — this is |
| 33 | // the smoke command operators run when a workflow misbehaves and we |
| 34 | // want a deterministic dump of what the parser actually saw. |
| 35 | // |
| 36 | // Exit codes: |
| 37 | // - 0: parse succeeded and produced no Error-severity diagnostics |
| 38 | // - 1: parse error (file too large, malformed YAML, IO error) |
| 39 | // - 2: parse produced one or more Error-severity diagnostics |
| 40 | var adminActionsParseCmd = &cobra.Command{ |
| 41 | Use: "parse <file>", |
| 42 | Short: "Parse a workflow YAML file and print diagnostics + canonical JSON", |
| 43 | Long: `Reads a workflow file, runs the v1 parser against it, and prints any |
| 44 | diagnostics followed by a canonical JSON rendering of the parsed AST. |
| 45 | |
| 46 | Useful for: |
| 47 | - debugging "why is my workflow not picking up changes" reports |
| 48 | - validating a workflow file before committing it |
| 49 | - producing a stable AST snapshot for inclusion in bug reports |
| 50 | |
| 51 | Exit code 0 = clean parse, 2 = Error-severity diagnostics produced, |
| 52 | 1 = the file itself was unreadable or oversized.`, |
| 53 | Args: cobra.ExactArgs(1), |
| 54 | RunE: func(cmd *cobra.Command, args []string) error { |
| 55 | path := args[0] |
| 56 | src, err := os.ReadFile(path) |
| 57 | if err != nil { |
| 58 | return fmt.Errorf("read %s: %w", path, err) |
| 59 | } |
| 60 | wf, diags, err := workflow.Parse(src) |
| 61 | if err != nil { |
| 62 | return fmt.Errorf("parse %s: %w", path, err) |
| 63 | } |
| 64 | |
| 65 | out := cmd.OutOrStdout() |
| 66 | errs := 0 |
| 67 | for _, d := range diags { |
| 68 | fmt.Fprintln(out, d.String()) |
| 69 | if d.Severity == workflow.Error { |
| 70 | errs++ |
| 71 | } |
| 72 | } |
| 73 | if len(diags) > 0 { |
| 74 | fmt.Fprintln(out, "---") |
| 75 | } |
| 76 | |
| 77 | enc := json.NewEncoder(out) |
| 78 | enc.SetIndent("", " ") |
| 79 | if err := enc.Encode(wf); err != nil { |
| 80 | return fmt.Errorf("encode AST: %w", err) |
| 81 | } |
| 82 | |
| 83 | if errs > 0 { |
| 84 | os.Exit(2) |
| 85 | } |
| 86 | return nil |
| 87 | }, |
| 88 | } |
| 89 | |
| 90 | func newAdminActionsCancelAllCmd() *cobra.Command { |
| 91 | var repoID int64 |
| 92 | var limit int |
| 93 | var dryRun bool |
| 94 | var confirm bool |
| 95 | |
| 96 | cmd := &cobra.Command{ |
| 97 | Use: "cancel-all", |
| 98 | Short: "Request cancellation for active Actions workflow runs", |
| 99 | Long: `Requests cancellation for queued/running Actions workflow runs. |
| 100 | |
| 101 | By default this scans all repositories, oldest first. Use --repo-id to scope |
| 102 | the operation to one repository. Running jobs receive cancel_requested=true and |
| 103 | are killed by their runner's cancel-check loop; queued jobs become terminal |
| 104 | immediately. |
| 105 | |
| 106 | This is an operator break-glass command. Run with --dry-run first, then repeat |
| 107 | with --confirm to mutate state.`, |
| 108 | Args: cobra.NoArgs, |
| 109 | RunE: func(cmd *cobra.Command, _ []string) error { |
| 110 | if repoID < 0 { |
| 111 | return errors.New("admin actions cancel-all: --repo-id must be zero or positive") |
| 112 | } |
| 113 | if limit < 1 || limit > 5000 { |
| 114 | return errors.New("admin actions cancel-all: --limit must be between 1 and 5000") |
| 115 | } |
| 116 | if !dryRun && !confirm { |
| 117 | return errors.New("admin actions cancel-all: refusing to mutate without --confirm; use --dry-run to inspect") |
| 118 | } |
| 119 | |
| 120 | cfg, err := config.Load(nil) |
| 121 | if err != nil { |
| 122 | return err |
| 123 | } |
| 124 | if cfg.DB.URL == "" { |
| 125 | return errors.New("admin actions cancel-all: DB not configured (set SHITHUB_DATABASE_URL)") |
| 126 | } |
| 127 | ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute) |
| 128 | defer cancel() |
| 129 | |
| 130 | pool, err := db.Open(ctx, db.Config{ |
| 131 | URL: cfg.DB.URL, |
| 132 | MaxConns: 2, |
| 133 | MinConns: 0, |
| 134 | ConnectTimeout: cfg.DB.ConnectTimeout, |
| 135 | }) |
| 136 | if err != nil { |
| 137 | return fmt.Errorf("admin actions cancel-all: db open: %w", err) |
| 138 | } |
| 139 | defer pool.Close() |
| 140 | |
| 141 | q := actionsdb.New() |
| 142 | runs, err := q.ListActiveWorkflowRunsForAdmin(ctx, pool, actionsdb.ListActiveWorkflowRunsForAdminParams{ |
| 143 | RepoID: repoID, |
| 144 | LimitCount: int32(limit), |
| 145 | }) |
| 146 | if err != nil { |
| 147 | return fmt.Errorf("admin actions cancel-all: list active runs: %w", err) |
| 148 | } |
| 149 | |
| 150 | out := cmd.OutOrStdout() |
| 151 | scope := "all repositories" |
| 152 | if repoID != 0 { |
| 153 | scope = fmt.Sprintf("repo_id=%d", repoID) |
| 154 | } |
| 155 | if dryRun { |
| 156 | for _, run := range runs { |
| 157 | _, _ = fmt.Fprintf(out, |
| 158 | "would cancel: run_id=%d repo_id=%d run_index=%d status=%s workflow=%s ref=%s sha=%s\n", |
| 159 | run.ID, run.RepoID, run.RunIndex, run.Status, run.WorkflowFile, run.HeadRef, run.HeadSha) |
| 160 | } |
| 161 | _, _ = fmt.Fprintf(out, "cancel-all dry-run: found %d active run(s) in %s (limit=%d)\n", |
| 162 | len(runs), scope, limit) |
| 163 | return nil |
| 164 | } |
| 165 | |
| 166 | cancelledRuns := 0 |
| 167 | cancelledJobs := 0 |
| 168 | completedRuns := 0 |
| 169 | for _, run := range runs { |
| 170 | result, err := actionslifecycle.CancelRun(ctx, actionslifecycle.Deps{Pool: pool}, run.ID, actionslifecycle.CancelReasonUser) |
| 171 | if err != nil { |
| 172 | return fmt.Errorf("admin actions cancel-all: cancel run %d: %w", run.ID, err) |
| 173 | } |
| 174 | if len(result.ChangedJobs) > 0 { |
| 175 | cancelledRuns++ |
| 176 | } |
| 177 | cancelledJobs += len(result.ChangedJobs) |
| 178 | if result.RunCompleted { |
| 179 | completedRuns++ |
| 180 | } |
| 181 | _, _ = fmt.Fprintf(out, |
| 182 | "cancelled: run_id=%d repo_id=%d run_index=%d changed_jobs=%d run_completed=%t\n", |
| 183 | run.ID, run.RepoID, run.RunIndex, len(result.ChangedJobs), result.RunCompleted) |
| 184 | } |
| 185 | _, _ = fmt.Fprintf(out, |
| 186 | "cancel-all: scanned %d active run(s) in %s; changed_runs=%d changed_jobs=%d completed_runs=%d\n", |
| 187 | len(runs), scope, cancelledRuns, cancelledJobs, completedRuns) |
| 188 | return nil |
| 189 | }, |
| 190 | } |
| 191 | cmd.Flags().Int64Var(&repoID, "repo-id", 0, "Repository id to scope cancellation to; 0 scans all repositories") |
| 192 | cmd.Flags().IntVar(&limit, "limit", 500, "Maximum active runs to scan") |
| 193 | cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print matching active runs without mutating state") |
| 194 | cmd.Flags().BoolVar(&confirm, "confirm", false, "Confirm cancellation of matching active runs") |
| 195 | return cmd |
| 196 | } |
| 197 | |
| 198 | func init() { |
| 199 | adminActionsCmd.AddCommand(adminActionsParseCmd) |
| 200 | adminActionsCmd.AddCommand(newAdminActionsCancelAllCmd()) |
| 201 | adminCmd.AddCommand(adminActionsCmd) |
| 202 | } |
| 203 |