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