detached fb3fd37

fix(preview): address CodeRabbit review findings

- Add DefaultPreviewSeparator constant, use in CLI route filtering
- Add PreviewStatusTimeout constant, replace raw "timeout" string
- Log s.Update errors instead of silently discarding
- Use cliWritef for non-interactive spinner fallback output
- Use domain.ErrRouteNotFound/ErrPreviewNotFound sentinels in tests
  and remote client
- Distinguish route-not-found from real errors in TTL cleanup
commit
fb3fd37bb72569e7c7e592e14a31ac568670b092
author
brice · · 2026-03-18 22:21 UTC
committer
brice · · 2026-03-18 22:04 UTC
parent
459b87f
signature
unsigned
8 files · +29 -13
M internal/adapters/in/cli/preview.go +7 -5
@@ -255,7 +255,7 @@ func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName str
255255 }
256256
257257 for _, r := range routes {
258- if strings.Contains(r.Domain, "--") {
258+ if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
259259 continue
260260 }
261261 reg, _, _ := parseImageRef(r.Image)
@@ -320,7 +320,7 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
320320 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321321 }
322322
323- result, err := pollPreviewWithSpinner(ctx, client, name)
323+ result, err := pollPreviewWithSpinner(ctx, out, client, name)
324324 if err != nil {
325325 return err
326326 }
@@ -396,7 +396,7 @@ func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
396396 }
397397 }
398398
399-func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {
399+func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
400400 done := make(chan previewPollResult, 1)
401401 go func() {
402402 result, err := pollPreviewStatus(ctx, client, name)
@@ -404,7 +404,9 @@ func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name str
404404 }()
405405
406406 if !isInteractiveTerminal() {
407- fmt.Printf("Waiting for preview %s...\n", name)
407+ if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
408+ return nil, err
409+ }
408410 r := <-done
409411 return r.preview, r.err
410412 }
@@ -433,7 +435,7 @@ func pollPreviewStatus(ctx context.Context, client *remote.Client, name string)
433435 case <-ctx.Done():
434436 return nil, ctx.Err()
435437 case <-timeout:
436- return &domain.PreviewRoute{Status: "timeout"}, nil
438+ return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
437439 case <-ticker.C:
438440 p, err := client.GetPreview(ctx, name)
439441 if err != nil {
@@ -255,7 +255,7 @@ func resolveRegistryForImage(ctx context.Context, cp ControlPlane, imageName str
255 }255 }
256 256
257 for _, r := range routes {257 for _, r := range routes {
258- if strings.Contains(r.Domain, "--") {258+ if strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
259 continue259 continue
260 }260 }
261 reg, _, _ := parseImageRef(r.Image)261 reg, _, _ := parseImageRef(r.Image)
@@ -320,7 +320,7 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
320 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))320 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
321 }321 }
322 322
323- result, err := pollPreviewWithSpinner(ctx, client, name)323+ result, err := pollPreviewWithSpinner(ctx, out, client, name)
324 if err != nil {324 if err != nil {
325 return err325 return err
326 }326 }
@@ -396,7 +396,7 @@ func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd {
396 }396 }
397 }397 }
398 398
399-func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) {399+func pollPreviewWithSpinner(ctx context.Context, out io.Writer, client *remote.Client, name string) (*domain.PreviewRoute, error) {
400 done := make(chan previewPollResult, 1)400 done := make(chan previewPollResult, 1)
401 go func() {401 go func() {
402 result, err := pollPreviewStatus(ctx, client, name)402 result, err := pollPreviewStatus(ctx, client, name)
@@ -404,7 +404,9 @@ func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name str
404 }()404 }()
405 405
406 if !isInteractiveTerminal() {406 if !isInteractiveTerminal() {
407- fmt.Printf("Waiting for preview %s...\n", name)407+ if err := cliWritef(out, "Waiting for preview %s...\n", name); err != nil {
408+ return nil, err
409+ }
408 r := <-done410 r := <-done
409 return r.preview, r.err411 return r.preview, r.err
410 }412 }
@@ -433,7 +435,7 @@ func pollPreviewStatus(ctx context.Context, client *remote.Client, name string)
433 case <-ctx.Done():435 case <-ctx.Done():
434 return nil, ctx.Err()436 return nil, ctx.Err()
435 case <-timeout:437 case <-timeout:
436- return &domain.PreviewRoute{Status: "timeout"}, nil438+ return &domain.PreviewRoute{Status: domain.PreviewStatusTimeout}, nil
437 case <-ticker.C:439 case <-ticker.C:
438 p, err := client.GetPreview(ctx, name)440 p, err := client.GetPreview(ctx, name)
439 if err != nil {441 if err != nil {
M internal/adapters/in/cli/push.go +1 -1
@@ -280,7 +280,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
280280 // pollute the selection when pushing the base image.
281281 filtered := routes[:0]
282282 for _, r := range routes {
283- if !strings.Contains(r.Domain, "--") {
283+ if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284284 filtered = append(filtered, r)
285285 }
286286 }
@@ -280,7 +280,7 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
280 // pollute the selection when pushing the base image.280 // pollute the selection when pushing the base image.
281 filtered := routes[:0]281 filtered := routes[:0]
282 for _, r := range routes {282 for _, r := range routes {
283- if !strings.Contains(r.Domain, "--") {283+ if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284 filtered = append(filtered, r)284 filtered = append(filtered, r)
285 }285 }
286 }286 }
M internal/adapters/in/cli/remote/preview.go +2 -2
@@ -59,12 +59,12 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err
5959 func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {
6060 previews, err := c.ListPreviews(ctx)
6161 if err != nil {
62- return nil, err
62+ return nil, fmt.Errorf("list previews: %w", err)
6363 }
6464 for _, p := range previews {
6565 if p.Name == name {
6666 return &p, nil
6767 }
6868 }
69- return nil, fmt.Errorf("preview %q not found", name)
69+ return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
7070 }
@@ -59,12 +59,12 @@ func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) err
59 func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {59 func (c *Client) GetPreview(ctx context.Context, name string) (*domain.PreviewRoute, error) {
60 previews, err := c.ListPreviews(ctx)60 previews, err := c.ListPreviews(ctx)
61 if err != nil {61 if err != nil {
62- return nil, err62+ return nil, fmt.Errorf("list previews: %w", err)
63 }63 }
64 for _, p := range previews {64 for _, p := range previews {
65 if p.Name == name {65 if p.Name == name {
66 return &p, nil66 return &p, nil
67 }67 }
68 }68 }
69- return nil, fmt.Errorf("preview %q not found", name)69+ return nil, fmt.Errorf("preview %q: %w", name, domain.ErrPreviewNotFound)
70 }70 }
M internal/app/run.go +5 -1
@@ -599,7 +599,11 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
599599 }
600600 // Remove route from config so proxy stops routing to this domain
601601 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
602- log.Debug().Err(err).Str("domain", p.Domain).Msg("preview route already removed from config")
602+ if errors.Is(err, domain.ErrRouteNotFound) {
603+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
604+ } else {
605+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
606+ }
603607 }
604608 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
605609 })
@@ -599,7 +599,11 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
599 }599 }
600 // Remove route from config so proxy stops routing to this domain600 // Remove route from config so proxy stops routing to this domain
601 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {601 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
602- log.Debug().Err(err).Str("domain", p.Domain).Msg("preview route already removed from config")602+ if errors.Is(err, domain.ErrRouteNotFound) {
603+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
604+ } else {
605+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
606+ }
603 }607 }
604 svc.proxySvc.InvalidateTarget(ctx, p.Domain)608 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
605 })609 })
M internal/domain/preview.go +5 -0
@@ -9,6 +9,11 @@ const (
99 PreviewStatusDeploying PreviewStatus = "deploying"
1010 PreviewStatusRunning PreviewStatus = "running"
1111 PreviewStatusFailed PreviewStatus = "failed"
12+ PreviewStatusTimeout PreviewStatus = "timeout"
13+
14+ // DefaultPreviewSeparator is the subdomain separator used to construct
15+ // preview domain names (e.g. "myapp--feat.example.com").
16+ DefaultPreviewSeparator = "--"
1217 )
1318
1419 // PreviewRoute represents an ephemeral preview environment.
@@ -9,6 +9,11 @@ const (
9 PreviewStatusDeploying PreviewStatus = "deploying"9 PreviewStatusDeploying PreviewStatus = "deploying"
10 PreviewStatusRunning PreviewStatus = "running"10 PreviewStatusRunning PreviewStatus = "running"
11 PreviewStatusFailed PreviewStatus = "failed"11 PreviewStatusFailed PreviewStatus = "failed"
12+ PreviewStatusTimeout PreviewStatus = "timeout"
13+
14+ // DefaultPreviewSeparator is the subdomain separator used to construct
15+ // preview domain names (e.g. "myapp--feat.example.com").
16+ DefaultPreviewSeparator = "--"
12 )17 )
13 18
14 // PreviewRoute represents an ephemeral preview environment.19 // PreviewRoute represents an ephemeral preview environment.
M internal/usecase/auto/preview/integration_test.go +1 -1
@@ -44,7 +44,7 @@ func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Rout
4444 if f.getRouteFn != nil {
4545 return f.getRouteFn(ctx, d)
4646 }
47- return nil, fmt.Errorf("not found")
47+ return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound)
4848 }
4949
5050 func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) {
@@ -44,7 +44,7 @@ func (f *fakeRouteManager) GetRoute(ctx context.Context, d string) (*domain.Rout
44 if f.getRouteFn != nil {44 if f.getRouteFn != nil {
45 return f.getRouteFn(ctx, d)45 return f.getRouteFn(ctx, d)
46 }46 }
47- return nil, fmt.Errorf("not found")47+ return nil, fmt.Errorf("route %q: %w", d, domain.ErrRouteNotFound)
48 }48 }
49 49
50 func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) {50 func (f *fakeRouteManager) GetVolumeConfig() (bool, string, bool) {
M internal/usecase/auto/preview/service.go +6 -2
@@ -382,7 +382,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
382382 HTTPS: req.HTTPS,
383383 }); err != nil {
384384 preview.Status = domain.PreviewStatusFailed
385- _ = s.Update(ctx, preview)
385+ if updateErr := s.Update(ctx, preview); updateErr != nil {
386+ log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
387+ }
386388 return fmt.Errorf("register preview route %q: %w", req.Domain, err)
387389 }
388390 }
@@ -407,7 +409,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
407409 })
408410 if err != nil {
409411 preview.Status = domain.PreviewStatusFailed
410- _ = s.Update(ctx, preview)
412+ if updateErr := s.Update(ctx, preview); updateErr != nil {
413+ log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
414+ }
411415 return fmt.Errorf("deploy preview %q: %w", req.Name, err)
412416 }
413417 preview.Containers = []string{container.Name}
@@ -382,7 +382,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
382 HTTPS: req.HTTPS,382 HTTPS: req.HTTPS,
383 }); err != nil {383 }); err != nil {
384 preview.Status = domain.PreviewStatusFailed384 preview.Status = domain.PreviewStatusFailed
385- _ = s.Update(ctx, preview)385+ if updateErr := s.Update(ctx, preview); updateErr != nil {
386+ log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
387+ }
386 return fmt.Errorf("register preview route %q: %w", req.Domain, err)388 return fmt.Errorf("register preview route %q: %w", req.Domain, err)
387 }389 }
388 }390 }
@@ -407,7 +409,9 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
407 })409 })
408 if err != nil {410 if err != nil {
409 preview.Status = domain.PreviewStatusFailed411 preview.Status = domain.PreviewStatusFailed
410- _ = s.Update(ctx, preview)412+ if updateErr := s.Update(ctx, preview); updateErr != nil {
413+ log.Warn().Err(updateErr).Str("preview", req.Name).Msg("failed to update preview status")
414+ }
411 return fmt.Errorf("deploy preview %q: %w", req.Name, err)415 return fmt.Errorf("deploy preview %q: %w", req.Name, err)
412 }416 }
413 preview.Containers = []string{container.Name}417 preview.Containers = []string{container.Name}
M internal/usecase/auto/preview/service_test.go +2 -1
@@ -89,7 +89,8 @@ func TestPreviewService_Extend(t *testing.T) {
8989 err := svc.Extend(t.Context(), "feat", 24*time.Hour)
9090 require.NoError(t, err)
9191
92- p, _ := svc.Get(t.Context(), "feat")
92+ p, err := svc.Get(t.Context(), "feat")
93+ require.NoError(t, err)
9394 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))
9495 }
9596
@@ -89,7 +89,8 @@ func TestPreviewService_Extend(t *testing.T) {
89 err := svc.Extend(t.Context(), "feat", 24*time.Hour)89 err := svc.Extend(t.Context(), "feat", 24*time.Hour)
90 require.NoError(t, err)90 require.NoError(t, err)
91 91
92- p, _ := svc.Get(t.Context(), "feat")92+ p, err := svc.Get(t.Context(), "feat")
93+ require.NoError(t, err)
93 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))94 assert.True(t, p.ExpiresAt.After(now.Add(23*time.Hour)))
94 }95 }
95 96

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close