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