tenseleyflow/shithub / 34b84f5

Browse files

repo/actions: add approvals and policy settings UI (S41j-3)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
34b84f539804d5bc8494dfed7ff2145de7d22277
Parents
a325a33
Tree
cf64695

15 changed files

StatusFile+-
A internal/actions/lifecycle/approval.go 134 0
A internal/actions/lifecycle/approval_test.go 56 0
M internal/auth/audit/audit.go 3 0
M internal/web/handlers/repo/actions.go 87 61
A internal/web/handlers/repo/actions_approval.go 88 0
M internal/web/handlers/repo/actions_cancel.go 8 1
M internal/web/handlers/repo/actions_test.go 142 16
M internal/web/handlers/repo/repo.go 2 0
M internal/web/handlers/repo/repo_test.go 2 1
M internal/web/handlers/repo/settings_actions.go 201 0
M internal/web/handlers/repo/settings_actions_test.go 40 0
M internal/web/static/css/shithub.css 19 0
M internal/web/templates/_repo_settings_nav.html 3 0
M internal/web/templates/repo/action_run.html 22 0
A internal/web/templates/repo/settings_actions.html 64 0
internal/actions/lifecycle/approval.goadded
@@ -0,0 +1,134 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+
9
+	"github.com/jackc/pgx/v5"
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/actions/checksync"
13
+	actionsevents "github.com/tenseleyFlow/shithub/internal/actions/events"
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+)
16
+
17
+var (
18
+	ErrApprovalActorRequired = errors.New("actions lifecycle: approval actor required")
19
+	ErrRunNotApprovalPending = errors.New("actions lifecycle: run is not pending approval")
20
+)
21
+
22
+type ApprovalResult struct {
23
+	Run         actionsdb.WorkflowRun
24
+	ChangedJobs []actionsdb.WorkflowJob
25
+}
26
+
27
+// ApproveRun records a maintainer approval. The existing queued jobs remain
28
+// queued; the runner claim query re-evaluates dispatch naturally, so approval
29
+// never duplicates workflow_runs or workflow_jobs.
30
+func ApproveRun(ctx context.Context, deps Deps, runID, actorUserID int64) (ApprovalResult, error) {
31
+	if actorUserID == 0 {
32
+		return ApprovalResult{}, ErrApprovalActorRequired
33
+	}
34
+	q := actionsdb.New()
35
+	run, err := q.ApproveWorkflowRun(ctx, deps.Pool, actionsdb.ApproveWorkflowRunParams{
36
+		ID: runID,
37
+		ApprovedByUserID: pgtype.Int8{
38
+			Int64: actorUserID,
39
+			Valid: true,
40
+		},
41
+	})
42
+	if err != nil {
43
+		if errors.Is(err, pgx.ErrNoRows) {
44
+			return ApprovalResult{}, ErrRunNotApprovalPending
45
+		}
46
+		return ApprovalResult{}, err
47
+	}
48
+	return ApprovalResult{Run: workflowRunFromApprovalRow(run)}, nil
49
+}
50
+
51
+func workflowRunFromApprovalRow(row actionsdb.ApproveWorkflowRunRow) actionsdb.WorkflowRun {
52
+	return actionsdb.WorkflowRun{
53
+		ID:               row.ID,
54
+		RepoID:           row.RepoID,
55
+		RunIndex:         row.RunIndex,
56
+		WorkflowFile:     row.WorkflowFile,
57
+		WorkflowName:     row.WorkflowName,
58
+		HeadSha:          row.HeadSha,
59
+		HeadRef:          row.HeadRef,
60
+		Event:            row.Event,
61
+		EventPayload:     row.EventPayload,
62
+		ActorUserID:      row.ActorUserID,
63
+		ParentRunID:      row.ParentRunID,
64
+		ConcurrencyGroup: row.ConcurrencyGroup,
65
+		Status:           row.Status,
66
+		Conclusion:       row.Conclusion,
67
+		Pinned:           row.Pinned,
68
+		NeedApproval:     row.NeedApproval,
69
+		ApprovedByUserID: row.ApprovedByUserID,
70
+		StartedAt:        row.StartedAt,
71
+		CompletedAt:      row.CompletedAt,
72
+		Version:          row.Version,
73
+		CreatedAt:        row.CreatedAt,
74
+		UpdatedAt:        row.UpdatedAt,
75
+		TriggerEventID:   row.TriggerEventID,
76
+	}
77
+}
78
+
79
+// RejectRun turns a pending-approval run into a completed/action_required run
80
+// and mirrors every queued job to its check_run. It only acts on runs that are
81
+// still pending approval, so an approve/reject race resolves cleanly.
82
+func RejectRun(ctx context.Context, deps Deps, runID, actorUserID int64) (ApprovalResult, error) {
83
+	if actorUserID == 0 {
84
+		return ApprovalResult{}, ErrApprovalActorRequired
85
+	}
86
+	q := actionsdb.New()
87
+	tx, err := deps.Pool.Begin(ctx)
88
+	if err != nil {
89
+		return ApprovalResult{}, err
90
+	}
91
+	committed := false
92
+	defer func() {
93
+		if !committed {
94
+			_ = tx.Rollback(ctx)
95
+		}
96
+	}()
97
+	if _, err := q.RejectWorkflowRunApproval(ctx, tx, actionsdb.RejectWorkflowRunApprovalParams{
98
+		RunID: runID,
99
+		RejectedByUserID: pgtype.Int8{
100
+			Int64: actorUserID,
101
+			Valid: true,
102
+		},
103
+	}); err != nil {
104
+		if errors.Is(err, pgx.ErrNoRows) {
105
+			return ApprovalResult{}, ErrRunNotApprovalPending
106
+		}
107
+		return ApprovalResult{}, err
108
+	}
109
+	jobs, err := q.MarkWorkflowJobsRejected(ctx, tx, runID)
110
+	if err != nil {
111
+		return ApprovalResult{}, err
112
+	}
113
+	run, err := q.MarkWorkflowRunRejected(ctx, tx, runID)
114
+	if err != nil {
115
+		if errors.Is(err, pgx.ErrNoRows) {
116
+			return ApprovalResult{}, ErrRunNotApprovalPending
117
+		}
118
+		return ApprovalResult{}, err
119
+	}
120
+	for _, job := range jobs {
121
+		if err := actionsevents.EmitJobTx(ctx, tx, run, job, actionsevents.ActionCancelled); err != nil {
122
+			return ApprovalResult{}, err
123
+		}
124
+	}
125
+	if err := actionsevents.EmitRunTx(ctx, tx, run, actionsevents.ActionCompleted); err != nil {
126
+		return ApprovalResult{}, err
127
+	}
128
+	if err := tx.Commit(ctx); err != nil {
129
+		return ApprovalResult{}, err
130
+	}
131
+	committed = true
132
+	checksync.ChangedJobs(ctx, checksync.Deps{Pool: deps.Pool, Logger: deps.Logger}, jobs)
133
+	return ApprovalResult{Run: run, ChangedJobs: jobs}, nil
134
+}
internal/actions/lifecycle/approval_test.goadded
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"testing"
9
+
10
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
11
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
12
+)
13
+
14
+func TestApproveRunDoesNotRecordApprovalForNonQueuedRun(t *testing.T) {
15
+	ctx := context.Background()
16
+	pool := dbtest.NewTestDB(t)
17
+	repoID, userID := setupLifecycleRepo(t, pool)
18
+	q := actionsdb.New()
19
+	run := insertLifecycleRun(t, pool, repoID, userID, 1)
20
+	if _, err := pool.Exec(ctx, `
21
+		UPDATE workflow_runs
22
+		SET need_approval = true,
23
+		    status = 'completed',
24
+		    conclusion = 'success',
25
+		    completed_at = now()
26
+		WHERE id = $1`,
27
+		run.ID,
28
+	); err != nil {
29
+		t.Fatalf("mark run completed: %v", err)
30
+	}
31
+	if _, err := q.InsertWorkflowRunApproval(ctx, pool, actionsdb.InsertWorkflowRunApprovalParams{
32
+		RunID:           run.ID,
33
+		RequestedReason: "approval required",
34
+	}); err != nil {
35
+		t.Fatalf("InsertWorkflowRunApproval: %v", err)
36
+	}
37
+
38
+	_, err := ApproveRun(ctx, Deps{Pool: pool}, run.ID, userID)
39
+	if !errors.Is(err, ErrRunNotApprovalPending) {
40
+		t.Fatalf("ApproveRun error = %v, want ErrRunNotApprovalPending", err)
41
+	}
42
+	approval, err := q.GetWorkflowRunApproval(ctx, pool, run.ID)
43
+	if err != nil {
44
+		t.Fatalf("GetWorkflowRunApproval: %v", err)
45
+	}
46
+	if approval.ApprovedByUserID.Valid || approval.ApprovedAt.Valid {
47
+		t.Fatalf("approval row changed for non-queued run: %+v", approval)
48
+	}
49
+	gotRun, err := q.GetWorkflowRunByID(ctx, pool, run.ID)
50
+	if err != nil {
51
+		t.Fatalf("GetWorkflowRunByID: %v", err)
52
+	}
53
+	if gotRun.ApprovedByUserID.Valid {
54
+		t.Fatalf("run was approved despite terminal state: %+v", gotRun)
55
+	}
56
+}
internal/auth/audit/audit.gomodified
@@ -91,6 +91,7 @@ const (
9191
 	ActionActionsSecretDeleted   Action = "actions_secret_deleted"
9292
 	ActionActionsVariableSet     Action = "actions_variable_set"
9393
 	ActionActionsVariableDeleted Action = "actions_variable_deleted"
94
+	ActionActionsPolicyUpdated   Action = "actions_policy_updated"
9495
 
9596
 	// S41h — Actions run/job lifecycle. Metadata must stay structural:
9697
 	// run/job ids, status, conclusion, workflow path/name. Never include
@@ -102,6 +103,8 @@ const (
102103
 	ActionWorkflowJobStarted   Action = "workflow_job_started"
103104
 	ActionWorkflowJobCompleted Action = "workflow_job_completed"
104105
 	ActionWorkflowJobCancelled Action = "workflow_job_cancelled"
106
+	ActionWorkflowRunApproved  Action = "workflow_run_approved"
107
+	ActionWorkflowRunRejected  Action = "workflow_run_rejected"
105108
 
106109
 	// S34 — site admin actions. Always recorded with the real admin's
107110
 	// id in actor_id; impersonation flows additionally carry the
internal/web/handlers/repo/actions.gomodified
@@ -88,39 +88,46 @@ type actionsPaginationView struct {
8888
 }
8989
 
9090
 type actionsRunDetailView struct {
91
-	ID             int64
92
-	RunIndex       int64
93
-	WorkflowFile   string
94
-	WorkflowName   string
95
-	Title          string
96
-	HeadSha        string
97
-	HeadShaShort   string
98
-	HeadRef        string
99
-	Event          string
100
-	EventLabel     string
101
-	ActorUsername  string
102
-	StateText      string
103
-	StateClass     string
104
-	StateIcon      string
105
-	CreatedAt      time.Time
106
-	UpdatedAt      time.Time
107
-	Duration       string
108
-	IsTerminal     bool
109
-	StatusHref     string
110
-	CancelHref     string
111
-	CanCancel      bool
112
-	RerunHref      string
113
-	CanRerun       bool
114
-	ParentRunIndex int64
115
-	ParentRunHref  string
116
-	ActionsHref    string
117
-	CodeHref       string
118
-	ArtifactCount  int
119
-	JobCount       int
120
-	CompletedCount int
121
-	FailureCount   int
122
-	Jobs           []actionsJobDetailView
123
-	Stages         []actionsJobStageView
91
+	ID               int64
92
+	RunIndex         int64
93
+	WorkflowFile     string
94
+	WorkflowName     string
95
+	Title            string
96
+	HeadSha          string
97
+	HeadShaShort     string
98
+	HeadRef          string
99
+	Event            string
100
+	EventLabel       string
101
+	ActorUsername    string
102
+	StateText        string
103
+	StateClass       string
104
+	StateIcon        string
105
+	CreatedAt        time.Time
106
+	UpdatedAt        time.Time
107
+	Duration         string
108
+	IsTerminal       bool
109
+	NeedApproval     bool
110
+	ApprovalPending  bool
111
+	ApprovalRejected bool
112
+	ApprovalReason   string
113
+	ApproveHref      string
114
+	RejectHref       string
115
+	CanApprove       bool
116
+	StatusHref       string
117
+	CancelHref       string
118
+	CanCancel        bool
119
+	RerunHref        string
120
+	CanRerun         bool
121
+	ParentRunIndex   int64
122
+	ParentRunHref    string
123
+	ActionsHref      string
124
+	CodeHref         string
125
+	ArtifactCount    int
126
+	JobCount         int
127
+	CompletedCount   int
128
+	FailureCount     int
129
+	Jobs             []actionsJobDetailView
130
+	Stages           []actionsJobStageView
124131
 }
125132
 
126133
 type actionsJobDetailView struct {
@@ -748,36 +755,44 @@ func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner
748755
 	runPath := basePath + "/runs/" + strconv.FormatInt(run.RunIndex, 10)
749756
 	now := time.Now()
750757
 	stateText, stateClass, stateIcon := workflowRunState(run.Status, run.Conclusion)
758
+	approvalPending := run.NeedApproval && !run.ApprovedByUserID.Valid && run.Status == actionsdb.WorkflowRunStatusQueued
759
+	if approvalPending {
760
+		stateText, stateClass, stateIcon = "Approval required", "pending", "clock"
761
+	}
751762
 	updatedAt := pgTime(run.UpdatedAt, run.CreatedAt.Time)
752763
 	view := actionsRunDetailView{
753
-		ID:             run.ID,
754
-		RunIndex:       run.RunIndex,
755
-		WorkflowFile:   run.WorkflowFile,
756
-		WorkflowName:   run.WorkflowName,
757
-		Title:          workflowDisplayName(run.WorkflowName, run.WorkflowFile),
758
-		HeadSha:        run.HeadSha,
759
-		HeadShaShort:   shortSHA(run.HeadSha),
760
-		HeadRef:        run.HeadRef,
761
-		Event:          string(run.Event),
762
-		EventLabel:     workflowRunEventLabel(string(run.Event)),
763
-		ActorUsername:  run.ActorUsername,
764
-		StateText:      stateText,
765
-		StateClass:     stateClass,
766
-		StateIcon:      stateIcon,
767
-		CreatedAt:      run.CreatedAt.Time,
768
-		UpdatedAt:      updatedAt,
769
-		Duration:       workflowRunDuration(run.Status, run.StartedAt, run.CompletedAt, run.CreatedAt, updatedAt, now),
770
-		IsTerminal:     workflowRunTerminal(run.Status),
771
-		StatusHref:     runPath + "/status",
772
-		CancelHref:     runPath + "/cancel",
773
-		RerunHref:      runPath + "/rerun",
774
-		ActionsHref:    basePath,
775
-		CodeHref:       "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
776
-		ArtifactCount:  len(artifacts),
777
-		JobCount:       len(jobs),
778
-		CompletedCount: 0,
779
-		FailureCount:   0,
780
-		Jobs:           make([]actionsJobDetailView, 0, len(jobs)),
764
+		ID:              run.ID,
765
+		RunIndex:        run.RunIndex,
766
+		WorkflowFile:    run.WorkflowFile,
767
+		WorkflowName:    run.WorkflowName,
768
+		Title:           workflowDisplayName(run.WorkflowName, run.WorkflowFile),
769
+		HeadSha:         run.HeadSha,
770
+		HeadShaShort:    shortSHA(run.HeadSha),
771
+		HeadRef:         run.HeadRef,
772
+		Event:           string(run.Event),
773
+		EventLabel:      workflowRunEventLabel(string(run.Event)),
774
+		ActorUsername:   run.ActorUsername,
775
+		StateText:       stateText,
776
+		StateClass:      stateClass,
777
+		StateIcon:       stateIcon,
778
+		CreatedAt:       run.CreatedAt.Time,
779
+		UpdatedAt:       updatedAt,
780
+		Duration:        workflowRunDuration(run.Status, run.StartedAt, run.CompletedAt, run.CreatedAt, updatedAt, now),
781
+		IsTerminal:      workflowRunTerminal(run.Status),
782
+		NeedApproval:    run.NeedApproval,
783
+		ApprovalPending: approvalPending,
784
+		StatusHref:      runPath + "/status",
785
+		ApproveHref:     runPath + "/approve",
786
+		RejectHref:      runPath + "/reject",
787
+		CancelHref:      runPath + "/cancel",
788
+		RerunHref:       runPath + "/rerun",
789
+		ActionsHref:     basePath,
790
+		CodeHref:        "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
791
+		ArtifactCount:   len(artifacts),
792
+		JobCount:        len(jobs),
793
+		CompletedCount:  0,
794
+		FailureCount:    0,
795
+		Jobs:            make([]actionsJobDetailView, 0, len(jobs)),
781796
 	}
782797
 	if run.ParentRunID.Valid {
783798
 		parent, err := q.GetWorkflowRunByID(ctx, h.d.Pool, run.ParentRunID.Int64)
@@ -786,12 +801,23 @@ func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner
786801
 			view.ParentRunHref = basePath + "/runs/" + strconv.FormatInt(parent.RunIndex, 10)
787802
 		}
788803
 	}
804
+	if run.NeedApproval {
805
+		if approval, err := q.GetWorkflowRunApproval(ctx, h.d.Pool, run.ID); err == nil {
806
+			view.ApprovalReason = approval.RequestedReason
807
+			view.ApprovalRejected = approval.RejectedAt.Valid
808
+		} else if !errors.Is(err, pgx.ErrNoRows) {
809
+			return actionsRunDetailView{}, err
810
+		}
811
+	}
789812
 	for _, job := range jobs {
790813
 		steps, err := q.ListStepsForJob(ctx, h.d.Pool, job.ID)
791814
 		if err != nil {
792815
 			return actionsRunDetailView{}, err
793816
 		}
794817
 		jobView := actionsJobDetailViewFromRow(job, owner, repoName, run.RunIndex, now)
818
+		if approvalPending && job.Status == actionsdb.WorkflowJobStatusQueued {
819
+			jobView.WaitReason = "Waiting for maintainer approval"
820
+		}
795821
 		jobView.Steps = make([]actionsStepDetailView, 0, len(steps))
796822
 		for _, step := range steps {
797823
 			jobView.Steps = append(jobView.Steps, actionsStepDetailViewFromRow(step, owner, repoName, run.RunIndex, job.JobIndex, now))
internal/web/handlers/repo/actions_approval.goadded
@@ -0,0 +1,88 @@
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/audit"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
16
+	"github.com/tenseleyFlow/shithub/internal/worker"
17
+)
18
+
19
+func (h *Handlers) repoActionRunApprove(w http.ResponseWriter, r *http.Request) {
20
+	h.repoActionRunApprovalDecision(w, r, true)
21
+}
22
+
23
+func (h *Handlers) repoActionRunReject(w http.ResponseWriter, r *http.Request) {
24
+	h.repoActionRunApprovalDecision(w, r, false)
25
+}
26
+
27
+func (h *Handlers) repoActionRunApprovalDecision(w http.ResponseWriter, r *http.Request, approve bool) {
28
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionActionsApprove)
29
+	if !ok {
30
+		return
31
+	}
32
+	runIndex, ok := parsePositiveInt64Param(r, "runIndex")
33
+	if !ok {
34
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
35
+		return
36
+	}
37
+	run, err := actionsdb.New().GetWorkflowRunForRepoByIndex(r.Context(), h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
38
+		RepoID:   row.ID,
39
+		RunIndex: runIndex,
40
+	})
41
+	if err != nil {
42
+		if errors.Is(err, pgx.ErrNoRows) {
43
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
44
+		} else {
45
+			h.d.Logger.WarnContext(r.Context(), "repo actions: lookup run for approval", "repo_id", row.ID, "run_index", runIndex, "error", err)
46
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
47
+		}
48
+		return
49
+	}
50
+	viewer := middleware.CurrentUserFromContext(r.Context())
51
+	var action audit.Action
52
+	if approve {
53
+		if _, err := actionslifecycle.ApproveRun(r.Context(), actionslifecycle.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, run.ID, viewer.ID); err != nil {
54
+			h.writeRepoApprovalError(w, r, run.ID, err)
55
+			return
56
+		}
57
+		action = audit.ActionWorkflowRunApproved
58
+		_ = worker.Notify(r.Context(), h.d.Pool)
59
+	} else {
60
+		if _, err := actionslifecycle.RejectRun(r.Context(), actionslifecycle.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, run.ID, viewer.ID); err != nil {
61
+			h.writeRepoApprovalError(w, r, run.ID, err)
62
+			return
63
+		}
64
+		action = audit.ActionWorkflowRunRejected
65
+	}
66
+	actor, meta := viewer.AuditActor(map[string]any{
67
+		"run_id":        run.ID,
68
+		"run_index":     run.RunIndex,
69
+		"workflow_file": run.WorkflowFile,
70
+		"event":         string(run.Event),
71
+		"head_ref":      run.HeadRef,
72
+		"head_sha":      run.HeadSha,
73
+	})
74
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, actor, action, audit.TargetRepo, row.ID, meta)
75
+	http.Redirect(w, r, repoActionRunHref(owner.Username, row.Name, runIndex), http.StatusSeeOther)
76
+}
77
+
78
+func (h *Handlers) writeRepoApprovalError(w http.ResponseWriter, r *http.Request, runID int64, err error) {
79
+	switch {
80
+	case errors.Is(err, actionslifecycle.ErrRunNotApprovalPending):
81
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "run is not pending approval")
82
+	case errors.Is(err, actionslifecycle.ErrApprovalActorRequired):
83
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "approval actor required")
84
+	default:
85
+		h.d.Logger.WarnContext(r.Context(), "repo actions: approval decision", "run_id", runID, "error", err)
86
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
87
+	}
88
+}
internal/web/handlers/repo/actions_cancel.gomodified
@@ -112,7 +112,14 @@ func (h *Handlers) applyActionsLifecycleControls(r *http.Request, row reposdb.Re
112112
 		return
113113
 	}
