@@ -3,13 +3,20 @@ |
| 3 | 3 | package main |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "context" |
| 6 | 7 | "encoding/json" |
| 8 | + "errors" |
| 7 | 9 | "fmt" |
| 8 | 10 | "os" |
| 11 | + "time" |
| 9 | 12 | |
| 10 | 13 | "github.com/spf13/cobra" |
| 11 | 14 | |
| 15 | + actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle" |
| 16 | + actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 12 | 17 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/infra/config" |
| 19 | + "github.com/tenseleyFlow/shithub/internal/infra/db" |
| 13 | 20 | ) |
| 14 | 21 | |
| 15 | 22 | // adminActionsCmd is the parent group for actions-related operator |
@@ -80,7 +87,116 @@ Exit code 0 = clean parse, 2 = Error-severity diagnostics produced, |
| 80 | 87 | }, |
| 81 | 88 | } |
| 82 | 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 | + |
| 83 | 198 | func init() { |
| 84 | 199 | adminActionsCmd.AddCommand(adminActionsParseCmd) |
| 200 | + adminActionsCmd.AddCommand(newAdminActionsCancelAllCmd()) |
| 85 | 201 | adminCmd.AddCommand(adminActionsCmd) |
| 86 | 202 | } |