Go · 20994 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package repo wires the HTTP handlers for repository creation and the
4 // (placeholder) repo home page. The actual orchestration lives in
5 // internal/repos; this package is the thin web layer that calls into it.
6 package repo
7
8 import (
9 "context"
10 "errors"
11 "log/slog"
12 "net/http"
13 "strconv"
14 "strings"
15
16 "github.com/go-chi/chi/v5"
17 "github.com/jackc/pgx/v5"
18 "github.com/jackc/pgx/v5/pgtype"
19 "github.com/jackc/pgx/v5/pgxpool"
20 "golang.org/x/sync/singleflight"
21
22 "github.com/tenseleyFlow/shithub/internal/auth/audit"
23 "github.com/tenseleyFlow/shithub/internal/auth/policy"
24 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
25 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
26 checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
27 "github.com/tenseleyFlow/shithub/internal/infra/storage"
28 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
29 "github.com/tenseleyFlow/shithub/internal/orgs"
30 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
31 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
32 "github.com/tenseleyFlow/shithub/internal/ratelimit"
33 "github.com/tenseleyFlow/shithub/internal/repos"
34 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
35 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
36 "github.com/tenseleyFlow/shithub/internal/repos/templates"
37 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
38 "github.com/tenseleyFlow/shithub/internal/web/middleware"
39 "github.com/tenseleyFlow/shithub/internal/web/render"
40 )
41
42 // CloneURLs configures the operator-visible HTTPS / SSH endpoints we
43 // surface on the empty-repo placeholder. SSHEnabled flips whether the
44 // "Clone over SSH" snippet renders at all (S12 wires the protocol).
45 type CloneURLs struct {
46 BaseURL string // e.g. "https://shithub.example"
47 SSHEnabled bool
48 SSHHost string // e.g. "git@shithub.example" — only used when SSHEnabled
49 }
50
51 // cloneHTTPS / cloneSSH compose the per-repo URL strings that every
52 // "Code" view drops into the clone dropdown. Centralized so the
53 // per-template plumbing in code views and the repo home stay
54 // consistent (and switching to a `git://` later is a one-line edit).
55 func (h *Handlers) cloneHTTPS(owner, name string) string {
56 return h.d.CloneURLs.BaseURL + "/" + owner + "/" + name + ".git"
57 }
58
59 func (h *Handlers) cloneSSH(owner, name string) string {
60 return h.d.CloneURLs.SSHHost + ":" + owner + "/" + name + ".git"
61 }
62
63 // Deps wires the handler set.
64 type Deps struct {
65 Logger *slog.Logger
66 Render *render.Renderer
67 Pool *pgxpool.Pool
68 RepoFS *storage.RepoFS
69 // ObjectStore serves archived Actions logs and other repo-scoped blobs.
70 // nil keeps pages renderable in dev/test but archived logs show an
71 // unavailable message instead of exposing storage details.
72 ObjectStore storage.ObjectStore
73 Audit *audit.Recorder
74 Limiter *throttle.Limiter
75 RateLimiter *ratelimit.Limiter
76 CloneURLs CloneURLs
77 // SecretBox AEAD-wraps webhook secrets at rest (S33). nil disables
78 // the webhook surface (the handler renders a placeholder page).
79 SecretBox *secretbox.Box
80 // ShithubdPath is forwarded to repos.Create so newly-init'd repos
81 // have hook shims pointing at the right binary. Empty in test fixtures
82 // that don't exercise hooks.
83 ShithubdPath string
84 }
85
86 // Handlers is the registered handler set. Construct via New.
87 type Handlers struct {
88 d Deps
89 rq *reposdb.Queries
90 uq *usersdb.Queries
91 iq *issuesdb.Queries
92 pq *pullsdb.Queries
93 cq *checksdb.Queries
94 submoduleBackfills singleflight.Group
95 }
96
97 // New constructs the handler set, validating Deps.
98 func New(d Deps) (*Handlers, error) {
99 if d.Render == nil {
100 return nil, errors.New("repo: nil Render")
101 }
102 if d.Pool == nil {
103 return nil, errors.New("repo: nil Pool")
104 }
105 if d.RepoFS == nil {
106 return nil, errors.New("repo: nil RepoFS")
107 }
108 if d.Audit == nil {
109 d.Audit = audit.NewRecorder()
110 }
111 if d.Limiter == nil {
112 d.Limiter = throttle.NewLimiter()
113 }
114 if d.RateLimiter == nil {
115 d.RateLimiter = ratelimit.New(d.Pool)
116 }
117 return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New(), pq: pullsdb.New(), cq: checksdb.New()}, nil
118 }
119
120 // MountNew registers /new (auth-required). Caller wraps with
121 // middleware.RequireUser before invoking.
122 func (h *Handlers) MountNew(r chi.Router) {
123 r.Get("/new", h.newRepoForm)
124 r.Post("/new", h.newRepoSubmit)
125 }
126
127 // MountRepoActionsAPI registers POST/state-changing routes under
128 // /{owner}/{repo}/actions/. Caller wraps with RequireUser.
129 func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
130 r.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", h.repoActionRunCancel)
131 r.Post("/{owner}/{repo}/actions/runs/{runIndex}/rerun", h.repoActionRunRerun)
132 r.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", h.repoActionJobCancel)
133 r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
134 }
135
136 // MountRepoActionsStreams registers long-lived Actions streaming routes.
137 // Caller must mount this outside compression and request-timeout middleware.
138 func (h *Handlers) MountRepoActionsStreams(r chi.Router) {
139 r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", h.repoActionStepLogStream)
140 }
141
142 // MountRepoHome registers the root repository route plus product-tab shells
143 // that are intentionally public and read-gated like the Code tab. The
144 // two-segment route doesn't collide with the /{username} catch-all from S09;
145 // caller is responsible for ordering this BEFORE /{username}.
146 func (h *Handlers) MountRepoHome(r chi.Router) {
147 r.Get("/{owner}/{repo}/actions.atom", h.repoActionsAtom)
148 r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
149 r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
150 r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)
151 r.Get("/{owner}/{repo}/actions", h.repoTabActions)
152 r.Get("/{owner}/{repo}/projects", h.repoTabProjects)
153 r.Get("/{owner}/{repo}/wiki", h.repoTabWiki)
154 r.Get("/{owner}/{repo}/security", h.repoTabSecurity)
155 r.Get("/{owner}/{repo}/pulse", h.repoTabInsights)
156 r.Get("/{owner}/{repo}/packages", h.repoTabPackages)
157 r.Get("/{owner}/{repo}/releases", h.repoTabReleases)
158 r.Get("/{owner}/{repo}", h.repoHome)
159 }
160
161 // newRepoForm renders GET /new.
162 func (h *Handlers) newRepoForm(w http.ResponseWriter, r *http.Request) {
163 h.renderNewForm(w, r, formState{
164 Owner: strings.TrimSpace(r.URL.Query().Get("owner")),
165 Visibility: "public",
166 }, "")
167 }
168
169 // formState mirrors the new-repo form so a re-render after a validation
170 // error can repopulate the user's input.
171 type formState struct {
172 Owner string // "user:<id>" or "org:<id>"
173 Name string
174 Description string
175 Visibility string
176 SourceRemote string
177 InitReadme bool
178 License string
179 Gitignore string
180 }
181
182 // ownerOption is one entry the new-repo owner picker shows.
183 type ownerOption struct {
184 Kind string // "user" | "org"
185 ID int64
186 Slug string
187 Display string
188 // Token is the form value: "user:<id>" or "org:<id>".
189 Token string
190 }
191
192 // newRepoSubmit handles POST /new.
193 func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
194 if err := r.ParseForm(); err != nil {
195 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
196 return
197 }
198 user := middleware.CurrentUserFromContext(r.Context())
199 form := formState{
200 Owner: strings.TrimSpace(r.PostFormValue("owner")),
201 Name: repos.NormalizeName(r.PostFormValue("name")),
202 Description: strings.TrimSpace(r.PostFormValue("description")),
203 Visibility: strings.TrimSpace(r.PostFormValue("visibility")),
204 SourceRemote: strings.TrimSpace(r.PostFormValue("source_remote_url")),
205 InitReadme: r.PostFormValue("init_readme") == "on",
206 License: strings.TrimSpace(r.PostFormValue("license")),
207 Gitignore: strings.TrimSpace(r.PostFormValue("gitignore")),
208 }
209 if form.Visibility == "" {
210 form.Visibility = "public"
211 }
212 var sourceRemoteURL string
213 if form.SourceRemote != "" {
214 if form.InitReadme || form.License != "" || form.Gitignore != "" {
215 h.renderNewForm(w, r, form, "Source imports can't be combined with initial README, license, or .gitignore files.")
216 return
217 }
218 var sourceErr error
219 sourceRemoteURL, sourceErr = repos.ValidateSourceRemoteURL(r.Context(), form.SourceRemote)
220 if sourceErr != nil {
221 h.renderNewForm(w, r, form, "Source remote URL must be a public http(s) git remote without credentials.")
222 return
223 }
224 form.SourceRemote = sourceRemoteURL
225 }
226
227 params := repos.Params{
228 ActorUserID: user.ID,
229 ActorIsSiteAdmin: user.IsSiteAdmin,
230 Name: form.Name,
231 Description: form.Description,
232 Visibility: form.Visibility,
233 InitReadme: form.InitReadme,
234 LicenseKey: form.License,
235 GitignoreKey: form.Gitignore,
236 }
237 // Owner picker: "org:N" routes through the org-owner branch with
238 // the per-org allow_member_repo_create gate; anything else
239 // defaults to the viewer's personal namespace.
240 if kind, id, ok := parseOwnerToken(form.Owner); ok && kind == "org" {
241 odeps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
242 org, oerr := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, id)
243 if oerr != nil || org.DeletedAt.Valid {
244 h.renderNewForm(w, r, form, "Selected organization not found.")
245 return
246 }
247 isMem, _ := orgs.IsMember(r.Context(), odeps, org.ID, user.ID)
248 if !isMem {
249 h.renderNewForm(w, r, form, "You're not a member of that organization.")
250 return
251 }
252 isOwner, _ := orgs.IsOwner(r.Context(), odeps, org.ID, user.ID)
253 if !isOwner && !org.AllowMemberRepoCreate {
254 h.renderNewForm(w, r, form, "This organization restricts repo creation to owners.")
255 return
256 }
257 params.OwnerOrgID = org.ID
258 params.OwnerSlug = string(org.Slug)
259 } else {
260 params.OwnerUserID = user.ID
261 params.OwnerUsername = user.Username
262 }
263
264 res, err := repos.Create(r.Context(), repos.Deps{
265 Pool: h.d.Pool,
266 RepoFS: h.d.RepoFS,
267 Audit: h.d.Audit,
268 Limiter: h.d.Limiter,
269 Logger: h.d.Logger,
270 ShithubdPath: h.d.ShithubdPath,
271 }, params)
272 if err != nil {
273 // Surface the real error in the journal — friendlyCreateError
274 // collapses every non-typed cause into the bland "Could not
275 // create the repository" string the user sees, so without
276 // this log line the operator has no signal to triage a failed
277 // create.
278 h.d.Logger.WarnContext(
279 r.Context(), "repos: create failed",
280 "error", err,
281 "actor_user_id", params.ActorUserID,
282 "owner_user_id", params.OwnerUserID,
283 "owner_org_id", params.OwnerOrgID,
284 "name", params.Name,
285 "visibility", params.Visibility,
286 )
287 h.renderNewForm(w, r, form, friendlyCreateError(err))
288 return
289 }
290 ownerSlug := params.OwnerUsername
291 if ownerSlug == "" {
292 ownerSlug = params.OwnerSlug
293 }
294 if sourceRemoteURL != "" {
295 if _, err := h.rq.UpsertRepoSourceRemote(r.Context(), h.d.Pool, reposdb.UpsertRepoSourceRemoteParams{
296 RepoID: res.Repo.ID,
297 RemoteUrl: sourceRemoteURL,
298 }); err != nil {
299 h.d.Logger.WarnContext(r.Context(), "repos: source remote save after create", "error", err, "repo_id", res.Repo.ID)
300 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-save-failed", http.StatusSeeOther)
301 return
302 }
303 if err := h.fetchRepoSourceRemote(r.Context(), res.Repo, ownerSlug, sourceRemoteURL); err != nil {
304 h.d.Logger.WarnContext(r.Context(), "repos: source remote fetch after create", "error", err, "repo_id", res.Repo.ID, "remote", sourceRemoteURL)
305 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-fetch-failed", http.StatusSeeOther)
306 return
307 }
308 }
309 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name, http.StatusSeeOther)
310 }
311
312 // parseOwnerToken splits a value like "org:42" into ("org", 42, true).
313 // Returns ok=false on missing or unparseable input.
314 func parseOwnerToken(s string) (kind string, id int64, ok bool) {
315 if s == "" {
316 return "", 0, false
317 }
318 colon := strings.IndexByte(s, ':')
319 if colon <= 0 || colon == len(s)-1 {
320 return "", 0, false
321 }
322 kind = s[:colon]
323 if kind != "user" && kind != "org" {
324 return "", 0, false
325 }
326 n, err := strconv.ParseInt(s[colon+1:], 10, 64)
327 if err != nil || n <= 0 {
328 return "", 0, false
329 }
330 return kind, n, true
331 }
332
333 func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form formState, errMsg string) {
334 w.Header().Set("Content-Type", "text/html; charset=utf-8")
335 owners := h.ownerOptions(r)
336 // Default the form's owner pick to the viewer when the field is
337 // empty or invalid. GET /new?owner=<slug> can preselect an org,
338 // but only after matching that slug against the viewer's allowed
339 // owner choices.
340 form.Owner = selectedOwnerToken(form.Owner, owners)
341 if err := h.d.Render.RenderPage(w, r, "repo/new", map[string]any{
342 "Title": "New repository",
343 "CSRFToken": middleware.CSRFTokenForRequest(r),
344 "Form": form,
345 "Error": errMsg,
346 "Licenses": templates.Licenses(),
347 "Gitignores": templates.Gitignores(),
348 "Owners": owners,
349 }); err != nil {
350 h.d.Logger.ErrorContext(r.Context(), "repo: render new", "error", err)
351 }
352 }
353
354 func selectedOwnerToken(raw string, owners []ownerOption) string {
355 if len(owners) == 0 {
356 return ""
357 }
358 raw = strings.TrimSpace(raw)
359 if raw != "" {
360 for _, owner := range owners {
361 if raw == owner.Token {
362 return owner.Token
363 }
364 }
365 for _, owner := range owners {
366 if strings.EqualFold(raw, owner.Slug) {
367 return owner.Token
368 }
369 }
370 }
371 return owners[0].Token
372 }
373
374 // ownerOptions returns the entries the form's owner picker shows:
375 // the viewer themselves plus every org they're a member of where
376 // they're allowed to create (owner role OR allow_member_repo_create).
377 func (h *Handlers) ownerOptions(r *http.Request) []ownerOption {
378 user := middleware.CurrentUserFromContext(r.Context())
379 if user.IsAnonymous() {
380 return nil
381 }
382 out := []ownerOption{{
383 Kind: "user", ID: user.ID, Slug: user.Username,
384 Display: user.Username,
385 Token: "user:" + strconv.FormatInt(user.ID, 10),
386 }}
387 memberships, err := orgsdb.New().ListOrgsForUser(r.Context(), h.d.Pool, user.ID)
388 if err != nil {
389 return out
390 }
391 for _, m := range memberships {
392 isOwner := m.Role == orgsdb.OrgRoleOwner
393 canCreate := isOwner
394 if !isOwner {
395 full, ferr := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, m.OrgID)
396 if ferr == nil {
397 canCreate = full.AllowMemberRepoCreate
398 }
399 }
400 if !canCreate {
401 continue
402 }
403 display := m.DisplayName
404 if display == "" {
405 display = string(m.Slug)
406 }
407 out = append(out, ownerOption{
408 Kind: "org",
409 ID: m.OrgID,
410 Slug: string(m.Slug),
411 Display: display,
412 Token: "org:" + strconv.FormatInt(m.OrgID, 10),
413 })
414 }
415 return out
416 }
417
418 // repoHome serves GET /{owner}/{repo}. Empty repos render the quick setup
419 // placeholder; populated repos render the Code tab at the repository home URL.
420 func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
421 owner := chi.URLParam(r, "owner")
422 name := chi.URLParam(r, "repo")
423
424 row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()))
425 if err != nil {
426 // Maybe the (owner, name) is a stale name; look up the redirect
427 // table and 301 to the canonical URL so old bookmarks keep
428 // working. Authoritative miss after the redirect check 404s.
429 if newURL := h.tryRedirect(r, owner, name); newURL != "" {
430 http.Redirect(w, r, newURL, http.StatusMovedPermanently)
431 return
432 }
433 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
434 return
435 }
436
437 // Keep the populated repository home at /owner/name, matching GitHub's
438 // Code tab instead of forcing users through the /tree/default-branch URL.
439 diskPath, fsErr := h.d.RepoFS.RepoPath(owner, row.Name)
440 hasBranch := false
441 if fsErr == nil {
442 if ok, herr := repogit.HasAnyBranch(r.Context(), diskPath); herr == nil {
443 hasBranch = ok
444 } else {
445 h.d.Logger.WarnContext(r.Context(), "repo: HasAnyBranch", "error", herr)
446 }
447 }
448 if hasBranch {
449 refs, refsErr := repogit.ListRefs(r.Context(), diskPath)
450 if refsErr != nil {
451 h.d.Logger.WarnContext(r.Context(), "repo: ListRefs", "error", refsErr)
452 }
453 h.renderRepoTree(w, r, &codeContext{
454 owner: owner,
455 row: row,
456 gitDir: diskPath,
457 refs: refs,
458 allRefs: refNames(refs),
459 ref: row.DefaultBranch,
460 subpath: "",
461 })
462 return
463 }
464
465 common := map[string]any{
466 "Title": row.Name + " · " + owner,
467 "CSRFToken": middleware.CSRFTokenForRequest(r),
468 "Owner": owner,
469 "Repo": row,
470 "DefaultBranch": row.DefaultBranch,
471 "HTTPSCloneURL": h.cloneHTTPS(owner, row.Name),
472 "SSHEnabled": h.d.CloneURLs.SSHEnabled,
473 "SSHCloneURL": h.cloneSSH(owner, row.Name),
474 "RepoActions": h.repoActions(r, row.ID),
475 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
476 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
477 "ActiveSubnav": "code",
478 }
479
480 w.Header().Set("Content-Type", "text/html; charset=utf-8")
481 // Empty path only — populated repos already redirected above.
482 if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil {
483 h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
484 }
485 }
486
487 // lookupRepoForViewer returns the repo row when:
488 // - it exists,
489 // - it is not soft-deleted,
490 // - AND the viewer is allowed to see it (public OR viewer is owner).
491 //
492 // Anything else returns ErrNoRows so the caller can 404 uniformly.
493 //
494 // Owner kind is dispatched via principals: ownerName resolves to
495 // either a user_id or an org_id via the same single-source-of-truth
496 // table that drives /{slug} routing. Both kinds resolve through the
497 // same indexed lookup so the cost is identical.
498 func (h *Handlers) lookupRepoForViewer(ctx context.Context, ownerName, repoName string, viewer middleware.CurrentUser) (reposdb.Repo, error) {
499 principal, err := orgs.Resolve(ctx, h.d.Pool, ownerName)
500 if err != nil {
501 return reposdb.Repo{}, pgx.ErrNoRows
502 }
503 var row reposdb.Repo
504 switch principal.Kind {
505 case orgs.PrincipalUser:
506 row, err = h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
507 OwnerUserID: pgtype.Int8{Int64: principal.ID, Valid: true},
508 Name: repoName,
509 })
510 case orgs.PrincipalOrg:
511 row, err = h.rq.GetRepoByOwnerOrgAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{
512 OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true},
513 Name: repoName,
514 })
515 default:
516 return reposdb.Repo{}, pgx.ErrNoRows
517 }
518 if err != nil {
519 return reposdb.Repo{}, err
520 }
521 // Visibility decision delegated to policy.Can. We use ActionRepoRead;
522 // when the decision denies, return ErrNoRows so the caller can 404.
523 repoRef := policy.NewRepoRefFromRepo(row)
524 var actor policy.Actor
525 if viewer.IsAnonymous() {
526 actor = policy.AnonymousActor()
527 } else {
528 actor = viewer.PolicyActor()
529 }
530 // ActionRepoRead deny on a private repo with a non-collab viewer is
531 // indistinguishable from "doesn't exist" — Maybe404 returns 404 in
532 // that shape, so the caller's pgx.ErrNoRows fallthrough is the
533 // right shape regardless of whether the row was missing or just
534 // invisible. Honest 403s (e.g. ActionRepoWrite on archived) are
535 // gated through `loadRepoAndAuthorize`, not this read-only helper.
536 if !policy.Can(ctx, policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoRead, repoRef).Allow {
537 return reposdb.Repo{}, pgx.ErrNoRows
538 }
539 return row, nil
540 }
541
542 // friendlyCreateError maps the typed errors from internal/repos to the
543 // strings the form surfaces to the user.
544 func friendlyCreateError(err error) string {
545 switch {
546 case errors.Is(err, repos.ErrInvalidName):
547 return "Name must be 1–100 chars: lowercase letters, digits, dots, hyphens, underscores."
548 case errors.Is(err, repos.ErrReservedName):
549 return "That name is reserved."
550 case errors.Is(err, repos.ErrTaken):
551 return "You already own a repository with that name."
552 case errors.Is(err, repos.ErrNoVerifiedEmail):
553 return "Verify a primary email before creating a repository — we use it for the initial commit author."
554 case errors.Is(err, repos.ErrDescriptionTooLong):
555 return "Description is too long (max 350 characters)."
556 case errors.Is(err, repos.ErrUnknownLicense):
557 return "Unknown license selection."
558 case errors.Is(err, repos.ErrUnknownGitignore):
559 return "Unknown .gitignore selection."
560 }
561 if t, ok := isThrottled(err); ok {
562 return "You're creating repositories too quickly. Try again in " + t + "."
563 }
564 return "Could not create the repository. Try again."
565 }
566
567 // isThrottled extracts the user-friendly retry-after string from the
568 // throttle package's typed error, if that's what we caught.
569 func isThrottled(err error) (string, bool) {
570 var t *throttle.ErrThrottled
571 if errors.As(err, &t) {
572 return t.RetryAfter.String(), true
573 }
574 return "", false
575 }
576