114114
 	viewer := middleware.CurrentUserFromContext(r.Context())
115
-	dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(row))
115
+	repoRef := policy.NewRepoRefFromRepo(row)
116
+	if view.ApprovalPending {
117
+		approvalDec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionActionsApprove, repoRef)
118
+		if approvalDec.Allow {
119
+			view.CanApprove = true
120
+		}
121
+	}
122
+	dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionRepoWrite, repoRef)
116123
 	if !dec.Allow {
117124
 		return
118125
 	}
internal/web/handlers/repo/actions_test.gomodified
@@ -390,6 +390,115 @@ func TestRepoActionRunRendersCancelControlsForWritersOnly(t *testing.T) {
390390
 	}
391391
 }
392392
 
393
+func TestRepoActionRunApprovalControlsAndDecisions(t *testing.T) {
394
+	t.Parallel()
395
+	f := newRepoFixture(t)
396
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
397
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
398
+		RunIndex:       31,
399
+		WorkflowFile:   ".shithub/workflows/pr.yml",
400
+		WorkflowName:   "PR",
401
+		HeadRef:        "refs/heads/contrib",
402
+		Event:          actionsdb.WorkflowRunEventPullRequest,
403
+		Status:         actionsdb.WorkflowRunStatusQueued,
404
+		ActorUserID:    f.stranger.ID,
405
+		CreatedOffset:  -5 * time.Minute,
406
+		NeedApproval:   true,
407
+		ApprovalReason: "Pull request workflow requires maintainer approval before runner dispatch.",
408
+	}, now)
409
+	f.insertWorkflowJob(t, workflowJobFixture{
410
+		RunID:    runID,
411
+		JobIndex: 0,
412
+		JobKey:   "build",
413
+		JobName:  "Build",
414
+		RunsOn:   "ubuntu-latest",
415
+		Status:   actionsdb.WorkflowJobStatusQueued,
416
+	})
417
+
418
+	resp := httptest.NewRecorder()
419
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/31", nil)
420
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
421
+	if resp.Code != http.StatusOK {
422
+		t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String())
423
+	}
424
+	body := resp.Body.String()
425
+	for _, want := range []string{
426
+		"RUN=PR:#31:pull_request:bob:pending;",
427
+		"APPROVE=/alice/public-repo/actions/runs/31/approve;",
428
+		"REJECT=/alice/public-repo/actions/runs/31/reject;",
429
+		"APPROVAL_PENDING=Pull request workflow requires maintainer approval before runner dispatch.;",
430
+		"WAIT=Waiting for maintainer approval;",
431
+	} {
432
+		if !strings.Contains(body, want) {
433
+			t.Fatalf("approval body missing %q in %s", want, body)
434
+		}
435
+	}
436
+
437
+	resp = httptest.NewRecorder()
438
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/31", nil)
439
+	f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req)
440
+	if resp.Code != http.StatusOK {
441
+		t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String())
442
+	}
443
+	if strings.Contains(resp.Body.String(), "APPROVE=") || strings.Contains(resp.Body.String(), "REJECT=") {
444
+		t.Fatalf("approval controls leaked to stranger: %s", resp.Body.String())
445
+	}
446
+
447
+	resp = httptest.NewRecorder()
448
+	req = httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/31/approve", nil)
449
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
450
+	if resp.Code != http.StatusSeeOther {
451
+		t.Fatalf("approve status=%d body=%s", resp.Code, resp.Body.String())
452
+	}
453
+	run, err := actionsdb.New().GetWorkflowRunByID(context.Background(), f.pool, runID)
454
+	if err != nil {
455
+		t.Fatalf("GetWorkflowRunByID: %v", err)
456
+	}
457
+	if !run.ApprovedByUserID.Valid || run.ApprovedByUserID.Int64 != f.owner.ID {
458
+		t.Fatalf("approved_by not recorded: %+v", run)
459
+	}
460
+
461
+	rejectRunID := f.insertWorkflowRun(t, workflowRunFixture{
462
+		RunIndex:     32,
463
+		WorkflowFile: ".shithub/workflows/pr.yml",
464
+		WorkflowName: "PR",
465
+		HeadRef:      "refs/heads/contrib",
466
+		Event:        actionsdb.WorkflowRunEventPullRequest,
467
+		Status:       actionsdb.WorkflowRunStatusQueued,
468
+		ActorUserID:  f.stranger.ID,
469
+		NeedApproval: true,
470
+	}, now)
471
+	jobID := f.insertWorkflowJob(t, workflowJobFixture{
472
+		RunID:    rejectRunID,
473
+		JobIndex: 0,
474
+		JobKey:   "build",
475
+		JobName:  "Build",
476
+		Status:   actionsdb.WorkflowJobStatusQueued,
477
+	})
478
+	resp = httptest.NewRecorder()
479
+	req = httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/32/reject", nil)
480
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
481
+	if resp.Code != http.StatusSeeOther {
482
+		t.Fatalf("reject status=%d body=%s", resp.Code, resp.Body.String())
483
+	}
484
+	run, err = actionsdb.New().GetWorkflowRunByID(context.Background(), f.pool, rejectRunID)
485
+	if err != nil {
486
+		t.Fatalf("GetWorkflowRunByID rejected: %v", err)
487
+	}
488
+	if run.Status != actionsdb.WorkflowRunStatusCompleted ||
489
+		!run.Conclusion.Valid || run.Conclusion.CheckConclusion != actionsdb.CheckConclusionActionRequired {
490
+		t.Fatalf("rejected run: %+v", run)
491
+	}
492
+	job, err := actionsdb.New().GetWorkflowJobByID(context.Background(), f.pool, jobID)
493
+	if err != nil {
494
+		t.Fatalf("GetWorkflowJobByID rejected: %v", err)
495
+	}
496
+	if job.Status != actionsdb.WorkflowJobStatusCancelled ||
497
+		!job.Conclusion.Valid || job.Conclusion.CheckConclusion != actionsdb.CheckConclusionActionRequired {
498
+		t.Fatalf("rejected job: %+v", job)
499
+	}
500
+}
501
+
393502
 func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) {
394503
 	t.Parallel()
395504
 	f := newRepoFixture(t)
@@ -799,6 +908,8 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
799908
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
800909
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel)
801910
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", f.handlers.repoActionRunRerun)
911
+	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/approve", f.handlers.repoActionRunApprove)
912
+	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/reject", f.handlers.repoActionRunReject)
802913
 	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel)
