tenseleyflow/shithub / 5c50e31

Browse files

S24: REST API for check-runs + check-suites under /api/v1

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5c50e31d5389952144cd65b2a265ca5adcf32851
Parents
752c4ed
Tree
973fec0

2 changed files

StatusFile+-
M internal/web/handlers/api/api.go 4 0
A internal/web/handlers/api/checks.go 321 0
internal/web/handlers/api/api.gomodified
@@ -59,6 +59,10 @@ func (h *Handlers) Mount(r chi.Router) {
5959
 			r.Use(middleware.RequireScope(pat.ScopeUserRead))
6060
 			r.Get("/api/v1/user", h.userMe)
6161
 		})
62
+		// S24 check-runs / check-suites — RequireScope is per-route
63
+		// inside the helper since reads need repo:read but writes need
64
+		// repo:write.
65
+		h.mountChecks(r)
6266
 	})
6367
 }
6468
 
internal/web/handlers/api/checks.goadded
@@ -0,0 +1,321 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"strconv"
10
+	"time"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
18
+	"github.com/tenseleyFlow/shithub/internal/checks"
19
+	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
20
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
23
+)
24
+
25
+// mountChecks registers the S24 check-runs / check-suites routes.
26
+// Caller has already wrapped this group with PATAuthMiddleware; we
27
+// add a per-route RequireScope(repo:write or repo:read) on top.
28
+func (h *Handlers) mountChecks(r chi.Router) {
29
+	r.Group(func(r chi.Router) {
30
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
31
+		r.Post("/api/v1/repos/{owner}/{repo}/check-runs", h.checkRunCreate)
32
+		r.Patch("/api/v1/repos/{owner}/{repo}/check-runs/{id}", h.checkRunUpdate)
33
+	})
34
+	r.Group(func(r chi.Router) {
35
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
36
+		r.Get("/api/v1/repos/{owner}/{repo}/commits/{sha}/check-runs", h.checkRunsForCommit)
37
+		r.Get("/api/v1/repos/{owner}/{repo}/commits/{sha}/check-suites", h.checkSuitesForCommit)
38
+	})
39
+}
40
+
41
+// resolveRepo loads the repo for {owner}/{repo} and confirms the
42
+// PAT-authenticated user has the `action` permission. Returns the
43
+// resolved repo (or nil, false on failure with response written).
44
+func (h *Handlers) resolveAPIRepo(w http.ResponseWriter, r *http.Request, action policy.Action) (*reposdb.Repo, bool) {
45
+	auth := middleware.PATAuthFromContext(r.Context())
46
+	if auth.UserID == 0 {
47
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
48
+		return nil, false
49
+	}
50
+	owner, err := usersdb.New().GetUserByUsername(r.Context(), h.d.Pool, chi.URLParam(r, "owner"))
51
+	if err != nil {
52
+		writeAPIError(w, http.StatusNotFound, "repo not found")
53
+		return nil, false
54
+	}
55
+	repo, err := reposdb.New().GetRepoByOwnerUserAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
56
+		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
57
+		Name:        chi.URLParam(r, "repo"),
58
+	})
59
+	if err != nil {
60
+		writeAPIError(w, http.StatusNotFound, "repo not found")
61
+		return nil, false
62
+	}
63
+	actor := policy.UserActor(auth.UserID, "", false, false)
64
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, policy.NewRepoRefFromRepo(repo)).Allow {
65
+		// Existence-leak: 404 instead of 403 when the actor can't see
66
+		// the repo. The PAT-scope check above is the public 403; this
67
+		// is the visibility gate.
68
+		writeAPIError(w, http.StatusNotFound, "repo not found")
69
+		return nil, false
70
+	}
71
+	return &repo, true
72
+}
73
+
74
+// ─── POST /api/v1/repos/{owner}/{repo}/check-runs ───────────────────
75
+
76
+type checkRunRequest struct {
77
+	Name        string        `json:"name"`
78
+	HeadSHA     string        `json:"head_sha"`
79
+	AppSlug     string        `json:"app_slug,omitempty"`
80
+	Status      string        `json:"status,omitempty"`
81
+	Conclusion  string        `json:"conclusion,omitempty"`
82
+	StartedAt   string        `json:"started_at,omitempty"`
83
+	CompletedAt string        `json:"completed_at,omitempty"`
84
+	DetailsURL  string        `json:"details_url,omitempty"`
85
+	Output      checks.Output `json:"output,omitempty"`
86
+	ExternalID  string        `json:"external_id,omitempty"`
87
+}
88
+
89
+func (h *Handlers) checkRunCreate(w http.ResponseWriter, r *http.Request) {
90
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
91
+	if !ok {
92
+		return
93
+	}
94
+	var body checkRunRequest
95
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
96
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
97
+		return
98
+	}
99
+	startedAt, err := parseTimeOptional(body.StartedAt)
100
+	if err != nil {
101
+		writeAPIError(w, http.StatusBadRequest, "started_at: "+err.Error())
102
+		return
103
+	}
104
+	completedAt, err := parseTimeOptional(body.CompletedAt)
105
+	if err != nil {
106
+		writeAPIError(w, http.StatusBadRequest, "completed_at: "+err.Error())
107
+		return
108
+	}
109
+	run, err := checks.Create(r.Context(), checks.Deps{Pool: h.d.Pool}, checks.CreateParams{
110
+		RepoID:      repo.ID,
111
+		HeadSHA:     body.HeadSHA,
112
+		AppSlug:     body.AppSlug,
113
+		Name:        body.Name,
114
+		Status:      body.Status,
115
+		Conclusion:  body.Conclusion,
116
+		StartedAt:   startedAt,
117
+		CompletedAt: completedAt,
118
+		DetailsURL:  body.DetailsURL,
119
+		Output:      body.Output,
120
+		ExternalID:  body.ExternalID,
121
+	})
122
+	if err != nil {
123
+		writeChecksError(w, err)
124
+		return
125
+	}
126
+	writeJSON(w, http.StatusCreated, presentRun(run))
127
+}
128
+
129
+// ─── PATCH /api/v1/repos/{owner}/{repo}/check-runs/{id} ─────────────
130
+
131
+type checkRunUpdateRequest struct {
132
+	Status      *string        `json:"status,omitempty"`
133
+	Conclusion  *string        `json:"conclusion,omitempty"`
134
+	StartedAt   *string        `json:"started_at,omitempty"`
135
+	CompletedAt *string        `json:"completed_at,omitempty"`
136
+	DetailsURL  *string        `json:"details_url,omitempty"`
137
+	Output      *checks.Output `json:"output,omitempty"`
138
+}
139
+
140
+func (h *Handlers) checkRunUpdate(w http.ResponseWriter, r *http.Request) {
141
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
142
+	if !ok {
143
+		return
144
+	}
145
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
146
+	if err != nil {
147
+		writeAPIError(w, http.StatusNotFound, "run not found")
148
+		return
149
+	}
150
+	// Ensure the run belongs to this repo before applying.
151
+	cur, err := checksdb.New().GetCheckRun(r.Context(), h.d.Pool, id)
152
+	if err != nil {
153
+		if errors.Is(err, pgx.ErrNoRows) {
154
+			writeAPIError(w, http.StatusNotFound, "run not found")
155
+		} else {
156
+			writeAPIError(w, http.StatusInternalServerError, "lookup failed")
157
+		}
158
+		return
159
+	}
160
+	if cur.RepoID != repo.ID {
161
+		writeAPIError(w, http.StatusNotFound, "run not found")
162
+		return
163
+	}
164
+	var body checkRunUpdateRequest
165
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
166
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
167
+		return
168
+	}
169
+	params := checks.UpdateParams{RunID: id}
170
+	if body.Status != nil {
171
+		params.HasStatus = true
172
+		params.Status = *body.Status
173
+	}
174
+	if body.Conclusion != nil {
175
+		params.HasConclusion = true
176
+		params.Conclusion = *body.Conclusion
177
+	}
178
+	if body.StartedAt != nil {
179
+		t, err := parseTimeOptional(*body.StartedAt)
180
+		if err != nil {
181
+			writeAPIError(w, http.StatusBadRequest, "started_at: "+err.Error())
182
+			return
183
+		}
184
+		params.HasStartedAt = true
185
+		params.StartedAt = t
186
+	}
187
+	if body.CompletedAt != nil {
188
+		t, err := parseTimeOptional(*body.CompletedAt)
189
+		if err != nil {
190
+			writeAPIError(w, http.StatusBadRequest, "completed_at: "+err.Error())
191
+			return
192
+		}
193
+		params.HasCompletedAt = true
194
+		params.CompletedAt = t
195
+	}
196
+	if body.DetailsURL != nil {
197
+		params.HasDetailsURL = true
198
+		params.DetailsURL = *body.DetailsURL
199
+	}
200
+	if body.Output != nil {
201
+		params.HasOutput = true
202
+		params.Output = *body.Output
203
+	}
204
+	updated, err := checks.Update(r.Context(), checks.Deps{Pool: h.d.Pool}, params)
205
+	if err != nil {
206
+		writeChecksError(w, err)
207
+		return
208
+	}
209
+	writeJSON(w, http.StatusOK, presentRun(updated))
210
+}
211
+
212
+// ─── GET /api/v1/repos/{owner}/{repo}/commits/{sha}/check-runs ──────
213
+
214
+func (h *Handlers) checkRunsForCommit(w http.ResponseWriter, r *http.Request) {
215
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
216
+	if !ok {
217
+		return
218
+	}
219
+	sha := chi.URLParam(r, "sha")
220
+	rows, err := checksdb.New().ListCheckRunsForCommit(r.Context(), h.d.Pool, checksdb.ListCheckRunsForCommitParams{
221
+		RepoID: repo.ID, HeadSha: sha,
222
+	})
223
+	if err != nil {
224
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
225
+		return
226
+	}
227
+	out := make([]map[string]any, 0, len(rows))
228
+	for _, run := range rows {
229
+		out = append(out, presentRun(run))
230
+	}
231
+	writeJSON(w, http.StatusOK, map[string]any{"runs": out})
232
+}
233
+
234
+// ─── GET /api/v1/repos/{owner}/{repo}/commits/{sha}/check-suites ────
235
+
236
+func (h *Handlers) checkSuitesForCommit(w http.ResponseWriter, r *http.Request) {
237
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
238
+	if !ok {
239
+		return
240
+	}
241
+	sha := chi.URLParam(r, "sha")
242
+	rows, err := checksdb.New().ListCheckSuitesForCommit(r.Context(), h.d.Pool, checksdb.ListCheckSuitesForCommitParams{
243
+		RepoID: repo.ID, HeadSha: sha,
244
+	})
245
+	if err != nil {
246
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
247
+		return
248
+	}
249
+	out := make([]map[string]any, 0, len(rows))
250
+	for _, s := range rows {
251
+		out = append(out, presentSuite(s))
252
+	}
253
+	writeJSON(w, http.StatusOK, map[string]any{"suites": out})
254
+}
255
+
256
+// ─── helpers ────────────────────────────────────────────────────────
257
+
258
+func parseTimeOptional(s string) (time.Time, error) {
259
+	if s == "" {
260
+		return time.Time{}, nil
261
+	}
262
+	return time.Parse(time.RFC3339, s)
263
+}
264
+
265
+func presentRun(r checksdb.CheckRun) map[string]any {
266
+	out := map[string]any{
267
+		"id":          r.ID,
268
+		"suite_id":    r.SuiteID,
269
+		"head_sha":    r.HeadSha,
270
+		"name":        r.Name,
271
+		"status":      string(r.Status),
272
+		"details_url": r.DetailsUrl,
273
+		"output":      json.RawMessage(r.Output),
274
+	}
275
+	if r.Conclusion.Valid {
276
+		out["conclusion"] = string(r.Conclusion.CheckConclusion)
277
+	}
278
+	if r.StartedAt.Valid {
279
+		out["started_at"] = r.StartedAt.Time.Format(time.RFC3339)
280
+	}
281
+	if r.CompletedAt.Valid {
282
+		out["completed_at"] = r.CompletedAt.Time.Format(time.RFC3339)
283
+	}
284
+	if r.ExternalID.Valid {
285
+		out["external_id"] = r.ExternalID.String
286
+	}
287
+	return out
288
+}
289
+
290
+func presentSuite(s checksdb.CheckSuite) map[string]any {
291
+	out := map[string]any{
292
+		"id":        s.ID,
293
+		"head_sha":  s.HeadSha,
294
+		"app_slug":  s.AppSlug,
295
+		"status":    string(s.Status),
296
+	}
297
+	if s.Conclusion.Valid {
298
+		out["conclusion"] = string(s.Conclusion.CheckConclusion)
299
+	}
300
+	return out
301
+}
302
+
303
+// writeChecksError maps the orchestrator's typed errors to HTTP codes.
304
+func writeChecksError(w http.ResponseWriter, err error) {
305
+	switch {
306
+	case errors.Is(err, checks.ErrEmptyName),
307
+		errors.Is(err, checks.ErrNameTooLong),
308
+		errors.Is(err, checks.ErrInvalidStatus),
309
+		errors.Is(err, checks.ErrInvalidConclusion),
310
+		errors.Is(err, checks.ErrCompletedNeedsConclusion),
311
+		errors.Is(err, checks.ErrShortHeadSHA),
312
+		errors.Is(err, checks.ErrOutputTextTooLarge),
313
+		errors.Is(err, checks.ErrOutputSummaryTooLarge):
314
+		writeAPIError(w, http.StatusBadRequest, err.Error())
315
+	case errors.Is(err, checks.ErrCheckRunNotFound),
316
+		errors.Is(err, checks.ErrSuiteNotFound):
317
+		writeAPIError(w, http.StatusNotFound, err.Error())
318
+	default:
319
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
320
+	}
321
+}