@@ -415,6 +415,127 @@ func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) { |
| 415 | 415 | } |
| 416 | 416 | } |
| 417 | 417 | |
| 418 | +func TestRepoActionRunRendersRerunControlsForWritersOnly(t *testing.T) { |
| 419 | + t.Parallel() |
| 420 | + f := newRepoFixture(t) |
| 421 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| 422 | + f.insertWorkflowRun(t, workflowRunFixture{ |
| 423 | + RunIndex: 14, |
| 424 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| 425 | + WorkflowName: "CI", |
| 426 | + HeadRef: "trunk", |
| 427 | + Event: actionsdb.WorkflowRunEventPush, |
| 428 | + Status: actionsdb.WorkflowRunStatusCompleted, |
| 429 | + Conclusion: actionsdb.CheckConclusionFailure, |
| 430 | + ActorUserID: f.owner.ID, |
| 431 | + CreatedOffset: -20 * time.Minute, |
| 432 | + StartedOffset: -19 * time.Minute, |
| 433 | + DoneOffset: -18 * time.Minute, |
| 434 | + }, now) |
| 435 | + |
| 436 | + resp := httptest.NewRecorder() |
| 437 | + req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/14", nil) |
| 438 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| 439 | + if resp.Code != http.StatusOK { |
| 440 | + t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String()) |
| 441 | + } |
| 442 | + body := resp.Body.String() |
| 443 | + if !strings.Contains(body, "RERUN=/alice/public-repo/actions/runs/14/rerun;") { |
| 444 | + t.Fatalf("owner body missing rerun control: %s", body) |
| 445 | + } |
| 446 | + if strings.Contains(body, "CANCEL_RUN=") { |
| 447 | + t.Fatalf("terminal run rendered cancel control: %s", body) |
| 448 | + } |
| 449 | + |
| 450 | + resp = httptest.NewRecorder() |
| 451 | + req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/14", nil) |
| 452 | + f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req) |
| 453 | + if resp.Code != http.StatusOK { |
| 454 | + t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String()) |
| 455 | + } |
| 456 | + if strings.Contains(resp.Body.String(), "RERUN=") { |
| 457 | + t.Fatalf("rerun control leaked to non-writer: %s", resp.Body.String()) |
| 458 | + } |
| 459 | +} |
| 460 | + |
| 461 | +func TestRepoActionRunRerunQueuesOriginalCommitWorkflow(t *testing.T) { |
| 462 | + t.Parallel() |
| 463 | + f := newRepoFixture(t) |
| 464 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| 465 | + oldSHA := f.seedWorkflowFile(t, "ci.yml", rerunOldWorkflow) |
| 466 | + gitDir, err := f.handlers.d.RepoFS.RepoPath(f.owner.Username, f.publicRepo.Name) |
| 467 | + if err != nil { |
| 468 | + t.Fatalf("RepoPath: %v", err) |
| 469 | + } |
| 470 | + _, err = (repogit.InitialCommit{ |
| 471 | + GitDir: gitDir, |
| 472 | + AuthorName: "Alice", |
| 473 | + AuthorEmail: "alice@example.test", |
| 474 | + Branch: "trunk", |
| 475 | + Message: "Change workflow", |
| 476 | + When: now.Add(5 * time.Minute), |
| 477 | + Files: []repogit.FileEntry{ |
| 478 | + {Path: ".shithub/workflows/ci.yml", Body: []byte(rerunNewWorkflow)}, |
| 479 | + }, |
| 480 | + }).Build(context.Background()) |
| 481 | + if err != nil { |
| 482 | + t.Fatalf("InitialCommit.Build new workflow: %v", err) |
| 483 | + } |
| 484 | + sourceRunID := f.insertWorkflowRun(t, workflowRunFixture{ |
| 485 | + RunIndex: 15, |
| 486 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| 487 | + WorkflowName: "CI", |
| 488 | + HeadSHA: oldSHA, |
| 489 | + HeadRef: "refs/heads/trunk", |
| 490 | + Event: actionsdb.WorkflowRunEventPush, |
| 491 | + EventPayload: `{"ref":"refs/heads/trunk"}`, |
| 492 | + Status: actionsdb.WorkflowRunStatusCompleted, |
| 493 | + Conclusion: actionsdb.CheckConclusionFailure, |
| 494 | + ActorUserID: f.owner.ID, |
| 495 | + CreatedOffset: -20 * time.Minute, |
| 496 | + StartedOffset: -19 * time.Minute, |
| 497 | + DoneOffset: -18 * time.Minute, |
| 498 | + }, now) |
| 499 | + |
| 500 | + resp := httptest.NewRecorder() |
| 501 | + req := httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/15/rerun", nil) |
| 502 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| 503 | + if resp.Code != http.StatusSeeOther { |
| 504 | + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) |
| 505 | + } |
| 506 | + if loc := resp.Header().Get("Location"); loc != "/alice/public-repo/actions/runs/16" { |
| 507 | + t.Fatalf("Location=%q", loc) |
| 508 | + } |
| 509 | + |
| 510 | + rerun, err := actionsdb.New().GetWorkflowRunForRepoByIndex(context.Background(), f.pool, actionsdb.GetWorkflowRunForRepoByIndexParams{ |
| 511 | + RepoID: f.publicRepo.ID, |
| 512 | + RunIndex: 16, |
| 513 | + }) |
| 514 | + if err != nil { |
| 515 | + t.Fatalf("GetWorkflowRunForRepoByIndex rerun: %v", err) |
| 516 | + } |
| 517 | + if !rerun.ParentRunID.Valid || rerun.ParentRunID.Int64 != sourceRunID || rerun.HeadSha != oldSHA { |
| 518 | + t.Fatalf("rerun row: %+v source=%d oldSHA=%s", rerun, sourceRunID, oldSHA) |
| 519 | + } |
| 520 | + jobs, err := actionsdb.New().ListJobsForRun(context.Background(), f.pool, rerun.ID) |
| 521 | + if err != nil { |
| 522 | + t.Fatalf("ListJobsForRun: %v", err) |
| 523 | + } |
| 524 | + if len(jobs) != 1 || jobs[0].JobKey != "old_job" { |
| 525 | + t.Fatalf("rerun jobs came from wrong workflow: %+v", jobs) |
| 526 | + } |
| 527 | + |
| 528 | + resp = httptest.NewRecorder() |
| 529 | + req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/16", nil) |
| 530 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| 531 | + if resp.Code != http.StatusOK { |
| 532 | + t.Fatalf("rerun detail status=%d body=%s", resp.Code, resp.Body.String()) |
| 533 | + } |
| 534 | + if !strings.Contains(resp.Body.String(), "PARENT=15:/alice/public-repo/actions/runs/15;") { |
| 535 | + t.Fatalf("rerun detail missing parent link: %s", resp.Body.String()) |
| 536 | + } |
| 537 | +} |
| 538 | + |
| 418 | 539 | func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) { |
| 419 | 540 | t.Parallel() |
| 420 | 541 | f := newRepoFixture(t) |
@@ -641,6 +762,7 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler { |
| 641 | 762 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) |
| 642 | 763 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) |
| 643 | 764 | mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel) |
| 765 | + mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", f.handlers.repoActionRunRerun) |
| 644 | 766 | mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel) |
| 645 | 767 | mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch) |
| 646 | 768 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) |
@@ -669,6 +791,26 @@ jobs: |
| 669 | 791 | - run: echo hello |
| 670 | 792 | ` |
| 671 | 793 | |
| 794 | +const rerunOldWorkflow = `name: CI |
| 795 | +on: push |
| 796 | +jobs: |
| 797 | + old_job: |
| 798 | + name: Old job |
| 799 | + runs-on: ubuntu-latest |
| 800 | + steps: |
| 801 | + - run: echo old |
| 802 | +` |
| 803 | + |
| 804 | +const rerunNewWorkflow = `name: CI |
| 805 | +on: push |
| 806 | +jobs: |
| 807 | + new_job: |
| 808 | + name: New job |
| 809 | + runs-on: ubuntu-latest |
| 810 | + steps: |
| 811 | + - run: echo new |
| 812 | +` |
| 813 | + |
| 672 | 814 | func dispatchWorkflowInputSpecs() []workflow.DispatchInput { |
| 673 | 815 | return []workflow.DispatchInput{ |
| 674 | 816 | { |
@@ -716,8 +858,10 @@ type workflowRunFixture struct { |
| 716 | 858 | RunIndex int64 |
| 717 | 859 | WorkflowFile string |
| 718 | 860 | WorkflowName string |
| 861 | + HeadSHA string |
| 719 | 862 | HeadRef string |
| 720 | 863 | Event actionsdb.WorkflowRunEvent |
| 864 | + EventPayload string |
| 721 | 865 | Status actionsdb.WorkflowRunStatus |
| 722 | 866 | Conclusion actionsdb.CheckConclusion |
| 723 | 867 | ActorUserID int64 |
@@ -746,6 +890,14 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas |
| 746 | 890 | if fx.Conclusion != "" { |
| 747 | 891 | conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true} |
| 748 | 892 | } |
| 893 | + headSHA := fx.HeadSHA |
| 894 | + if headSHA == "" { |
| 895 | + headSHA = strings.Repeat(strconvDigit(fx.RunIndex), 40) |
| 896 | + } |
| 897 | + eventPayload := fx.EventPayload |
| 898 | + if eventPayload == "" { |
| 899 | + eventPayload = "{}" |
| 900 | + } |
| 749 | 901 | var id int64 |
| 750 | 902 | err := f.pool.QueryRow( |
| 751 | 903 | context.Background(), ` |
@@ -755,17 +907,18 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas |
| 755 | 907 | status, conclusion, started_at, completed_at, created_at, updated_at |
| 756 | 908 | ) VALUES ( |
| 757 | 909 | $1, $2, $3, $4, |
| 758 | | - $5, $6, $7, '{}'::jsonb, $8, |
| 759 | | - $9, $10, $11, $12, $13, $14 |
| 910 | + $5, $6, $7, $8::jsonb, $9, |
| 911 | + $10, $11, $12, $13, $14, $15 |
| 760 | 912 | ) |
| 761 | 913 | RETURNING id`, |
| 762 | 914 | repoID, |
| 763 | 915 | fx.RunIndex, |
| 764 | 916 | fx.WorkflowFile, |
| 765 | 917 | fx.WorkflowName, |
| 766 | | - strings.Repeat(strconvDigit(fx.RunIndex), 40), |
| 918 | + headSHA, |
| 767 | 919 | fx.HeadRef, |
| 768 | 920 | fx.Event, |
| 921 | + eventPayload, |
| 769 | 922 | fx.ActorUserID, |
| 770 | 923 | fx.Status, |
| 771 | 924 | conclusion, |