803914
 	mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch)
804915
 	mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
@@ -891,20 +1002,22 @@ func (f *repoFixture) seedWorkflowFile(t *testing.T, name, body string) string {
8911002
 }
8921003
 
8931004
 type workflowRunFixture struct {
894
-	RunIndex      int64
895
-	WorkflowFile  string
896
-	WorkflowName  string
897
-	HeadSHA       string
898
-	HeadRef       string
899
-	Event         actionsdb.WorkflowRunEvent
900
-	EventPayload  string
901
-	Status        actionsdb.WorkflowRunStatus
902
-	Conclusion    actionsdb.CheckConclusion
903
-	ActorUserID   int64
904
-	CreatedOffset time.Duration
905
-	StartedOffset time.Duration
906
-	DoneOffset    time.Duration
907
-	RepoID        int64
1005
+	RunIndex       int64
1006
+	WorkflowFile   string
1007
+	WorkflowName   string
1008
+	HeadSHA        string
1009
+	HeadRef        string
1010
+	Event          actionsdb.WorkflowRunEvent
1011
+	EventPayload   string
1012
+	Status         actionsdb.WorkflowRunStatus
1013
+	Conclusion     actionsdb.CheckConclusion
1014
+	ActorUserID    int64
1015
+	CreatedOffset  time.Duration
1016
+	StartedOffset  time.Duration
1017
+	DoneOffset     time.Duration
1018
+	RepoID         int64
1019
+	NeedApproval   bool
1020
+	ApprovalReason string
9081021
 }
