tenseleyflow/shithub / 84abaca

Browse files

web/handlers/api: rulesets surface synthesized from branch_protection_rules

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
84abacace96974e793c8d4f63f88a42218103ff9
Parents
0514c27
Tree
944f526

1 changed file

StatusFile+-
A internal/web/handlers/api/rulesets.go 250 0
internal/web/handlers/api/rulesets.goadded
@@ -0,0 +1,250 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"path/filepath"
9
+	"sort"
10
+	"strconv"
11
+	"strings"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
18
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// mountRulesets registers the S50 §9 rulesets REST surface. Three
23
+// read-only endpoints synthesizing gh's modern rulesets shape from
24
+// our existing `branch_protection_rules` rows. One row → one
25
+// ruleset (named after its pattern); rules are emitted as a typed
26
+// array (`pull_request`, `non_fast_forward`, `deletion`,
27
+// `required_signatures`, `required_status_checks`).
28
+//
29
+//	GET /api/v1/repos/{o}/{r}/rulesets
30
+//	GET /api/v1/repos/{o}/{r}/rulesets/{id}
31
+//	GET /api/v1/repos/{o}/{r}/rules/branches/{branch}  rules applying to a branch
32
+//
33
+// All endpoints require `repo:read` and gate on `ActionRepoRead`.
34
+// Mirrors gh's response shape — clients pinned to gh's documented
35
+// rulesets surface work without per-field shims.
36
+func (h *Handlers) mountRulesets(r chi.Router) {
37
+	r.Group(func(r chi.Router) {
38
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
39
+		r.Get("/api/v1/repos/{owner}/{repo}/rulesets", h.rulesetsList)
40
+		r.Get("/api/v1/repos/{owner}/{repo}/rulesets/{id}", h.rulesetGet)
41
+		// Branches can contain `/`; wildcard segment, same as the
42
+		// branches single-get route in branches.go.
43
+		r.Get("/api/v1/repos/{owner}/{repo}/rules/branches/*", h.rulesForBranch)
44
+	})
45
+}
46
+
47
+// rulesetResponse mirrors gh's `Ruleset` shape. The fields gh emits
48
+// but we don't synthesize (linked rule actors, multi-actor bypass)
49
+// stay absent; clients that key on those should treat missing as
50
+// "feature not configured."
51
+type rulesetResponse struct {
52
+	ID          int64             `json:"id"`
53
+	Name        string            `json:"name"`
54
+	Target      string            `json:"target"`
55
+	SourceType  string            `json:"source_type"`
56
+	Source      string            `json:"source"`
57
+	Enforcement string            `json:"enforcement"`
58
+	Conditions  rulesetConditions `json:"conditions"`
59
+	Rules       []rulesetRule     `json:"rules"`
60
+	CreatedAt   string            `json:"created_at,omitempty"`
61
+	UpdatedAt   string            `json:"updated_at,omitempty"`
62
+}
63
+
64
+type rulesetConditions struct {
65
+	RefName rulesetRefName `json:"ref_name"`
66
+}
67
+
68
+type rulesetRefName struct {
69
+	Include []string `json:"include"`
70
+	Exclude []string `json:"exclude"`
71
+}
72
+
73
+// rulesetRule mirrors gh's `RepositoryRule` discriminated-union. The
74
+// `parameters` payload depends on `type`; we emit it as an arbitrary
75
+// map so each rule type can carry its own shape without forcing a
76
+// fat struct.
77
+type rulesetRule struct {
78
+	Type       string         `json:"type"`
79
+	Parameters map[string]any `json:"parameters,omitempty"`
80
+}
81
+
82
+// presentRuleset projects one `BranchProtectionRule` row to gh's
83
+// ruleset shape. The owner/repo pair is needed for the `source`
84
+// field; the caller resolves them once and threads through.
85
+func presentRuleset(rule reposdb.BranchProtectionRule, ownerRepo string) rulesetResponse {
86
+	out := rulesetResponse{
87
+		ID:          rule.ID,
88
+		Name:        "Pattern: " + rule.Pattern,
89
+		Target:      "branch",
90
+		SourceType:  "Repository",
91
+		Source:      ownerRepo,
92
+		Enforcement: "active",
93
+		Conditions: rulesetConditions{
94
+			RefName: rulesetRefName{
95
+				Include: []string{"refs/heads/" + rule.Pattern},
96
+				Exclude: []string{},
97
+			},
98
+		},
99
+		Rules: buildRulesetRules(rule),
100
+	}
101
+	if rule.CreatedAt.Valid {
102
+		out.CreatedAt = rule.CreatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
103
+	}
104
+	if rule.UpdatedAt.Valid {
105
+		out.UpdatedAt = rule.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
106
+	}
107
+	return out
108
+}
109
+
110
+// buildRulesetRules emits the typed rules array. Each protection
111
+// column maps to one gh rule type; empty / unset columns are
112
+// skipped so clients see only the rules an admin actually
113
+// configured.
114
+func buildRulesetRules(rule reposdb.BranchProtectionRule) []rulesetRule {
115
+	out := make([]rulesetRule, 0, 5)
116
+	if rule.RequirePrForPush || rule.RequiredReviewCount > 0 || rule.RequireCodeOwnerReview || rule.DismissStaleReviewsOnPush {
117
+		out = append(out, rulesetRule{
118
+			Type: "pull_request",
119
+			Parameters: map[string]any{
120
+				"required_approving_review_count": rule.RequiredReviewCount,
121
+				"dismiss_stale_reviews_on_push":   rule.DismissStaleReviewsOnPush,
122
+				"require_code_owner_review":       rule.RequireCodeOwnerReview,
123
+			},
124
+		})
125
+	}
126
+	if rule.PreventForcePush {
127
+		out = append(out, rulesetRule{Type: "non_fast_forward"})
128
+	}
129
+	if rule.PreventDeletion {
130
+		out = append(out, rulesetRule{Type: "deletion"})
131
+	}
132
+	if rule.RequireSignedCommits {
133
+		out = append(out, rulesetRule{Type: "required_signatures"})
134
+	}
135
+	if len(rule.StatusChecksRequired) > 0 || rule.DismissStaleStatusChecksOnPush {
136
+		// gh's payload uses an array of `{context, integration_id}`
137
+		// objects; we don't track integrations, so emit `{context}`
138
+		// only. Clients that key on `integration_id` will see absent
139
+		// which gh allows for unscoped contexts.
140
+		checks := make([]map[string]any, 0, len(rule.StatusChecksRequired))
141
+		for _, c := range rule.StatusChecksRequired {
142
+			checks = append(checks, map[string]any{"context": c})
143
+		}
144
+		out = append(out, rulesetRule{
145
+			Type: "required_status_checks",
146
+			Parameters: map[string]any{
147
+				"required_status_checks":               checks,
148
+				"strict_required_status_checks_policy": rule.DismissStaleStatusChecksOnPush,
149
+			},
150
+		})
151
+	}
152
+	return out
153
+}
154
+
155
+// sourceFor returns `<owner>/<repo>` for the ruleset `source`
156
+// field. Pulled from the chi path params — the routing layer
157
+// already used them for the repo lookup, no extra DB hit needed.
158
+func sourceFor(r *http.Request, repo *reposdb.Repo) string {
159
+	owner := strings.ToLower(chi.URLParam(r, "owner"))
160
+	if owner == "" {
161
+		return repo.Name
162
+	}
163
+	return owner + "/" + repo.Name
164
+}
165
+
166
+func (h *Handlers) rulesetsList(w http.ResponseWriter, r *http.Request) {
167
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
168
+	if !ok {
169
+		return
170
+	}
171
+	rules, err := reposdb.New().ListBranchProtectionRules(r.Context(), h.d.Pool, repo.ID)
172
+	if err != nil {
173
+		h.d.Logger.ErrorContext(r.Context(), "api: list rulesets", "error", err, "repo_id", repo.ID)
174
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
175
+		return
176
+	}
177
+	source := sourceFor(r, repo)
178
+	out := make([]rulesetResponse, 0, len(rules))
179
+	for _, rule := range rules {
180
+		out = append(out, presentRuleset(rule, source))
181
+	}
182
+	// Stable ordering by id so clients see a deterministic list;
183
+	// the underlying query orders too, but the defensive sort
184
+	// makes the contract independent of the query's ORDER BY.
185
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
186
+	writeJSON(w, http.StatusOK, out)
187
+}
188
+
189
+func (h *Handlers) rulesetGet(w http.ResponseWriter, r *http.Request) {
190
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
191
+	if !ok {
192
+		return
193
+	}
194
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
195
+	if err != nil {
196
+		writeAPIError(w, http.StatusNotFound, "ruleset not found")
197
+		return
198
+	}
199
+	rule, err := reposdb.New().GetBranchProtectionRule(r.Context(), h.d.Pool, id)
200
+	if err != nil {
201
+		if errors.Is(err, pgx.ErrNoRows) {
202
+			writeAPIError(w, http.StatusNotFound, "ruleset not found")
203
+			return
204
+		}
205
+		h.d.Logger.ErrorContext(r.Context(), "api: get ruleset", "error", err, "id", id)
206
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
207
+		return
208
+	}
209
+	// Cross-repo lookup safety: 404 if the ruleset belongs to a
210
+	// different repo. Same status as "doesn't exist" so the
211
+	// response doesn't leak existence across repo boundaries.
212
+	if rule.RepoID != repo.ID {
213
+		writeAPIError(w, http.StatusNotFound, "ruleset not found")
214
+		return
215
+	}
216
+	writeJSON(w, http.StatusOK, presentRuleset(rule, sourceFor(r, repo)))
217
+}
218
+
219
+func (h *Handlers) rulesForBranch(w http.ResponseWriter, r *http.Request) {
220
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
221
+	if !ok {
222
+		return
223
+	}
224
+	branch := chi.URLParam(r, "*")
225
+	if branch == "" {
226
+		writeAPIError(w, http.StatusNotFound, "branch not specified")
227
+		return
228
+	}
229
+	rules, err := reposdb.New().ListBranchProtectionRules(r.Context(), h.d.Pool, repo.ID)
230
+	if err != nil {
231
+		h.d.Logger.ErrorContext(r.Context(), "api: list rulesets for branch", "error", err, "repo_id", repo.ID)
232
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
233
+		return
234
+	}
235
+	source := sourceFor(r, repo)
236
+	// Return EVERY matching rule, not just the longest-match. gh's
237
+	// /rules/branches/{branch} endpoint lists every applicable rule;
238
+	// the longest-match heuristic our pre-receive enforcer uses is
239
+	// an internal precedence detail, not a contract surface.
240
+	out := make([]rulesetResponse, 0)
241
+	for _, rule := range rules {
242
+		match, mErr := filepath.Match(rule.Pattern, branch)
243
+		if mErr != nil || !match {
244
+			continue
245
+		}
246
+		out = append(out, presentRuleset(rule, source))
247
+	}
248
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
249
+	writeJSON(w, http.StatusOK, out)
250
+}