tenseleyflow/shithub / 561824f

Browse files

web/actions: expose workflow rerun controls

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
561824fef6eedd09e5dbe286983064161c07c031
Parents
740d0b4
Tree
79afe71

13 changed files

StatusFile+-
M internal/web/auth_wiring.go 15 0
M internal/web/handlers/api/actions_cancel.go 1 0
A internal/web/handlers/api/actions_rerun.go 95 0
M internal/web/handlers/api/api.go 1 0
M internal/web/handlers/api/runners_test.go 152 0
M internal/web/handlers/repo/actions.go 13 1
M internal/web/handlers/repo/actions_cancel.go 6 2
A internal/web/handlers/repo/actions_rerun.go 65 0
M internal/web/handlers/repo/actions_test.go 156 3
M internal/web/handlers/repo/repo.go 1 0
M internal/web/handlers/repo/repo_test.go 1 1
M internal/web/server.go 1 1
M internal/web/templates/repo/action_run.html 7 0
internal/web/auth_wiring.gomodified
@@ -5,10 +5,12 @@ package web
55
 import (
66
 	"context"
77
 	"errors"
8
+	"fmt"
89
 	"io/fs"
910
 	"log/slog"
1011
 	"net/http"
1112
 	"os"
13
+	"path/filepath"
1214
 	"time"
1315
 
1416
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -38,6 +40,7 @@ var sharedPATDebouncer = pat.NewDebouncer(0)
3840
 
3941
 // buildAPIHandlers wires the PAT-authenticated API surface.
4042
 func buildAPIHandlers(
43
+	cfg config.Config,
4144
 	pool *pgxpool.Pool,
4245
 	objectStore storage.ObjectStore,
4346
 	runnerJWT *runnerjwt.Signer,
@@ -45,11 +48,23 @@ func buildAPIHandlers(
4548
 	rateLimiter *ratelimit.Limiter,
4649
 	logger *slog.Logger,
4750
 ) (*apih.Handlers, error) {
51
+	if cfg.Storage.ReposRoot == "" {
52
+		return nil, errors.New("api: cfg.Storage.ReposRoot is empty")
53
+	}
54
+	root, err := filepath.Abs(cfg.Storage.ReposRoot)
55
+	if err != nil {
56
+		return nil, fmt.Errorf("api: resolve repos_root: %w", err)
57
+	}
58
+	rfs, err := storage.NewRepoFS(root)
59
+	if err != nil {
60
+		return nil, fmt.Errorf("api: NewRepoFS: %w", err)
61
+	}
4862
 	return apih.New(apih.Deps{
4963
 		Pool:        pool,
5064
 		Debouncer:   sharedPATDebouncer,
5165
 		Logger:      logger,
5266
 		ObjectStore: objectStore,
67
+		RepoFS:      rfs,
5368
 		RunnerJWT:   runnerJWT,
5469
 		SecretBox:   secretBox,
5570
 		RateLimiter: rateLimiter,
internal/web/handlers/api/actions_cancel.gomodified
@@ -24,6 +24,7 @@ func (h *Handlers) mountActionsLifecycle(r chi.Router) {
2424
 	r.Group(func(r chi.Router) {
2525
 		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
2626
 		r.Post("/api/v1/jobs/{id}/cancel", h.workflowJobCancel)
27
+		r.Post("/api/v1/runs/{id}/rerun", h.workflowRunRerun)
2728
 	})
2829
 }
2930
 
internal/web/handlers/api/actions_rerun.goadded
@@ -0,0 +1,95 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+	"github.com/jackc/pgx/v5"
12
+
13
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+)
19
+
20
+func (h *Handlers) workflowRunRerun(w http.ResponseWriter, r *http.Request) {
21
+	auth := middleware.PATAuthFromContext(r.Context())
22
+	if auth.UserID == 0 {
23
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
24
+		return
25
+	}
26
+	runID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
27
+	if err != nil || runID <= 0 {
28
+		writeAPIError(w, http.StatusNotFound, "run not found")
29
+		return
30
+	}
31
+	run, repo, ok := h.resolveLifecycleRun(w, r, auth.UserID, runID)
32
+	if !ok {
33
+		return
34
+	}
35
+	result, err := actionslifecycle.RerunRun(r.Context(), actionslifecycle.Deps{
36
+		Pool:   h.d.Pool,
37
+		RepoFS: h.d.RepoFS,
38
+		Logger: h.d.Logger,
39
+	}, run.ID, auth.UserID)
40
+	if err != nil {
41
+		h.writeRerunError(w, r, run.ID, err)
42
+		return
43
+	}
44
+	writeJSON(w, http.StatusCreated, map[string]any{
45
+		"run_id":        result.RunID,
46
+		"run_index":     result.RunIndex,
47
+		"parent_run_id": result.ParentRunID,
48
+		"repo_id":       repo.ID,
49
+		"workflow_file": run.WorkflowFile,
50
+		"head_sha":      run.HeadSha,
51
+	})
52
+}
53
+
54
+func (h *Handlers) resolveLifecycleRun(
55
+	w http.ResponseWriter,
56
+	r *http.Request,
57
+	userID int64,
58
+	runID int64,
59
+) (actionsdb.WorkflowRun, reposdb.Repo, bool) {
60
+	q := actionsdb.New()
61
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, runID)
62
+	if err != nil {
63
+		writeAPIError(w, http.StatusNotFound, "run not found")
64
+		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
65
+	}
66
+	repo, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, run.RepoID)
67
+	if err != nil {
68
+		if errors.Is(err, pgx.ErrNoRows) {
69
+			writeAPIError(w, http.StatusNotFound, "run not found")
70
+		} else {
71
+			writeAPIError(w, http.StatusInternalServerError, "repo lookup failed")
72
+		}
73
+		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
74
+	}
75
+	actor := policy.UserActor(userID, "", false, false)
76
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
77
+		writeAPIError(w, http.StatusNotFound, "run not found")
78
+		return actionsdb.WorkflowRun{}, reposdb.Repo{}, false
79
+	}
80
+	return run, repo, true
81
+}
82
+
83
+func (h *Handlers) writeRerunError(w http.ResponseWriter, r *http.Request, runID int64, err error) {
84
+	switch {
85
+	case errors.Is(err, actionslifecycle.ErrRunNotRerunnable):
86
+		writeAPIError(w, http.StatusConflict, "run is not rerunnable")
87
+	case errors.Is(err, actionslifecycle.ErrWorkflowSourceUnavailable):
88
+		writeAPIError(w, http.StatusConflict, "workflow source unavailable")
89
+	case errors.Is(err, actionslifecycle.ErrWorkflowSourceInvalid):
90
+		writeAPIError(w, http.StatusUnprocessableEntity, "workflow source invalid")
91
+	default:
92
+		h.d.Logger.WarnContext(r.Context(), "api actions rerun", "run_id", runID, "error", err)
93
+		writeAPIError(w, http.StatusInternalServerError, "rerun failed")
94
+	}
95
+}
internal/web/handlers/api/api.gomodified
@@ -34,6 +34,7 @@ type Deps struct {
3434
 	Debouncer   *pat.Debouncer
3535
 	Logger      *slog.Logger
3636
 	ObjectStore storage.ObjectStore
37
+	RepoFS      *storage.RepoFS
3738
 	RunnerJWT   *runnerjwt.Signer
3839
 	SecretBox   *secretbox.Box
3940
 	RateLimiter *ratelimit.Limiter
internal/web/handlers/api/runners_test.gomodified
@@ -30,6 +30,7 @@ import (
3030
 	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
3131
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
3232
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
33
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
3334
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
3435
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
3536
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
@@ -498,6 +499,82 @@ func TestWorkflowJobCancelAPIRequestsCancellation(t *testing.T) {
498499
 	}
499500
 }
500501
 
502
+func TestWorkflowRunRerunAPIQueuesOriginalCommitWorkflow(t *testing.T) {
503
+	ctx := context.Background()
504
+	pool := dbtest.NewTestDB(t)
505
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
506
+	repoID, userID := setupRunnerAPIRepo(t, pool)
507
+	rfs, err := storage.NewRepoFS(t.TempDir())
508
+	if err != nil {
509
+		t.Fatalf("NewRepoFS: %v", err)
510
+	}
511
+	gitDir, err := rfs.RepoPath("alice", "demo")
512
+	if err != nil {
513
+		t.Fatalf("RepoPath: %v", err)
514
+	}
515
+	if err := rfs.InitBare(ctx, gitDir); err != nil {
516
+		t.Fatalf("InitBare: %v", err)
517
+	}
518
+	oldSHA := commitRunnerAPIWorkflow(t, gitDir, runnerAPIOldWorkflow, time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC))
519
+	wf := parseRunnerAPIWorkflow(t, runnerAPIOldWorkflow)
520
+	original, err := trigger.Enqueue(ctx, trigger.Deps{Pool: pool, Logger: logger}, trigger.EnqueueParams{
521
+		RepoID:         repoID,
522
+		WorkflowFile:   ".shithub/workflows/ci.yml",
523
+		HeadSHA:        oldSHA,
524
+		HeadRef:        "refs/heads/trunk",
525
+		EventKind:      trigger.EventPush,
526
+		EventPayload:   map[string]any{"ref": "refs/heads/trunk"},
527
+		ActorUserID:    userID,
528
+		TriggerEventID: "push:api-rerun",
529
+		Workflow:       wf,
530
+	})
531
+	if err != nil {
532
+		t.Fatalf("trigger.Enqueue original: %v", err)
533
+	}
534
+	if _, err := actionsdb.New().CompleteWorkflowRun(ctx, pool, actionsdb.CompleteWorkflowRunParams{
535
+		ID:         original.RunID,
536
+		Conclusion: actionsdb.CheckConclusionFailure,
537
+	}); err != nil {
538
+		t.Fatalf("CompleteWorkflowRun: %v", err)
539
+	}
540
+	_ = commitRunnerAPIWorkflow(t, gitDir, runnerAPINewWorkflow, time.Date(2026, 5, 11, 12, 5, 0, 0, time.UTC))
541
+
542
+	rawPAT := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
543
+	router := newRunnerAPIRouterWithRepoFS(t, pool, logger, runnerAPISigner(t, time.Now()), rfs)
544
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/runs/%d/rerun", original.RunID), nil)
545
+	req.Header.Set("Authorization", "Bearer "+rawPAT)
546
+	rr := httptest.NewRecorder()
547
+	router.ServeHTTP(rr, req)
548
+	if rr.Code != http.StatusCreated {
549
+		t.Fatalf("rerun status: got %d, want 201; body=%s", rr.Code, rr.Body.String())
550
+	}
551
+	var body struct {
552
+		RunID       int64 `json:"run_id"`
553
+		RunIndex    int64 `json:"run_index"`
554
+		ParentRunID int64 `json:"parent_run_id"`
555
+	}
556
+	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
557
+		t.Fatalf("decode response: %v", err)
558
+	}
559
+	if body.RunID == 0 || body.RunID == original.RunID || body.ParentRunID != original.RunID {
560
+		t.Fatalf("response: %+v original=%+v", body, original)
561
+	}
562
+	rerun, err := actionsdb.New().GetWorkflowRunByID(ctx, pool, body.RunID)
563
+	if err != nil {
564
+		t.Fatalf("GetWorkflowRunByID rerun: %v", err)
565
+	}
566
+	if rerun.HeadSha != oldSHA || !rerun.ParentRunID.Valid || rerun.ParentRunID.Int64 != original.RunID {
567
+		t.Fatalf("rerun row: %+v oldSHA=%s", rerun, oldSHA)
568
+	}
569
+	jobs, err := actionsdb.New().ListJobsForRun(ctx, pool, body.RunID)
570
+	if err != nil {
571
+		t.Fatalf("ListJobsForRun: %v", err)
572
+	}
573
+	if len(jobs) != 1 || jobs[0].JobKey != "old_job" {
574
+		t.Fatalf("rerun jobs came from wrong workflow: %+v", jobs)
575
+	}
576
+}
577
+
501578
 func postRunnerLogChunk(t *testing.T, router http.Handler, jobID int64, token string, seq int32, chunk []byte) string {
502579
 	t.Helper()
503580
 	body := fmt.Sprintf(`{"seq":%d,"chunk":%q}`, seq, base64.StdEncoding.EncodeToString(chunk))
@@ -546,6 +623,28 @@ func newRunnerAPIRouter(
546623
 	return r
547624
 }
548625
 
626
+func newRunnerAPIRouterWithRepoFS(
627
+	t *testing.T,
628
+	pool *pgxpool.Pool,
629
+	logger *slog.Logger,
630
+	signer *runnerjwt.Signer,
631
+	rfs *storage.RepoFS,
632
+) http.Handler {
633
+	t.Helper()
634
+	h, err := apih.New(apih.Deps{
635
+		Pool:      pool,
636
+		Logger:    logger,
637
+		RunnerJWT: signer,
638
+		RepoFS:    rfs,
639
+	})
640
+	if err != nil {
641
+		t.Fatalf("api.New: %v", err)
642
+	}
643
+	r := chi.NewRouter()
644
+	h.Mount(r)
645
+	return r
646
+}
647
+
549648
 func newRunnerAPIRouterWithSecretBox(
550649
 	t *testing.T,
551650
 	pool *pgxpool.Pool,
@@ -568,6 +667,59 @@ func newRunnerAPIRouterWithSecretBox(
568667
 	return r
569668
 }
570669
 
670
+const runnerAPIOldWorkflow = `name: CI
671
+on: push
672
+jobs:
673
+  old_job:
674
+    name: Old job
675
+    runs-on: ubuntu-latest
676
+    steps:
677
+      - run: echo old
678
+`
679
+
680
+const runnerAPINewWorkflow = `name: CI
681
+on: push
682
+jobs:
683
+  new_job:
684
+    name: New job
685
+    runs-on: ubuntu-latest
686
+    steps:
687
+      - run: echo new
688
+`
689
+
690
+func commitRunnerAPIWorkflow(t *testing.T, gitDir, body string, when time.Time) string {
691
+	t.Helper()
692
+	commit, err := (repogit.InitialCommit{
693
+		GitDir:      gitDir,
694
+		AuthorName:  "Alice",
695
+		AuthorEmail: "alice@example.test",
696
+		Branch:      "trunk",
697
+		Message:     "Update workflow",
698
+		When:        when,
699
+		Files: []repogit.FileEntry{
700
+			{Path: ".shithub/workflows/ci.yml", Body: []byte(body)},
701
+		},
702
+	}).Build(context.Background())
703
+	if err != nil {
704
+		t.Fatalf("InitialCommit.Build: %v", err)
705
+	}
706
+	return commit
707
+}
708
+
709
+func parseRunnerAPIWorkflow(t *testing.T, body string) *workflow.Workflow {
710
+	t.Helper()
711
+	wf, diags, err := workflow.Parse([]byte(body))
712
+	if err != nil {
713
+		t.Fatalf("workflow.Parse: %v", err)
714
+	}
715
+	for _, d := range diags {
716
+		if d.Severity == workflow.Error {
717
+			t.Fatalf("workflow diagnostic: %v", d)
718
+		}
719
+	}
720
+	return wf
721
+}
722
+
571723
 func testRunnerAPISecretBox(t *testing.T) *secretbox.Box {
572724
 	t.Helper()
573725
 	key, err := secretbox.GenerateKey()
internal/web/handlers/repo/actions.gomodified
@@ -109,6 +109,10 @@ type actionsRunDetailView struct {
109109
 	StatusHref     string
110110
 	CancelHref     string
111111
 	CanCancel      bool
112
+	RerunHref      string
113
+	CanRerun       bool
114
+	ParentRunIndex int64
115
+	ParentRunHref  string
112116
 	ActionsHref    string
113117
 	CodeHref       string
114118
 	ArtifactCount  int
@@ -617,7 +621,7 @@ func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
617621
 		}
618622
 		return
619623
 	}
620
-	h.applyActionsCancelControls(r, row, &view)
624
+	h.applyActionsLifecycleControls(r, row, &view)
621625
 
622626
 	data := h.repoHeaderData(r, row, owner.Username, "actions")
623627
 	data["Title"] = view.Title + " #" + strconv.FormatInt(view.RunIndex, 10) + " · " + row.Name
@@ -765,6 +769,7 @@ func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner
765769
 		IsTerminal:     workflowRunTerminal(run.Status),
766770
 		StatusHref:     runPath + "/status",
767771
 		CancelHref:     runPath + "/cancel",
772
+		RerunHref:      runPath + "/rerun",
768773
 		ActionsHref:    basePath,
769774
 		CodeHref:       "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
770775
 		ArtifactCount:  len(artifacts),
@@ -773,6 +778,13 @@ func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner
773778
 		FailureCount:   0,
774779
 		Jobs:           make([]actionsJobDetailView, 0, len(jobs)),
775780
 	}
781
+	if run.ParentRunID.Valid {
782
+		parent, err := q.GetWorkflowRunByID(ctx, h.d.Pool, run.ParentRunID.Int64)
783
+		if err == nil && parent.RepoID == repoID {
784
+			view.ParentRunIndex = parent.RunIndex
785
+			view.ParentRunHref = basePath + "/runs/" + strconv.FormatInt(parent.RunIndex, 10)
786
+		}
787
+	}
776788
 	for _, job := range jobs {
777789
 		steps, err := q.ListStepsForJob(ctx, h.d.Pool, job.ID)
778790
 		if err != nil {
internal/web/handlers/repo/actions_cancel.gomodified
@@ -107,8 +107,8 @@ func (h *Handlers) repoActionJobCancel(w http.ResponseWriter, r *http.Request) {
107107
 	http.Redirect(w, r, repoActionRunHref(owner.Username, row.Name, runIndex)+"#job-"+strconv.FormatInt(int64(jobIndex), 10), http.StatusSeeOther)
108108
 }
109109
 
110
-func (h *Handlers) applyActionsCancelControls(r *http.Request, row reposdb.Repo, view *actionsRunDetailView) {
111
-	if view == nil || view.IsTerminal {
110
+func (h *Handlers) applyActionsLifecycleControls(r *http.Request, row reposdb.Repo, view *actionsRunDetailView) {
111
+	if view == nil {
112112
 		return
113113
 	}
114114
 	viewer := middleware.CurrentUserFromContext(r.Context())
@@ -116,6 +116,10 @@ func (h *Handlers) applyActionsCancelControls(r *http.Request, row reposdb.Repo,
116116
 	if !dec.Allow {
117117
 		return
118118
 	}
119
+	if view.IsTerminal {
120
+		view.CanRerun = true
121
+		return
122
+	}
119123
 	for i := range view.Jobs {
120124
 		if view.Jobs[i].IsCancellable && !view.Jobs[i].CancelRequested {
121125
 			view.Jobs[i].CanCancel = true
internal/web/handlers/repo/actions_rerun.goadded
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+
9
+	"github.com/jackc/pgx/v5"
10
+
11
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
12
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
13
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
14
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
15
+)
16
+
17
+func (h *Handlers) repoActionRunRerun(w http.ResponseWriter, r *http.Request) {
18
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
19
+	if !ok {
20
+		return
21
+	}
22
+	runIndex, ok := parsePositiveInt64Param(r, "runIndex")
23
+	if !ok {
24
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
25
+		return
26
+	}
27
+	run, err := actionsdb.New().GetWorkflowRunForRepoByIndex(r.Context(), h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
28
+		RepoID:   row.ID,
29
+		RunIndex: runIndex,
30
+	})
31
+	if err != nil {
32
+		if errors.Is(err, pgx.ErrNoRows) {
33
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
34
+		} else {
35
+			h.d.Logger.WarnContext(r.Context(), "repo actions: lookup run for rerun", "repo_id", row.ID, "run_index", runIndex, "error", err)
36
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
37
+		}
38
+		return
39
+	}
40
+	viewer := middleware.CurrentUserFromContext(r.Context())
41
+	result, err := actionslifecycle.RerunRun(r.Context(), actionslifecycle.Deps{
42
+		Pool:   h.d.Pool,
43
+		RepoFS: h.d.RepoFS,
44
+		Logger: h.d.Logger,
45
+	}, run.ID, viewer.ID)
46
+	if err != nil {
47
+		h.writeRepoRerunError(w, r, run.ID, err)
48
+		return
49
+	}
50
+	http.Redirect(w, r, repoActionRunHref(owner.Username, row.Name, result.RunIndex), http.StatusSeeOther)
51
+}
52
+
53
+func (h *Handlers) writeRepoRerunError(w http.ResponseWriter, r *http.Request, runID int64, err error) {
54
+	switch {
55
+	case errors.Is(err, actionslifecycle.ErrRunNotRerunnable):
56
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "run is not rerunnable")
57
+	case errors.Is(err, actionslifecycle.ErrWorkflowSourceUnavailable):
58
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "workflow source unavailable")
59
+	case errors.Is(err, actionslifecycle.ErrWorkflowSourceInvalid):
60
+		h.d.Render.HTTPError(w, r, http.StatusUnprocessableEntity, "workflow source invalid")
61
+	default:
62
+		h.d.Logger.WarnContext(r.Context(), "repo actions: rerun", "run_id", runID, "error", err)
63
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
64
+	}
65
+}
internal/web/handlers/repo/actions_test.gomodified
@@ -415,6 +415,127 @@ func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) {
415415
 	}
416416
 }
417417
 
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
+
418539
 func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) {
419540
 	t.Parallel()
420541
 	f := newRepoFixture(t)
@@ -641,6 +762,7 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
641762
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus)
642763
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
643764
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel)
765
+	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", f.handlers.repoActionRunRerun)
644766
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel)
645767
 	mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch)