9091022
 
9101023
 func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, base time.Time) int64 {
@@ -940,11 +1053,11 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
9401053
 		INSERT INTO workflow_runs (
9411054
 			repo_id, run_index, workflow_file, workflow_name,
9421055
 			head_sha, head_ref, event, event_payload, actor_user_id,
943
-			status, conclusion, started_at, completed_at, created_at, updated_at
1056
+			status, conclusion, need_approval, started_at, completed_at, created_at, updated_at
9441057
 		) VALUES (
9451058
 			$1, $2, $3, $4,
9461059
 			$5, $6, $7, $8::jsonb, $9,
947
-			$10, $11, $12, $13, $14, $15
1060
+			$10, $11, $12, $13, $14, $15, $16
9481061
 		)
9491062
 		RETURNING id`,
9501063
 		repoID,
@@ -958,6 +1071,7 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
9581071
 		fx.ActorUserID,
9591072
 		fx.Status,
9601073
 		conclusion,
1074
+		fx.NeedApproval,
9611075
 		startedAt,
9621076
 		completedAt,
9631077
 		createdAt,
@@ -966,6 +1080,18 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
9661080
 	if err != nil {
9671081
 		t.Fatalf("insert workflow run %d: %v", fx.RunIndex, err)
9681082
 	}
1083
+	if fx.NeedApproval {
1084
+		reason := fx.ApprovalReason
1085
+		if reason == "" {
1086
+			reason = "approval required"
1087
+		}
1088
+		if _, err := actionsdb.New().InsertWorkflowRunApproval(context.Background(), f.pool, actionsdb.InsertWorkflowRunApprovalParams{
1089
+			RunID:           id,
1090
+			RequestedReason: reason,
1091
+		}); err != nil {
1092
+			t.Fatalf("insert workflow approval %d: %v", fx.RunIndex, err)
1093
+		}
1094
+	}
9691095
 	return id
9701096
 }
9711097
 
internal/web/handlers/repo/repo.gomodified
@@ -129,6 +129,8 @@ func (h *Handlers) MountNew(r chi.Router) {
129129
 func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
130130
 	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", h.repoActionRunCancel)
131131
 	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", h.repoActionRunRerun)
132
+	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/approve", h.repoActionRunApprove)
133
+	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/reject", h.repoActionRunReject)
132134
 	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", h.repoActionJobCancel)
133135
 	r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
134136
 }
internal/web/handlers/repo/repo_test.gomodified
@@ -150,9 +150,10 @@ 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.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 .WaitReason }}WAIT={{ .WaitReason }};{{ end }}{{ 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 }}{{ if .Run.CanApprove }}APPROVE={{ .Run.ApproveHref }};REJECT={{ .Run.RejectHref }};{{ end }}{{ if .Run.ApprovalPending }}APPROVAL_PENDING={{ .Run.ApprovalReason }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .WaitReason }}WAIT={{ .WaitReason }};{{ end }}{{ 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 }}`)},
156
+		"repo/settings_actions.html":   {Data: []byte(`{{ define "page" }}POLICY={{ .Policy.ActionsEnabled }}:{{ .Policy.RequirePRApproval }}:{{ .Policy.EffectiveActionsEnabled }}:{{ .Policy.EffectiveRequirePRApproval }}:{{ .Policy.EffectiveMaxRepoQueuedRuns }};{{ with .Error }}ERROR={{ . }}{{ end }}{{ end }}`)},
156157
 		"repo/settings_secrets.html":   {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
157158
 	}
