@@ -303,6 +303,118 @@ func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) { |
| 303 | 303 | } |
| 304 | 304 | } |
| 305 | 305 | |
| 306 | +func TestRepoActionRunRendersCancelControlsForWritersOnly(t *testing.T) { |
| 307 | + t.Parallel() |
| 308 | + f := newRepoFixture(t) |
| 309 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| 310 | + runID := f.insertWorkflowRun(t, workflowRunFixture{ |
| 311 | + RunIndex: 12, |
| 312 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| 313 | + WorkflowName: "CI", |
| 314 | + HeadRef: "trunk", |
| 315 | + Event: actionsdb.WorkflowRunEventPush, |
| 316 | + Status: actionsdb.WorkflowRunStatusRunning, |
| 317 | + ActorUserID: f.owner.ID, |
| 318 | + CreatedOffset: -5 * time.Minute, |
| 319 | + StartedOffset: -4 * time.Minute, |
| 320 | + }, now) |
| 321 | + f.insertWorkflowJob(t, workflowJobFixture{ |
| 322 | + RunID: runID, |
| 323 | + JobIndex: 0, |
| 324 | + JobKey: "build", |
| 325 | + JobName: "Build", |
| 326 | + RunsOn: "ubuntu-latest", |
| 327 | + Status: actionsdb.WorkflowJobStatusQueued, |
| 328 | + }) |
| 329 | + |
| 330 | + resp := httptest.NewRecorder() |
| 331 | + req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil) |
| 332 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| 333 | + if resp.Code != http.StatusOK { |
| 334 | + t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String()) |
| 335 | + } |
| 336 | + body := resp.Body.String() |
| 337 | + for _, want := range []string{ |
| 338 | + "CANCEL_RUN=/alice/public-repo/actions/runs/12/cancel;", |
| 339 | + "CANCEL_JOB=/alice/public-repo/actions/runs/12/jobs/0/cancel;", |
| 340 | + } { |
| 341 | + if !strings.Contains(body, want) { |
| 342 | + t.Fatalf("owner body missing %q in %s", want, body) |
| 343 | + } |
| 344 | + } |
| 345 | + |
| 346 | + resp = httptest.NewRecorder() |
| 347 | + req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil) |
| 348 | + f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req) |
| 349 | + if resp.Code != http.StatusOK { |
| 350 | + t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String()) |
| 351 | + } |
| 352 | + if strings.Contains(resp.Body.String(), "CANCEL_") { |
| 353 | + t.Fatalf("cancel controls leaked to non-writer: %s", resp.Body.String()) |
| 354 | + } |
| 355 | +} |
| 356 | + |
| 357 | +func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) { |
| 358 | + t.Parallel() |
| 359 | + f := newRepoFixture(t) |
| 360 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| 361 | + runID := f.insertWorkflowRun(t, workflowRunFixture{ |
| 362 | + RunIndex: 13, |
| 363 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| 364 | + WorkflowName: "CI", |
| 365 | + HeadRef: "trunk", |
| 366 | + Event: actionsdb.WorkflowRunEventPush, |
| 367 | + Status: actionsdb.WorkflowRunStatusQueued, |
| 368 | + ActorUserID: f.owner.ID, |
| 369 | + CreatedOffset: -5 * time.Minute, |
| 370 | + }, now) |
| 371 | + jobID := f.insertWorkflowJob(t, workflowJobFixture{ |
| 372 | + RunID: runID, |
| 373 | + JobIndex: 0, |
| 374 | + JobKey: "build", |
| 375 | + JobName: "Build", |
| 376 | + RunsOn: "ubuntu-latest", |
| 377 | + Status: actionsdb.WorkflowJobStatusQueued, |
| 378 | + }) |
| 379 | + stepID := f.insertWorkflowStep(t, workflowStepFixture{ |
| 380 | + JobID: jobID, |
| 381 | + StepIndex: 0, |
| 382 | + RunCommand: "go test ./...", |
| 383 | + }) |
| 384 | + |
| 385 | + resp := httptest.NewRecorder() |
| 386 | + req := httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/13/cancel", nil) |
| 387 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| 388 | + if resp.Code != http.StatusSeeOther { |
| 389 | + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) |
| 390 | + } |
| 391 | + if loc := resp.Header().Get("Location"); loc != "/alice/public-repo/actions/runs/13" { |
| 392 | + t.Fatalf("Location=%q", loc) |
| 393 | + } |
| 394 | + job, err := actionsdb.New().GetWorkflowJobByID(context.Background(), f.pool, jobID) |
| 395 | + if err != nil { |
| 396 | + t.Fatalf("GetWorkflowJobByID: %v", err) |
| 397 | + } |
| 398 | + if job.Status != actionsdb.WorkflowJobStatusCancelled || !job.CancelRequested { |
| 399 | + t.Fatalf("job: %+v", job) |
| 400 | + } |
| 401 | + step, err := actionsdb.New().GetWorkflowStepByID(context.Background(), f.pool, stepID) |
| 402 | + if err != nil { |
| 403 | + t.Fatalf("GetWorkflowStepByID: %v", err) |
| 404 | + } |
| 405 | + if step.Status != actionsdb.WorkflowStepStatusCancelled { |
| 406 | + t.Fatalf("step: %+v", step) |
| 407 | + } |
| 408 | + run, err := actionsdb.New().GetWorkflowRunByID(context.Background(), f.pool, runID) |
| 409 | + if err != nil { |
| 410 | + t.Fatalf("GetWorkflowRunByID: %v", err) |
| 411 | + } |
| 412 | + if run.Status != actionsdb.WorkflowRunStatusCompleted || |
| 413 | + !run.Conclusion.Valid || run.Conclusion.CheckConclusion != actionsdb.CheckConclusionCancelled { |
| 414 | + t.Fatalf("run: %+v", run) |
| 415 | + } |
| 416 | +} |
| 417 | + |
| 306 | 418 | func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) { |
| 307 | 419 | t.Parallel() |
| 308 | 420 | f := newRepoFixture(t) |
@@ -528,6 +640,8 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler { |
| 528 | 640 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog) |
| 529 | 641 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) |
| 530 | 642 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) |
| 643 | + mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel) |
| 644 | + mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel) |
| 531 | 645 | mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch) |
| 532 | 646 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) |
| 533 | 647 | return mux |