646768
 	mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
@@ -669,6 +791,26 @@ jobs:
669791
       - run: echo hello
670792
 `
671793
 
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
+
672814
 func dispatchWorkflowInputSpecs() []workflow.DispatchInput {
673815
 	return []workflow.DispatchInput{
674816
 		{
@@ -716,8 +858,10 @@ type workflowRunFixture struct {
716858
 	RunIndex      int64
717859
 	WorkflowFile  string
718860
 	WorkflowName  string
861
+	HeadSHA       string
719862
 	HeadRef       string
720863
 	Event         actionsdb.WorkflowRunEvent
864
+	EventPayload  string
721865
 	Status        actionsdb.WorkflowRunStatus
722866
 	Conclusion    actionsdb.CheckConclusion
723867
 	ActorUserID   int64
@@ -746,6 +890,14 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
746890
 	if fx.Conclusion != "" {
747891
 		conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
748892
 	}
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
+	}
749901
 	var id int64
750902
 	err := f.pool.QueryRow(
751903
 		context.Background(), `
@@ -755,17 +907,18 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
755907
 			status, conclusion, started_at, completed_at, created_at, updated_at
756908
 		) VALUES (
757909
 			$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
760912
 		)
761913
 		RETURNING id`,
762914
 		repoID,
763915
 		fx.RunIndex,
764916
 		fx.WorkflowFile,
765917
 		fx.WorkflowName,
766
-		strings.Repeat(strconvDigit(fx.RunIndex), 40),
918
+		headSHA,
767919
 		fx.HeadRef,
768920
 		fx.Event,
921
+		eventPayload,
769922
 		fx.ActorUserID,
770923
 		fx.Status,
771924
 		conclusion,
internal/web/handlers/repo/repo.gomodified
@@ -128,6 +128,7 @@ func (h *Handlers) MountNew(r chi.Router) {
128128
 // /{owner}/{repo}/actions/. Caller wraps with RequireUser.
129129
 func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
130130
 	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", h.repoActionRunCancel)
131
+	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", h.repoActionRunRerun)
131132
 	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", h.repoActionJobCancel)
132133
 	r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
133134
 }
internal/web/handlers/repo/repo_test.gomodified
@@ -150,7 +150,7 @@ func minimalTemplatesFS() fstest.MapFS {
150150
 		"repo/new.html":                {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
151151
 		"repo/actions.html":            {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .DispatchWorkflows }}DISPATCH={{ .Name }}:{{ .DispatchHref }}:{{ range .Inputs }}{{ .Name }}/{{ .Type }}/{{ .Required }}/{{ .Default }}/{{ range .Options }}{{ .Value }}|{{ end }},{{ end }};{{ end }}{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
152152
 		"repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
153
-		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};{{ if .Run.CanCancel }}CANCEL_RUN={{ .Run.CancelHref }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .CanCancel }}CANCEL_JOB={{ .CancelHref }};{{ end }}{{ if .CancelRequested }}CANCEL_REQUESTED={{ .Name }};{{ end }}{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
153
+		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};{{ if .Run.ParentRunHref }}PARENT={{ .Run.ParentRunIndex }}:{{ .Run.ParentRunHref }};{{ end }}{{ if .Run.CanRerun }}RERUN={{ .Run.RerunHref }};{{ end }}{{ if .Run.CanCancel }}CANCEL_RUN={{ .Run.CancelHref }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .CanCancel }}CANCEL_JOB={{ .CancelHref }};{{ end }}{{ if .CancelRequested }}CANCEL_REQUESTED={{ .Name }};{{ end }}{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
154154
 		"repo/action_run_status.html":  {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
155155
 		"repo/action_step_log.html":    {Data: []byte(`{{ define "page" }}STEPLOG={{ .Log.Job.Name }}:{{ .Log.Step.Name }}:{{ .Log.LogSource }}:{{ .Log.DownloadURL }}:{{ .Log.LogTruncated }};{{ with .Log.StreamHref }}STREAM={{ . }};{{ end }}{{ with .Log.LogError }}ERROR={{ . }};{{ end }}LOG={{ .Log.LogText }};{{ end }}`)},
156156
 		"repo/settings_secrets.html":   {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
internal/web/server.gomodified
@@ -184,7 +184,7 @@ func Run(ctx context.Context, opts Options) error {
184184
 			logger.Warn("actions runner API disabled: auth.totp_key_b64 is not configured",
185185
 				"hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable runner job JWTs")
186186
 		}
187
-		api, err := buildAPIHandlers(pool, objectStore, runnerJWT, actionsBox, ratelimit.New(pool), logger)
187
+		api, err := buildAPIHandlers(cfg, pool, objectStore, runnerJWT, actionsBox, ratelimit.New(pool), logger)
188188
 		if err != nil {
189189
 			return fmt.Errorf("api handlers: %w", err)
190190
 		}
internal/web/templates/repo/action_run.htmlmodified
@@ -13,10 +13,17 @@
1313
         {{ if .Run.ActorUsername }}by <a href="/{{ .Run.ActorUsername }}">{{ .Run.ActorUsername }}</a>{{ end }}
1414
         for <a href="/{{ .Owner }}/{{ .Repo.Name }}/commit/{{ .Run.HeadSha }}"><code>{{ .Run.HeadShaShort }}</code></a>
1515
         {{ if .Run.HeadRef }}on <a class="shithub-branch-name" href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .Run.HeadRef }}">{{ .Run.HeadRef }}</a>{{ end }}
16
+        {{ if .Run.ParentRunHref }} · re-run of <a href="{{ .Run.ParentRunHref }}">#{{ .Run.ParentRunIndex }}</a>{{ end }}
1617
       </p>
1718
     </div>
1819
     <div class="shithub-actions-run-head-actions">
1920
       {{ template "action-run-status" . }}
21
+      {{ if .Run.CanRerun }}
22
+        <form method="POST" action="{{ .Run.RerunHref }}" class="shithub-actions-inline-form">
23
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
24
+          <button type="submit" class="shithub-button">{{ octicon "history" }} Re-run jobs</button>
25
+        </form>
26
+      {{ end }}
2027
       {{ if .Run.CanCancel }}
2128
         <form method="POST" action="{{ .Run.CancelHref }}" class="shithub-actions-inline-form">
2229
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">