tenseleyflow/shithub / e0211a8

Browse files

api: repos REST core (list/single/create/patch/delete)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e0211a883ee5efebeceb187638e6b4a479bf4b5d
Parents
6136b0a
Tree
91e0172

4 changed files

StatusFile+-
M internal/web/auth_wiring.go 18 9
M internal/web/handlers/api/api.go 18 0
M internal/web/handlers/api/meta.go 1 0
A internal/web/handlers/api/repos.go 640 0
internal/web/auth_wiring.gomodified
@@ -60,16 +60,25 @@ func buildAPIHandlers(
6060
 	if err != nil {
6161
 		return nil, fmt.Errorf("api: NewRepoFS: %w", err)
6262
 	}
63
+	shithubdPath := "shithubd"
64
+	if exe, err := os.Executable(); err == nil {
65
+		if abs, absErr := filepath.Abs(exe); absErr == nil {
66
+			shithubdPath = abs
67
+		}
68
+	}
6369
 	return apih.New(apih.Deps{
64
-		Pool:        pool,
65
-		Debouncer:   sharedPATDebouncer,
66
-		Logger:      logger,
67
-		ObjectStore: objectStore,
68
-		RepoFS:      rfs,
69
-		RunnerJWT:   runnerJWT,
70
-		SecretBox:   secretBox,
71
-		RateLimiter: rateLimiter,
72
-		BaseURL:     cfg.Auth.BaseURL,
70
+		Pool:         pool,
71
+		Debouncer:    sharedPATDebouncer,
72
+		Logger:       logger,
73
+		ObjectStore:  objectStore,
74
+		RepoFS:       rfs,
75
+		RunnerJWT:    runnerJWT,
76
+		SecretBox:    secretBox,
77
+		RateLimiter:  rateLimiter,
78
+		Audit:        audit.NewRecorder(),
79
+		Throttle:     throttle.NewLimiter(),
80
+		ShithubdPath: shithubdPath,
81
+		BaseURL:      cfg.Auth.BaseURL,
7382
 		APILimit: apilimit.Config{
7483
 			AuthedPerHour: cfg.RateLimit.API.AuthedPerHour,
7584
 			AnonPerHour:   cfg.RateLimit.API.AnonPerHour,
internal/web/handlers/api/api.gomodified
@@ -18,9 +18,11 @@ import (
1818
 	"github.com/go-chi/chi/v5"
1919
 	"github.com/jackc/pgx/v5/pgxpool"
2020
 
21
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
2122
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
2223
 	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
2324
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
25
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2426
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2527
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
2628
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
@@ -39,6 +41,20 @@ type Deps struct {
3941
 	RunnerJWT   *runnerjwt.Signer
4042
 	SecretBox   *secretbox.Box
4143
 	RateLimiter *ratelimit.Limiter
44
+	// Audit records security-sensitive mutations (repo create/delete,
45
+	// settings changes). Required for any handler that mutates server
46
+	// state; nil disables the audit emission for that handler.
47
+	Audit *audit.Recorder
48
+	// Throttle is the per-actor anti-abuse limiter consulted by
49
+	// repos.Create. Independent from RateLimiter (which is the
50
+	// shared-counter rate-limit subsystem) — Throttle uses the older
51
+	// per-action counter that the HTML create flow shares with us so
52
+	// budgets are observed consistently across both surfaces.
53
+	Throttle *throttle.Limiter
54
+	// ShithubdPath is the absolute path of the running shithubd
55
+	// binary, forwarded to repos.Create so its hook shims resolve
56
+	// correctly. Empty disables hook installation (test fixtures).
57
+	ShithubdPath string
4258
 	// BaseURL is the public scheme://host prefix used for absolute
4359
 	// pagination Link headers. Empty falls back to path-relative URLs.
4460
 	BaseURL string
@@ -125,6 +141,8 @@ func (h *Handlers) Mount(r chi.Router) {
125141
 		h.mountUserEmails(r)
126142
 		// S50 §1 — user SSH keys CRUD.
127143
 		h.mountUserKeys(r)
144
+		// S50 §2 — repos REST core (list/single/create/patch/delete).
145
+		h.mountRepos(r)
128146
 	})
129147
 }
130148
 
internal/web/handlers/api/meta.gomodified
@@ -25,6 +25,7 @@ var APICapabilities = []string{
2525
 	"actions-lifecycle",
2626
 	"user-emails",
2727
 	"ssh-keys",
28
+	"repos",
2829
 }
2930
 
3031
 type metaResponse struct {
internal/web/handlers/api/repos.goadded
@@ -0,0 +1,640 @@
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
+	"strings"
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/orgs"
19
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
20
+	"github.com/tenseleyFlow/shithub/internal/repos"
21
+	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
22
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
24
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
25
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
26
+)
27
+
28
+// mountRepos registers the S50 §2 REST surface for repositories.
29
+//
30
+//	GET    /api/v1/user/repos                 list authenticated user's repos
31
+//	GET    /api/v1/users/{username}/repos     list a user's public repos
32
+//	GET    /api/v1/orgs/{org}/repos           list an org's repos (visibility-aware)
33
+//	GET    /api/v1/repos/{owner}/{repo}       fetch a single repo
34
+//	POST   /api/v1/user/repos                 create personal repo
35
+//	POST   /api/v1/orgs/{org}/repos           create org-owned repo
36
+//	PATCH  /api/v1/repos/{owner}/{repo}       update mutable repo settings
37
+//	DELETE /api/v1/repos/{owner}/{repo}       soft-delete a repo
38
+//
39
+// Scopes: repo:read for GETs, repo:write for POST/PATCH/DELETE. Existence
40
+// leaks are smothered behind policy gates that 404 instead of 403 when
41
+// the caller can't see the resource.
42
+func (h *Handlers) mountRepos(r chi.Router) {
43
+	r.Group(func(r chi.Router) {
44
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
45
+		r.Get("/api/v1/user/repos", h.userReposList)
46
+		r.Get("/api/v1/users/{username}/repos", h.userPublicReposList)
47
+		r.Get("/api/v1/orgs/{org}/repos", h.orgReposList)
48
+		r.Get("/api/v1/repos/{owner}/{repo}", h.repoGet)
49
+	})
50
+	r.Group(func(r chi.Router) {
51
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
52
+		r.Post("/api/v1/user/repos", h.userRepoCreate)
53
+		r.Post("/api/v1/orgs/{org}/repos", h.orgRepoCreate)
54
+		r.Patch("/api/v1/repos/{owner}/{repo}", h.repoPatch)
55
+		r.Delete("/api/v1/repos/{owner}/{repo}", h.repoDelete)
56
+	})
57
+}
58
+
59
+// repoResponse mirrors GitHub's repo shape. Field selection is the
60
+// minimum the CLI's `gh repo view` / clone logic needs to operate.
61
+type repoResponse struct {
62
+	ID            int64  `json:"id"`
63
+	Name          string `json:"name"`
64
+	FullName      string `json:"full_name"`
65
+	OwnerLogin    string `json:"owner_login"`
66
+	OwnerType     string `json:"owner_type"` // "user" | "org"
67
+	Description   string `json:"description"`
68
+	Visibility    string `json:"visibility"`
69
+	Private       bool   `json:"private"`
70
+	DefaultBranch string `json:"default_branch"`
71
+	Fork          bool   `json:"fork"`
72
+	Archived      bool   `json:"archived"`
73
+	HasIssues     bool   `json:"has_issues"`
74
+	HasPulls      bool   `json:"has_pulls"`
75
+	StarCount     int64  `json:"star_count"`
76
+	WatcherCount  int64  `json:"watcher_count"`
77
+	ForkCount     int64  `json:"fork_count"`
78
+	CreatedAt     string `json:"created_at"`
79
+	UpdatedAt     string `json:"updated_at"`
80
+}
81
+
82
+func presentRepo(r reposdb.Repo, ownerLogin string) repoResponse {
83
+	ownerType := "user"
84
+	if r.OwnerOrgID.Valid {
85
+		ownerType = "org"
86
+	}
87
+	return repoResponse{
88
+		ID:            r.ID,
89
+		Name:          r.Name,
90
+		FullName:      ownerLogin + "/" + r.Name,
91
+		OwnerLogin:    ownerLogin,
92
+		OwnerType:     ownerType,
93
+		Description:   r.Description,
94
+		Visibility:    string(r.Visibility),
95
+		Private:       r.Visibility != reposdb.RepoVisibilityPublic,
96
+		DefaultBranch: r.DefaultBranch,
97
+		Fork:          r.ForkOfRepoID.Valid,
98
+		Archived:      r.IsArchived,
99
+		HasIssues:     r.HasIssues,
100
+		HasPulls:      r.HasPulls,
101
+		StarCount:     r.StarCount,
102
+		WatcherCount:  r.WatcherCount,
103
+		ForkCount:     r.ForkCount,
104
+		CreatedAt:     r.CreatedAt.Time.UTC().Format(time.RFC3339),
105
+		UpdatedAt:     r.UpdatedAt.Time.UTC().Format(time.RFC3339),
106
+	}
107
+}
108
+
109
+// ─── list endpoints ─────────────────────────────────────────────────
110
+
111
+func (h *Handlers) userReposList(w http.ResponseWriter, r *http.Request) {
112
+	auth := middleware.PATAuthFromContext(r.Context())
113
+	if auth.UserID == 0 {
114
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
115
+		return
116
+	}
117
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
118
+	q := reposdb.New()
119
+	total, err := q.CountReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: auth.UserID, Valid: true})
120
+	if err != nil {
121
+		h.d.Logger.ErrorContext(r.Context(), "api: count user repos", "error", err)
122
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
123
+		return
124
+	}
125
+	rows, err := q.ListReposForOwnerUserPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerUserPagedParams{
126
+		OwnerUserID: pgtype.Int8{Int64: auth.UserID, Valid: true},
127
+		Limit:       int32(perPage),
128
+		Offset:      int32((page - 1) * perPage),
129
+	})
130
+	if err != nil {
131
+		h.d.Logger.ErrorContext(r.Context(), "api: list user repos", "error", err)
132
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
133
+		return
134
+	}
135
+	h.writeRepoListPage(w, r, page, perPage, int(total), rows, auth.Username)
136
+}
137
+
138
+func (h *Handlers) userPublicReposList(w http.ResponseWriter, r *http.Request) {
139
+	owner, ok := h.resolveAPIUserOwner(w, r, chi.URLParam(r, "username"))
140
+	if !ok {
141
+		return
142
+	}
143
+	auth := middleware.PATAuthFromContext(r.Context())
144
+	q := reposdb.New()
145
+	// Self-view of /users/{me}/repos shows everything (private included),
146
+	// matching GitHub's behavior.
147
+	if auth.UserID == owner.ID {
148
+		page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
149
+		total, err := q.CountReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: owner.ID, Valid: true})
150
+		if err != nil {
151
+			h.d.Logger.ErrorContext(r.Context(), "api: count user repos", "error", err)
152
+			writeAPIError(w, http.StatusInternalServerError, "list failed")
153
+			return
154
+		}
155
+		rows, err := q.ListReposForOwnerUserPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerUserPagedParams{
156
+			OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
157
+			Limit:       int32(perPage),
158
+			Offset:      int32((page - 1) * perPage),
159
+		})
160
+		if err != nil {
161
+			h.d.Logger.ErrorContext(r.Context(), "api: list user repos", "error", err)
162
+			writeAPIError(w, http.StatusInternalServerError, "list failed")
163
+			return
164
+		}
165
+		h.writeRepoListPage(w, r, page, perPage, int(total), rows, owner.Username)
166
+		return
167
+	}
168
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
169
+	total, err := q.CountPublicReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: owner.ID, Valid: true})
170
+	if err != nil {
171
+		h.d.Logger.ErrorContext(r.Context(), "api: count public repos", "error", err)
172
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
173
+		return
174
+	}
175
+	rows, err := q.ListPublicReposForOwnerUser(r.Context(), h.d.Pool, reposdb.ListPublicReposForOwnerUserParams{
176
+		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
177
+		Limit:       int32(perPage),
178
+		Offset:      int32((page - 1) * perPage),
179
+	})
180
+	if err != nil {
181
+		h.d.Logger.ErrorContext(r.Context(), "api: list public repos", "error", err)
182
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
183
+		return
184
+	}
185
+	h.writeRepoListPage(w, r, page, perPage, int(total), rows, owner.Username)
186
+}
187
+
188
+func (h *Handlers) orgReposList(w http.ResponseWriter, r *http.Request) {
189
+	org, ok := h.resolveAPIOrgOwner(w, r, chi.URLParam(r, "org"))
190
+	if !ok {
191
+		return
192
+	}
193
+	auth := middleware.PATAuthFromContext(r.Context())
194
+	q := reposdb.New()
195
+
196
+	memberView := false
197
+	if auth.UserID != 0 {
198
+		isMem, err := orgs.IsMember(r.Context(), orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, auth.UserID)
199
+		if err != nil {
200
+			h.d.Logger.ErrorContext(r.Context(), "api: org member check", "error", err)
201
+			writeAPIError(w, http.StatusInternalServerError, "list failed")
202
+			return
203
+		}
204
+		memberView = isMem || auth.IsSiteAdmin
205
+	}
206
+
207
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
208
+	if memberView {
209
+		total, err := q.CountReposForOwnerOrg(r.Context(), h.d.Pool, pgtype.Int8{Int64: org.ID, Valid: true})
210
+		if err != nil {
211
+			h.d.Logger.ErrorContext(r.Context(), "api: count org repos", "error", err)
212
+			writeAPIError(w, http.StatusInternalServerError, "list failed")
213
+			return
214
+		}
215
+		rows, err := q.ListReposForOwnerOrgPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerOrgPagedParams{
216
+			OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
217
+			Limit:      int32(perPage),
218
+			Offset:     int32((page - 1) * perPage),
219
+		})
220
+		if err != nil {
221
+			h.d.Logger.ErrorContext(r.Context(), "api: list org repos", "error", err)
222
+			writeAPIError(w, http.StatusInternalServerError, "list failed")
223
+			return
224
+		}
225
+		h.writeRepoListPage(w, r, page, perPage, int(total), rows, string(org.Slug))
226
+		return
227
+	}
228
+	total, err := q.CountPublicReposForOwnerOrg(r.Context(), h.d.Pool, pgtype.Int8{Int64: org.ID, Valid: true})
229
+	if err != nil {
230
+		h.d.Logger.ErrorContext(r.Context(), "api: count public org repos", "error", err)
231
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
232
+		return
233
+	}
234
+	rows, err := q.ListPublicReposForOwnerOrg(r.Context(), h.d.Pool, reposdb.ListPublicReposForOwnerOrgParams{
235
+		OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
236
+		Limit:      int32(perPage),
237
+		Offset:     int32((page - 1) * perPage),
238
+	})
239
+	if err != nil {
240
+		h.d.Logger.ErrorContext(r.Context(), "api: list public org repos", "error", err)
241
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
242
+		return
243
+	}
244
+	h.writeRepoListPage(w, r, page, perPage, int(total), rows, string(org.Slug))
245
+}
246
+
247
+func (h *Handlers) writeRepoListPage(w http.ResponseWriter, r *http.Request, page, perPage, total int, rows []reposdb.Repo, ownerLogin string) {
248
+	link := apipage.Page{
249
+		Current: page, PerPage: perPage, Total: total,
250
+	}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
251
+	if link != "" {
252
+		w.Header().Set("Link", link)
253
+	}
254
+	out := make([]repoResponse, 0, len(rows))
255
+	for _, row := range rows {
256
+		out = append(out, presentRepo(row, ownerLogin))
257
+	}
258
+	writeJSON(w, http.StatusOK, out)
259
+}
260
+
261
+// ─── single-repo GET ────────────────────────────────────────────────
262
+
263
+func (h *Handlers) repoGet(w http.ResponseWriter, r *http.Request) {
264
+	repo, ownerLogin, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoRead)
265
+	if !ok {
266
+		return
267
+	}
268
+	writeJSON(w, http.StatusOK, presentRepo(repo, ownerLogin))
269
+}
270
+
271
+// ─── create endpoints ───────────────────────────────────────────────
272
+
273
+type repoCreateRequest struct {
274
+	Name        string `json:"name"`
275
+	Description string `json:"description"`
276
+	Visibility  string `json:"visibility"`
277
+	Private     *bool  `json:"private,omitempty"`
278
+	AutoInit    bool   `json:"auto_init"`
279
+	License     string `json:"license_template"`
280
+	Gitignore   string `json:"gitignore_template"`
281
+}
282
+
283
+// resolvedVisibility picks "public" or "private" from a request, honoring
284
+// either `visibility` (preferred, matches our internal vocab) or the
285
+// gh-compatible `private` boolean. Defaults to "private" — safer than
286
+// public.
287
+func (req repoCreateRequest) resolvedVisibility() (string, error) {
288
+	if req.Visibility != "" {
289
+		switch strings.ToLower(req.Visibility) {
290
+		case "public", "private":
291
+			return strings.ToLower(req.Visibility), nil
292
+		default:
293
+			return "", errors.New("visibility must be public or private")
294
+		}
295
+	}
296
+	if req.Private != nil {
297
+		if *req.Private {
298
+			return "private", nil
299
+		}
300
+		return "public", nil
301
+	}
302
+	return "private", nil
303
+}
304
+
305
+func (h *Handlers) userRepoCreate(w http.ResponseWriter, r *http.Request) {
306
+	auth := middleware.PATAuthFromContext(r.Context())
307
+	if auth.UserID == 0 {
308
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
309
+		return
310
+	}
311
+	var body repoCreateRequest
312
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
313
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
314
+		return
315
+	}
316
+	visibility, err := body.resolvedVisibility()
317
+	if err != nil {
318
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
319
+		return
320
+	}
321
+	params := repos.Params{
322
+		ActorUserID:      auth.UserID,
323
+		ActorIsSiteAdmin: auth.IsSiteAdmin,
324
+		OwnerUserID:      auth.UserID,
325
+		OwnerUsername:    auth.Username,
326
+		Name:             repos.NormalizeName(body.Name),
327
+		Description:      body.Description,
328
+		Visibility:       visibility,
329
+		InitReadme:       body.AutoInit,
330
+		LicenseKey:       body.License,
331
+		GitignoreKey:     body.Gitignore,
332
+	}
333
+	h.runRepoCreate(w, r, params, auth.Username)
334
+}
335
+
336
+func (h *Handlers) orgRepoCreate(w http.ResponseWriter, r *http.Request) {
337
+	auth := middleware.PATAuthFromContext(r.Context())
338
+	if auth.UserID == 0 {
339
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
340
+		return
341
+	}
342
+	org, ok := h.resolveAPIOrgOwner(w, r, chi.URLParam(r, "org"))
343
+	if !ok {
344
+		return
345
+	}
346
+	odeps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
347
+	isMember, err := orgs.IsMember(r.Context(), odeps, org.ID, auth.UserID)
348
+	if err != nil {
349
+		h.d.Logger.ErrorContext(r.Context(), "api: org member check", "error", err)
350
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
351
+		return
352
+	}
353
+	if !isMember && !auth.IsSiteAdmin {
354
+		// Existence-leak parity with the rest of the surface.
355
+		writeAPIError(w, http.StatusNotFound, "org not found")
356
+		return
357
+	}
358
+	isOwner, err := orgs.IsOwner(r.Context(), odeps, org.ID, auth.UserID)
359
+	if err != nil {
360
+		h.d.Logger.ErrorContext(r.Context(), "api: org owner check", "error", err)
361
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
362
+		return
363
+	}
364
+	if !isOwner && !org.AllowMemberRepoCreate && !auth.IsSiteAdmin {
365
+		writeAPIError(w, http.StatusForbidden, "organization restricts repo creation to owners")
366
+		return
367
+	}
368
+	var body repoCreateRequest
369
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
370
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
371
+		return
372
+	}
373
+	visibility, err := body.resolvedVisibility()
374
+	if err != nil {
375
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
376
+		return
377
+	}
378
+	params := repos.Params{
379
+		ActorUserID:      auth.UserID,
380
+		ActorIsSiteAdmin: auth.IsSiteAdmin,
381
+		OwnerOrgID:       org.ID,
382
+		OwnerSlug:        string(org.Slug),
383
+		Name:             repos.NormalizeName(body.Name),
384
+		Description:      body.Description,
385
+		Visibility:       visibility,
386
+		InitReadme:       body.AutoInit,
387
+		LicenseKey:       body.License,
388
+		GitignoreKey:     body.Gitignore,
389
+	}
390
+	h.runRepoCreate(w, r, params, string(org.Slug))
391
+}
392
+
393
+func (h *Handlers) runRepoCreate(w http.ResponseWriter, r *http.Request, params repos.Params, ownerLogin string) {
394
+	if h.d.Audit == nil || h.d.Throttle == nil || h.d.RepoFS == nil {
395
+		writeAPIError(w, http.StatusServiceUnavailable, "repo create is not configured")
396
+		return
397
+	}
398
+	res, err := repos.Create(r.Context(), repos.Deps{
399
+		Pool:         h.d.Pool,
400
+		RepoFS:       h.d.RepoFS,
401
+		Audit:        h.d.Audit,
402
+		Limiter:      h.d.Throttle,
403
+		Logger:       h.d.Logger,
404
+		ShithubdPath: h.d.ShithubdPath,
405
+	}, params)
406
+	if err != nil {
407
+		writeRepoCreateError(w, err)
408
+		return
409
+	}
410
+	writeJSON(w, http.StatusCreated, presentRepo(res.Repo, ownerLogin))
411
+}
412
+
413
+func writeRepoCreateError(w http.ResponseWriter, err error) {
414
+	switch {
415
+	case errors.Is(err, repos.ErrInvalidName),
416
+		errors.Is(err, repos.ErrReservedName),
417
+		errors.Is(err, repos.ErrDescriptionTooLong),
418
+		errors.Is(err, repos.ErrUnknownLicense),
419
+		errors.Is(err, repos.ErrUnknownGitignore):
420
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
421
+	case errors.Is(err, repos.ErrTaken):
422
+		writeAPIError(w, http.StatusConflict, "name taken for owner")
423
+	case errors.Is(err, repos.ErrNoVerifiedEmail):
424
+		writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email")
425
+	default:
426
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
427
+	}
428
+}
429
+
430
+// ─── update / delete ────────────────────────────────────────────────
431
+
432
+type repoPatchRequest struct {
433
+	Description *string `json:"description,omitempty"`
434
+	HasIssues   *bool   `json:"has_issues,omitempty"`
435
+	HasPulls    *bool   `json:"has_pulls,omitempty"`
436
+	Archived    *bool   `json:"archived,omitempty"`
437
+	Visibility  *string `json:"visibility,omitempty"`
438
+}
439
+
440
+func (h *Handlers) repoPatch(w http.ResponseWriter, r *http.Request) {
441
+	repo, ownerLogin, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoSettingsGeneral)
442
+	if !ok {
443
+		return
444
+	}
445
+	auth := middleware.PATAuthFromContext(r.Context())
446
+	var body repoPatchRequest
447
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
448
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
449
+		return
450
+	}
451
+	// General settings (description, has_issues, has_pulls) go through
452
+	// the single UpdateRepoGeneralSettings query so the form-driven HTML
453
+	// surface and this REST path observe the same row updates.
454
+	if body.Description != nil || body.HasIssues != nil || body.HasPulls != nil {
455
+		desc := repo.Description
456
+		if body.Description != nil {
457
+			if err := repos.ValidateDescription(*body.Description); err != nil {
458
+				writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
459
+				return
460
+			}
461
+			desc = *body.Description
462
+		}
463
+		hasIssues := repo.HasIssues
464
+		if body.HasIssues != nil {
465
+			hasIssues = *body.HasIssues
466
+		}
467
+		hasPulls := repo.HasPulls
468
+		if body.HasPulls != nil {
469
+			hasPulls = *body.HasPulls
470
+		}
471
+		if err := reposdb.New().UpdateRepoGeneralSettings(r.Context(), h.d.Pool, reposdb.UpdateRepoGeneralSettingsParams{
472
+			ID:          repo.ID,
473
+			Description: desc,
474
+			HasIssues:   hasIssues,
475
+			HasPulls:    hasPulls,
476
+		}); err != nil {
477
+			h.d.Logger.ErrorContext(r.Context(), "api: repo patch general", "error", err)
478
+			writeAPIError(w, http.StatusInternalServerError, "update failed")
479
+			return
480
+		}
481
+	}
482
+	if body.Archived != nil {
483
+		ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
484
+		if *body.Archived && !repo.IsArchived {
485
+			if err := lifecycle.Archive(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
486
+				h.d.Logger.ErrorContext(r.Context(), "api: archive", "error", err)
487
+				writeAPIError(w, http.StatusInternalServerError, "archive failed")
488
+				return
489
+			}
490
+		} else if !*body.Archived && repo.IsArchived {
491
+			if err := lifecycle.Unarchive(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
492
+				h.d.Logger.ErrorContext(r.Context(), "api: unarchive", "error", err)
493
+				writeAPIError(w, http.StatusInternalServerError, "unarchive failed")
494
+				return
495
+			}
496
+		}
497
+	}
498
+	if body.Visibility != nil {
499
+		newVis := strings.ToLower(*body.Visibility)
500
+		if newVis != "public" && newVis != "private" {
501
+			writeAPIError(w, http.StatusUnprocessableEntity, "visibility must be public or private")
502
+			return
503
+		}
504
+		if newVis != string(repo.Visibility) {
505
+			ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
506
+			if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil {
507
+				h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err)
508
+				writeAPIError(w, http.StatusInternalServerError, "visibility update failed")
509
+				return
510
+			}
511
+		}
512
+	}
513
+	// Re-load the freshest copy so the response reflects all four
514
+	// possible updates in a single payload.
515
+	fresh, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, repo.ID)
516
+	if err != nil {
517
+		h.d.Logger.ErrorContext(r.Context(), "api: refetch after patch", "error", err)
518
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
519
+		return
520
+	}
521
+	writeJSON(w, http.StatusOK, presentRepo(fresh, ownerLogin))
522
+}
523
+
524
+func (h *Handlers) repoDelete(w http.ResponseWriter, r *http.Request) {
525
+	repo, _, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoDelete)
526
+	if !ok {
527
+		return
528
+	}
529
+	auth := middleware.PATAuthFromContext(r.Context())
530
+	ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
531
+	if err := lifecycle.SoftDelete(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
532
+		if errors.Is(err, lifecycle.ErrAlreadyDeleted) {
533
+			writeAPIError(w, http.StatusNotFound, "repo not found")
534
+			return
535
+		}
536
+		h.d.Logger.ErrorContext(r.Context(), "api: soft delete", "error", err)
537
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
538
+		return
539
+	}
540
+	w.WriteHeader(http.StatusNoContent)
541
+}
542
+
543
+// ─── resolvers ──────────────────────────────────────────────────────
544
+
545
+func (h *Handlers) resolveAPIUserOwner(w http.ResponseWriter, r *http.Request, username string) (usersdb.User, bool) {
546
+	user, err := usersdb.New().GetUserByUsername(r.Context(), h.d.Pool, username)
547
+	if err != nil {
548
+		if errors.Is(err, pgx.ErrNoRows) {
549
+			writeAPIError(w, http.StatusNotFound, "user not found")
550
+			return usersdb.User{}, false
551
+		}
552
+		h.d.Logger.ErrorContext(r.Context(), "api: lookup user", "error", err)
553
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
554
+		return usersdb.User{}, false
555
+	}
556
+	return user, true
557
+}
558
+
559
+func (h *Handlers) resolveAPIOrgOwner(w http.ResponseWriter, r *http.Request, slug string) (orgsdb.Org, bool) {
560
+	org, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
561
+	if err != nil {
562
+		if errors.Is(err, pgx.ErrNoRows) {
563
+			writeAPIError(w, http.StatusNotFound, "org not found")
564
+			return orgsdb.Org{}, false
565
+		}
566
+		h.d.Logger.ErrorContext(r.Context(), "api: lookup org", "error", err)
567
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
568
+		return orgsdb.Org{}, false
569
+	}
570
+	if org.DeletedAt.Valid {
571
+		writeAPIError(w, http.StatusNotFound, "org not found")
572
+		return orgsdb.Org{}, false
573
+	}
574
+	return org, true
575
+}
576
+
577
+// resolveAPIRepoWithLogin loads {owner}/{repo}, runs the policy gate
578
+// (404-on-deny), and additionally returns the owner's login string for
579
+// rendering `full_name`. The login lookup is one extra DB round-trip per
580
+// request — fine for a non-hot path. We compose on top of the existing
581
+// resolveAPIRepo so the existence-leak treatment stays identical.
582
+func (h *Handlers) resolveAPIRepoWithLogin(w http.ResponseWriter, r *http.Request, action policy.Action) (reposdb.Repo, string, bool) {
583
+	auth := middleware.PATAuthFromContext(r.Context())
584
+	if auth.UserID == 0 && actionRequiresAuth(action) {
585
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
586
+		return reposdb.Repo{}, "", false
587
+	}
588
+	ownerLogin := chi.URLParam(r, "owner")
589
+	repoName := chi.URLParam(r, "repo")
590
+	repo, login, err := lookupRepoByLogin(r, h.d.Pool, ownerLogin, repoName)
591
+	if err != nil {
592
+		writeAPIError(w, http.StatusNotFound, "repo not found")
593
+		return reposdb.Repo{}, "", false
594
+	}
595
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), action, policy.NewRepoRefFromRepo(repo)).Allow {
596
+		writeAPIError(w, http.StatusNotFound, "repo not found")
597
+		return reposdb.Repo{}, "", false
598
+	}
599
+	return repo, login, true
600
+}
601
+
602
+// actionRequiresAuth returns true for actions that always require a
603
+// logged-in caller (everything except plain read).
604
+func actionRequiresAuth(a policy.Action) bool {
605
+	return a != policy.ActionRepoRead
606
+}
607
+
608
+// lookupRepoByLogin tries the user-owner path first, then the org-owner
609
+// path. The login string returned is whichever resolved successfully so
610
+// the caller can plug it into the full_name field.
611
+func lookupRepoByLogin(r *http.Request, pool reposdbPool, ownerLogin, repoName string) (reposdb.Repo, string, error) {
612
+	rq := reposdb.New()
613
+	if user, err := usersdb.New().GetUserByUsername(r.Context(), pool, ownerLogin); err == nil {
614
+		repo, err := rq.GetRepoByOwnerUserAndName(r.Context(), pool, reposdb.GetRepoByOwnerUserAndNameParams{
615
+			OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true},
616
+			Name:        repoName,
617
+		})
618
+		if err == nil {
619
+			return repo, user.Username, nil
620
+		}
621
+		if !errors.Is(err, pgx.ErrNoRows) {
622
+			return reposdb.Repo{}, "", err
623
+		}
624
+	}
625
+	if org, err := orgsdb.New().GetOrgBySlug(r.Context(), pool, ownerLogin); err == nil {
626
+		repo, err := rq.GetRepoByOwnerOrgAndName(r.Context(), pool, reposdb.GetRepoByOwnerOrgAndNameParams{
627
+			OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
628
+			Name:       repoName,
629
+		})
630
+		if err == nil {
631
+			return repo, string(org.Slug), nil
632
+		}
633
+	}
634
+	return reposdb.Repo{}, "", pgx.ErrNoRows
635
+}
636
+
637
+// reposdbPool aliases the pgx DBTX interface that all sqlc-generated
638
+// methods accept; declaring it here keeps this file from importing
639
+// pgxpool directly for what is effectively a typed parameter.
640
+type reposdbPool = reposdb.DBTX