158159
 }
internal/web/handlers/repo/settings_actions.gomodified
@@ -4,12 +4,17 @@ package repo
44
 
55
 import (
66
 	"errors"
7
+	"fmt"
78
 	"net/http"
9
+	"strconv"
810
 	"strings"
911
 
1012
 	"github.com/go-chi/chi/v5"
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
1115
 
1216
 	"github.com/tenseleyFlow/shithub/internal/actions/secrets"
17
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
1318
 	actionsvars "github.com/tenseleyFlow/shithub/internal/actions/variables"
1419
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1520
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
@@ -20,6 +25,8 @@ import (
2025
 // MountSettingsActions registers the Actions secrets + variables settings
2126
 // routes. Caller wraps with RequireUser; per-route policy gates inside.
2227
 func (h *Handlers) MountSettingsActions(r chi.Router) {
28
+	r.Get("/{owner}/{repo}/settings/actions", h.settingsActionsPolicy)
29
+	r.Post("/{owner}/{repo}/settings/actions", h.settingsActionsPolicyUpdate)
2330
 	r.Get("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecrets)
2431
 	r.Post("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecretSet)
2532
 	r.Post("/{owner}/{repo}/settings/secrets/actions/{name}/delete", h.settingsActionsSecretDelete)
@@ -28,6 +35,200 @@ func (h *Handlers) MountSettingsActions(r chi.Router) {
2835
 	r.Post("/{owner}/{repo}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
2936
 }
3037
 
38
+type repoActionsPolicyForm struct {
39
+	ActionsEnabled              string
40
+	RequirePRApproval           string
41
+	MaxRepoQueuedRuns           string
42
+	MaxRepoConcurrentJobs       string
43
+	MaxOwnerConcurrentJobs      string
44
+	ActorTriggerLimitPerHour    string
45
+	EffectiveActionsEnabled     bool
46
+	EffectiveRequirePRApproval  bool
47
+	EffectiveMaxRepoQueuedRuns  int32
48
+	EffectiveMaxRepoConcurrent  int32
49
+	EffectiveMaxOwnerConcurrent int32
50
+	EffectiveActorHourlyLimit   int32
51
+}
52
+
53
+func (h *Handlers) settingsActionsPolicy(w http.ResponseWriter, r *http.Request) {
54
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
55
+	if !ok {
56
+		return
57
+	}
58
+	h.renderRepoActionsPolicySettings(w, r, row, owner.Username, "", settingsNoticeMessage(r.URL.Query().Get("notice")))
59
+}
60
+
61
+func (h *Handlers) settingsActionsPolicyUpdate(w http.ResponseWriter, r *http.Request) {
62
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
63
+	if !ok {
64
+		return
65
+	}
66
+	if err := r.ParseForm(); err != nil {
67
+		http.Error(w, "form parse", http.StatusBadRequest)
68
+		return
69
+	}
70
+	form, err := repoActionsPolicyFormFromRequest(r)
71
+	if err != nil {
72
+		h.renderRepoActionsPolicySettings(w, r, row, owner.Username, err.Error(), "")
73
+		return
74
+	}
75
+	viewer := middleware.CurrentUserFromContext(r.Context())
76
+	if _, err := actionsdb.New().UpsertActionsRepoPolicy(r.Context(), h.d.Pool, actionsdb.UpsertActionsRepoPolicyParams{
77
+		RepoID:                   row.ID,
78
+		ActionsEnabled:           actionsdb.ActionsPolicyState(form.ActionsEnabled),
79
+		RequirePrApproval:        nullableBoolSetting(form.RequirePRApproval),
80
+		MaxRepoQueuedRuns:        nullableInt4Setting(form.MaxRepoQueuedRuns),
81
+		MaxRepoConcurrentJobs:    nullableInt4Setting(form.MaxRepoConcurrentJobs),
82
+		MaxOwnerConcurrentJobs:   nullableInt4Setting(form.MaxOwnerConcurrentJobs),
83
+		ActorTriggerLimitPerHour: nullableInt4Setting(form.ActorTriggerLimitPerHour),
84
+		UpdatedByUserID:          pgtype.Int8{Int64: viewer.ID, Valid: viewer.ID != 0},
85
+	}); err != nil {
86
+		h.d.Logger.WarnContext(r.Context(), "actions policy: update repo", "repo_id", row.ID, "error", err)
87
+		h.renderRepoActionsPolicySettings(w, r, row, owner.Username, "Could not save Actions policy.", "")
88
+		return
89
+	}
90
+	actor, meta := viewer.AuditActor(map[string]any{
91
+		"actions_enabled":              form.ActionsEnabled,
92
+		"require_pr_approval":          form.RequirePRApproval,
93
+		"max_repo_queued_runs":         form.MaxRepoQueuedRuns,
94
+		"max_repo_concurrent_jobs":     form.MaxRepoConcurrentJobs,
95
+		"max_owner_concurrent_jobs":    form.MaxOwnerConcurrentJobs,
96
+		"actor_trigger_limit_per_hour": form.ActorTriggerLimitPerHour,
97
+	})
98
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, actor, audit.ActionActionsPolicyUpdated, audit.TargetRepo, row.ID, meta)
99
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/actions?notice=saved", http.StatusSeeOther)
100
+}
101
+
102
+func (h *Handlers) renderRepoActionsPolicySettings(w http.ResponseWriter, r *http.Request, row reposdb.Repo, owner, errMsg, notice string) {
103
+	form, loadErr := h.loadRepoActionsPolicyForm(r, row.ID)
104
+	if loadErr != nil {
105
+		h.d.Logger.WarnContext(r.Context(), "actions policy: load repo settings", "repo_id", row.ID, "error", loadErr)
106
+		errMsg = "Could not load Actions policy."
107
+	}
108
+	data := map[string]any{
109
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
110
+		"Title":          "Actions settings · " + row.Name,
111
+		"Owner":          owner,
112
+		"Repo":           row,
113
+		"SettingsActive": "actions",
114
+		"Error":          errMsg,
115
+		"Notice":         notice,
116
+		"Policy":         form,
117
+	}
118
+	h.d.Render.RenderPage(w, r, "repo/settings_actions", data)
119
+}
120
+
121
+func (h *Handlers) loadRepoActionsPolicyForm(r *http.Request, repoID int64) (repoActionsPolicyForm, error) {
122
+	q := actionsdb.New()
123
+	eff, err := q.GetEffectiveActionsPolicyForRepo(r.Context(), h.d.Pool, repoID)
124
+	if err != nil {
125
+		return repoActionsPolicyForm{}, err
126
+	}
127
+	form := repoActionsPolicyForm{
128
+		ActionsEnabled:              "inherit",
129
+		RequirePRApproval:           "inherit",
130
+		EffectiveActionsEnabled:     eff.ActionsEnabled,
131
+		EffectiveRequirePRApproval:  eff.RequirePrApproval,
132
+		EffectiveMaxRepoQueuedRuns:  eff.MaxRepoQueuedRuns,
133
+		EffectiveMaxRepoConcurrent:  eff.MaxRepoConcurrentJobs,
134
+		EffectiveMaxOwnerConcurrent: eff.MaxOwnerConcurrentJobs,
135
+		EffectiveActorHourlyLimit:   eff.ActorTriggerLimitPerHour,
136
+	}
137
+	row, err := q.GetActionsRepoPolicy(r.Context(), h.d.Pool, repoID)
138
+	if err != nil {
139
+		if errors.Is(err, pgx.ErrNoRows) {
140
+			return form, nil
141
+		}
142
+		return repoActionsPolicyForm{}, err
143
+	}
144
+	form.ActionsEnabled = string(row.ActionsEnabled)
145
+	form.RequirePRApproval = nullableBoolSettingString(row.RequirePrApproval)
146
+	form.MaxRepoQueuedRuns = nullableInt4SettingString(row.MaxRepoQueuedRuns)
147
+	form.MaxRepoConcurrentJobs = nullableInt4SettingString(row.MaxRepoConcurrentJobs)
148
+	form.MaxOwnerConcurrentJobs = nullableInt4SettingString(row.MaxOwnerConcurrentJobs)
149
+	form.ActorTriggerLimitPerHour = nullableInt4SettingString(row.ActorTriggerLimitPerHour)
150
+	return form, nil
151
+}
152
+
153
+func repoActionsPolicyFormFromRequest(r *http.Request) (repoActionsPolicyForm, error) {
154
+	form := repoActionsPolicyForm{
155
+		ActionsEnabled:           strings.TrimSpace(r.PostFormValue("actions_enabled")),
156
+		RequirePRApproval:        strings.TrimSpace(r.PostFormValue("require_pr_approval")),
157
+		MaxRepoQueuedRuns:        strings.TrimSpace(r.PostFormValue("max_repo_queued_runs")),
158
+		MaxRepoConcurrentJobs:    strings.TrimSpace(r.PostFormValue("max_repo_concurrent_jobs")),
159
+		MaxOwnerConcurrentJobs:   strings.TrimSpace(r.PostFormValue("max_owner_concurrent_jobs")),
160
+		ActorTriggerLimitPerHour: strings.TrimSpace(r.PostFormValue("actor_trigger_limit_per_hour")),
161
+	}
162
+	switch form.ActionsEnabled {
163
+	case "inherit", "enabled", "disabled":
164
+	default:
165
+		return repoActionsPolicyForm{}, errors.New("Invalid Actions enablement setting.")
166
+	}
167
+	switch form.RequirePRApproval {
168
+	case "inherit", "true", "false":
169
+	default:
170
+		return repoActionsPolicyForm{}, errors.New("Invalid pull request approval setting.")
171
+	}
172
+	for label, value := range map[string]string{
173
+		"queued run cap":           form.MaxRepoQueuedRuns,
174
+		"repository concurrency":   form.MaxRepoConcurrentJobs,
175
+		"owner concurrency":        form.MaxOwnerConcurrentJobs,
176
+		"actor hourly trigger cap": form.ActorTriggerLimitPerHour,
177
+	} {
178
+		if err := validateOptionalNonnegativeInt(value); err != nil {
179
+			return repoActionsPolicyForm{}, fmt.Errorf("Invalid %s.", label)
180
+		}
181
+	}
182
+	return form, nil
183
+}
184
+
185
+func validateOptionalNonnegativeInt(v string) error {
186
+	if v == "" {
187
+		return nil
188
+	}
189
+	n, err := strconv.ParseInt(v, 10, 32)
190
+	if err != nil || n < 0 {
191
+		return errors.New("invalid integer")
192
+	}
193
+	return nil
194
+}
195
+
196
+func nullableBoolSetting(v string) pgtype.Bool {
197
+	switch v {
198
+	case "true":
199
+		return pgtype.Bool{Bool: true, Valid: true}
200
+	case "false":
201
+		return pgtype.Bool{Bool: false, Valid: true}
202
+	default:
203
+		return pgtype.Bool{}
204
+	}
205
+}
206
+
207
+func nullableBoolSettingString(v pgtype.Bool) string {
208
+	if !v.Valid {
209
+		return "inherit"
210
+	}
211
+	if v.Bool {
212
+		return "true"
213
+	}
214
+	return "false"
215
+}
216
+
217
+func nullableInt4Setting(v string) pgtype.Int4 {
218
+	if v == "" {
219
+		return pgtype.Int4{}
220
+	}
221
+	n, _ := strconv.ParseInt(v, 10, 32)
222
+	return pgtype.Int4{Int32: int32(n), Valid: true}
223
+}
224
+
225
+func nullableInt4SettingString(v pgtype.Int4) string {
226
+	if !v.Valid {
227
+		return ""
228
+	}
229
+	return strconv.FormatInt(int64(v.Int32), 10)
230
+}
231
+
31232
 func (h *Handlers) settingsActionsSecrets(w http.ResponseWriter, r *http.Request) {
32233
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
33234
 	if !ok {
internal/web/handlers/repo/settings_actions_test.gomodified
@@ -115,6 +115,46 @@ func TestSettingsActionsRepoVariableCRUDRendersValue(t *testing.T) {
115115
 	}
116116
 }
117117
 
118
+func TestSettingsActionsPolicyCRUD(t *testing.T) {
119
+	t.Parallel()
120
+	f := newRepoFixture(t)
121
+	mux := f.actionsSettingsMux(f.owner.ID, f.owner.Username)
122
+
123
+	resp := httptest.NewRecorder()
124
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/settings/actions", nil)
125
+	mux.ServeHTTP(resp, req)
126
+	if resp.Code != http.StatusOK {
127
+		t.Fatalf("GET policy status=%d body=%s", resp.Code, resp.Body.String())
128
+	}
129
+	if got := resp.Body.String(); !strings.Contains(got, "POLICY=inherit:inherit:true:true:50;") {
130
+		t.Fatalf("default policy missing: %s", got)
131
+	}
132
+
133
+	resp = httptest.NewRecorder()
134
+	req = newFormRequest(http.MethodPost, "/alice/public-repo/settings/actions", url.Values{
135
+		"actions_enabled":              {"disabled"},
136
+		"require_pr_approval":          {"false"},
137
+		"max_repo_queued_runs":         {"3"},
138
+		"max_repo_concurrent_jobs":     {"2"},
139
+		"max_owner_concurrent_jobs":    {"5"},
140
+		"actor_trigger_limit_per_hour": {"7"},
141
+	})
142
+	mux.ServeHTTP(resp, req)
143
+	if resp.Code != http.StatusSeeOther {
144
+		t.Fatalf("POST policy status=%d body=%s", resp.Code, resp.Body.String())
145
+	}
146
+
147
+	resp = httptest.NewRecorder()
148
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/settings/actions", nil)
149
+	mux.ServeHTTP(resp, req)
150
+	if resp.Code != http.StatusOK {
151
+		t.Fatalf("GET saved policy status=%d body=%s", resp.Code, resp.Body.String())
152
+	}
153
+	if got := resp.Body.String(); !strings.Contains(got, "POLICY=disabled:false:false:false:3;") {
154
+		t.Fatalf("saved policy missing: %s", got)
155
+	}
156
+}
157
+
118158
 func (f *repoFixture) actionsSettingsMux(userID int64, username string) http.Handler {
119159
 	mux := chi.NewRouter()
120160
 	mux.Use(func(next http.Handler) http.Handler {
internal/web/static/css/shithub.cssmodified
@@ -5980,6 +5980,25 @@ button.shithub-repo-action {
59805980
   color: var(--fg-muted);
59815981
   font-size: 0.82rem;
59825982
 }
5983
+.shithub-actions-approval-box {
5984
+  display: flex;
5985
+  flex-direction: column;
5986
+  gap: 0.35rem;
5987
+  margin-bottom: 1rem;
5988
+  padding: 1rem;
5989
+  border: 1px solid var(--border-default);
5990
+  border-radius: 6px;
5991
+  background: var(--canvas-subtle);
5992
+}
5993
+.shithub-actions-approval-box strong {
5994
+  display: inline-flex;
5995
+  align-items: center;
5996
+  gap: 0.4rem;
5997
+}
5998
+.shithub-actions-approval-box p {
5999
+  margin: 0;
6000
+  color: var(--fg-muted);
6001
+}
59836002
 .shithub-actions-workflow-card,
59846003
 .shithub-actions-annotations {
59856004
   border: 1px solid var(--border-default);
internal/web/templates/_repo_settings_nav.htmlmodified
@@ -21,6 +21,9 @@
2121
       <li{{ if eq .SettingsActive "webhooks" }} class="active"{{ end }}>
2222
         <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/webhooks">Webhooks</a>
2323
       </li>
24
+      <li{{ if eq .SettingsActive "actions" }} class="active"{{ end }}>
25
+        <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/actions">Actions</a>
26
+      </li>
2427
       <li{{ if eq .SettingsActive "actions-secrets" }} class="active"{{ end }}>
2528
         <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings/secrets/actions">Actions secrets</a>
2629
       </li>
internal/web/templates/repo/action_run.htmlmodified
@@ -24,6 +24,16 @@
2424
           <button type="submit" class="shithub-button">{{ octicon "history" }} Re-run jobs</button>
2525
         </form>
2626
       {{ end }}
27
+      {{ if and .Run.ApprovalPending .Run.CanApprove }}
28
+        <form method="POST" action="{{ .Run.ApproveHref }}" class="shithub-actions-inline-form">
29
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
30
+          <button type="submit" class="shithub-button shithub-button-primary">{{ octicon "check" }} Approve and run</button>
31
+        </form>
32
+        <form method="POST" action="{{ .Run.RejectHref }}" class="shithub-actions-inline-form">
33
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
34
+          <button type="submit" class="shithub-button shithub-button-danger">{{ octicon "x" }} Reject</button>
35
+        </form>
36
+      {{ end }}
2737
       {{ if .Run.CanCancel }}
2838
         <form method="POST" action="{{ .Run.CancelHref }}" class="shithub-actions-inline-form">
2939
           <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
@@ -55,6 +65,18 @@
5565
     </aside>
5666
 
5767
     <div class="shithub-actions-run-main">
68
+      {{ if .Run.ApprovalPending }}
69
+        <section class="shithub-actions-approval-box">
70
+          <strong>{{ octicon "clock" }} Waiting for approval</strong>
71
+          <p>{{ if .Run.ApprovalReason }}{{ .Run.ApprovalReason }}{{ else }}This workflow is paused until a maintainer approves it.{{ end }}</p>
72
+        </section>
73
+      {{ else if .Run.ApprovalRejected }}
74
+        <section class="shithub-actions-approval-box">
75
+          <strong>{{ octicon "x-circle" }} Workflow rejected</strong>
76
+          <p>This run was not dispatched to a runner.</p>
77
+        </section>
78
+      {{ end }}
79
+
5880
       <section id="summary" class="shithub-actions-summary-strip">
5981
         <div>
6082
           <span>Triggered via</span>
internal/web/templates/repo/settings_actions.htmladded
@@ -0,0 +1,64 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "repo-settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Actions settings</h1>
6
+    {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+
9
+    <section class="shithub-settings-section">
10
+      <h2>Actions policy</h2>
11
+      <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/actions" novalidate>
12
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
13
+        <label>
14
+          <span>Actions</span>
15
+          <select name="actions_enabled">
16
+            <option value="inherit"{{ if eq .Policy.ActionsEnabled "inherit" }} selected{{ end }}>Use inherited policy</option>
17
+            <option value="enabled"{{ if eq .Policy.ActionsEnabled "enabled" }} selected{{ end }}>Enabled</option>
18
+            <option value="disabled"{{ if eq .Policy.ActionsEnabled "disabled" }} selected{{ end }}>Disabled</option>
19
+          </select>
20
+        </label>
21
+        <label>
22
+          <span>Pull request approval</span>
23
+          <select name="require_pr_approval">
24
+            <option value="inherit"{{ if eq .Policy.RequirePRApproval "inherit" }} selected{{ end }}>Use inherited policy</option>
25
+            <option value="true"{{ if eq .Policy.RequirePRApproval "true" }} selected{{ end }}>Require maintainer approval</option>
26
+            <option value="false"{{ if eq .Policy.RequirePRApproval "false" }} selected{{ end }}>Do not require approval</option>
27
+          </select>
28
+        </label>
29
+        <label>
30
+          <span>Queued runs per repository</span>
31
+          <input type="number" name="max_repo_queued_runs" min="0" step="1" value="{{ .Policy.MaxRepoQueuedRuns }}" placeholder="{{ .Policy.EffectiveMaxRepoQueuedRuns }}">
32
+        </label>
33
+        <label>
34
+          <span>Concurrent jobs per repository</span>
35
+          <input type="number" name="max_repo_concurrent_jobs" min="0" step="1" value="{{ .Policy.MaxRepoConcurrentJobs }}" placeholder="{{ .Policy.EffectiveMaxRepoConcurrent }}">
36
+        </label>
37
+        <label>
38
+          <span>Concurrent jobs per owner</span>
39
+          <input type="number" name="max_owner_concurrent_jobs" min="0" step="1" value="{{ .Policy.MaxOwnerConcurrentJobs }}" placeholder="{{ .Policy.EffectiveMaxOwnerConcurrent }}">
40
+        </label>
41
+        <label>
42
+          <span>Triggers per actor per hour</span>
43
+          <input type="number" name="actor_trigger_limit_per_hour" min="0" step="1" value="{{ .Policy.ActorTriggerLimitPerHour }}" placeholder="{{ .Policy.EffectiveActorHourlyLimit }}">
44
+        </label>
45
+        <button type="submit" class="shithub-button shithub-button-primary">Save policy</button>
46
+      </form>
47
+    </section>
48
+
49
+    <section class="shithub-settings-section">
50
+      <h2>Effective policy</h2>
51
+      <table class="shithub-branches-table">
52
+        <tbody>
53
+          <tr><th>Actions</th><td>{{ if .Policy.EffectiveActionsEnabled }}enabled{{ else }}disabled{{ end }}</td></tr>
54
+          <tr><th>Pull request approval</th><td>{{ if .Policy.EffectiveRequirePRApproval }}required{{ else }}not required{{ end }}</td></tr>
55
+          <tr><th>Queued runs per repository</th><td>{{ .Policy.EffectiveMaxRepoQueuedRuns }}</td></tr>
56
+          <tr><th>Concurrent jobs per repository</th><td>{{ .Policy.EffectiveMaxRepoConcurrent }}</td></tr>
57
+          <tr><th>Concurrent jobs per owner</th><td>{{ .Policy.EffectiveMaxOwnerConcurrent }}</td></tr>
58
+          <tr><th>Triggers per actor per hour</th><td>{{ .Policy.EffectiveActorHourlyLimit }}</td></tr>
59
+        </tbody>
60
+      </table>
61
+    </section>
62
+  </div>
63
+</div>
64
+{{- end }}